phase 2 i18n clean

This commit is contained in:
ValueOn AG 2026-04-10 12:33:27 +02:00
parent 259fd25d9b
commit be9e47caad
111 changed files with 4819 additions and 4371 deletions

19
app.py
View file

@ -317,6 +317,15 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.error(f"Feature catalog registration failed: {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) # Pre-warm service center modules (avoids first-request import latency)
try: try:
from modules.serviceCenter import preWarm from modules.serviceCenter import preWarm
@ -481,6 +490,16 @@ from modules.auth import (
ProactiveTokenRefreshMiddleware, 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) app.add_middleware(CSRFMiddleware)
# Token refresh middleware (silent refresh for expired OAuth tokens) # Token refresh middleware (silent refresh for expired OAuth tokens)

View file

@ -20,7 +20,7 @@ from enum import Enum
import uuid import uuid
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
class AuditCategory(str, Enum): class AuditCategory(str, Enum):
@ -82,6 +82,7 @@ class AuditAction(str, Enum):
CONFIG_CHANGE = "config_change" CONFIG_CHANGE = "config_change"
@i18nModel("Audit-Log-Eintrag")
class AuditLogEntry(BaseModel): class AuditLogEntry(BaseModel):
""" """
Audit log entry for database storage. Audit log entry for database storage.
@ -92,117 +93,94 @@ class AuditLogEntry(BaseModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique identifier for the audit entry", 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
timestamp: float = Field( timestamp: float = Field(
default_factory=getUtcTimestamp, default_factory=getUtcTimestamp,
description="UTC timestamp when the event occurred", 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 # Actor identification
userId: str = Field( userId: str = Field(
description="ID of the user who performed the action (or 'system' for system events)", 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( username: Optional[str] = Field(
default=None, default=None,
description="Username at the time of the event (for historical reference)", 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 # Context
mandateId: Optional[str] = Field( mandateId: Optional[str] = Field(
default=None, default=None,
description="Mandate context (if applicable)", 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( featureInstanceId: Optional[str] = Field(
default=None, default=None,
description="Feature instance context (if applicable)", 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 # Event classification
category: str = Field( category: str = Field(
description="Event category (access, key, data, security, gdpr, permission, system)", 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( action: str = Field(
description="Specific action performed", 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 # Event details
resourceType: Optional[str] = Field( resourceType: Optional[str] = Field(
default=None, default=None,
description="Type of resource affected (e.g., 'User', 'ChatWorkflow', 'TrusteeContract')", 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( resourceId: Optional[str] = Field(
default=None, default=None,
description="ID of the affected resource", 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( details: Optional[str] = Field(
default=None, default=None,
description="Additional details about the event", 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 # Request metadata
ipAddress: Optional[str] = Field( ipAddress: Optional[str] = Field(
default=None, default=None,
description="IP address of the client", 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( userAgent: Optional[str] = Field(
default=None, default=None,
description="User agent string from the request", 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 # Outcome
success: bool = Field( success: bool = Field(
default=True, default=True,
description="Whether the action was successful", 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( errorMessage: Optional[str] = Field(
default=None, default=None,
description="Error message if the action failed", 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"},
},
)

View file

@ -6,14 +6,17 @@ from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
@i18nModel("Basisdatensatz")
class PowerOnModel(BaseModel): class PowerOnModel(BaseModel):
"""Basis-Datenmodell mit System-Audit-Feldern fuer alle DB-Tabellen."""
sysCreatedAt: Optional[float] = Field( sysCreatedAt: Optional[float] = Field(
default=None, default=None,
description="Record creation timestamp (UTC, set by system)", description="Record creation timestamp (UTC, set by system)",
json_schema_extra={ json_schema_extra={
"label": "Erstellt am",
"frontend_type": "timestamp", "frontend_type": "timestamp",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
@ -25,6 +28,7 @@ class PowerOnModel(BaseModel):
default=None, default=None,
description="User ID who created this record (set by system)", description="User ID who created this record (set by system)",
json_schema_extra={ json_schema_extra={
"label": "Erstellt von",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
@ -36,6 +40,7 @@ class PowerOnModel(BaseModel):
default=None, default=None,
description="Record last modification timestamp (UTC, set by system)", description="Record last modification timestamp (UTC, set by system)",
json_schema_extra={ json_schema_extra={
"label": "Geaendert am",
"frontend_type": "timestamp", "frontend_type": "timestamp",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
@ -47,6 +52,7 @@ class PowerOnModel(BaseModel):
default=None, default=None,
description="User ID who last modified this record (set by system)", description="User ID who last modified this record (set by system)",
json_schema_extra={ json_schema_extra={
"label": "Geaendert von",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
@ -54,15 +60,3 @@ class PowerOnModel(BaseModel):
"system": True, "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"},
},
)

View file

@ -7,7 +7,7 @@ from enum import Enum
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
import uuid import uuid
# End-customer price for storage above plan-included volume (CHF per GB per month). # End-customer price for storage above plan-included volume (CHF per GB per month).
@ -38,203 +38,170 @@ class PeriodTypeEnum(str, Enum):
YEAR = "YEAR" YEAR = "YEAR"
@i18nModel("Abrechnungskonto")
class BillingAccount(PowerOnModel): class BillingAccount(PowerOnModel):
"""Billing account for mandate or user-mandate combination.""" """Billing account for mandate or user-mandate combination."""
id: str = Field( 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") 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)") userId: Optional[str] = Field(
balance: float = Field(default=0.0, description="Current balance in CHF") None,
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF") description="Foreign key to User (None = mandate pool account, set = user audit account)",
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp") json_schema_extra={"label": "Benutzer-ID"},
enabled: bool = Field(default=True, description="Account is active") )
balance: float = Field(default=0.0, description="Current balance in CHF", json_schema_extra={"label": "Guthaben (CHF)"})
warningThreshold: float = Field(
registerModelLabels( default=0.0,
"BillingAccount", description="Warning threshold in CHF",
{"en": "Billing Account", "de": "Abrechnungskonto"}, json_schema_extra={"label": "Warnschwelle (CHF)"},
{ )
"id": {"en": "ID", "de": "ID"}, lastWarningAt: Optional[datetime] = Field(
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, None,
"userId": {"en": "User ID", "de": "Benutzer-ID"}, description="Last warning sent timestamp",
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"}, json_schema_extra={"label": "Letzte Warnung"},
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"}, )
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"}, enabled: bool = Field(default=True, description="Account is active", json_schema_extra={"label": "Aktiv"})
"enabled": {"en": "Enabled", "de": "Aktiv"},
},
)
@i18nModel("Transaktion")
class BillingTransaction(PowerOnModel): class BillingTransaction(PowerOnModel):
"""Single billing transaction (credit, debit, adjustment).""" """Single billing transaction (credit, debit, adjustment)."""
id: str = Field( 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") accountId: str = Field(..., description="Foreign key to BillingAccount", json_schema_extra={"label": "Konto-ID"})
transactionType: TransactionTypeEnum = Field(..., description="Transaction type") transactionType: TransactionTypeEnum = Field(..., description="Transaction type", json_schema_extra={"label": "Typ"})
amount: float = Field(..., description="Amount in CHF (always positive)") amount: float = Field(..., description="Amount in CHF (always positive)", json_schema_extra={"label": "Betrag (CHF)"})
description: str = Field(..., description="Transaction description") description: str = Field(..., description="Transaction description", json_schema_extra={"label": "Beschreibung"})
# Reference to source # Reference to source
referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type") referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type", json_schema_extra={"label": "Referenztyp"})
referenceId: Optional[str] = Field(None, description="Reference ID") referenceId: Optional[str] = Field(None, description="Reference ID", json_schema_extra={"label": "Referenz-ID"})
# Context for workflow transactions # Context for workflow transactions
workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)") 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") 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)") 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.)") 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)") 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") 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) # AI call metadata (for per-call analytics)
processingTime: Optional[float] = Field(None, description="Processing time in seconds") 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") 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") 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") errorCount: Optional[int] = Field(None, description="Number of errors in this call", json_schema_extra={"label": "Fehleranzahl"})
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"},
},
)
@i18nModel("Abrechnungseinstellungen")
class BillingSettings(BaseModel): class BillingSettings(BaseModel):
"""Billing settings per mandate. Only PREPAY_MANDATE model.""" """Billing settings per mandate. Only PREPAY_MANDATE model."""
id: str = Field( 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 # 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 # Auto-Recharge for AI budget
autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low") 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)") rechargeAmountCHF: float = Field(
rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month") default=10.0,
rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month") description="Amount per auto-recharge (CHF, prepaid via Stripe)",
monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset") 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 # Notifications
notifyEmails: List[str] = Field( notifyEmails: List[str] = Field(
default_factory=list, default_factory=list,
description="Email addresses for billing alerts (pool exhausted, warnings, etc.)", 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) # Storage overage (high-watermark within subscription period; resets on new period)
storageHighWatermarkMB: float = Field( 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( 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( storageBilledUpToMB: float = Field(
default=0.0, default=0.0,
description="Overage MB already debited this period (above plan-included volume)", 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): class StripeWebhookEvent(BaseModel):
"""Stores processed Stripe webhook event IDs for idempotency.""" """Stores processed Stripe webhook event IDs for idempotency."""
id: str = Field( 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)") event_id: str = Field(..., description="Stripe event ID (evt_xxx)")
processed_at: datetime = Field( processed_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), default_factory=lambda: datetime.now(timezone.utc),
description="When the event was processed" description="When the event was processed",
) )
@i18nModel("Nutzungsstatistik")
class UsageStatistics(BaseModel): class UsageStatistics(BaseModel):
"""Aggregated usage statistics for quick retrieval.""" """Aggregated usage statistics for quick retrieval."""
id: str = Field( 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") accountId: str = Field(..., description="Foreign key to BillingAccount", json_schema_extra={"label": "Konto-ID"})
periodType: PeriodTypeEnum = Field(..., description="Period type") periodType: PeriodTypeEnum = Field(..., description="Period type", json_schema_extra={"label": "Periodentyp"})
periodStart: date = Field(..., description="Period start date") periodStart: date = Field(..., description="Period start date", json_schema_extra={"label": "Periodenbeginn"})
# Aggregated values # Aggregated values
totalCostCHF: float = Field(default=0.0, description="Total cost in CHF") 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") transactionCount: int = Field(default=0, description="Number of transactions", json_schema_extra={"label": "Anzahl Transaktionen"})
# Breakdown by provider # Breakdown by provider
costByProvider: Dict[str, float] = Field( costByProvider: Dict[str, float] = Field(
default_factory=dict, default_factory=dict,
description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})" description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})",
json_schema_extra={"label": "Kosten nach Anbieter"},
) )
# Breakdown by feature # Breakdown by feature
costByFeature: Dict[str, float] = Field( costByFeature: Dict[str, float] = Field(
default_factory=dict, 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 # Response Models for API
# ============================================================================ # ============================================================================
@ -277,4 +244,3 @@ class BillingCheckResult(BaseModel):
subscriptionUiPath: Optional[str] = None subscriptionUiPath: Optional[str] = None
userAction: Optional[str] = None userAction: Optional[str] = None

File diff suppressed because it is too large Load diff

View file

@ -9,66 +9,81 @@ Google Drive folder, FTP directory, etc.) for agent-accessible data containers.
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
import uuid import uuid
@i18nModel("Datenquelle")
class DataSource(PowerOnModel): class DataSource(PowerOnModel):
"""Configured external data source linked to a UserConnection.""" """Konfigurierte externe Datenquelle verknuepft mit einer UserConnection."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(
connectionId: str = Field(description="FK to UserConnection") default_factory=lambda: str(uuid.uuid4()),
sourceType: str = Field( description="Primary key",
description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)" 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( displayPath: Optional[str] = Field(
default=None, default=None,
description="Human-readable full path for UI (connection-relative, slash-separated)", 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( scope: str = Field(
default="personal", default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global", 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": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}}, {"value": "global", "label": {"en": "Global", "de": "Global"}},
]} ]},
) )
neutralize: bool = Field( neutralize: bool = Field(
default=False, default=False,
description="Whether this data source should be neutralized before AI processing", 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): class ExternalEntry(BaseModel):
"""An item (file or folder) from an external data source.""" """An item (file or folder) from an external data source."""
name: str = Field(description="Item name") name: str = Field(description="Item name")

View file

@ -6,7 +6,7 @@ Document reference models for typed document references in workflows.
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
class DocumentReference(BaseModel): class DocumentReference(BaseModel):
@ -14,11 +14,19 @@ class DocumentReference(BaseModel):
pass pass
@i18nModel("Dokumentlisten-Referenz")
class DocumentListReference(DocumentReference): class DocumentListReference(DocumentReference):
"""Reference to a document list via message label""" """Reference to a document list via message label"""
messageId: Optional[str] = Field(None, description="Optional message ID for cross-round references") messageId: Optional[str] = Field(
label: str = Field(description="Document list label") 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: def to_string(self) -> str:
"""Convert to string format: docList:messageId:label or docList:label""" """Convert to string format: docList:messageId:label or docList:label"""
if self.messageId: if self.messageId:
@ -26,11 +34,19 @@ class DocumentListReference(DocumentReference):
return f"docList:{self.label}" return f"docList:{self.label}"
@i18nModel("Dokumentelement-Referenz")
class DocumentItemReference(DocumentReference): class DocumentItemReference(DocumentReference):
"""Reference to a specific document item""" """Reference to a specific document item"""
documentId: str = Field(description="Document ID") documentId: str = Field(
fileName: Optional[str] = Field(None, description="Optional file name") 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: def to_string(self) -> str:
"""Convert to string format: docItem:documentId:fileName or docItem:documentId""" """Convert to string format: docItem:documentId:fileName or docItem:documentId"""
if self.fileName: if self.fileName:
@ -38,21 +54,23 @@ class DocumentItemReference(DocumentReference):
return f"docItem:{self.documentId}" return f"docItem:{self.documentId}"
@i18nModel("Dokumentreferenz-Liste")
class DocumentReferenceList(BaseModel): class DocumentReferenceList(BaseModel):
"""List of document references with conversion methods""" """List of document references with conversion methods"""
references: List[DocumentReference] = Field( references: List[DocumentReference] = Field(
default_factory=list, 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]: def to_string_list(self) -> List[str]:
"""Convert all references to string list""" """Convert all references to string list"""
return [ref.to_string() for ref in self.references] return [ref.to_string() for ref in self.references]
@classmethod @classmethod
def from_string_list(cls, stringList: List[str]) -> "DocumentReferenceList": def from_string_list(cls, stringList: List[str]) -> "DocumentReferenceList":
"""Parse string list to typed references """Parse string list to typed references
Supports formats: Supports formats:
- docList:label - docList:label
- docList:messageId:label - docList:messageId:label
@ -60,13 +78,13 @@ class DocumentReferenceList(BaseModel):
- docItem:documentId:fileName - docItem:documentId:fileName
""" """
references = [] references = []
for refStr in stringList: for refStr in stringList:
if not refStr or not isinstance(refStr, str): if not refStr or not isinstance(refStr, str):
continue continue
refStr = refStr.strip() refStr = refStr.strip()
# Parse docList: references # Parse docList: references
if refStr.startswith("docList:"): if refStr.startswith("docList:"):
parts = refStr[8:].split(":", 1) # Remove "docList:" prefix parts = refStr[8:].split(":", 1) # Remove "docList:" prefix
@ -77,7 +95,7 @@ class DocumentReferenceList(BaseModel):
elif len(parts) == 1 and parts[0]: elif len(parts) == 1 and parts[0]:
# docList:label # docList:label
references.append(DocumentListReference(label=parts[0])) references.append(DocumentListReference(label=parts[0]))
# Parse docItem: references # Parse docItem: references
elif refStr.startswith("docItem:"): elif refStr.startswith("docItem:"):
parts = refStr[8:].split(":", 1) # Remove "docItem:" prefix parts = refStr[8:].split(":", 1) # Remove "docItem:" prefix
@ -88,33 +106,12 @@ class DocumentReferenceList(BaseModel):
elif len(parts) == 1 and parts[0]: elif len(parts) == 1 and parts[0]:
# docItem:documentId # docItem:documentId
references.append(DocumentItemReference(documentId=parts[0])) references.append(DocumentItemReference(documentId=parts[0]))
# Unknown format - skip or log warning # Unknown format - skip or log warning
else: else:
# Try to parse as simple string (backward compatibility) # Try to parse as simple string (backward compatibility)
# Assume it's a label if it doesn't match known patterns # Assume it's a label if it doesn't match known patterns
if refStr: if refStr:
references.append(DocumentListReference(label=refStr)) references.append(DocumentListReference(label=refStr))
return cls(references=references) 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"},
},
)

View file

@ -9,54 +9,69 @@ so the agent can query structured feature data (e.g. TrusteePosition rows).
from typing import Dict, Optional from typing import Dict, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
import uuid import uuid
@i18nModel("Feature-Datenquelle")
class FeatureDataSource(PowerOnModel): class FeatureDataSource(PowerOnModel):
"""A feature-instance table attached as data source in the AI workspace.""" """Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(
featureInstanceId: str = Field(description="FK to FeatureInstance") default_factory=lambda: str(uuid.uuid4()),
featureCode: str = Field(description="Feature code (e.g. trustee, commcoach)") description="Primary key",
tableName: str = Field(description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)") json_schema_extra={"label": "ID"},
objectKey: str = Field(description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)") )
label: str = Field(description="User-visible label") featureInstanceId: str = Field(
mandateId: str = Field(default="", description="Mandate scope") description="FK to FeatureInstance",
userId: str = Field(default="", description="Owner user ID") json_schema_extra={"label": "Feature-Instanz"},
workspaceInstanceId: str = Field(description="Workspace instance where this source is used") )
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( scope: str = Field(
default="personal", default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global", 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": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}}, {"value": "global", "label": {"en": "Global", "de": "Global"}},
]} ]},
) )
neutralize: bool = Field( neutralize: bool = Field(
default=False, default=False,
description="Whether this data source should be neutralized before AI processing", 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( recordFilter: Optional[Dict[str, str]] = Field(
default=None, default=None,
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}", 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"},
},
)

View file

@ -6,85 +6,56 @@ import uuid
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel 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.datamodelUtils import TextMultilingual
@i18nModel("Feature")
class Feature(PowerOnModel): class Feature(PowerOnModel):
""" """Feature-Definition (global, z.B. 'trustee', 'chatbot'). Verfuegbare Funktionalitaeten der Plattform."""
Feature-Definition (global, z.B. 'trustee', 'chatbot').
Features sind die verfügbaren Funktionalitäten der Plattform.
"""
code: str = Field( code: str = Field(
description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'", 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( label: TextMultilingual = Field(
description="Feature label in multiple languages (I18n)", 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( icon: str = Field(
default="", default="",
description="Icon identifier for the feature", 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( @i18nModel("Feature-Instanz")
"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"},
},
)
class FeatureInstance(PowerOnModel): 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( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the feature instance", 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( featureCode: str = Field(
description="FK Feature.code", description="FK -> Feature.code",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True} json_schema_extra={"label": "Feature", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
) )
mandateId: str = Field( mandateId: str = Field(
description="FK Mandate.id (CASCADE DELETE)", description="FK -> Mandate.id (CASCADE DELETE)",
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}
) )
label: str = Field( label: str = Field(
default="", default="",
description="Instance label, z.B. 'Buchhaltung 2025'", 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( enabled: bool = Field(
default=True, default=True,
description="Whether this feature instance is enabled", 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( config: Optional[Dict[str, Any]] = Field(
default=None, default=None,
description="Instance-specific configuration (JSONB). Structure depends on featureCode.", 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"},
},
)

View file

@ -5,26 +5,34 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
import uuid import uuid
@i18nModel("Dateiordner")
class FileFolder(PowerOnModel): 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}) """Hierarchischer Ordner fuer die Dateiverwaltung."""
name: str = Field(description="Folder name", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) id: str = Field(
parentId: Optional[str] = Field(default=None, description="Parent folder ID (null = root)", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) default_factory=lambda: str(uuid.uuid4()),
mandateId: Optional[str] = Field(default=None, description="Mandate context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) description="Primary key",
featureInstanceId: Optional[str] = Field(default=None, description="Feature instance context", 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},
)
name: str = Field(
registerModelLabels( description="Folder name",
"FileFolder", json_schema_extra={"label": "Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
{"en": "File Folder", "fr": "Dossier de fichiers"}, )
{ parentId: Optional[str] = Field(
"id": {"en": "ID", "fr": "ID"}, default=None,
"name": {"en": "Name", "fr": "Nom"}, description="Parent folder ID (null = root)",
"parentId": {"en": "Parent Folder", "fr": "Dossier parent"}, json_schema_extra={"label": "Uebergeordneter Ordner", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, )
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"}, 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},
)

View file

@ -5,66 +5,110 @@
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
import uuid import uuid
import base64 import base64
@i18nModel("Datei")
class FileItem(PowerOnModel): 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}) """Metadaten einer gespeicherten Datei."""
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}) id: str = Field(
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"}) default_factory=lambda: str(uuid.uuid4()),
fileName: str = Field(description="Name of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) description="Primary key",
mimeType: str = Field(description="MIME type of the file", 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},
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}) mandateId: Optional[str] = Field(
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}) default="",
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="ID of the mandate this file belongs to",
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}) json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "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}) )
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( scope: str = Field(
default="personal", default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global", 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": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}}, {"value": "global", "label": {"en": "Global", "de": "Global"}},
]} ]},
) )
neutralize: bool = Field( neutralize: bool = Field(
default=False, default=False,
description="Whether this file should be neutralized before AI processing", 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): class FilePreview(BaseModel):
content: Union[str, bytes] = Field(description="File content (text or binary)") """Vorschau-Inhalt einer Datei fuer die Anzeige."""
mimeType: str = Field(description="MIME type of the file") content: Union[str, bytes] = Field(
fileName: str = Field(description="Original fileName") description="File content (text or binary)",
isText: bool = Field(description="Whether the content is text (True) or binary (False)") json_schema_extra={"label": "Inhalt"},
encoding: Optional[str] = Field(None, description="Text encoding if content is text") )
size: int = Field(description="Size of the content in bytes") 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]: def toDictWithBase64Encoding(self) -> Dict[str, Any]:
"""Convert to dictionary with base64 encoding for binary content.""" """Convert to dictionary with base64 encoding for binary content."""
@ -72,29 +116,21 @@ class FilePreview(BaseModel):
if isinstance(data.get("content"), bytes): if isinstance(data.get("content"), bytes):
data["content"] = base64.b64encode(data["content"]).decode("utf-8") data["content"] = base64.b64encode(data["content"]).decode("utf-8")
return data 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): class FileData(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") """Rohdaten einer Datei (z.B. Base64)."""
data: str = Field(description="File data content") id: str = Field(
base64Encoded: bool = Field(description="Whether the data is base64 encoded") default_factory=lambda: str(uuid.uuid4()),
registerModelLabels( description="Primary key",
"FileData", json_schema_extra={"label": "ID"},
{"en": "File Data", "fr": "Données de fichier"}, )
{ data: str = Field(
"id": {"en": "ID", "fr": "ID"}, description="File data content",
"data": {"en": "Data", "fr": "Données"}, json_schema_extra={"label": "Daten"},
"base64Encoded": {"en": "Base64 Encoded", "fr": "Encodé en Base64"}, )
}, base64Encoded: bool = Field(
) description="Whether the data is base64 encoded",
json_schema_extra={"label": "Base64-kodiert"},
)

View file

@ -10,9 +10,10 @@ import secrets
from typing import Optional, List from typing import Optional, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
@i18nModel("Einladung")
class Invitation(PowerOnModel): class Invitation(PowerOnModel):
""" """
Einladungs-Token für neue User. Einladungs-Token für neue User.
@ -21,103 +22,76 @@ class Invitation(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the invitation", 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( token: str = Field(
default_factory=lambda: secrets.token_urlsafe(32), default_factory=lambda: secrets.token_urlsafe(32),
description="Secure invitation token", 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( mandateId: str = Field(
description="FK → Mandate.id - Target mandate for the invitation", 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( featureInstanceId: Optional[str] = Field(
default=None, default=None,
description="Optional FK → FeatureInstance.id - Direct access to specific feature", 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( roleIds: List[str] = Field(
default_factory=list, default_factory=list,
description="List of Role IDs to assign to the invited user", 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( targetUsername: Optional[str] = Field(
default=None, default=None,
description="Username of the invited user (must match on acceptance)", 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( email: Optional[str] = Field(
default=None, default=None,
description="Email address to send invitation link (optional)", 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( expiresAt: float = Field(
description="When the invitation expires (UTC timestamp)", 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( usedBy: Optional[str] = Field(
default=None, default=None,
description="User ID of the person who used the invitation", 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( usedAt: Optional[float] = Field(
default=None, default=None,
description="When the invitation was used (UTC timestamp)", 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( revokedAt: Optional[float] = Field(
default=None, default=None,
description="When the invitation was revoked (UTC timestamp)", 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( emailSent: Optional[bool] = Field(
default=False, default=False,
description="Whether the invitation email was successfully sent", 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( maxUses: int = Field(
default=1, default=1,
ge=1, ge=1,
le=100, le=100,
description="Maximum number of times this invitation can be used", 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( currentUses: int = Field(
default=0, default=0,
ge=0, ge=0,
description="Current number of times this invitation has been used", 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"},
},
)

View file

@ -15,173 +15,231 @@ Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector.
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel 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 modules.shared.timeUtils import getUtcTimestamp
import uuid import uuid
@i18nModel("Datei-Inhaltsindex")
class FileContentIndex(PowerOnModel): class FileContentIndex(PowerOnModel):
"""Structural index of a file's content objects. Created without AI. """Struktureller Index der Inhaltsobjekte einer Datei."""
Scope is mirrored from FileItem (poweron_management) at indexing time.""" id: str = Field(
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key (typically = fileId)") default_factory=lambda: str(uuid.uuid4()),
userId: str = Field(description="Owner user ID") description="Primary key (typically = fileId)",
featureInstanceId: str = Field(default="", description="Feature instance scope") json_schema_extra={"label": "ID"},
mandateId: str = Field(default="", description="Mandate scope") )
fileName: str = Field(description="Original file name") userId: str = Field(
mimeType: str = Field(description="MIME type of the file") description="Owner user ID",
containerPath: Optional[str] = Field(default=None, description="Path within a container (e.g. 'archive.zip/folder/report.pdf')") json_schema_extra={"label": "Benutzer-ID"},
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") featureInstanceId: str = Field(
structure: Dict[str, Any] = Field(default_factory=dict, description="Structural overview (pages, sections, hierarchy)") default="",
objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object") description="Feature instance scope",
extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp") json_schema_extra={"label": "Feature-Instanz-ID"},
status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed") )
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( scope: str = Field(
default="personal", default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global", description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"label": "Sichtbarkeit"},
) )
neutralizationStatus: Optional[str] = Field( neutralizationStatus: Optional[str] = Field(
default=None, default=None,
description="Neutralization status: completed, failed, skipped, None = not required", description="Neutralization status: completed, failed, skipped, None = not required",
json_schema_extra={"label": "Neutralisierungsstatus"},
) )
isNeutralized: bool = Field( isNeutralized: bool = Field(
default=False, default=False,
description="True if content was neutralized before indexing", description="True if content was neutralized before indexing",
json_schema_extra={"label": "Neutralisiert"},
) )
registerModelLabels( @i18nModel("Inhalts-Chunk")
"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"},
},
)
class ContentChunk(PowerOnModel): class ContentChunk(PowerOnModel):
"""Persisted content chunk with embedding vector. Reusable across workflows. """Persistierter Inhalts-Chunk mit Embedding-Vektor."""
Scalar content object (or chunk thereof) with pgvector embedding.""" id: str = Field(
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") default_factory=lambda: str(uuid.uuid4()),
contentObjectId: str = Field(description="Reference to the content object within FileContentIndex") description="Primary key",
fileId: str = Field(description="FK to the source file") json_schema_extra={"label": "ID"},
userId: str = Field(description="Owner user ID") )
featureInstanceId: str = Field(default="", description="Feature instance scope") contentObjectId: str = Field(
contentType: str = Field(description="Content type: text, image, videostream, audiostream, other") description="Reference to the content object within FileContentIndex",
data: str = Field(description="Content data (text, base64, URL)") json_schema_extra={"label": "Inhaltsobjekt-ID"},
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)") fileId: str = Field(
chunkMetadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata") 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( embedding: Optional[List[float]] = Field(
default=None, description="pgvector embedding (NOT NULL for text chunks)", default=None,
json_schema_extra={"db_type": "vector(1536)"} description="pgvector embedding (NOT NULL for text chunks)",
json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
) )
registerModelLabels( @i18nModel("Runden-Speicher")
"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"},
},
)
class RoundMemory(PowerOnModel): class RoundMemory(PowerOnModel):
"""Persistent per-round memory for agent tool results, file refs, and decisions. """Persistenter Speicher pro Agenten-Runde."""
id: str = Field(
Stored after each agent round so that RAG can retrieve relevant context default_factory=lambda: str(uuid.uuid4()),
even after the ConversationManager summarises older messages away. description="Primary key",
""" json_schema_extra={"label": "ID"},
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") )
workflowId: str = Field(description="FK to the workflow") workflowId: str = Field(
roundNumber: int = Field(default=0, description="Agent round that produced this memory") description="FK to the workflow",
memoryType: str = Field( json_schema_extra={"label": "Workflow-ID"},
description="Category: file_ref, tool_result, decision, data_source_ref" )
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:<fileId>' 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:<fileId>' or 'plan'")
summary: str = Field(default="", description="Compact summary (max ~2000 chars)")
fullData: Optional[str] = Field( fullData: Optional[str] = Field(
default=None, default=None,
description="Full tool output when small enough (max ~8000 chars)", 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( embedding: Optional[List[float]] = Field(
default=None, default=None,
description="Embedding of summary for semantic retrieval", description="Embedding of summary for semantic retrieval",
json_schema_extra={"db_type": "vector(1536)"}, json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
) )
registerModelLabels( @i18nModel("Workflow-Speicher")
"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"},
},
)
class WorkflowMemory(PowerOnModel): class WorkflowMemory(PowerOnModel):
"""Workflow-scoped key-value cache for entities and facts. """Workflow-spezifischer Key-Value-Cache fuer Entitaeten und Fakten."""
Extracted during agent rounds, persisted for cross-round and cross-workflow reuse.""" id: str = Field(
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") default_factory=lambda: str(uuid.uuid4()),
workflowId: str = Field(description="FK to the workflow") description="Primary key",
userId: str = Field(description="Owner user ID") json_schema_extra={"label": "ID"},
featureInstanceId: str = Field(default="", description="Feature instance scope") )
key: str = Field(description="Key identifier (e.g. 'entity:companyName')") workflowId: str = Field(
value: str = Field(description="Extracted value") description="FK to the workflow",
source: str = Field(default="extraction", description="Origin: extraction, tool, conversation, summary") json_schema_extra={"label": "Workflow-ID"},
embedding: Optional[List[float]] = Field( )
default=None, description="Optional embedding for semantic lookup", userId: str = Field(
json_schema_extra={"db_type": "vector(1536)"} 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"},
},
)

View file

@ -10,9 +10,10 @@ Rollen werden über Junction Tables verknüpft für saubere CASCADE DELETE.
import uuid import uuid
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
@i18nModel("Benutzer-Mandant")
class UserMandate(PowerOnModel): class UserMandate(PowerOnModel):
""" """
User-Mitgliedschaft in einem Mandanten. User-Mitgliedschaft in einem Mandanten.
@ -21,36 +22,24 @@ class UserMandate(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user-mandate membership", 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( userId: str = Field(
description="FK → User.id (CASCADE DELETE)", 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( mandateId: str = Field(
description="FK → Mandate.id (CASCADE DELETE)", 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( enabled: bool = Field(
default=True, default=True,
description="Whether this membership is enabled", 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): class FeatureAccess(PowerOnModel):
""" """
User-Zugriff auf eine Feature-Instanz. User-Zugriff auf eine Feature-Instanz.
@ -59,36 +48,24 @@ class FeatureAccess(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the feature access", 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( userId: str = Field(
description="FK → User.id (CASCADE DELETE)", 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( featureInstanceId: str = Field(
description="FK → FeatureInstance.id (CASCADE DELETE)", 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( enabled: bool = Field(
default=True, default=True,
description="Whether this feature access is enabled", 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): class UserMandateRole(PowerOnModel):
""" """
Junction Table: UserMandate zu Role. Junction Table: UserMandate zu Role.
@ -97,29 +74,19 @@ class UserMandateRole(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record", 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( userMandateId: str = Field(
description="FK → UserMandate.id (CASCADE DELETE)", 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( roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)", 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( @i18nModel("Feature-Zugang-Rolle")
"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"},
},
)
class FeatureAccessRole(PowerOnModel): class FeatureAccessRole(PowerOnModel):
""" """
Junction Table: FeatureAccess zu Role. Junction Table: FeatureAccess zu Role.
@ -128,24 +95,13 @@ class FeatureAccessRole(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record", 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( featureAccessId: str = Field(
description="FK → FeatureAccess.id (CASCADE DELETE)", 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( roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)", 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"},
},
)

View file

@ -7,7 +7,7 @@ from typing import Optional
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
class MessagingChannel(str, Enum): class MessagingChannel(str, Enum):
@ -26,86 +26,137 @@ class DeliveryStatus(str, Enum):
FAILED = "failed" FAILED = "failed"
@i18nModel("Messaging-Abonnement")
class MessagingSubscription(PowerOnModel): class MessagingSubscription(PowerOnModel):
"""Data model for messaging subscriptions""" """Data model for messaging subscriptions"""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the subscription", 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( subscriptionId: str = Field(
description="Unique subscription identifier (e.g., 'system_errors', 'audit_login')", 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( subscriptionLabel: str = Field(
description="Display name of the subscription", 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( mandateId: str = Field(
description="ID of the mandate this subscription belongs to", 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( featureInstanceId: str = Field(
description="ID of the feature instance this subscription belongs to", 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( description: Optional[str] = Field(
default=None, default=None,
description="Description of the subscription", 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( isSystemSubscription: bool = Field(
default=False, default=False,
description="Whether this is a system subscription (only admin can create)", 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( enabled: bool = Field(
default=True, default=True,
description="Whether the subscription is enabled", 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) model_config = ConfigDict(use_enum_values=True)
registerModelLabels( @i18nModel("Messaging-Registrierung")
"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é"},
},
)
class MessagingSubscriptionRegistration(BaseModel): class MessagingSubscriptionRegistration(BaseModel):
"""Data model for user registrations to messaging subscriptions""" """Data model for user registrations to messaging subscriptions"""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the registration", 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( mandateId: str = Field(
description="ID of the mandate this registration belongs to", 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( featureInstanceId: str = Field(
description="ID of the feature instance this registration belongs to", 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( subscriptionId: str = Field(
description="ID of the subscription this registration belongs to", 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( userId: str = Field(
description="ID of the user registered to this subscription", 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( channel: MessagingChannel = Field(
description="Channel type for this registration", description="Channel type for this registration",
@ -117,62 +168,83 @@ class MessagingSubscriptionRegistration(BaseModel):
{"value": "email", "label": {"en": "Email", "fr": "Email"}}, {"value": "email", "label": {"en": "Email", "fr": "Email"}},
{"value": "sms", "label": {"en": "SMS", "fr": "SMS"}}, {"value": "sms", "label": {"en": "SMS", "fr": "SMS"}},
{"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}}, {"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( channelConfig: str = Field(
default="", default="",
description="Channel-specific configuration (e.g., email address, phone number, Teams user ID)", 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( enabled: bool = Field(
default=True, default=True,
description="Whether this registration is enabled", 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) model_config = ConfigDict(use_enum_values=True)
registerModelLabels( @i18nModel("Messaging-Zustellung")
"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é"},
},
)
class MessagingDelivery(BaseModel): class MessagingDelivery(BaseModel):
"""Data model for individual message deliveries""" """Data model for individual message deliveries"""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the delivery", 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( mandateId: str = Field(
description="ID of the mandate this delivery belongs to", 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( featureInstanceId: str = Field(
description="ID of the feature instance this delivery belongs to", 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( subscriptionId: str = Field(
description="ID of the subscription this delivery belongs to", 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( userId: str = Field(
description="ID of the user receiving this delivery", 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( channel: MessagingChannel = Field(
description="Channel used for this delivery", description="Channel used for this delivery",
@ -184,9 +256,10 @@ class MessagingDelivery(BaseModel):
{"value": "email", "label": {"en": "Email", "fr": "Email"}}, {"value": "email", "label": {"en": "Email", "fr": "Email"}},
{"value": "sms", "label": {"en": "SMS", "fr": "SMS"}}, {"value": "sms", "label": {"en": "SMS", "fr": "SMS"}},
{"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}}, {"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( status: DeliveryStatus = Field(
default=DeliveryStatus.PENDING, default=DeliveryStatus.PENDING,
@ -198,112 +271,113 @@ class MessagingDelivery(BaseModel):
"frontend_options": [ "frontend_options": [
{"value": "pending", "label": {"en": "Pending", "fr": "En attente"}}, {"value": "pending", "label": {"en": "Pending", "fr": "En attente"}},
{"value": "sent", "label": {"en": "Sent", "fr": "Envoyé"}}, {"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( errorMessage: Optional[str] = Field(
default=None, default=None,
description="Error message if delivery failed", 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( sentAt: Optional[float] = Field(
default=None, default=None,
description="When the delivery was sent (UTC timestamp in seconds)", 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) model_config = ConfigDict(use_enum_values=True)
registerModelLabels( @i18nModel("Messaging-Ereignisparameter")
"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"},
},
)
class MessagingEventParameters(BaseModel): class MessagingEventParameters(BaseModel):
"""Data model for event parameters passed to subscription functions""" """Data model for event parameters passed to subscription functions"""
triggerData: dict = Field( triggerData: dict = Field(
default_factory=dict, default_factory=dict,
description="Event data from trigger as dictionary/JSON", 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( @i18nModel("Messaging-Sendeergebnis")
"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"},
},
)
class MessagingSendResult(BaseModel): class MessagingSendResult(BaseModel):
"""Data model for sendMessage result""" """Data model for sendMessage result"""
success: bool = Field( success: bool = Field(
description="Whether the message was sent successfully", 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( deliveryId: Optional[str] = Field(
default=None, default=None,
description="ID of the created MessagingDelivery record", 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( errorMessage: Optional[str] = Field(
default=None, default=None,
description="Error message if sending failed", 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): class MessagingSubscriptionExecutionResult(BaseModel):
"""Data model for subscription function execution result""" """Data model for subscription function execution result"""
success: bool = Field( success: bool = Field(
description="Whether the subscription execution was successful", 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( messagesSent: int = Field(
default=0, default=0,
description="Number of messages sent", 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( errorMessage: Optional[str] = Field(
default=None, default=None,
description="Error message if execution failed", 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",
},
) )

View file

@ -10,7 +10,7 @@ from typing import Optional, List
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
class NotificationType(str, Enum): class NotificationType(str, Enum):
@ -29,20 +29,25 @@ class NotificationStatus(str, Enum):
DISMISSED = "dismissed" # Verworfen/Geschlossen DISMISSED = "dismissed" # Verworfen/Geschlossen
@i18nModel("Benachrichtigungs-Aktion")
class NotificationAction(BaseModel): class NotificationAction(BaseModel):
"""Possible action for a notification""" """Possible action for a notification"""
actionId: str = Field( 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( 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( style: str = Field(
default="default", default="default",
description="Button style: 'primary', 'danger', 'default'" description="Button style: 'primary', 'danger', 'default'",
json_schema_extra={"label": "Stil"},
) )
@i18nModel("Benachrichtigung")
class UserNotification(PowerOnModel): class UserNotification(PowerOnModel):
""" """
In-app notification for a user. In-app notification for a user.
@ -51,18 +56,18 @@ class UserNotification(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the notification", 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( userId: str = Field(
description="Target user ID for this notification", 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( type: NotificationType = Field(
default=NotificationType.SYSTEM, default=NotificationType.SYSTEM,
description="Type of notification", description="Type of notification",
json_schema_extra={ json_schema_extra={
"label": "Typ",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": True, "frontend_required": True,
@ -78,6 +83,7 @@ class UserNotification(PowerOnModel):
default=NotificationStatus.UNREAD, default=NotificationStatus.UNREAD,
description="Current status of the notification", description="Current status of the notification",
json_schema_extra={ json_schema_extra={
"label": "Status",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
@ -89,115 +95,63 @@ class UserNotification(PowerOnModel):
] ]
} }
) )
# Content
title: str = Field( title: str = Field(
description="Notification title", 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( message: str = Field(
description="Notification message/body", 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( icon: Optional[str] = Field(
default=None, default=None,
description="Optional icon identifier (e.g., 'mail', 'warning', 'info')", 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( referenceType: Optional[str] = Field(
default=None, default=None,
description="Type of referenced object (e.g., 'Invitation', 'Workflow')", 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( referenceId: Optional[str] = Field(
default=None, default=None,
description="ID of referenced object", 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( actions: Optional[List[NotificationAction]] = Field(
default=None, default=None,
description="List of possible actions for this notification", 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( actionTaken: Optional[str] = Field(
default=None, default=None,
description="Which action was taken (actionId)", 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( actionResult: Optional[str] = Field(
default=None, default=None,
description="Result message from the action", 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( readAt: Optional[float] = Field(
default=None, default=None,
description="When the notification was read (UTC timestamp)", 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( actionedAt: Optional[float] = Field(
default=None, default=None,
description="When action was taken (UTC timestamp)", 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( expiresAt: Optional[float] = Field(
default=None, default=None,
description="When the notification expires (optional, UTC timestamp)", 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) 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é"},
},
)

View file

@ -14,7 +14,7 @@ from typing import Optional
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel 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.datamodelUtils import TextMultilingual
from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelUam import AccessLevel
@ -26,6 +26,7 @@ class AccessRuleContext(str, Enum):
RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.) RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.)
@i18nModel("Rolle")
class Role(PowerOnModel): class Role(PowerOnModel):
""" """
Data model for RBAC roles. Data model for RBAC roles.
@ -41,56 +42,42 @@ class Role(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the role", 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( roleLabel: str = Field(
description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')", 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: TextMultilingual = Field(
description="Role description in multiple languages", 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!) # KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!)
mandateId: Optional[str] = Field( mandateId: Optional[str] = Field(
default=None, default=None,
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.", 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( featureInstanceId: Optional[str] = Field(
default=None, default=None,
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.", 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( featureCode: Optional[str] = Field(
default=None, default=None,
description="Feature code (z.B. 'trustee') - für Template-Rollen", 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( isSystemRole: bool = Field(
default=False, default=False,
description="Whether this is a system role that cannot be deleted", 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( @i18nModel("Zugriffsregel")
"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"},
},
)
class AccessRule(PowerOnModel): class AccessRule(PowerOnModel):
""" """
Data model for access control rules. Data model for access control rules.
@ -101,15 +88,15 @@ class AccessRule(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the access rule", 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( roleId: str = Field(
description="FK → Role.id (CASCADE DELETE!)", 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( context: AccessRuleContext = Field(
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!", 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": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
{"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}}, {"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}},
{"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}} {"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}}
@ -118,17 +105,17 @@ class AccessRule(PowerOnModel):
item: Optional[str] = Field( item: Optional[str] = Field(
default=None, default=None,
description="Item identifier (null = all items in context). Format: DATA: '<table>' or '<table>.<field>', UI: cascading string (e.g., 'playground.voice.settings'), RESOURCE: cascading string (e.g., 'ai.model.anthropic')", description="Item identifier (null = all items in context). Format: DATA: '<table>' or '<table>.<field>', 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( view: bool = Field(
default=False, default=False,
description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.", 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( read: Optional[AccessLevel] = Field(
default=None, default=None,
description="Read permission level (only for DATA context)", 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": "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": "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"}}, {"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( create: Optional[AccessLevel] = Field(
default=None, default=None,
description="Create permission level (only for DATA context)", 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": "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": "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"}}, {"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( update: Optional[AccessLevel] = Field(
default=None, default=None,
description="Update permission level (only for DATA context)", 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": "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": "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"}}, {"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( delete: Optional[AccessLevel] = Field(
default=None, default=None,
description="Delete permission level (only for DATA context)", 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": "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": "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"}}, {"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 Definition - für Enforcement auf Application-Level
IMMUTABLE_FIELDS = { IMMUTABLE_FIELDS = {
"Role": ["mandateId", "featureInstanceId", "featureCode"], "Role": ["mandateId", "featureInstanceId", "featureCode"],

View file

@ -12,7 +12,7 @@ Multi-Tenant Design:
from typing import Optional, Any from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict, model_validator from pydantic import BaseModel, Field, ConfigDict, model_validator
from modules.datamodels.datamodelBase import PowerOnModel 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 modules.shared.timeUtils import getUtcTimestamp
from .datamodelUam import AuthAuthority from .datamodelUam import AuthAuthority
from enum import Enum from enum import Enum
@ -31,46 +31,79 @@ class TokenPurpose(str, Enum):
DATA_CONNECTION = "dataConnection" DATA_CONNECTION = "dataConnection"
@i18nModel("Token")
class Token(PowerOnModel): class Token(PowerOnModel):
""" """
Authentication Token model. Authentication Token model.
Multi-Tenant Design: Multi-Tenant Design:
- Token ist User-gebunden, NICHT Mandant-gebunden - Token ist User-gebunden, NICHT Mandant-gebunden
- Ermöglicht parallele Arbeit in mehreren Mandanten - Ermöglicht parallele Arbeit in mehreren Mandanten
- Mandant-Kontext wird per Request-Header bestimmt - Mandant-Kontext wird per Request-Header bestimmt
""" """
id: Optional[str] = None id: Optional[str] = Field(
userId: str default=None,
authority: AuthAuthority 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( 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( tokenPurpose: Optional[TokenPurpose] = Field(
default=None, default=None,
description="authSession = gateway login JWT; dataConnection = provider OAuth for a connection", 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( 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( 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( 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( 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( 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) model_config = ConfigDict(use_enum_values=True)
@ -91,51 +124,44 @@ class Token(PowerOnModel):
return data return data
registerModelLabels( @i18nModel("Authentifizierungsereignis")
"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"},
},
)
class AuthEvent(PowerOnModel): class AuthEvent(PowerOnModel):
"""Authentication event for audit logging.""" """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}) id: str = Field(
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) default_factory=lambda: str(uuid.uuid4()),
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}) description="Unique ID of the auth event",
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}) json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
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}) userId: str = Field(
success: bool = Field(default=True, description="Whether the authentication event was successful", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": True}) description="ID of the user this event belongs to",
details: Optional[str] = Field(default=None, description="Additional details about the event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
eventType: str = Field(
registerModelLabels( description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')",
"AuthEvent", json_schema_extra={"label": "Ereignistyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
{"en": "Authentication Event", "de": "Authentifizierungsereignis", "fr": "Événement d'authentification"}, )
{ timestamp: float = Field(
"id": {"en": "ID", "de": "ID", "fr": "ID"}, default_factory=getUtcTimestamp,
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, description="Unix timestamp when the event occurred",
"eventType": {"en": "Event Type", "de": "Ereignistyp", "fr": "Type d'événement"}, json_schema_extra={"label": "Zeitstempel", "frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True},
"timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"}, )
"ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"}, ipAddress: Optional[str] = Field(
"userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"}, default=None,
"success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"}, description="IP address from which the event originated",
"details": {"en": "Details", "de": "Details", "fr": "Détails"}, 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},
)

View file

@ -11,7 +11,7 @@ from enum import Enum
from datetime import datetime, timezone from datetime import datetime, timezone
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
import uuid import uuid
@ -55,123 +55,224 @@ class BillingPeriodEnum(str, Enum):
# Catalog: SubscriptionPlan (static, in-memory) # Catalog: SubscriptionPlan (static, in-memory)
# ============================================================================ # ============================================================================
@i18nModel("Abonnement-Plan")
class SubscriptionPlan(BaseModel): class SubscriptionPlan(BaseModel):
"""Plan definition (catalog entry). Not stored per mandate — static.""" """Plan-Definition (Katalog). Nicht pro Mandat gespeichert — statisch."""
planKey: str = Field(..., description="Unique plan identifier") planKey: str = Field(
selectableByUser: bool = Field(default=True, description="Whether users can choose this plan in the UI") ...,
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)") title: Dict[str, str] = Field(
description: Dict[str, str] = Field(default_factory=dict, description="Multilingual description") 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") currency: str = Field(
billingPeriod: BillingPeriodEnum = Field(default=BillingPeriodEnum.MONTHLY, description="Recurring interval") default="CHF",
pricePerUserCHF: float = Field(default=0.0, description="Price per active user per period") description="Billing currency",
pricePerFeatureInstanceCHF: float = Field(default=0.0, description="Price per active feature instance per period") json_schema_extra={"label": "Waehrung"},
autoRenew: bool = Field(default=True, description="Stripe renews automatically at period end") )
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)") maxUsers: Optional[int] = Field(
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)") None,
trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)") description="Hard cap on active users (None = unlimited)",
maxDataVolumeMB: Optional[int] = Field(None, description="Soft-limit for data volume in MB per mandate (None = unlimited)") json_schema_extra={"label": "Max. Benutzer"},
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") maxFeatureInstances: Optional[int] = Field(
None,
description="Hard cap on active feature instances (None = unlimited)",
registerModelLabels( json_schema_extra={"label": "Max. Instanzen"},
"SubscriptionPlan", )
{"en": "Subscription Plan", "de": "Abonnement-Plan", "fr": "Plan d'abonnement"}, trialDays: Optional[int] = Field(
{ None,
"planKey": {"en": "Plan", "de": "Plan", "fr": "Plan"}, description="Trial duration in days (only for trial plans)",
"selectableByUser": {"en": "Selectable", "de": "Wählbar", "fr": "Sélectionnable"}, json_schema_extra={"label": "Probentage"},
"billingPeriod": {"en": "Billing Period", "de": "Abrechnungszeitraum", "fr": "Période de facturation"}, )
"pricePerUserCHF": {"en": "Price per User (CHF)", "de": "Preis pro User (CHF)"}, maxDataVolumeMB: Optional[int] = Field(
"pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"}, None,
"maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"}, description="Soft-limit for data volume in MB per mandate (None = unlimited)",
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"}, json_schema_extra={"label": "Datenvolumen (MB)"},
"maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"}, )
"budgetAiCHF": {"en": "AI Budget (CHF)", "de": "AI-Budget (CHF)"}, 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) # Stripe Price mapping (persisted in DB, auto-created at bootstrap)
# ============================================================================ # ============================================================================
@i18nModel("Stripe-Planpreise")
class StripePlanPrice(BaseModel): class StripePlanPrice(BaseModel):
"""Persisted mapping from planKey to Stripe Product/Price IDs. """Persistierte Zuordnung planKey zu Stripe Product/Price IDs."""
Auto-created at startup no manual configuration needed. id: str = Field(
Uses separate Stripe Products for users and instances for clear invoice labels.""" default_factory=lambda: str(uuid.uuid4()),
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") description="Primary key",
planKey: str = Field(..., description="Reference to SubscriptionPlan.planKey") json_schema_extra={"label": "ID"},
stripeProductId: str = Field("", description="Legacy single-product ID (unused)") )
stripeProductIdUsers: Optional[str] = Field(None, description="Stripe Product ID for user licenses") planKey: str = Field(
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") description="Reference to SubscriptionPlan.planKey",
stripePriceIdInstances: Optional[str] = Field(None, description="Stripe Price ID for instance line item") json_schema_extra={"label": "Plan"},
)
stripeProductId: str = Field(
registerModelLabels( "",
"StripePlanPrice", description="Legacy single-product ID (unused)",
{"en": "Stripe Plan Prices", "de": "Stripe-Planpreise"}, json_schema_extra={"label": "Stripe-Produkt-ID (Legacy)"},
{ )
"planKey": {"en": "Plan", "de": "Plan"}, stripeProductIdUsers: Optional[str] = Field(
"stripeProductIdUsers": {"en": "Product (Users)", "de": "Produkt (User)"}, None,
"stripeProductIdInstances": {"en": "Product (Instances)", "de": "Produkt (Instanzen)"}, description="Stripe Product ID for user licenses",
"stripePriceIdUsers": {"en": "Price ID (Users)", "de": "Preis-ID (User)"}, json_schema_extra={"label": "Produkt (User)"},
"stripePriceIdInstances": {"en": "Price ID (Instances)", "de": "Preis-ID (Instanzen)"}, )
}, 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 # Instance: MandateSubscription
# ============================================================================ # ============================================================================
@i18nModel("Mandanten-Abonnement")
class MandateSubscription(PowerOnModel): class MandateSubscription(PowerOnModel):
"""A subscription instance bound to a specific mandate. """Abonnement-Instanz gebunden an einen Mandanten."""
See wiki/concepts/Subscription-State-Machine.md for state transitions.""" id: str = Field(
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") default_factory=lambda: str(uuid.uuid4()),
mandateId: str = Field(..., description="Foreign key to Mandate") description="Primary key",
planKey: str = Field(..., description="Reference to SubscriptionPlan.planKey") 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") status: SubscriptionStatusEnum = Field(
recurring: bool = Field(default=True, description="True: auto-renews at period end. False: expires at period end (gekuendigt).") 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") startedAt: datetime = Field(
effectiveFrom: Optional[datetime] = Field(None, description="When this subscription becomes operative. None = immediate. Set for SCHEDULED subs.") default_factory=lambda: datetime.now(timezone.utc),
endedAt: Optional[datetime] = Field(None, description="When subscription ended (terminal)") description="Record creation timestamp",
currentPeriodStart: Optional[datetime] = Field(None, description="Current billing period start (synced from Stripe)") json_schema_extra={"label": "Gestartet"},
currentPeriodEnd: Optional[datetime] = Field(None, description="Current billing period end (synced from Stripe)") )
trialEndsAt: Optional[datetime] = Field(None, description="Trial expiry timestamp") 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)") snapshotPricePerUserCHF: float = Field(
snapshotPricePerInstanceCHF: float = Field(default=0.0, description="Price snapshot at activation") 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)") stripeSubscriptionId: Optional[str] = Field(
stripeItemIdUsers: Optional[str] = Field(None, description="Stripe Subscription Item ID for user seats") None,
stripeItemIdInstances: Optional[str] = Field(None, description="Stripe Subscription Item ID for feature instances") description="Stripe Subscription ID (sub_xxx)",
json_schema_extra={"label": "Stripe-Abonnement-ID"},
)
registerModelLabels( stripeItemIdUsers: Optional[str] = Field(
"MandateSubscription", None,
{"en": "Mandate Subscription", "de": "Mandanten-Abonnement", "fr": "Abonnement du mandat"}, description="Stripe Subscription Item ID for user seats",
{ json_schema_extra={"label": "Stripe-Item (User)"},
"id": {"en": "ID", "de": "ID"}, )
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, stripeItemIdInstances: Optional[str] = Field(
"planKey": {"en": "Plan", "de": "Plan"}, None,
"status": {"en": "Status", "de": "Status"}, description="Stripe Subscription Item ID for feature instances",
"recurring": {"en": "Recurring", "de": "Wiederkehrend"}, json_schema_extra={"label": "Stripe-Item (Instanzen)"},
"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)"},
},
)
# ============================================================================ # ============================================================================
@ -225,10 +326,10 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"STANDARD_YEARLY": SubscriptionPlan( "STANDARD_YEARLY": SubscriptionPlan(
planKey="STANDARD_YEARLY", planKey="STANDARD_YEARLY",
selectableByUser=True, selectableByUser=True,
title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"}, title={"en": "Standard (Yearly)", "de": "Standard (Jaehrlich)", "fr": "Standard (Annuel)"},
description={ description={
"en": "Usage-based billing per active user and feature instance, billed yearly. Includes 120 CHF AI budget.", "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, billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=948.0, pricePerUserCHF=948.0,

View file

@ -14,7 +14,7 @@ from typing import Optional, List, Dict, Any
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.datamodels.datamodelBase import PowerOnModel 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 modules.shared.timeUtils import getUtcTimestamp
@ -61,6 +61,7 @@ class UserPermissions(BaseModel):
) )
@i18nModel("Mandant")
class Mandate(PowerOnModel): class Mandate(PowerOnModel):
""" """
Mandate (Mandant/Tenant) model. Mandate (Mandant/Tenant) model.
@ -69,31 +70,31 @@ class Mandate(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the mandate", 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( name: str = Field(
description="Name of the mandate", 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( label: Optional[str] = Field(
default=None, default=None,
description="Display label of the mandate", 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( enabled: bool = Field(
default=True, default=True,
description="Indicates whether the mandate is enabled", 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( isSystem: bool = Field(
default=False, default=False,
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.", 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( deletedAt: Optional[float] = Field(
default=None, default=None,
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.", 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') @field_validator('isSystem', mode='before')
@ -104,38 +105,91 @@ class Mandate(PowerOnModel):
return False return False
return v return v
registerModelLabels( @i18nModel("Benutzerverbindung")
"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"},
},
)
class UserConnection(PowerOnModel): 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}) id: str = Field(
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) default_factory=lambda: str(uuid.uuid4()),
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}) description="Unique ID of the connection",
externalId: str = Field(description="User ID in the external system", 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"},
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}) userId: str = Field(
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"}) description="ID of the user this connection belongs to",
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}) json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Benutzer-ID"},
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}) authority: AuthAuthority = Field(
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": [ description="Authentication authority",
{"value": "active", "label": {"en": "Active", "fr": "Actif"}}, json_schema_extra={
{"value": "expired", "label": {"en": "Expired", "fr": "Expiré"}}, "frontend_type": "select",
{"value": "none", "label": {"en": "None", "fr": "Aucun"}}, "frontend_readonly": True,
]}) "frontend_required": False,
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}) "frontend_options": "/api/connections/authorities/options",
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": "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
@computed_field @computed_field
@ -157,29 +211,7 @@ class UserConnection(PowerOnModel):
return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}" return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}"
registerModelLabels( @i18nModel("Benutzer")
"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"},
},
)
class User(PowerOnModel): class User(PowerOnModel):
""" """
User model. User model.
@ -193,31 +225,37 @@ class User(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user", 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( username: str = Field(
description="Username for login (immutable after creation)", 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( email: Optional[EmailStr] = Field(
default=None, default=None,
description="Email address of the user", 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( fullName: Optional[str] = Field(
default=None, default=None,
description="Full name of the user", 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( language: str = Field(
default="de", default="de",
description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)", 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": [ json_schema_extra={
{"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}}, "frontend_type": "select",
{"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}}, "frontend_readonly": False,
{"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}}, "frontend_required": True,
{"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}}, "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') @field_validator('language', mode='before')
@ -245,13 +283,13 @@ class User(PowerOnModel):
enabled: bool = Field( enabled: bool = Field(
default=True, default=True,
description="Indicates whether the user is enabled", 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( isSysAdmin: bool = Field(
default=False, default=False,
description="Global SysAdmin flag. SysAdmin = System-Zugriff, KEIN Daten-Zugriff!", 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') @field_validator('isSysAdmin', mode='before')
@ -265,48 +303,45 @@ class User(PowerOnModel):
authenticationAuthority: AuthAuthority = Field( authenticationAuthority: AuthAuthority = Field(
default=AuthAuthority.LOCAL, default=AuthAuthority.LOCAL,
description="Primary authentication authority", 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( roleLabels: List[str] = Field(
default_factory=list, default_factory=list,
description="Role labels (from DB or enriched when loading users)", 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( @i18nModel("Benutzerzugang")
"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"},
},
)
class UserInDB(User): class UserInDB(User):
"""User model with password hash for database storage.""" """User model with password hash for database storage."""
hashedPassword: Optional[str] = Field(None, description="Hash of the user password") hashedPassword: Optional[str] = Field(
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)") None,
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)") description="Hash of the user password",
json_schema_extra={"label": "Passwort-Hash"},
)
registerModelLabels( resetToken: Optional[str] = Field(
"UserInDB", None,
{"en": "User Access", "de": "Benutzerzugang", "fr": "Accès de l'utilisateur"}, description="Password reset token (UUID)",
{ json_schema_extra={"label": "Reset-Token"},
"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: Optional[float] = Field(
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"}, 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]]: 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 return out if out else None
@i18nModel("Spracheinstellungen")
class UserVoicePreferences(PowerOnModel): class UserVoicePreferences(PowerOnModel):
"""User-level voice/language preferences, shared across all features.""" """User-level voice/language preferences, shared across all features."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(
userId: str = Field(description="User ID") default_factory=lambda: str(uuid.uuid4()),
mandateId: Optional[str] = Field(default=None, description="Mandate scope (None = global for user)") description="Primary key",
sttLanguage: str = Field(default="de-DE", description="Speech-to-text language code") json_schema_extra={"label": "ID"},
ttsLanguage: str = Field(default="de-DE", description="Text-to-speech language code") )
ttsVoice: Optional[str] = Field(default=None, description="Preferred TTS voice identifier") userId: str = Field(description="User ID", json_schema_extra={"label": "Benutzer-ID"})
ttsVoiceMap: Optional[Dict[str, str]] = Field(default=None, description="Language-to-voice mapping") mandateId: Optional[str] = Field(
translationSourceLanguage: Optional[str] = Field(default=None, description="Source language for translations") default=None,
translationTargetLanguage: Optional[str] = Field(default=None, description="Target language for translations") 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") @field_validator("ttsVoiceMap", mode="before")
@classmethod @classmethod
@ -354,18 +422,3 @@ class UserVoicePreferences(PowerOnModel):
return _normalizeTtsVoiceMap(value) 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"},
},
)

View file

@ -7,7 +7,7 @@ from typing import List, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
UiLanguageStatus = Literal["complete", "incomplete", "generating"] UiLanguageStatus = Literal["complete", "incomplete", "generating"]
@ -20,7 +20,7 @@ class I18nEntry(BaseModel):
"db.management.files.name" for backend data objects. "db.management.files.name" for backend data objects.
key: German plaintext (the canonical identifier across all sets). key: German plaintext (the canonical identifier across all sets).
value: For xx (base set): UI context description for AI translation. 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( context: str = Field(
@ -37,17 +37,15 @@ class I18nEntry(BaseModel):
) )
@i18nModel("UI-Sprachset")
class UiLanguageSet(PowerOnModel): class UiLanguageSet(PowerOnModel):
"""One row per language. id = ISO 639-1 code or 'xx' (base set). """Ein Sprachset pro Sprache. id = ISO 639-1 Code oder 'xx' (Basisset). Enthaelt alle Uebersetzungen."""
The xx set is the master: key = German plaintext, value = UI context for AI.
All other sets (incl. de) are AI-generated translations.
"""
id: str = Field( id: str = Field(
..., ...,
description="ISO 639-1 language code or 'xx' for the base set", description="ISO 639-1 language code or 'xx' for the base set",
json_schema_extra={ json_schema_extra={
"label": "Code",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
@ -57,6 +55,7 @@ class UiLanguageSet(PowerOnModel):
..., ...,
description="Human-readable language name", description="Human-readable language name",
json_schema_extra={ json_schema_extra={
"label": "Bezeichnung",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
@ -66,6 +65,7 @@ class UiLanguageSet(PowerOnModel):
default_factory=list, default_factory=list,
description="Translation entries: list of {context, key, value}", description="Translation entries: list of {context, key, value}",
json_schema_extra={ json_schema_extra={
"label": "Eintraege",
"frontend_type": "textarea", "frontend_type": "textarea",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False, "frontend_required": False,
@ -75,6 +75,7 @@ class UiLanguageSet(PowerOnModel):
default="complete", default="complete",
description="complete | incomplete | generating", description="complete | incomplete | generating",
json_schema_extra={ json_schema_extra={
"label": "Status",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
@ -89,21 +90,9 @@ class UiLanguageSet(PowerOnModel):
default=False, default=False,
description="True only for the xx base set", description="True only for the xx base set",
json_schema_extra={ json_schema_extra={
"label": "Standard",
"frontend_type": "boolean", "frontend_type": "boolean",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": 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"},
},
)

View file

@ -2,20 +2,40 @@
# All rights reserved. # All rights reserved.
"""Utility datamodels: Prompt, TextMultilingual.""" """Utility datamodels: Prompt, TextMultilingual."""
from typing import Dict, Optional from typing import Any, Dict, Optional
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
import uuid import uuid
@i18nModel("Prompt")
class Prompt(PowerOnModel): 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}) """Benutzer- oder System-Prompt fuer die KI."""
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}) id: str = Field(
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}) default_factory=lambda: str(uuid.uuid4()),
content: str = Field(description="Content of the prompt", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True}) description="Primary key",
name: str = Field(description="Name of the prompt", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) 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') @field_validator('isSystem', mode='before')
@classmethod @classmethod
def _coerceIsSystem(cls, v): def _coerceIsSystem(cls, v):
@ -23,62 +43,64 @@ class Prompt(PowerOnModel):
if v is None: if v is None:
return False return False
return v 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): class TextMultilingual(BaseModel):
""" """Multilingual text field. Language codes follow ISO 639-1 (en, de, fr, it, …)."""
Multilingual text field supporting multiple languages.
Default languages: en (English), ge (German), fr (French), it (Italian)
English (en) is the default/required language.
"""
en: str = Field(description="English text (default language, required)") 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") fr: Optional[str] = Field(None, description="French text")
it: Optional[str] = Field(None, description="Italian text") it: Optional[str] = Field(None, description="Italian text")
@field_validator('en') @field_validator('en')
@classmethod @classmethod
def validate_en_required(cls, v): def _validateEnRequired(cls, v):
"""Ensure English text is not empty"""
if not v or not v.strip(): if not v or not v.strip():
raise ValueError("English text (en) is required and cannot be empty") raise ValueError("English text (en) is required and cannot be empty")
return v return v
def model_dump(self, **kwargs) -> Dict[str, str]: def model_dump(self, **kwargs) -> Dict[str, str]:
"""Return as dictionary, filtering out None values"""
result = {} result = {}
for lang in ['en', 'ge', 'fr', 'it']: for key in self.model_fields:
value = getattr(self, lang, None) value = getattr(self, key, None)
if value is not None: if value is not None:
result[lang] = value result[key] = value
return result return result
@classmethod @classmethod
def from_dict(cls, data: Dict[str, str]) -> 'TextMultilingual': def from_dict(cls, data: Dict[str, str]) -> 'TextMultilingual':
"""Create TextMultilingual from dictionary""" fields = {k: data[k] for k in cls.model_fields if k in data}
return cls( fields.setdefault('en', '')
en=data.get('en', ''), return cls(**fields)
ge=data.get('ge'),
fr=data.get('fr'),
it=data.get('it')
)
def get_text(self, lang: str = 'en') -> str: 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) value = getattr(self, lang, None)
if value: if value:
return 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("")

View file

@ -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 typing import Dict, Any, List, Optional, TYPE_CHECKING
from pydantic import BaseModel, Field 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 from modules.shared.jsonUtils import extractJsonString, tryParseJson, repairBrokenJson
# Import DocumentReferenceList at runtime (needed for ActionDefinition) # Import DocumentReferenceList at runtime (needed for ActionDefinition)
from modules.datamodels.datamodelDocref import DocumentReferenceList from modules.datamodels.datamodelDocref import DocumentReferenceList
@i18nModel("Aktionsdefinition")
class ActionDefinition(BaseModel): class ActionDefinition(BaseModel):
"""Action definition with selection and parameters from planning phase""" """Action definition with selection and parameters from planning phase"""
# Core action selection (Stage 1) # Core action selection (Stage 1)
action: str = Field(description="Compound action name (method.action)") action: str = Field(description="Compound action name (method.action)", json_schema_extra={"label": "Aktion"})
actionObjective: str = Field(description="Objective for this action") actionObjective: str = Field(description="Objective for this action", json_schema_extra={"label": "Aktionsziel"})
userMessage: Optional[str] = Field( userMessage: Optional[str] = Field(
None, 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( parametersContext: Optional[str] = Field(
None, None,
description="Context for parameter generation" description="Context for parameter generation",
json_schema_extra={"label": "Parameter-Kontext"},
) )
learnings: List[str] = Field( learnings: List[str] = Field(
default_factory=list, 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) # Resources (ALWAYS defined in Stage 1 if action needs them)
documentList: Optional[DocumentReferenceList] = Field( documentList: Optional[DocumentReferenceList] = Field(
None, 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( connectionReference: Optional[str] = Field(
None, 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 (may be defined in Stage 1 OR Stage 2, depending on action and actionObjective)
parameters: Optional[Dict[str, Any]] = Field( parameters: Optional[Dict[str, Any]] = Field(
None, 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: def hasParameters(self) -> bool:
@ -75,34 +82,47 @@ class ActionDefinition(BaseModel):
self.connectionReference = connectionRef self.connectionReference = connectionRef
@i18nModel("KI-Antwort-Metadaten")
class AiResponseMetadata(BaseModel): class AiResponseMetadata(BaseModel):
"""Metadata for AI response (varies by operation type).""" """Metadata for AI response (varies by operation type)."""
# Document Generation Metadata # Document Generation Metadata
title: Optional[str] = Field(None, description="Document title") title: Optional[str] = Field(None, description="Document title", json_schema_extra={"label": "Titel"})
filename: Optional[str] = Field(None, description="Document filename") filename: Optional[str] = Field(None, description="Document filename", json_schema_extra={"label": "Dateiname"})
# Operation-Specific Metadata # Operation-Specific Metadata
operationType: Optional[str] = Field(None, description="Type of operation performed") 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") schemaVersion: Optional[str] = Field(
extractionMethod: Optional[str] = Field(None, description="Method used for extraction") None,
sourceDocuments: Optional[List[str]] = Field(None, description="Source document references") 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) # Additional metadata (for extensibility)
additionalData: Optional[Dict[str, Any]] = Field(None, description="Additional operation-specific metadata") additionalData: Optional[Dict[str, Any]] = Field(
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(
None, 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): class ExtractContentParameters(BaseModel):
"""Parameters for extraction action. """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. 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. 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 extractionOptions: Optional[Any] = Field( # ExtractionOptions - forward reference
None, 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): class AiResponse(BaseModel):
"""Unified response from all AI calls (planning, text, documents)""" """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( metadata: Optional[AiResponseMetadata] = Field(
None, 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( documents: Optional[List[DocumentData]] = Field(
None, 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]: def toJson(self) -> Dict[str, Any]:
@ -186,278 +216,88 @@ class AiResponse(BaseModel):
# Workflow-level models # Workflow-level models
@i18nModel("Anfragekontext")
class RequestContext(BaseModel): class RequestContext(BaseModel):
"""Normalized request context from user input""" """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 documents: List[Any] = Field( # ChatDocument - forward reference
default_factory=list, 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( 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") 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") 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") 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") 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") 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): class UnderstandingResult(BaseModel):
"""Result from initial understanding phase (combined AI call)""" """Result from initial understanding phase (combined AI call)"""
parameters: Dict[str, Any] = Field( parameters: Dict[str, Any] = Field(
default_factory=dict, 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( intention: Dict[str, Any] = Field(
default_factory=dict, 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( context: Dict[str, Any] = Field(
default_factory=dict, 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( documentReferences: List[Dict[str, Any]] = Field(
default_factory=list, 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 tasks: List["TaskDefinition"] = Field( # Forward reference
default_factory=list, default_factory=list,
description="Task definitions with deliverables" description="Task definitions with deliverables",
json_schema_extra={"label": "Aufgaben"},
) )
@i18nModel("Aufgabenbeschreibung")
class TaskDefinition(BaseModel): class TaskDefinition(BaseModel):
"""Task definition from understanding phase""" """Task definition from understanding phase"""
id: str = Field(description="Task identifier") id: str = Field(description="Task identifier", json_schema_extra={"label": "Aufgaben-ID"})
objective: str = Field(description="Task objective") objective: str = Field(description="Task objective", json_schema_extra={"label": "Ziel"})
deliverable: Dict[str, Any] = Field( 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") 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") 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") requiresContentGeneration: bool = Field(default=True, description="Whether task requires content generation", json_schema_extra={"label": "Benötigt Inhaltserstellung"})
requiredDocuments: List[str] = Field( requiredDocuments: List[str] = Field(
default_factory=list, 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 extractionOptions: Optional[Any] = Field( # ExtractionOptions - forward reference
None, 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""" """Result from task execution"""
taskId: str = Field(description="Task identifier") taskId: str = Field(description="Task identifier", json_schema_extra={"label": "Aufgaben-ID"})
actionResult: Any = Field(description="ActionResult from task execution") # ActionResult - forward reference actionResult: Any = Field(description="ActionResult from task execution", json_schema_extra={"label": "Aktionsergebnis"}) # 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"},
},
)

View file

@ -6,85 +6,97 @@ from typing import Optional, Any, Union, List, Dict, Callable, Awaitable
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelChat import ActionResult from modules.datamodels.datamodelChat import ActionResult
from modules.shared.frontendTypes import FrontendType from modules.shared.frontendTypes import FrontendType
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
@i18nModel("Workflow-Aktionsparameter")
class WorkflowActionParameter(BaseModel): class WorkflowActionParameter(BaseModel):
""" """
Parameter schema definition for a workflow action. Parameter schema definition for a workflow action.
This defines the structure and UI rendering for a single action parameter, This defines the structure and UI rendering for a single action parameter,
NOT the actual parameter values (those are in ActionDefinition.parameters). NOT the actual parameter values (those are in ActionDefinition.parameters).
""" """
name: str = Field(description="Parameter name") name: str = Field(
type: str = Field(description="Python type as string: 'str', 'int', 'bool', 'List[str]', etc.") description="Parameter name",
frontendType: FrontendType = Field(description="UI rendering type (from global FrontendType enum)") 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( frontendOptions: Optional[Union[str, List[str]]] = Field(
None, 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( validation: Optional[Dict[str, Any]] = Field(
None, 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): class WorkflowActionDefinition(BaseModel):
""" """
Complete schema definition of a workflow action. Complete schema definition of a workflow action.
This defines the metadata, parameters, and execution function for an action. This defines the metadata, parameters, and execution function for an action.
This is different from datamodelWorkflow.ActionDefinition which contains This is different from datamodelWorkflow.ActionDefinition which contains
actual execution values (action, actionObjective, parameters with values). actual execution values (action, actionObjective, parameters with values).
This class defines the ACTION SCHEMA, not the execution plan. This class defines the ACTION SCHEMA, not the execution plan.
""" """
actionId: str = Field( 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( parameters: Dict[str, WorkflowActionParameter] = Field(
default_factory=dict, default_factory=dict,
description="Parameter schema definitions" description="Parameter schema definitions",
json_schema_extra={"label": "Parameter"},
) )
execute: Optional[Callable] = Field( execute: Optional[Callable] = Field(
None, 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"},
},
)

View file

@ -12,14 +12,14 @@ logger = logging.getLogger(__name__)
# Feature metadata # Feature metadata
FEATURE_CODE = "chatbot" FEATURE_CODE = "chatbot"
FEATURE_LABEL = {"en": "Chatbot", "de": "Chatbot", "fr": "Chatbot"} FEATURE_LABEL = "Chatbot"
FEATURE_ICON = "mdi-robot" FEATURE_ICON = "mdi-robot"
# UI Objects for RBAC catalog # UI Objects for RBAC catalog
UI_OBJECTS = [ UI_OBJECTS = [
{ {
"objectKey": "ui.feature.chatbot.conversations", "objectKey": "ui.feature.chatbot.conversations",
"label": {"en": "Conversations", "de": "Konversationen", "fr": "Conversations"}, "label": "Konversationen",
"meta": {"area": "conversations"} "meta": {"area": "conversations"}
} }
] ]
@ -28,22 +28,22 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [ RESOURCE_OBJECTS = [
{ {
"objectKey": "resource.feature.chatbot.startStream", "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"} "meta": {"endpoint": "/api/chatbot/{instanceId}/start/stream", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.chatbot.stop", "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"} "meta": {"endpoint": "/api/chatbot/{instanceId}/stop/{workflowId}", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.chatbot.threads", "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"} "meta": {"endpoint": "/api/chatbot/{instanceId}/threads", "method": "GET"}
}, },
{ {
"objectKey": "resource.feature.chatbot.delete", "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"} "meta": {"endpoint": "/api/chatbot/{instanceId}/{workflowId}", "method": "DELETE"}
}, },
] ]
@ -74,11 +74,7 @@ REQUIRED_SERVICES = [
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{ {
"roleLabel": "chatbot-viewer", "roleLabel": "chatbot-viewer",
"description": { "description": "Chatbot Betrachter - Chat-Threads ansehen (nur lesen)",
"en": "Chatbot Viewer - View chat threads (read-only)",
"de": "Chatbot Betrachter - Chat-Threads ansehen (nur lesen)",
"fr": "Visualiseur Chatbot - Consulter les threads (lecture seule)"
},
"accessRules": [ "accessRules": [
# UI: only threads view, NO active chat # UI: only threads view, NO active chat
{"context": "UI", "item": "ui.feature.chatbot.threads", "view": True}, {"context": "UI", "item": "ui.feature.chatbot.threads", "view": True},
@ -90,11 +86,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "chatbot-user", "roleLabel": "chatbot-user",
"description": { "description": "Chatbot Benutzer - Chatbot nutzen und eigene Threads verwalten",
"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"
},
"accessRules": [ "accessRules": [
# UI: full access to all views # UI: full access to all views
{"context": "UI", "item": "ui.feature.chatbot.conversations", "view": True}, {"context": "UI", "item": "ui.feature.chatbot.conversations", "view": True},
@ -110,11 +102,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "chatbot-admin", "roleLabel": "chatbot-admin",
"description": { "description": "Chatbot Admin - Vollzugriff auf alle Chatbot-Funktionen",
"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"
},
"accessRules": [ "accessRules": [
# Full UI access # Full UI access
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
@ -391,7 +379,8 @@ def _syncTemplateRolesToDb() -> int:
try: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface() rootInterface = getRootInterface()
# Get existing template roles for this feature (Pydantic models) # Get existing template roles for this feature (Pydantic models)
@ -412,7 +401,7 @@ def _syncTemplateRolesToDb() -> int:
# Create new template role # Create new template role
newRole = Role( newRole = Role(
roleLabel=roleLabel, roleLabel=roleLabel,
description=roleTemplate.get("description", {}), description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE, featureCode=FEATURE_CODE,
mandateId=None, # Global template mandateId=None, # Global template
featureInstanceId=None, featureInstanceId=None,

View file

@ -32,6 +32,8 @@ from modules.features.chatbot.interfaceFeatureChatbot import ChatbotConversation
# Import chatbot feature # Import chatbot feature
from modules.features.chatbot import chatProcess from modules.features.chatbot import chatProcess
from modules.features.chatbot.mainChatbot import getEventManager 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). # Pre-warm AI connectors when this router loads (before first request).
# Ensures connectors are ready; avoids 48 s delay on first chatbot message. # Ensures connectors are ready; avoids 48 s delay on first chatbot message.
@ -265,7 +267,7 @@ async def stream_chatbot_start(
if not workflow: if not workflow:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="Failed to create or load workflow" detail=routeApiMsg("Failed to create or load workflow")
) )
# Get event queue for the workflow # Get event queue for the workflow
@ -562,7 +564,7 @@ def delete_chatbot(
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete workflow" detail=routeApiMsg("Failed to delete workflow")
) )
return { return {

View file

@ -11,23 +11,23 @@ from typing import Dict, List, Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FEATURE_CODE = "commcoach" FEATURE_CODE = "commcoach"
FEATURE_LABEL = {"en": "Communication Coach", "de": "Kommunikations-Coach", "fr": "Coach Communication"} FEATURE_LABEL = "Kommunikations-Coach"
FEATURE_ICON = "mdi-account-voice" FEATURE_ICON = "mdi-account-voice"
UI_OBJECTS = [ UI_OBJECTS = [
{ {
"objectKey": "ui.feature.commcoach.dashboard", "objectKey": "ui.feature.commcoach.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, "label": "Dashboard",
"meta": {"area": "dashboard"} "meta": {"area": "dashboard"}
}, },
{ {
"objectKey": "ui.feature.commcoach.coaching", "objectKey": "ui.feature.commcoach.coaching",
"label": {"en": "Coaching & Dossier", "de": "Coaching & Dossier", "fr": "Coaching & Dossier"}, "label": "Coaching & Dossier",
"meta": {"area": "coaching"} "meta": {"area": "coaching"}
}, },
{ {
"objectKey": "ui.feature.commcoach.settings", "objectKey": "ui.feature.commcoach.settings",
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Parametres"}, "label": "Einstellungen",
"meta": {"area": "settings"} "meta": {"area": "settings"}
}, },
] ]
@ -35,7 +35,7 @@ UI_OBJECTS = [
DATA_OBJECTS = [ DATA_OBJECTS = [
{ {
"objectKey": "data.feature.commcoach.CoachingContext", "objectKey": "data.feature.commcoach.CoachingContext",
"label": {"en": "Coaching Context", "de": "Coaching-Kontext", "fr": "Contexte coaching"}, "label": "Coaching-Kontext",
"meta": { "meta": {
"table": "CoachingContext", "table": "CoachingContext",
"fields": ["id", "title", "category", "status"], "fields": ["id", "title", "category", "status"],
@ -45,7 +45,7 @@ DATA_OBJECTS = [
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingSession", "objectKey": "data.feature.commcoach.CoachingSession",
"label": {"en": "Coaching Session", "de": "Coaching-Session", "fr": "Session coaching"}, "label": "Coaching-Session",
"meta": { "meta": {
"table": "CoachingSession", "table": "CoachingSession",
"fields": ["id", "contextId", "status", "summary"], "fields": ["id", "contextId", "status", "summary"],
@ -55,12 +55,12 @@ DATA_OBJECTS = [
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingMessage", "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"]} "meta": {"table": "CoachingMessage", "fields": ["id", "sessionId", "role", "content"]}
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingTask", "objectKey": "data.feature.commcoach.CoachingTask",
"label": {"en": "Coaching Task", "de": "Coaching-Aufgabe", "fr": "Tache coaching"}, "label": "Coaching-Aufgabe",
"meta": { "meta": {
"table": "CoachingTask", "table": "CoachingTask",
"fields": ["id", "contextId", "title", "status"], "fields": ["id", "contextId", "title", "status"],
@ -70,27 +70,27 @@ DATA_OBJECTS = [
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingScore", "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"]} "meta": {"table": "CoachingScore", "fields": ["id", "dimension", "score", "trend"]}
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingUserProfile", "objectKey": "data.feature.commcoach.CoachingUserProfile",
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"}, "label": "Benutzerprofil",
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "dailyReminderEnabled"]} "meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "dailyReminderEnabled"]}
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingPersona", "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"]} "meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]}
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingBadge", "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"]} "meta": {"table": "CoachingBadge", "fields": ["id", "badgeKey", "awardedAt"]}
}, },
{ {
"objectKey": "data.feature.commcoach.*", "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} "meta": {"wildcard": True}
}, },
] ]
@ -98,27 +98,27 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [ RESOURCE_OBJECTS = [
{ {
"objectKey": "resource.feature.commcoach.context.create", "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"} "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.commcoach.context.archive", "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"} "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.commcoach.session.start", "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"} "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/sessions/start", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.commcoach.session.complete", "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"} "meta": {"endpoint": "/api/commcoach/{instanceId}/sessions/{sessionId}/complete", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.commcoach.task.manage", "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"} "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/tasks", "method": "POST"}
}, },
] ]
@ -126,30 +126,22 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{ {
"roleLabel": "commcoach-viewer", "roleLabel": "commcoach-viewer",
"description": { "description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
"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)",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "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": "UI", "item": "ui.feature.commcoach.settings", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"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", "roleLabel": "commcoach-user",
"description": { "description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "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": "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.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"}, {"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", "roleLabel": "commcoach-admin",
"description": { "description": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True}, {"context": "RESOURCE", "item": None, "view": True},
@ -271,6 +259,7 @@ def _syncTemplateRolesToDb() -> int:
try: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface() rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
@ -287,7 +276,7 @@ def _syncTemplateRolesToDb() -> int:
else: else:
newRole = Role( newRole = Role(
roleLabel=roleLabel, roleLabel=roleLabel,
description=roleTemplate.get("description", {}), description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE, featureCode=FEATURE_CODE,
mandateId=None, mandateId=None,
featureInstanceId=None, featureInstanceId=None,

View file

@ -33,6 +33,8 @@ from .datamodelCommcoach import (
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest,
) )
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureCommcoach")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_activeProcessTasks: dict = {} _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") 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) mandateId = instance.get("mandateId") if isinstance(instance, dict) else getattr(instance, "mandateId", None)
if not mandateId: 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) return str(mandateId)
def _validateOwnership(record: dict, context: RequestContext, fieldName: str = "userId") -> None: def _validateOwnership(record: dict, context: RequestContext, fieldName: str = "userId") -> None:
"""Strict ownership check. SysAdmin does NOT bypass for content access.""" """Strict ownership check. SysAdmin does NOT bypass for content access."""
if record.get(fieldName) != str(context.user.id): 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) ctx = interface.getContext(contextId)
if not ctx: 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) _validateOwnership(ctx, context)
tasks = interface.getTasks(contextId, userId) tasks = interface.getTasks(contextId, userId)
@ -187,7 +189,7 @@ async def updateContext(
ctx = interface.getContext(contextId) ctx = interface.getContext(contextId)
if not ctx: 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) _validateOwnership(ctx, context)
updates = body.model_dump(exclude_none=True) updates = body.model_dump(exclude_none=True)
@ -208,7 +210,7 @@ async def deleteContext(
ctx = interface.getContext(contextId) ctx = interface.getContext(contextId)
if not ctx: 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) _validateOwnership(ctx, context)
interface.deleteContext(contextId) interface.deleteContext(contextId)
@ -228,7 +230,7 @@ async def archiveContext(
ctx = interface.getContext(contextId) ctx = interface.getContext(contextId)
if not ctx: 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) _validateOwnership(ctx, context)
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value}) updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value})
@ -249,7 +251,7 @@ async def activateContext(
ctx = interface.getContext(contextId) ctx = interface.getContext(contextId)
if not ctx: 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) _validateOwnership(ctx, context)
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value}) updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value})
@ -274,7 +276,7 @@ async def listSessions(
ctx = interface.getContext(contextId) ctx = interface.getContext(contextId)
if not ctx: 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) _validateOwnership(ctx, context)
sessions = interface.getSessions(contextId, userId) sessions = interface.getSessions(contextId, userId)
@ -297,7 +299,7 @@ async def startSession(
ctx = interface.getContext(contextId) ctx = interface.getContext(contextId)
if not ctx: 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) _validateOwnership(ctx, context)
activeSession = interface.getActiveSession(contextId, userId) activeSession = interface.getActiveSession(contextId, userId)
@ -420,7 +422,7 @@ async def getSession(
session = interface.getSession(sessionId) session = interface.getSession(sessionId)
if not session: 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) _validateOwnership(session, context)
messages = interface.getMessages(sessionId) messages = interface.getMessages(sessionId)
@ -441,7 +443,7 @@ async def completeSession(
session = interface.getSession(sessionId) session = interface.getSession(sessionId)
if not session: 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) _validateOwnership(session, context)
if session.get("status") != CoachingSessionStatus.ACTIVE.value: if session.get("status") != CoachingSessionStatus.ACTIVE.value:
@ -466,7 +468,7 @@ async def cancelSession(
session = interface.getSession(sessionId) session = interface.getSession(sessionId)
if not session: 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) _validateOwnership(session, context)
from modules.shared.timeUtils import getIsoTimestamp from modules.shared.timeUtils import getIsoTimestamp
@ -496,11 +498,11 @@ async def sendMessageStream(
session = interface.getSession(sessionId) session = interface.getSession(sessionId)
if not session: 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) _validateOwnership(session, context)
if session.get("status") != CoachingSessionStatus.ACTIVE.value: 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") contextId = session.get("contextId")
service = CommcoachService(context.user, mandateId, instanceId) service = CommcoachService(context.user, mandateId, instanceId)
@ -572,15 +574,15 @@ async def sendAudioStream(
session = interface.getSession(sessionId) session = interface.getSession(sessionId)
if not session: 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) _validateOwnership(session, context)
if session.get("status") != CoachingSessionStatus.ACTIVE.value: 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() audioBody = await request.body()
if not audioBody: 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 from .serviceCommcoach import _getUserVoicePrefs
language, _ = _getUserVoicePrefs(str(context.user.id), mandateId) language, _ = _getUserVoicePrefs(str(context.user.id), mandateId)
@ -640,7 +642,7 @@ async def streamSession(
session = interface.getSession(sessionId) session = interface.getSession(sessionId)
if not session: 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) _validateOwnership(session, context)
async def _eventGenerator(): async def _eventGenerator():
@ -708,7 +710,7 @@ async def createTask(
ctx = interface.getContext(contextId) ctx = interface.getContext(contextId)
if not ctx: 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) _validateOwnership(ctx, context)
taskData = CoachingTask( taskData = CoachingTask(
@ -739,7 +741,7 @@ async def updateTask(
task = interface.getTask(taskId) task = interface.getTask(taskId)
if not task: 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) _validateOwnership(task, context)
updates = body.model_dump(exclude_none=True) updates = body.model_dump(exclude_none=True)
@ -761,7 +763,7 @@ async def updateTaskStatus(
task = interface.getTask(taskId) task = interface.getTask(taskId)
if not task: 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) _validateOwnership(task, context)
updates = {"status": body.status.value} updates = {"status": body.status.value}
@ -786,7 +788,7 @@ async def deleteTask(
task = interface.getTask(taskId) task = interface.getTask(taskId)
if not task: 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) _validateOwnership(task, context)
interface.deleteTask(taskId) interface.deleteTask(taskId)
@ -867,7 +869,7 @@ async def exportDossier(
ctx = interface.getContext(contextId) ctx = interface.getContext(contextId)
if not ctx: 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) _validateOwnership(ctx, context)
tasks = interface.getTasks(contextId, userId) tasks = interface.getTasks(contextId, userId)
@ -902,7 +904,7 @@ async def exportSession(
session = interface.getSession(sessionId) session = interface.getSession(sessionId)
if not session: 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) _validateOwnership(session, context)
contextId = session.get("contextId") contextId = session.get("contextId")
@ -983,9 +985,9 @@ async def updatePersonaRoute(
persona = interface.getPersona(personaId) persona = interface.getPersona(personaId)
if not persona: 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": 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) _validateOwnership(persona, context)
updates = body.model_dump(exclude_none=True) updates = body.model_dump(exclude_none=True)
@ -1006,9 +1008,9 @@ async def deletePersonaRoute(
persona = interface.getPersona(personaId) persona = interface.getPersona(personaId)
if not persona: 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": 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) _validateOwnership(persona, context)
interface.deletePersona(personaId) interface.deletePersona(personaId)

View file

@ -17,9 +17,8 @@ class TestFeatureMetadata:
assert FEATURE_CODE == "commcoach" assert FEATURE_CODE == "commcoach"
def test_featureLabel(self): def test_featureLabel(self):
assert "de" in FEATURE_LABEL assert isinstance(FEATURE_LABEL, str)
assert "en" in FEATURE_LABEL assert "Coach" in FEATURE_LABEL
assert "Coach" in FEATURE_LABEL["de"]
def test_featureIcon(self): def test_featureIcon(self):
assert FEATURE_ICON.startswith("mdi-") assert FEATURE_ICON.startswith("mdi-")
@ -37,17 +36,17 @@ class TestFeatureDefinition:
class TestRbacObjects: class TestRbacObjects:
def test_uiObjectsExist(self): def test_uiObjectsExist(self):
objs = getUiObjects() objs = getUiObjects()
assert len(objs) >= 4 assert len(objs) >= 3
keys = [o["objectKey"] for o in objs] keys = [o["objectKey"] for o in objs]
assert "ui.feature.commcoach.dashboard" in keys assert "ui.feature.commcoach.dashboard" in keys
assert "ui.feature.commcoach.coaching" in keys assert "ui.feature.commcoach.coaching" in keys
assert "ui.feature.commcoach.dossier" in keys
assert "ui.feature.commcoach.settings" in keys assert "ui.feature.commcoach.settings" in keys
def test_uiObjectsHaveLabels(self): def test_uiObjectsHaveLabels(self):
for obj in getUiObjects(): for obj in getUiObjects():
assert "label" in obj assert "label" in obj
assert "de" in obj["label"] assert isinstance(obj["label"], str)
assert len(obj["label"]) > 0
def test_dataObjectsExist(self): def test_dataObjectsExist(self):
objs = getDataObjects() objs = getDataObjects()
@ -94,7 +93,7 @@ class TestTemplateRoles:
def test_roleHasDescription(self): def test_roleHasDescription(self):
for role in getTemplateRoles(): for role in getTemplateRoles():
assert "description" in role assert "description" in role
assert "de" in role["description"] assert isinstance(role["description"], str) and len(role["description"].strip()) > 0
def test_roleHasAccessRules(self): def test_roleHasAccessRules(self):
for role in getTemplateRoles(): for role in getTemplateRoles():

View file

@ -6,7 +6,7 @@ from enum import Enum
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
import uuid import uuid
@ -54,437 +54,341 @@ class AutoTemplateScope(str, Enum):
# AutoWorkflow # AutoWorkflow
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@i18nModel("Workflow")
class AutoWorkflow(PowerOnModel): class AutoWorkflow(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Primary key", 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( mandateId: str = Field(
description="Mandate ID", 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( featureInstanceId: str = Field(
description="Feature instance ID", 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( label: str = Field(
description="User-friendly workflow name", 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( description: Optional[str] = Field(
default=None, default=None,
description="Workflow description", 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( tags: List[str] = Field(
default_factory=list, default_factory=list,
description="Tags for categorization", 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( isTemplate: bool = Field(
default=False, default=False,
description="Whether this workflow is a template", 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( templateSourceId: Optional[str] = Field(
default=None, default=None,
description="ID of the template this workflow was created from", 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( templateScope: Optional[str] = Field(
default=None, default=None,
description="Template scope: user, instance, mandate, system (AutoTemplateScope)", 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( sharedReadOnly: bool = Field(
default=False, default=False,
description="If true, shared template is read-only for non-owners", 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( currentVersionId: Optional[str] = Field(
default=None, default=None,
description="ID of the currently published AutoVersion", 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( active: bool = Field(
default=True, default=True,
description="Whether workflow is active", 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( eventId: Optional[str] = Field(
default=None, default=None,
description="Scheduler event ID for incremental sync", 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( notifyOnFailure: bool = Field(
default=True, default=True,
description="Send notification (in-app + email) when a run fails", 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 # Legacy fields kept for backward compatibility during transition
graph: Dict[str, Any] = Field( graph: Dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)", 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( invocations: List[Dict[str, Any]] = Field(
default_factory=list, default_factory=list,
description="Entry points / starts (manual, form, schedule, webhook, ...)", 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 # AutoVersion
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@i18nModel("Workflow-Version")
class AutoVersion(PowerOnModel): class AutoVersion(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Primary key", 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( workflowId: str = Field(
description="FK -> AutoWorkflow", 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( versionNumber: int = Field(
default=1, default=1,
description="Incrementing version number", 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( status: str = Field(
default=AutoWorkflowStatus.DRAFT.value, default=AutoWorkflowStatus.DRAFT.value,
description="Version status: draft, published, archived", 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( graph: Dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
description="Graph with nodes and connections (incl. node parameters)", 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( invocations: List[Dict[str, Any]] = Field(
default_factory=list, default_factory=list,
description="Entry points / starts for this version", 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( publishedAt: Optional[float] = Field(
default=None, default=None,
description="Timestamp when version was published", 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( publishedBy: Optional[str] = Field(
default=None, default=None,
description="User ID who published this version", 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 # AutoRun
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@i18nModel("Workflow-Ausführung")
class AutoRun(PowerOnModel): class AutoRun(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Primary key", 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( workflowId: str = Field(
description="Workflow ID", 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( mandateId: Optional[str] = Field(
default=None, default=None,
description="Mandate ID for cross-feature querying", 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( ownerId: Optional[str] = Field(
default=None, default=None,
description="User ID who triggered this run", 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( versionId: Optional[str] = Field(
default=None, default=None,
description="AutoVersion ID used for this run", 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( status: str = Field(
default=AutoRunStatus.RUNNING.value, default=AutoRunStatus.RUNNING.value,
description="Status: running, paused, completed, failed, cancelled", 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( trigger: Dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
description="Trigger info (type, entryPointId, payload, etc.)", 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( startedAt: Optional[float] = Field(
default=None, default=None,
description="Run start timestamp", 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( completedAt: Optional[float] = Field(
default=None, default=None,
description="Run completion timestamp", 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( nodeOutputs: Dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
description="Outputs from executed nodes", 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( currentNodeId: Optional[str] = Field(
default=None, default=None,
description="Node ID when paused (human task / email wait)", 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( resumeContext: Dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
description="Context for resume (connectionMap, inputSources, etc.)", 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( error: Optional[str] = Field(
default=None, default=None,
description="Error message if failed", 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( costTokens: int = Field(
default=0, default=0,
description="Total tokens consumed by AI nodes", 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( costCredits: float = Field(
default=0.0, default=0.0,
description="Total credits consumed", 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 # AutoStepLog
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@i18nModel("Schritt-Protokoll")
class AutoStepLog(PowerOnModel): class AutoStepLog(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Primary key", 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( runId: str = Field(
description="FK -> AutoRun", 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( nodeId: str = Field(
description="Node ID in the graph", 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( nodeType: str = Field(
description="Node type (e.g. ai.chat, email.send)", 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( status: str = Field(
default=AutoStepStatus.PENDING.value, default=AutoStepStatus.PENDING.value,
description="Step status: pending, running, completed, failed, skipped", 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( inputSnapshot: Dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
description="Snapshot of inputs at execution time", 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( output: Dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
description="Node output", 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( error: Optional[str] = Field(
default=None, default=None,
description="Error message if step failed", 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( startedAt: Optional[float] = Field(
default=None, default=None,
description="Step start timestamp", 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( completedAt: Optional[float] = Field(
default=None, default=None,
description="Step completion timestamp", 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( durationMs: Optional[int] = Field(
default=None, default=None,
description="Execution duration in milliseconds", 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( tokensUsed: int = Field(
default=0, default=0,
description="Tokens consumed by this step", 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( retryCount: int = Field(
default=0, default=0,
description="Number of retries executed", 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 # AutoTask
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@i18nModel("Aufgabe")
class AutoTask(PowerOnModel): class AutoTask(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Primary key", 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( runId: str = Field(
description="FK -> AutoRun", 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( workflowId: str = Field(
description="Workflow ID", 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( nodeId: str = Field(
description="Node ID in the graph", 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( nodeType: str = Field(
description="Node type: form, approval, upload, comment, review, selection, confirmation", 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( config: Dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
description="Node config (form schema, approval text, etc.)", 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( assigneeId: Optional[str] = Field(
default=None, default=None,
description="User ID assigned to complete the task", 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( status: str = Field(
default=AutoTaskStatus.PENDING.value, default=AutoTaskStatus.PENDING.value,
description="Status: pending, completed, cancelled, expired", 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( result: Optional[Dict[str, Any]] = Field(
default=None, default=None,
description="Task result (form data, approval decision, etc.)", 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( expiresAt: Optional[float] = Field(
default=None, default=None,
description="Expiration timestamp for the task", 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 # Backward-compatible aliases for transition period
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -30,22 +30,18 @@ def default_manual_entry_point() -> Dict[str, Any]:
"kind": "manual", "kind": "manual",
"category": "on_demand", "category": "on_demand",
"enabled": True, "enabled": True,
"title": { "title": "Jetzt ausführen",
"de": "Jetzt ausführen",
"en": "Run now",
"fr": "Exécuter",
},
"description": {}, "description": {},
"config": {}, "config": {},
} }
def _normalize_title(title: Any) -> Dict[str, str]: def _normalize_title(title: Any) -> str:
if isinstance(title, dict): 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(): if isinstance(title, str) and title.strip():
return {"de": title, "en": title, "fr": title} return title.strip()
return {"de": "Start", "en": "Start", "fr": "Départ"} return "Start"
def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]: def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]:

View file

@ -21,28 +21,28 @@ REQUIRED_SERVICES = [
{"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}}, {"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}},
{"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}}, {"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" FEATURE_ICON = "mdi-sitemap"
UI_OBJECTS = [ UI_OBJECTS = [
{ {
"objectKey": "ui.feature.graphicalEditor.editor", "objectKey": "ui.feature.graphicalEditor.editor",
"label": {"en": "Editor", "de": "Editor", "fr": "Éditeur"}, "label": "Editor",
"meta": {"area": "editor"} "meta": {"area": "editor"}
}, },
{ {
"objectKey": "ui.feature.graphicalEditor.workflows", "objectKey": "ui.feature.graphicalEditor.workflows",
"label": {"en": "Workflows", "de": "Workflows", "fr": "Workflows"}, "label": "Workflows",
"meta": {"area": "workflows"} "meta": {"area": "workflows"}
}, },
{ {
"objectKey": "ui.feature.graphicalEditor.templates", "objectKey": "ui.feature.graphicalEditor.templates",
"label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"}, "label": "Vorlagen",
"meta": {"area": "templates"} "meta": {"area": "templates"}
}, },
{ {
"objectKey": "ui.feature.graphicalEditor.workflows-tasks", "objectKey": "ui.feature.graphicalEditor.workflows-tasks",
"label": {"en": "Tasks", "de": "Tasks", "fr": "Tâches"}, "label": "Tasks",
"meta": {"area": "tasks"} "meta": {"area": "tasks"}
}, },
] ]
@ -50,17 +50,17 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [ RESOURCE_OBJECTS = [
{ {
"objectKey": "resource.feature.graphicalEditor.dashboard", "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"} "meta": {"endpoint": "/api/workflows/{instanceId}/info", "method": "GET"}
}, },
{ {
"objectKey": "resource.feature.graphicalEditor.node-types", "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"} "meta": {"endpoint": "/api/workflows/{instanceId}/node-types", "method": "GET"}
}, },
{ {
"objectKey": "resource.feature.graphicalEditor.execute", "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"} "meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"}
}, },
] ]
@ -68,11 +68,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{ {
"roleLabel": "graphicalEditor-viewer", "roleLabel": "graphicalEditor-viewer",
"description": { "description": "Grafischer Editor Betrachter - Workflows ansehen (nur lesen)",
"en": "GraphicalEditor Viewer - View workflows (read-only)",
"de": "Grafischer Editor Betrachter - Workflows ansehen (nur lesen)",
"fr": "Visualiseur Éditeur graphique - Consulter les workflows (lecture seule)",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True}, {"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True}, {"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True},
@ -82,11 +78,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "graphicalEditor-user", "roleLabel": "graphicalEditor-user",
"description": { "description": "Grafischer Editor Benutzer - Flow-Builder nutzen",
"en": "GraphicalEditor User - Use flow builder",
"de": "Grafischer Editor Benutzer - Flow-Builder nutzen",
"fr": "Utilisateur Éditeur graphique - Utiliser le flow builder",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.graphicalEditor.editor", "view": True}, {"context": "UI", "item": "ui.feature.graphicalEditor.editor", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True}, {"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True},
@ -100,11 +92,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "graphicalEditor-admin", "roleLabel": "graphicalEditor-admin",
"description": { "description": "Grafischer Editor Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)",
"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)",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True}, {"context": "RESOURCE", "item": None, "view": True},
@ -272,6 +260,7 @@ def _syncTemplateRolesToDb() -> int:
try: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface() rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
@ -285,7 +274,7 @@ def _syncTemplateRolesToDb() -> int:
else: else:
newRole = Role( newRole = Role(
roleLabel=roleLabel, roleLabel=roleLabel,
description=template.get("description", {}), description=coerce_text_multilingual(template.get("description", {})),
featureCode=FEATURE_CODE, featureCode=FEATURE_CODE,
mandateId=None, mandateId=None,
featureInstanceId=None, featureInstanceId=None,

View file

@ -5,14 +5,14 @@ AI_NODES = [
{ {
"id": "ai.prompt", "id": "ai.prompt",
"category": "ai", "category": "ai",
"label": {"en": "Prompt", "de": "Prompt", "fr": "Invite"}, "label": "Prompt",
"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"}, "description": "Prompt eingeben und KI führt aus",
"parameters": [ "parameters": [
{"name": "aiPrompt", "type": "string", "required": True, "frontendType": "textarea", {"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", {"name": "outputFormat", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["text", "json", "emailDraft"]}, "frontendOptions": {"options": ["text", "json", "emailDraft"]},
"description": {"en": "Output format", "de": "Ausgabeformat", "fr": "Format de sortie"}, "default": "text"}, "description": "Ausgabeformat", "default": "text"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -25,11 +25,11 @@ AI_NODES = [
{ {
"id": "ai.webResearch", "id": "ai.webResearch",
"category": "ai", "category": "ai",
"label": {"en": "Web Research", "de": "Web-Recherche", "fr": "Recherche web"}, "label": "Web-Recherche",
"description": {"en": "Research on the web", "de": "Recherche im Web", "fr": "Recherche sur le web"}, "description": "Recherche im Web",
"parameters": [ "parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -42,12 +42,12 @@ AI_NODES = [
{ {
"id": "ai.summarizeDocument", "id": "ai.summarizeDocument",
"category": "ai", "category": "ai",
"label": {"en": "Summarize Document", "de": "Dokument zusammenfassen", "fr": "Résumer document"}, "label": "Dokument zusammenfassen",
"description": {"en": "Summarize document content", "de": "Dokumentinhalt zusammenfassen", "fr": "Résumer le contenu du document"}, "description": "Dokumentinhalt zusammenfassen",
"parameters": [ "parameters": [
{"name": "summaryLength", "type": "string", "required": False, "frontendType": "select", {"name": "summaryLength", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["short", "medium", "long"]}, "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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -60,12 +60,12 @@ AI_NODES = [
{ {
"id": "ai.translateDocument", "id": "ai.translateDocument",
"category": "ai", "category": "ai",
"label": {"en": "Translate Document", "de": "Dokument übersetzen", "fr": "Traduire document"}, "label": "Dokument übersetzen",
"description": {"en": "Translate document to target language", "de": "Dokument in Zielsprache übersetzen", "fr": "Traduire le document"}, "description": "Dokument in Zielsprache übersetzen",
"parameters": [ "parameters": [
{"name": "targetLanguage", "type": "string", "required": True, "frontendType": "select", {"name": "targetLanguage", "type": "string", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["en", "de", "fr", "it", "es", "pt", "nl"]}, "frontendOptions": {"options": ["en", "de", "fr", "it", "es", "pt", "nl"]},
"description": {"en": "Target language", "de": "Zielsprache", "fr": "Langue cible"}}, "description": "Zielsprache"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -78,12 +78,12 @@ AI_NODES = [
{ {
"id": "ai.convertDocument", "id": "ai.convertDocument",
"category": "ai", "category": "ai",
"label": {"en": "Convert Document", "de": "Dokument konvertieren", "fr": "Convertir document"}, "label": "Dokument konvertieren",
"description": {"en": "Convert document to another format", "de": "Dokument in anderes Format konvertieren", "fr": "Convertir le document"}, "description": "Dokument in anderes Format konvertieren",
"parameters": [ "parameters": [
{"name": "targetFormat", "type": "string", "required": True, "frontendType": "select", {"name": "targetFormat", "type": "string", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["pdf", "docx", "txt", "html", "md"]}, "frontendOptions": {"options": ["pdf", "docx", "txt", "html", "md"]},
"description": {"en": "Target format", "de": "Zielformat", "fr": "Format cible"}}, "description": "Zielformat"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -96,11 +96,11 @@ AI_NODES = [
{ {
"id": "ai.generateDocument", "id": "ai.generateDocument",
"category": "ai", "category": "ai",
"label": {"en": "Generate Document", "de": "Dokument generieren", "fr": "Générer document"}, "label": "Dokument generieren",
"description": {"en": "Generate document from prompt", "de": "Dokument aus Prompt generieren", "fr": "Générer un document"}, "description": "Dokument aus Prompt generieren",
"parameters": [ "parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -113,14 +113,14 @@ AI_NODES = [
{ {
"id": "ai.generateCode", "id": "ai.generateCode",
"category": "ai", "category": "ai",
"label": {"en": "Generate Code", "de": "Code generieren", "fr": "Générer code"}, "label": "Code generieren",
"description": {"en": "Generate code from description", "de": "Code aus Beschreibung generieren", "fr": "Générer du code"}, "description": "Code aus Beschreibung generieren",
"parameters": [ "parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", {"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", {"name": "language", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["python", "javascript", "typescript", "java", "csharp", "go"]}, "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, "inputs": 1,
"outputs": 1, "outputs": 1,

View file

@ -6,26 +6,26 @@ CLICKUP_NODES = [
{ {
"id": "clickup.searchTasks", "id": "clickup.searchTasks",
"category": "clickup", "category": "clickup",
"label": {"en": "Search tasks", "de": "Aufgaben suchen", "fr": "Rechercher tâches"}, "label": "Aufgaben suchen",
"description": {"en": "Search tasks in a workspace", "de": "Aufgaben in einem Workspace suchen", "fr": "Rechercher des tâches"}, "description": "Aufgaben in einem Workspace suchen",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"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", {"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", {"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", {"name": "listId", "type": "string", "required": False, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"}, "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", {"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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -38,18 +38,18 @@ CLICKUP_NODES = [
{ {
"id": "clickup.listTasks", "id": "clickup.listTasks",
"category": "clickup", "category": "clickup",
"label": {"en": "List tasks", "de": "Aufgaben auflisten", "fr": "Lister les tâches"}, "label": "Aufgaben auflisten",
"description": {"en": "List tasks in a list", "de": "Aufgaben einer Liste auflisten", "fr": "Lister les tâches"}, "description": "Aufgaben einer Liste auflisten",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"name": "pathQuery", "type": "string", "required": True, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"}, "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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -62,15 +62,15 @@ CLICKUP_NODES = [
{ {
"id": "clickup.getTask", "id": "clickup.getTask",
"category": "clickup", "category": "clickup",
"label": {"en": "Get task", "de": "Aufgabe abrufen", "fr": "Obtenir la tâche"}, "label": "Aufgabe abrufen",
"description": {"en": "Get one task by ID or path", "de": "Eine Aufgabe abrufen", "fr": "Obtenir une tâche"}, "description": "Eine Aufgabe abrufen",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -83,39 +83,39 @@ CLICKUP_NODES = [
{ {
"id": "clickup.createTask", "id": "clickup.createTask",
"category": "clickup", "category": "clickup",
"label": {"en": "Create task", "de": "Aufgabe erstellen", "fr": "Créer une tâche"}, "label": "Aufgabe erstellen",
"description": {"en": "Create a task in a list", "de": "Aufgabe erstellen", "fr": "Créer une tâche"}, "description": "Aufgabe erstellen",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"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", {"name": "pathQuery", "type": "string", "required": False, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"}, "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", {"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", {"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", {"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", {"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", {"name": "taskPriority", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["1", "2", "3", "4"]}, "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", {"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", {"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", {"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", {"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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -128,19 +128,19 @@ CLICKUP_NODES = [
{ {
"id": "clickup.updateTask", "id": "clickup.updateTask",
"category": "clickup", "category": "clickup",
"label": {"en": "Update task", "de": "Aufgabe aktualisieren", "fr": "Mettre à jour la tâche"}, "label": "Aufgabe aktualisieren",
"description": {"en": "Update task fields", "de": "Felder der Aufgabe ändern", "fr": "Mettre à jour les champs"}, "description": "Felder der Aufgabe ändern",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"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", {"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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -153,17 +153,17 @@ CLICKUP_NODES = [
{ {
"id": "clickup.uploadAttachment", "id": "clickup.uploadAttachment",
"category": "clickup", "category": "clickup",
"label": {"en": "Upload attachment", "de": "Anhang hochladen", "fr": "Téléverser pièce jointe"}, "label": "Anhang hochladen",
"description": {"en": "Upload file to a task", "de": "Datei an Task anhängen", "fr": "Joindre un fichier"}, "description": "Datei an Task anhängen",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"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", {"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", {"name": "fileName", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "File name", "de": "Dateiname", "fr": "Nom du fichier"}}, "description": "Dateiname"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,

View file

@ -5,12 +5,12 @@ DATA_NODES = [
{ {
"id": "data.aggregate", "id": "data.aggregate",
"category": "data", "category": "data",
"label": {"en": "Aggregate", "de": "Sammeln", "fr": "Agréger"}, "label": "Sammeln",
"description": {"en": "Collect results from loop iterations", "de": "Ergebnisse aus Schleifen-Iterationen sammeln", "fr": "Collecter les résultats des itérations"}, "description": "Ergebnisse aus Schleifen-Iterationen sammeln",
"parameters": [ "parameters": [
{"name": "mode", "type": "string", "required": False, "frontendType": "select", {"name": "mode", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["collect", "concat", "sum", "count"]}, "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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -22,11 +22,11 @@ DATA_NODES = [
{ {
"id": "data.transform", "id": "data.transform",
"category": "data", "category": "data",
"label": {"en": "Transform", "de": "Umwandeln", "fr": "Transformer"}, "label": "Umwandeln",
"description": {"en": "Map and restructure data", "de": "Daten umstrukturieren", "fr": "Restructurer les données"}, "description": "Daten umstrukturieren",
"parameters": [ "parameters": [
{"name": "mappings", "type": "json", "required": True, "frontendType": "mappingTable", {"name": "mappings", "type": "json", "required": True, "frontendType": "mappingTable",
"description": {"en": "Field mappings", "de": "Feld-Zuordnungen", "fr": "Correspondances"}, "default": []}, "description": "Feld-Zuordnungen", "default": []},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -38,11 +38,11 @@ DATA_NODES = [
{ {
"id": "data.filter", "id": "data.filter",
"category": "data", "category": "data",
"label": {"en": "Filter", "de": "Filtern", "fr": "Filtrer"}, "label": "Filtern",
"description": {"en": "Filter items by condition", "de": "Elemente nach Bedingung filtern", "fr": "Filtrer par condition"}, "description": "Elemente nach Bedingung filtern",
"parameters": [ "parameters": [
{"name": "condition", "type": "string", "required": True, "frontendType": "filterExpression", {"name": "condition", "type": "string", "required": True, "frontendType": "filterExpression",
"description": {"en": "Filter condition", "de": "Filterbedingung", "fr": "Condition de filtre"}}, "description": "Filterbedingung"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,

View file

@ -5,23 +5,23 @@ EMAIL_NODES = [
{ {
"id": "email.checkEmail", "id": "email.checkEmail",
"category": "email", "category": "email",
"label": {"en": "Check Email", "de": "E-Mail prüfen", "fr": "Vérifier email"}, "label": "E-Mail prüfen",
"description": {"en": "Check for new emails", "de": "Neue E-Mails prüfen", "fr": "Vérifier les nouveaux emails"}, "description": "Neue E-Mails prüfen",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"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", {"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", {"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", {"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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -34,29 +34,29 @@ EMAIL_NODES = [
{ {
"id": "email.searchEmail", "id": "email.searchEmail",
"category": "email", "category": "email",
"label": {"en": "Search Email", "de": "E-Mail suchen", "fr": "Rechercher email"}, "label": "E-Mail suchen",
"description": {"en": "Search or find emails", "de": "E-Mails suchen", "fr": "Rechercher des emails"}, "description": "E-Mails suchen",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"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", {"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", {"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", {"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", {"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", {"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", {"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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -69,17 +69,17 @@ EMAIL_NODES = [
{ {
"id": "email.draftEmail", "id": "email.draftEmail",
"category": "email", "category": "email",
"label": {"en": "Draft Email", "de": "E-Mail entwerfen", "fr": "Brouillon email"}, "label": "E-Mail entwerfen",
"description": {"en": "Create a draft email", "de": "E-Mail-Entwurf erstellen", "fr": "Créer un brouillon"}, "description": "E-Mail-Entwurf erstellen",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,

View file

@ -5,26 +5,22 @@ FILE_NODES = [
{ {
"id": "file.create", "id": "file.create",
"category": "file", "category": "file",
"label": {"en": "Create File", "de": "Datei erstellen", "fr": "Créer fichier"}, "label": "Datei erstellen",
"description": { "description": "Erstellt eine Datei aus Kontext (Text/Markdown von KI).",
"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.",
},
"parameters": [ "parameters": [
{"name": "contentSources", "type": "json", "required": False, "frontendType": "json", {"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", {"name": "outputFormat", "type": "string", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]}, "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", {"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", {"name": "templateName", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["default", "corporate", "minimal"]}, "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", {"name": "language", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["de", "en", "fr"]}, "frontendOptions": {"options": ["de", "en", "fr"]},
"description": {"en": "Language", "de": "Sprache", "fr": "Langue"}, "default": "de"}, "description": "Sprache", "default": "de"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,

View file

@ -5,20 +5,20 @@ FLOW_NODES = [
{ {
"id": "flow.ifElse", "id": "flow.ifElse",
"category": "flow", "category": "flow",
"label": {"en": "If / Else", "de": "Wenn / Sonst", "fr": "Si / Sinon"}, "label": "Wenn / Sonst",
"description": {"en": "Branch based on condition", "de": "Verzweigung nach Bedingung", "fr": "Branche selon condition"}, "description": "Verzweigung nach Bedingung",
"parameters": [ "parameters": [
{ {
"name": "condition", "name": "condition",
"type": "string", "type": "string",
"required": True, "required": True,
"frontendType": "condition", "frontendType": "condition",
"description": {"en": "Condition to evaluate", "de": "Bedingung", "fr": "Condition"}, "description": "Bedingung",
}, },
], ],
"inputs": 1, "inputs": 1,
"outputs": 2, "outputs": 2,
"outputLabels": {"en": ["Yes", "No"], "de": ["Ja", "Nein"], "fr": ["Oui", "Non"]}, "outputLabels": ["Ja", "Nein"],
"inputPorts": {0: {"accepts": ["Transit"]}}, "inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}}, "outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}},
"executor": "flow", "executor": "flow",
@ -27,22 +27,22 @@ FLOW_NODES = [
{ {
"id": "flow.switch", "id": "flow.switch",
"category": "flow", "category": "flow",
"label": {"en": "Switch", "de": "Switch", "fr": "Switch"}, "label": "Switch",
"description": {"en": "Multiple branches based on value", "de": "Mehrere Zweige nach Wert", "fr": "Branches multiples selon valeur"}, "description": "Mehrere Zweige nach Wert",
"parameters": [ "parameters": [
{ {
"name": "value", "name": "value",
"type": "string", "type": "string",
"required": True, "required": True,
"frontendType": "text", "frontendType": "text",
"description": {"en": "Value to match", "de": "Zu vergleichender Wert", "fr": "Valeur à comparer"}, "description": "Zu vergleichender Wert",
}, },
{ {
"name": "cases", "name": "cases",
"type": "array", "type": "array",
"required": False, "required": False,
"frontendType": "caseList", "frontendType": "caseList",
"description": {"en": "List of cases", "de": "Fälle", "fr": "Cas"}, "description": "Fälle",
}, },
], ],
"inputs": 1, "inputs": 1,
@ -55,15 +55,15 @@ FLOW_NODES = [
{ {
"id": "flow.loop", "id": "flow.loop",
"category": "flow", "category": "flow",
"label": {"en": "Loop / For Each", "de": "Schleife / Für Jedes", "fr": "Boucle / Pour Chaque"}, "label": "Schleife / Für Jedes",
"description": {"en": "Iterate over array items", "de": "Über Array-Elemente iterieren", "fr": "Itérer sur les éléments"}, "description": "Über Array-Elemente iterieren",
"parameters": [ "parameters": [
{ {
"name": "items", "name": "items",
"type": "string", "type": "string",
"required": True, "required": True,
"frontendType": "text", "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, "inputs": 1,
@ -76,8 +76,8 @@ FLOW_NODES = [
{ {
"id": "flow.merge", "id": "flow.merge",
"category": "flow", "category": "flow",
"label": {"en": "Merge", "de": "Zusammenführen", "fr": "Fusionner"}, "label": "Zusammenführen",
"description": {"en": "Merge multiple branches", "de": "Mehrere Zweige zusammenführen", "fr": "Fusionner plusieurs branches"}, "description": "Mehrere Zweige zusammenführen",
"parameters": [ "parameters": [
{ {
"name": "mode", "name": "mode",
@ -85,7 +85,7 @@ FLOW_NODES = [
"required": False, "required": False,
"frontendType": "select", "frontendType": "select",
"frontendOptions": {"options": ["first", "all", "append"]}, "frontendOptions": {"options": ["first", "all", "append"]},
"description": {"en": "Merge mode", "de": "Zusammenführungsmodus", "fr": "Mode de fusion"}, "description": "Zusammenführungsmodus",
"default": "first", "default": "first",
}, },
], ],

View file

@ -5,19 +5,15 @@ INPUT_NODES = [
{ {
"id": "input.form", "id": "input.form",
"category": "input", "category": "input",
"label": {"en": "Form", "de": "Formular", "fr": "Formulaire"}, "label": "Formular",
"description": {"en": "User fills out a form", "de": "Benutzer füllt ein Formular aus", "fr": "L'utilisateur remplit un formulaire"}, "description": "Benutzer füllt ein Formular aus",
"parameters": [ "parameters": [
{ {
"name": "fields", "name": "fields",
"type": "json", "type": "json",
"required": True, "required": True,
"frontendType": "fieldBuilder", "frontendType": "fieldBuilder",
"description": { "description": "Formularfelder",
"en": "Form fields: [{name, type, label, required, options?}]",
"de": "Formularfelder",
"fr": "Champs du formulaire",
},
"default": [], "default": [],
}, },
], ],
@ -31,16 +27,16 @@ INPUT_NODES = [
{ {
"id": "input.approval", "id": "input.approval",
"category": "input", "category": "input",
"label": {"en": "Approval", "de": "Genehmigung", "fr": "Approbation"}, "label": "Genehmigung",
"description": {"en": "User approves or rejects", "de": "Benutzer genehmigt oder lehnt ab", "fr": "L'utilisateur approuve ou rejette"}, "description": "Benutzer genehmigt oder lehnt ab",
"parameters": [ "parameters": [
{"name": "title", "type": "string", "required": True, "frontendType": "text", {"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", {"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", {"name": "approvalType", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["generic", "document"]}, "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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -52,18 +48,18 @@ INPUT_NODES = [
{ {
"id": "input.upload", "id": "input.upload",
"category": "input", "category": "input",
"label": {"en": "Upload", "de": "Upload", "fr": "Téléversement"}, "label": "Upload",
"description": {"en": "User uploads file(s)", "de": "Benutzer lädt Datei(en) hoch", "fr": "L'utilisateur téléverse des fichiers"}, "description": "Benutzer lädt Datei(en) hoch",
"parameters": [ "parameters": [
{"name": "accept", "type": "string", "required": False, "frontendType": "text", {"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", {"name": "allowedTypes", "type": "json", "required": False, "frontendType": "multiselect",
"frontendOptions": {"options": ["pdf", "docx", "xlsx", "pptx", "txt", "csv", "jpg", "png", "gif"]}, "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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -75,13 +71,13 @@ INPUT_NODES = [
{ {
"id": "input.comment", "id": "input.comment",
"category": "input", "category": "input",
"label": {"en": "Comment", "de": "Kommentar", "fr": "Commentaire"}, "label": "Kommentar",
"description": {"en": "User adds a comment", "de": "Benutzer fügt einen Kommentar hinzu", "fr": "L'utilisateur ajoute un commentaire"}, "description": "Benutzer fügt einen Kommentar hinzu",
"parameters": [ "parameters": [
{"name": "placeholder", "type": "string", "required": False, "frontendType": "text", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -93,14 +89,14 @@ INPUT_NODES = [
{ {
"id": "input.review", "id": "input.review",
"category": "input", "category": "input",
"label": {"en": "Review", "de": "Prüfung", "fr": "Revue"}, "label": "Prüfung",
"description": {"en": "User reviews content", "de": "Benutzer prüft Inhalt", "fr": "L'utilisateur révise le contenu"}, "description": "Benutzer prüft Inhalt",
"parameters": [ "parameters": [
{"name": "contentRef", "type": "string", "required": True, "frontendType": "text", {"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", {"name": "reviewType", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["generic", "document"]}, "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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -112,13 +108,13 @@ INPUT_NODES = [
{ {
"id": "input.selection", "id": "input.selection",
"category": "input", "category": "input",
"label": {"en": "Selection", "de": "Auswahl", "fr": "Sélection"}, "label": "Auswahl",
"description": {"en": "User selects from options", "de": "Benutzer wählt aus Optionen", "fr": "L'utilisateur choisit parmi les options"}, "description": "Benutzer wählt aus Optionen",
"parameters": [ "parameters": [
{"name": "options", "type": "json", "required": True, "frontendType": "keyValueRows", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -130,15 +126,15 @@ INPUT_NODES = [
{ {
"id": "input.confirmation", "id": "input.confirmation",
"category": "input", "category": "input",
"label": {"en": "Confirmation", "de": "Bestätigung", "fr": "Confirmation"}, "label": "Bestätigung",
"description": {"en": "User confirms yes/no", "de": "Benutzer bestätigt Ja/Nein", "fr": "L'utilisateur confirme oui/non"}, "description": "Benutzer bestätigt Ja/Nein",
"parameters": [ "parameters": [
{"name": "question", "type": "string", "required": True, "frontendType": "text", {"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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,

View file

@ -5,17 +5,17 @@ SHAREPOINT_NODES = [
{ {
"id": "sharepoint.findFile", "id": "sharepoint.findFile",
"category": "sharepoint", "category": "sharepoint",
"label": {"en": "Find File", "de": "Datei finden", "fr": "Trouver fichier"}, "label": "Datei finden",
"description": {"en": "Find file by path or search", "de": "Datei nach Pfad oder Suche finden", "fr": "Trouver fichier par chemin ou recherche"}, "description": "Datei nach Pfad oder Suche finden",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -28,14 +28,14 @@ SHAREPOINT_NODES = [
{ {
"id": "sharepoint.readFile", "id": "sharepoint.readFile",
"category": "sharepoint", "category": "sharepoint",
"label": {"en": "Read File", "de": "Datei lesen", "fr": "Lire fichier"}, "label": "Datei lesen",
"description": {"en": "Extract content from file", "de": "Inhalt aus Datei extrahieren", "fr": "Extraire le contenu du fichier"}, "description": "Inhalt aus Datei extrahieren",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"}, "frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "File path", "de": "Dateipfad", "fr": "Chemin"}}, "description": "Dateipfad"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -48,14 +48,14 @@ SHAREPOINT_NODES = [
{ {
"id": "sharepoint.uploadFile", "id": "sharepoint.uploadFile",
"category": "sharepoint", "category": "sharepoint",
"label": {"en": "Upload File", "de": "Datei hochladen", "fr": "Téléverser fichier"}, "label": "Datei hochladen",
"description": {"en": "Upload file to SharePoint", "de": "Datei zu SharePoint hochladen", "fr": "Téléverser fichier vers SharePoint"}, "description": "Datei zu SharePoint hochladen",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"}, "frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Target folder path", "de": "Zielordner-Pfad", "fr": "Chemin du dossier cible"}}, "description": "Zielordner-Pfad"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -68,14 +68,14 @@ SHAREPOINT_NODES = [
{ {
"id": "sharepoint.listFiles", "id": "sharepoint.listFiles",
"category": "sharepoint", "category": "sharepoint",
"label": {"en": "List Files", "de": "Dateien auflisten", "fr": "Lister fichiers"}, "label": "Dateien auflisten",
"description": {"en": "List files in folder", "de": "Dateien in Ordner auflisten", "fr": "Lister les fichiers"}, "description": "Dateien in Ordner auflisten",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"name": "pathQuery", "type": "string", "required": False, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"}, "frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Folder path", "de": "Ordnerpfad", "fr": "Chemin du dossier"}, "default": "/"}, "description": "Ordnerpfad", "default": "/"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -88,14 +88,14 @@ SHAREPOINT_NODES = [
{ {
"id": "sharepoint.downloadFile", "id": "sharepoint.downloadFile",
"category": "sharepoint", "category": "sharepoint",
"label": {"en": "Download File", "de": "Datei herunterladen", "fr": "Télécharger fichier"}, "label": "Datei herunterladen",
"description": {"en": "Download file from path", "de": "Datei vom Pfad herunterladen", "fr": "Télécharger le fichier"}, "description": "Datei vom Pfad herunterladen",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"}, "frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Full file path", "de": "Vollständiger Dateipfad", "fr": "Chemin complet du fichier"}}, "description": "Vollständiger Dateipfad"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -108,17 +108,17 @@ SHAREPOINT_NODES = [
{ {
"id": "sharepoint.copyFile", "id": "sharepoint.copyFile",
"category": "sharepoint", "category": "sharepoint",
"label": {"en": "Copy File", "de": "Datei kopieren", "fr": "Copier fichier"}, "label": "Datei kopieren",
"description": {"en": "Copy file to destination", "de": "Datei an Ziel kopieren", "fr": "Copier le fichier"}, "description": "Datei an Ziel kopieren",
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", {"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", {"name": "sourcePath", "type": "string", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"}, "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", {"name": "destPath", "type": "string", "required": True, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"}, "frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Destination folder", "de": "Zielordner", "fr": "Dossier cible"}}, "description": "Zielordner"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,

View file

@ -5,12 +5,8 @@ TRIGGER_NODES = [
{ {
"id": "trigger.manual", "id": "trigger.manual",
"category": "trigger", "category": "trigger",
"label": {"en": "Start", "de": "Start", "fr": "Départ"}, "label": "Start",
"description": { "description": "Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …).",
"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.",
},
"parameters": [], "parameters": [],
"inputs": 0, "inputs": 0,
"outputs": 1, "outputs": 1,
@ -22,19 +18,15 @@ TRIGGER_NODES = [
{ {
"id": "trigger.form", "id": "trigger.form",
"category": "trigger", "category": "trigger",
"label": {"en": "Start (form)", "de": "Start (Formular)", "fr": "Départ (formulaire)"}, "label": "Start (Formular)",
"description": { "description": "Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node.",
"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.",
},
"parameters": [ "parameters": [
{ {
"name": "formFields", "name": "formFields",
"type": "json", "type": "json",
"required": False, "required": False,
"frontendType": "fieldBuilder", "frontendType": "fieldBuilder",
"description": {"en": "Field definitions", "de": "Felddefinitionen", "fr": "Définitions"}, "description": "Felddefinitionen",
}, },
], ],
"inputs": 0, "inputs": 0,
@ -47,19 +39,15 @@ TRIGGER_NODES = [
{ {
"id": "trigger.schedule", "id": "trigger.schedule",
"category": "trigger", "category": "trigger",
"label": {"en": "Start (schedule)", "de": "Start (Zeitplan)", "fr": "Départ (planification)"}, "label": "Start (Zeitplan)",
"description": { "description": "Cron-Ausdruck für geplante Läufe.",
"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.",
},
"parameters": [ "parameters": [
{ {
"name": "cron", "name": "cron",
"type": "string", "type": "string",
"required": False, "required": False,
"frontendType": "cron", "frontendType": "cron",
"description": {"en": "Cron expression", "de": "Cron-Ausdruck", "fr": "Expression cron"}, "description": "Cron-Ausdruck",
}, },
], ],
"inputs": 0, "inputs": 0,

View file

@ -5,21 +5,17 @@ TRUSTEE_NODES = [
{ {
"id": "trustee.refreshAccountingData", "id": "trustee.refreshAccountingData",
"category": "trustee", "category": "trustee",
"label": {"en": "Refresh Accounting Data", "de": "Buchhaltungsdaten aktualisieren", "fr": "Actualiser données comptables"}, "label": "Buchhaltungsdaten aktualisieren",
"description": { "description": "Buchhaltungsdaten aus externem System importieren/aktualisieren.",
"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.",
},
"parameters": [ "parameters": [
{"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden", {"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", {"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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -32,22 +28,18 @@ TRUSTEE_NODES = [
{ {
"id": "trustee.extractFromFiles", "id": "trustee.extractFromFiles",
"category": "trustee", "category": "trustee",
"label": {"en": "Extract Documents", "de": "Dokumente extrahieren", "fr": "Extraire documents"}, "label": "Dokumente extrahieren",
"description": { "description": "Dokumenttyp und Daten aus PDF/JPG per AI extrahieren.",
"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.",
},
"parameters": [ "parameters": [
{"name": "connectionReference", "type": "string", "required": False, "frontendType": "userConnection", {"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", {"name": "sharepointFolder", "type": "string", "required": False, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"}, "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", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -60,17 +52,13 @@ TRUSTEE_NODES = [
{ {
"id": "trustee.processDocuments", "id": "trustee.processDocuments",
"category": "trustee", "category": "trustee",
"label": {"en": "Process Documents", "de": "Dokumente verarbeiten", "fr": "Traiter documents"}, "label": "Dokumente verarbeiten",
"description": { "description": "TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen.",
"en": "Create TrusteeDocument + TrusteePosition from extraction result.",
"de": "TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen.",
"fr": "Créer TrusteeDocument + TrusteePosition.",
},
"parameters": [ "parameters": [
{"name": "documentList", "type": "string", "required": True, "frontendType": "text", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -83,17 +71,13 @@ TRUSTEE_NODES = [
{ {
"id": "trustee.syncToAccounting", "id": "trustee.syncToAccounting",
"category": "trustee", "category": "trustee",
"label": {"en": "Sync to Accounting", "de": "In Buchhaltung synchronisieren", "fr": "Synchroniser comptabilité"}, "label": "In Buchhaltung synchronisieren",
"description": { "description": "Trustee-Positionen in Buchhaltungssystem übertragen.",
"en": "Push trustee positions to accounting system.",
"de": "Trustee-Positionen in Buchhaltungssystem übertragen.",
"fr": "Transférer les positions vers la comptabilité.",
},
"parameters": [ "parameters": [
{"name": "documentList", "type": "string", "required": True, "frontendType": "text", {"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", {"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, "inputs": 1,
"outputs": 1, "outputs": 1,

View file

@ -61,16 +61,16 @@ def getNodeTypesForApi(
nodes = getNodeTypes(services, language) nodes = getNodeTypes(services, language)
localized = [_localizeNode(n, language) for n in nodes] localized = [_localizeNode(n, language) for n in nodes]
categories = [ categories = [
{"id": "trigger", "label": {"en": "Trigger", "de": "Trigger", "fr": "Déclencheur"}}, {"id": "trigger", "label": "Trigger"},
{"id": "input", "label": {"en": "Input/Human", "de": "Eingabe/Mensch", "fr": "Entrée/Humain"}}, {"id": "input", "label": "Eingabe/Mensch"},
{"id": "flow", "label": {"en": "Flow", "de": "Ablauf", "fr": "Flux"}}, {"id": "flow", "label": "Ablauf"},
{"id": "data", "label": {"en": "Data", "de": "Daten", "fr": "Données"}}, {"id": "data", "label": "Daten"},
{"id": "ai", "label": {"en": "AI", "de": "KI", "fr": "IA"}}, {"id": "ai", "label": "KI"},
{"id": "file", "label": {"en": "File", "de": "Datei", "fr": "Fichier"}}, {"id": "file", "label": "Datei"},
{"id": "email", "label": {"en": "Email", "de": "E-Mail", "fr": "Email"}}, {"id": "email", "label": "E-Mail"},
{"id": "sharepoint", "label": {"en": "SharePoint", "de": "SharePoint", "fr": "SharePoint"}}, {"id": "sharepoint", "label": "SharePoint"},
{"id": "clickup", "label": {"en": "ClickUp", "de": "ClickUp", "fr": "ClickUp"}}, {"id": "clickup", "label": "ClickUp"},
{"id": "trustee", "label": {"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"}}, {"id": "trustee", "label": "Treuhand"},
] ]
catalogSerialized = {} catalogSerialized = {}

View file

@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
class PortField(BaseModel): class PortField(BaseModel):
name: str name: str
type: str # str, int, bool, List[str], List[Document], Dict[str,Any] type: str # str, int, bool, List[str], List[Document], Dict[str,Any]
description: Dict[str, str] = {} # {en, de, fr} description: str = ""
required: bool = True required: bool = True
@ -57,97 +57,97 @@ class OutputPortDef(BaseModel):
PORT_TYPE_CATALOG: Dict[str, PortSchema] = { PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
"DocumentList": PortSchema(name="DocumentList", fields=[ "DocumentList": PortSchema(name="DocumentList", fields=[
PortField(name="documents", type="List[Document]", PortField(name="documents", type="List[Document]",
description={"en": "List of documents", "de": "Dokumentenliste", "fr": "Liste de documents"}), description="Dokumentenliste"),
]), ]),
"FileList": PortSchema(name="FileList", fields=[ "FileList": PortSchema(name="FileList", fields=[
PortField(name="files", type="List[File]", PortField(name="files", type="List[File]",
description={"en": "List of files", "de": "Dateiliste", "fr": "Liste de fichiers"}), description="Dateiliste"),
]), ]),
"EmailDraft": PortSchema(name="EmailDraft", fields=[ "EmailDraft": PortSchema(name="EmailDraft", fields=[
PortField(name="subject", type="str", PortField(name="subject", type="str",
description={"en": "Subject", "de": "Betreff", "fr": "Sujet"}), description="Betreff"),
PortField(name="body", type="str", PortField(name="body", type="str",
description={"en": "Body", "de": "Inhalt", "fr": "Corps"}), description="Inhalt"),
PortField(name="to", type="List[str]", 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, 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, 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=[ "EmailList": PortSchema(name="EmailList", fields=[
PortField(name="emails", type="List[Email]", PortField(name="emails", type="List[Email]",
description={"en": "Emails", "de": "E-Mails", "fr": "Emails"}), description="E-Mails"),
]), ]),
"TaskList": PortSchema(name="TaskList", fields=[ "TaskList": PortSchema(name="TaskList", fields=[
PortField(name="tasks", type="List[Task]", PortField(name="tasks", type="List[Task]",
description={"en": "Tasks", "de": "Aufgaben", "fr": "Tâches"}), description="Aufgaben"),
]), ]),
"TaskResult": PortSchema(name="TaskResult", fields=[ "TaskResult": PortSchema(name="TaskResult", fields=[
PortField(name="success", type="bool", PortField(name="success", type="bool",
description={"en": "Success", "de": "Erfolg", "fr": "Succès"}), description="Erfolg"),
PortField(name="taskId", type="str", PortField(name="taskId", type="str",
description={"en": "Task ID", "de": "Aufgaben-ID", "fr": "ID tâche"}), description="Aufgaben-ID"),
PortField(name="task", type="Dict", PortField(name="task", type="Dict",
description={"en": "Task data", "de": "Aufgabendaten", "fr": "Données tâche"}), description="Aufgabendaten"),
]), ]),
"FormPayload": PortSchema(name="FormPayload", fields=[ "FormPayload": PortSchema(name="FormPayload", fields=[
PortField(name="payload", type="Dict[str,Any]", PortField(name="payload", type="Dict[str,Any]",
description={"en": "Form data", "de": "Formulardaten", "fr": "Données formulaire"}), description="Formulardaten"),
]), ]),
"AiResult": PortSchema(name="AiResult", fields=[ "AiResult": PortSchema(name="AiResult", fields=[
PortField(name="prompt", type="str", PortField(name="prompt", type="str",
description={"en": "Prompt", "de": "Prompt", "fr": "Invite"}), description="Prompt"),
PortField(name="response", type="str", PortField(name="response", type="str",
description={"en": "Response text", "de": "Antworttext", "fr": "Texte réponse"}), description="Antworttext"),
PortField(name="responseData", type="Dict", required=False, 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", PortField(name="context", type="str",
description={"en": "Context", "de": "Kontext", "fr": "Contexte"}), description="Kontext"),
PortField(name="documents", type="List[Document]", PortField(name="documents", type="List[Document]",
description={"en": "Documents", "de": "Dokumente", "fr": "Documents"}), description="Dokumente"),
]), ]),
"BoolResult": PortSchema(name="BoolResult", fields=[ "BoolResult": PortSchema(name="BoolResult", fields=[
PortField(name="result", type="bool", PortField(name="result", type="bool",
description={"en": "Result", "de": "Ergebnis", "fr": "Résultat"}), description="Ergebnis"),
PortField(name="reason", type="str", required=False, PortField(name="reason", type="str", required=False,
description={"en": "Reason", "de": "Begründung", "fr": "Raison"}), description="Begründung"),
]), ]),
"TextResult": PortSchema(name="TextResult", fields=[ "TextResult": PortSchema(name="TextResult", fields=[
PortField(name="text", type="str", PortField(name="text", type="str",
description={"en": "Text", "de": "Text", "fr": "Texte"}), description="Text"),
]), ]),
"LoopItem": PortSchema(name="LoopItem", fields=[ "LoopItem": PortSchema(name="LoopItem", fields=[
PortField(name="currentItem", type="Any", PortField(name="currentItem", type="Any",
description={"en": "Current item", "de": "Aktuelles Element", "fr": "Élément courant"}), description="Aktuelles Element"),
PortField(name="currentIndex", type="int", PortField(name="currentIndex", type="int",
description={"en": "Current index", "de": "Aktueller Index", "fr": "Index courant"}), description="Aktueller Index"),
PortField(name="items", type="List[Any]", 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", PortField(name="count", type="int",
description={"en": "Total count", "de": "Gesamtanzahl", "fr": "Nombre total"}), description="Gesamtanzahl"),
]), ]),
"AggregateResult": PortSchema(name="AggregateResult", fields=[ "AggregateResult": PortSchema(name="AggregateResult", fields=[
PortField(name="items", type="List[Any]", 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", PortField(name="count", type="int",
description={"en": "Count", "de": "Anzahl", "fr": "Nombre"}), description="Anzahl"),
]), ]),
"MergeResult": PortSchema(name="MergeResult", fields=[ "MergeResult": PortSchema(name="MergeResult", fields=[
PortField(name="inputs", type="Dict[int,Any]", 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", 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", 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=[ "ActionResult": PortSchema(name="ActionResult", fields=[
PortField(name="success", type="bool", PortField(name="success", type="bool",
description={"en": "Success", "de": "Erfolg", "fr": "Succès"}), description="Erfolg"),
PortField(name="error", type="str", required=False, PortField(name="error", type="str", required=False,
description={"en": "Error", "de": "Fehler", "fr": "Erreur"}), description="Fehler"),
PortField(name="data", type="Dict", required=False, 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=[]), "Transit": PortSchema(name="Transit", fields=[]),
} }
@ -479,10 +479,16 @@ def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
portFields = [] portFields = []
for f in fields_param: for f in fields_param:
if isinstance(f, dict) and f.get("name"): 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( portFields.append(PortField(
name=f["name"], name=f["name"],
type=f.get("type", "str"), 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), required=f.get("required", False),
)) ))
return PortSchema(name="FormPayload_dynamic", fields=portFields) if portFields else None 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( portFields.append(PortField(
name=m["outputField"], name=m["outputField"],
type=m.get("type", "str"), 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 return PortSchema(name="Transform_dynamic", fields=portFields) if portFields else None

View file

@ -26,6 +26,8 @@ from modules.workflows.automation2.runEnvelope import (
normalize_run_envelope, normalize_run_envelope,
) )
from modules.features.graphicalEditor.entryPoints import find_invocation from modules.features.graphicalEditor.entryPoints import find_invocation
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -48,13 +50,13 @@ def _build_execute_run_envelope(
if not workflow: if not workflow:
raise HTTPException( raise HTTPException(
status_code=400, 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) inv = find_invocation(workflow, entry_point_id)
if not inv: 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): 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") kind = inv.get("kind", "manual")
trig_map = { trig_map = {
"manual": "manual", "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") raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found")
featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId) featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId)
if not featureAccess or not featureAccess.enabled: 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 "" return str(instance.mandateId) if instance.mandateId else ""
@ -327,7 +329,7 @@ def create_draft_version(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
version = iface.createDraftVersion(workflowId) version = iface.createDraftVersion(workflowId)
if not version: if not version:
raise HTTPException(status_code=404, detail="Workflow not found") raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
return version return version
@ -345,7 +347,7 @@ def publish_version(
userId = str(context.user.id) if context.user else None userId = str(context.user.id) if context.user else None
version = iface.publishVersion(versionId, userId=userId) version = iface.publishVersion(versionId, userId=userId)
if not version: 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 return version
@ -362,7 +364,7 @@ def unpublish_version(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
version = iface.unpublishVersion(versionId) version = iface.unpublishVersion(versionId)
if not version: 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 return version
@ -379,7 +381,7 @@ def archive_version(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
version = iface.archiveVersion(versionId) version = iface.archiveVersion(versionId)
if not version: if not version:
raise HTTPException(status_code=404, detail="Version not found") raise HTTPException(status_code=404, detail=routeApiMsg("Version not found"))
return version return version
@ -442,11 +444,11 @@ def create_template_from_workflow(
workflowId = body.get("workflowId") workflowId = body.get("workflowId")
scope = body.get("scope", "user") scope = body.get("scope", "user")
if not workflowId: 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) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
template = iface.createTemplateFromWorkflow(workflowId, scope=scope) template = iface.createTemplateFromWorkflow(workflowId, scope=scope)
if not template: if not template:
raise HTTPException(status_code=404, detail="Workflow not found") raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
return template return template
@ -463,7 +465,7 @@ def copy_template(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
workflow = iface.copyTemplateToUser(templateId) workflow = iface.copyTemplateToUser(templateId)
if not workflow: if not workflow:
raise HTTPException(status_code=404, detail="Template not found") raise HTTPException(status_code=404, detail=routeApiMsg("Template not found"))
return workflow return workflow
@ -480,11 +482,11 @@ def share_template(
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
scope = body.get("scope") scope = body.get("scope")
if not scope or scope not in ("user", "instance", "mandate", "system"): 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) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
template = iface.shareTemplate(templateId, scope=scope) template = iface.shareTemplate(templateId, scope=scope)
if not template: if not template:
raise HTTPException(status_code=404, detail="Template not found") raise HTTPException(status_code=404, detail=routeApiMsg("Template not found"))
return template return template
@ -506,12 +508,12 @@ async def post_editor_chat(
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
message = body.get("message", "") message = body.get("message", "")
if not 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) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
wf = iface.getWorkflow(workflowId) wf = iface.getWorkflow(workflowId)
if not wf: 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") userLanguage = body.get("userLanguage", "de")
conversationHistory = body.get("conversationHistory") or [] conversationHistory = body.get("conversationHistory") or []
@ -946,7 +948,7 @@ def get_workflow(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
wf = iface.getWorkflow(workflowId) wf = iface.getWorkflow(workflowId)
if not wf: if not wf:
raise HTTPException(status_code=404, detail="Workflow not found") raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
return wf return wf
@ -979,7 +981,7 @@ def update_workflow(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
updated = iface.updateWorkflow(workflowId, body) updated = iface.updateWorkflow(workflowId, body)
if not updated: if not updated:
raise HTTPException(status_code=404, detail="Workflow not found") raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
return updated return updated
@ -995,7 +997,7 @@ def delete_workflow(
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
if not iface.deleteWorkflow(workflowId): 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} return {"success": True}
@ -1015,20 +1017,20 @@ async def post_workflow_webhook(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
wf = iface.getWorkflow(workflowId) wf = iface.getWorkflow(workflowId)
if not wf or not wf.get("graph"): 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) inv = find_invocation(wf, entryPointId)
if not inv: 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": 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): 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 {} cfg = inv.get("config") or {}
secret = cfg.get("webhookSecret") secret = cfg.get("webhookSecret")
if secret: if secret:
hdr = request.headers.get("X-Webhook-Secret") hdr = request.headers.get("X-Webhook-Secret")
if hdr != str(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( services = getGraphicalEditorServices(
context.user, context.user,
@ -1083,14 +1085,14 @@ async def post_workflow_form_submit(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
wf = iface.getWorkflow(workflowId) wf = iface.getWorkflow(workflowId)
if not wf or not wf.get("graph"): 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) inv = find_invocation(wf, entryPointId)
if not inv: 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": 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): 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( services = getGraphicalEditorServices(
context.user, context.user,
@ -1161,7 +1163,7 @@ def get_workflow_runs(
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
if not iface.getWorkflow(workflowId): 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) runs = iface.getRunsByWorkflow(workflowId)
return {"runs": runs} return {"runs": runs}
@ -1200,16 +1202,16 @@ async def resume_run(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
run = iface.getRun(runId) run = iface.getRun(runId)
if not run: 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") taskId = body.get("taskId")
result = body.get("result") result = body.get("result")
if not taskId or result is None: 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) task = iface.getTask(taskId)
if not task or task.get("runId") != runId: 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": 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) iface.updateTask(taskId, status="completed", result=result)
nodeId = task.get("nodeId") nodeId = task.get("nodeId")
nodeOutputs = dict(run.get("nodeOutputs") or {}) nodeOutputs = dict(run.get("nodeOutputs") or {})
@ -1217,7 +1219,7 @@ async def resume_run(
workflowId = run.get("workflowId") workflowId = run.get("workflowId")
wf = iface.getWorkflow(workflowId) if workflowId else None wf = iface.getWorkflow(workflowId) if workflowId else None
if not wf or not wf.get("graph"): 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"] graph = wf["graph"]
services = getGraphicalEditorServices(context.user, mandateId=mandateId, featureInstanceId=instanceId) services = getGraphicalEditorServices(context.user, mandateId=mandateId, featureInstanceId=instanceId)
resume_result = await executeGraph( resume_result = await executeGraph(
@ -1280,16 +1282,16 @@ async def complete_task(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
task = iface.getTask(taskId) task = iface.getTask(taskId)
if not task: 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") runId = task.get("runId")
result = body.get("result") result = body.get("result")
if result is None: 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) run = iface.getRun(runId)
if not run: 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": 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) iface.updateTask(taskId, status="completed", result=result)
nodeId = task.get("nodeId") nodeId = task.get("nodeId")
nodeOutputs = dict(run.get("nodeOutputs") or {}) nodeOutputs = dict(run.get("nodeOutputs") or {})
@ -1297,7 +1299,7 @@ async def complete_task(
workflowId = run.get("workflowId") workflowId = run.get("workflowId")
wf = iface.getWorkflow(workflowId) if workflowId else None wf = iface.getWorkflow(workflowId) if workflowId else None
if not wf or not wf.get("graph"): 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"] graph = wf["graph"]
services = getGraphicalEditorServices(context.user, mandateId=mandateId, featureInstanceId=instanceId) services = getGraphicalEditorServices(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return await executeGraph( return await executeGraph(

View file

@ -7,7 +7,7 @@ from enum import Enum
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
class DataScope(str, Enum): class DataScope(str, Enum):
@ -17,83 +17,128 @@ class DataScope(str, Enum):
GLOBAL = "global" GLOBAL = "global"
@i18nModel("Daten-Neutralisierung Konfiguration")
class DataNeutraliserConfig(PowerOnModel): 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}) """Konfiguration fuer die Daten-Neutralisierung."""
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) id: str = Field(
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}) default_factory=lambda: str(uuid.uuid4()),
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) description="Unique ID of the configuration",
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}) json_schema_extra={"label": "ID", "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": [ )
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}}, mandateId: str = Field(
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, description="ID of the mandate this configuration belongs to",
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
{"value": "global", "label": {"en": "Global", "de": "Global"}}, )
]}) featureInstanceId: str = Field(
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}) description="ID of the feature instance this configuration belongs to",
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}) json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
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}) userId: str = Field(
registerModelLabels( description="ID of the user who created this configuration",
"DataNeutraliserConfig", json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
{"en": "Data Neutralization Config", "fr": "Configuration de neutralisation des données"}, )
{ enabled: bool = Field(
"id": {"en": "ID", "fr": "ID"}, default=True,
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, description="Whether data neutralization is enabled",
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, )
"enabled": {"en": "Enabled", "fr": "Activé"}, scope: str = Field(
"scope": {"en": "Scope", "fr": "Portée"}, default="personal",
"neutralizationStatus": {"en": "Neutralization Status", "fr": "Statut de neutralisation"}, description="Data visibility scope: personal, featureInstance, mandate, global",
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"}, json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
"sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"}, {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
"sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"}, {"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): 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}) """Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten."""
mandateId: str = Field(description="ID of the mandate this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) id: str = Field(
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}) default_factory=lambda: str(uuid.uuid4()),
userId: str = Field(description="ID of the user who created this attribute", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) description="Unique ID of the attribute mapping (used as UID in neutralized files)",
originalText: str = Field(description="Original text that was neutralized", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
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}) 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): class DataNeutralizationSnapshot(BaseModel):
"""Stores the full neutralized text (with embedded placeholders) per source.""" """Speichert den vollstaendigen neutralisierten Text (mit Platzhaltern) pro Quelle."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(
mandateId: str = Field(description="Mandate scope") default_factory=lambda: str(uuid.uuid4()),
featureInstanceId: str = Field(default="", description="Feature instance scope") json_schema_extra={"label": "ID"},
userId: str = Field(description="User who triggered neutralization") )
sourceLabel: str = Field(description="Human label, e.g. 'Prompt', 'Kontext', 'Nachricht 3'") mandateId: str = Field(
neutralizedText: str = Field(description="Full text with [type.uuid] placeholders embedded") description="Mandate scope",
placeholderCount: int = Field(default=0, description="Number of placeholders in the text") json_schema_extra={"label": "Mandanten-ID"},
registerModelLabels( )
"DataNeutralizerAttributes", featureInstanceId: str = Field(
{"en": "Neutralized Data Attribute", "fr": "Attribut de données neutralisées"}, default="",
{ description="Feature instance scope",
"id": {"en": "ID", "fr": "ID"}, json_schema_extra={"label": "Feature-Instanz-ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, )
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, userId: str = Field(
"userId": {"en": "User ID", "fr": "ID utilisateur"}, description="User who triggered neutralization",
"originalText": {"en": "Original Text", "fr": "Texte original"}, json_schema_extra={"label": "Benutzer-ID"},
"fileId": {"en": "File ID", "fr": "ID de fichier"}, )
"patternType": {"en": "Pattern Type", "fr": "Type de modèle"}, sourceLabel: str = Field(
}, description="Human label, e.g. 'Prompt', 'Kontext', 'Nachricht 3'",
) json_schema_extra={"label": "Quelle"},
registerModelLabels( )
"DataNeutralizationSnapshot", neutralizedText: str = Field(
{"en": "Neutralization Snapshot", "de": "Neutralisierungs-Snapshot"}, description="Full text with [type.uuid] placeholders embedded",
{ json_schema_extra={"label": "Neutralisierter Text"},
"id": {"en": "ID"}, )
"mandateId": {"en": "Mandate ID"}, placeholderCount: int = Field(
"featureInstanceId": {"en": "Feature Instance ID"}, default=0,
"userId": {"en": "User ID"}, description="Number of placeholders in the text",
"sourceLabel": {"en": "Source", "de": "Quelle"}, json_schema_extra={"label": "Platzhalter"},
"neutralizedText": {"en": "Neutralized Text", "de": "Neutralisierter Text"}, )
"placeholderCount": {"en": "Placeholders", "de": "Platzhalter"},
},
)

View file

@ -12,14 +12,14 @@ logger = logging.getLogger(__name__)
# Feature metadata # Feature metadata
FEATURE_CODE = "neutralization" FEATURE_CODE = "neutralization"
FEATURE_LABEL = {"en": "Neutralization", "de": "Neutralisierung", "fr": "Neutralisation"} FEATURE_LABEL = "Neutralisierung"
FEATURE_ICON = "mdi-shield-check" FEATURE_ICON = "mdi-shield-check"
# UI Objects for RBAC catalog # UI Objects for RBAC catalog
UI_OBJECTS = [ UI_OBJECTS = [
{ {
"objectKey": "ui.feature.neutralization.playground", "objectKey": "ui.feature.neutralization.playground",
"label": {"en": "Playground", "de": "Spielwiese", "fr": "Bac à sable"}, "label": "Spielwiese",
"meta": {"area": "playground"} "meta": {"area": "playground"}
} }
] ]
@ -28,17 +28,17 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [ RESOURCE_OBJECTS = [
{ {
"objectKey": "resource.feature.neutralization.process.text", "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"} "meta": {"endpoint": "/api/neutralization/process/text", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.neutralization.process.files", "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"} "meta": {"endpoint": "/api/neutralization/process/files", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.neutralization.config.update", "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"} "meta": {"endpoint": "/api/neutralization/config", "method": "PUT"}
}, },
] ]
@ -47,11 +47,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{ {
"roleLabel": "neutralization-viewer", "roleLabel": "neutralization-viewer",
"description": { "description": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)",
"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)",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
@ -59,11 +55,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "neutralization-user", "roleLabel": "neutralization-user",
"description": { "description": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
@ -72,11 +64,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "neutralization-admin", "roleLabel": "neutralization-admin",
"description": { "description": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
@ -84,11 +72,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "neutralization-analyst", "roleLabel": "neutralization-analyst",
"description": { "description": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
@ -163,7 +147,8 @@ def _syncTemplateRolesToDb() -> int:
try: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface() rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
@ -180,7 +165,7 @@ def _syncTemplateRolesToDb() -> int:
else: else:
newRole = Role( newRole = Role(
roleLabel=roleLabel, roleLabel=roleLabel,
description=roleTemplate.get("description", {}), description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE, featureCode=FEATURE_CODE,
mandateId=None, mandateId=None,
featureInstanceId=None, featureInstanceId=None,

View file

@ -10,6 +10,8 @@ from modules.auth import limiter, getRequestContext, RequestContext
# Import interfaces # Import interfaces
from .datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes, DataNeutralizationSnapshot from .datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes, DataNeutralizationSnapshot
from .neutralizePlayground import NeutralizationPlayground from .neutralizePlayground import NeutralizationPlayground
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureNeutralizer")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -22,7 +24,7 @@ def _assertFeatureInstancePathMatchesContext(featureInstanceIdFromPath: str, con
if ctxId and pathId and pathId != ctxId: if ctxId and pathId and pathId != ctxId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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(): if not file.filename or not file.filename.strip():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="File name is required" detail=routeApiMsg("File name is required")
) )
content = await file.read() content = await file.read()
if not content: if not content:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="File is empty" detail=routeApiMsg("File is empty")
) )
service = NeutralizationPlayground( service = NeutralizationPlayground(
context.user, context.user,
@ -164,7 +166,7 @@ def neutralize_text(
if not text: if not text:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Text content is required" detail=routeApiMsg("Text content is required")
) )
service = NeutralizationPlayground( service = NeutralizationPlayground(
@ -199,7 +201,7 @@ def resolve_text(
if not text: if not text:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Text content is required" detail=routeApiMsg("Text content is required")
) )
service = NeutralizationPlayground( service = NeutralizationPlayground(
@ -320,7 +322,7 @@ async def process_sharepoint_files(
if not source_path or not target_path: if not source_path or not target_path:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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( service = NeutralizationPlayground(
@ -353,7 +355,7 @@ def batch_process_files(
if not files_data: if not files_data:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Files data is required" detail=routeApiMsg("Files data is required")
) )
service = NeutralizationPlayground( service = NeutralizationPlayground(
@ -453,7 +455,7 @@ def _retriggerNeutralizationBody(context: RequestContext, fileId: str) -> Dict[s
if not fileId: if not fileId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="fileId is required", detail=routeApiMsg("fileId is required"),
) )
service = NeutralizationPlayground( service = NeutralizationPlayground(
context.user, context.user,
@ -521,7 +523,7 @@ def cleanup_file_attributes(
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to cleanup file attributes" detail=routeApiMsg("Failed to cleanup file attributes")
) )
except HTTPException: except HTTPException:

View file

@ -20,7 +20,7 @@ from modules.features.neutralization.interfaceFeatureNeutralizer import Interfac
# Import all necessary classes and functions for neutralization # Import all necessary classes and functions for neutralization
from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute
from .subProcessText import TextProcessor, PlainText from .subProcessText import TextProcessor, PlainText
from .subProcessList import ListProcessor, TableData from .subProcessList import ListProcessor, NeutralizationTableData
from .subProcessBinary import BinaryProcessor from .subProcessBinary import BinaryProcessor
from .subProcessPdfInPlace import neutralize_pdf_in_place from .subProcessPdfInPlace import neutralize_pdf_in_place
from .subPatterns import HeaderPatterns, DataPatterns, TextTablePatterns from .subPatterns import HeaderPatterns, DataPatterns, TextTablePatterns

View file

@ -15,7 +15,7 @@ from .subParseString import StringParser
from .subPatterns import getPatternForHeader, HeaderPatterns from .subPatterns import getPatternForHeader, HeaderPatterns
@dataclass @dataclass
class TableData: class NeutralizationTableData:
"""Repräsentiert Tabellendaten""" """Repräsentiert Tabellendaten"""
headers: List[str] headers: List[str]
rows: List[List[str]] rows: List[List[str]]
@ -34,17 +34,17 @@ class ListProcessor:
self.string_parser = StringParser(NamesToParse) self.string_parser = StringParser(NamesToParse)
self.header_patterns = HeaderPatterns.patterns self.header_patterns = HeaderPatterns.patterns
def _anonymizeTable(self, table: TableData) -> TableData: def _anonymizeTable(self, table: NeutralizationTableData) -> NeutralizationTableData:
""" """
Anonymize table data based on headers Anonymize table data based on headers
Args: Args:
table: TableData object to anonymize table: NeutralizationTableData object to anonymize
Returns: Returns:
TableData: Anonymized table NeutralizationTableData: Anonymized table
""" """
anonymizedTable = TableData( anonymizedTable = NeutralizationTableData(
headers=table.headers.copy(), headers=table.headers.copy(),
rows=[row.copy() for row in table.rows], rows=[row.copy() for row in table.rows],
source_type=table.source_type source_type=table.source_type
@ -76,7 +76,7 @@ class ListProcessor:
Tuple of (processed_data, mapping, replaced_fields, processed_info) Tuple of (processed_data, mapping, replaced_fields, processed_info)
""" """
df = pd.read_csv(StringIO(content), encoding='utf-8') df = pd.read_csv(StringIO(content), encoding='utf-8')
table = TableData( table = NeutralizationTableData(
headers=df.columns.tolist(), headers=df.columns.tolist(),
rows=df.values.tolist(), rows=df.values.tolist(),
source_type='csv' source_type='csv'

View file

@ -8,7 +8,7 @@ from typing import List, Dict, Any, Optional, ForwardRef
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel 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 modules.shared.timeUtils import getUtcTimestamp
import uuid import uuid
@ -109,6 +109,7 @@ class GeoPolylinie(BaseModel):
) )
@i18nModel("Dokument")
class Dokument(BaseModel): class Dokument(BaseModel):
"""Supporting data object for file and URL management with versioning.""" """Supporting data object for file and URL management with versioning."""
id: str = Field( id: str = Field(
@ -117,24 +118,28 @@ class Dokument(BaseModel):
frontend_type="text", frontend_type="text",
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
label="ID",
) )
mandateId: str = Field( mandateId: str = Field(
description="ID of the mandate this document belongs to", description="ID of the mandate this document belongs to",
frontend_type="text", frontend_type="text",
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
label="Mandats-ID",
) )
featureInstanceId: str = Field( featureInstanceId: str = Field(
description="ID of the feature instance this document belongs to", description="ID of the feature instance this document belongs to",
frontend_type="text", frontend_type="text",
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
label="Feature-Instanz-ID",
) )
label: str = Field( label: str = Field(
description="Document label", description="Document label",
frontend_type="text", frontend_type="text",
frontend_readonly=False, frontend_readonly=False,
frontend_required=True, frontend_required=True,
label="Bezeichnung",
) )
versionsbezeichnung: Optional[str] = Field( versionsbezeichnung: Optional[str] = Field(
None, None,
@ -369,6 +374,7 @@ class Gemeinde(BaseModel):
ParzelleRef = ForwardRef('Parzelle') ParzelleRef = ForwardRef('Parzelle')
@i18nModel("Parzelle")
class Parzelle(PowerOnModel): class Parzelle(PowerOnModel):
"""Represents a plot with all building law properties.""" """Represents a plot with all building law properties."""
id: str = Field( id: str = Field(
@ -377,18 +383,21 @@ class Parzelle(PowerOnModel):
frontend_type="text", frontend_type="text",
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
label="ID",
) )
mandateId: str = Field( mandateId: str = Field(
description="ID of the mandate", description="ID of the mandate",
frontend_type="text", frontend_type="text",
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
label="Mandats-ID",
) )
featureInstanceId: str = Field( featureInstanceId: str = Field(
description="ID of the feature instance", description="ID of the feature instance",
frontend_type="text", frontend_type="text",
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
label="Feature-Instanz-ID",
) )
# Grunddaten # Grunddaten
@ -397,6 +406,7 @@ class Parzelle(PowerOnModel):
frontend_type="text", frontend_type="text",
frontend_readonly=False, frontend_readonly=False,
frontend_required=True, frontend_required=True,
label="Bezeichnung",
) )
parzellenAliasTags: List[str] = Field( parzellenAliasTags: List[str] = Field(
default_factory=list, default_factory=list,
@ -595,6 +605,7 @@ class Parzelle(PowerOnModel):
) )
@i18nModel("Projekt")
class Projekt(PowerOnModel): class Projekt(PowerOnModel):
"""Core object representing a construction project.""" """Core object representing a construction project."""
id: str = Field( id: str = Field(
@ -603,24 +614,28 @@ class Projekt(PowerOnModel):
frontend_type="text", frontend_type="text",
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
label="ID",
) )
mandateId: str = Field( mandateId: str = Field(
description="ID of the mandate", description="ID of the mandate",
frontend_type="text", frontend_type="text",
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
label="Mandats-ID",
) )
featureInstanceId: str = Field( featureInstanceId: str = Field(
description="ID of the feature instance", description="ID of the feature instance",
frontend_type="text", frontend_type="text",
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
label="Feature-Instanz-ID",
) )
label: str = Field( label: str = Field(
description="Project designation", description="Project designation",
frontend_type="text", frontend_type="text",
frontend_readonly=False, frontend_readonly=False,
frontend_required=True, frontend_required=True,
label="Bezeichnung",
) )
statusProzess: Optional[StatusProzess] = Field( statusProzess: Optional[StatusProzess] = Field(
None, None,
@ -628,6 +643,7 @@ class Projekt(PowerOnModel):
frontend_type="select", frontend_type="select",
frontend_readonly=False, frontend_readonly=False,
frontend_required=False, frontend_required=False,
label="Prozessstatus",
) )
perimeter: Optional[GeoPolylinie] = Field( perimeter: Optional[GeoPolylinie] = Field(
None, None,
@ -670,39 +686,3 @@ class Projekt(PowerOnModel):
Parzelle.model_rebuild() Parzelle.model_rebuild()
Projekt.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"},
},
)

View file

@ -10,14 +10,14 @@ import logging
# Feature metadata for RBAC catalog # Feature metadata for RBAC catalog
FEATURE_CODE = "realestate" FEATURE_CODE = "realestate"
FEATURE_LABEL = {"en": "Real Estate", "de": "Immobilien", "fr": "Immobilier"} FEATURE_LABEL = "Immobilien"
FEATURE_ICON = "mdi-home-city" FEATURE_ICON = "mdi-home-city"
# UI Objects for RBAC catalog (only map view) # UI Objects for RBAC catalog (only map view)
UI_OBJECTS = [ UI_OBJECTS = [
{ {
"objectKey": "ui.feature.realestate.dashboard", "objectKey": "ui.feature.realestate.dashboard",
"label": {"en": "Map", "de": "Karte", "fr": "Carte"}, "label": "Karte",
"meta": {"area": "dashboard"} "meta": {"area": "dashboard"}
}, },
] ]
@ -26,12 +26,12 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [ RESOURCE_OBJECTS = [
{ {
"objectKey": "resource.feature.realestate.project.create", "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"} "meta": {"endpoint": "/api/realestate/project", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.realestate.project.delete", "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"} "meta": {"endpoint": "/api/realestate/project/{projectId}", "method": "DELETE"}
}, },
] ]
@ -41,11 +41,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{ {
"roleLabel": "realestate-viewer", "roleLabel": "realestate-viewer",
"description": { "description": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)",
"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)",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
@ -53,11 +49,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "realestate-user", "roleLabel": "realestate-user",
"description": { "description": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
@ -66,11 +58,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "realestate-admin", "roleLabel": "realestate-admin",
"description": { "description": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
@ -80,11 +68,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "realestate-manager", "roleLabel": "realestate-manager",
"description": { "description": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
@ -154,6 +138,7 @@ def _syncTemplateRolesToDb() -> int:
try: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface() rootInterface = getRootInterface()
db = rootInterface.db db = rootInterface.db
@ -174,7 +159,7 @@ def _syncTemplateRolesToDb() -> int:
else: else:
newRole = Role( newRole = Role(
roleLabel=roleLabel, roleLabel=roleLabel,
description=roleTemplate.get("description", {}), description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE, featureCode=FEATURE_CODE,
mandateId=None, mandateId=None,
featureInstanceId=None, featureInstanceId=None,

View file

@ -59,6 +59,8 @@ from modules.aicore.aicorePluginTavily import AiTavily
# Import attribute utilities for model schema # Import attribute utilities for model schema
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureRealEstate")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -339,7 +341,7 @@ def update_project(
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
updated = interface.updateProjekt(projectId, data) updated = interface.updateProjekt(projectId, data)
if not updated: if not updated:
raise HTTPException(status_code=500, detail="Update failed") raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
return updated return updated
@ -360,7 +362,7 @@ def delete_project(
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId: if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
if not interface.deleteProjekt(projectId): if not interface.deleteProjekt(projectId):
raise HTTPException(status_code=500, detail="Delete failed") raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
# ----- Parcels CRUD ----- # ----- Parcels CRUD -----
@ -496,7 +498,7 @@ def update_parcel(
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
updated = interface.updateParzelle(parcelId, data) updated = interface.updateParzelle(parcelId, data)
if not updated: if not updated:
raise HTTPException(status_code=500, detail="Update failed") raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
return updated return updated
@ -517,7 +519,7 @@ def delete_parcel(
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId: if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
if not interface.deleteParzelle(parcelId): 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 ===== # ===== 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}") logger.warning(f"CSRF token missing for POST /api/realestate/command from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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})") 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}") logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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})") 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}") logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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})") 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}") logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Special handling for Projekt with parcel data # Special handling for Projekt with parcel data
@ -1265,7 +1267,7 @@ async def create_table_record(
if not label: if not label:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="label is required" detail=routeApiMsg("label is required")
) )
status_prozess = data.get("statusProzess", "Eingang") status_prozess = data.get("statusProzess", "Eingang")
@ -1278,7 +1280,7 @@ async def create_table_record(
if not isinstance(parzellen_data, list): if not isinstance(parzellen_data, list):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="parzellen must be an array" detail=routeApiMsg("parzellen must be an array")
) )
elif "parzelle" in data: elif "parzelle" in data:
# Single parcel # Single parcel
@ -1289,7 +1291,7 @@ async def create_table_record(
if not parzellen_data: if not parzellen_data:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # 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) logger.error(f"Error fetching WFS parcels: {e}", exc_info=True)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, 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}") logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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}") 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: if not csrf_token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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", []) parcels = body.get("parcels", [])
if not parcels: if not parcels:
@ -1868,19 +1870,19 @@ async def add_adjacent_parcel(
if not csrf_token: if not csrf_token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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") location = body.get("location")
selected_parcels = body.get("selected_parcels", []) selected_parcels = body.get("selected_parcels", [])
if not location or "x" not in location or "y" not in location: 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']}" loc_str = f"{location['x']},{location['y']}"
connector = SwissTopoMapServerConnector() connector = SwissTopoMapServerConnector()
parcel_data = await connector.search_parcel(loc_str) parcel_data = await connector.search_parcel(loc_str)
if not parcel_data: if not parcel_data:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) extracted = connector.extract_parcel_attributes(parcel_data)
attributes = parcel_data.get("attributes", {}) 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): if not is_parcel_adjacent_to_selection(new_parcel_response, selected_parcels):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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", []) bbox = parcel_data.get("bbox", [])
map_view["zoom_bounds"] = { 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}") logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {context.user.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Validate CSRF token format
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
try: try:
int(csrf_token, 16) int(csrf_token, 16)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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})") logger.info(f"Adding parcel to project {projekt_id} for user {context.user.id} (mandate: {context.mandateId})")

View file

@ -12,24 +12,24 @@ logger = logging.getLogger(__name__)
# Feature metadata # Feature metadata
FEATURE_CODE = "teamsbot" FEATURE_CODE = "teamsbot"
FEATURE_LABEL = {"en": "Teams Bot", "de": "Teams Bot", "fr": "Teams Bot"} FEATURE_LABEL = "Teams Bot"
FEATURE_ICON = "mdi-headset" FEATURE_ICON = "mdi-headset"
# UI Objects for RBAC catalog # UI Objects for RBAC catalog
UI_OBJECTS = [ UI_OBJECTS = [
{ {
"objectKey": "ui.feature.teamsbot.dashboard", "objectKey": "ui.feature.teamsbot.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, "label": "Dashboard",
"meta": {"area": "dashboard"} "meta": {"area": "dashboard"}
}, },
{ {
"objectKey": "ui.feature.teamsbot.sessions", "objectKey": "ui.feature.teamsbot.sessions",
"label": {"en": "Sessions", "de": "Sitzungen", "fr": "Sessions"}, "label": "Sitzungen",
"meta": {"area": "sessions"} "meta": {"area": "sessions"}
}, },
{ {
"objectKey": "ui.feature.teamsbot.settings", "objectKey": "ui.feature.teamsbot.settings",
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Paramètres"}, "label": "Einstellungen",
"meta": {"area": "settings", "admin_only": True} "meta": {"area": "settings", "admin_only": True}
}, },
] ]
@ -38,7 +38,7 @@ UI_OBJECTS = [
DATA_OBJECTS = [ DATA_OBJECTS = [
{ {
"objectKey": "data.feature.teamsbot.TeamsbotSession", "objectKey": "data.feature.teamsbot.TeamsbotSession",
"label": {"en": "Session", "de": "Sitzung", "fr": "Session"}, "label": "Sitzung",
"meta": { "meta": {
"table": "TeamsbotSession", "table": "TeamsbotSession",
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"], "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
@ -48,7 +48,7 @@ DATA_OBJECTS = [
}, },
{ {
"objectKey": "data.feature.teamsbot.TeamsbotTranscript", "objectKey": "data.feature.teamsbot.TeamsbotTranscript",
"label": {"en": "Transcript", "de": "Transkript", "fr": "Transcription"}, "label": "Transkript",
"meta": { "meta": {
"table": "TeamsbotTranscript", "table": "TeamsbotTranscript",
"fields": ["id", "sessionId", "speaker", "text", "timestamp"], "fields": ["id", "sessionId", "speaker", "text", "timestamp"],
@ -58,7 +58,7 @@ DATA_OBJECTS = [
}, },
{ {
"objectKey": "data.feature.teamsbot.TeamsbotBotResponse", "objectKey": "data.feature.teamsbot.TeamsbotBotResponse",
"label": {"en": "Bot Response", "de": "Bot-Antwort", "fr": "Réponse du bot"}, "label": "Bot-Antwort",
"meta": { "meta": {
"table": "TeamsbotBotResponse", "table": "TeamsbotBotResponse",
"fields": ["id", "sessionId", "responseText", "detectedIntent"], "fields": ["id", "sessionId", "responseText", "detectedIntent"],
@ -68,7 +68,7 @@ DATA_OBJECTS = [
}, },
{ {
"objectKey": "data.feature.teamsbot.*", "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"} "meta": {"wildcard": True, "description": "Wildcard for all teamsbot data tables"}
}, },
] ]
@ -77,22 +77,22 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [ RESOURCE_OBJECTS = [
{ {
"objectKey": "resource.feature.teamsbot.session.start", "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"} "meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.teamsbot.session.stop", "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"} "meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}/stop", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.teamsbot.session.delete", "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"} "meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}", "method": "DELETE"}
}, },
{ {
"objectKey": "resource.feature.teamsbot.config.edit", "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} "meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True}
}, },
] ]
@ -101,11 +101,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{ {
"roleLabel": "teamsbot-admin", "roleLabel": "teamsbot-admin",
"description": { "description": "Teams Bot Administrator - Vollzugriff auf alle Sitzungen und Einstellungen",
"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"
},
"accessRules": [ "accessRules": [
# Full UI access (all views including settings) # Full UI access (all views including settings)
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
@ -120,11 +116,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "teamsbot-viewer", "roleLabel": "teamsbot-viewer",
"description": { "description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
"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)",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
@ -133,11 +125,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "teamsbot-user", "roleLabel": "teamsbot-user",
"description": { "description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
@ -223,7 +211,8 @@ def _syncTemplateRolesToDb() -> int:
try: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface() rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
templateRoles = [r for r in existingRoles if r.mandateId is None] templateRoles = [r for r in existingRoles if r.mandateId is None]
@ -239,7 +228,7 @@ def _syncTemplateRolesToDb() -> int:
else: else:
newRole = Role( newRole = Role(
roleLabel=roleLabel, roleLabel=roleLabel,
description=roleTemplate.get("description", {}), description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE, featureCode=FEATURE_CODE,
mandateId=None, mandateId=None,
featureInstanceId=None, featureInstanceId=None,

View file

@ -40,6 +40,8 @@ from .datamodelTeamsbot import (
# Import service # Import service
from .service import TeamsbotService from .service import TeamsbotService
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureTeamsbot")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -71,7 +73,7 @@ def _extractTeamsMeetingUrl(rawInput: str) -> str:
urls = re.findall(urlPattern, rawInput) urls = re.findall(urlPattern, rawInput)
if not urls: 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) # Step 2: Find the Teams URL (prefer direct teams.microsoft.com, then SafeLinks)
teamsUrl = None teamsUrl = None
@ -101,7 +103,7 @@ def _extractTeamsMeetingUrl(rawInput: str) -> str:
if not teamsUrl or "teams.microsoft.com" not in teamsUrl: if not teamsUrl or "teams.microsoft.com" not in teamsUrl:
raise HTTPException( raise HTTPException(
status_code=400, 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)})") 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) mandateId = instance.get("mandateId") if isinstance(instance, dict) else getattr(instance, "mandateId", None)
if not mandateId: 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) return str(mandateId)
@ -463,7 +465,7 @@ async def deleteSession(
# Don't delete active sessions # Don't delete active sessions
currentStatus = session.get("status") currentStatus = session.get("status")
if currentStatus in [TeamsbotSessionStatus.ACTIVE.value, TeamsbotSessionStatus.JOINING.value]: 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) interface.deleteSession(sessionId)
logger.info(f"Teamsbot session {sessionId} deleted") 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.""" """List all system bot accounts for this mandate. Passwords are never returned."""
if not context.isSysAdmin: 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) mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
bots = interface.getSystemBots(mandateId) bots = interface.getSystemBots(mandateId)
@ -655,7 +657,7 @@ async def createSystemBot(
): ):
"""Create a new system bot account. Password is encrypted before storage.""" """Create a new system bot account. Password is encrypted before storage."""
if not context.isSysAdmin: 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) mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
@ -666,7 +668,7 @@ async def createSystemBot(
if not email or not password: if not email or not password:
from fastapi import HTTPException 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 # Encrypt the password
from modules.shared.configuration import encryptValue from modules.shared.configuration import encryptValue
@ -698,7 +700,7 @@ async def deleteSystemBot(
): ):
"""Delete a system bot account.""" """Delete a system bot account."""
if not context.isSysAdmin: 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) _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
@ -750,7 +752,7 @@ async def saveUserAccount(
displayName = body.get("displayName") displayName = body.get("displayName")
if not email or not password: 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 from modules.shared.configuration import encryptValue
encryptedPassword = encryptValue(password, userId=userId, keyName="userAccountPassword") encryptedPassword = encryptValue(password, userId=userId, keyName="userAccountPassword")
@ -827,7 +829,7 @@ async def submitMfaCode(
await queue.put({"action": mfaAction, "code": mfaCode}) await queue.put({"action": mfaAction, "code": mfaCode})
return {"submitted": True} return {"submitted": True}
else: 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. Does NOT join the meeting only checks which page Teams serves.
""" """
if not context.isSysAdmin: 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 import aiohttp
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
@ -935,7 +937,7 @@ async def testAuth(
body = await request.json() body = await request.json()
meetingUrl = body.get("meetingUrl") meetingUrl = body.get("meetingUrl")
if not 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: # Load system bot credentials:
# 1. Use email/password from request body (direct override) # 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) # Forward to browser bot service (single all-in-one call — may timeout with many variants)
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
if not browserBotUrl: 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("/") browserBotUrl = browserBotUrl.rstrip("/")
payload = { payload = {
@ -1037,14 +1039,14 @@ async def getTestAuthVariants(
Frontend calls this once, then runs each variant individually. Frontend calls this once, then runs each variant individually.
""" """
if not context.isSysAdmin: 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 import aiohttp
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
effectiveConfig = _getInstanceConfig(instanceId) effectiveConfig = _getInstanceConfig(instanceId)
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
if not browserBotUrl: 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("/") browserBotUrl = browserBotUrl.rstrip("/")
try: try:
@ -1073,7 +1075,7 @@ async def testAuthSingleVariant(
Each call stays within Azure's 240s timeout. Each call stays within Azure's 240s timeout.
""" """
if not context.isSysAdmin: 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 import aiohttp
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
@ -1084,7 +1086,7 @@ async def testAuthSingleVariant(
variantId = body.get("variantId") variantId = body.get("variantId")
meetingUrl = body.get("meetingUrl") meetingUrl = body.get("meetingUrl")
if not variantId or not 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) # Load credentials (same logic as testAuth)
email = body.get("botEmail") email = body.get("botEmail")
@ -1116,7 +1118,7 @@ async def testAuthSingleVariant(
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
if not browserBotUrl: 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("/") browserBotUrl = browserBotUrl.rstrip("/")
payload = { payload = {
@ -1157,12 +1159,12 @@ async def listSessionScreenshots(
): ):
"""List debug screenshots for a session. Proxied from Browser Bot filesystem.""" """List debug screenshots for a session. Proxied from Browser Bot filesystem."""
if not context.isSysAdmin: 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) _validateInstanceAccess(instanceId, context)
effectiveConfig = _getInstanceConfig(instanceId) effectiveConfig = _getInstanceConfig(instanceId)
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
if not browserBotUrl: 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 import aiohttp
browserBotUrl = browserBotUrl.rstrip("/") browserBotUrl = browserBotUrl.rstrip("/")
@ -1194,16 +1196,16 @@ async def getScreenshotFile(
): ):
"""Serve a single debug screenshot image. Proxied from Browser Bot.""" """Serve a single debug screenshot image. Proxied from Browser Bot."""
if not context.isSysAdmin: 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) _validateInstanceAccess(instanceId, context)
if not filename.endswith(".png") or ".." in filename or "/" in filename or "\\" in filename: 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) effectiveConfig = _getInstanceConfig(instanceId)
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
if not browserBotUrl: 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 import aiohttp
from fastapi.responses import Response as FastAPIResponse from fastapi.responses import Response as FastAPIResponse
@ -1216,7 +1218,7 @@ async def getScreenshotFile(
imageBytes = await resp.read() imageBytes = await resp.read()
return FastAPIResponse(content=imageBytes, media_type="image/png") return FastAPIResponse(content=imageBytes, media_type="image/png")
else: 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: except aiohttp.ClientError as e:
logger.error(f"Screenshot file error: {e}") logger.error(f"Screenshot file error: {e}")
raise HTTPException(status_code=503, detail=f"Browser Bot connection failed: {str(e)}") raise HTTPException(status_code=503, detail=f"Browser Bot connection failed: {str(e)}")

View file

@ -7,15 +7,16 @@ from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
import uuid import uuid
@i18nModel("Organisation")
class TrusteeOrganisation(PowerOnModel): class TrusteeOrganisation(PowerOnModel):
"""Represents trustee organisations (companies) within the Trustee feature.""" """Represents trustee organisations (companies) within the Trustee feature."""
id: str = Field( # Unique string label (PK), not UUID id: str = Field( # Unique string label (PK), not UUID
description="Unique organisation identifier (label)", description="Unique organisation identifier (label)",
json_schema_extra={ json_schema_extra={
"label": "ID",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, # Editable at creation, then readonly "frontend_readonly": False, # Editable at creation, then readonly
"frontend_required": True "frontend_required": True
@ -24,6 +25,7 @@ class TrusteeOrganisation(PowerOnModel):
label: str = Field( label: str = Field(
description="Company name", description="Company name",
json_schema_extra={ json_schema_extra={
"label": "Bezeichnung",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True "frontend_required": True
@ -33,6 +35,7 @@ class TrusteeOrganisation(PowerOnModel):
default=True, default=True,
description="Whether the organisation is enabled", description="Whether the organisation is enabled",
json_schema_extra={ json_schema_extra={
"label": "Aktiviert",
"frontend_type": "checkbox", "frontend_type": "checkbox",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -42,6 +45,7 @@ class TrusteeOrganisation(PowerOnModel):
default=None, default=None,
description="Mandate ID (system-level organisation)", description="Mandate ID (system-level organisation)",
json_schema_extra={ json_schema_extra={
"label": "Mandat",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -51,6 +55,7 @@ class TrusteeOrganisation(PowerOnModel):
default=None, default=None,
description="Feature Instance ID for instance-level isolation", description="Feature Instance ID for instance-level isolation",
json_schema_extra={ json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -59,25 +64,13 @@ class TrusteeOrganisation(PowerOnModel):
# System attributes are automatically set by DatabaseConnector: # System attributes are automatically set by DatabaseConnector:
# sysCreatedAt, sysModifiedAt, sysCreatedBy, sysModifiedBy (PowerOnModel) # sysCreatedAt, sysModifiedAt, sysCreatedBy, sysModifiedBy (PowerOnModel)
@i18nModel("Rolle")
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"},
},
)
class TrusteeRole(PowerOnModel): class TrusteeRole(PowerOnModel):
"""Defines roles within the Trustee feature.""" """Defines roles within the Trustee feature."""
id: str = Field( # Unique string label (PK), not UUID id: str = Field( # Unique string label (PK), not UUID
description="Unique role identifier (label)", description="Unique role identifier (label)",
json_schema_extra={ json_schema_extra={
"label": "ID",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True "frontend_required": True
@ -86,6 +79,7 @@ class TrusteeRole(PowerOnModel):
desc: str = Field( desc: str = Field(
description="Role description", description="Role description",
json_schema_extra={ json_schema_extra={
"label": "Beschreibung",
"frontend_type": "textarea", "frontend_type": "textarea",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True "frontend_required": True
@ -95,6 +89,7 @@ class TrusteeRole(PowerOnModel):
default=None, default=None,
description="Mandate ID", description="Mandate ID",
json_schema_extra={ json_schema_extra={
"label": "Mandat",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -104,6 +99,7 @@ class TrusteeRole(PowerOnModel):
default=None, default=None,
description="Feature Instance ID for instance-level isolation", description="Feature Instance ID for instance-level isolation",
json_schema_extra={ json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -111,25 +107,14 @@ class TrusteeRole(PowerOnModel):
) )
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@i18nModel("Zugriff")
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"},
},
)
class TrusteeAccess(PowerOnModel): class TrusteeAccess(PowerOnModel):
"""Defines user access to organisations with specific roles.""" """Defines user access to organisations with specific roles."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique access ID", description="Unique access ID",
json_schema_extra={ json_schema_extra={
"label": "ID",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -138,6 +123,7 @@ class TrusteeAccess(PowerOnModel):
organisationId: str = Field( organisationId: str = Field(
description="Reference to TrusteeOrganisation.id", description="Reference to TrusteeOrganisation.id",
json_schema_extra={ json_schema_extra={
"label": "Organisation",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
@ -147,6 +133,7 @@ class TrusteeAccess(PowerOnModel):
roleId: str = Field( roleId: str = Field(
description="Reference to TrusteeRole.id", description="Reference to TrusteeRole.id",
json_schema_extra={ json_schema_extra={
"label": "Rolle",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
@ -156,6 +143,7 @@ class TrusteeAccess(PowerOnModel):
userId: str = Field( userId: str = Field(
description="User ID assigned to this role", description="User ID assigned to this role",
json_schema_extra={ json_schema_extra={
"label": "Benutzer",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
@ -166,6 +154,7 @@ class TrusteeAccess(PowerOnModel):
default=None, default=None,
description="Optional reference to TrusteeContract.id. If None, access is for full organisation. If set, access is limited to this specific contract.", 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={ json_schema_extra={
"label": "Vertrag (optional)",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False, "frontend_required": False,
@ -177,6 +166,7 @@ class TrusteeAccess(PowerOnModel):
default=None, default=None,
description="Mandate ID", description="Mandate ID",
json_schema_extra={ json_schema_extra={
"label": "Mandat",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -186,6 +176,7 @@ class TrusteeAccess(PowerOnModel):
default=None, default=None,
description="Feature Instance ID for instance-level isolation", description="Feature Instance ID for instance-level isolation",
json_schema_extra={ json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -193,28 +184,14 @@ class TrusteeAccess(PowerOnModel):
) )
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@i18nModel("Vertrag")
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"},
},
)
class TrusteeContract(PowerOnModel): class TrusteeContract(PowerOnModel):
"""Defines customer contracts within organisations.""" """Defines customer contracts within organisations."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique contract ID", description="Unique contract ID",
json_schema_extra={ json_schema_extra={
"label": "ID",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -223,6 +200,7 @@ class TrusteeContract(PowerOnModel):
organisationId: str = Field( organisationId: str = Field(
description="Reference to TrusteeOrganisation.id (immutable after creation)", description="Reference to TrusteeOrganisation.id (immutable after creation)",
json_schema_extra={ json_schema_extra={
"label": "Organisation",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, # Editable at creation, then readonly "frontend_readonly": False, # Editable at creation, then readonly
"frontend_required": True, "frontend_required": True,
@ -232,6 +210,7 @@ class TrusteeContract(PowerOnModel):
label: str = Field( label: str = Field(
description="Label for the customer contract (e.g., 'Muster AG 2026')", description="Label for the customer contract (e.g., 'Muster AG 2026')",
json_schema_extra={ json_schema_extra={
"label": "Bezeichnung",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True "frontend_required": True
@ -241,6 +220,7 @@ class TrusteeContract(PowerOnModel):
default=True, default=True,
description="Whether the contract is enabled", description="Whether the contract is enabled",
json_schema_extra={ json_schema_extra={
"label": "Aktiviert",
"frontend_type": "checkbox", "frontend_type": "checkbox",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -250,6 +230,7 @@ class TrusteeContract(PowerOnModel):
default=None, default=None,
description="Mandate ID", description="Mandate ID",
json_schema_extra={ json_schema_extra={
"label": "Mandat",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -259,6 +240,7 @@ class TrusteeContract(PowerOnModel):
default=None, default=None,
description="Feature Instance ID for instance-level isolation", description="Feature Instance ID for instance-level isolation",
json_schema_extra={ json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -266,21 +248,6 @@ class TrusteeContract(PowerOnModel):
) )
# System attributes are automatically set by DatabaseConnector # 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): class TrusteeDocumentTypeEnum(str, Enum):
"""Document type for trustee documents (expense extraction, ingest, sync).""" """Document type for trustee documents (expense extraction, ingest, sync)."""
INVOICE = "invoice" INVOICE = "invoice"
@ -290,7 +257,7 @@ class TrusteeDocumentTypeEnum(str, Enum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
AUTO = "auto" AUTO = "auto"
@i18nModel("Dokument")
class TrusteeDocument(PowerOnModel): class TrusteeDocument(PowerOnModel):
"""Contains document references for bookings. """Contains document references for bookings.
@ -305,6 +272,7 @@ class TrusteeDocument(PowerOnModel):
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique document ID", description="Unique document ID",
json_schema_extra={ json_schema_extra={
"label": "ID",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -314,6 +282,7 @@ class TrusteeDocument(PowerOnModel):
default=None, default=None,
description="Reference to central Files table (Files.id)", description="Reference to central Files table (Files.id)",
json_schema_extra={ json_schema_extra={
"label": "Datei-Referenz",
"frontend_type": "file_reference", "frontend_type": "file_reference",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -322,6 +291,7 @@ class TrusteeDocument(PowerOnModel):
documentName: str = Field( documentName: str = Field(
description="File name (e.g., 'Beleg.pdf')", description="File name (e.g., 'Beleg.pdf')",
json_schema_extra={ json_schema_extra={
"label": "Dokumentname",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True "frontend_required": True
@ -331,6 +301,7 @@ class TrusteeDocument(PowerOnModel):
default="application/octet-stream", default="application/octet-stream",
description="MIME type of the document", description="MIME type of the document",
json_schema_extra={ json_schema_extra={
"label": "MIME-Typ",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
@ -341,6 +312,7 @@ class TrusteeDocument(PowerOnModel):
default=None, default=None,
description="Source type (e.g., 'sharepoint', 'upload', 'email')", description="Source type (e.g., 'sharepoint', 'upload', 'email')",
json_schema_extra={ json_schema_extra={
"label": "Quelltyp",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -350,6 +322,7 @@ class TrusteeDocument(PowerOnModel):
default=None, default=None,
description="Original source location (e.g., SharePoint path)", description="Original source location (e.g., SharePoint path)",
json_schema_extra={ json_schema_extra={
"label": "Quellort",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -359,6 +332,7 @@ class TrusteeDocument(PowerOnModel):
default=None, default=None,
description="Mandate ID (auto-set from context)", description="Mandate ID (auto-set from context)",
json_schema_extra={ json_schema_extra={
"label": "Mandat",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
@ -369,6 +343,7 @@ class TrusteeDocument(PowerOnModel):
default=None, default=None,
description="Feature Instance ID for instance-level isolation (auto-set from context)", description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={ json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
@ -379,6 +354,7 @@ class TrusteeDocument(PowerOnModel):
default=None, default=None,
description="Document type (e.g. invoice, expense_receipt, bank_document, contract); use TrusteeDocumentTypeEnum values", description="Document type (e.g. invoice, expense_receipt, bank_document, contract); use TrusteeDocumentTypeEnum values",
json_schema_extra={ json_schema_extra={
"label": "Dokumenttyp",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -388,6 +364,7 @@ class TrusteeDocument(PowerOnModel):
default=None, default=None,
description="External Beleg-ID in accounting system (e.g. RMA); set on first successful upload, reused on re-sync", description="External Beleg-ID in accounting system (e.g. RMA); set on first successful upload, reused on re-sync",
json_schema_extra={ json_schema_extra={
"label": "Beleg-ID (Buchhaltung)",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
@ -396,25 +373,7 @@ class TrusteeDocument(PowerOnModel):
) )
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@i18nModel("Position")
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)"},
},
)
class TrusteePosition(PowerOnModel): class TrusteePosition(PowerOnModel):
"""Contains booking positions (expense entries). """Contains booking positions (expense entries).
@ -425,6 +384,7 @@ class TrusteePosition(PowerOnModel):
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique position ID", description="Unique position ID",
json_schema_extra={ json_schema_extra={
"label": "ID",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
@ -434,6 +394,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Reference to TrusteeDocument.id (Beleg / primary document)", description="Reference to TrusteeDocument.id (Beleg / primary document)",
json_schema_extra={ json_schema_extra={
"label": "Dokument",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False, "frontend_required": False,
@ -444,6 +405,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Reference to TrusteeDocument.id (Bank-Referenz / second document)", description="Reference to TrusteeDocument.id (Bank-Referenz / second document)",
json_schema_extra={ json_schema_extra={
"label": "Bank-Referenz",
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False, "frontend_required": False,
@ -454,6 +416,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Value date (ISO format: YYYY-MM-DD)", description="Value date (ISO format: YYYY-MM-DD)",
json_schema_extra={ json_schema_extra={
"label": "Valutadatum",
"frontend_type": "date", "frontend_type": "date",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True "frontend_required": True
@ -463,6 +426,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Transaction timestamp (UTC timestamp in seconds)", description="Transaction timestamp (UTC timestamp in seconds)",
json_schema_extra={ json_schema_extra={
"label": "Transaktionszeitpunkt",
"frontend_type": "timestamp", "frontend_type": "timestamp",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True "frontend_required": True
@ -472,6 +436,7 @@ class TrusteePosition(PowerOnModel):
default="", default="",
description="Company name", description="Company name",
json_schema_extra={ json_schema_extra={
"label": "Firma",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -481,6 +446,7 @@ class TrusteePosition(PowerOnModel):
default="", default="",
description="Description", description="Description",
json_schema_extra={ json_schema_extra={
"label": "Beschreibung",
"frontend_type": "textarea", "frontend_type": "textarea",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -490,6 +456,7 @@ class TrusteePosition(PowerOnModel):
default="", default="",
description="Tags (comma-separated)", description="Tags (comma-separated)",
json_schema_extra={ json_schema_extra={
"label": "Tags",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -514,6 +481,7 @@ class TrusteePosition(PowerOnModel):
default=0.0, default=0.0,
description="Booking amount", description="Booking amount",
json_schema_extra={ json_schema_extra={
"label": "Buchungsbetrag",
"frontend_type": "number", "frontend_type": "number",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True "frontend_required": True
@ -538,6 +506,7 @@ class TrusteePosition(PowerOnModel):
default=0.0, default=0.0,
description="Original amount (manual input, no automatic currency conversion)", description="Original amount (manual input, no automatic currency conversion)",
json_schema_extra={ json_schema_extra={
"label": "Originalbetrag",
"frontend_type": "number", "frontend_type": "number",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True "frontend_required": True
@ -547,6 +516,7 @@ class TrusteePosition(PowerOnModel):
default=0.0, default=0.0,
description="VAT percentage", description="VAT percentage",
json_schema_extra={ json_schema_extra={
"label": "MwSt-Prozentsatz",
"frontend_type": "number", "frontend_type": "number",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -556,6 +526,7 @@ class TrusteePosition(PowerOnModel):
default=0.0, default=0.0,
description="VAT amount (calculated: bookingAmount * vatPercentage / 100, can be manually overridden)", description="VAT amount (calculated: bookingAmount * vatPercentage / 100, can be manually overridden)",
json_schema_extra={ json_schema_extra={
"label": "MwSt-Betrag",
"frontend_type": "number", "frontend_type": "number",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -565,6 +536,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Debit account number (e.g. '4200' for expenses)", description="Debit account number (e.g. '4200' for expenses)",
json_schema_extra={ json_schema_extra={
"label": "Soll-Konto",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -574,6 +546,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Credit account number (e.g. '1020' for bank)", description="Credit account number (e.g. '1020' for bank)",
json_schema_extra={ json_schema_extra={
"label": "Haben-Konto",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -583,6 +556,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Tax code for the accounting system", description="Tax code for the accounting system",
json_schema_extra={ json_schema_extra={
"label": "Steuercode",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -592,6 +566,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Cost center identifier", description="Cost center identifier",
json_schema_extra={ json_schema_extra={
"label": "Kostenstelle",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -601,6 +576,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Booking reference (e.g. voucher number)", description="Booking reference (e.g. voucher number)",
json_schema_extra={ json_schema_extra={
"label": "Buchungsreferenz",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -626,6 +602,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="IBAN of the payment recipient (from invoice / QR code)", description="IBAN of the payment recipient (from invoice / QR code)",
json_schema_extra={ json_schema_extra={
"label": "Empfänger-IBAN",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -635,6 +612,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Bank or account holder name of the payment recipient", description="Bank or account holder name of the payment recipient",
json_schema_extra={ json_schema_extra={
"label": "Empfänger-Name",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -644,6 +622,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="BIC / SWIFT code of the recipient bank", description="BIC / SWIFT code of the recipient bank",
json_schema_extra={ json_schema_extra={
"label": "Empfänger-BIC",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -653,6 +632,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Structured payment reference (QR-Referenz, ESR, SCOR, Mitteilung)", description="Structured payment reference (QR-Referenz, ESR, SCOR, Mitteilung)",
json_schema_extra={ json_schema_extra={
"label": "Zahlungsreferenz",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -662,6 +642,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Payment due date (ISO format: YYYY-MM-DD)", description="Payment due date (ISO format: YYYY-MM-DD)",
json_schema_extra={ json_schema_extra={
"label": "Fälligkeitsdatum",
"frontend_type": "date", "frontend_type": "date",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
@ -671,6 +652,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Mandate ID (auto-set from context)", description="Mandate ID (auto-set from context)",
json_schema_extra={ json_schema_extra={
"label": "Mandat",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
@ -681,6 +663,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="Feature Instance ID for instance-level isolation (auto-set from context)", description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={ json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
@ -691,6 +674,7 @@ class TrusteePosition(PowerOnModel):
default=None, default=None,
description="External ID (UUID) of the synced record in the accounting system; set by sync, used for duplicate check", description="External ID (UUID) of the synced record in the accounting system; set by sync, used for duplicate check",
json_schema_extra={ json_schema_extra={
"label": "Buha-Sync-ID",
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "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) ── # ── TrusteeData* tables (synced from external accounting apps for analysis) ──
@i18nModel("Konto (Sync)")
class TrusteeDataAccount(PowerOnModel): class TrusteeDataAccount(PowerOnModel):
"""Chart of accounts synced from external accounting system.""" """Chart of accounts synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
accountNumber: str = Field(description="Account number (e.g. '1020')") accountNumber: str = Field(description="Account number (e.g. '1020')", json_schema_extra={"label": "Kontonummer"})
label: str = Field(default="", description="Account name") label: str = Field(default="", description="Account name", json_schema_extra={"label": "Bezeichnung"})
accountType: Optional[str] = Field(default=None, description="asset / liability / equity / revenue / expense") 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") accountGroup: Optional[str] = Field(default=None, description="Account group/category", json_schema_extra={"label": "Gruppe"})
currency: str = Field(default="CHF", description="Account currency") currency: str = Field(default="CHF", description="Account currency", json_schema_extra={"label": "Währung"})
isActive: bool = Field(default=True) isActive: bool = Field(default=True, json_schema_extra={"label": "Aktiv"})
mandateId: Optional[str] = Field(default=None) mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
featureInstanceId: Optional[str] = Field(default=None) featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
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"},
},
)
@i18nModel("Buchung (Sync)")
class TrusteeDataJournalEntry(PowerOnModel): class TrusteeDataJournalEntry(PowerOnModel):
"""Journal entry header synced from external accounting system.""" """Journal entry header synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) 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") 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)") 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") reference: Optional[str] = Field(default=None, description="Booking reference / voucher number", json_schema_extra={"label": "Referenz"})
description: str = Field(default="", description="Booking text") description: str = Field(default="", description="Booking text", json_schema_extra={"label": "Beschreibung"})
currency: str = Field(default="CHF") currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
totalAmount: float = Field(default=0.0, description="Total amount of entry") totalAmount: float = Field(default=0.0, description="Total amount of entry", json_schema_extra={"label": "Betrag"})
mandateId: Optional[str] = Field(default=None) mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
featureInstanceId: Optional[str] = Field(default=None) featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
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"},
},
)
@i18nModel("Buchungszeile (Sync)")
class TrusteeDataJournalLine(PowerOnModel): class TrusteeDataJournalLine(PowerOnModel):
"""Journal entry line (debit/credit) synced from external accounting system.""" """Journal entry line (debit/credit) synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id") journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id", json_schema_extra={"label": "Buchung"})
accountNumber: str = Field(description="Account number") accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"})
debitAmount: float = Field(default=0.0) debitAmount: float = Field(default=0.0, json_schema_extra={"label": "Soll"})
creditAmount: float = Field(default=0.0) creditAmount: float = Field(default=0.0, json_schema_extra={"label": "Haben"})
currency: str = Field(default="CHF") currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
taxCode: Optional[str] = Field(default=None) taxCode: Optional[str] = Field(default=None, json_schema_extra={"label": "Steuercode"})
costCenter: Optional[str] = Field(default=None) costCenter: Optional[str] = Field(default=None, json_schema_extra={"label": "Kostenstelle"})
description: str = Field(default="") description: str = Field(default="", json_schema_extra={"label": "Beschreibung"})
mandateId: Optional[str] = Field(default=None) mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
featureInstanceId: Optional[str] = Field(default=None) featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
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"},
},
)
@i18nModel("Kontakt (Sync)")
class TrusteeDataContact(PowerOnModel): class TrusteeDataContact(PowerOnModel):
"""Customer or vendor synced from external accounting system.""" """Customer or vendor synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) 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") 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") contactType: str = Field(default="customer", description="customer / vendor / both", json_schema_extra={"label": "Typ"})
contactNumber: Optional[str] = Field(default=None, description="Customer/vendor number") contactNumber: Optional[str] = Field(default=None, description="Customer/vendor number", json_schema_extra={"label": "Nummer"})
name: str = Field(default="", description="Name / company") name: str = Field(default="", description="Name / company", json_schema_extra={"label": "Name"})
address: Optional[str] = Field(default=None) address: Optional[str] = Field(default=None, json_schema_extra={"label": "Adresse"})
zip: Optional[str] = Field(default=None) zip: Optional[str] = Field(default=None, json_schema_extra={"label": "PLZ"})
city: Optional[str] = Field(default=None) city: Optional[str] = Field(default=None, json_schema_extra={"label": "Ort"})
country: Optional[str] = Field(default=None) country: Optional[str] = Field(default=None, json_schema_extra={"label": "Land"})
email: Optional[str] = Field(default=None) email: Optional[str] = Field(default=None, json_schema_extra={"label": "E-Mail"})
phone: Optional[str] = Field(default=None) phone: Optional[str] = Field(default=None, json_schema_extra={"label": "Telefon"})
vatNumber: Optional[str] = Field(default=None) vatNumber: Optional[str] = Field(default=None, json_schema_extra={"label": "MWST-Nr."})
mandateId: Optional[str] = Field(default=None) mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
featureInstanceId: Optional[str] = Field(default=None) featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
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"},
},
)
@i18nModel("Kontosaldo (Sync)")
class TrusteeDataAccountBalance(PowerOnModel): class TrusteeDataAccountBalance(PowerOnModel):
"""Account balance per period, derived from journal lines or directly from accounting system.""" """Account balance per period, derived from journal lines or directly from accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
accountNumber: str = Field(description="Account number") accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"})
periodYear: int = Field(description="Fiscal year") periodYear: int = Field(description="Fiscal year", json_schema_extra={"label": "Jahr"})
periodMonth: int = Field(default=0, description="Month (1-12); 0 = annual total") periodMonth: int = Field(default=0, description="Month (1-12); 0 = annual total", json_schema_extra={"label": "Monat"})
openingBalance: float = Field(default=0.0) openingBalance: float = Field(default=0.0, json_schema_extra={"label": "Eröffnungssaldo"})
debitTotal: float = Field(default=0.0) debitTotal: float = Field(default=0.0, json_schema_extra={"label": "Soll-Umsatz"})
creditTotal: float = Field(default=0.0) creditTotal: float = Field(default=0.0, json_schema_extra={"label": "Haben-Umsatz"})
closingBalance: float = Field(default=0.0) closingBalance: float = Field(default=0.0, json_schema_extra={"label": "Schlusssaldo"})
currency: str = Field(default="CHF") currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
mandateId: Optional[str] = Field(default=None) mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
featureInstanceId: Optional[str] = Field(default=None) featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
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"},
},
)
@i18nModel("Buchhaltungs-Konfiguration")
class TrusteeAccountingConfig(PowerOnModel): class TrusteeAccountingConfig(PowerOnModel):
"""Per-instance accounting system configuration with encrypted credentials. """Per-instance accounting system configuration with encrypted credentials.
Each feature instance can connect to exactly one accounting system. Each feature instance can connect to exactly one accounting system.
Credentials are stored encrypted (decrypted at runtime by the AccountingBridge). Credentials are stored encrypted (decrypted at runtime by the AccountingBridge).
""" """
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
featureInstanceId: str = Field(description="FK -> FeatureInstance.id (1:1)") 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'") 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") 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") encryptedConfig: str = Field(default="", description="Encrypted JSON blob with connector credentials", json_schema_extra={"label": "Verschlüsselte Konfiguration"})
isActive: bool = Field(default=True) isActive: bool = Field(default=True, json_schema_extra={"label": "Aktiv"})
lastSyncAt: Optional[float] = Field(default=None, description="Timestamp of last sync attempt") 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") 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") 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})") 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") 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) mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
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"},
},
)
@i18nModel("Buchhaltungs-Synchronisation")
class TrusteeAccountingSync(PowerOnModel): class TrusteeAccountingSync(PowerOnModel):
"""Tracks which position was synced to which external system and when. """Tracks which position was synced to which external system and when.
Used for duplicate prevention, audit trail, and retry logic. Used for duplicate prevention, audit trail, and retry logic.
""" """
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
positionId: str = Field(description="FK -> TrusteePosition.id") positionId: str = Field(description="FK -> TrusteePosition.id", json_schema_extra={"label": "Position"})
featureInstanceId: str = Field(description="FK -> FeatureInstance.id") featureInstanceId: str = Field(description="FK -> FeatureInstance.id", json_schema_extra={"label": "Feature-Instanz"})
connectorType: str = Field(description="Connector type at time of sync") 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") 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") 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") 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)") 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") syncedAt: Optional[float] = Field(default=None, description="Timestamp of successful sync", json_schema_extra={"label": "Synchronisiert am"})
errorMessage: Optional[str] = Field(default=None) 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)") 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) 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"},
},
)

View file

@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
# Feature metadata # Feature metadata
FEATURE_CODE = "trustee" FEATURE_CODE = "trustee"
FEATURE_LABEL = {"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"} FEATURE_LABEL = "Treuhand"
FEATURE_ICON = "mdi-briefcase" FEATURE_ICON = "mdi-briefcase"
# UI Objects for RBAC catalog # UI Objects for RBAC catalog
@ -20,37 +20,47 @@ FEATURE_ICON = "mdi-briefcase"
UI_OBJECTS = [ UI_OBJECTS = [
{ {
"objectKey": "ui.feature.trustee.dashboard", "objectKey": "ui.feature.trustee.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, "label": "Dashboard",
"meta": {"area": "dashboard"} "meta": {"area": "dashboard"}
}, },
{ {
"objectKey": "ui.feature.trustee.positions", "objectKey": "ui.feature.trustee.positions",
"label": {"en": "Positions", "de": "Positionen", "fr": "Positions"}, "label": "Positionen",
"meta": {"area": "positions"} "meta": {"area": "positions"}
}, },
{ {
"objectKey": "ui.feature.trustee.documents", "objectKey": "ui.feature.trustee.documents",
"label": {"en": "Documents", "de": "Dokumente", "fr": "Documents"}, "label": "Dokumente",
"meta": {"area": "documents"} "meta": {"area": "documents"}
}, },
{ {
"objectKey": "ui.feature.trustee.expense-import", "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"} "meta": {"area": "expense-import"}
}, },
{ {
"objectKey": "ui.feature.trustee.scan-upload", "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"} "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", "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} "meta": {"area": "settings", "admin_only": True}
}, },
{ {
"objectKey": "ui.feature.trustee.instance-roles", "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} "meta": {"area": "admin", "admin_only": True}
}, },
] ]
@ -60,7 +70,7 @@ UI_OBJECTS = [
DATA_OBJECTS = [ DATA_OBJECTS = [
{ {
"objectKey": "data.feature.trustee.TrusteeOrganisation", "objectKey": "data.feature.trustee.TrusteeOrganisation",
"label": {"en": "Organisation", "de": "Organisation", "fr": "Organisation"}, "label": "Organisation",
"meta": { "meta": {
"table": "TrusteeOrganisation", "table": "TrusteeOrganisation",
"fields": ["id", "label", "enabled"], "fields": ["id", "label", "enabled"],
@ -70,7 +80,7 @@ DATA_OBJECTS = [
}, },
{ {
"objectKey": "data.feature.trustee.TrusteePosition", "objectKey": "data.feature.trustee.TrusteePosition",
"label": {"en": "Position", "de": "Position", "fr": "Position"}, "label": "Position",
"meta": { "meta": {
"table": "TrusteePosition", "table": "TrusteePosition",
"fields": ["id", "label", "description", "organisationId"], "fields": ["id", "label", "description", "organisationId"],
@ -80,12 +90,12 @@ DATA_OBJECTS = [
}, },
{ {
"objectKey": "data.feature.trustee.TrusteeDocument", "objectKey": "data.feature.trustee.TrusteeDocument",
"label": {"en": "Document", "de": "Dokument", "fr": "Document"}, "label": "Dokument",
"meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]} "meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]}
}, },
{ {
"objectKey": "data.feature.trustee.TrusteeAccountingConfig", "objectKey": "data.feature.trustee.TrusteeAccountingConfig",
"label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"}, "label": "Buchhaltungs-Konfiguration",
"meta": { "meta": {
"table": "TrusteeAccountingConfig", "table": "TrusteeAccountingConfig",
"fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"], "fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"],
@ -95,37 +105,37 @@ DATA_OBJECTS = [
}, },
{ {
"objectKey": "data.feature.trustee.TrusteeAccountingSync", "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"]} "meta": {"table": "TrusteeAccountingSync", "fields": ["id", "positionId", "syncStatus", "externalId"]}
}, },
{ {
"objectKey": "data.feature.trustee.TrusteeDataAccount", "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"]} "meta": {"table": "TrusteeDataAccount", "fields": ["id", "accountNumber", "label", "accountType", "accountGroup", "currency", "isActive"]}
}, },
{ {
"objectKey": "data.feature.trustee.TrusteeDataJournalEntry", "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"]} "meta": {"table": "TrusteeDataJournalEntry", "fields": ["id", "externalId", "bookingDate", "reference", "description", "currency", "totalAmount"]}
}, },
{ {
"objectKey": "data.feature.trustee.TrusteeDataJournalLine", "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"]} "meta": {"table": "TrusteeDataJournalLine", "fields": ["id", "journalEntryId", "accountNumber", "debitAmount", "creditAmount", "currency", "taxCode", "costCenter", "description"]}
}, },
{ {
"objectKey": "data.feature.trustee.TrusteeDataContact", "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"]} "meta": {"table": "TrusteeDataContact", "fields": ["id", "externalId", "contactType", "contactNumber", "name", "address", "zip", "city", "country", "email", "phone", "vatNumber"]}
}, },
{ {
"objectKey": "data.feature.trustee.TrusteeDataAccountBalance", "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"]} "meta": {"table": "TrusteeDataAccountBalance", "fields": ["id", "accountNumber", "periodYear", "periodMonth", "openingBalance", "debitTotal", "creditTotal", "closingBalance", "currency"]}
}, },
{ {
"objectKey": "data.feature.trustee.*", "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"} "meta": {"wildcard": True, "description": "Wildcard for all trustee data tables"}
}, },
] ]
@ -135,127 +145,379 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [ RESOURCE_OBJECTS = [
{ {
"objectKey": "resource.feature.trustee.documents.create", "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"} "meta": {"endpoint": "/api/trustee/{instanceId}/documents", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.trustee.documents.update", "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"} "meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "PUT"}
}, },
{ {
"objectKey": "resource.feature.trustee.documents.delete", "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"} "meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "DELETE"}
}, },
{ {
"objectKey": "resource.feature.trustee.positions.create", "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"} "meta": {"endpoint": "/api/trustee/{instanceId}/positions", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.trustee.positions.update", "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"} "meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "PUT"}
}, },
{ {
"objectKey": "resource.feature.trustee.positions.delete", "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"} "meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "DELETE"}
}, },
{ {
"objectKey": "resource.feature.trustee.instance-roles.manage", "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} "meta": {"endpoint": "/api/trustee/{instanceId}/instance-roles", "method": "ALL", "admin_only": True}
}, },
{ {
"objectKey": "resource.feature.trustee.accounting.manage", "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} "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/config", "method": "ALL", "admin_only": True}
}, },
{ {
"objectKey": "resource.feature.trustee.accounting.sync", "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"} "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.trustee.accounting.view", "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"} "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 # Template roles for this feature with AccessRules
# Each role defines default UI and DATA permissions # Each role defines default UI and DATA permissions
# Note: UI item=None means ALL views, specific items restrict to named views # Note: UI item=None means ALL views, specific items restrict to named views
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) # 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 = [ TEMPLATE_ROLES = [
{ {
"roleLabel": "trustee-viewer", "roleLabel": "trustee-viewer",
"description": { "description": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)",
"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)",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "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.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"}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
], ],
}, },
{ {
"roleLabel": "trustee-user", "roleLabel": "trustee-user",
"description": { "description": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "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.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.expense-import", "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"}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
], ],
}, },
{ {
"roleLabel": "trustee-admin", "roleLabel": "trustee-admin",
"description": { "description": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"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.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", "roleLabel": "trustee-accountant",
"description": { "description": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "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.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": "UI", "item": "ui.feature.trustee.settings", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, {"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.sync", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "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", "roleLabel": "trustee-client",
"description": { "description": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
"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",
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
@ -293,6 +555,21 @@ def getTemplateRoles() -> List[Dict[str, Any]]:
return TEMPLATE_ROLES 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]]: def getDataObjects() -> List[Dict[str, Any]]:
"""Return DATA objects for RBAC catalog registration.""" """Return DATA objects for RBAC catalog registration."""
return DATA_OBJECTS return DATA_OBJECTS
@ -358,7 +635,8 @@ def _syncTemplateRolesToDb() -> int:
try: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface() rootInterface = getRootInterface()
# Get existing template roles for this feature (Pydantic models) # Get existing template roles for this feature (Pydantic models)
@ -378,7 +656,7 @@ def _syncTemplateRolesToDb() -> int:
# Create new template role # Create new template role
newRole = Role( newRole = Role(
roleLabel=roleLabel, roleLabel=roleLabel,
description=roleTemplate.get("description", {}), description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE, featureCode=FEATURE_CODE,
mandateId=None, # Global template mandateId=None, # Global template
featureInstanceId=None, featureInstanceId=None,

View file

@ -37,6 +37,10 @@ from modules.datamodels.datamodelPagination import (
PaginationMetadata, PaginationMetadata,
normalize_pagination_dict, normalize_pagination_dict,
) )
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureTrustee")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -116,6 +120,78 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
return str(instance.mandateId) 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) # ATTRIBUTES ENDPOINT (for FormGeneratorTable)
# ============================================================================ # ============================================================================
@ -385,7 +461,7 @@ def create_organisation(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createOrganisation(data.model_dump()) result = interface.createOrganisation(data.model_dump())
if not result: 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 return result
@ -408,7 +484,7 @@ def update_organisation(
result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"})) result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"}))
if not result: 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 return result
@ -430,7 +506,7 @@ def delete_organisation(
success = interface.deleteOrganisation(orgId) success = interface.deleteOrganisation(orgId)
if not success: 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"} return {"message": f"Organisation {orgId} deleted"}
@ -498,7 +574,7 @@ def create_role(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createRole(data.model_dump()) result = interface.createRole(data.model_dump())
if not result: 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 return result
@ -521,7 +597,7 @@ def update_role(
result = interface.updateRole(roleId, data.model_dump(exclude={"id"})) result = interface.updateRole(roleId, data.model_dump(exclude={"id"}))
if not result: 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 return result
@ -543,7 +619,7 @@ def delete_role(
success = interface.deleteRole(roleId) success = interface.deleteRole(roleId)
if not success: 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"} return {"message": f"Role {roleId} deleted"}
@ -641,7 +717,7 @@ def create_access(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createAccess(data.model_dump()) result = interface.createAccess(data.model_dump())
if not result: 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 return result
@ -664,7 +740,7 @@ def update_access(
result = interface.updateAccess(accessId, data.model_dump(exclude={"id"})) result = interface.updateAccess(accessId, data.model_dump(exclude={"id"}))
if not result: 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 return result
@ -686,7 +762,7 @@ def delete_access(
success = interface.deleteAccess(accessId) success = interface.deleteAccess(accessId)
if not success: 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"} return {"message": f"Access {accessId} deleted"}
@ -769,7 +845,7 @@ def create_contract(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createContract(data.model_dump()) result = interface.createContract(data.model_dump())
if not result: 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 return result
@ -792,7 +868,7 @@ def update_contract(
result = interface.updateContract(contractId, data.model_dump(exclude={"id"})) result = interface.updateContract(contractId, data.model_dump(exclude={"id"}))
if not result: 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 return result
@ -814,7 +890,7 @@ def delete_contract(
success = interface.deleteContract(contractId) success = interface.deleteContract(contractId)
if not success: 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"} return {"message": f"Contract {contractId} deleted"}
@ -938,7 +1014,7 @@ def get_document_data(
data = interface.getDocumentData(documentId) data = interface.getDocumentData(documentId)
if not data: 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( return StreamingResponse(
io.BytesIO(data), io.BytesIO(data),
@ -995,7 +1071,7 @@ async def create_document(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createDocument(body) result = interface.createDocument(body)
if not result: 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 return result
@ -1025,7 +1101,7 @@ async def upload_document(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createDocument(docData) result = interface.createDocument(docData)
if not result: 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 return result
@ -1048,7 +1124,7 @@ def update_document(
result = interface.updateDocument(documentId, data.model_dump(exclude={"id"})) result = interface.updateDocument(documentId, data.model_dump(exclude={"id"}))
if not result: 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 return result
@ -1070,7 +1146,7 @@ def delete_document(
success = interface.deleteDocument(documentId) success = interface.deleteDocument(documentId)
if not success: 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"} return {"message": f"Document {documentId} deleted"}
@ -1220,7 +1296,7 @@ def create_position(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createPosition(data.model_dump()) result = interface.createPosition(data.model_dump())
if not result: 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 return result
@ -1243,7 +1319,7 @@ def update_position(
result = interface.updatePosition(positionId, data.model_dump(exclude={"id"})) result = interface.updatePosition(positionId, data.model_dump(exclude={"id"}))
if not result: 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 return result
@ -1265,7 +1341,7 @@ def delete_position(
success = interface.deletePosition(positionId) success = interface.deletePosition(positionId)
if not success: 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"} return {"message": f"Position {positionId} deleted"}
@ -1398,7 +1474,7 @@ async def save_accounting_config(
if not plainConfig: if not plainConfig:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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") encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
@ -1511,7 +1587,7 @@ async def sync_positions_to_accounting(
positionIds = data.get("positionIds", []) positionIds = data.get("positionIds", [])
if not 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) results = await bridge.pushBatchToAccounting(instanceId, positionIds)
failed = [r for r in results if not r.success] failed = [r for r in results if not r.success]
@ -1678,8 +1754,6 @@ def get_positions_by_document(
# ===== Instance Roles Management ===== # ===== Instance Roles Management =====
# These endpoints allow feature admins to manage instance-specific roles and their AccessRules # 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: def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
""" """
@ -1711,7 +1785,7 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
if not hasAdminPermission: if not hasAdminPermission:
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Keine Berechtigung zur Rollenverwaltung" detail=routeApiMsg("Keine Berechtigung zur Rollenverwaltung")
) )
return mandateId return mandateId

View file

@ -5,27 +5,32 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.i18nRegistry import i18nModel
import uuid import uuid
@i18nModel("Workspace Benutzereinstellungen")
class WorkspaceUserSettings(PowerOnModel): class WorkspaceUserSettings(PowerOnModel):
"""Per-user workspace settings. None values mean 'use instance default'.""" """Benutzerspezifische Workspace-Einstellungen. None = Instanz-Standard."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(
userId: str = Field(description="User ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) default_factory=lambda: str(uuid.uuid4()),
mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) description="Primary key",
featureInstanceId: str = Field(description="Feature Instance ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
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}) )
userId: str = Field(
description="User ID",
registerModelLabels( json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
"WorkspaceUserSettings", )
{"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"}, mandateId: str = Field(
{ description="Mandate ID",
"id": {"en": "ID", "de": "ID"}, json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
"userId": {"en": "User ID", "de": "Benutzer-ID"}, )
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, featureInstanceId: str = Field(
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID"}, description="Feature Instance ID",
"maxAgentRounds": {"en": "Max Agent Rounds", "de": "Max. Agenten-Runden"}, 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},
)

View file

@ -12,32 +12,28 @@ from typing import Dict, List, Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FEATURE_CODE = "workspace" FEATURE_CODE = "workspace"
FEATURE_LABEL = {"en": "AI Workspace", "de": "AI Workspace", "fr": "AI Workspace"} FEATURE_LABEL = "AI Workspace"
FEATURE_ICON = "mdi-brain" FEATURE_ICON = "mdi-brain"
UI_OBJECTS = [ UI_OBJECTS = [
{ {
"objectKey": "ui.feature.workspace.dashboard", "objectKey": "ui.feature.workspace.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, "label": "Dashboard",
"meta": {"area": "dashboard"} "meta": {"area": "dashboard"}
}, },
{ {
"objectKey": "ui.feature.workspace.editor", "objectKey": "ui.feature.workspace.editor",
"label": {"en": "Editor", "de": "Editor", "fr": "Editeur"}, "label": "Editor",
"meta": {"area": "editor"} "meta": {"area": "editor"}
}, },
{ {
"objectKey": "ui.feature.workspace.settings", "objectKey": "ui.feature.workspace.settings",
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Parametres"}, "label": "Einstellungen",
"meta": {"area": "settings"} "meta": {"area": "settings"}
}, },
{ {
"objectKey": "ui.feature.workspace.rag-insights", "objectKey": "ui.feature.workspace.rag-insights",
"label": { "label": "Wissens-Insights",
"en": "Knowledge insights",
"de": "Wissens-Insights",
"fr": "Aperçu des connaissances",
},
"meta": {"area": "rag-insights"}, "meta": {"area": "rag-insights"},
}, },
] ]
@ -45,37 +41,37 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [ RESOURCE_OBJECTS = [
{ {
"objectKey": "resource.feature.workspace.start", "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"} "meta": {"endpoint": "/api/workspace/{instanceId}/start/stream", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.workspace.stop", "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"} "meta": {"endpoint": "/api/workspace/{instanceId}/{workflowId}/stop", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.workspace.files", "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"} "meta": {"endpoint": "/api/workspace/{instanceId}/files", "method": "GET"}
}, },
{ {
"objectKey": "resource.feature.workspace.folders", "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"} "meta": {"endpoint": "/api/workspace/{instanceId}/folders", "method": "GET"}
}, },
{ {
"objectKey": "resource.feature.workspace.datasources", "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"} "meta": {"endpoint": "/api/workspace/{instanceId}/datasources", "method": "GET"}
}, },
{ {
"objectKey": "resource.feature.workspace.voice", "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"} "meta": {"endpoint": "/api/workspace/{instanceId}/voice/*", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.workspace.edits", "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"} "meta": {"endpoint": "/api/workspace/{instanceId}/edit/*", "method": "POST"}
}, },
] ]
@ -83,11 +79,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{ {
"roleLabel": "workspace-viewer", "roleLabel": "workspace-viewer",
"description": { "description": "Workspace Betrachter - Workspace ansehen (nur lesen)",
"en": "Workspace Viewer - View workspace (read-only)",
"de": "Workspace Betrachter - Workspace ansehen (nur lesen)",
"fr": "Visualiseur Workspace - Consulter le workspace (lecture seule)"
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.workspace.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.workspace.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.workspace.editor", "view": True}, {"context": "UI", "item": "ui.feature.workspace.editor", "view": True},
@ -98,11 +90,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "workspace-user", "roleLabel": "workspace-user",
"description": { "description": "Workspace Benutzer - AI Workspace und Tools nutzen",
"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"
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.workspace.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.workspace.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.workspace.editor", "view": True}, {"context": "UI", "item": "ui.feature.workspace.editor", "view": True},
@ -120,11 +108,7 @@ TEMPLATE_ROLES = [
}, },
{ {
"roleLabel": "workspace-admin", "roleLabel": "workspace-admin",
"description": { "description": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)",
"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"
},
"accessRules": [ "accessRules": [
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True}, {"context": "RESOURCE", "item": None, "view": True},
@ -194,6 +178,7 @@ def _syncTemplateRolesToDb() -> int:
try: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface() rootInterface = getRootInterface()
@ -211,7 +196,7 @@ def _syncTemplateRolesToDb() -> int:
else: else:
newRole = Role( newRole = Role(
roleLabel=roleLabel, roleLabel=roleLabel,
description=roleTemplate.get("description", {}), description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE, featureCode=FEATURE_CODE,
mandateId=None, mandateId=None,
featureInstanceId=None, featureInstanceId=None,

View file

@ -29,6 +29,8 @@ from modules.interfaces.interfaceAiObjects import AiObjects
from modules.serviceCenter.core.serviceStreaming import get_event_manager from modules.serviceCenter.core.serviceStreaming import get_event_manager
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit
from modules.shared.timeUtils import parseTimestamp from modules.shared.timeUtils import parseTimestamp
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureWorkspace")
logger = logging.getLogger(__name__) 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") raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found")
featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId) featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId)
if not featureAccess or not featureAccess.enabled: 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 mandateId = str(instance.mandateId) if instance.mandateId else None
instanceConfig = instance.config if hasattr(instance, "config") and instance.config else {} instanceConfig = instance.config if hasattr(instance, "config") and instance.config else {}
return mandateId, instanceConfig return mandateId, instanceConfig
@ -1178,10 +1180,10 @@ async def getFileContent(
fileData = fileRecord if isinstance(fileRecord, dict) else fileRecord.model_dump() fileData = fileRecord if isinstance(fileRecord, dict) else fileRecord.model_dump()
filePath = fileData.get("filePath") filePath = fileData.get("filePath")
if not 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 import os
if not os.path.isfile(filePath): 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") mimeType = fileData.get("mimeType", "application/octet-stream")
with open(filePath, "rb") as fh: with open(filePath, "rb") as fh:
content = fh.read() content = fh.read()
@ -1436,11 +1438,11 @@ async def listFeatureConnectionTables(
rootIf = getRootInterface() rootIf = getRootInterface()
inst = rootIf.getFeatureInstance(fiId) inst = rootIf.getFeatureInstance(fiId)
if not inst: 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 mandateId = str(inst.mandateId) if inst.mandateId else None
if wsMandateId and mandateId and mandateId != wsMandateId: 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() catalog = getCatalogService()
try: try:
@ -1495,12 +1497,12 @@ async def listParentObjects(
rootIf = getRootInterface() rootIf = getRootInterface()
inst = rootIf.getFeatureInstance(fiId) inst = rootIf.getFeatureInstance(fiId)
if not inst: 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 featureCode = inst.featureCode
mandateId = str(inst.mandateId) if inst.mandateId else "" mandateId = str(inst.mandateId) if inst.mandateId else ""
if wsMandateId and mandateId and mandateId != wsMandateId: 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() catalog = getCatalogService()
parentObj = None parentObj = None
@ -1614,7 +1616,7 @@ async def createFeatureDataSource(
inst = rootIf.getFeatureInstance(body.featureInstanceId) inst = rootIf.getFeatureInstance(body.featureInstanceId)
mandateId = str(inst.mandateId) if inst else (str(context.mandateId) if context.mandateId else "") mandateId = str(inst.mandateId) if inst else (str(context.mandateId) if context.mandateId else "")
if wsMandateId and mandateId and mandateId != wsMandateId: 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( fds = FeatureDataSource(
featureInstanceId=body.featureInstanceId, featureInstanceId=body.featureInstanceId,
@ -1814,7 +1816,7 @@ async def synthesizeVoice(
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
text = body.get("text", "") text = body.get("text", "")
if not 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"}) return JSONResponse({"audio": None, "note": "TTS via browser Speech Synthesis API recommended"})
@ -1858,7 +1860,7 @@ async def acceptEdit(
try: try:
success = dbMgmt.updateFileData(edit.fileId, edit.newContent.encode("utf-8")) success = dbMgmt.updateFileData(edit.fileId, edit.newContent.encode("utf-8"))
if not success: 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View file

@ -25,6 +25,7 @@ from modules.datamodels.datamodelRbac import (
AccessRuleContext, AccessRuleContext,
Role, Role,
) )
from modules.datamodels.datamodelUtils import coerce_text_multilingual
from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelMembership import ( from modules.datamodels.datamodelMembership import (
UserMandate, UserMandate,
@ -547,7 +548,7 @@ def initRoles(db: DatabaseConnector) -> None:
standardRoles = [ standardRoles = [
Role( Role(
roleLabel="admin", 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 mandateId=None, # Global template role
featureInstanceId=None, featureInstanceId=None,
featureCode=None, featureCode=None,
@ -555,7 +556,7 @@ def initRoles(db: DatabaseConnector) -> None:
), ),
Role( Role(
roleLabel="user", 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 mandateId=None, # Global template role
featureInstanceId=None, featureInstanceId=None,
featureCode=None, featureCode=None,
@ -563,7 +564,7 @@ def initRoles(db: DatabaseConnector) -> None:
), ),
Role( Role(
roleLabel="viewer", 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 mandateId=None, # Global template role
featureInstanceId=None, featureInstanceId=None,
featureCode=None, featureCode=None,
@ -728,7 +729,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
newRole = Role( newRole = Role(
id=newRoleId, id=newRoleId,
roleLabel=roleLabel, roleLabel=roleLabel,
description=templateRole.get("description", {}), description=coerce_text_multilingual(templateRole.get("description", {})),
mandateId=mandateId, mandateId=mandateId,
featureInstanceId=None, featureInstanceId=None,
featureCode=None, featureCode=None,
@ -797,11 +798,7 @@ def _initSysAdminRole(db: DatabaseConnector, mandateId: str) -> Optional[str]:
logger.info("Creating sysadmin role in root mandate") logger.info("Creating sysadmin role in root mandate")
sysadminRole = Role( sysadminRole = Role(
roleLabel="sysadmin", roleLabel="sysadmin",
description={ description=coerce_text_multilingual("System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten"),
"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"
},
mandateId=mandateId, mandateId=mandateId,
featureInstanceId=None, featureInstanceId=None,
featureCode=None, featureCode=None,

View file

@ -15,6 +15,7 @@ from typing import List, Dict, Any, Optional
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.datamodels.datamodelRbac import Role, AccessRule from modules.datamodels.datamodelRbac import Role, AccessRule
from modules.datamodels.datamodelUtils import coerce_text_multilingual
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -198,6 +199,9 @@ class FeatureInterface:
# Copy template roles if requested # Copy template roles if requested
if copyTemplateRoles: if copyTemplateRoles:
self._copyTemplateRoles(featureCode, mandateId, instanceId) self._copyTemplateRoles(featureCode, mandateId, instanceId)
# Copy template workflows (if feature defines TEMPLATE_WORKFLOWS)
self._copyTemplateWorkflows(featureCode, mandateId, instanceId)
cleanedRecord = dict(createdInstance) cleanedRecord = dict(createdInstance)
return FeatureInstance(**cleanedRecord) return FeatureInstance(**cleanedRecord)
@ -206,6 +210,72 @@ class FeatureInterface:
logger.error(f"Error creating feature instance: {e}") logger.error(f"Error creating feature instance: {e}")
raise ValueError(f"Failed to create 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: def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
""" """
Copy feature-specific template roles to a new instance. Copy feature-specific template roles to a new instance.
@ -268,7 +338,7 @@ class FeatureInterface:
newRole = Role( newRole = Role(
id=newRoleId, id=newRoleId,
roleLabel=templateRole.get("roleLabel"), roleLabel=templateRole.get("roleLabel"),
description=templateRole.get("description", {}), description=coerce_text_multilingual(templateRole.get("description", {})),
featureCode=featureCode, featureCode=featureCode,
mandateId=mandateId, mandateId=mandateId,
featureInstanceId=instanceId, featureInstanceId=instanceId,
@ -354,7 +424,7 @@ class FeatureInterface:
newRole = Role( newRole = Role(
id=newRoleId, id=newRoleId,
roleLabel=templateRole.get("roleLabel"), roleLabel=templateRole.get("roleLabel"),
description=templateRole.get("description", {}), description=coerce_text_multilingual(templateRole.get("description", {})),
featureCode=featureCode, featureCode=featureCode,
mandateId=mandateId, mandateId=mandateId,
featureInstanceId=featureInstanceId, featureInstanceId=featureInstanceId,

View file

@ -13,6 +13,8 @@ from modules.shared.configuration import APP_CONFIG
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getCurrentUser
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeAdmin")
# Static folder setup - using absolute path from app root # Static folder setup - using absolute path from app root
baseDir = FilePath(__file__).parent.parent.parent # Go up to gateway 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") allowedOrigins = APP_CONFIG.get("APP_ALLOWED_ORIGINS")
if not allowedOrigins: if not allowedOrigins:
raise HTTPException( raise HTTPException(
status_code=500, detail="APP_ALLOWED_ORIGINS configuration is required" status_code=500, detail=routeApiMsg("APP_ALLOWED_ORIGINS configuration is required")
) )
return { return {
@ -59,17 +61,17 @@ def get_environment(
apiBaseUrl = APP_CONFIG.get("APP_API_URL") apiBaseUrl = APP_CONFIG.get("APP_API_URL")
if not apiBaseUrl: if not apiBaseUrl:
raise HTTPException( 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") environment = APP_CONFIG.get("APP_ENV")
if not environment: 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") instanceLabel = APP_CONFIG.get("APP_ENV_LABEL")
if not instanceLabel: if not instanceLabel:
raise HTTPException( raise HTTPException(
status_code=500, detail="APP_ENV_LABEL configuration is required" status_code=500, detail=routeApiMsg("APP_ENV_LABEL configuration is required")
) )
return { return {
@ -91,5 +93,5 @@ def options_route(request: Request, fullPath: str) -> Response:
def favicon(request: Request) -> FileResponse: def favicon(request: Request) -> FileResponse:
favicon_path = staticFolder / "favicon.ico" favicon_path = staticFolder / "favicon.ico"
if not favicon_path.exists(): 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") return FileResponse(str(favicon_path), media_type="image/x-icon")

View file

@ -27,6 +27,8 @@ from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService from modules.security.rbacCatalog import getCatalogService
from modules.routes.routeNotifications import create_access_change_notification from modules.routes.routeNotifications import create_access_change_notification
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeAdminFeatures")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -418,7 +420,7 @@ def list_feature_instances(
if not context.mandateId: if not context.mandateId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required" detail=routeApiMsg("X-Mandate-Id header is required")
) )
try: try:
@ -483,7 +485,7 @@ def get_feature_instance_filter_values(
) -> list: ) -> list:
"""Return distinct filter values for a column in feature instances.""" """Return distinct filter values for a column in feature instances."""
if not context.mandateId: 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: try:
from modules.routes.routeDataUsers import _handleFilterValuesRequest from modules.routes.routeDataUsers import _handleFilterValuesRequest
rootInterface = getRootInterface() rootInterface = getRootInterface()
@ -530,7 +532,7 @@ def get_feature_instance(
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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() return instance.model_dump()
@ -563,14 +565,14 @@ def create_feature_instance(
if not context.mandateId: if not context.mandateId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Check mandate admin permission
if not _hasMandateAdminRole(context): if not _hasMandateAdminRole(context):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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: try:
@ -670,14 +672,14 @@ def delete_feature_instance(
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Check mandate admin permission
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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) featureInterface.deleteFeatureInstance(instanceId)
@ -737,14 +739,14 @@ def updateFeatureInstance(
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Check mandate admin permission
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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) # Build update data (only non-None values)
@ -763,7 +765,7 @@ def updateFeatureInstance(
if not updated: if not updated:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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 # Clear chatbot config cache when config was updated for chatbot instances
@ -820,14 +822,14 @@ def sync_instance_roles(
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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) # Check admin permission (Mandate-Admin or Feature-Admin)
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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) result = featureInterface.syncRolesFromTemplate(instanceId, addOnly)
@ -1061,7 +1063,7 @@ def list_feature_instance_users(
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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) # 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") 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 context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.hasSysAdminRole: 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) featureAccesses = rootInterface.getFeatureAccessesByInstance(instanceId)
result = [] result = []
for fa in featureAccesses: for fa in featureAccesses:
@ -1217,14 +1219,14 @@ def add_user_to_feature_instance(
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail=routeApiMsg("Access denied to this feature instance")
) )
# Check admin permission # Check admin permission
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Verify user exists
@ -1238,7 +1240,7 @@ def add_user_to_feature_instance(
if not data.roleIds: if not data.roleIds:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 from modules.datamodels.datamodelRbac import Role
@ -1325,14 +1327,14 @@ def remove_user_from_feature_instance(
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail=routeApiMsg("Access denied to this feature instance")
) )
# Check admin permission # Check admin permission
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Find FeatureAccess record
@ -1341,7 +1343,7 @@ def remove_user_from_feature_instance(
if not existingAccess: if not existingAccess:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) featureAccessId = str(existingAccess.id)
@ -1415,14 +1417,14 @@ def update_feature_instance_user_roles(
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail=routeApiMsg("Access denied to this feature instance")
) )
# Check admin permission # Check admin permission
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Find FeatureAccess record
@ -1431,7 +1433,7 @@ def update_feature_instance_user_roles(
if not existingAccess: if not existingAccess:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) featureAccessId = str(existingAccess.id)
@ -1523,7 +1525,7 @@ def get_feature_instance_available_roles(
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Get roles for this instance using interface method
@ -1619,7 +1621,7 @@ def _renameFeatureInstance(
instance = featureInterface.getFeatureInstance(instanceId) instance = featureInterface.getFeatureInstance(instanceId)
if not instance: 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) userId = str(context.user.id)
isInstanceAdmin = False isInstanceAdmin = False
@ -1637,11 +1639,11 @@ def _renameFeatureInstance(
break break
if not isInstanceAdmin: 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()}) updated = featureInterface.updateFeatureInstance(instanceId, {"label": data.label.strip()})
if not updated: 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} return {"id": instanceId, "label": updated.label}

View file

@ -21,8 +21,11 @@ from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelRbac import Role, AccessRule from modules.datamodels.datamodelRbac import Role, AccessRule
from modules.datamodels.datamodelUtils import coerce_text_multilingual
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeAdminRbacExport")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -165,7 +168,7 @@ async def import_global_rbac(
if "roles" not in data: if "roles" not in data:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing 'roles' field in import data" detail=routeApiMsg("Missing 'roles' field in import data")
) )
rootInterface = getRootInterface() rootInterface = getRootInterface()
@ -227,7 +230,7 @@ async def import_global_rbac(
# Create new role # Create new role
newRole = Role( newRole = Role(
roleLabel=roleLabel, roleLabel=roleLabel,
description=roleData.get("description", {}), description=coerce_text_multilingual(roleData.get("description", {})),
featureCode=featureCode, featureCode=featureCode,
mandateId=None, mandateId=None,
featureInstanceId=None, featureInstanceId=None,
@ -298,14 +301,14 @@ def export_mandate_rbac(
if not context.mandateId: if not context.mandateId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Check mandate admin permission
if not _hasMandateAdminRole(context): if not _hasMandateAdminRole(context):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to export RBAC" detail=routeApiMsg("Mandate-Admin role required to export RBAC")
) )
try: try:
@ -392,14 +395,14 @@ async def import_mandate_rbac(
if not context.mandateId: if not context.mandateId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Check mandate admin permission
if not _hasMandateAdminRole(context): if not _hasMandateAdminRole(context):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to import RBAC" detail=routeApiMsg("Mandate-Admin role required to import RBAC")
) )
try: try:
@ -417,7 +420,7 @@ async def import_mandate_rbac(
if "roles" not in data: if "roles" not in data:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing 'roles' field in import data" detail=routeApiMsg("Missing 'roles' field in import data")
) )
rootInterface = getRootInterface() rootInterface = getRootInterface()
@ -482,7 +485,7 @@ async def import_mandate_rbac(
# Create new role at mandate level # Create new role at mandate level
newRole = Role( newRole = Role(
roleLabel=roleLabel, roleLabel=roleLabel,
description=roleData.get("description", {}), description=coerce_text_multilingual(roleData.get("description", {})),
featureCode=featureCode, featureCode=featureCode,
mandateId=str(context.mandateId), mandateId=str(context.mandateId),
featureInstanceId=None, featureInstanceId=None,

View file

@ -23,6 +23,8 @@ from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelMembership import UserMandate
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeAdminRbacRules")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -113,7 +115,7 @@ def get_permissions(
if not interface.rbac: if not interface.rbac:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="RBAC interface not available" detail=routeApiMsg("RBAC interface not available")
) )
# MULTI-TENANT: Get permissions using context (mandateId/featureInstanceId) # MULTI-TENANT: Get permissions using context (mandateId/featureInstanceId)
@ -189,7 +191,7 @@ def get_all_permissions(
if not interface.rbac: if not interface.rbac:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="RBAC interface not available" detail=routeApiMsg("RBAC interface not available")
) )
# Determine which contexts to fetch # Determine which contexts to fetch
@ -363,7 +365,7 @@ def get_access_rules(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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 # Get interface - uses root interface for admin access
interface = getRootInterface() interface = getRootInterface()
@ -488,11 +490,11 @@ def get_access_rules_by_role(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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 # MandateAdmin: verify role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(roleId, adminMandateIds): 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() interface = getRootInterface()
@ -535,7 +537,7 @@ def get_access_rule(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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 # Get interface - uses root interface for admin access
interface = getRootInterface() interface = getRootInterface()
@ -550,7 +552,7 @@ def get_access_rule(
# MandateAdmin: verify rule's role belongs to their mandates # MandateAdmin: verify rule's role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(str(rule.roleId), adminMandateIds): 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 # Convert to dict for JSON serialization
return rule.model_dump() return rule.model_dump()
@ -586,7 +588,7 @@ def create_access_rule(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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 # Get interface - uses root interface for admin access
interface = getRootInterface() interface = getRootInterface()
@ -621,7 +623,7 @@ def create_access_rule(
# MandateAdmin: verify the rule's role belongs to their mandates # MandateAdmin: verify the rule's role belongs to their mandates
if not isSysAdmin and accessRule.roleId: if not isSysAdmin and accessRule.roleId:
if not _isRoleInAdminMandates(str(accessRule.roleId), adminMandateIds): 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 # Create rule
createdRule = interface.createAccessRule(accessRule) createdRule = interface.createAccessRule(accessRule)
@ -666,7 +668,7 @@ def update_access_rule(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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 # Get interface - uses root interface for admin access
interface = getRootInterface() interface = getRootInterface()
@ -681,7 +683,7 @@ def update_access_rule(
# MandateAdmin: verify existing rule's role belongs to their mandates # MandateAdmin: verify existing rule's role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(str(existingRule.roleId), adminMandateIds): 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 # Validate and parse access rule data
try: try:
@ -754,7 +756,7 @@ def delete_access_rule(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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 # Get interface - uses root interface for admin access
interface = getRootInterface() interface = getRootInterface()
@ -769,7 +771,7 @@ def delete_access_rule(
# MandateAdmin: verify rule's role belongs to their mandates # MandateAdmin: verify rule's role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(str(existingRule.roleId), adminMandateIds): 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 # Delete rule
success = interface.deleteAccessRule(ruleId) success = interface.deleteAccessRule(ruleId)
@ -835,7 +837,7 @@ def list_roles(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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() interface = getRootInterface()
@ -1008,7 +1010,7 @@ def get_roles_filter_values(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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() interface = getRootInterface()
dbRoles = interface.getAllRoles(pagination=None) dbRoles = interface.getAllRoles(pagination=None)
@ -1083,12 +1085,12 @@ def create_role(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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 # MandateAdmin: can only create roles in their own mandates
if not isSysAdmin: if not isSysAdmin:
if not role.mandateId or str(role.mandateId) not in adminMandateIds: 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() interface = getRootInterface()
@ -1142,7 +1144,7 @@ def get_role(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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() interface = getRootInterface()
@ -1156,7 +1158,7 @@ def get_role(
# MandateAdmin: verify role belongs to their mandates # MandateAdmin: verify role belongs to their mandates
if not isSysAdmin: if not isSysAdmin:
if not role.mandateId or str(role.mandateId) not in adminMandateIds: 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 { return {
"id": role.id, "id": role.id,
@ -1203,7 +1205,7 @@ def update_role(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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() interface = getRootInterface()
@ -1213,9 +1215,9 @@ def update_role(
if not existingRole: if not existingRole:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found") raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
if existingRole.isSystemRole and not existingRole.mandateId: 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: 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) updatedRole = interface.updateRole(roleId, role)
@ -1267,7 +1269,7 @@ def delete_role(
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds: 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() interface = getRootInterface()
@ -1277,9 +1279,9 @@ def delete_role(
if not existingRole: if not existingRole:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found") raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
if existingRole.isSystemRole and not existingRole.mandateId: 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: 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) success = interface.deleteRole(roleId)
if not success: if not success:

View file

@ -24,6 +24,8 @@ from modules.datamodels.datamodelMembership import (
) )
from modules.datamodels.datamodelFeatures import FeatureInstance, Feature from modules.datamodels.datamodelFeatures import FeatureInstance, Feature
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeAdminUserAccessOverview")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -116,7 +118,7 @@ def listUsersForOverview(
- List of user dictionaries with basic info - List of user dictionaries with basic info
""" """
if not _hasMandateAdminRole(context): 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: try:
interface = getRootInterface() interface = getRootInterface()
@ -209,7 +211,7 @@ def getUserAccessOverview(
- Resource access (what resources the user can use) - Resource access (what resources the user can use)
""" """
if not _hasMandateAdminRole(context): 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: try:
interface = getRootInterface() interface = getRootInterface()
@ -239,7 +241,7 @@ def getUserAccessOverview(
break break
if not userInAdminMandate: 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 # Get user
user = interface.getUser(userId) user = interface.getUser(userId)
@ -528,7 +530,7 @@ def getEffectivePermissions(
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
# Check if user has admin role in any mandate # Check if user has admin role in any mandate
if not _hasMandateAdminRole(context): 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: try:
interface = getRootInterface() interface = getRootInterface()
@ -550,7 +552,7 @@ def getEffectivePermissions(
break break
if not adminMandateIds: if not adminMandateIds:
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail=routeApiMsg("Insufficient permissions"))
userInAdminMandate = False userInAdminMandate = False
for mid in adminMandateIds: for mid in adminMandateIds:
@ -559,7 +561,7 @@ def getEffectivePermissions(
break break
if not userInAdminMandate: 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 # Get user
user = interface.getUser(userId) user = interface.getUser(userId)

View file

@ -9,6 +9,9 @@ from modules.auth import limiter
# Import the attribute definition and helper functions # Import the attribute definition and helper functions
from modules.shared.attributeUtils import getModelClasses, getModelAttributeDefinitions, AttributeResponse, AttributeDefinition from modules.shared.attributeUtils import getModelClasses, getModelAttributeDefinitions, AttributeResponse, AttributeDefinition
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeAttributes")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -42,8 +45,8 @@ def get_entity_attributes(
# Check if entity type is known # Check if entity type is known
if entityType not in modelClasses: if entityType not in modelClasses:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Entity type '{entityType}' not found." detail=routeApiMsg("Entitätstyp nicht gefunden.") + f" ({entityType})",
) )
# Get model class and derive attributes from it # Get model class and derive attributes from it

View file

@ -38,6 +38,9 @@ from modules.datamodels.datamodelBilling import (
BillingStatisticsChartData, BillingStatisticsChartData,
BillingCheckResult, BillingCheckResult,
) )
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeBilling")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -337,9 +340,9 @@ def _creditStripeSessionIfNeeded(
amount_chf_str = metadata.get("amountChf", "0") amount_chf_str = metadata.get("amountChf", "0")
if not session_id: 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: 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) existing_payment_tx = billingInterface.getPaymentTransactionByReferenceId(session_id)
if existing_payment_tx: if existing_payment_tx:
@ -363,11 +366,11 @@ def _creditStripeSessionIfNeeded(
if amount_total is not None: if amount_total is not None:
amount_chf = amount_total / 100.0 amount_chf = amount_total / 100.0
else: 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) settings = billingInterface.getSettings(mandate_id)
if not settings: 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) account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
@ -537,10 +540,10 @@ def getStatistics(
try: try:
# Validate period # Validate period
if period not in ["day", "month", "year"]: 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: 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) billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
settings = billingInterface.getSettings(ctx.mandateId) settings = billingInterface.getSettings(ctx.mandateId)
@ -642,13 +645,13 @@ def getSettingsAdmin(
Access: SysAdmin (any mandate) or MandateAdmin (own mandate). Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
""" """
if not _isAdminOfMandate(ctx, targetMandateId): 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: try:
billingInterface = getBillingInterface(ctx.user, targetMandateId) billingInterface = getBillingInterface(ctx.user, targetMandateId)
settings = billingInterface.getSettings(targetMandateId) settings = billingInterface.getSettings(targetMandateId)
if not settings: 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 return settings
@ -672,7 +675,7 @@ def createOrUpdateSettings(
Access: SysAdmin (any mandate) or MandateAdmin (own mandate). Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
""" """
if not _isAdminOfMandate(ctx, targetMandateId): 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: try:
billingInterface = getBillingInterface(ctx.user, targetMandateId) billingInterface = getBillingInterface(ctx.user, targetMandateId)
existingSettings = billingInterface.getSettings(targetMandateId) existingSettings = billingInterface.getSettings(targetMandateId)
@ -742,12 +745,12 @@ def addCredit(
settings = billingInterface.getSettings(targetMandateId) settings = billingInterface.getSettings(targetMandateId)
if not settings: 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) account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
if creditRequest.amount == 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 from modules.datamodels.datamodelBilling import BillingTransaction
@ -794,10 +797,10 @@ def createCheckoutSession(
settings = billingInterface.getSettings(targetMandateId) settings = billingInterface.getSettings(targetMandateId)
if not settings: 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): 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 from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session
redirect_url = create_checkout_session( redirect_url = create_checkout_session(
@ -832,7 +835,7 @@ def confirmCheckoutSession(
stripe = _getStripeClient() stripe = _getStripeClient()
session = stripe.checkout.Session.retrieve(confirmRequest.sessionId) session = stripe.checkout.Session.retrieve(confirmRequest.sessionId)
if not session: 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 from modules.shared.stripeClient import stripeToDict
session_dict = stripeToDict(session) session_dict = stripeToDict(session)
@ -841,7 +844,7 @@ def confirmCheckoutSession(
user_id = metadata.get("userId") or None user_id = metadata.get("userId") or None
if not mandate_id: 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") payment_status = session_dict.get("payment_status")
if payment_status != "paid": if payment_status != "paid":
@ -850,10 +853,10 @@ def confirmCheckoutSession(
billingInterface = getBillingInterface(ctx.user, mandate_id) billingInterface = getBillingInterface(ctx.user, mandate_id)
settings = billingInterface.getSettings(mandate_id) settings = billingInterface.getSettings(mandate_id)
if not settings: 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): 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() root_billing_interface = _getRootInterface()
return _creditStripeSessionIfNeeded(root_billing_interface, session_dict, eventId=None) return _creditStripeSessionIfNeeded(root_billing_interface, session_dict, eventId=None)
@ -880,10 +883,10 @@ async def stripeWebhook(
webhook_secret = APP_CONFIG.get("STRIPE_WEBHOOK_SECRET") webhook_secret = APP_CONFIG.get("STRIPE_WEBHOOK_SECRET")
if not webhook_secret: if not webhook_secret:
logger.error("STRIPE_WEBHOOK_SECRET not configured") 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: 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() payload = await request.body()
@ -894,10 +897,10 @@ async def stripeWebhook(
) )
except ValueError as e: except ValueError as e:
logger.warning(f"Stripe webhook invalid payload: {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: except Exception as e:
logger.warning(f"Stripe webhook signature verification failed: {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}") 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). Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
""" """
if not _isAdminOfMandate(ctx, targetMandateId): 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: try:
billingInterface = getBillingInterface(ctx.user, targetMandateId) billingInterface = getBillingInterface(ctx.user, targetMandateId)
@ -1291,7 +1294,7 @@ def getUsersForMandate(
Used by billing admin to select users for credit assignment. Used by billing admin to select users for credit assignment.
""" """
if not _isAdminOfMandate(ctx, targetMandateId): 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: try:
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
@ -1414,7 +1417,7 @@ def getTransactionsAdmin(
): ):
"""Get all transactions for a mandate with pagination support.""" """Get all transactions for a mandate with pagination support."""
if not _isAdminOfMandate(ctx, targetMandateId): 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: try:
paginationParams: Optional[PaginationParams] = None paginationParams: Optional[PaginationParams] = None
if pagination: if pagination:
@ -1461,7 +1464,7 @@ def getTransactionFilterValues(
): ):
"""Return distinct filter values for a column in mandate transactions.""" """Return distinct filter values for a column in mandate transactions."""
if not _isAdminOfMandate(ctx, targetMandateId): 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: try:
crossFilterParams: Optional[PaginationParams] = None crossFilterParams: Optional[PaginationParams] = None
if pagination: if pagination:

View file

@ -12,6 +12,8 @@ from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection
from modules.interfaces.interfaceDbApp import getInterface from modules.interfaces.interfaceDbApp import getInterface
from modules.serviceHub import getInterface as getServices from modules.serviceHub import getInterface as getServices
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeClickup")
logger = logging.getLogger(__name__) 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: def _clickup_connection_or_404(interface, connection_id: str, user_id: str) -> UserConnection:
connection = _getUserConnection(interface, connection_id, user_id) connection = _getUserConnection(interface, connection_id, user_id)
if not connection: 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) authority = connection.authority.value if hasattr(connection.authority, "value") else str(connection.authority)
if authority.lower() != AuthAuthority.CLICKUP.value: if authority.lower() != AuthAuthority.CLICKUP.value:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Connection is not a ClickUp connection", detail=routeApiMsg("Connection is not a ClickUp connection"),
) )
return connection return connection
@ -57,7 +59,7 @@ def _svc_for_connection(current_user: User, connection: UserConnection):
if not services.clickup.setAccessTokenFromConnection(connection): if not services.clickup.setAccessTokenFromConnection(connection):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Failed to set ClickUp access token", detail=routeApiMsg("Failed to set ClickUp access token"),
) )
return services.clickup return services.clickup

View file

@ -26,6 +26,8 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe
from modules.interfaces.interfaceDbApp import getInterface from modules.interfaces.interfaceDbApp import getInterface
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
from modules.interfaces.interfaceDbManagement import ComponentObjects from modules.interfaces.interfaceDbManagement import ComponentObjects
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeDataConnections")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -414,7 +416,7 @@ def update_connection(
if not connection: if not connection:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Connection not found" detail=routeApiMsg("Connection not found")
) )
# Update connection fields # Update connection fields
@ -486,7 +488,7 @@ def connect_service(
if not connection: if not connection:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) # Data-app OAuth (JWT state issued server-side in /auth/connect)
@ -542,7 +544,7 @@ def disconnect_service(
if not connection: if not connection:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Connection not found" detail=routeApiMsg("Connection not found")
) )
# Update connection status # Update connection status
@ -592,7 +594,7 @@ def delete_connection(
if not connection: if not connection:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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 # Remove the connection - only need connectionId since permissions are verified

View file

@ -17,6 +17,8 @@ from modules.datamodels.datamodelFileFolder import FileFolder
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeDataFiles")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -422,7 +424,7 @@ def create_folder(
name = body.get("name", "") name = body.get("name", "")
parentId = body.get("parentId") parentId = body.get("parentId")
if not name: if not name:
raise HTTPException(status_code=400, detail="name is required") raise HTTPException(status_code=400, detail=routeApiMsg("name is required"))
try: try:
mgmt = interfaceDbManagement.getInterface( mgmt = interfaceDbManagement.getInterface(
currentUser, currentUser,
@ -449,7 +451,7 @@ def rename_folder(
"""Rename a folder.""" """Rename a folder."""
newName = body.get("name", "") newName = body.get("name", "")
if not newName: if not newName:
raise HTTPException(status_code=400, detail="name is required") raise HTTPException(status_code=400, detail=routeApiMsg("name is required"))
try: try:
mgmt = interfaceDbManagement.getInterface( mgmt = interfaceDbManagement.getInterface(
currentUser, currentUser,
@ -554,7 +556,7 @@ def download_folder(
fileEntries = _collectFiles(folderId, "") fileEntries = _collectFiles(folderId, "")
if not fileEntries: 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() buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
@ -595,7 +597,7 @@ def batch_delete_items(
recursiveFolders = bool(body.get("recursiveFolders", True)) recursiveFolders = bool(body.get("recursiveFolders", True))
if not isinstance(fileIds, list) or not isinstance(folderIds, list): 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: try:
mgmt = interfaceDbManagement.getInterface( mgmt = interfaceDbManagement.getInterface(
@ -638,7 +640,7 @@ def batch_move_items(
targetParentId = body.get("targetParentId") targetParentId = body.get("targetParentId")
if not isinstance(fileIds, list) or not isinstance(folderIds, list): 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: try:
mgmt = interfaceDbManagement.getInterface( mgmt = interfaceDbManagement.getInterface(
@ -683,7 +685,7 @@ def updateFileScope(
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}") raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}")
if scope == "global" and not context.hasSysAdminRole: 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( managementInterface = interfaceDbManagement.getInterface(
context.user, context.user,
@ -875,14 +877,14 @@ def update_file(
if file_info.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)): if file_info.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Check if user has access to the file using RBAC
if not managementInterface.checkRbacPermission(FileItem, "update", fileId): if not managementInterface.checkRbacPermission(FileItem, "update", fileId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update this file" detail=routeApiMsg("Not authorized to update this file")
) )
# Update the file # Update the file
@ -890,7 +892,7 @@ def update_file(
if not result: if not result:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update file" detail=routeApiMsg("Failed to update file")
) )
# Get updated file # Get updated file
@ -928,7 +930,7 @@ def delete_file(
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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"} return {"message": f"File with ID {fileId} successfully deleted"}

View file

@ -32,6 +32,8 @@ from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeNotifications import create_access_change_notification from modules.routes.routeNotifications import create_access_change_notification
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException 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: if not adminMandateIds:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required" detail=routeApiMsg("Admin role required")
) )
# Parse pagination parameter # Parse pagination parameter
@ -180,7 +182,7 @@ def get_mandate_filter_values(
if not isSysAdmin: if not isSysAdmin:
adminMandateIds = _getAdminMandateIds(context) adminMandateIds = _getAdminMandateIds(context)
if not adminMandateIds: 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() appInterface = interfaceDbApp.getRootInterface()
@ -248,7 +250,7 @@ def get_mandate(
if mandateId not in adminMandateIds: if mandateId not in adminMandateIds:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required for this mandate" detail=routeApiMsg("Admin role required for this mandate")
) )
appInterface = interfaceDbApp.getRootInterface() appInterface = interfaceDbApp.getRootInterface()
@ -289,7 +291,7 @@ def create_mandate(
if not name or (isinstance(name, str) and name.strip() == ''): if not name or (isinstance(name, str) and name.strip() == ''):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Mandate name is required" detail=routeApiMsg("Mandate name is required")
) )
# Get optional fields with defaults # Get optional fields with defaults
@ -308,7 +310,7 @@ def create_mandate(
if not newMandate: if not newMandate:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create mandate" detail=routeApiMsg("Failed to create mandate")
) )
try: try:
@ -392,7 +394,7 @@ def update_mandate(
if not updatedMandate: if not updatedMandate:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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}") logger.info(f"Mandate {mandateId} updated by SysAdmin {currentUser.id}")
@ -438,7 +440,7 @@ def delete_mandate(
if confirmName != mandateName: if confirmName != mandateName:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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: try:
@ -487,7 +489,7 @@ def list_mandate_users(
if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole: if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required" detail=routeApiMsg("Mandate-Admin role required")
) )
try: try:
@ -647,7 +649,7 @@ def get_mandate_users_filter_values(
) -> list: ) -> list:
"""Return distinct filter values for a column in mandate users.""" """Return distinct filter values for a column in mandate users."""
if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole: 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: try:
from modules.routes.routeDataUsers import _handleFilterValuesRequest from modules.routes.routeDataUsers import _handleFilterValuesRequest
@ -714,7 +716,7 @@ def add_user_to_mandate(
if not _hasMandateAdminRole(context, targetMandateId): if not _hasMandateAdminRole(context, targetMandateId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to add users" detail=routeApiMsg("Mandate-Admin role required to add users")
) )
try: try:
@ -831,7 +833,7 @@ def remove_user_from_mandate(
if not _hasMandateAdminRole(context, targetMandateId): if not _hasMandateAdminRole(context, targetMandateId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required" detail=routeApiMsg("Mandate-Admin role required")
) )
try: try:
@ -857,7 +859,7 @@ def remove_user_from_mandate(
if _isLastMandateAdmin(rootInterface, targetMandateId, targetUserId): if _isLastMandateAdmin(rootInterface, targetMandateId, targetUserId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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) # Delete UserMandate (CASCADE will delete UserMandateRole entries)
@ -920,7 +922,7 @@ def update_user_roles_in_mandate(
if not _hasMandateAdminRole(context, targetMandateId): if not _hasMandateAdminRole(context, targetMandateId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required" detail=routeApiMsg("Mandate-Admin role required")
) )
try: try:
@ -953,7 +955,7 @@ def update_user_roles_in_mandate(
if _isLastMandateAdmin(rootInterface, targetMandateId, targetUserId): if _isLastMandateAdmin(rootInterface, targetMandateId, targetUserId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Remove existing role assignments

View file

@ -14,6 +14,8 @@ import modules.interfaces.interfaceDbManagement as interfaceDbManagement
from modules.datamodels.datamodelUtils import Prompt from modules.datamodels.datamodelUtils import Prompt
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeDataPrompts")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -173,7 +175,7 @@ def update_prompt(
if not updatedPrompt: if not updatedPrompt:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error updating the prompt" detail=routeApiMsg("Error updating the prompt")
) )
return Prompt(**updatedPrompt) return Prompt(**updatedPrompt)
@ -207,7 +209,7 @@ def delete_prompt(
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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"} return {"message": f"Prompt with ID {promptId} successfully deleted"}

View file

@ -10,6 +10,8 @@ from modules.auth import limiter, getRequestContext, RequestContext
from modules.auth.authentication import _hasSysAdminRole from modules.auth.authentication import _hasSysAdminRole
from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeDataSources")
logger = logging.getLogger(__name__) 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}") raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}")
if scope == "global" and not _hasSysAdminRole(context.user): 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: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface

View file

@ -24,6 +24,8 @@ from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeDataUsers")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -297,7 +299,7 @@ def get_user_options(
elif context.hasSysAdminRole: elif context.hasSysAdminRole:
users = appInterface.getAllUsers() users = appInterface.getAllUsers()
else: else:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
return [ return [
{"value": user.id, "label": user.fullName or user.username or user.email or user.id} {"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: if not adminMandateIds:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel
@ -581,7 +583,7 @@ def get_user(
if not userMandate: if not userMandate:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="User not in your mandate" detail=routeApiMsg("User not in your mandate")
) )
return user return user
@ -636,7 +638,7 @@ def create_user(
if not userRole: if not userRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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( appInterface.createUserMandate(
@ -667,7 +669,7 @@ def update_user(
if not isSelfUpdate and not _isAdminForUser(context, userId): if not isSelfUpdate and not _isAdminForUser(context, userId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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) # Use rootInterface for user lookup/update (avoids RBAC filtering on User table)
@ -687,7 +689,7 @@ def update_user(
if not updatedUser: if not updatedUser:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error updating the user" detail=routeApiMsg("Error updating the user")
) )
return updatedUser return updatedUser
@ -709,7 +711,7 @@ def reset_user_password(
if not _isAdminForUser(context, userId): if not _isAdminForUser(context, userId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to reset passwords" detail=routeApiMsg("Admin role required to reset passwords")
) )
# Get user interface # Get user interface
@ -719,7 +721,7 @@ def reset_user_password(
if len(newPassword) < 8: if len(newPassword) < 8:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Reset password
@ -727,7 +729,7 @@ def reset_user_password(
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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 # 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): if not appInterface.verifyPassword(currentPassword, context.user.passwordHash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect" detail=routeApiMsg("Current password is incorrect")
) )
# Validate new password strength # Validate new password strength
if len(newPassword) < 8: if len(newPassword) < 8:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Change password
@ -807,7 +809,7 @@ def change_password(
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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 # SECURITY: Automatically revoke all tokens for the user after password change
@ -877,7 +879,7 @@ def send_password_link(
if not _isAdminForUser(context, userId): if not _isAdminForUser(context, userId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Get user interface
@ -888,14 +890,14 @@ def send_password_link(
if not targetUser: if not targetUser:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="User not found" detail=routeApiMsg("User not found")
) )
# Check if user has an email # Check if user has an email
if not targetUser.email: if not targetUser.email:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # 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}") logger.warning(f"Failed to send password setup email to {targetUser.email}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send email" detail=routeApiMsg("Failed to send email")
) )
except HTTPException: except HTTPException:
@ -1010,7 +1012,7 @@ def delete_user(
if not userMandate: if not userMandate:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Delete UserMandate entries for this user first
@ -1022,7 +1024,7 @@ def delete_user(
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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"} return {"message": f"User with ID {userId} successfully deleted"}

View file

@ -25,6 +25,8 @@ from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
from modules.shared.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary from modules.shared.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeGdpr")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -316,14 +318,14 @@ def delete_account(
if not confirmDeletion: if not confirmDeletion:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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) # Prevent SysAdmin self-deletion (safety measure)
if getattr(currentUser, "isSysAdmin", False): if getattr(currentUser, "isSysAdmin", False):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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: try:

View file

@ -38,8 +38,11 @@ from modules.datamodels.datamodelNotification import NotificationType
from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface
from modules.routes.routeNotifications import _createNotification from modules.routes.routeNotifications import _createNotification
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import _loadCache as _reloadI18nCache, apiRouteContext
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
routeApiMsg = apiRouteContext("routeI18n")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter( router = APIRouter(
@ -270,16 +273,28 @@ async def _translateBatch(
finally: finally:
aiObjects.billingCallback = None aiObjects.billingCallback = None
_matchCapitalization(keysToTranslate, result)
return 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: def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str:
userId = str(currentUser.id) userId = str(currentUser.id)
memberIds = _userMemberMandateIds(currentUser) memberIds = _userMemberMandateIds(currentUser)
if not memberIds: if not memberIds:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 = ( headerRaw = (
@ -289,7 +304,7 @@ def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str:
if headerRaw not in memberIds: if headerRaw not in memberIds:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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): if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId):
return headerRaw return headerRaw
@ -298,7 +313,7 @@ def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str:
return mid return mid
raise HTTPException( raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED, 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): if not isinstance(entries, list):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 = [] result = []
for e in entries: 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]: 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 Only touches entries whose context is "ui". Gateway entries (api.*, table.*)
- Keys in DB but not in incoming -> remove written by _syncRegistryToDb at boot are preserved untouched.
- Keys in both -> update context (value)
""" """
if not incomingEntries: if not incomingEntries:
logger.warning("i18n xx-sync: no entries — aborting") 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]) row = dict(rows[0])
curEntries = _rowEntries(row) 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} incomingByKey = {e["key"]: e for e in incomingEntries}
incomingKeys = set(incomingByKey.keys()) incomingKeys = set(incomingByKey.keys())
dbKeys = set(curByKey.keys()) dbUiKeys = set(curUiByKey.keys())
added = sorted(incomingKeys - dbKeys) added = sorted(incomingKeys - dbUiKeys)
removed = sorted(dbKeys - incomingKeys) removed = sorted(dbUiKeys - incomingKeys)
newEntries = [] newUiEntries = [
for e in incomingEntries: {"context": e["context"], "key": e["key"], "value": e["value"]}
newEntries.append({"context": e["context"], "key": e["key"], "value": e["value"]}) for e in incomingEntries
for e in curEntries: ]
if e["key"] not in incomingKeys:
continue
if not added and not removed and all( if not added and not removed and all(
curByKey.get(e["key"], {}).get("value") == e["value"] curUiByKey.get(e["key"], {}).get("value") == e["value"]
and curByKey.get(e["key"], {}).get("context") == e["context"] and curUiByKey.get(e["key"], {}).get("context") == e["context"]
for e in incomingEntries 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() now = getUtcTimestamp()
row["entries"] = newEntries row["entries"] = mergedEntries
if "keys" in row: if "keys" in row:
del row["keys"] del row["keys"]
row["sysModifiedAt"] = now row["sysModifiedAt"] = now
row["sysModifiedBy"] = userId row["sysModifiedBy"] = userId
db.recordModify(UiLanguageSet, "xx", row) db.recordModify(UiLanguageSet, "xx", row)
logger.info("i18n xx-master sync: +%d added, -%d removed, total=%d", len(added), len(removed), len(newEntries)) logger.info(
return {"added": added, "removed": removed, "entriesCount": len(newEntries)} "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 ----------------------------------------------------------------- # --- Public -----------------------------------------------------------------
@ -439,6 +459,8 @@ async def list_language_codes():
out = [] out = []
for r in rows: for r in rows:
entries = _rowEntries(r) entries = _rowEntries(r)
uiCount = sum(1 for e in entries if e.get("context", "ui") == "ui")
gatewayCount = len(entries) - uiCount
out.append( out.append(
{ {
"code": r["id"], "code": r["id"],
@ -446,6 +468,8 @@ async def list_language_codes():
"status": r.get("status"), "status": r.get("status"),
"isDefault": bool(r.get("isDefault")), "isDefault": bool(r.get("isDefault")),
"entriesCount": len(entries), "entriesCount": len(entries),
"uiCount": uiCount,
"gatewayCount": gatewayCount,
} }
) )
return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"])) 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() db = _publicMgmtDb()
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows: 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]) return _row_to_public(rows[0])
@ -472,7 +496,7 @@ def _validate_iso2_code(code: str) -> str:
c = code.strip().lower() c = code.strip().lower()
if not re.fullmatch(r"[a-z]{2}", c): if not re.fullmatch(r"[a-z]{2}", c):
raise HTTPException( 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 return c
@ -530,6 +554,7 @@ async def _run_create_language_job_async(userId: str, code: str, label: str, cur
title="Sprachset erstellt", title="Sprachset erstellt",
message=f"Die Sprache «{label}» ({code}) wurde per KI übersetzt{statusHint}.", 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)) logger.info("i18n create job done: code=%s, translated=%d/%d", code, len(translated), len(xxEntries))
except Exception as e: except Exception as e:
logger.exception("create language job failed: %s", e) logger.exception("create language job failed: %s", e)
@ -551,16 +576,16 @@ async def create_language_set(
mandateId = _resolveMandateIdForAiI18n(request, currentUser) mandateId = _resolveMandateIdForAiI18n(request, currentUser)
code = _validate_iso2_code(body.code) code = _validate_iso2_code(body.code)
if code == "xx": 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() db = _publicMgmtDb()
existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if existing: 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) xxEntries = _loadMasterXxEntries(db)
if not xxEntries: 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 "" resolvedLabel = (body.label or "").strip() if body.label else ""
if not resolvedLabel: if not resolvedLabel:
@ -594,54 +619,59 @@ async def create_language_set(
def _compute_language_sync_diff(db, code: str) -> dict: def _compute_language_sync_diff(db, code: str) -> dict:
"""Return key sync metrics before AI translate (no DB writes).""" """Return key sync metrics before AI translate (no DB writes)."""
if code == "xx": 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}) rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows: if not rows:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden") raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
xx_entries = _loadMasterXxEntries(db) xxEntries = _loadMasterXxEntries(db)
if not xx_entries: 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]) row = dict(rows[0])
cur_entries = _rowEntries(row) curEntries = _rowEntries(row)
cur_by_key = {e["key"]: e for e in cur_entries} masterIds = {_entryId(e) for e in xxEntries}
xx_by_key = {e["key"]: e for e in xx_entries} currentIds = {_entryId(e) for e in curEntries}
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)
return { return {
"code": code, "code": code,
"addedCount": added_count, "addedCount": len(masterIds - currentIds),
"removedCount": removed_count, "removedCount": len(currentIds - masterIds),
"masterEntryCount": len(master_keys), "masterEntryCount": len(masterIds),
"currentEntryCount": len(current_keys), "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: 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": 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}) rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows: 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) xxEntries = _loadMasterXxEntries(db)
if not xxEntries: 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]) row = dict(rows[0])
curEntries = _rowEntries(row) curEntries = _rowEntries(row)
curByKey = {e["key"]: e for e in curEntries} curById = {_entryId(e): e for e in curEntries}
xxByKey = {e["key"]: e for e in xxEntries} xxById = {_entryId(e): e for e in xxEntries}
masterKeys = set(xxByKey.keys()) masterIds = set(xxById.keys())
currentKeys = set(curByKey.keys()) currentIds = set(curById.keys())
removedKeys = sorted(currentKeys - masterKeys) removedIds = currentIds - masterIds
addedKeys = sorted(masterKeys - currentKeys) addedIds = masterIds - currentIds
translatedCount = 0 translatedCount = 0
if addedKeys: if addedIds:
toTranslate = {k: xxByKey[k].get("value", "") for k in addedKeys} toTranslate = {xxById[eid]["key"]: xxById[eid].get("value", "") for eid in addedIds}
langLabel = row.get("label") or code langLabel = row.get("label") or code
billingCb = None billingCb = None
if adminUser: if adminUser:
@ -650,28 +680,29 @@ async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: O
billingCb = _makeBillingCallback(adminUser, memberIds[0]) billingCb = _makeBillingCallback(adminUser, memberIds[0])
try: try:
translated = await _translateBatch(toTranslate, langLabel, code, billingCallback=billingCb) 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: except Exception as e:
logger.error("AI translation during sync failed for %s: %s", code, e) logger.error("AI translation during sync failed for %s: %s", code, e)
translated = {} translated = {}
for k in addedKeys: for eid in addedIds:
curByKey[k] = { xxEntry = xxById[eid]
"context": xxByKey[k]["context"], curById[eid] = {
"key": k, "context": xxEntry["context"],
"value": translated.get(k, f"[{k}]"), "key": xxEntry["key"],
"value": translated.get(xxEntry["key"], f"[{xxEntry['key']}]"),
} }
for k in removedKeys: for eid in removedIds:
del curByKey[k] del curById[eid]
for k in masterKeys & currentKeys: for eid in masterIds & currentIds:
curByKey[k]["context"] = xxByKey[k]["context"] 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() now = getUtcTimestamp()
untranslated = len(addedKeys) - translatedCount untranslated = len(addedIds) - translatedCount
row["entries"] = newEntries row["entries"] = newEntries
if "keys" in row: if "keys" in row:
del row["keys"] del row["keys"]
@ -681,8 +712,8 @@ async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: O
db.recordModify(UiLanguageSet, code, row) db.recordModify(UiLanguageSet, code, row)
return { return {
"code": code, "code": code,
"added": addedKeys, "added": sorted({xxById[eid]["key"] for eid in addedIds}),
"removed": removedKeys, "removed": sorted({eid[0] for eid in removedIds}),
"translated": translatedCount, "translated": translatedCount,
"entriesCount": len(newEntries), "entriesCount": len(newEntries),
} }
@ -701,7 +732,9 @@ async def sync_xx_master(
db = getMgmtInterface(adminUser, mandateId=None).db db = getMgmtInterface(adminUser, mandateId=None).db
fromBody = await _readOptionalEntriesFromBody(request) fromBody = await _readOptionalEntriesFromBody(request)
entries = fromBody if fromBody is not None else _scanCodebaseKeys() 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") @router.put("/sets/update-all")
@ -727,6 +760,7 @@ async def update_all_language_sets(
continue continue
res = await _syncLanguageWithXx(db, cid, str(adminUser.id), adminUser=adminUser) res = await _syncLanguageWithXx(db, cid, str(adminUser.id), adminUser=adminUser)
results.append(res) results.append(res)
await _reloadI18nCache()
return {"xxSync": xxSync, "updated": results} 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).""" """How many keys would be added/removed vs xx before running a full sync (SysAdmin)."""
c = code.strip().lower() c = code.strip().lower()
if c in ("update-all", "sync-xx", "sync-de"): 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 db = getMgmtInterface(adminUser, mandateId=None).db
return _compute_language_sync_diff(db, c) return _compute_language_sync_diff(db, c)
@ -750,11 +784,13 @@ async def update_language_set(
): ):
c = code.strip().lower() c = code.strip().lower()
if c in ("update-all", "sync-xx", "sync-de"): 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": 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 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}") @router.delete("/sets/{code}")
@ -768,7 +804,8 @@ async def delete_language_set(
db = getMgmtInterface(adminUser, mandateId=None).db db = getMgmtInterface(adminUser, mandateId=None).db
ok = db.recordDelete(UiLanguageSet, c) ok = db.recordDelete(UiLanguageSet, c)
if not ok: 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} return {"deleted": c}
@ -780,7 +817,7 @@ async def download_language_set(
db = _publicMgmtDb() db = _publicMgmtDb()
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code.strip().lower()}) rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code.strip().lower()})
if not rows: 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]) payload = _row_to_public(rows[0])
raw = json.dumps(payload, ensure_ascii=False, indent=2) raw = json.dumps(payload, ensure_ascii=False, indent=2)
return Response( return Response(
@ -828,7 +865,7 @@ async def import_language_sets(
adminUser: User = Depends(requireSysAdminRole), adminUser: User = Depends(requireSysAdminRole),
): ):
if not file.filename or not file.filename.endswith(".json"): 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: try:
raw = await file.read() raw = await file.read()
@ -837,7 +874,7 @@ async def import_language_sets(
raise HTTPException(status_code=400, detail=f"Ungültiges JSON: {e}") raise HTTPException(status_code=400, detail=f"Ungültiges JSON: {e}")
if not isinstance(data, list): 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 db = getMgmtInterface(adminUser, mandateId=None).db
now = getUtcTimestamp() now = getUtcTimestamp()
@ -893,4 +930,44 @@ async def import_language_sets(
created.append(code) created.append(code)
logger.info("i18n import: created=%s, updated=%s", created, updated) logger.info("i18n import: created=%s, updated=%s", created, updated)
await _reloadI18nCache()
return {"created": created, "updated": updated, "totalProcessed": len(created) + len(updated)} 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}

View file

@ -25,6 +25,8 @@ from modules.routes.routeDataUsers import _applyFiltersAndSort
from modules.datamodels.datamodelInvitation import Invitation from modules.datamodels.datamodelInvitation import Invitation
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeInvitations")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -161,7 +163,7 @@ def create_invitation(
if not context.mandateId: if not context.mandateId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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) mandateId = str(context.mandateId)
# Validate roles are mandate-level (no featureInstanceId) # Validate roles are mandate-level (no featureInstanceId)
@ -188,12 +190,12 @@ def create_invitation(
if str(context.mandateId) != mandateId: if str(context.mandateId) != mandateId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this mandate" detail=routeApiMsg("Access denied to this mandate")
) )
if not _hasMandateAdminRole(context): if not _hasMandateAdminRole(context):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Calculate expiration time
@ -427,14 +429,14 @@ def list_invitations(
if not context.mandateId: if not context.mandateId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Check mandate admin permission
if not _hasMandateAdminRole(context): if not _hasMandateAdminRole(context):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to list invitations" detail=routeApiMsg("Mandate-Admin role required to list invitations")
) )
try: try:
@ -522,9 +524,9 @@ def get_invitation_filter_values(
) -> list: ) -> list:
"""Return distinct filter values for a column in invitations.""" """Return distinct filter values for a column in invitations."""
if not context.mandateId: 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): 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: try:
from modules.routes.routeDataUsers import _handleFilterValuesRequest from modules.routes.routeDataUsers import _handleFilterValuesRequest
rootInterface = getRootInterface() rootInterface = getRootInterface()
@ -575,14 +577,14 @@ def revoke_invitation(
if not context.mandateId: if not context.mandateId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Check mandate admin permission
if not _hasMandateAdminRole(context): if not _hasMandateAdminRole(context):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to revoke invitations" detail=routeApiMsg("Mandate-Admin role required to revoke invitations")
) )
try: try:
@ -601,14 +603,14 @@ def revoke_invitation(
if str(invitation.mandateId) != str(context.mandateId): if str(invitation.mandateId) != str(context.mandateId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this invitation" detail=routeApiMsg("Access denied to this invitation")
) )
# Already revoked? # Already revoked?
if invitation.revokedAt: if invitation.revokedAt:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation is already revoked" detail=routeApiMsg("Invitation is already revoked")
) )
# Revoke invitation # Revoke invitation
@ -781,14 +783,14 @@ def accept_invitation(
if not invitation: if not invitation:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Invitation not found" detail=routeApiMsg("Invitation not found")
) )
# Validate invitation # Validate invitation
if invitation.revokedAt: if invitation.revokedAt:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has been revoked" detail=routeApiMsg("Invitation has been revoked")
) )
currentTime = getUtcTimestamp() currentTime = getUtcTimestamp()
@ -796,7 +798,7 @@ def accept_invitation(
if expiresAt < currentTime: if expiresAt < currentTime:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has expired" detail=routeApiMsg("Invitation has expired")
) )
currentUses = invitation.currentUses or 0 currentUses = invitation.currentUses or 0
@ -804,7 +806,7 @@ def accept_invitation(
if currentUses >= maxUses: if currentUses >= maxUses:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Validate user matches - invitation is bound by username or email
@ -833,7 +835,7 @@ def accept_invitation(
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 mandateId = str(invitation.mandateId) if invitation.mandateId else None

View file

@ -22,6 +22,8 @@ from modules.datamodels.datamodelMessaging import (
) )
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeMessaging")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -139,7 +141,7 @@ def update_subscription(
if not updatedSubscription: if not updatedSubscription:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error updating the subscription" detail=routeApiMsg("Error updating the subscription")
) )
return MessagingSubscription(**updatedSubscription) return MessagingSubscription(**updatedSubscription)
@ -166,7 +168,7 @@ def delete_subscription(
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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"} return {"message": f"Subscription with ID {subscriptionId} successfully deleted"}
@ -263,7 +265,7 @@ def unsubscribe_user(
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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}"} return {"message": f"Successfully unsubscribed from {subscriptionId} for channel {channel.value}"}
@ -339,7 +341,7 @@ def update_registration(
if not updatedRegistration: if not updatedRegistration:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error updating the registration" detail=routeApiMsg("Error updating the registration")
) )
return MessagingSubscriptionRegistration(**updatedRegistration) return MessagingSubscriptionRegistration(**updatedRegistration)
@ -366,7 +368,7 @@ def delete_registration(
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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"} return {"message": f"Registration with ID {registrationId} successfully deleted"}
@ -397,7 +399,7 @@ def trigger_subscription(
if not _hasTriggerPermission(context): if not _hasTriggerPermission(context):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Get messaging service from request app state

View file

@ -22,6 +22,8 @@ from modules.datamodels.datamodelNotification import (
from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelRbac import Role
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeNotifications")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -238,14 +240,14 @@ def markAsRead(
if not notification: if not notification:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found" detail=routeApiMsg("Notification not found")
) )
# Verify ownership # Verify ownership
if str(notification.userId) != str(currentUser.id): if str(notification.userId) != str(currentUser.id):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this notification" detail=routeApiMsg("Not authorized to access this notification")
) )
# Update status # Update status
@ -332,21 +334,21 @@ def executeAction(
if not notification: if not notification:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found" detail=routeApiMsg("Notification not found")
) )
# Verify ownership # Verify ownership
if str(notification.userId) != str(currentUser.id): if str(notification.userId) != str(currentUser.id):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Check if already actioned
if notification.status == NotificationStatus.ACTIONED.value: if notification.status == NotificationStatus.ACTIONED.value:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Notification has already been actioned" detail=routeApiMsg("Notification has already been actioned")
) )
# Validate action exists # Validate action exists
@ -416,7 +418,7 @@ def _handleInvitationAction(
if not invitationId: if not invitationId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="No invitation reference found" detail=routeApiMsg("No invitation reference found")
) )
# Get the invitation (Pydantic model) # Get the invitation (Pydantic model)
@ -425,7 +427,7 @@ def _handleInvitationAction(
if not invitation: if not invitation:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Invitation not found" detail=routeApiMsg("Invitation not found")
) )
# Verify user matches (username or email) # Verify user matches (username or email)
@ -436,18 +438,18 @@ def _handleInvitationAction(
if currentUser.username != targetUsername: if currentUser.username != targetUsername:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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: elif invitationEmail:
if not currentUserEmail or currentUserEmail != invitationEmail: if not currentUserEmail or currentUserEmail != invitationEmail:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="This invitation is for a different user" detail=routeApiMsg("This invitation is for a different user")
) )
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Check if invitation is still valid
@ -456,13 +458,13 @@ def _handleInvitationAction(
if expiresAt < currentTime: if expiresAt < currentTime:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has expired" detail=routeApiMsg("Invitation has expired")
) )
if invitation.revokedAt: if invitation.revokedAt:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has been revoked" detail=routeApiMsg("Invitation has been revoked")
) )
currentUses = invitation.currentUses or 0 currentUses = invitation.currentUses or 0
@ -470,7 +472,7 @@ def _handleInvitationAction(
if currentUses >= maxUses: if currentUses >= maxUses:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has reached maximum uses" detail=routeApiMsg("Invitation has reached maximum uses")
) )
if actionId == "accept": if actionId == "accept":
@ -565,14 +567,14 @@ def deleteNotification(
if not notification: if not notification:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found" detail=routeApiMsg("Notification not found")
) )
# Verify ownership # Verify ownership
if str(notification.userId) != str(currentUser.id): if str(notification.userId) != str(currentUser.id):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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) # Mark as dismissed (soft delete)

View file

@ -64,6 +64,8 @@ from modules.routes.routeRealEstateScraping import (
# Import attribute utilities for model schema # Import attribute utilities for model schema
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeRealEstate")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -308,7 +310,7 @@ async def update_project(
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
updated = interface.updateProjekt(projectId, data) updated = interface.updateProjekt(projectId, data)
if not updated: if not updated:
raise HTTPException(status_code=500, detail="Update failed") raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
return updated return updated
@ -329,7 +331,7 @@ async def delete_project(
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId: if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
if not interface.deleteProjekt(projectId): if not interface.deleteProjekt(projectId):
raise HTTPException(status_code=500, detail="Delete failed") raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
# ----- Parcels CRUD ----- # ----- Parcels CRUD -----
@ -429,7 +431,7 @@ async def update_parcel(
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
updated = interface.updateParzelle(parcelId, data) updated = interface.updateParzelle(parcelId, data)
if not updated: if not updated:
raise HTTPException(status_code=500, detail="Update failed") raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
return updated return updated
@ -450,7 +452,7 @@ async def delete_parcel(
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId: if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
if not interface.deleteParzelle(parcelId): 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}") logger.warning(f"CSRF token missing for POST /api/realestate/command from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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})") 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}") logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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})") 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}") logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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})") 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}") logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Special handling for Projekt with parcel data # Special handling for Projekt with parcel data
@ -874,7 +876,7 @@ async def create_table_record(
if not label: if not label:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="label is required" detail=routeApiMsg("label is required")
) )
status_prozess = data.get("statusProzess", "Eingang") status_prozess = data.get("statusProzess", "Eingang")
@ -887,7 +889,7 @@ async def create_table_record(
if not isinstance(parzellen_data, list): if not isinstance(parzellen_data, list):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="parzellen must be an array" detail=routeApiMsg("parzellen must be an array")
) )
elif "parzelle" in data: elif "parzelle" in data:
# Single parcel (backward compatibility) # Single parcel (backward compatibility)
@ -898,7 +900,7 @@ async def create_table_record(
if not parzellen_data: if not parzellen_data:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # 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}") logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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}") 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}") logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Validate CSRF token format
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
try: try:
int(csrf_token, 16) int(csrf_token, 16)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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})") 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}") logger.warning(f"CSRF token missing for GET /api/realestate/bzo-information from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for GET /api/realestate/bzo-information from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/bzo-information from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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})") logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {currentUser.mandateId})")

View file

@ -36,6 +36,8 @@ from modules.connectors.connectorOerebWfs import OerebWfsConnector
# Import Tavily connector for BZO document search # Import Tavily connector for BZO document search
from modules.aicore.aicorePluginTavily import AiTavily from modules.aicore.aicorePluginTavily import AiTavily
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeRealEstateScraping")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) 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}") logger.warning(f"CSRF token missing for POST /api/realestate/scrape-switzerland from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for POST /api/realestate/scrape-switzerland from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/scrape-switzerland from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Extract parameters from body with defaults # Extract parameters from body with defaults
@ -137,19 +139,19 @@ async def scrape_switzerland_route(
if grid_size <= 0 or grid_size > 10000: if grid_size <= 0 or grid_size > 10000:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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: if max_concurrent <= 0 or max_concurrent > 200:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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: if batch_size <= 0 or batch_size > 1000:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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( 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}") logger.warning(f"CSRF token missing for GET /api/realestate/gemeinden from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for GET /api/realestate/gemeinden from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/gemeinden from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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}") 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}") logger.warning(f"CSRF token missing for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # 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}") logger.warning(f"Invalid CSRF token format for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail=routeApiMsg("Invalid CSRF token format")
) )
# Validate token is hex string # 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}") 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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})") logger.info(f"Starting BZO document fetch for user {currentUser.id} (mandate: {currentUser.mandateId})")

View file

@ -17,6 +17,8 @@ from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeSecurityAdmin")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -132,7 +134,7 @@ def list_tokens(
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error listing tokens: {str(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") @router.post("/tokens/revoke/user")
@ -151,7 +153,7 @@ def revoke_tokens_by_user(
authority = payload.get("authority") authority = payload.get("authority")
reason = payload.get("reason", "sysadmin revoke") reason = payload.get("reason", "sysadmin revoke")
if not userId: if not userId:
raise HTTPException(status_code=400, detail="userId is required") raise HTTPException(status_code=400, detail=routeApiMsg("userId is required"))
appInterface = getRootInterface() appInterface = getRootInterface()
# MULTI-TENANT: SysAdmin can revoke any user's tokens (no mandate restriction) # MULTI-TENANT: SysAdmin can revoke any user's tokens (no mandate restriction)
@ -167,7 +169,7 @@ def revoke_tokens_by_user(
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error revoking tokens by user: {str(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") @router.post("/tokens/revoke/session")
@ -187,7 +189,7 @@ def revoke_tokens_by_session(
authority = payload.get("authority", "local") authority = payload.get("authority", "local")
reason = payload.get("reason", "sysadmin session revoke") reason = payload.get("reason", "sysadmin session revoke")
if not userId or not sessionId: 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() appInterface = getRootInterface()
# MULTI-TENANT: SysAdmin can revoke any session (no mandate check) # MULTI-TENANT: SysAdmin can revoke any session (no mandate check)
@ -203,7 +205,7 @@ def revoke_tokens_by_session(
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error revoking tokens by session: {str(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") @router.post("/tokens/revoke/id")
@ -221,7 +223,7 @@ def revoke_token_by_id(
tokenId = payload.get("tokenId") tokenId = payload.get("tokenId")
reason = payload.get("reason", "sysadmin revoke") reason = payload.get("reason", "sysadmin revoke")
if not tokenId: if not tokenId:
raise HTTPException(status_code=400, detail="tokenId is required") raise HTTPException(status_code=400, detail=routeApiMsg("tokenId is required"))
appInterface = getRootInterface() appInterface = getRootInterface()
# MULTI-TENANT: SysAdmin can revoke any token (no mandate check) # MULTI-TENANT: SysAdmin can revoke any token (no mandate check)
ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason) ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason)
@ -230,7 +232,7 @@ def revoke_token_by_id(
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error revoking token by id: {str(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") @router.post("/tokens/revoke/mandate")
@ -249,7 +251,7 @@ def revoke_tokens_by_mandate(
authority = payload.get("authority", "local") authority = payload.get("authority", "local")
reason = payload.get("reason", "sysadmin mandate revoke") reason = payload.get("reason", "sysadmin mandate revoke")
if not mandateId: 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 # MULTI-TENANT: SysAdmin can revoke tokens for any mandate
appInterface = getRootInterface() appInterface = getRootInterface()
@ -271,7 +273,7 @@ def revoke_tokens_by_mandate(
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error revoking tokens by mandate: {str(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} return {"databases": databases}
except Exception as e: except Exception as e:
logger.error(f"Failed to load databases from host: {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") @router.get("/databases/{database_name}/tables")
@ -310,7 +312,7 @@ def get_database_tables(
MULTI-TENANT: SysAdmin-only (infrastructure management). MULTI-TENANT: SysAdmin-only (infrastructure management).
""" """
if not database_name.startswith("poweron_"): 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 connector = None
try: try:
@ -341,7 +343,7 @@ def drop_table(
MULTI-TENANT: SysAdmin-only (infrastructure management). MULTI-TENANT: SysAdmin-only (infrastructure management).
""" """
if not database_name.startswith("poweron_"): 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 connector = None
try: try:
@ -354,7 +356,7 @@ def drop_table(
WHERE table_schema = 'public' AND table_name = %s WHERE table_schema = 'public' AND table_name = %s
""", (table_name,)) """, (table_name,))
if not cursor.fetchone(): 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 # Drop the table
cursor.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') 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)}") logger.error(f"Error dropping table: {str(e)}")
if connector and connector.connection: if connector and connector.connection:
connector.connection.rollback() 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: finally:
if connector: if connector:
connector.close() connector.close()
@ -389,7 +391,7 @@ def drop_database(
dbName = payload.get("database") dbName = payload.get("database")
if not dbName or not dbName.startswith("poweron_"): 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 # Validate database exists
try: try:
@ -425,7 +427,7 @@ def drop_database(
logger.error(f"Error dropping database tables: {str(e)}") logger.error(f"Error dropping database tables: {str(e)}")
if connector and connector.connection: if connector and connector.connection:
connector.connection.rollback() 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: finally:
if connector: if connector:
connector.close() connector.close()

View file

@ -19,6 +19,8 @@ from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatu
from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeSecurityClickup")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -53,7 +55,7 @@ def _require_clickup_config():
if not CLIENT_ID or not CLIENT_SECRET or not REDIRECT_URI: if not CLIENT_ID or not CLIENT_SECRET or not REDIRECT_URI:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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 connection = conn
break break
if not connection: 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( state_jwt = _issue_oauth_state(
{ {
@ -123,11 +125,11 @@ async def auth_connect_callback(
"""OAuth callback for ClickUp data connection.""" """OAuth callback for ClickUp data connection."""
state_data = _parse_oauth_state(state) state_data = _parse_oauth_state(state)
if state_data.get("flow") != _FLOW_CONNECT: 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") connection_id = state_data.get("connectionId")
user_id = state_data.get("userId") user_id = state_data.get("userId")
if not connection_id or not user_id: 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() _require_clickup_config()

View file

@ -33,6 +33,8 @@ from modules.auth import (
from modules.auth.tokenManager import TokenManager from modules.auth.tokenManager import TokenManager
from modules.auth.oauthProviderConfig import googleAuthScopes, googleDataScopes from modules.auth.oauthProviderConfig import googleAuthScopes, googleDataScopes
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeSecurityGoogle")
logger = logging.getLogger(__name__) 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: if not AUTH_CLIENT_ID or not AUTH_CLIENT_SECRET or not AUTH_REDIRECT_URI:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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: if not DATA_CLIENT_ID or not DATA_CLIENT_SECRET or not DATA_REDIRECT_URI:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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).""" """OAuth callback for Google Auth app (login only)."""
state_data = _parse_oauth_state(state) state_data = _parse_oauth_state(state)
if state_data.get("flow") != _FLOW_LOGIN: 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() _require_google_auth_config()
oauth = OAuth2Session(client_id=AUTH_CLIENT_ID, redirect_uri=AUTH_REDIRECT_URI) 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: if user_info_response.status_code != 200:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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() user_info = user_info_response.json()
@ -310,7 +312,7 @@ def auth_connect(
connection = conn connection = conn
break break
if not connection: 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( state_jwt = _issue_oauth_state(
{ {
@ -359,11 +361,11 @@ async def auth_connect_callback(
"""OAuth callback for Google Data app (UserConnection).""" """OAuth callback for Google Data app (UserConnection)."""
state_data = _parse_oauth_state(state) state_data = _parse_oauth_state(state)
if state_data.get("flow") != _FLOW_CONNECT: 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") connection_id = state_data.get("connectionId")
user_id = state_data.get("userId") user_id = state_data.get("userId")
if not connection_id or not user_id: 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() _require_google_data_config()
oauth = OAuth2Session(client_id=DATA_CLIENT_ID, redirect_uri=DATA_REDIRECT_URI) 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: if user_info_response.status_code != 200:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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() user_info = user_info_response.json()
@ -557,7 +559,7 @@ def logout(
if not token: if not token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="No token found", detail=routeApiMsg("No token found"),
) )
try: try:
@ -568,7 +570,7 @@ def logout(
logger.error(f"Failed to decode JWT on Google logout: {str(e)}") logger.error(f"Failed to decode JWT on Google logout: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid token", detail=routeApiMsg("Invalid token"),
) )
revoked = 0 revoked = 0
@ -635,13 +637,13 @@ async def verify_token(
if not google_connection: if not google_connection:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) current_token = TokenManager().getFreshToken(google_connection.id)
if not current_token: if not current_token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) token_verification = await verify_google_token(current_token.tokenAccess)
return { return {
@ -690,7 +692,7 @@ async def refresh_token(
if not google_connection: if not google_connection:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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: else:
for conn in connections: for conn in connections:
@ -700,13 +702,13 @@ async def refresh_token(
if not google_connection: if not google_connection:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) current_token = TokenManager().getFreshToken(google_connection.id)
if not current_token: if not current_token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) expiresAtValue = parseTimestamp(current_token.expiresAt)
google_connection.expiresAt = ( google_connection.expiresAt = (

View file

@ -21,6 +21,8 @@ from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Manda
from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeSecurityLocal")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -231,7 +233,7 @@ def login(
if not csrf_token: if not csrf_token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing" detail=routeApiMsg("CSRF token missing")
) )
# Get gateway interface with root privileges for authentication # Get gateway interface with root privileges for authentication
@ -248,7 +250,7 @@ def login(
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password", detail=routeApiMsg("Invalid username or password"),
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
@ -280,7 +282,7 @@ def login(
expires_at = datetime.fromtimestamp(payload.get("exp")) expires_at = datetime.fromtimestamp(payload.get("exp"))
except Exception as e: except Exception as e:
logger.error(f"Failed to decode access token: {str(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 # Get user-specific interface for token operations
userInterface = getInterface(user) userInterface = getInterface(user)
@ -425,7 +427,7 @@ def register_user(
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to register user" detail=routeApiMsg("Failed to register user")
) )
# Check for pending invitations BEFORE provisioning. # Check for pending invitations BEFORE provisioning.
@ -581,32 +583,32 @@ def refresh_token(
# Get refresh token from cookie # Get refresh token from cookie
refresh_token = request.cookies.get('refresh_token') refresh_token = request.cookies.get('refresh_token')
if not 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 # Validate refresh token
try: try:
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "refresh": 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: 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: 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 # Get user information from refresh token payload
user_id = payload.get("userId") user_id = payload.get("userId")
if not user_id: 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 # Get user from database using the user ID from refresh token
try: try:
app_interface = getRootInterface() app_interface = getRootInterface()
current_user = app_interface.getUser(user_id) current_user = app_interface.getUser(user_id)
if not current_user: 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: except Exception as e:
logger.error(f"Failed to get user from database: {str(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 # Create new token data
# MULTI-TENANT: Token does NOT contain mandateId anymore # MULTI-TENANT: Token does NOT contain mandateId anymore
@ -627,7 +629,7 @@ def refresh_token(
expires_at = datetime.fromtimestamp(payload.get("exp")) expires_at = datetime.fromtimestamp(payload.get("exp"))
except Exception as e: except Exception as e:
logger.error(f"Failed to decode new access token: {str(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 { return {
"type": "token_refresh_success", "type": "token_refresh_success",
@ -643,7 +645,7 @@ def refresh_token(
raise raise
except Exception as e: except Exception as e:
logger.error(f"Token refresh error: {str(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") @router.post("/logout")
@limiter.limit("30/minute") @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() token = auth_header.split(" ", 1)[1].strip()
if not token: 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: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 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") jti = payload.get("jti")
except Exception as e: except Exception as e:
logger.error(f"Failed to decode JWT on logout: {str(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 revoked = 0
if session_id: if session_id:
@ -927,14 +929,14 @@ def password_reset(
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Validate password strength
if len(password) < 8: if len(password) < 8:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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() rootInterface = getRootInterface()
@ -945,7 +947,7 @@ def password_reset(
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Ungültiger oder abgelaufener Reset-Link" detail=routeApiMsg("Ungültiger oder abgelaufener Reset-Link")
) )
# Log success # Log success
@ -968,7 +970,7 @@ def password_reset(
logger.error(f"Error in password reset: {str(e)}") logger.error(f"Error in password reset: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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() rootIf = getRootInterface()
records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"id": mappingId}) records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"id": mappingId})
if not records: 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] rec = records[0]
recUserId = rec.get("userId") if isinstance(rec, dict) else getattr(rec, "userId", None) recUserId = rec.get("userId") if isinstance(rec, dict) else getattr(rec, "userId", None)
if recUserId != userId: 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) rootIf.db.recordDelete(DataNeutralizerAttributes, mappingId)
return {"deleted": True, "id": mappingId} return {"deleted": True, "id": mappingId}

View file

@ -34,6 +34,8 @@ from modules.auth import (
from modules.auth.tokenManager import TokenManager from modules.auth.tokenManager import TokenManager
from modules.auth.oauthProviderConfig import msftAuthScopes, msftDataScopes, msftDataScopesForRefresh from modules.auth.oauthProviderConfig import msftAuthScopes, msftDataScopes, msftDataScopesForRefresh
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeSecurityMsft")
logger = logging.getLogger(__name__) 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: if not AUTH_CLIENT_ID or not AUTH_CLIENT_SECRET or not AUTH_REDIRECT_URI:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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: if not DATA_CLIENT_ID or not DATA_CLIENT_SECRET or not DATA_REDIRECT_URI:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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: ) -> HTMLResponse:
state_data = _parse_oauth_state(state) state_data = _parse_oauth_state(state)
if state_data.get("flow") != _FLOW_LOGIN: 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() _require_msft_auth_config()
msal_app = msal.ConfidentialClientApplication( msal_app = msal.ConfidentialClientApplication(
@ -171,7 +173,7 @@ async def auth_login_callback(
if user_info_response.status_code != 200: if user_info_response.status_code != 200:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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() user_info = user_info_response.json()
@ -256,7 +258,7 @@ def auth_connect(
break break
if not connection: if not connection:
raise HTTPException( 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( msal_app = msal.ConfidentialClientApplication(
@ -301,11 +303,11 @@ async def auth_connect_callback(
) -> HTMLResponse: ) -> HTMLResponse:
state_data = _parse_oauth_state(state) state_data = _parse_oauth_state(state)
if state_data.get("flow") != _FLOW_CONNECT: 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") connection_id = state_data.get("connectionId")
user_id = state_data.get("userId") user_id = state_data.get("userId")
if not connection_id or not user_id: 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() _require_msft_data_config()
msal_app = msal.ConfidentialClientApplication( msal_app = msal.ConfidentialClientApplication(
@ -343,7 +345,7 @@ async def auth_connect_callback(
if user_info_response.status_code != 200: if user_info_response.status_code != 200:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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() user_info = user_info_response.json()
@ -465,7 +467,7 @@ def adminconsent(request: Request) -> RedirectResponse:
if not redirect_uri: if not redirect_uri:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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"}) state_jwt = _issue_oauth_state({"flow": "admin_consent"})
scope_param = _msft_data_admin_consent_scope_param() scope_param = _msft_data_admin_consent_scope_param()
@ -528,7 +530,7 @@ def adminconsent_callback(
state_data = _parse_oauth_state(state) state_data = _parse_oauth_state(state)
if state_data.get("flow") != "admin_consent": 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") granted = str(admin_consent or "").strip().lower() in ("true", "1", "yes")
if not granted: if not granted:
@ -615,7 +617,7 @@ def logout(
if not token: if not token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="No token found", detail=routeApiMsg("No token found"),
) )
try: try:
@ -626,7 +628,7 @@ def logout(
logger.error(f"Failed to decode JWT on Microsoft logout: {str(e)}") logger.error(f"Failed to decode JWT on Microsoft logout: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid token", detail=routeApiMsg("Invalid token"),
) )
revoked = 0 revoked = 0
@ -720,7 +722,7 @@ async def refresh_token(
if not msft_connection: if not msft_connection:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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: else:
for conn in connections: for conn in connections:
@ -730,13 +732,13 @@ async def refresh_token(
if not msft_connection: if not msft_connection:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) current_token = TokenManager().getFreshToken(msft_connection.id)
if not current_token: if not current_token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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() token_manager = TokenManager()
refreshed_token = token_manager.refreshToken(current_token) refreshed_token = token_manager.refreshToken(current_token)
@ -760,7 +762,7 @@ async def refresh_token(
} }
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to refresh token", detail=routeApiMsg("Failed to refresh token"),
) )
except HTTPException: except HTTPException:
raise raise

View file

@ -13,6 +13,8 @@ from modules.auth import limiter, getCurrentUser
from modules.datamodels.datamodelUam import User, UserConnection from modules.datamodels.datamodelUam import User, UserConnection
from modules.interfaces.interfaceDbApp import getInterface from modules.interfaces.interfaceDbApp import getInterface
from modules.serviceHub import getInterface as getServices from modules.serviceHub import getInterface as getServices
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeSharepoint")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -111,7 +113,7 @@ async def get_sharepoint_sites(
if not services.sharepoint.setAccessTokenFromConnection(connection): if not services.sharepoint.setAccessTokenFromConnection(connection):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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 # Discover SharePoint sites
@ -164,7 +166,7 @@ async def list_sharepoint_folders(
if not services.sharepoint.setAccessTokenFromConnection(connection): if not services.sharepoint.setAccessTokenFromConnection(connection):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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) # Normalize folder path (empty string for root)
@ -229,7 +231,7 @@ async def getSharepointFolderOptions(
if not services.sharepoint.setAccessTokenFromConnection(connection): if not services.sharepoint.setAccessTokenFromConnection(connection):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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 # Mode 1: Return sites list if no siteId specified
@ -343,7 +345,7 @@ async def getSharepointFolderOptionsByReference(
if not services.sharepoint.setAccessTokenFromConnection(connection): if not services.sharepoint.setAccessTokenFromConnection(connection):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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 # Mode 1: Return sites list if no siteId specified

View file

@ -23,6 +23,8 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService from modules.security.rbacCatalog import getCatalogService
from modules.security.rbac import RbacClass from modules.security.rbac import RbacClass
from modules.security.rootAccess import getRootDbAppConnector from modules.security.rootAccess import getRootDbAppConnector
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeStore")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -327,7 +329,7 @@ def activateStoreFeature(
mandateId = data.mandateId mandateId = data.mandateId
if not _isUserAdminInMandate(db, userId, 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 ────────────────────────────── # ── 1. Resolve subscription & plan ──────────────────────────────
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS, SubscriptionStatusEnum from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS, SubscriptionStatusEnum
@ -353,7 +355,7 @@ def activateStoreFeature(
) )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED, 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", "") planKey = operative.get("planKey", "")
@ -382,7 +384,7 @@ def activateStoreFeature(
) )
if not instance: 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 instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
@ -460,12 +462,12 @@ def deactivateStoreFeature(
# Verify instance exists in mandate # Verify instance exists in mandate
instances = db.getRecordset(FeatureInstance, recordFilter={"id": instanceId, "mandateId": mandateId}) instances = db.getRecordset(FeatureInstance, recordFilter={"id": instanceId, "mandateId": mandateId})
if not instances: 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 # Find user's FeatureAccess
accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId}) accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId})
if not accesses: 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") featureAccessId = accesses[0].get("id")
db.recordDelete(FeatureAccess, featureAccessId) db.recordDelete(FeatureAccess, featureAccessId)

View file

@ -23,6 +23,8 @@ from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeSubscription")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -53,7 +55,7 @@ def _assertMandateAdmin(context: RequestContext, mandateId: str) -> None:
return return
except Exception: except Exception:
pass 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) mandateId = _resolveMandateId(context)
if not mandateId: 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) _assertMandateAdmin(context, mandateId)
try: try:
@ -195,7 +197,7 @@ def cancelSubscription(
) )
mandateId = _resolveMandateId(context) mandateId = _resolveMandateId(context)
if not mandateId: 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) _assertMandateAdmin(context, mandateId)
try: try:
@ -221,7 +223,7 @@ def reactivateSubscription(
) )
mandateId = _resolveMandateId(context) mandateId = _resolveMandateId(context)
if not mandateId: 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) _assertMandateAdmin(context, mandateId)
try: try:
@ -243,7 +245,7 @@ def forceCancel(
): ):
"""Sysadmin: immediately expire any non-terminal subscription.""" """Sysadmin: immediately expire any non-terminal subscription."""
if not context.hasSysAdminRole: 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 ( from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
getService as getSubscriptionService, getService as getSubscriptionService,
@ -251,7 +253,7 @@ def forceCancel(
from modules.interfaces.interfaceDbSubscription import _getRootInterface as getSubRootInterface from modules.interfaces.interfaceDbSubscription import _getRootInterface as getSubRootInterface
sub = getSubRootInterface().getById(data.subscriptionId) sub = getSubRootInterface().getById(data.subscriptionId)
if not sub: 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"] mandateId = sub["mandateId"]
try: try:
@ -278,7 +280,7 @@ def verifyCheckout(
""" """
mandateId = _resolveMandateId(context) mandateId = _resolveMandateId(context)
if not mandateId: 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) _assertMandateAdmin(context, mandateId)
try: try:
@ -288,7 +290,7 @@ def verifyCheckout(
session = stripeToDict(rawSession) session = stripeToDict(rawSession)
except Exception as e: except Exception as e:
logger.error("Failed to retrieve checkout session %s: %s", data.sessionId, 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") payStatus = session.get("payment_status")
if session.get("status") != "complete": if session.get("status") != "complete":
@ -297,7 +299,7 @@ def verifyCheckout(
return {"status": "pending", "message": "Checkout not yet completed"} return {"status": "pending", "message": "Checkout not yet completed"}
if session.get("mode") != "subscription": 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 from modules.routes.routeBilling import _handleSubscriptionCheckoutCompleted
@ -421,7 +423,7 @@ def getAllSubscriptions(
): ):
"""SysAdmin: list ALL subscriptions across all mandates with enriched metadata.""" """SysAdmin: list ALL subscriptions across all mandates with enriched metadata."""
if not context.hasSysAdminRole: 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 paginationParams: Optional[PaginationParams] = None
if pagination: if pagination:
@ -467,7 +469,7 @@ def getFilterValues(
): ):
"""Return distinct values for a column, respecting all active filters except the requested one.""" """Return distinct values for a column, respecting all active filters except the requested one."""
if not context.hasSysAdminRole: 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 crossFilterParams: Optional[PaginationParams] = None
if pagination: if pagination:

View file

@ -12,7 +12,7 @@ Navigation API Konzept:
import logging import logging
from typing import Dict, List, Any, Optional 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 import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
@ -130,11 +130,11 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
def _buildDynamicBlock( def _buildDynamicBlock(
userId: str, userId: str,
language: str,
isSysAdmin: bool isSysAdmin: bool
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Build the dynamic features block with mandates, features, and instances. 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. Returns None if user has no feature instances.
""" """
@ -181,21 +181,29 @@ def _buildDynamicBlock(
if featureKey not in featuresMap: if featureKey not in featuresMap:
feature = featureInterface.getFeature(instance.featureCode) 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'): if feature and hasattr(feature, 'label'):
featureLabel = feature.label featureLabel = feature.label
# Convert Pydantic model to dict if needed
if hasattr(featureLabel, 'model_dump'): if hasattr(featureLabel, 'model_dump'):
featureLabel = featureLabel.model_dump() featureLabel = featureLabel.model_dump()
elif isinstance(featureLabel, str):
pass
elif not isinstance(featureLabel, dict): elif not isinstance(featureLabel, dict):
# Fallback: try to access as attributes featureLabel = {
featureLabel = {"de": getattr(featureLabel, 'de', instance.featureCode), "en": getattr(featureLabel, 'en', instance.featureCode)} "de": getattr(featureLabel, 'de', instance.featureCode),
"en": getattr(featureLabel, 'en', instance.featureCode),
}
else: else:
featureLabel = {"de": instance.featureCode, "en": instance.featureCode} 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] = { featuresMap[featureKey] = {
"uiComponent": f"feature.{instance.featureCode}", "uiComponent": f"feature.{instance.featureCode}",
"uiLabel": featureLabel.get(language, featureLabel.get("en", instance.featureCode)), "uiLabel": resolvedFeatureLabel,
"order": 10, "order": 10,
"instances": [], "instances": [],
"_mandateId": mandateId, "_mandateId": mandateId,
@ -228,9 +236,8 @@ def _buildDynamicBlock(
# Build path for this view # Build path for this view
viewPath = f"/mandates/{mandateId}/{instance.featureCode}/{instance.id}/{viewName}" viewPath = f"/mandates/{mandateId}/{instance.featureCode}/{instance.id}/{viewName}"
# Get label in requested language
label = uiObj.get("label", {}) 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({ views.append({
"uiComponent": f"page.feature.{instance.featureCode}.{viewName}", "uiComponent": f"page.feature.{instance.featureCode}.{viewName}",
@ -347,7 +354,6 @@ def _getInstanceViewPermissions(
def _filterItems( def _filterItems(
items: List[Dict[str, Any]], items: List[Dict[str, Any]],
language: str,
isSysAdmin: bool, isSysAdmin: bool,
roleIds: List[str], roleIds: List[str],
hasGlobalPermission: bool hasGlobalPermission: bool
@ -361,19 +367,18 @@ def _filterItems(
if item.get("sysAdminOnly") and not isSysAdmin: if item.get("sysAdminOnly") and not isSysAdmin:
continue continue
if item.get("public"): if item.get("public"):
filteredItems.append(_formatBlockItem(item, language)) filteredItems.append(_formatBlockItem(item))
continue continue
if isSysAdmin: if isSysAdmin:
filteredItems.append(_formatBlockItem(item, language)) filteredItems.append(_formatBlockItem(item))
continue continue
if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]): if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]):
filteredItems.append(_formatBlockItem(item, language)) filteredItems.append(_formatBlockItem(item))
filteredItems.sort(key=lambda i: i["order"]) filteredItems.sort(key=lambda i: i["order"])
return filteredItems return filteredItems
def _buildStaticBlocks( def _buildStaticBlocks(
language: str,
isSysAdmin: bool, isSysAdmin: bool,
roleIds: List[str], roleIds: List[str],
hasGlobalPermission: bool hasGlobalPermission: bool
@ -381,8 +386,8 @@ def _buildStaticBlocks(
""" """
Build static navigation blocks from NAVIGATION_SECTIONS. Build static navigation blocks from NAVIGATION_SECTIONS.
Returns list of blocks with items filtered by permissions. Labels/titles are plain German strings (i18n base keys).
Supports subgroups within sections. The frontend translates them via t().
""" """
blocks = [] blocks = []
@ -397,12 +402,12 @@ def _buildStaticBlocks(
filteredSubgroups = [] filteredSubgroups = []
for subgroup in section["subgroups"]: for subgroup in section["subgroups"]:
subItems = _filterItems( subItems = _filterItems(
subgroup.get("items", []), language, isSysAdmin, roleIds, hasGlobalPermission subgroup.get("items", []), isSysAdmin, roleIds, hasGlobalPermission
) )
if subItems: if subItems:
filteredSubgroups.append({ filteredSubgroups.append({
"id": subgroup["id"], "id": subgroup["id"],
"title": subgroup["title"].get(language, subgroup["title"].get("en", subgroup["id"])), "title": subgroup["title"],
"order": subgroup.get("order", 50), "order": subgroup.get("order", 50),
"items": subItems, "items": subItems,
}) })
@ -412,28 +417,28 @@ def _buildStaticBlocks(
topLevelItems = [] topLevelItems = []
if hasItems: if hasItems:
topLevelItems = _filterItems( topLevelItems = _filterItems(
section["items"], language, isSysAdmin, roleIds, hasGlobalPermission section["items"], isSysAdmin, roleIds, hasGlobalPermission
) )
if filteredSubgroups or topLevelItems: if filteredSubgroups or topLevelItems:
blocks.append({ blocks.append({
"type": "static", "type": "static",
"id": section["id"], "id": section["id"],
"title": section["title"].get(language, section["title"].get("en", section["id"])), "title": section["title"],
"order": section.get("order", 50), "order": section.get("order", 50),
"items": topLevelItems, "items": topLevelItems,
"subgroups": filteredSubgroups, "subgroups": filteredSubgroups,
}) })
else: else:
filteredItems = _filterItems( filteredItems = _filterItems(
section.get("items", []), language, isSysAdmin, roleIds, hasGlobalPermission section.get("items", []), isSysAdmin, roleIds, hasGlobalPermission
) )
if filteredItems: if filteredItems:
blocks.append({ blocks.append({
"type": "static", "type": "static",
"id": section["id"], "id": section["id"],
"title": section["title"].get(language, section["title"].get("en", section["id"])), "title": section["title"],
"order": section.get("order", 50), "order": section.get("order", 50),
"items": filteredItems, "items": filteredItems,
}) })
@ -441,19 +446,19 @@ def _buildStaticBlocks(
return blocks 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 Labels are plain German strings (i18n base keys).
Does NOT include icon (UI maps via uiComponent) The frontend translates them via t().
""" """
objectKey = item["objectKey"] objectKey = item["objectKey"]
uiComponent = _objectKeyToUiComponent(objectKey) uiComponent = _objectKeyToUiComponent(objectKey)
return { return {
"uiComponent": uiComponent, "uiComponent": uiComponent,
"uiLabel": item["label"].get(language, item["label"].get("en", item["id"])), "uiLabel": item["label"],
"uiPath": item["path"], "uiPath": item["path"],
"order": item.get("order", 50), "order": item.get("order", 50),
"objectKey": objectKey, "objectKey": objectKey,
@ -464,52 +469,15 @@ def _formatBlockItem(item: Dict[str, Any], language: str) -> Dict[str, Any]:
@limiter.limit("60/minute") @limiter.limit("60/minute")
def get_navigation( def get_navigation(
request: Request, request: Request,
language: str = Query("de", description="Language for labels (en, de, fr)"),
reqContext: RequestContext = Depends(getRequestContext) reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get unified navigation structure with blocks. 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 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: try:
isSysAdmin = reqContext.hasSysAdminRole isSysAdmin = reqContext.hasSysAdminRole
@ -526,11 +494,11 @@ def get_navigation(
hasGlobalPermission = _checkUiPermission(roleIds, "_global_check") hasGlobalPermission = _checkUiPermission(roleIds, "_global_check")
# Build static blocks from NAVIGATION_SECTIONS # 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 # Build dynamic block (features) if user has feature instances
if userId: if userId:
dynamicBlock = _buildDynamicBlock(userId, language, isSysAdmin) dynamicBlock = _buildDynamicBlock(userId, isSysAdmin)
if dynamicBlock: if dynamicBlock:
blocks.append(dynamicBlock) blocks.append(dynamicBlock)
@ -538,14 +506,12 @@ def get_navigation(
blocks.sort(key=lambda b: b["order"]) blocks.sort(key=lambda b: b["order"])
return { return {
"language": language,
"blocks": blocks, "blocks": blocks,
} }
except Exception as e: except Exception as e:
logger.error(f"Error getting navigation: {e}") logger.error(f"Error getting navigation: {e}")
return { return {
"language": language,
"blocks": [], "blocks": [],
"error": str(e), "error": str(e),
} }

View file

@ -18,6 +18,8 @@ from typing import Optional, Dict, Any, List
from modules.auth import getCurrentUser, getRequestContext, RequestContext, limiter from modules.auth import getCurrentUser, getRequestContext, RequestContext, limiter
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface, VoiceObjects from modules.interfaces.interfaceVoiceObjects import getVoiceInterface, VoiceObjects
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeVoiceGoogle")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/voice-google", tags=["Voice Google"]) router = APIRouter(prefix="/voice-google", tags=["Voice Google"])
@ -132,7 +134,7 @@ async def detect_language(
if not text.strip(): if not text.strip():
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Empty text provided for language detection" detail=routeApiMsg("Empty text provided for language detection")
) )
# Get voice interface # Get voice interface
@ -176,7 +178,7 @@ async def translate_text(
if not text.strip(): if not text.strip():
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Empty text provided for translation" detail=routeApiMsg("Empty text provided for translation")
) )
# Get voice interface # Get voice interface
@ -306,7 +308,7 @@ async def text_to_speech(
if not text.strip(): if not text.strip():
raise HTTPException( raise HTTPException(
status_code=400, 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 "") mandateId = str(getattr(context, "mandateId", "") or "")

View file

@ -17,6 +17,8 @@ from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelUam import User, UserVoicePreferences, _normalizeTtsVoiceMap from modules.datamodels.datamodelUam import User, UserVoicePreferences, _normalizeTtsVoiceMap
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeVoiceUser")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -176,7 +178,7 @@ def _resolveMandateIdForVoiceTestAi(request: Request, currentUser: User) -> str:
if headerRaw not in memberIds: if headerRaw not in memberIds:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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): if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId):
logger.info( logger.info(
@ -294,7 +296,7 @@ async def _generateTtsSampleTextForLocale(
logger.warning("Voice test AI sample empty or errorCount=%s", getattr(response, "errorCount", None)) logger.warning("Voice test AI sample empty or errorCount=%s", getattr(response, "errorCount", None))
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, 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: if len(content) > 500:
content = content[:500].rstrip() content = content[:500].rstrip()

View file

@ -23,6 +23,9 @@ from modules.datamodels.datamodelPagination import PaginationParams
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoRun, AutoStepLog, AutoWorkflow, AutoTask,
) )
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeWorkflowDashboard")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
limiter = Limiter(key_func=get_remote_address) 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).""" """Get step logs for a specific run (with access check)."""
db = _getDb() db = _getDb()
if not db._ensureTableExists(AutoRun): 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}) runs = db.getRecordset(AutoRun, recordFilter={"id": runId})
if not runs: 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]) run = dict(runs[0])
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
@ -256,7 +259,7 @@ def get_run_steps(
elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): elif runMandate and userId and _isUserMandateAdmin(userId, runMandate):
pass pass
else: else:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
if not db._ensureTableExists(AutoStepLog): if not db._ensureTableExists(AutoStepLog):
return {"steps": []} return {"steps": []}

View file

@ -8,7 +8,7 @@ Feature-Container register their RBAC objects via mainXxx.py at startup.
""" """
import logging import logging
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional, Union
from threading import Lock from threading import Lock
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,7 +43,7 @@ class RbacCatalogService:
self._initialized = True self._initialized = True
logger.info("RBAC Catalog Service initialized") 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.""" """Register a UI object for a feature."""
try: try:
self._uiObjects[objectKey] = {"objectKey": objectKey, "featureCode": featureCode, "label": label, "meta": meta or {}, "type": "UI"} 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}") logger.error(f"Failed to register DATA object {objectKey}: {e}")
return False 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.""" """Register a feature definition."""
try: try:
self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon} self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon}

View file

@ -33,98 +33,98 @@ IMPORTABLE_SERVICES: Dict[str, Dict[str, Any]] = {
"class": "TicketService", "class": "TicketService",
"dependencies": [], "dependencies": [],
"objectKey": "service.ticket", "objectKey": "service.ticket",
"label": {"en": "Ticket System", "de": "Ticket-System", "fr": "Système de tickets"}, "label": "Ticket-System",
}, },
"messaging": { "messaging": {
"module": "modules.serviceCenter.services.serviceMessaging.mainServiceMessaging", "module": "modules.serviceCenter.services.serviceMessaging.mainServiceMessaging",
"class": "MessagingService", "class": "MessagingService",
"dependencies": [], "dependencies": [],
"objectKey": "service.messaging", "objectKey": "service.messaging",
"label": {"en": "Messaging", "de": "Nachrichten", "fr": "Messagerie"}, "label": "Nachrichten",
}, },
"billing": { "billing": {
"module": "modules.serviceCenter.services.serviceBilling.mainServiceBilling", "module": "modules.serviceCenter.services.serviceBilling.mainServiceBilling",
"class": "BillingService", "class": "BillingService",
"dependencies": ["subscription"], "dependencies": ["subscription"],
"objectKey": "service.billing", "objectKey": "service.billing",
"label": {"en": "Billing", "de": "Abrechnung", "fr": "Facturation"}, "label": "Abrechnung",
}, },
"subscription": { "subscription": {
"module": "modules.serviceCenter.services.serviceSubscription.mainServiceSubscription", "module": "modules.serviceCenter.services.serviceSubscription.mainServiceSubscription",
"class": "SubscriptionService", "class": "SubscriptionService",
"dependencies": [], "dependencies": [],
"objectKey": "service.subscription", "objectKey": "service.subscription",
"label": {"en": "Subscription", "de": "Abonnement", "fr": "Abonnement"}, "label": "Abonnement",
}, },
"sharepoint": { "sharepoint": {
"module": "modules.serviceCenter.services.serviceSharepoint.mainServiceSharepoint", "module": "modules.serviceCenter.services.serviceSharepoint.mainServiceSharepoint",
"class": "SharepointService", "class": "SharepointService",
"dependencies": ["security"], "dependencies": ["security"],
"objectKey": "service.sharepoint", "objectKey": "service.sharepoint",
"label": {"en": "SharePoint", "de": "SharePoint", "fr": "SharePoint"}, "label": "SharePoint",
}, },
"clickup": { "clickup": {
"module": "modules.serviceCenter.services.serviceClickup.mainServiceClickup", "module": "modules.serviceCenter.services.serviceClickup.mainServiceClickup",
"class": "ClickupService", "class": "ClickupService",
"dependencies": ["security"], "dependencies": ["security"],
"objectKey": "service.clickup", "objectKey": "service.clickup",
"label": {"en": "ClickUp", "de": "ClickUp", "fr": "ClickUp"}, "label": "ClickUp",
}, },
"chat": { "chat": {
"module": "modules.serviceCenter.services.serviceChat.mainServiceChat", "module": "modules.serviceCenter.services.serviceChat.mainServiceChat",
"class": "ChatService", "class": "ChatService",
"dependencies": ["utils"], "dependencies": ["utils"],
"objectKey": "service.chat", "objectKey": "service.chat",
"label": {"en": "Chat", "de": "Chat", "fr": "Chat"}, "label": "Chat",
}, },
"extraction": { "extraction": {
"module": "modules.serviceCenter.services.serviceExtraction.mainServiceExtraction", "module": "modules.serviceCenter.services.serviceExtraction.mainServiceExtraction",
"class": "ExtractionService", "class": "ExtractionService",
"dependencies": ["chat", "utils"], "dependencies": ["chat", "utils"],
"objectKey": "service.extraction", "objectKey": "service.extraction",
"label": {"en": "Extraction", "de": "Extraktion", "fr": "Extraction"}, "label": "Extraktion",
}, },
"generation": { "generation": {
"module": "modules.serviceCenter.services.serviceGeneration.mainServiceGeneration", "module": "modules.serviceCenter.services.serviceGeneration.mainServiceGeneration",
"class": "GenerationService", "class": "GenerationService",
"dependencies": ["utils", "chat"], "dependencies": ["utils", "chat"],
"objectKey": "service.generation", "objectKey": "service.generation",
"label": {"en": "Generation", "de": "Generierung", "fr": "Génération"}, "label": "Generierung",
}, },
"ai": { "ai": {
"module": "modules.serviceCenter.services.serviceAi.mainServiceAi", "module": "modules.serviceCenter.services.serviceAi.mainServiceAi",
"class": "AiService", "class": "AiService",
"dependencies": ["chat", "utils", "extraction", "billing"], "dependencies": ["chat", "utils", "extraction", "billing"],
"objectKey": "service.ai", "objectKey": "service.ai",
"label": {"en": "AI", "de": "KI", "fr": "IA"}, "label": "KI",
}, },
"web": { "web": {
"module": "modules.serviceCenter.services.serviceWeb.mainServiceWeb", "module": "modules.serviceCenter.services.serviceWeb.mainServiceWeb",
"class": "WebService", "class": "WebService",
"dependencies": ["ai", "chat", "utils"], "dependencies": ["ai", "chat", "utils"],
"objectKey": "service.web", "objectKey": "service.web",
"label": {"en": "Web Research", "de": "Web-Recherche", "fr": "Recherche Web"}, "label": "Web-Recherche",
}, },
"neutralization": { "neutralization": {
"module": "modules.features.neutralization.serviceNeutralization.mainServiceNeutralization", "module": "modules.features.neutralization.serviceNeutralization.mainServiceNeutralization",
"class": "NeutralizationService", "class": "NeutralizationService",
"dependencies": ["extraction", "generation"], "dependencies": ["extraction", "generation"],
"objectKey": "service.neutralization", "objectKey": "service.neutralization",
"label": {"en": "Neutralization", "de": "Neutralisierung", "fr": "Neutralisation"}, "label": "Neutralisierung",
}, },
"agent": { "agent": {
"module": "modules.serviceCenter.services.serviceAgent.mainServiceAgent", "module": "modules.serviceCenter.services.serviceAgent.mainServiceAgent",
"class": "AgentService", "class": "AgentService",
"dependencies": ["ai", "chat", "utils", "extraction", "billing", "streaming", "knowledge"], "dependencies": ["ai", "chat", "utils", "extraction", "billing", "streaming", "knowledge"],
"objectKey": "service.agent", "objectKey": "service.agent",
"label": {"en": "Agent", "de": "Agent", "fr": "Agent"}, "label": "Agent",
}, },
"knowledge": { "knowledge": {
"module": "modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge", "module": "modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge",
"class": "KnowledgeService", "class": "KnowledgeService",
"dependencies": ["ai"], "dependencies": ["ai"],
"objectKey": "service.knowledge", "objectKey": "service.knowledge",
"label": {"en": "Knowledge Store", "de": "Wissensspeicher", "fr": "Base de connaissances"}, "label": "Wissensspeicher",
}, },
} }

View file

@ -18,6 +18,12 @@ from modules.datamodels.datamodelExtraction import ContentPart
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped 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__) logger = logging.getLogger(__name__)
@ -719,12 +725,8 @@ class StructureFiller:
self.services.chat.progressLogUpdate(sectionOperationId, 0.8, "Validating generated content") self.services.chat.progressLogUpdate(sectionOperationId, 0.8, "Validating generated content")
class _AiResponse:
def __init__(self, content):
self.content = content
responseElements = await self._processAiResponseForSection( responseElements = await self._processAiResponseForSection(
aiResponse=_AiResponse(aiResponseJson), aiResponse=_AiResponseFallback(aiResponseJson),
contentType=contentType, contentType=contentType,
operationType=operationType, operationType=operationType,
sectionId=sectionId, sectionId=sectionId,
@ -1032,17 +1034,10 @@ class StructureFiller:
else: else:
generatedElements = [] generatedElements = []
class AiResponse: aiResponse = _AiResponseFallback(aiResponseJson)
def __init__(self, content):
self.content = content
aiResponse = AiResponse(aiResponseJson)
except Exception as parseError: except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
class AiResponse: aiResponse = _AiResponseFallback(aiResponseJson)
def __init__(self, content):
self.content = content
aiResponse = AiResponse(aiResponseJson)
generatedElements = [] generatedElements = []
self.services.chat.progressLogUpdate(sectionOperationId, 0.6, "Processing AI response") self.services.chat.progressLogUpdate(sectionOperationId, 0.6, "Processing AI response")
@ -1200,17 +1195,10 @@ class StructureFiller:
else: else:
generatedElements = [] generatedElements = []
class AiResponse: aiResponse = _AiResponseFallback(aiResponseJson)
def __init__(self, content):
self.content = content
aiResponse = AiResponse(aiResponseJson)
except Exception as parseError: except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
class AiResponse: aiResponse = _AiResponseFallback(aiResponseJson)
def __init__(self, content):
self.content = content
aiResponse = AiResponse(aiResponseJson)
generatedElements = [] generatedElements = []
self.services.chat.progressLogUpdate(sectionOperationId, 0.6, "Processing AI response") self.services.chat.progressLogUpdate(sectionOperationId, 0.6, "Processing AI response")
@ -1467,17 +1455,10 @@ class StructureFiller:
else: else:
generatedElements = [] generatedElements = []
class AiResponse: aiResponse = _AiResponseFallback(aiResponseJson)
def __init__(self, content):
self.content = content
aiResponse = AiResponse(aiResponseJson)
except Exception as parseError: except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
class AiResponse: aiResponse = _AiResponseFallback(aiResponseJson)
def __init__(self, content):
self.content = content
aiResponse = AiResponse(aiResponseJson)
generatedElements = [] generatedElements = []
self.services.chat.progressLogUpdate(sectionOperationId, 0.6, "Processing AI response") self.services.chat.progressLogUpdate(sectionOperationId, 0.6, "Processing AI response")

View file

@ -36,42 +36,44 @@ class AttributeDefinition(BaseModel):
placeholder: Optional[str] = None placeholder: Optional[str] = None
# Global registry for model labels def _getModelLabelEntry(modelName: str) -> Dict[str, Any]:
MODEL_LABELS: Dict[str, Dict[str, Dict[str, str]]] = {} """Resolve label data produced by @i18nModel (see modules.shared.i18nRegistry.MODEL_LABELS)."""
try:
from modules.shared.i18nRegistry import MODEL_LABELS as i18nModelLabels
def registerModelLabels(modelName: str, modelLabel: Dict[str, str], labels: Dict[str, Dict[str, str]]): except ImportError:
""" return {}
Register labels for a model's attributes and the model itself. return i18nModelLabels.get(modelName) or {}
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 getModelLabels(modelName: str, language: str = "en") -> Dict[str, str]: 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: Reads @i18nModel registration (German base strings); non-German languages use the i18n cache.
modelName: Name of the model class Attribute values are strings; dict-shaped entries are still accepted for unusual callers.
language: Language code (default: "en")
Returns:
Dictionary mapping attribute names to their labels in the specified language
""" """
modelData = MODEL_LABELS.get(modelName, {}) modelData = _getModelLabelEntry(modelName)
attributeLabels = modelData.get("attributes", {}) attributeLabels = modelData.get("attributes", {})
return { result: Dict[str, str] = {}
attr: translations.get(language, translations.get("en", attr)) for attr, translations in attributeLabels.items():
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]: 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: def getModelLabel(modelName: str, language: str = "en") -> str:
""" """Get the label for a model in the specified language (see getModelLabels)."""
Get the label for a model in the specified language. modelData = _getModelLabelEntry(modelName)
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, {})
modelLabel = modelData.get("model", {}) 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]: def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]:

Some files were not shown because too many files have changed in this diff Show more