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:
logger.error(f"Feature catalog registration failed: {e}")
# Sync gateway i18n registry to DB and load translation cache
try:
from modules.shared.i18nRegistry import _syncRegistryToDb, _loadCache
await _syncRegistryToDb()
await _loadCache()
logger.info("i18n registry sync + cache load completed")
except Exception as e:
logger.warning(f"i18n registry sync failed (non-critical): {e}")
# Pre-warm service center modules (avoids first-request import latency)
try:
from modules.serviceCenter import preWarm
@ -481,6 +490,16 @@ from modules.auth import (
ProactiveTokenRefreshMiddleware,
)
# i18n language detection middleware (sets per-request language from Accept-Language header)
from modules.shared.i18nRegistry import _setLanguage
@app.middleware("http")
async def _i18nMiddleware(request: Request, call_next):
acceptLang = request.headers.get("Accept-Language", "")
lang = acceptLang[:2].lower() if len(acceptLang) >= 2 and acceptLang[:2].isalpha() else "de"
_setLanguage(lang)
return await call_next(request)
app.add_middleware(CSRFMiddleware)
# Token refresh middleware (silent refresh for expired OAuth tokens)

View file

@ -20,7 +20,7 @@ from enum import Enum
import uuid
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
class AuditCategory(str, Enum):
@ -82,6 +82,7 @@ class AuditAction(str, Enum):
CONFIG_CHANGE = "config_change"
@i18nModel("Audit-Log-Eintrag")
class AuditLogEntry(BaseModel):
"""
Audit log entry for database storage.
@ -92,117 +93,94 @@ class AuditLogEntry(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique identifier for the audit entry",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Timestamp
timestamp: float = Field(
default_factory=getUtcTimestamp,
description="UTC timestamp when the event occurred",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Zeitstempel", "frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True}
)
# Actor identification
userId: str = Field(
description="ID of the user who performed the action (or 'system' for system events)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
username: Optional[str] = Field(
default=None,
description="Username at the time of the event (for historical reference)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Benutzername", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Context
mandateId: Optional[str] = Field(
default=None,
description="Mandate context (if applicable)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature instance context (if applicable)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Event classification
category: str = Field(
description="Event category (access, key, data, security, gdpr, permission, system)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Kategorie", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
action: str = Field(
description="Specific action performed",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Aktion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
# Event details
resourceType: Optional[str] = Field(
default=None,
description="Type of resource affected (e.g., 'User', 'ChatWorkflow', 'TrusteeContract')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Ressourcentyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
resourceId: Optional[str] = Field(
default=None,
description="ID of the affected resource",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Ressourcen-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
details: Optional[str] = Field(
default=None,
description="Additional details about the event",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Details", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
)
# Request metadata
ipAddress: Optional[str] = Field(
default=None,
description="IP address of the client",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "IP-Adresse", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
userAgent: Optional[str] = Field(
default=None,
description="User agent string from the request",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "User-Agent", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Outcome
success: bool = Field(
default=True,
description="Whether the action was successful",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Erfolgreich", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True}
)
errorMessage: Optional[str] = Field(
default=None,
description="Error message if the action failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Fehlermeldung", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
)
# Register labels for internationalization
registerModelLabels(
"AuditLogEntry",
{"en": "Audit Log Entry", "de": "Audit-Log-Eintrag", "fr": "Entrée du journal d'audit"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID", "fr": "ID de l'instance"},
"category": {"en": "Category", "de": "Kategorie", "fr": "Catégorie"},
"action": {"en": "Action", "de": "Aktion", "fr": "Action"},
"resourceType": {"en": "Resource Type", "de": "Ressourcentyp", "fr": "Type de ressource"},
"resourceId": {"en": "Resource ID", "de": "Ressourcen-ID", "fr": "ID de ressource"},
"details": {"en": "Details", "de": "Details", "fr": "Détails"},
"ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"},
"userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"},
"success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"},
"errorMessage": {"en": "Error Message", "de": "Fehlermeldung", "fr": "Message d'erreur"},
},
)

View file

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

View file

@ -7,7 +7,7 @@ from enum import Enum
from datetime import date, datetime, timezone
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
# End-customer price for storage above plan-included volume (CHF per GB per month).
@ -38,203 +38,170 @@ class PeriodTypeEnum(str, Enum):
YEAR = "YEAR"
@i18nModel("Abrechnungskonto")
class BillingAccount(PowerOnModel):
"""Billing account for mandate or user-mandate combination."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
mandateId: str = Field(..., description="Foreign key to Mandate")
userId: Optional[str] = Field(None, description="Foreign key to User (None = mandate pool account, set = user audit account)")
balance: float = Field(default=0.0, description="Current balance in CHF")
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
enabled: bool = Field(default=True, description="Account is active")
registerModelLabels(
"BillingAccount",
{"en": "Billing Account", "de": "Abrechnungskonto"},
{
"id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID"},
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
"enabled": {"en": "Enabled", "de": "Aktiv"},
},
)
mandateId: str = Field(..., description="Foreign key to Mandate", json_schema_extra={"label": "Mandanten-ID"})
userId: Optional[str] = Field(
None,
description="Foreign key to User (None = mandate pool account, set = user audit account)",
json_schema_extra={"label": "Benutzer-ID"},
)
balance: float = Field(default=0.0, description="Current balance in CHF", json_schema_extra={"label": "Guthaben (CHF)"})
warningThreshold: float = Field(
default=0.0,
description="Warning threshold in CHF",
json_schema_extra={"label": "Warnschwelle (CHF)"},
)
lastWarningAt: Optional[datetime] = Field(
None,
description="Last warning sent timestamp",
json_schema_extra={"label": "Letzte Warnung"},
)
enabled: bool = Field(default=True, description="Account is active", json_schema_extra={"label": "Aktiv"})
@i18nModel("Transaktion")
class BillingTransaction(PowerOnModel):
"""Single billing transaction (credit, debit, adjustment)."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
accountId: str = Field(..., description="Foreign key to BillingAccount")
transactionType: TransactionTypeEnum = Field(..., description="Transaction type")
amount: float = Field(..., description="Amount in CHF (always positive)")
description: str = Field(..., description="Transaction description")
accountId: str = Field(..., description="Foreign key to BillingAccount", json_schema_extra={"label": "Konto-ID"})
transactionType: TransactionTypeEnum = Field(..., description="Transaction type", json_schema_extra={"label": "Typ"})
amount: float = Field(..., description="Amount in CHF (always positive)", json_schema_extra={"label": "Betrag (CHF)"})
description: str = Field(..., description="Transaction description", json_schema_extra={"label": "Beschreibung"})
# Reference to source
referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type")
referenceId: Optional[str] = Field(None, description="Reference ID")
referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type", json_schema_extra={"label": "Referenztyp"})
referenceId: Optional[str] = Field(None, description="Reference ID", json_schema_extra={"label": "Referenz-ID"})
# Context for workflow transactions
workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)")
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID")
featureCode: Optional[str] = Field(None, description="Feature code (e.g., automation)")
aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)")
aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)")
createdByUserId: Optional[str] = Field(None, description="User who created/caused this transaction")
workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)", json_schema_extra={"label": "Workflow-ID"})
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID", json_schema_extra={"label": "Feature-Instanz-ID"})
featureCode: Optional[str] = Field(None, description="Feature code (e.g., automation)", json_schema_extra={"label": "Feature-Code"})
aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)", json_schema_extra={"label": "AI-Anbieter"})
aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)", json_schema_extra={"label": "AI-Modell"})
createdByUserId: Optional[str] = Field(None, description="User who created/caused this transaction", json_schema_extra={"label": "Erstellt von Benutzer"})
# AI call metadata (for per-call analytics)
processingTime: Optional[float] = Field(None, description="Processing time in seconds")
bytesSent: Optional[int] = Field(None, description="Bytes sent to AI model")
bytesReceived: Optional[int] = Field(None, description="Bytes received from AI model")
errorCount: Optional[int] = Field(None, description="Number of errors in this call")
registerModelLabels(
"BillingTransaction",
{"en": "Billing Transaction", "de": "Transaktion"},
{
"id": {"en": "ID", "de": "ID"},
"accountId": {"en": "Account ID", "de": "Konto-ID"},
"transactionType": {"en": "Type", "de": "Typ"},
"amount": {"en": "Amount (CHF)", "de": "Betrag (CHF)"},
"description": {"en": "Description", "de": "Beschreibung"},
"referenceType": {"en": "Reference Type", "de": "Referenztyp"},
"referenceId": {"en": "Reference ID", "de": "Referenz-ID"},
"workflowId": {"en": "Workflow ID", "de": "Workflow-ID"},
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID"},
"featureCode": {"en": "Feature Code", "de": "Feature-Code"},
"aicoreProvider": {"en": "AI Provider", "de": "AI-Anbieter"},
"aicoreModel": {"en": "AI Model", "de": "AI-Modell"},
"createdByUserId": {"en": "Created By User", "de": "Erstellt von Benutzer"},
},
)
processingTime: Optional[float] = Field(None, description="Processing time in seconds", json_schema_extra={"label": "Verarbeitungszeit (s)"})
bytesSent: Optional[int] = Field(None, description="Bytes sent to AI model", json_schema_extra={"label": "Gesendete Bytes"})
bytesReceived: Optional[int] = Field(None, description="Bytes received from AI model", json_schema_extra={"label": "Empfangene Bytes"})
errorCount: Optional[int] = Field(None, description="Number of errors in this call", json_schema_extra={"label": "Fehleranzahl"})
@i18nModel("Abrechnungseinstellungen")
class BillingSettings(BaseModel):
"""Billing settings per mandate. Only PREPAY_MANDATE model."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)", json_schema_extra={"label": "Mandanten-ID"})
warningThresholdPercent: float = Field(
default=10.0,
description="Warning threshold as percentage",
json_schema_extra={"label": "Warnschwelle (%)"},
)
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)")
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
# Stripe
stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate")
stripeCustomerId: Optional[str] = Field(
None,
description="Stripe Customer ID (cus_xxx) — one per mandate",
json_schema_extra={"label": "Stripe-Kunden-ID"},
)
# Auto-Recharge for AI budget
autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low")
rechargeAmountCHF: float = Field(default=10.0, description="Amount per auto-recharge (CHF, prepaid via Stripe)")
rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month")
rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month")
monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset")
autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low", json_schema_extra={"label": "Auto-Nachladung"})
rechargeAmountCHF: float = Field(
default=10.0,
description="Amount per auto-recharge (CHF, prepaid via Stripe)",
json_schema_extra={"label": "Nachladebetrag (CHF)"},
)
rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month", json_schema_extra={"label": "Max. Nachladungen/Monat"})
rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month", json_schema_extra={"label": "Nachladungen diesen Monat"})
monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset", json_schema_extra={"label": "Monats-Reset"})
# Notifications
notifyEmails: List[str] = Field(
default_factory=list,
description="Email addresses for billing alerts (pool exhausted, warnings, etc.)",
json_schema_extra={"label": "E-Mails fuer Billing-Alerts (Inhaber/Admin)"},
)
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached", json_schema_extra={"label": "Bei Warnung benachrichtigen"})
# Storage overage (high-watermark within subscription period; resets on new period)
storageHighWatermarkMB: float = Field(
default=0.0, description="Peak indexed data volume MB this billing period"
default=0.0,
description="Peak indexed data volume MB this billing period",
json_schema_extra={"label": "Speicher-Peak (MB)"},
)
storagePeriodStartAt: Optional[datetime] = Field(
None, description="Subscription billing period start used for storage reset"
None,
description="Subscription billing period start used for storage reset",
json_schema_extra={"label": "Speicher-Periodenbeginn"},
)
storageBilledUpToMB: float = Field(
default=0.0,
description="Overage MB already debited this period (above plan-included volume)",
json_schema_extra={"label": "Speicher abgerechneter Überhang (MB)"},
)
registerModelLabels(
"BillingSettings",
{"en": "Billing Settings", "de": "Abrechnungseinstellungen"},
{
"id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
"stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"},
"autoRechargeEnabled": {"en": "Auto-Recharge", "de": "Auto-Nachladung"},
"rechargeAmountCHF": {"en": "Recharge Amount (CHF)", "de": "Nachladebetrag (CHF)"},
"rechargeMaxPerMonth": {"en": "Max Recharges/Month", "de": "Max. Nachladungen/Monat"},
"notifyEmails": {
"en": "Billing notification emails (owner / admin)",
"de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
},
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
"storageHighWatermarkMB": {"en": "Storage peak (MB)", "de": "Speicher-Peak (MB)"},
"storagePeriodStartAt": {"en": "Storage period start", "de": "Speicher-Periodenbeginn"},
"storageBilledUpToMB": {
"en": "Storage billed overage (MB)",
"de": "Speicher abgerechneter Überhang (MB)",
},
},
)
class StripeWebhookEvent(BaseModel):
"""Stores processed Stripe webhook event IDs for idempotency."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
)
event_id: str = Field(..., description="Stripe event ID (evt_xxx)")
processed_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="When the event was processed"
description="When the event was processed",
)
@i18nModel("Nutzungsstatistik")
class UsageStatistics(BaseModel):
"""Aggregated usage statistics for quick retrieval."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
accountId: str = Field(..., description="Foreign key to BillingAccount")
periodType: PeriodTypeEnum = Field(..., description="Period type")
periodStart: date = Field(..., description="Period start date")
accountId: str = Field(..., description="Foreign key to BillingAccount", json_schema_extra={"label": "Konto-ID"})
periodType: PeriodTypeEnum = Field(..., description="Period type", json_schema_extra={"label": "Periodentyp"})
periodStart: date = Field(..., description="Period start date", json_schema_extra={"label": "Periodenbeginn"})
# Aggregated values
totalCostCHF: float = Field(default=0.0, description="Total cost in CHF")
transactionCount: int = Field(default=0, description="Number of transactions")
totalCostCHF: float = Field(default=0.0, description="Total cost in CHF", json_schema_extra={"label": "Gesamtkosten (CHF)"})
transactionCount: int = Field(default=0, description="Number of transactions", json_schema_extra={"label": "Anzahl Transaktionen"})
# Breakdown by provider
costByProvider: Dict[str, float] = Field(
default_factory=dict,
description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})"
default_factory=dict,
description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})",
json_schema_extra={"label": "Kosten nach Anbieter"},
)
# Breakdown by feature
costByFeature: Dict[str, float] = Field(
default_factory=dict,
description="Cost breakdown by feature (e.g., {'automation': 5.80, 'workspace': 3.20})"
description="Cost breakdown by feature (e.g., {'automation': 5.80, 'workspace': 3.20})",
json_schema_extra={"label": "Kosten nach Feature"},
)
registerModelLabels(
"UsageStatistics",
{"en": "Usage Statistics", "de": "Nutzungsstatistik"},
{
"id": {"en": "ID", "de": "ID"},
"accountId": {"en": "Account ID", "de": "Konto-ID"},
"periodType": {"en": "Period Type", "de": "Periodentyp"},
"periodStart": {"en": "Period Start", "de": "Periodenbeginn"},
"totalCostCHF": {"en": "Total Cost (CHF)", "de": "Gesamtkosten (CHF)"},
"transactionCount": {"en": "Transaction Count", "de": "Anzahl Transaktionen"},
"costByProvider": {"en": "Cost by Provider", "de": "Kosten nach Anbieter"},
"costByFeature": {"en": "Cost by Feature", "de": "Kosten nach Feature"},
},
)
# ============================================================================
# Response Models for API
# ============================================================================
@ -277,4 +244,3 @@ class BillingCheckResult(BaseModel):
subscriptionUiPath: Optional[str] = None
userAction: Optional[str] = None

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 pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Datenquelle")
class DataSource(PowerOnModel):
"""Configured external data source linked to a UserConnection."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
connectionId: str = Field(description="FK to UserConnection")
sourceType: str = Field(
description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)"
"""Konfigurierte externe Datenquelle verknuepft mit einer UserConnection."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
connectionId: str = Field(
description="FK to UserConnection",
json_schema_extra={"label": "Verbindungs-ID"},
)
sourceType: str = Field(
description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)",
json_schema_extra={"label": "Quellentyp"},
)
path: str = Field(
description="External path (e.g. '/sites/MySite/Documents/Reports')",
json_schema_extra={"label": "Pfad"},
)
label: str = Field(
description="User-visible label (often the last path segment)",
json_schema_extra={"label": "Bezeichnung"},
)
path: str = Field(description="External path (e.g. '/sites/MySite/Documents/Reports')")
label: str = Field(description="User-visible label (often the last path segment)")
displayPath: Optional[str] = Field(
default=None,
description="Human-readable full path for UI (connection-relative, slash-separated)",
json_schema_extra={"label": "Anzeigepfad"},
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Scoped to feature instance",
json_schema_extra={"label": "Feature-Instanz"},
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate scope",
json_schema_extra={"label": "Mandanten-ID"},
)
userId: str = Field(
default="",
description="Owner user ID",
json_schema_extra={"label": "Benutzer-ID"},
)
autoSync: bool = Field(
default=False,
description="Automatically sync on schedule",
json_schema_extra={"label": "Auto-Sync"},
)
lastSynced: Optional[float] = Field(
default=None,
description="Last sync timestamp",
json_schema_extra={"label": "Letzter Sync"},
)
featureInstanceId: Optional[str] = Field(default=None, description="Scoped to feature instance")
mandateId: Optional[str] = Field(default=None, description="Mandate scope")
userId: str = Field(default="", description="Owner user ID")
autoSync: bool = Field(default=False, description="Automatically sync on schedule")
lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp")
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
]},
)
neutralize: bool = Field(
default=False,
description="Whether this data source should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
registerModelLabels(
"DataSource",
{"en": "Data Source", "de": "Datenquelle", "fr": "Source de données"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
"sourceType": {"en": "Source Type", "de": "Quellentyp", "fr": "Type de source"},
"path": {"en": "Path", "de": "Pfad", "fr": "Chemin"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"displayPath": {"en": "Display path", "de": "Anzeigepfad", "fr": "Chemin affiché"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de fonctionnalité"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"},
"lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
},
)
class ExternalEntry(BaseModel):
"""An item (file or folder) from an external data source."""
name: str = Field(description="Item name")

View file

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

View file

@ -9,54 +9,69 @@ so the agent can query structured feature data (e.g. TrusteePosition rows).
from typing import Dict, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Feature-Datenquelle")
class FeatureDataSource(PowerOnModel):
"""A feature-instance table attached as data source in the AI workspace."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
featureInstanceId: str = Field(description="FK to FeatureInstance")
featureCode: str = Field(description="Feature code (e.g. trustee, commcoach)")
tableName: str = Field(description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)")
objectKey: str = Field(description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)")
label: str = Field(description="User-visible label")
mandateId: str = Field(default="", description="Mandate scope")
userId: str = Field(default="", description="Owner user ID")
workspaceInstanceId: str = Field(description="Workspace instance where this source is used")
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
featureInstanceId: str = Field(
description="FK to FeatureInstance",
json_schema_extra={"label": "Feature-Instanz"},
)
featureCode: str = Field(
description="Feature code (e.g. trustee, commcoach)",
json_schema_extra={"label": "Feature"},
)
tableName: str = Field(
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
json_schema_extra={"label": "Tabelle"},
)
objectKey: str = Field(
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
json_schema_extra={"label": "Objekt-Schluessel"},
)
label: str = Field(
description="User-visible label",
json_schema_extra={"label": "Bezeichnung"},
)
mandateId: str = Field(
default="",
description="Mandate scope",
json_schema_extra={"label": "Mandant"},
)
userId: str = Field(
default="",
description="Owner user ID",
json_schema_extra={"label": "Benutzer"},
)
workspaceInstanceId: str = Field(
description="Workspace instance where this source is used",
json_schema_extra={"label": "Workspace"},
)
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
]},
)
neutralize: bool = Field(
default=False,
description="Whether this data source should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
recordFilter: Optional[Dict[str, str]] = Field(
default=None,
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
json_schema_extra={"label": "Datensatzfilter"},
)
registerModelLabels(
"FeatureDataSource",
{"en": "Feature Data Source", "de": "Feature-Datenquelle", "fr": "Source de données fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"featureCode": {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"},
"tableName": {"en": "Table", "de": "Tabelle", "fr": "Table"},
"objectKey": {"en": "Object Key", "de": "Objekt-Schlüssel", "fr": "Clé objet"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"workspaceInstanceId": {"en": "Workspace", "de": "Workspace", "fr": "Espace de travail"},
},
)

View file

@ -6,85 +6,56 @@ import uuid
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.datamodels.datamodelUtils import TextMultilingual
@i18nModel("Feature")
class Feature(PowerOnModel):
"""
Feature-Definition (global, z.B. 'trustee', 'chatbot').
Features sind die verfügbaren Funktionalitäten der Plattform.
"""
"""Feature-Definition (global, z.B. 'trustee', 'chatbot'). Verfuegbare Funktionalitaeten der Plattform."""
code: str = Field(
description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Code", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
label: TextMultilingual = Field(
description="Feature label in multiple languages (I18n)",
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Bezeichnung", "frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
)
icon: str = Field(
default="",
description="Icon identifier for the feature",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Symbol", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
"Feature",
{"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"},
{
"code": {"en": "Code", "de": "Code", "fr": "Code"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"},
},
)
@i18nModel("Feature-Instanz")
class FeatureInstance(PowerOnModel):
"""
Instanz eines Features in einem Mandanten.
Ein Mandant kann mehrere Instanzen desselben Features haben.
"""
"""Instanz eines Features in einem Mandanten. Ein Mandant kann mehrere Instanzen desselben Features haben."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the feature instance",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureCode: str = Field(
description="FK Feature.code",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
description="FK -> Feature.code",
json_schema_extra={"label": "Feature", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
)
mandateId: str = Field(
description="FK Mandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
description="FK -> Mandate.id (CASCADE DELETE)",
json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
label: str = Field(
default="",
description="Instance label, z.B. 'Buchhaltung 2025'",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
enabled: bool = Field(
default=True,
description="Whether this feature instance is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
config: Optional[Dict[str, Any]] = Field(
default=None,
description="Instance-specific configuration (JSONB). Structure depends on featureCode.",
json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Konfiguration", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
"FeatureInstance",
{"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"featureCode": {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"config": {"en": "Configuration", "de": "Konfiguration", "fr": "Configuration"},
},
)

View file

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

View file

@ -5,66 +5,110 @@
from typing import Dict, Any, List, Optional, Union
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
import base64
@i18nModel("Datei")
class FileItem(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"})
fileName: str = Field(description="Name of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileSize: int = Field(description="Size of the file in bytes", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False})
tags: Optional[List[str]] = Field(default=None, description="Tags for categorization and search", json_schema_extra={"frontend_type": "tags", "frontend_readonly": False, "frontend_required": False})
folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
"""Metadaten einer gespeicherten Datei."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
mandateId: Optional[str] = Field(
default="",
description="ID of the mandate this file belongs to",
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
featureInstanceId: Optional[str] = Field(
default="",
description="ID of the feature instance this file belongs to",
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"},
)
fileName: str = Field(
description="Name of the file",
json_schema_extra={"label": "Dateiname", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
)
mimeType: str = Field(
description="MIME type of the file",
json_schema_extra={"label": "MIME-Typ", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
fileHash: str = Field(
description="Hash of the file",
json_schema_extra={"label": "Datei-Hash", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
fileSize: int = Field(
description="Size of the file in bytes",
json_schema_extra={"label": "Dateigroesse", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False},
)
tags: Optional[List[str]] = Field(
default=None,
description="Tags for categorization and search",
json_schema_extra={"label": "Tags", "frontend_type": "tags", "frontend_readonly": False, "frontend_required": False},
)
folderId: Optional[str] = Field(
default=None,
description="ID of the parent folder",
json_schema_extra={"label": "Ordner-ID", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False},
)
description: Optional[str] = Field(
default=None,
description="User-provided description of the file",
json_schema_extra={"label": "Beschreibung", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False},
)
status: Optional[str] = Field(
default=None,
description="Processing status: pending, extracted, embedding, indexed, failed",
json_schema_extra={"label": "Status", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
]},
)
neutralize: bool = Field(
default=False,
description="Whether this file should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
registerModelLabels(
"FileItem",
{"en": "File Item", "fr": "Élément de fichier"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité"},
"fileName": {"en": "fileName", "fr": "Nom de fichier"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"fileHash": {"en": "File Hash", "fr": "Hash du fichier"},
"fileSize": {"en": "File Size", "fr": "Taille du fichier"},
"tags": {"en": "Tags", "fr": "Tags"},
"folderId": {"en": "Folder ID", "fr": "ID du dossier"},
"description": {"en": "Description", "fr": "Description"},
"status": {"en": "Status", "fr": "Statut"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
},
)
@i18nModel("Datei-Vorschau")
class FilePreview(BaseModel):
content: Union[str, bytes] = Field(description="File content (text or binary)")
mimeType: str = Field(description="MIME type of the file")
fileName: str = Field(description="Original fileName")
isText: bool = Field(description="Whether the content is text (True) or binary (False)")
encoding: Optional[str] = Field(None, description="Text encoding if content is text")
size: int = Field(description="Size of the content in bytes")
"""Vorschau-Inhalt einer Datei fuer die Anzeige."""
content: Union[str, bytes] = Field(
description="File content (text or binary)",
json_schema_extra={"label": "Inhalt"},
)
mimeType: str = Field(
description="MIME type of the file",
json_schema_extra={"label": "MIME-Typ"},
)
fileName: str = Field(
description="Original fileName",
json_schema_extra={"label": "Dateiname"},
)
isText: bool = Field(
description="Whether the content is text (True) or binary (False)",
json_schema_extra={"label": "Ist Text"},
)
encoding: Optional[str] = Field(
None,
description="Text encoding if content is text",
json_schema_extra={"label": "Kodierung"},
)
size: int = Field(
description="Size of the content in bytes",
json_schema_extra={"label": "Groesse"},
)
def toDictWithBase64Encoding(self) -> Dict[str, Any]:
"""Convert to dictionary with base64 encoding for binary content."""
@ -72,29 +116,21 @@ class FilePreview(BaseModel):
if isinstance(data.get("content"), bytes):
data["content"] = base64.b64encode(data["content"]).decode("utf-8")
return data
registerModelLabels(
"FilePreview",
{"en": "File Preview", "fr": "Aperçu du fichier"},
{
"content": {"en": "Content", "fr": "Contenu"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"fileName": {"en": "fileName", "fr": "Nom de fichier"},
"isText": {"en": "Is Text", "fr": "Est du texte"},
"encoding": {"en": "Encoding", "fr": "Encodage"},
"size": {"en": "Size", "fr": "Taille"},
},
)
@i18nModel("Dateidaten")
class FileData(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
data: str = Field(description="File data content")
base64Encoded: bool = Field(description="Whether the data is base64 encoded")
registerModelLabels(
"FileData",
{"en": "File Data", "fr": "Données de fichier"},
{
"id": {"en": "ID", "fr": "ID"},
"data": {"en": "Data", "fr": "Données"},
"base64Encoded": {"en": "Base64 Encoded", "fr": "Encodé en Base64"},
},
)
"""Rohdaten einer Datei (z.B. Base64)."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
data: str = Field(
description="File data content",
json_schema_extra={"label": "Daten"},
)
base64Encoded: bool = Field(
description="Whether the data is base64 encoded",
json_schema_extra={"label": "Base64-kodiert"},
)

View file

@ -10,9 +10,10 @@ import secrets
from typing import Optional, List
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
@i18nModel("Einladung")
class Invitation(PowerOnModel):
"""
Einladungs-Token für neue User.
@ -21,103 +22,76 @@ class Invitation(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
token: str = Field(
default_factory=lambda: secrets.token_urlsafe(32),
description="Secure invitation token",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Token", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Ziel der Einladung
mandateId: str = Field(
description="FK → Mandate.id - Target mandate for the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Optional FK → FeatureInstance.id - Direct access to specific feature",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
roleIds: List[str] = Field(
default_factory=list,
description="List of Role IDs to assign to the invited user",
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Rollen", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True}
)
# Einladungs-Details
targetUsername: Optional[str] = Field(
default=None,
description="Username of the invited user (must match on acceptance)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Ziel-Benutzername", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
)
email: Optional[str] = Field(
default=None,
description="Email address to send invitation link (optional)",
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "E-Mail (optional)", "frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
)
expiresAt: float = Field(
description="When the invitation expires (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Gueltig bis", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
)
# Status
usedBy: Optional[str] = Field(
default=None,
description="User ID of the person who used the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Verwendet von", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
usedAt: Optional[float] = Field(
default=None,
description="When the invitation was used (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Verwendet am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
revokedAt: Optional[float] = Field(
default=None,
description="When the invitation was revoked (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Widerrufen am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
# Email-Status
emailSent: Optional[bool] = Field(
default=False,
description="Whether the invitation email was successfully sent",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "E-Mail gesendet", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
# Einschränkungen
maxUses: int = Field(
default=1,
ge=1,
le=100,
description="Maximum number of times this invitation can be used",
json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Max. Verwendungen", "frontend_type": "number", "frontend_readonly": False, "frontend_required": False}
)
currentUses: int = Field(
default=0,
ge=0,
description="Current number of times this invitation has been used",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Aktuelle Verwendungen", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False}
)
registerModelLabels(
"Invitation",
{"en": "Invitation", "de": "Einladung", "fr": "Invitation"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"token": {"en": "Token", "de": "Token", "fr": "Jeton"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"},
"targetUsername": {"en": "Target Username", "de": "Ziel-Benutzername", "fr": "Nom d'utilisateur cible"},
"email": {"en": "Email (optional)", "de": "E-Mail (optional)", "fr": "Email (optionnel)"},
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
"usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"},
"usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"},
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
"emailSent": {"en": "Email Sent", "de": "E-Mail gesendet", "fr": "Email envoyé"},
"maxUses": {"en": "Max Uses", "de": "Max. Verwendungen", "fr": "Utilisations max"},
"currentUses": {"en": "Current Uses", "de": "Aktuelle Verwendungen", "fr": "Utilisations actuelles"},
},
)

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 pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.shared.timeUtils import getUtcTimestamp
import uuid
@i18nModel("Datei-Inhaltsindex")
class FileContentIndex(PowerOnModel):
"""Structural index of a file's content objects. Created without AI.
Scope is mirrored from FileItem (poweron_management) at indexing time."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key (typically = fileId)")
userId: str = Field(description="Owner user ID")
featureInstanceId: str = Field(default="", description="Feature instance scope")
mandateId: str = Field(default="", description="Mandate scope")
fileName: str = Field(description="Original file name")
mimeType: str = Field(description="MIME type of the file")
containerPath: Optional[str] = Field(default=None, description="Path within a container (e.g. 'archive.zip/folder/report.pdf')")
totalObjects: int = Field(default=0, description="Total number of content objects extracted")
totalSize: int = Field(default=0, description="Total size of all content objects in bytes")
structure: Dict[str, Any] = Field(default_factory=dict, description="Structural overview (pages, sections, hierarchy)")
objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object")
extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp")
status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed")
"""Struktureller Index der Inhaltsobjekte einer Datei."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key (typically = fileId)",
json_schema_extra={"label": "ID"},
)
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer-ID"},
)
featureInstanceId: str = Field(
default="",
description="Feature instance scope",
json_schema_extra={"label": "Feature-Instanz-ID"},
)
mandateId: str = Field(
default="",
description="Mandate scope",
json_schema_extra={"label": "Mandanten-ID"},
)
fileName: str = Field(
description="Original file name",
json_schema_extra={"label": "Dateiname"},
)
mimeType: str = Field(
description="MIME type of the file",
json_schema_extra={"label": "MIME-Typ"},
)
containerPath: Optional[str] = Field(
default=None,
description="Path within a container (e.g. 'archive.zip/folder/report.pdf')",
json_schema_extra={"label": "Container-Pfad"},
)
totalObjects: int = Field(
default=0,
description="Total number of content objects extracted",
json_schema_extra={"label": "Anzahl Objekte"},
)
totalSize: int = Field(
default=0,
description="Total size of all content objects in bytes",
json_schema_extra={"label": "Gesamtgroesse"},
)
structure: Dict[str, Any] = Field(
default_factory=dict,
description="Structural overview (pages, sections, hierarchy)",
json_schema_extra={"label": "Struktur"},
)
objectSummary: List[Dict[str, Any]] = Field(
default_factory=list,
description="Compact summary per content object",
json_schema_extra={"label": "Objekt-Zusammenfassung"},
)
extractedAt: float = Field(
default_factory=getUtcTimestamp,
description="Extraction timestamp",
json_schema_extra={"label": "Extrahiert am"},
)
status: str = Field(
default="pending",
description="Processing status: pending, extracted, embedding, indexed, failed",
json_schema_extra={"label": "Status"},
)
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"label": "Sichtbarkeit"},
)
neutralizationStatus: Optional[str] = Field(
default=None,
description="Neutralization status: completed, failed, skipped, None = not required",
json_schema_extra={"label": "Neutralisierungsstatus"},
)
isNeutralized: bool = Field(
default=False,
description="True if content was neutralized before indexing",
json_schema_extra={"label": "Neutralisiert"},
)
registerModelLabels(
"FileContentIndex",
{"en": "File Content Index", "fr": "Index du contenu de fichier"},
{
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"fileName": {"en": "File Name", "fr": "Nom de fichier"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"containerPath": {"en": "Container Path", "fr": "Chemin du conteneur"},
"totalObjects": {"en": "Total Objects", "fr": "Nombre total d'objets"},
"totalSize": {"en": "Total Size", "fr": "Taille totale"},
"structure": {"en": "Structure", "fr": "Structure"},
"objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"},
"extractedAt": {"en": "Extracted At", "fr": "Extrait le"},
"status": {"en": "Status", "fr": "Statut"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"},
"isNeutralized": {"en": "Is Neutralized", "de": "Neutralisiert"},
},
)
@i18nModel("Inhalts-Chunk")
class ContentChunk(PowerOnModel):
"""Persisted content chunk with embedding vector. Reusable across workflows.
Scalar content object (or chunk thereof) with pgvector embedding."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
contentObjectId: str = Field(description="Reference to the content object within FileContentIndex")
fileId: str = Field(description="FK to the source file")
userId: str = Field(description="Owner user ID")
featureInstanceId: str = Field(default="", description="Feature instance scope")
contentType: str = Field(description="Content type: text, image, videostream, audiostream, other")
data: str = Field(description="Content data (text, base64, URL)")
contextRef: Dict[str, Any] = Field(default_factory=dict, description="Context reference (page, position, label)")
summary: Optional[str] = Field(default=None, description="AI-generated summary (on demand)")
chunkMetadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
"""Persistierter Inhalts-Chunk mit Embedding-Vektor."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
contentObjectId: str = Field(
description="Reference to the content object within FileContentIndex",
json_schema_extra={"label": "Inhaltsobjekt-ID"},
)
fileId: str = Field(
description="FK to the source file",
json_schema_extra={"label": "Datei-ID"},
)
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer-ID"},
)
featureInstanceId: str = Field(
default="",
description="Feature instance scope",
json_schema_extra={"label": "Feature-Instanz-ID"},
)
contentType: str = Field(
description="Content type: text, image, videostream, audiostream, other",
json_schema_extra={"label": "Inhaltstyp"},
)
data: str = Field(
description="Content data (text, base64, URL)",
json_schema_extra={"label": "Daten"},
)
contextRef: Dict[str, Any] = Field(
default_factory=dict,
description="Context reference (page, position, label)",
json_schema_extra={"label": "Kontext-Referenz"},
)
summary: Optional[str] = Field(
default=None,
description="AI-generated summary (on demand)",
json_schema_extra={"label": "Zusammenfassung"},
)
chunkMetadata: Dict[str, Any] = Field(
default_factory=dict,
description="Additional metadata",
json_schema_extra={"label": "Metadaten"},
)
embedding: Optional[List[float]] = Field(
default=None, description="pgvector embedding (NOT NULL for text chunks)",
json_schema_extra={"db_type": "vector(1536)"}
default=None,
description="pgvector embedding (NOT NULL for text chunks)",
json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
)
registerModelLabels(
"ContentChunk",
{"en": "Content Chunk", "fr": "Fragment de contenu"},
{
"id": {"en": "ID", "fr": "ID"},
"contentObjectId": {"en": "Content Object ID", "fr": "ID de l'objet de contenu"},
"fileId": {"en": "File ID", "fr": "ID du fichier"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
"contentType": {"en": "Content Type", "fr": "Type de contenu"},
"data": {"en": "Data", "fr": "Données"},
"contextRef": {"en": "Context Reference", "fr": "Référence contextuelle"},
"summary": {"en": "Summary", "fr": "Résumé"},
"chunkMetadata": {"en": "Metadata", "fr": "Métadonnées"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
},
)
@i18nModel("Runden-Speicher")
class RoundMemory(PowerOnModel):
"""Persistent per-round memory for agent tool results, file refs, and decisions.
Stored after each agent round so that RAG can retrieve relevant context
even after the ConversationManager summarises older messages away.
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
workflowId: str = Field(description="FK to the workflow")
roundNumber: int = Field(default=0, description="Agent round that produced this memory")
memoryType: str = Field(
description="Category: file_ref, tool_result, decision, data_source_ref"
"""Persistenter Speicher pro Agenten-Runde."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
workflowId: str = Field(
description="FK to the workflow",
json_schema_extra={"label": "Workflow-ID"},
)
roundNumber: int = Field(
default=0,
description="Agent round that produced this memory",
json_schema_extra={"label": "Rundennummer"},
)
memoryType: str = Field(
description="Category: file_ref, tool_result, decision, data_source_ref",
json_schema_extra={"label": "Speichertyp"},
)
key: str = Field(
description="Dedup key, e.g. 'readFile:<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(
default=None,
description="Full tool output when small enough (max ~8000 chars)",
json_schema_extra={"label": "Volldaten"},
)
fileIds: List[str] = Field(
default_factory=list,
description="Referenced file IDs",
json_schema_extra={"label": "Datei-IDs"},
)
fileIds: List[str] = Field(default_factory=list, description="Referenced file IDs")
embedding: Optional[List[float]] = Field(
default=None,
description="Embedding of summary for semantic retrieval",
json_schema_extra={"db_type": "vector(1536)"},
json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
)
registerModelLabels(
"RoundMemory",
{"en": "Round Memory", "fr": "Mémoire de tour"},
{
"id": {"en": "ID", "fr": "ID"},
"workflowId": {"en": "Workflow ID", "fr": "ID du workflow"},
"roundNumber": {"en": "Round Number", "fr": "Numéro de tour"},
"memoryType": {"en": "Memory Type", "fr": "Type de mémoire"},
"key": {"en": "Key", "fr": "Clé"},
"summary": {"en": "Summary", "fr": "Résumé"},
"fullData": {"en": "Full Data", "fr": "Données complètes"},
"fileIds": {"en": "File IDs", "fr": "IDs de fichier"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
},
)
@i18nModel("Workflow-Speicher")
class WorkflowMemory(PowerOnModel):
"""Workflow-scoped key-value cache for entities and facts.
Extracted during agent rounds, persisted for cross-round and cross-workflow reuse."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
workflowId: str = Field(description="FK to the workflow")
userId: str = Field(description="Owner user ID")
featureInstanceId: str = Field(default="", description="Feature instance scope")
key: str = Field(description="Key identifier (e.g. 'entity:companyName')")
value: str = Field(description="Extracted value")
source: str = Field(default="extraction", description="Origin: extraction, tool, conversation, summary")
embedding: Optional[List[float]] = Field(
default=None, description="Optional embedding for semantic lookup",
json_schema_extra={"db_type": "vector(1536)"}
"""Workflow-spezifischer Key-Value-Cache fuer Entitaeten und Fakten."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
workflowId: str = Field(
description="FK to the workflow",
json_schema_extra={"label": "Workflow-ID"},
)
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer-ID"},
)
featureInstanceId: str = Field(
default="",
description="Feature instance scope",
json_schema_extra={"label": "Feature-Instanz-ID"},
)
key: str = Field(
description="Key identifier (e.g. 'entity:companyName')",
json_schema_extra={"label": "Schluessel"},
)
value: str = Field(
description="Extracted value",
json_schema_extra={"label": "Wert"},
)
source: str = Field(
default="extraction",
description="Origin: extraction, tool, conversation, summary",
json_schema_extra={"label": "Quelle"},
)
embedding: Optional[List[float]] = Field(
default=None,
description="Optional embedding for semantic lookup",
json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
)
registerModelLabels(
"WorkflowMemory",
{"en": "Workflow Memory", "fr": "Mémoire de workflow"},
{
"id": {"en": "ID", "fr": "ID"},
"workflowId": {"en": "Workflow ID", "fr": "ID du workflow"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
"key": {"en": "Key", "fr": "Clé"},
"value": {"en": "Value", "fr": "Valeur"},
"source": {"en": "Source", "fr": "Source"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
},
)

View file

@ -10,9 +10,10 @@ Rollen werden über Junction Tables verknüpft für saubere CASCADE DELETE.
import uuid
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
@i18nModel("Benutzer-Mandant")
class UserMandate(PowerOnModel):
"""
User-Mitgliedschaft in einem Mandanten.
@ -21,36 +22,24 @@ class UserMandate(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user-mandate membership",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
userId: str = Field(
description="FK → User.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
json_schema_extra={"label": "Benutzer", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
)
mandateId: str = Field(
description="FK → Mandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"}
json_schema_extra={"label": "Mandant", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"}
)
enabled: bool = Field(
default=True,
description="Whether this membership is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
# Rollen werden via Junction Table UserMandateRole verknüpft
registerModelLabels(
"UserMandate",
{"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
},
)
@i18nModel("Feature-Zugang")
class FeatureAccess(PowerOnModel):
"""
User-Zugriff auf eine Feature-Instanz.
@ -59,36 +48,24 @@ class FeatureAccess(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the feature access",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
userId: str = Field(
description="FK → User.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
json_schema_extra={"label": "Benutzer", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
)
featureInstanceId: str = Field(
description="FK → FeatureInstance.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
)
enabled: bool = Field(
default=True,
description="Whether this feature access is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
# Rollen werden via Junction Table FeatureAccessRole verknüpft
registerModelLabels(
"FeatureAccess",
{"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
},
)
@i18nModel("Benutzer-Mandant-Rolle")
class UserMandateRole(PowerOnModel):
"""
Junction Table: UserMandate zu Role.
@ -97,29 +74,19 @@ class UserMandateRole(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
userMandateId: str = Field(
description="FK → UserMandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/user-mandates/", "frontend_fk_display_field": "userId"}
json_schema_extra={"label": "Benutzer-Mandant", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/user-mandates/", "frontend_fk_display_field": "userId"}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
registerModelLabels(
"UserMandateRole",
{"en": "User Mandate Role", "de": "Benutzer-Mandant-Rolle", "fr": "Rôle mandat utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userMandateId": {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"},
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
},
)
@i18nModel("Feature-Zugang-Rolle")
class FeatureAccessRole(PowerOnModel):
"""
Junction Table: FeatureAccess zu Role.
@ -128,24 +95,13 @@ class FeatureAccessRole(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
featureAccessId: str = Field(
description="FK → FeatureAccess.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-access/", "frontend_fk_display_field": "userId"}
json_schema_extra={"label": "Feature-Zugang", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-access/", "frontend_fk_display_field": "userId"}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
registerModelLabels(
"FeatureAccessRole",
{"en": "Feature Access Role", "de": "Feature-Zugang-Rolle", "fr": "Rôle accès fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"featureAccessId": {"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"},
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
},
)

View file

@ -7,7 +7,7 @@ from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
class MessagingChannel(str, Enum):
@ -26,86 +26,137 @@ class DeliveryStatus(str, Enum):
FAILED = "failed"
@i18nModel("Messaging-Abonnement")
class MessagingSubscription(PowerOnModel):
"""Data model for messaging subscriptions"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the subscription",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "ID",
},
)
subscriptionId: str = Field(
description="Unique subscription identifier (e.g., 'system_errors', 'audit_login')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True,
"label": "Abonnement-ID",
},
)
subscriptionLabel: str = Field(
description="Display name of the subscription",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True,
"label": "Bezeichnung",
},
)
mandateId: str = Field(
description="ID of the mandate this subscription belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Mandanten-ID",
},
)
featureInstanceId: str = Field(
description="ID of the feature instance this subscription belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Feature-Instanz-ID",
},
)
description: Optional[str] = Field(
default=None,
description="Description of the subscription",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={
"frontend_type": "textarea",
"frontend_readonly": False,
"frontend_required": False,
"label": "Beschreibung",
},
)
isSystemSubscription: bool = Field(
default=False,
description="Whether this is a system subscription (only admin can create)",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": True,
"frontend_required": False,
"label": "System-Abonnement",
},
)
enabled: bool = Field(
default=True,
description="Whether the subscription is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": False,
"frontend_required": False,
"label": "Aktiviert",
},
)
model_config = ConfigDict(use_enum_values=True)
registerModelLabels(
"MessagingSubscription",
{"en": "Messaging Subscription", "fr": "Abonnement de messagerie"},
{
"id": {"en": "ID", "fr": "ID"},
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"subscriptionLabel": {"en": "Subscription Label", "fr": "Label d'abonnement"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"description": {"en": "Description", "fr": "Description"},
"isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"},
"enabled": {"en": "Enabled", "fr": "Activé"},
},
)
@i18nModel("Messaging-Registrierung")
class MessagingSubscriptionRegistration(BaseModel):
"""Data model for user registrations to messaging subscriptions"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the registration",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "ID",
},
)
mandateId: str = Field(
description="ID of the mandate this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Mandanten-ID",
},
)
featureInstanceId: str = Field(
description="ID of the feature instance this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Feature-Instanz-ID",
},
)
subscriptionId: str = Field(
description="ID of the subscription this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True,
"label": "Abonnement-ID",
},
)
userId: str = Field(
description="ID of the user registered to this subscription",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Benutzer-ID",
},
)
channel: MessagingChannel = Field(
description="Channel type for this registration",
@ -117,62 +168,83 @@ class MessagingSubscriptionRegistration(BaseModel):
{"value": "email", "label": {"en": "Email", "fr": "Email"}},
{"value": "sms", "label": {"en": "SMS", "fr": "SMS"}},
{"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}},
{"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}}
]
}
{"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}},
],
"label": "Kanal",
},
)
channelConfig: str = Field(
default="",
description="Channel-specific configuration (e.g., email address, phone number, Teams user ID)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False,
"label": "Kanal-Konfiguration",
},
)
enabled: bool = Field(
default=True,
description="Whether this registration is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": False,
"frontend_required": False,
"label": "Aktiviert",
},
)
model_config = ConfigDict(use_enum_values=True)
registerModelLabels(
"MessagingSubscriptionRegistration",
{"en": "Messaging Registration", "fr": "Inscription à la messagerie"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"channel": {"en": "Channel", "fr": "Canal"},
"channelConfig": {"en": "Channel Config", "fr": "Configuration du canal"},
"enabled": {"en": "Enabled", "fr": "Activé"},
},
)
@i18nModel("Messaging-Zustellung")
class MessagingDelivery(BaseModel):
"""Data model for individual message deliveries"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the delivery",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "ID",
},
)
mandateId: str = Field(
description="ID of the mandate this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Mandanten-ID",
},
)
featureInstanceId: str = Field(
description="ID of the feature instance this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Feature-Instanz-ID",
},
)
subscriptionId: str = Field(
description="ID of the subscription this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Abonnement-ID",
},
)
userId: str = Field(
description="ID of the user receiving this delivery",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Benutzer-ID",
},
)
channel: MessagingChannel = Field(
description="Channel used for this delivery",
@ -184,9 +256,10 @@ class MessagingDelivery(BaseModel):
{"value": "email", "label": {"en": "Email", "fr": "Email"}},
{"value": "sms", "label": {"en": "SMS", "fr": "SMS"}},
{"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}},
{"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}}
]
}
{"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}},
],
"label": "Kanal",
},
)
status: DeliveryStatus = Field(
default=DeliveryStatus.PENDING,
@ -198,112 +271,113 @@ class MessagingDelivery(BaseModel):
"frontend_options": [
{"value": "pending", "label": {"en": "Pending", "fr": "En attente"}},
{"value": "sent", "label": {"en": "Sent", "fr": "Envoyé"}},
{"value": "failed", "label": {"en": "Failed", "fr": "Échoué"}}
]
}
{"value": "failed", "label": {"en": "Failed", "fr": "Échoué"}},
],
"label": "Status",
},
)
errorMessage: Optional[str] = Field(
default=None,
description="Error message if delivery failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "textarea",
"frontend_readonly": True,
"frontend_required": False,
"label": "Fehlermeldung",
},
)
sentAt: Optional[float] = Field(
default=None,
description="When the delivery was sent (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "datetime",
"frontend_readonly": True,
"frontend_required": False,
"label": "Gesendet am",
},
)
model_config = ConfigDict(use_enum_values=True)
registerModelLabels(
"MessagingDelivery",
{"en": "Messaging Delivery", "fr": "Livraison de messagerie"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"channel": {"en": "Channel", "fr": "Canal"},
"status": {"en": "Status", "fr": "Statut"},
"errorMessage": {"en": "Error Message", "fr": "Message d'erreur"},
"sentAt": {"en": "Sent At", "fr": "Envoyé le"},
},
)
@i18nModel("Messaging-Ereignisparameter")
class MessagingEventParameters(BaseModel):
"""Data model for event parameters passed to subscription functions"""
triggerData: dict = Field(
default_factory=dict,
description="Event data from trigger as dictionary/JSON",
json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={
"frontend_type": "json",
"frontend_readonly": False,
"frontend_required": False,
"label": "Trigger-Daten",
},
)
registerModelLabels(
"MessagingEventParameters",
{"en": "Messaging Event Parameters", "fr": "Paramètres d'événement de messagerie"},
{
"triggerData": {"en": "Trigger Data", "fr": "Données de déclenchement"},
},
)
registerModelLabels(
"MessagingSendResult",
{"en": "Messaging Send Result", "fr": "Résultat d'envoi de messagerie"},
{
"success": {"en": "Success", "fr": "Succès"},
"deliveryId": {"en": "Delivery ID", "fr": "ID de livraison"},
"errorMessage": {"en": "Error Message", "fr": "Message d'erreur"},
},
)
registerModelLabels(
"MessagingSubscriptionExecutionResult",
{"en": "Messaging Subscription Execution Result", "fr": "Résultat d'exécution d'abonnement"},
{
"success": {"en": "Success", "fr": "Succès"},
"messagesSent": {"en": "Messages Sent", "fr": "Messages envoyés"},
"errorMessage": {"en": "Error Message", "fr": "Message d'erreur"},
},
)
@i18nModel("Messaging-Sendeergebnis")
class MessagingSendResult(BaseModel):
"""Data model for sendMessage result"""
success: bool = Field(
description="Whether the message was sent successfully",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": True,
"frontend_required": True,
"label": "Erfolg",
},
)
deliveryId: Optional[str] = Field(
default=None,
description="ID of the created MessagingDelivery record",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Zustellungs-ID",
},
)
errorMessage: Optional[str] = Field(
default=None,
description="Error message if sending failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "textarea",
"frontend_readonly": True,
"frontend_required": False,
"label": "Fehlermeldung",
},
)
@i18nModel("Messaging-Abonnement-Ausführung")
class MessagingSubscriptionExecutionResult(BaseModel):
"""Data model for subscription function execution result"""
success: bool = Field(
description="Whether the subscription execution was successful",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": True,
"frontend_required": True,
"label": "Erfolg",
},
)
messagesSent: int = Field(
default=0,
description="Number of messages sent",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "number",
"frontend_readonly": True,
"frontend_required": False,
"label": "Gesendete Nachrichten",
},
)
errorMessage: Optional[str] = Field(
default=None,
description="Error message if execution failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "textarea",
"frontend_readonly": True,
"frontend_required": False,
"label": "Fehlermeldung",
},
)

View file

@ -10,7 +10,7 @@ from typing import Optional, List
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
class NotificationType(str, Enum):
@ -29,20 +29,25 @@ class NotificationStatus(str, Enum):
DISMISSED = "dismissed" # Verworfen/Geschlossen
@i18nModel("Benachrichtigungs-Aktion")
class NotificationAction(BaseModel):
"""Possible action for a notification"""
actionId: str = Field(
description="Unique identifier for the action (e.g., 'accept', 'decline')"
description="Unique identifier for the action (e.g., 'accept', 'decline')",
json_schema_extra={"label": "Aktions-ID"},
)
label: str = Field(
description="Display label for the action button"
description="Display label for the action button",
json_schema_extra={"label": "Bezeichnung"},
)
style: str = Field(
default="default",
description="Button style: 'primary', 'danger', 'default'"
description="Button style: 'primary', 'danger', 'default'",
json_schema_extra={"label": "Stil"},
)
@i18nModel("Benachrichtigung")
class UserNotification(PowerOnModel):
"""
In-app notification for a user.
@ -51,18 +56,18 @@ class UserNotification(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the notification",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
userId: str = Field(
description="Target user ID for this notification",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Benutzer", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
# Notification type and status
type: NotificationType = Field(
default=NotificationType.SYSTEM,
description="Type of notification",
json_schema_extra={
"label": "Typ",
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": True,
@ -78,6 +83,7 @@ class UserNotification(PowerOnModel):
default=NotificationStatus.UNREAD,
description="Current status of the notification",
json_schema_extra={
"label": "Status",
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": False,
@ -89,115 +95,63 @@ class UserNotification(PowerOnModel):
]
}
)
# Content
title: str = Field(
description="Notification title",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Titel", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
message: str = Field(
description="Notification message/body",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Nachricht", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": True}
)
icon: Optional[str] = Field(
default=None,
description="Optional icon identifier (e.g., 'mail', 'warning', 'info')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Symbol", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Reference to triggering object (for actionable notifications)
referenceType: Optional[str] = Field(
default=None,
description="Type of referenced object (e.g., 'Invitation', 'Workflow')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Referenz-Typ", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
referenceId: Optional[str] = Field(
default=None,
description="ID of referenced object",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Referenz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Actions (for actionable notifications like invitations)
actions: Optional[List[NotificationAction]] = Field(
default=None,
description="List of possible actions for this notification",
json_schema_extra={"frontend_type": "json", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Aktionen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False}
)
# Action result (when user takes action)
actionTaken: Optional[str] = Field(
default=None,
description="Which action was taken (actionId)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Durchgefuehrte Aktion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
actionResult: Optional[str] = Field(
default=None,
description="Result message from the action",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Aktions-Ergebnis", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
)
# Timestamps
readAt: Optional[float] = Field(
default=None,
description="When the notification was read (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Gelesen am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
actionedAt: Optional[float] = Field(
default=None,
description="When action was taken (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Bearbeitet am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
expiresAt: Optional[float] = Field(
default=None,
description="When the notification expires (optional, UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Gueltig bis", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
model_config = ConfigDict(use_enum_values=True)
registerModelLabels(
"UserNotification",
{"en": "Notification", "de": "Benachrichtigung", "fr": "Notification"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"type": {"en": "Type", "de": "Typ", "fr": "Type"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"title": {"en": "Title", "de": "Titel", "fr": "Titre"},
"message": {"en": "Message", "de": "Nachricht", "fr": "Message"},
"icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"},
"referenceType": {"en": "Reference Type", "de": "Referenz-Typ", "fr": "Type de référence"},
"referenceId": {"en": "Reference ID", "de": "Referenz-ID", "fr": "ID de référence"},
"actions": {"en": "Actions", "de": "Aktionen", "fr": "Actions"},
"actionTaken": {"en": "Action Taken", "de": "Durchgeführte Aktion", "fr": "Action effectuée"},
"actionResult": {"en": "Action Result", "de": "Aktions-Ergebnis", "fr": "Résultat de l'action"},
"readAt": {"en": "Read At", "de": "Gelesen am", "fr": "Lu le"},
"actionedAt": {"en": "Actioned At", "de": "Bearbeitet am", "fr": "Traité le"},
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
},
)
registerModelLabels(
"NotificationType",
{"en": "Notification Type", "de": "Benachrichtigungs-Typ", "fr": "Type de notification"},
{
"invitation": {"en": "Invitation", "de": "Einladung", "fr": "Invitation"},
"system": {"en": "System", "de": "System", "fr": "Système"},
"workflow": {"en": "Workflow", "de": "Workflow", "fr": "Workflow"},
"mention": {"en": "Mention", "de": "Erwähnung", "fr": "Mention"},
},
)
registerModelLabels(
"NotificationStatus",
{"en": "Notification Status", "de": "Benachrichtigungs-Status", "fr": "Statut de notification"},
{
"unread": {"en": "Unread", "de": "Ungelesen", "fr": "Non lu"},
"read": {"en": "Read", "de": "Gelesen", "fr": "Lu"},
"actioned": {"en": "Actioned", "de": "Bearbeitet", "fr": "Traité"},
"dismissed": {"en": "Dismissed", "de": "Verworfen", "fr": "Rejeté"},
},
)

View file

@ -14,7 +14,7 @@ from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.datamodels.datamodelUtils import TextMultilingual
from modules.datamodels.datamodelUam import AccessLevel
@ -26,6 +26,7 @@ class AccessRuleContext(str, Enum):
RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.)
@i18nModel("Rolle")
class Role(PowerOnModel):
"""
Data model for RBAC roles.
@ -41,56 +42,42 @@ class Role(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the role",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
roleLabel: str = Field(
description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Rollen-Label", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
description: TextMultilingual = Field(
description="Role description in multiple languages",
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Beschreibung", "frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
)
# KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!)
mandateId: Optional[str] = Field(
default=None,
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"}
json_schema_extra={"label": "Mandant", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
)
featureCode: Optional[str] = Field(
default=None,
description="Feature code (z.B. 'trustee') - für Template-Rollen",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "Feature-Code", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
isSystemRole: bool = Field(
default=False,
description="Whether this is a system role that cannot be deleted",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "System-Rolle", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
registerModelLabels(
"Role",
{"en": "Role", "de": "Rolle", "fr": "Rôle"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleLabel": {"en": "Role Label", "de": "Rollen-Label", "fr": "Label du rôle"},
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"featureCode": {"en": "Feature Code", "de": "Feature-Code", "fr": "Code fonctionnalité"},
"isSystemRole": {"en": "System Role", "de": "System-Rolle", "fr": "Rôle système"},
},
)
@i18nModel("Zugriffsregel")
class AccessRule(PowerOnModel):
"""
Data model for access control rules.
@ -101,15 +88,15 @@ class AccessRule(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the access rule",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE!)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
context: AccessRuleContext = Field(
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
json_schema_extra={"label": "Kontext", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
{"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
{"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}},
{"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}}
@ -118,17 +105,17 @@ class AccessRule(PowerOnModel):
item: Optional[str] = Field(
default=None,
description="Item identifier (null = all items in context). Format: DATA: '<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(
default=False,
description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Anzeigen", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True}
)
read: Optional[AccessLevel] = Field(
default=None,
description="Read permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
json_schema_extra={"label": "Lesen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
@ -138,7 +125,7 @@ class AccessRule(PowerOnModel):
create: Optional[AccessLevel] = Field(
default=None,
description="Create permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
json_schema_extra={"label": "Erstellen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
@ -148,7 +135,7 @@ class AccessRule(PowerOnModel):
update: Optional[AccessLevel] = Field(
default=None,
description="Update permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
json_schema_extra={"label": "Aktualisieren", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
@ -158,7 +145,7 @@ class AccessRule(PowerOnModel):
delete: Optional[AccessLevel] = Field(
default=None,
description="Delete permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
json_schema_extra={"label": "Loeschen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
@ -167,23 +154,6 @@ class AccessRule(PowerOnModel):
)
registerModelLabels(
"AccessRule",
{"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
"context": {"en": "Context", "de": "Kontext", "fr": "Contexte"},
"item": {"en": "Item", "de": "Element", "fr": "Élément"},
"view": {"en": "View", "de": "Anzeigen", "fr": "Vue"},
"read": {"en": "Read", "de": "Lesen", "fr": "Lecture"},
"create": {"en": "Create", "de": "Erstellen", "fr": "Créer"},
"update": {"en": "Update", "de": "Aktualisieren", "fr": "Mettre à jour"},
"delete": {"en": "Delete", "de": "Löschen", "fr": "Supprimer"},
},
)
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level
IMMUTABLE_FIELDS = {
"Role": ["mandateId", "featureInstanceId", "featureCode"],

View file

@ -12,7 +12,7 @@ Multi-Tenant Design:
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict, model_validator
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.shared.timeUtils import getUtcTimestamp
from .datamodelUam import AuthAuthority
from enum import Enum
@ -31,46 +31,79 @@ class TokenPurpose(str, Enum):
DATA_CONNECTION = "dataConnection"
@i18nModel("Token")
class Token(PowerOnModel):
"""
Authentication Token model.
Multi-Tenant Design:
- Token ist User-gebunden, NICHT Mandant-gebunden
- Ermöglicht parallele Arbeit in mehreren Mandanten
- Mandant-Kontext wird per Request-Header bestimmt
"""
id: Optional[str] = None
userId: str
authority: AuthAuthority
id: Optional[str] = Field(
default=None,
json_schema_extra={"label": "ID"},
)
userId: str = Field(
...,
json_schema_extra={"label": "Benutzer-ID"},
)
authority: AuthAuthority = Field(
...,
json_schema_extra={"label": "Autoritaet"},
)
connectionId: Optional[str] = Field(
None, description="ID of the connection this token belongs to"
None,
description="ID of the connection this token belongs to",
json_schema_extra={"label": "Verbindungs-ID"},
)
tokenPurpose: Optional[TokenPurpose] = Field(
default=None,
description="authSession = gateway login JWT; dataConnection = provider OAuth for a connection",
json_schema_extra={"label": "Token-Verwendung"},
)
tokenAccess: str = Field(
...,
json_schema_extra={"label": "Zugriffstoken"},
)
tokenType: str = Field(
default="bearer",
json_schema_extra={"label": "Token-Typ"},
)
tokenAccess: str
tokenType: str = "bearer"
expiresAt: float = Field(
description="When the token expires (UTC timestamp in seconds)"
description="When the token expires (UTC timestamp in seconds)",
json_schema_extra={"label": "Laeuft ab am"},
)
tokenRefresh: Optional[str] = Field(
default=None,
json_schema_extra={"label": "Refresh-Token"},
)
tokenRefresh: Optional[str] = None
status: TokenStatus = Field(
default=TokenStatus.ACTIVE, description="Token status: active/revoked"
default=TokenStatus.ACTIVE,
description="Token status: active/revoked",
json_schema_extra={"label": "Status"},
)
revokedAt: Optional[float] = Field(
None, description="When the token was revoked (UTC timestamp in seconds)"
None,
description="When the token was revoked (UTC timestamp in seconds)",
json_schema_extra={"label": "Widerrufen am"},
)
revokedBy: Optional[str] = Field(
None, description="User ID who revoked the token (admin/self)"
None,
description="User ID who revoked the token (admin/self)",
json_schema_extra={"label": "Widerrufen von"},
)
reason: Optional[str] = Field(
None,
description="Optional revocation reason",
json_schema_extra={"label": "Grund"},
)
reason: Optional[str] = Field(None, description="Optional revocation reason")
sessionId: Optional[str] = Field(
None, description="Logical session grouping for logout revocation"
None,
description="Logical session grouping for logout revocation",
json_schema_extra={"label": "Sitzungs-ID"},
)
# ENTFERNT: mandateId - Token ist nicht mehr Mandant-spezifisch
# Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
model_config = ConfigDict(use_enum_values=True)
@ -91,51 +124,44 @@ class Token(PowerOnModel):
return data
registerModelLabels(
"Token",
{"en": "Token", "de": "Token", "fr": "Jeton"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
"tokenPurpose": {"en": "Token purpose", "de": "Token-Verwendung", "fr": "Usage du jeton"},
"tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
"revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"},
"reason": {"en": "Reason", "de": "Grund", "fr": "Raison"},
"sessionId": {"en": "Session ID", "de": "Sitzungs-ID", "fr": "ID de session"},
},
)
@i18nModel("Authentifizierungsereignis")
class AuthEvent(PowerOnModel):
"""Authentication event for audit logging."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
eventType: str = Field(description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
timestamp: float = Field(default_factory=getUtcTimestamp, description="Unix timestamp when the event occurred", json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True})
ipAddress: Optional[str] = Field(default=None, description="IP address from which the event originated", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userAgent: Optional[str] = Field(default=None, description="User agent string from the request", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
success: bool = Field(default=True, description="Whether the authentication event was successful", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": True})
details: Optional[str] = Field(default=None, description="Additional details about the event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
registerModelLabels(
"AuthEvent",
{"en": "Authentication Event", "de": "Authentifizierungsereignis", "fr": "Événement d'authentification"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"eventType": {"en": "Event Type", "de": "Ereignistyp", "fr": "Type d'événement"},
"timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"},
"ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"},
"userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"},
"success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"},
"details": {"en": "Details", "de": "Details", "fr": "Détails"},
},
)
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the auth event",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
userId: str = Field(
description="ID of the user this event belongs to",
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
eventType: str = Field(
description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')",
json_schema_extra={"label": "Ereignistyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
timestamp: float = Field(
default_factory=getUtcTimestamp,
description="Unix timestamp when the event occurred",
json_schema_extra={"label": "Zeitstempel", "frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True},
)
ipAddress: Optional[str] = Field(
default=None,
description="IP address from which the event originated",
json_schema_extra={"label": "IP-Adresse", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
userAgent: Optional[str] = Field(
default=None,
description="User agent string from the request",
json_schema_extra={"label": "User-Agent", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
success: bool = Field(
default=True,
description="Whether the authentication event was successful",
json_schema_extra={"label": "Erfolgreich", "frontend_type": "boolean", "frontend_readonly": True, "frontend_required": True},
)
details: Optional[str] = Field(
default=None,
description="Additional details about the event",
json_schema_extra={"label": "Details", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)

View file

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

View file

@ -14,7 +14,7 @@ from typing import Optional, List, Dict, Any
from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.shared.timeUtils import getUtcTimestamp
@ -61,6 +61,7 @@ class UserPermissions(BaseModel):
)
@i18nModel("Mandant")
class Mandate(PowerOnModel):
"""
Mandate (Mandant/Tenant) model.
@ -69,31 +70,31 @@ class Mandate(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"},
)
name: str = Field(
description="Name of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True, "label": "Name"},
)
label: Optional[str] = Field(
default=None,
description="Display label of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Label"},
)
enabled: bool = Field(
default=True,
description="Indicates whether the mandate is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"},
)
isSystem: bool = Field(
default=False,
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False, "label": "System-Mandant"},
)
deletedAt: Optional[float] = Field(
default=None,
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gelöscht am"},
)
@field_validator('isSystem', mode='before')
@ -104,38 +105,91 @@ class Mandate(PowerOnModel):
return False
return v
registerModelLabels(
"Mandate",
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"name": {"en": "Name", "de": "Name", "fr": "Nom"},
"label": {"en": "Label", "de": "Label", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
"deletedAt": {"en": "Deleted at", "de": "Gelöscht am", "fr": "Supprimé le"},
},
)
@i18nModel("Benutzerverbindung")
class UserConnection(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"})
externalId: str = Field(description="User ID in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
externalUsername: str = Field(description="Username in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
externalEmail: Optional[EmailStr] = Field(None, description="Email in the external system", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False})
status: ConnectionStatus = Field(default=ConnectionStatus.ACTIVE, description="Connection status", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": "/api/connections/statuses/options"})
connectedAt: float = Field(default_factory=getUtcTimestamp, description="When the connection was established (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
lastChecked: float = Field(default_factory=getUtcTimestamp, description="When the connection was last verified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
expiresAt: Optional[float] = Field(None, description="When the connection expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
tokenStatus: Optional[str] = Field(None, description="Current token status: active, expired, none", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": [
{"value": "active", "label": {"en": "Active", "fr": "Actif"}},
{"value": "expired", "label": {"en": "Expired", "fr": "Expiré"}},
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
]})
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
grantedScopes: Optional[List[str]] = Field(None, description="OAuth scopes granted for this connection", json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False})
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the connection",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
userId: str = Field(
description="ID of the user this connection belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Benutzer-ID"},
)
authority: AuthAuthority = Field(
description="Authentication authority",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": "/api/connections/authorities/options",
"label": "Autorität",
},
)
externalId: str = Field(
description="User ID in the external system",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Externe ID"},
)
externalUsername: str = Field(
description="Username in the external system",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Externer Benutzername"},
)
externalEmail: Optional[EmailStr] = Field(
None,
description="Email in the external system",
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False, "label": "Externe E-Mail"},
)
status: ConnectionStatus = Field(
default=ConnectionStatus.ACTIVE,
description="Connection status",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": False,
"frontend_options": "/api/connections/statuses/options",
"label": "Status",
},
)
connectedAt: float = Field(
default_factory=getUtcTimestamp,
description="When the connection was established (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Verbunden am"},
)
lastChecked: float = Field(
default_factory=getUtcTimestamp,
description="When the connection was last verified (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Zuletzt geprüft"},
)
expiresAt: Optional[float] = Field(
None,
description="When the connection expires (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Läuft ab am"},
)
tokenStatus: Optional[str] = Field(
None,
description="Current token status: active, expired, none",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
{"value": "active", "label": {"en": "Active", "fr": "Actif"}},
{"value": "expired", "label": {"en": "Expired", "fr": "Expiré"}},
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
],
"label": "Verbindungsstatus",
},
)
tokenExpiresAt: Optional[float] = Field(
None,
description="When the current token expires (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Token läuft ab am"},
)
grantedScopes: Optional[List[str]] = Field(
None,
description="OAuth scopes granted for this connection",
json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False, "label": "Gewährte Berechtigungen"},
)
@computed_field
@computed_field
@ -157,29 +211,7 @@ class UserConnection(PowerOnModel):
return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}"
registerModelLabels(
"UserConnection",
{"en": "User Connection", "de": "Benutzerverbindung", "fr": "Connexion utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"},
"externalUsername": {"en": "External Username", "de": "Externer Benutzername", "fr": "Nom d'utilisateur externe"},
"externalEmail": {"en": "External Email", "de": "Externe E-Mail", "fr": "Email externe"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"connectedAt": {"en": "Connected At", "de": "Verbunden am", "fr": "Connecté le"},
"lastChecked": {"en": "Last Checked", "de": "Zuletzt geprüft", "fr": "Dernière vérification"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"grantedScopes": {"en": "Granted Scopes", "de": "Gewährte Berechtigungen", "fr": "Autorisations accordées"},
"connectionReference": {"en": "Connection Reference", "de": "Verbindungsreferenz", "fr": "Référence de connexion"},
"displayLabel": {"en": "Display Label", "de": "Anzeigebezeichnung", "fr": "Libellé d'affichage"},
},
)
@i18nModel("Benutzer")
class User(PowerOnModel):
"""
User model.
@ -193,31 +225,37 @@ class User(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"},
)
username: str = Field(
description="Username for login (immutable after creation)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Benutzername"},
)
email: Optional[EmailStr] = Field(
default=None,
description="Email address of the user",
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True, "label": "E-Mail"},
)
fullName: Optional[str] = Field(
default=None,
description="Full name of the user",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Vollständiger Name"},
)
language: str = Field(
default="de",
description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}},
]}
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}},
],
"label": "Sprache",
},
)
@field_validator('language', mode='before')
@ -245,13 +283,13 @@ class User(PowerOnModel):
enabled: bool = Field(
default=True,
description="Indicates whether the user is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"},
)
isSysAdmin: bool = Field(
default=False,
description="Global SysAdmin flag. SysAdmin = System-Zugriff, KEIN Daten-Zugriff!",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "System-Admin"},
)
@field_validator('isSysAdmin', mode='before')
@ -265,48 +303,45 @@ class User(PowerOnModel):
authenticationAuthority: AuthAuthority = Field(
default=AuthAuthority.LOCAL,
description="Primary authentication authority",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": "/api/connections/authorities/options",
"label": "Authentifizierung",
},
)
roleLabels: List[str] = Field(
default_factory=list,
description="Role labels (from DB or enriched when loading users)",
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False},
json_schema_extra={
"frontend_type": "multiselect",
"frontend_readonly": True,
"frontend_visible": False,
"frontend_required": False,
"label": "Rollen-Labels",
},
)
registerModelLabels(
"User",
{"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
"email": {"en": "Email", "de": "E-Mail", "fr": "Email"},
"fullName": {"en": "Full Name", "de": "Vollständiger Name", "fr": "Nom complet"},
"language": {"en": "Language", "de": "Sprache", "fr": "Langue"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
"authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
"roleLabels": {"en": "Role Labels", "de": "Rollen-Labels", "fr": "Libellés de rôles"},
},
)
@i18nModel("Benutzerzugang")
class UserInDB(User):
"""User model with password hash for database storage."""
hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")
registerModelLabels(
"UserInDB",
{"en": "User Access", "de": "Benutzerzugang", "fr": "Accès de l'utilisateur"},
{
"hashedPassword": {"en": "Password hash", "de": "Passwort-Hash", "fr": "Hachage de mot de passe"},
"resetToken": {"en": "Reset Token", "de": "Reset-Token", "fr": "Jeton de réinitialisation"},
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
},
)
hashedPassword: Optional[str] = Field(
None,
description="Hash of the user password",
json_schema_extra={"label": "Passwort-Hash"},
)
resetToken: Optional[str] = Field(
None,
description="Password reset token (UUID)",
json_schema_extra={"label": "Reset-Token"},
)
resetTokenExpires: Optional[float] = Field(
None,
description="Reset token expiration (UTC timestamp in seconds)",
json_schema_extra={"label": "Token läuft ab"},
)
def _normalizeTtsVoiceMap(value: Any) -> Optional[Dict[str, str]]:
@ -336,17 +371,50 @@ def _normalizeTtsVoiceMap(value: Any) -> Optional[Dict[str, str]]:
return out if out else None
@i18nModel("Spracheinstellungen")
class UserVoicePreferences(PowerOnModel):
"""User-level voice/language preferences, shared across all features."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
userId: str = Field(description="User ID")
mandateId: Optional[str] = Field(default=None, description="Mandate scope (None = global for user)")
sttLanguage: str = Field(default="de-DE", description="Speech-to-text language code")
ttsLanguage: str = Field(default="de-DE", description="Text-to-speech language code")
ttsVoice: Optional[str] = Field(default=None, description="Preferred TTS voice identifier")
ttsVoiceMap: Optional[Dict[str, str]] = Field(default=None, description="Language-to-voice mapping")
translationSourceLanguage: Optional[str] = Field(default=None, description="Source language for translations")
translationTargetLanguage: Optional[str] = Field(default=None, description="Target language for translations")
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
userId: str = Field(description="User ID", json_schema_extra={"label": "Benutzer-ID"})
mandateId: Optional[str] = Field(
default=None,
description="Mandate scope (None = global for user)",
json_schema_extra={"label": "Mandanten-ID"},
)
sttLanguage: str = Field(
default="de-DE",
description="Speech-to-text language code",
json_schema_extra={"label": "STT-Sprache"},
)
ttsLanguage: str = Field(
default="de-DE",
description="Text-to-speech language code",
json_schema_extra={"label": "TTS-Sprache"},
)
ttsVoice: Optional[str] = Field(
default=None,
description="Preferred TTS voice identifier",
json_schema_extra={"label": "TTS-Stimme"},
)
ttsVoiceMap: Optional[Dict[str, str]] = Field(
default=None,
description="Language-to-voice mapping",
json_schema_extra={"label": "Stimmen-Zuordnung"},
)
translationSourceLanguage: Optional[str] = Field(
default=None,
description="Source language for translations",
json_schema_extra={"label": "Übersetzung Quelle"},
)
translationTargetLanguage: Optional[str] = Field(
default=None,
description="Target language for translations",
json_schema_extra={"label": "Übersetzung Ziel"},
)
@field_validator("ttsVoiceMap", mode="before")
@classmethod
@ -354,18 +422,3 @@ class UserVoicePreferences(PowerOnModel):
return _normalizeTtsVoiceMap(value)
registerModelLabels(
"UserVoicePreferences",
{"en": "Voice Preferences", "de": "Spracheinstellungen", "fr": "Préférences vocales"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"sttLanguage": {"en": "STT Language", "de": "STT-Sprache", "fr": "Langue STT"},
"ttsLanguage": {"en": "TTS Language", "de": "TTS-Sprache", "fr": "Langue TTS"},
"ttsVoice": {"en": "TTS Voice", "de": "TTS-Stimme", "fr": "Voix TTS"},
"ttsVoiceMap": {"en": "Voice Map", "de": "Stimmen-Zuordnung", "fr": "Carte des voix"},
"translationSourceLanguage": {"en": "Translation Source", "de": "Übersetzung Quelle", "fr": "Langue source"},
"translationTargetLanguage": {"en": "Translation Target", "de": "Übersetzung Ziel", "fr": "Langue cible"},
},
)

View file

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

View file

@ -2,20 +2,40 @@
# All rights reserved.
"""Utility datamodels: Prompt, TextMultilingual."""
from typing import Dict, Optional
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field, field_validator
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Prompt")
class Prompt(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(default="", description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
isSystem: bool = Field(default=False, description="System prompt visible to all users (read-only for non-SysAdmin)", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False})
content: str = Field(description="Content of the prompt", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True})
name: str = Field(description="Name of the prompt", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
"""Benutzer- oder System-Prompt fuer die KI."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
mandateId: str = Field(
default="",
description="ID of the mandate this prompt belongs to",
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
isSystem: bool = Field(
default=False,
description="System prompt visible to all users (read-only for non-SysAdmin)",
json_schema_extra={"label": "System", "frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False},
)
content: str = Field(
description="Content of the prompt",
json_schema_extra={"label": "Inhalt", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True},
)
name: str = Field(
description="Name of the prompt",
json_schema_extra={"label": "Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
)
@field_validator('isSystem', mode='before')
@classmethod
def _coerceIsSystem(cls, v):
@ -23,62 +43,64 @@ class Prompt(PowerOnModel):
if v is None:
return False
return v
registerModelLabels(
"Prompt",
{"en": "Prompt", "fr": "Invite"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"isSystem": {"en": "System", "fr": "Système"},
"content": {"en": "Content", "fr": "Contenu"},
"name": {"en": "Name", "fr": "Nom"},
},
)
class TextMultilingual(BaseModel):
"""
Multilingual text field supporting multiple languages.
Default languages: en (English), ge (German), fr (French), it (Italian)
English (en) is the default/required language.
"""
"""Multilingual text field. Language codes follow ISO 639-1 (en, de, fr, it, …)."""
en: str = Field(description="English text (default language, required)")
ge: Optional[str] = Field(None, description="German text")
de: Optional[str] = Field(None, description="German text")
fr: Optional[str] = Field(None, description="French text")
it: Optional[str] = Field(None, description="Italian text")
@field_validator('en')
@classmethod
def validate_en_required(cls, v):
"""Ensure English text is not empty"""
def _validateEnRequired(cls, v):
if not v or not v.strip():
raise ValueError("English text (en) is required and cannot be empty")
return v
def model_dump(self, **kwargs) -> Dict[str, str]:
"""Return as dictionary, filtering out None values"""
result = {}
for lang in ['en', 'ge', 'fr', 'it']:
value = getattr(self, lang, None)
for key in self.model_fields:
value = getattr(self, key, None)
if value is not None:
result[lang] = value
result[key] = value
return result
@classmethod
def from_dict(cls, data: Dict[str, str]) -> 'TextMultilingual':
"""Create TextMultilingual from dictionary"""
return cls(
en=data.get('en', ''),
ge=data.get('ge'),
fr=data.get('fr'),
it=data.get('it')
)
fields = {k: data[k] for k in cls.model_fields if k in data}
fields.setdefault('en', '')
return cls(**fields)
def get_text(self, lang: str = 'en') -> str:
"""Get text for a specific language, fallback to English if not available"""
"""Get text for *lang*. Falls back to English."""
value = getattr(self, lang, None)
if value:
return value
return self.en # Fallback to English
return self.en
@classmethod
def fromUniform(cls, text: str) -> "TextMultilingual":
"""Same string in all languages (bootstrap / i18n key until per-language values exist in DB)."""
t = text.strip()
if not t:
raise ValueError("Text must be non-empty")
return cls(en=t, de=t, fr=t, it=t)
def coerce_text_multilingual(val: Any) -> TextMultilingual:
"""Normalize str, dict, or TextMultilingual for Role.description and similar fields."""
if isinstance(val, TextMultilingual):
return val
if isinstance(val, dict):
if not val:
return TextMultilingual.fromUniform("")
d = {k: val[k] for k in TextMultilingual.model_fields if k in val and val[k] is not None}
if not d.get("en"):
d["en"] = (d.get("de") or d.get("fr") or "").strip() or ""
return TextMultilingual(**{k: d[k] for k in TextMultilingual.model_fields if k in d})
if isinstance(val, str) and val.strip():
return TextMultilingual.fromUniform(val)
return TextMultilingual.fromUniform("")

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 pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.shared.jsonUtils import extractJsonString, tryParseJson, repairBrokenJson
# Import DocumentReferenceList at runtime (needed for ActionDefinition)
from modules.datamodels.datamodelDocref import DocumentReferenceList
@i18nModel("Aktionsdefinition")
class ActionDefinition(BaseModel):
"""Action definition with selection and parameters from planning phase"""
# Core action selection (Stage 1)
action: str = Field(description="Compound action name (method.action)")
actionObjective: str = Field(description="Objective for this action")
action: str = Field(description="Compound action name (method.action)", json_schema_extra={"label": "Aktion"})
actionObjective: str = Field(description="Objective for this action", json_schema_extra={"label": "Aktionsziel"})
userMessage: Optional[str] = Field(
None,
description="User-friendly message in user's language explaining what this action will do (generated by AI in prompts)"
description="User-friendly message in user's language explaining what this action will do (generated by AI in prompts)",
json_schema_extra={"label": "Benutzernachricht"},
)
parametersContext: Optional[str] = Field(
None,
description="Context for parameter generation"
description="Context for parameter generation",
json_schema_extra={"label": "Parameter-Kontext"},
)
learnings: List[str] = Field(
default_factory=list,
description="Learnings from previous actions"
description="Learnings from previous actions",
json_schema_extra={"label": "Erkenntnisse"},
)
# Resources (ALWAYS defined in Stage 1 if action needs them)
documentList: Optional[DocumentReferenceList] = Field(
None,
description="Document references (ALWAYS defined in Stage 1 if action needs documents)"
description="Document references (ALWAYS defined in Stage 1 if action needs documents)",
json_schema_extra={"label": "Dokumentenliste"},
)
connectionReference: Optional[str] = Field(
None,
description="Connection reference (ALWAYS defined in Stage 1 if action needs connection)"
description="Connection reference (ALWAYS defined in Stage 1 if action needs connection)",
json_schema_extra={"label": "Verbindungsreferenz"},
)
# Parameters (may be defined in Stage 1 OR Stage 2, depending on action and actionObjective)
parameters: Optional[Dict[str, Any]] = Field(
None,
description="Action-specific parameters (generated in Stage 2 for complex actions, or inferred from actionObjective for simple actions)"
description="Action-specific parameters (generated in Stage 2 for complex actions, or inferred from actionObjective for simple actions)",
json_schema_extra={"label": "Parameter"},
)
def hasParameters(self) -> bool:
@ -75,34 +82,47 @@ class ActionDefinition(BaseModel):
self.connectionReference = connectionRef
@i18nModel("KI-Antwort-Metadaten")
class AiResponseMetadata(BaseModel):
"""Metadata for AI response (varies by operation type)."""
# Document Generation Metadata
title: Optional[str] = Field(None, description="Document title")
filename: Optional[str] = Field(None, description="Document filename")
title: Optional[str] = Field(None, description="Document title", json_schema_extra={"label": "Titel"})
filename: Optional[str] = Field(None, description="Document filename", json_schema_extra={"label": "Dateiname"})
# Operation-Specific Metadata
operationType: Optional[str] = Field(None, description="Type of operation performed")
schemaVersion: Optional[str] = Field(None, description="Schema version (e.g., 'parameters_v1')", alias="schema")
extractionMethod: Optional[str] = Field(None, description="Method used for extraction")
sourceDocuments: Optional[List[str]] = Field(None, description="Source document references")
operationType: Optional[str] = Field(None, description="Type of operation performed", json_schema_extra={"label": "Vorgangstyp"})
schemaVersion: Optional[str] = Field(
None,
description="Schema version (e.g., 'parameters_v1')",
alias="schema",
json_schema_extra={"label": "Schema-Version"},
)
extractionMethod: Optional[str] = Field(None, description="Method used for extraction", json_schema_extra={"label": "Extraktionsmethode"})
sourceDocuments: Optional[List[str]] = Field(None, description="Source document references", json_schema_extra={"label": "Quelldokumente"})
# Additional metadata (for extensibility)
additionalData: Optional[Dict[str, Any]] = Field(None, description="Additional operation-specific metadata")
class DocumentData(BaseModel):
"""Single document in response"""
documentName: str = Field(description="Document name")
documentData: Any = Field(description="Document data (can be str, bytes, dict, etc.)")
mimeType: str = Field(description="MIME type of the document")
sourceJson: Optional[Dict[str, Any]] = Field(
additionalData: Optional[Dict[str, Any]] = Field(
None,
description="Source JSON structure (preserved when rendering to xlsx/docx/pdf)"
description="Additional operation-specific metadata",
json_schema_extra={"label": "Zusätzliche Daten"},
)
@i18nModel("Dokumentdaten")
class DocumentData(BaseModel):
"""Single document in response"""
documentName: str = Field(description="Document name", json_schema_extra={"label": "Dokumentname"})
documentData: Any = Field(description="Document data (can be str, bytes, dict, etc.)", json_schema_extra={"label": "Dokumentdaten"})
mimeType: str = Field(description="MIME type of the document", json_schema_extra={"label": "MIME-Typ"})
sourceJson: Optional[Dict[str, Any]] = Field(
None,
description="Source JSON structure (preserved when rendering to xlsx/docx/pdf)",
json_schema_extra={"label": "Quell-JSON"},
)
@i18nModel("Extraktionsparameter")
class ExtractContentParameters(BaseModel):
"""Parameters for extraction action.
@ -110,24 +130,34 @@ class ExtractContentParameters(BaseModel):
All action parameter models follow this pattern: defined in the same module as the action.
However, since this is a workflow-level model used across the system, it's defined here.
"""
documentList: DocumentReferenceList = Field(description="Document references to extract content from")
documentList: DocumentReferenceList = Field(
description="Document references to extract content from",
json_schema_extra={"label": "Dokumentenliste"},
)
extractionOptions: Optional[Any] = Field( # ExtractionOptions - forward reference
None,
description="Extraction options (determined dynamically based on task and document characteristics)"
description="Extraction options (determined dynamically based on task and document characteristics)",
json_schema_extra={"label": "Extraktionsoptionen"},
)
@i18nModel("KI-Antwort")
class AiResponse(BaseModel):
"""Unified response from all AI calls (planning, text, documents)"""
content: str = Field(description="Response content (JSON string for planning, text for analysis, unified JSON for documents)")
content: str = Field(
description="Response content (JSON string for planning, text for analysis, unified JSON for documents)",
json_schema_extra={"label": "Inhalt"},
)
metadata: Optional[AiResponseMetadata] = Field(
None,
description="Response metadata (varies by operation type)"
description="Response metadata (varies by operation type)",
json_schema_extra={"label": "Metadaten"},
)
documents: Optional[List[DocumentData]] = Field(
None,
description="Generated documents (only for document generation operations)"
description="Generated documents (only for document generation operations)",
json_schema_extra={"label": "Dokumente"},
)
def toJson(self) -> Dict[str, Any]:
@ -186,278 +216,88 @@ class AiResponse(BaseModel):
# Workflow-level models
@i18nModel("Anfragekontext")
class RequestContext(BaseModel):
"""Normalized request context from user input"""
originalPrompt: str = Field(description="Original user prompt")
originalPrompt: str = Field(description="Original user prompt", json_schema_extra={"label": "Ursprüngliche Eingabe"})
documents: List[Any] = Field( # ChatDocument - forward reference
default_factory=list,
description="Documents provided by user"
description="Documents provided by user",
json_schema_extra={"label": "Dokumente"},
)
userLanguage: str = Field(description="User's language")
userLanguage: str = Field(description="User's language", json_schema_extra={"label": "Benutzersprache"})
detectedComplexity: str = Field(
description="Complexity level: simple, moderate, complex"
description="Complexity level: simple, moderate, complex",
json_schema_extra={"label": "Erkannte Komplexität"},
)
requiresDocuments: bool = Field(default=False, description="Whether request requires documents")
requiresWebResearch: bool = Field(default=False, description="Whether request requires web research")
requiresAnalysis: bool = Field(default=False, description="Whether request requires analysis")
expectedOutputFormat: Optional[str] = Field(None, description="Expected output format")
expectedOutputType: Optional[str] = Field(None, description="Expected output type: answer, document, analysis")
requiresDocuments: bool = Field(default=False, description="Whether request requires documents", json_schema_extra={"label": "Benötigt Dokumente"})
requiresWebResearch: bool = Field(default=False, description="Whether request requires web research", json_schema_extra={"label": "Benötigt Web-Recherche"})
requiresAnalysis: bool = Field(default=False, description="Whether request requires analysis", json_schema_extra={"label": "Benötigt Analyse"})
expectedOutputFormat: Optional[str] = Field(None, description="Expected output format", json_schema_extra={"label": "Erwartetes Ausgabeformat"})
expectedOutputType: Optional[str] = Field(None, description="Expected output type: answer, document, analysis", json_schema_extra={"label": "Erwarteter Ausgabetyp"})
@i18nModel("Verständnis-Ergebnis")
class UnderstandingResult(BaseModel):
"""Result from initial understanding phase (combined AI call)"""
parameters: Dict[str, Any] = Field(
default_factory=dict,
description="Basic parameters (language, format, detail level)"
description="Basic parameters (language, format, detail level)",
json_schema_extra={"label": "Parameter"},
)
intention: Dict[str, Any] = Field(
default_factory=dict,
description="User intention (primaryGoal, secondaryGoals, intentionType)"
description="User intention (primaryGoal, secondaryGoals, intentionType)",
json_schema_extra={"label": "Absicht"},
)
context: Dict[str, Any] = Field(
default_factory=dict,
description="Extracted context (topics, requirements, constraints)"
description="Extracted context (topics, requirements, constraints)",
json_schema_extra={"label": "Kontext"},
)
documentReferences: List[Dict[str, Any]] = Field(
default_factory=list,
description="Document references with purpose and relevance"
description="Document references with purpose and relevance",
json_schema_extra={"label": "Dokumentenreferenzen"},
)
tasks: List["TaskDefinition"] = Field( # Forward reference
default_factory=list,
description="Task definitions with deliverables"
description="Task definitions with deliverables",
json_schema_extra={"label": "Aufgaben"},
)
@i18nModel("Aufgabenbeschreibung")
class TaskDefinition(BaseModel):
"""Task definition from understanding phase"""
id: str = Field(description="Task identifier")
objective: str = Field(description="Task objective")
id: str = Field(description="Task identifier", json_schema_extra={"label": "Aufgaben-ID"})
objective: str = Field(description="Task objective", json_schema_extra={"label": "Ziel"})
deliverable: Dict[str, Any] = Field(
description="Deliverable specification (type, format, style, detailLevel)"
description="Deliverable specification (type, format, style, detailLevel)",
json_schema_extra={"label": "Lieferobjekt"},
)
requiresWebResearch: bool = Field(default=False, description="Whether task requires web research")
requiresDocumentAnalysis: bool = Field(default=False, description="Whether task requires document analysis")
requiresContentGeneration: bool = Field(default=True, description="Whether task requires content generation")
requiresWebResearch: bool = Field(default=False, description="Whether task requires web research", json_schema_extra={"label": "Benötigt Web-Recherche"})
requiresDocumentAnalysis: bool = Field(default=False, description="Whether task requires document analysis", json_schema_extra={"label": "Benötigt Dokumentenanalyse"})
requiresContentGeneration: bool = Field(default=True, description="Whether task requires content generation", json_schema_extra={"label": "Benötigt Inhaltserstellung"})
requiredDocuments: List[str] = Field(
default_factory=list,
description="Document references needed for this task"
description="Document references needed for this task",
json_schema_extra={"label": "Benötigte Dokumente"},
)
extractionOptions: Optional[Any] = Field( # ExtractionOptions - forward reference
None,
description="Extraction options for document processing (determined dynamically based on task and document characteristics)"
description="Extraction options for document processing (determined dynamically based on task and document characteristics)",
json_schema_extra={"label": "Extraktionsoptionen"},
)
class TaskResult(BaseModel):
@i18nModel("Workflow-Aufgabenergebnis")
class WorkflowTaskResult(BaseModel):
"""Result from task execution"""
taskId: str = Field(description="Task identifier")
actionResult: Any = Field(description="ActionResult from task execution") # ActionResult - forward reference
# Register model labels for UI
registerModelLabels(
"RequestContext",
{"en": "Request Context", "fr": "Contexte de la demande"},
{
"originalPrompt": {"en": "Original Prompt", "fr": "Invite originale"},
"documents": {"en": "Documents", "fr": "Documents"},
"userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"},
"detectedComplexity": {"en": "Detected Complexity", "fr": "Complexité détectée"},
"requiresDocuments": {"en": "Requires Documents", "fr": "Nécessite des documents"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresAnalysis": {"en": "Requires Analysis", "fr": "Nécessite une analyse"},
"expectedOutputFormat": {"en": "Expected Output Format", "fr": "Format de sortie attendu"},
"expectedOutputType": {"en": "Expected Output Type", "fr": "Type de sortie attendu"},
},
)
registerModelLabels(
"UnderstandingResult",
{"en": "Understanding Result", "fr": "Résultat de compréhension"},
{
"parameters": {"en": "Parameters", "fr": "Paramètres"},
"intention": {"en": "Intention", "fr": "Intention"},
"context": {"en": "Context", "fr": "Contexte"},
"documentReferences": {"en": "Document References", "fr": "Références de documents"},
"tasks": {"en": "Tasks", "fr": "Tâches"},
},
)
registerModelLabels(
"TaskDefinition",
{"en": "Task Definition", "fr": "Définition de tâche"},
{
"id": {"en": "Task ID", "fr": "ID de la tâche"},
"objective": {"en": "Objective", "fr": "Objectif"},
"deliverable": {"en": "Deliverable", "fr": "Livrable"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresDocumentAnalysis": {"en": "Requires Document Analysis", "fr": "Nécessite une analyse de documents"},
"requiresContentGeneration": {"en": "Requires Content Generation", "fr": "Nécessite une génération de contenu"},
"requiredDocuments": {"en": "Required Documents", "fr": "Documents requis"},
"extractionOptions": {"en": "Extraction Options", "fr": "Options d'extraction"},
},
)
registerModelLabels(
"TaskResult",
{"en": "Task Result", "fr": "Résultat de tâche"},
{
"taskId": {"en": "Task ID", "fr": "ID de la tâche"},
"actionResult": {"en": "Action Result", "fr": "Résultat de l'action"},
},
)
registerModelLabels(
"RequestContext",
{"en": "Request Context", "fr": "Contexte de la demande"},
{
"originalPrompt": {"en": "Original Prompt", "fr": "Invite originale"},
"documents": {"en": "Documents", "fr": "Documents"},
"userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"},
"detectedComplexity": {"en": "Detected Complexity", "fr": "Complexité détectée"},
"requiresDocuments": {"en": "Requires Documents", "fr": "Nécessite des documents"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresAnalysis": {"en": "Requires Analysis", "fr": "Nécessite une analyse"},
"expectedOutputFormat": {"en": "Expected Output Format", "fr": "Format de sortie attendu"},
"expectedOutputType": {"en": "Expected Output Type", "fr": "Type de sortie attendu"},
},
)
registerModelLabels(
"UnderstandingResult",
{"en": "Understanding Result", "fr": "Résultat de compréhension"},
{
"parameters": {"en": "Parameters", "fr": "Paramètres"},
"intention": {"en": "Intention", "fr": "Intention"},
"context": {"en": "Context", "fr": "Contexte"},
"documentReferences": {"en": "Document References", "fr": "Références de documents"},
"tasks": {"en": "Tasks", "fr": "Tâches"},
},
)
registerModelLabels(
"TaskDefinition",
{"en": "Task Definition", "fr": "Définition de tâche"},
{
"id": {"en": "Task ID", "fr": "ID de la tâche"},
"objective": {"en": "Objective", "fr": "Objectif"},
"deliverable": {"en": "Deliverable", "fr": "Livrable"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresDocumentAnalysis": {"en": "Requires Document Analysis", "fr": "Nécessite une analyse de documents"},
"requiresContentGeneration": {"en": "Requires Content Generation", "fr": "Nécessite une génération de contenu"},
"requiredDocuments": {"en": "Required Documents", "fr": "Documents requis"},
"extractionOptions": {"en": "Extraction Options", "fr": "Options d'extraction"},
},
)
registerModelLabels(
"TaskResult",
{"en": "Task Result", "fr": "Résultat de tâche"},
{
"taskId": {"en": "Task ID", "fr": "ID de la tâche"},
"actionResult": {"en": "Action Result", "fr": "Résultat de l'action"},
},
)
# Register model labels for UI
registerModelLabels(
"ActionDefinition",
{"en": "Action Definition", "fr": "Définition d'action"},
{
"action": {"en": "Action", "fr": "Action"},
"actionObjective": {"en": "Action Objective", "fr": "Objectif de l'action"},
"parametersContext": {"en": "Parameters Context", "fr": "Contexte des paramètres"},
"learnings": {"en": "Learnings", "fr": "Apprentissages"},
"documentList": {"en": "Document List", "fr": "Liste de documents"},
"connectionReference": {"en": "Connection Reference", "fr": "Référence de connexion"},
"parameters": {"en": "Parameters", "fr": "Paramètres"},
},
)
registerModelLabels(
"AiResponse",
{"en": "AI Response", "fr": "Réponse IA"},
{
"content": {"en": "Content", "fr": "Contenu"},
"metadata": {"en": "Metadata", "fr": "Métadonnées"},
"documents": {"en": "Documents", "fr": "Documents"},
},
)
registerModelLabels(
"AiResponseMetadata",
{"en": "AI Response Metadata", "fr": "Métadonnées de réponse IA"},
{
"title": {"en": "Title", "fr": "Titre"},
"filename": {"en": "Filename", "fr": "Nom de fichier"},
"operationType": {"en": "Operation Type", "fr": "Type d'opération"},
"schemaVersion": {"en": "Schema Version", "fr": "Version du schéma"},
"extractionMethod": {"en": "Extraction Method", "fr": "Méthode d'extraction"},
"sourceDocuments": {"en": "Source Documents", "fr": "Documents sources"},
},
)
registerModelLabels(
"DocumentData",
{"en": "Document Data", "fr": "Données de document"},
{
"documentName": {"en": "Document Name", "fr": "Nom du document"},
"documentData": {"en": "Document Data", "fr": "Données du document"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
},
)
registerModelLabels(
"RequestContext",
{"en": "Request Context", "fr": "Contexte de requête"},
{
"originalPrompt": {"en": "Original Prompt", "fr": "Invite originale"},
"documents": {"en": "Documents", "fr": "Documents"},
"userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"},
"detectedComplexity": {"en": "Detected Complexity", "fr": "Complexité détectée"},
"requiresDocuments": {"en": "Requires Documents", "fr": "Nécessite des documents"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresAnalysis": {"en": "Requires Analysis", "fr": "Nécessite une analyse"},
},
)
registerModelLabels(
"UnderstandingResult",
{"en": "Understanding Result", "fr": "Résultat de compréhension"},
{
"parameters": {"en": "Parameters", "fr": "Paramètres"},
"intention": {"en": "Intention", "fr": "Intention"},
"context": {"en": "Context", "fr": "Contexte"},
"documentReferences": {"en": "Document References", "fr": "Références de documents"},
"tasks": {"en": "Tasks", "fr": "Tâches"},
},
)
registerModelLabels(
"TaskDefinition",
{"en": "Task Definition", "fr": "Définition de tâche"},
{
"id": {"en": "ID", "fr": "ID"},
"objective": {"en": "Objective", "fr": "Objectif"},
"deliverable": {"en": "Deliverable", "fr": "Livrable"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresDocumentAnalysis": {"en": "Requires Document Analysis", "fr": "Nécessite une analyse de document"},
"requiresContentGeneration": {"en": "Requires Content Generation", "fr": "Nécessite une génération de contenu"},
"requiredDocuments": {"en": "Required Documents", "fr": "Documents requis"},
"extractionOptions": {"en": "Extraction Options", "fr": "Options d'extraction"},
},
)
registerModelLabels(
"TaskResult",
{"en": "Task Result", "fr": "Résultat de tâche"},
{
"taskId": {"en": "Task ID", "fr": "ID de tâche"},
"actionResult": {"en": "Action Result", "fr": "Résultat d'action"},
},
)
taskId: str = Field(description="Task identifier", json_schema_extra={"label": "Aufgaben-ID"})
actionResult: Any = Field(description="ActionResult from task execution", json_schema_extra={"label": "Aktionsergebnis"}) # ActionResult - forward reference

View file

@ -6,85 +6,97 @@ from typing import Optional, Any, Union, List, Dict, Callable, Awaitable
from pydantic import BaseModel, Field
from modules.datamodels.datamodelChat import ActionResult
from modules.shared.frontendTypes import FrontendType
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
@i18nModel("Workflow-Aktionsparameter")
class WorkflowActionParameter(BaseModel):
"""
Parameter schema definition for a workflow action.
This defines the structure and UI rendering for a single action parameter,
NOT the actual parameter values (those are in ActionDefinition.parameters).
"""
name: str = Field(description="Parameter name")
type: str = Field(description="Python type as string: 'str', 'int', 'bool', 'List[str]', etc.")
frontendType: FrontendType = Field(description="UI rendering type (from global FrontendType enum)")
name: str = Field(
description="Parameter name",
json_schema_extra={"label": "Name"},
)
type: str = Field(
description="Python type as string: 'str', 'int', 'bool', 'List[str]', etc.",
json_schema_extra={"label": "Typ"},
)
frontendType: FrontendType = Field(
description="UI rendering type (from global FrontendType enum)",
json_schema_extra={"label": "Frontend-Typ"},
)
frontendOptions: Optional[Union[str, List[str]]] = Field(
None,
description="Options for select/multiselect/custom types. String reference (e.g., 'user.connection') or list of strings (e.g., ['txt', 'json']). For custom types, this is automatically set to the API endpoint."
description="Options for select/multiselect/custom types. String reference (e.g., 'user.connection') or list of strings (e.g., ['txt', 'json']). For custom types, this is automatically set to the API endpoint.",
json_schema_extra={"label": "Frontend-Optionen"},
)
required: bool = Field(
False,
description="Whether parameter is required",
json_schema_extra={"label": "Pflichtfeld"},
)
default: Optional[Any] = Field(
None,
description="Default value",
json_schema_extra={"label": "Standard"},
)
description: str = Field(
"",
description="Parameter description",
json_schema_extra={"label": "Beschreibung"},
)
required: bool = Field(False, description="Whether parameter is required")
default: Optional[Any] = Field(None, description="Default value")
description: str = Field("", description="Parameter description")
validation: Optional[Dict[str, Any]] = Field(
None,
description="Validation rules (e.g., {'min': 1, 'max': 100})"
description="Validation rules (e.g., {'min': 1, 'max': 100})",
json_schema_extra={"label": "Validierung"},
)
@i18nModel("Workflow-Aktionsdefinition")
class WorkflowActionDefinition(BaseModel):
"""
Complete schema definition of a workflow action.
This defines the metadata, parameters, and execution function for an action.
This is different from datamodelWorkflow.ActionDefinition which contains
actual execution values (action, actionObjective, parameters with values).
This class defines the ACTION SCHEMA, not the execution plan.
"""
actionId: str = Field(
description="Unique action identifier for RBAC (format: 'module.actionName', e.g., 'outlook.readEmails')"
description="Unique action identifier for RBAC (format: 'module.actionName', e.g., 'outlook.readEmails')",
json_schema_extra={"label": "Aktions-ID"},
)
description: str = Field(
description="Action description",
json_schema_extra={"label": "Beschreibung"},
)
description: str = Field(description="Action description")
parameters: Dict[str, WorkflowActionParameter] = Field(
default_factory=dict,
description="Parameter schema definitions"
description="Parameter schema definitions",
json_schema_extra={"label": "Parameter"},
)
execute: Optional[Callable] = Field(
None,
description="Execution function - async function that takes parameters dict and returns ActionResult. Set dynamically."
description="Execution function - async function that takes parameters dict and returns ActionResult. Set dynamically.",
json_schema_extra={"label": "Ausfuehrung"},
)
category: Optional[str] = Field(
None,
description="Action category for grouping",
json_schema_extra={"label": "Kategorie"},
)
tags: List[str] = Field(
default_factory=list,
description="Tags for search/filtering",
json_schema_extra={"label": "Tags"},
)
dynamicMode: bool = Field(
False,
description="Whether this action is available in dynamic workflow mode (only tagged actions are visible in action planning and refinement prompts)",
json_schema_extra={"label": "Dynamischer Modus"},
)
category: Optional[str] = Field(None, description="Action category for grouping")
tags: List[str] = Field(default_factory=list, description="Tags for search/filtering")
dynamicMode: bool = Field(False, description="Whether this action is available in dynamic workflow mode (only tagged actions are visible in action planning and refinement prompts)")
# Register model labels for UI
registerModelLabels(
"WorkflowActionDefinition",
{"en": "Workflow Action Definition", "fr": "Définition d'action de workflow"},
{
"actionId": {"en": "Action ID", "fr": "ID d'action"},
"description": {"en": "Description", "fr": "Description"},
"parameters": {"en": "Parameters", "fr": "Paramètres"},
"category": {"en": "Category", "fr": "Catégorie"},
"tags": {"en": "Tags", "fr": "Étiquettes"},
"dynamicMode": {"en": "Dynamic Mode", "fr": "Mode dynamique"},
},
)
registerModelLabels(
"WorkflowActionParameter",
{"en": "Workflow Action Parameter", "fr": "Paramètre d'action de workflow"},
{
"name": {"en": "Name", "fr": "Nom"},
"type": {"en": "Type", "fr": "Type"},
"frontendType": {"en": "Frontend Type", "fr": "Type frontend"},
"frontendOptions": {"en": "Frontend Options", "fr": "Options frontend"},
"required": {"en": "Required", "fr": "Requis"},
"default": {"en": "Default", "fr": "Par défaut"},
"description": {"en": "Description", "fr": "Description"},
"validation": {"en": "Validation", "fr": "Validation"},
},
)

View file

@ -12,14 +12,14 @@ logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "chatbot"
FEATURE_LABEL = {"en": "Chatbot", "de": "Chatbot", "fr": "Chatbot"}
FEATURE_LABEL = "Chatbot"
FEATURE_ICON = "mdi-robot"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.chatbot.conversations",
"label": {"en": "Conversations", "de": "Konversationen", "fr": "Conversations"},
"label": "Konversationen",
"meta": {"area": "conversations"}
}
]
@ -28,22 +28,22 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.chatbot.startStream",
"label": {"en": "Start Chat (Stream)", "de": "Chat starten (Stream)", "fr": "Démarrer chat (Stream)"},
"label": "Chat starten (Stream)",
"meta": {"endpoint": "/api/chatbot/{instanceId}/start/stream", "method": "POST"}
},
{
"objectKey": "resource.feature.chatbot.stop",
"label": {"en": "Stop Chat", "de": "Chat stoppen", "fr": "Arrêter chat"},
"label": "Chat stoppen",
"meta": {"endpoint": "/api/chatbot/{instanceId}/stop/{workflowId}", "method": "POST"}
},
{
"objectKey": "resource.feature.chatbot.threads",
"label": {"en": "Get Threads", "de": "Threads abrufen", "fr": "Récupérer threads"},
"label": "Threads abrufen",
"meta": {"endpoint": "/api/chatbot/{instanceId}/threads", "method": "GET"}
},
{
"objectKey": "resource.feature.chatbot.delete",
"label": {"en": "Delete Chat", "de": "Chat löschen", "fr": "Supprimer chat"},
"label": "Chat löschen",
"meta": {"endpoint": "/api/chatbot/{instanceId}/{workflowId}", "method": "DELETE"}
},
]
@ -74,11 +74,7 @@ REQUIRED_SERVICES = [
TEMPLATE_ROLES = [
{
"roleLabel": "chatbot-viewer",
"description": {
"en": "Chatbot Viewer - View chat threads (read-only)",
"de": "Chatbot Betrachter - Chat-Threads ansehen (nur lesen)",
"fr": "Visualiseur Chatbot - Consulter les threads (lecture seule)"
},
"description": "Chatbot Betrachter - Chat-Threads ansehen (nur lesen)",
"accessRules": [
# UI: only threads view, NO active chat
{"context": "UI", "item": "ui.feature.chatbot.threads", "view": True},
@ -90,11 +86,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "chatbot-user",
"description": {
"en": "Chatbot User - Use the chatbot and manage own threads",
"de": "Chatbot Benutzer - Chatbot nutzen und eigene Threads verwalten",
"fr": "Utilisateur Chatbot - Utiliser le chatbot et gérer ses threads"
},
"description": "Chatbot Benutzer - Chatbot nutzen und eigene Threads verwalten",
"accessRules": [
# UI: full access to all views
{"context": "UI", "item": "ui.feature.chatbot.conversations", "view": True},
@ -110,11 +102,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "chatbot-admin",
"description": {
"en": "Chatbot Admin - Full access to all chatbot features",
"de": "Chatbot Admin - Vollzugriff auf alle Chatbot-Funktionen",
"fr": "Administrateur Chatbot - Accès complet à toutes les fonctions chatbot"
},
"description": "Chatbot Admin - Vollzugriff auf alle Chatbot-Funktionen",
"accessRules": [
# Full UI access
{"context": "UI", "item": None, "view": True},
@ -391,7 +379,8 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
# Get existing template roles for this feature (Pydantic models)
@ -412,7 +401,7 @@ def _syncTemplateRolesToDb() -> int:
# Create new template role
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None, # Global template
featureInstanceId=None,

View file

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

View file

@ -11,23 +11,23 @@ from typing import Dict, List, Any
logger = logging.getLogger(__name__)
FEATURE_CODE = "commcoach"
FEATURE_LABEL = {"en": "Communication Coach", "de": "Kommunikations-Coach", "fr": "Coach Communication"}
FEATURE_LABEL = "Kommunikations-Coach"
FEATURE_ICON = "mdi-account-voice"
UI_OBJECTS = [
{
"objectKey": "ui.feature.commcoach.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"},
"label": "Dashboard",
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.commcoach.coaching",
"label": {"en": "Coaching & Dossier", "de": "Coaching & Dossier", "fr": "Coaching & Dossier"},
"label": "Coaching & Dossier",
"meta": {"area": "coaching"}
},
{
"objectKey": "ui.feature.commcoach.settings",
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Parametres"},
"label": "Einstellungen",
"meta": {"area": "settings"}
},
]
@ -35,7 +35,7 @@ UI_OBJECTS = [
DATA_OBJECTS = [
{
"objectKey": "data.feature.commcoach.CoachingContext",
"label": {"en": "Coaching Context", "de": "Coaching-Kontext", "fr": "Contexte coaching"},
"label": "Coaching-Kontext",
"meta": {
"table": "CoachingContext",
"fields": ["id", "title", "category", "status"],
@ -45,7 +45,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingSession",
"label": {"en": "Coaching Session", "de": "Coaching-Session", "fr": "Session coaching"},
"label": "Coaching-Session",
"meta": {
"table": "CoachingSession",
"fields": ["id", "contextId", "status", "summary"],
@ -55,12 +55,12 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingMessage",
"label": {"en": "Coaching Message", "de": "Coaching-Nachricht", "fr": "Message coaching"},
"label": "Coaching-Nachricht",
"meta": {"table": "CoachingMessage", "fields": ["id", "sessionId", "role", "content"]}
},
{
"objectKey": "data.feature.commcoach.CoachingTask",
"label": {"en": "Coaching Task", "de": "Coaching-Aufgabe", "fr": "Tache coaching"},
"label": "Coaching-Aufgabe",
"meta": {
"table": "CoachingTask",
"fields": ["id", "contextId", "title", "status"],
@ -70,27 +70,27 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingScore",
"label": {"en": "Coaching Score", "de": "Coaching-Score", "fr": "Score coaching"},
"label": "Coaching-Score",
"meta": {"table": "CoachingScore", "fields": ["id", "dimension", "score", "trend"]}
},
{
"objectKey": "data.feature.commcoach.CoachingUserProfile",
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"},
"label": "Benutzerprofil",
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "dailyReminderEnabled"]}
},
{
"objectKey": "data.feature.commcoach.CoachingPersona",
"label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"},
"label": "Coaching-Persona",
"meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]}
},
{
"objectKey": "data.feature.commcoach.CoachingBadge",
"label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"},
"label": "Coaching-Auszeichnung",
"meta": {"table": "CoachingBadge", "fields": ["id", "badgeKey", "awardedAt"]}
},
{
"objectKey": "data.feature.commcoach.*",
"label": {"en": "All CommCoach Data", "de": "Alle CommCoach-Daten", "fr": "Toutes les donnees CommCoach"},
"label": "Alle CommCoach-Daten",
"meta": {"wildcard": True}
},
]
@ -98,27 +98,27 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.commcoach.context.create",
"label": {"en": "Create Context", "de": "Kontext erstellen", "fr": "Creer contexte"},
"label": "Kontext erstellen",
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.context.archive",
"label": {"en": "Archive Context", "de": "Kontext archivieren", "fr": "Archiver contexte"},
"label": "Kontext archivieren",
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.session.start",
"label": {"en": "Start Session", "de": "Session starten", "fr": "Demarrer session"},
"label": "Session starten",
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/sessions/start", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.session.complete",
"label": {"en": "Complete Session", "de": "Session abschliessen", "fr": "Terminer session"},
"label": "Session abschliessen",
"meta": {"endpoint": "/api/commcoach/{instanceId}/sessions/{sessionId}/complete", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.task.manage",
"label": {"en": "Manage Tasks", "de": "Aufgaben verwalten", "fr": "Gerer taches"},
"label": "Aufgaben verwalten",
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/tasks", "method": "POST"}
},
]
@ -126,30 +126,22 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [
{
"roleLabel": "commcoach-viewer",
"description": {
"en": "Communication Coach Viewer - View coaching data (read-only)",
"de": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
"fr": "Visualiseur Coach Communication - Consulter les donnees coaching (lecture seule)",
},
"description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
# Viewer: keine RESOURCE-Endpunkte (Mutationen); Regel explizit fuer konsistente Kontext-Matrix
{"context": "RESOURCE", "item": None, "view": False},
],
},
{
"roleLabel": "commcoach-user",
"description": {
"en": "Communication Coach User - Can manage own coaching contexts and sessions",
"de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
"fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions",
},
"description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
{"context": "DATA", "item": "data.feature.commcoach.CoachingContext", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
@ -166,11 +158,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "commcoach-admin",
"description": {
"en": "Communication Coach Admin - All UI and API actions; data scoped to own records",
"de": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze",
"fr": "Administrateur Coach Communication - Toute l'UI et les API; donnees propres",
},
"description": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
@ -271,6 +259,7 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
@ -287,7 +276,7 @@ def _syncTemplateRolesToDb() -> int:
else:
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,

View file

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

View file

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

View file

@ -6,7 +6,7 @@ from enum import Enum
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
@ -54,437 +54,341 @@ class AutoTemplateScope(str, Enum):
# AutoWorkflow
# ---------------------------------------------------------------------------
@i18nModel("Workflow")
class AutoWorkflow(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Mandanten-ID"},
)
featureInstanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Feature-Instanz-ID"},
)
label: str = Field(
description="User-friendly workflow name",
json_schema_extra={"frontend_type": "text", "frontend_required": True},
json_schema_extra={"frontend_type": "text", "frontend_required": True, "label": "Bezeichnung"},
)
description: Optional[str] = Field(
default=None,
description="Workflow description",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Beschreibung"},
)
tags: List[str] = Field(
default_factory=list,
description="Tags for categorization",
json_schema_extra={"frontend_type": "tags", "frontend_required": False},
json_schema_extra={"frontend_type": "tags", "frontend_required": False, "label": "Tags"},
)
isTemplate: bool = Field(
default=False,
description="Whether this workflow is a template",
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False},
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Ist Vorlage"},
)
templateSourceId: Optional[str] = Field(
default=None,
description="ID of the template this workflow was created from",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Vorlagen-Quelle"},
)
templateScope: Optional[str] = Field(
default=None,
description="Template scope: user, instance, mandate, system (AutoTemplateScope)",
json_schema_extra={"frontend_type": "select", "frontend_required": False},
json_schema_extra={"frontend_type": "select", "frontend_required": False, "label": "Vorlagen-Bereich"},
)
sharedReadOnly: bool = Field(
default=False,
description="If true, shared template is read-only for non-owners",
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False},
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Freigabe nur-lesen"},
)
currentVersionId: Optional[str] = Field(
default=None,
description="ID of the currently published AutoVersion",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktuelle Version"},
)
active: bool = Field(
default=True,
description="Whether workflow is active",
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False},
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Aktiv"},
)
eventId: Optional[str] = Field(
default=None,
description="Scheduler event ID for incremental sync",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Event-ID"},
)
notifyOnFailure: bool = Field(
default=True,
description="Send notification (in-app + email) when a run fails",
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False},
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Bei Fehler benachrichtigen"},
)
# Legacy fields kept for backward compatibility during transition
graph: Dict[str, Any] = Field(
default_factory=dict,
description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Graph"},
)
invocations: List[Dict[str, Any]] = Field(
default_factory=list,
description="Entry points / starts (manual, form, schedule, webhook, ...)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Starts / Einstiegspunkte"},
)
registerModelLabels(
"AutoWorkflow",
{"en": "Workflow", "de": "Workflow", "fr": "Workflow"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID", "fr": "ID instance"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"tags": {"en": "Tags", "de": "Tags", "fr": "Tags"},
"isTemplate": {"en": "Is Template", "de": "Ist Vorlage", "fr": "Est modèle"},
"templateSourceId": {"en": "Template Source", "de": "Vorlagen-Quelle", "fr": "Source du modèle"},
"templateScope": {"en": "Template Scope", "de": "Vorlagen-Bereich", "fr": "Portée du modèle"},
"sharedReadOnly": {"en": "Shared Read-Only", "de": "Freigabe nur-lesen", "fr": "Partage lecture seule"},
"currentVersionId": {"en": "Current Version", "de": "Aktuelle Version", "fr": "Version actuelle"},
"active": {"en": "Active", "de": "Aktiv", "fr": "Actif"},
"eventId": {"en": "Event ID", "de": "Event-ID", "fr": "ID événement"},
"graph": {"en": "Graph", "de": "Graph", "fr": "Graphe"},
"invocations": {"en": "Starts / Entry points", "de": "Starts / Einstiegspunkte", "fr": "Points d'entrée"},
},
)
# ---------------------------------------------------------------------------
# AutoVersion
# ---------------------------------------------------------------------------
@i18nModel("Workflow-Version")
class AutoVersion(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
workflowId: str = Field(
description="FK -> AutoWorkflow",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"},
)
versionNumber: int = Field(
default=1,
description="Incrementing version number",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Version"},
)
status: str = Field(
default=AutoWorkflowStatus.DRAFT.value,
description="Version status: draft, published, archived",
json_schema_extra={"frontend_type": "select", "frontend_required": False},
json_schema_extra={"frontend_type": "select", "frontend_required": False, "label": "Status"},
)
graph: Dict[str, Any] = Field(
default_factory=dict,
description="Graph with nodes and connections (incl. node parameters)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": True},
json_schema_extra={"frontend_type": "textarea", "frontend_required": True, "label": "Graph"},
)
invocations: List[Dict[str, Any]] = Field(
default_factory=list,
description="Entry points / starts for this version",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Einstiegspunkte"},
)
publishedAt: Optional[float] = Field(
default=None,
description="Timestamp when version was published",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht am"},
)
publishedBy: Optional[str] = Field(
default=None,
description="User ID who published this version",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht von"},
)
registerModelLabels(
"AutoVersion",
{"en": "Workflow Version", "de": "Workflow-Version", "fr": "Version workflow"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"workflowId": {"en": "Workflow ID", "de": "Workflow-ID", "fr": "ID workflow"},
"versionNumber": {"en": "Version", "de": "Version", "fr": "Version"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"graph": {"en": "Graph", "de": "Graph", "fr": "Graphe"},
"invocations": {"en": "Entry Points", "de": "Einstiegspunkte", "fr": "Points d'entrée"},
"publishedAt": {"en": "Published At", "de": "Veröffentlicht am", "fr": "Publié le"},
"publishedBy": {"en": "Published By", "de": "Veröffentlicht von", "fr": "Publié par"},
},
)
# ---------------------------------------------------------------------------
# AutoRun
# ---------------------------------------------------------------------------
@i18nModel("Workflow-Ausführung")
class AutoRun(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
workflowId: str = Field(
description="Workflow ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"},
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID for cross-feature querying",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Mandanten-ID"},
)
ownerId: Optional[str] = Field(
default=None,
description="User ID who triggered this run",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Auslöser"},
)
versionId: Optional[str] = Field(
default=None,
description="AutoVersion ID used for this run",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Versions-ID"},
)
status: str = Field(
default=AutoRunStatus.RUNNING.value,
description="Status: running, paused, completed, failed, cancelled",
json_schema_extra={"frontend_type": "text", "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_required": False, "label": "Status"},
)
trigger: Dict[str, Any] = Field(
default_factory=dict,
description="Trigger info (type, entryPointId, payload, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Auslöser"},
)
startedAt: Optional[float] = Field(
default=None,
description="Run start timestamp",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"},
)
completedAt: Optional[float] = Field(
default=None,
description="Run completion timestamp",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"},
)
nodeOutputs: Dict[str, Any] = Field(
default_factory=dict,
description="Outputs from executed nodes",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Node-Ausgaben"},
)
currentNodeId: Optional[str] = Field(
default=None,
description="Node ID when paused (human task / email wait)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktueller Knoten"},
)
resumeContext: Dict[str, Any] = Field(
default_factory=dict,
description="Context for resume (connectionMap, inputSources, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Wiederaufnahme-Kontext"},
)
error: Optional[str] = Field(
default=None,
description="Error message if failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"},
)
costTokens: int = Field(
default=0,
description="Total tokens consumed by AI nodes",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"},
)
costCredits: float = Field(
default=0.0,
description="Total credits consumed",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Credits"},
)
registerModelLabels(
"AutoRun",
{"en": "Workflow Run", "de": "Workflow-Ausführung", "fr": "Exécution workflow"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"workflowId": {"en": "Workflow ID", "de": "Workflow-ID", "fr": "ID workflow"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"ownerId": {"en": "Owner", "de": "Auslöser", "fr": "Propriétaire"},
"versionId": {"en": "Version ID", "de": "Versions-ID", "fr": "ID version"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"trigger": {"en": "Trigger", "de": "Auslöser", "fr": "Déclencheur"},
"startedAt": {"en": "Started At", "de": "Gestartet am", "fr": "Démarré le"},
"completedAt": {"en": "Completed At", "de": "Abgeschlossen am", "fr": "Terminé le"},
"nodeOutputs": {"en": "Node Outputs", "de": "Node-Ausgaben", "fr": "Sorties nœuds"},
"currentNodeId": {"en": "Current Node", "de": "Aktueller Knoten", "fr": "Nœud actuel"},
"resumeContext": {"en": "Resume Context", "de": "Wiederaufnahme-Kontext", "fr": "Contexte reprise"},
"error": {"en": "Error", "de": "Fehler", "fr": "Erreur"},
"costTokens": {"en": "Tokens Used", "de": "Verbrauchte Tokens", "fr": "Tokens utilisés"},
"costCredits": {"en": "Credits Used", "de": "Verbrauchte Credits", "fr": "Crédits utilisés"},
},
)
# ---------------------------------------------------------------------------
# AutoStepLog
# ---------------------------------------------------------------------------
@i18nModel("Schritt-Protokoll")
class AutoStepLog(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
runId: str = Field(
description="FK -> AutoRun",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Lauf-ID"},
)
nodeId: str = Field(
description="Node ID in the graph",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"},
)
nodeType: str = Field(
description="Node type (e.g. ai.chat, email.send)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"},
)
status: str = Field(
default=AutoStepStatus.PENDING.value,
description="Step status: pending, running, completed, failed, skipped",
json_schema_extra={"frontend_type": "text", "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_required": False, "label": "Status"},
)
inputSnapshot: Dict[str, Any] = Field(
default_factory=dict,
description="Snapshot of inputs at execution time",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Eingabe-Snapshot"},
)
output: Dict[str, Any] = Field(
default_factory=dict,
description="Node output",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ausgabe"},
)
error: Optional[str] = Field(
default=None,
description="Error message if step failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"},
)
startedAt: Optional[float] = Field(
default=None,
description="Step start timestamp",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"},
)
completedAt: Optional[float] = Field(
default=None,
description="Step completion timestamp",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"},
)
durationMs: Optional[int] = Field(
default=None,
description="Execution duration in milliseconds",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Dauer (ms)"},
)
tokensUsed: int = Field(
default=0,
description="Tokens consumed by this step",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"},
)
retryCount: int = Field(
default=0,
description="Number of retries executed",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Wiederholungen"},
)
registerModelLabels(
"AutoStepLog",
{"en": "Step Log", "de": "Schritt-Protokoll", "fr": "Journal d'étape"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"runId": {"en": "Run ID", "de": "Lauf-ID", "fr": "ID exécution"},
"nodeId": {"en": "Node ID", "de": "Knoten-ID", "fr": "ID nœud"},
"nodeType": {"en": "Node Type", "de": "Knotentyp", "fr": "Type nœud"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"inputSnapshot": {"en": "Input Snapshot", "de": "Eingabe-Snapshot", "fr": "Snapshot entrée"},
"output": {"en": "Output", "de": "Ausgabe", "fr": "Sortie"},
"error": {"en": "Error", "de": "Fehler", "fr": "Erreur"},
"startedAt": {"en": "Started At", "de": "Gestartet am", "fr": "Démarré le"},
"completedAt": {"en": "Completed At", "de": "Abgeschlossen am", "fr": "Terminé le"},
"durationMs": {"en": "Duration (ms)", "de": "Dauer (ms)", "fr": "Durée (ms)"},
"tokensUsed": {"en": "Tokens Used", "de": "Verbrauchte Tokens", "fr": "Tokens utilisés"},
"retryCount": {"en": "Retry Count", "de": "Wiederholungen", "fr": "Nombre de tentatives"},
},
)
# ---------------------------------------------------------------------------
# AutoTask
# ---------------------------------------------------------------------------
@i18nModel("Aufgabe")
class AutoTask(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
runId: str = Field(
description="FK -> AutoRun",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Lauf-ID"},
)
workflowId: str = Field(
description="Workflow ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"},
)
nodeId: str = Field(
description="Node ID in the graph",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"},
)
nodeType: str = Field(
description="Node type: form, approval, upload, comment, review, selection, confirmation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"},
)
config: Dict[str, Any] = Field(
default_factory=dict,
description="Node config (form schema, approval text, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Konfiguration"},
)
assigneeId: Optional[str] = Field(
default=None,
description="User ID assigned to complete the task",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Zugewiesen an"},
)
status: str = Field(
default=AutoTaskStatus.PENDING.value,
description="Status: pending, completed, cancelled, expired",
json_schema_extra={"frontend_type": "text", "frontend_required": False},
json_schema_extra={"frontend_type": "text", "frontend_required": False, "label": "Status"},
)
result: Optional[Dict[str, Any]] = Field(
default=None,
description="Task result (form data, approval decision, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ergebnis"},
)
expiresAt: Optional[float] = Field(
default=None,
description="Expiration timestamp for the task",
json_schema_extra={"frontend_type": "datetime", "frontend_required": False},
json_schema_extra={"frontend_type": "datetime", "frontend_required": False, "label": "Läuft ab am"},
)
registerModelLabels(
"AutoTask",
{"en": "Task", "de": "Aufgabe", "fr": "Tâche"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"runId": {"en": "Run ID", "de": "Lauf-ID", "fr": "ID exécution"},
"workflowId": {"en": "Workflow ID", "de": "Workflow-ID", "fr": "ID workflow"},
"nodeId": {"en": "Node ID", "de": "Knoten-ID", "fr": "ID nœud"},
"nodeType": {"en": "Node Type", "de": "Knotentyp", "fr": "Type nœud"},
"config": {"en": "Config", "de": "Konfiguration", "fr": "Configuration"},
"assigneeId": {"en": "Assignee", "de": "Zugewiesen an", "fr": "Assigné à"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"result": {"en": "Result", "de": "Ergebnis", "fr": "Résultat"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
},
)
# ---------------------------------------------------------------------------
# Backward-compatible aliases for transition period
# ---------------------------------------------------------------------------

View file

@ -30,22 +30,18 @@ def default_manual_entry_point() -> Dict[str, Any]:
"kind": "manual",
"category": "on_demand",
"enabled": True,
"title": {
"de": "Jetzt ausführen",
"en": "Run now",
"fr": "Exécuter",
},
"title": "Jetzt ausführen",
"description": {},
"config": {},
}
def _normalize_title(title: Any) -> Dict[str, str]:
def _normalize_title(title: Any) -> str:
if isinstance(title, dict):
return {k: str(v) for k, v in title.items() if v is not None}
return str(title.get("de") or title.get("en") or title.get("fr") or "").strip()
if isinstance(title, str) and title.strip():
return {"de": title, "en": title, "fr": title}
return {"de": "Start", "en": "Start", "fr": "Départ"}
return title.strip()
return "Start"
def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]:

View file

@ -21,28 +21,28 @@ REQUIRED_SERVICES = [
{"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}},
{"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}},
]
FEATURE_LABEL = {"en": "Graphical Editor", "de": "Grafischer Editor", "fr": "Éditeur graphique"}
FEATURE_LABEL = "Grafischer Editor"
FEATURE_ICON = "mdi-sitemap"
UI_OBJECTS = [
{
"objectKey": "ui.feature.graphicalEditor.editor",
"label": {"en": "Editor", "de": "Editor", "fr": "Éditeur"},
"label": "Editor",
"meta": {"area": "editor"}
},
{
"objectKey": "ui.feature.graphicalEditor.workflows",
"label": {"en": "Workflows", "de": "Workflows", "fr": "Workflows"},
"label": "Workflows",
"meta": {"area": "workflows"}
},
{
"objectKey": "ui.feature.graphicalEditor.templates",
"label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"},
"label": "Vorlagen",
"meta": {"area": "templates"}
},
{
"objectKey": "ui.feature.graphicalEditor.workflows-tasks",
"label": {"en": "Tasks", "de": "Tasks", "fr": "Tâches"},
"label": "Tasks",
"meta": {"area": "tasks"}
},
]
@ -50,17 +50,17 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.graphicalEditor.dashboard",
"label": {"en": "Access Dashboard", "de": "Dashboard aufrufen", "fr": "Acceder au tableau de bord"},
"label": "Dashboard aufrufen",
"meta": {"endpoint": "/api/workflows/{instanceId}/info", "method": "GET"}
},
{
"objectKey": "resource.feature.graphicalEditor.node-types",
"label": {"en": "Get Node Types", "de": "Node-Typen abrufen", "fr": "Obtenir types de nœuds"},
"label": "Node-Typen abrufen",
"meta": {"endpoint": "/api/workflows/{instanceId}/node-types", "method": "GET"}
},
{
"objectKey": "resource.feature.graphicalEditor.execute",
"label": {"en": "Execute Workflow", "de": "Workflow ausführen", "fr": "Exécuter le workflow"},
"label": "Workflow ausführen",
"meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"}
},
]
@ -68,11 +68,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [
{
"roleLabel": "graphicalEditor-viewer",
"description": {
"en": "GraphicalEditor Viewer - View workflows (read-only)",
"de": "Grafischer Editor Betrachter - Workflows ansehen (nur lesen)",
"fr": "Visualiseur Éditeur graphique - Consulter les workflows (lecture seule)",
},
"description": "Grafischer Editor Betrachter - Workflows ansehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True},
@ -82,11 +78,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "graphicalEditor-user",
"description": {
"en": "GraphicalEditor User - Use flow builder",
"de": "Grafischer Editor Benutzer - Flow-Builder nutzen",
"fr": "Utilisateur Éditeur graphique - Utiliser le flow builder",
},
"description": "Grafischer Editor Benutzer - Flow-Builder nutzen",
"accessRules": [
{"context": "UI", "item": "ui.feature.graphicalEditor.editor", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True},
@ -100,11 +92,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "graphicalEditor-admin",
"description": {
"en": "GraphicalEditor Admin - Full UI and API for the instance; data remains user-scoped (MY)",
"de": "Grafischer Editor Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)",
"fr": "Administrateur Éditeur graphique - UI et API complets pour l'instance; donnees limitees a l'utilisateur (MY)",
},
"description": "Grafischer Editor Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
@ -272,6 +260,7 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
@ -285,7 +274,7 @@ def _syncTemplateRolesToDb() -> int:
else:
newRole = Role(
roleLabel=roleLabel,
description=template.get("description", {}),
description=coerce_text_multilingual(template.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,

View file

@ -5,14 +5,14 @@ AI_NODES = [
{
"id": "ai.prompt",
"category": "ai",
"label": {"en": "Prompt", "de": "Prompt", "fr": "Invite"},
"description": {"en": "Enter a prompt and AI does something", "de": "Prompt eingeben und KI führt aus", "fr": "Entrer une invite et l'IA exécute"},
"label": "Prompt",
"description": "Prompt eingeben und KI führt aus",
"parameters": [
{"name": "aiPrompt", "type": "string", "required": True, "frontendType": "textarea",
"description": {"en": "AI prompt", "de": "KI-Prompt", "fr": "Invite IA"}},
"description": "KI-Prompt"},
{"name": "outputFormat", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["text", "json", "emailDraft"]},
"description": {"en": "Output format", "de": "Ausgabeformat", "fr": "Format de sortie"}, "default": "text"},
"description": "Ausgabeformat", "default": "text"},
],
"inputs": 1,
"outputs": 1,
@ -25,11 +25,11 @@ AI_NODES = [
{
"id": "ai.webResearch",
"category": "ai",
"label": {"en": "Web Research", "de": "Web-Recherche", "fr": "Recherche web"},
"description": {"en": "Research on the web", "de": "Recherche im Web", "fr": "Recherche sur le web"},
"label": "Web-Recherche",
"description": "Recherche im Web",
"parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
"description": {"en": "Research query", "de": "Recherche-Anfrage", "fr": "Requête de recherche"}},
"description": "Recherche-Anfrage"},
],
"inputs": 1,
"outputs": 1,
@ -42,12 +42,12 @@ AI_NODES = [
{
"id": "ai.summarizeDocument",
"category": "ai",
"label": {"en": "Summarize Document", "de": "Dokument zusammenfassen", "fr": "Résumer document"},
"description": {"en": "Summarize document content", "de": "Dokumentinhalt zusammenfassen", "fr": "Résumer le contenu du document"},
"label": "Dokument zusammenfassen",
"description": "Dokumentinhalt zusammenfassen",
"parameters": [
{"name": "summaryLength", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["short", "medium", "long"]},
"description": {"en": "Short, medium, or long", "de": "Kurz, mittel oder lang", "fr": "Court, moyen ou long"}, "default": "medium"},
"description": "Kurz, mittel oder lang", "default": "medium"},
],
"inputs": 1,
"outputs": 1,
@ -60,12 +60,12 @@ AI_NODES = [
{
"id": "ai.translateDocument",
"category": "ai",
"label": {"en": "Translate Document", "de": "Dokument übersetzen", "fr": "Traduire document"},
"description": {"en": "Translate document to target language", "de": "Dokument in Zielsprache übersetzen", "fr": "Traduire le document"},
"label": "Dokument übersetzen",
"description": "Dokument in Zielsprache übersetzen",
"parameters": [
{"name": "targetLanguage", "type": "string", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["en", "de", "fr", "it", "es", "pt", "nl"]},
"description": {"en": "Target language", "de": "Zielsprache", "fr": "Langue cible"}},
"description": "Zielsprache"},
],
"inputs": 1,
"outputs": 1,
@ -78,12 +78,12 @@ AI_NODES = [
{
"id": "ai.convertDocument",
"category": "ai",
"label": {"en": "Convert Document", "de": "Dokument konvertieren", "fr": "Convertir document"},
"description": {"en": "Convert document to another format", "de": "Dokument in anderes Format konvertieren", "fr": "Convertir le document"},
"label": "Dokument konvertieren",
"description": "Dokument in anderes Format konvertieren",
"parameters": [
{"name": "targetFormat", "type": "string", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["pdf", "docx", "txt", "html", "md"]},
"description": {"en": "Target format", "de": "Zielformat", "fr": "Format cible"}},
"description": "Zielformat"},
],
"inputs": 1,
"outputs": 1,
@ -96,11 +96,11 @@ AI_NODES = [
{
"id": "ai.generateDocument",
"category": "ai",
"label": {"en": "Generate Document", "de": "Dokument generieren", "fr": "Générer document"},
"description": {"en": "Generate document from prompt", "de": "Dokument aus Prompt generieren", "fr": "Générer un document"},
"label": "Dokument generieren",
"description": "Dokument aus Prompt generieren",
"parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
"description": {"en": "Generation prompt", "de": "Generierungs-Prompt", "fr": "Invite de génération"}},
"description": "Generierungs-Prompt"},
],
"inputs": 1,
"outputs": 1,
@ -113,14 +113,14 @@ AI_NODES = [
{
"id": "ai.generateCode",
"category": "ai",
"label": {"en": "Generate Code", "de": "Code generieren", "fr": "Générer code"},
"description": {"en": "Generate code from description", "de": "Code aus Beschreibung generieren", "fr": "Générer du code"},
"label": "Code generieren",
"description": "Code aus Beschreibung generieren",
"parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
"description": {"en": "Code generation prompt", "de": "Code-Generierungs-Prompt", "fr": "Invite de génération de code"}},
"description": "Code-Generierungs-Prompt"},
{"name": "language", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["python", "javascript", "typescript", "java", "csharp", "go"]},
"description": {"en": "Programming language", "de": "Programmiersprache", "fr": "Langage de programmation"}, "default": "python"},
"description": "Programmiersprache", "default": "python"},
],
"inputs": 1,
"outputs": 1,

View file

@ -6,26 +6,26 @@ CLICKUP_NODES = [
{
"id": "clickup.searchTasks",
"category": "clickup",
"label": {"en": "Search tasks", "de": "Aufgaben suchen", "fr": "Rechercher tâches"},
"description": {"en": "Search tasks in a workspace", "de": "Aufgaben in einem Workspace suchen", "fr": "Rechercher des tâches"},
"label": "Aufgaben suchen",
"description": "Aufgaben in einem Workspace suchen",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
"description": "ClickUp-Verbindung"},
{"name": "teamId", "type": "string", "required": True, "frontendType": "text",
"description": {"en": "Workspace (team) ID", "de": "Team-/Workspace-ID", "fr": "ID équipe"}},
"description": "Team-/Workspace-ID"},
{"name": "query", "type": "string", "required": True, "frontendType": "text",
"description": {"en": "Search query", "de": "Suchbegriff", "fr": "Requête"}},
"description": "Suchbegriff"},
{"name": "page", "type": "number", "required": False, "frontendType": "number",
"description": {"en": "Page", "de": "Seite", "fr": "Page"}, "default": 0},
"description": "Seite", "default": 0},
{"name": "listId", "type": "string", "required": False, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Search in this list", "de": "In dieser Liste suchen", "fr": "Rechercher dans cette liste"}},
"description": "In dieser Liste suchen"},
{"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": {"en": "Include closed tasks", "de": "Erledigte einbeziehen", "fr": "Inclure terminées"}, "default": False},
"description": "Erledigte einbeziehen", "default": False},
{"name": "fullTaskData", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": {"en": "Return full task data", "de": "Vollständige Daten", "fr": "Données complètes"}, "default": False},
"description": "Vollständige Daten", "default": False},
{"name": "matchNameOnly", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": {"en": "Match title only", "de": "Nur Titel", "fr": "Titre uniquement"}, "default": True},
"description": "Nur Titel", "default": True},
],
"inputs": 1,
"outputs": 1,
@ -38,18 +38,18 @@ CLICKUP_NODES = [
{
"id": "clickup.listTasks",
"category": "clickup",
"label": {"en": "List tasks", "de": "Aufgaben auflisten", "fr": "Lister les tâches"},
"description": {"en": "List tasks in a list", "de": "Aufgaben einer Liste auflisten", "fr": "Lister les tâches"},
"label": "Aufgaben auflisten",
"description": "Aufgaben einer Liste auflisten",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
"description": "ClickUp-Verbindung"},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Path to list", "de": "Pfad zur Liste", "fr": "Chemin vers la liste"}},
"description": "Pfad zur Liste"},
{"name": "page", "type": "number", "required": False, "frontendType": "number",
"description": {"en": "Page", "de": "Seite", "fr": "Page"}, "default": 0},
"description": "Seite", "default": 0},
{"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": {"en": "Include closed", "de": "Erledigte einbeziehen", "fr": "Inclure terminées"}, "default": False},
"description": "Erledigte einbeziehen", "default": False},
],
"inputs": 1,
"outputs": 1,
@ -62,15 +62,15 @@ CLICKUP_NODES = [
{
"id": "clickup.getTask",
"category": "clickup",
"label": {"en": "Get task", "de": "Aufgabe abrufen", "fr": "Obtenir la tâche"},
"description": {"en": "Get one task by ID or path", "de": "Eine Aufgabe abrufen", "fr": "Obtenir une tâche"},
"label": "Aufgabe abrufen",
"description": "Eine Aufgabe abrufen",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
"description": "ClickUp-Verbindung"},
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}},
"description": "Task-ID"},
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Or path .../task/{id}", "de": "Oder Pfad", "fr": "Ou chemin"}},
"description": "Oder Pfad"},
],
"inputs": 1,
"outputs": 1,
@ -83,39 +83,39 @@ CLICKUP_NODES = [
{
"id": "clickup.createTask",
"category": "clickup",
"label": {"en": "Create task", "de": "Aufgabe erstellen", "fr": "Créer une tâche"},
"description": {"en": "Create a task in a list", "de": "Aufgabe erstellen", "fr": "Créer une tâche"},
"label": "Aufgabe erstellen",
"description": "Aufgabe erstellen",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
"description": "ClickUp-Verbindung"},
{"name": "teamId", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Workspace (team)", "de": "Workspace", "fr": "Équipe"}},
"description": "Workspace"},
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Path to list", "de": "Pfad zur Liste", "fr": "Chemin"}},
"description": "Pfad zur Liste"},
{"name": "listId", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "List ID", "de": "Listen-ID", "fr": "ID liste"}},
"description": "Listen-ID"},
{"name": "name", "type": "string", "required": True, "frontendType": "text",
"description": {"en": "Task name", "de": "Name", "fr": "Nom"}},
"description": "Name"},
{"name": "description", "type": "string", "required": False, "frontendType": "textarea",
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}},
"description": "Beschreibung"},
{"name": "taskStatus", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Status", "de": "Status", "fr": "Statut"}},
"description": "Status"},
{"name": "taskPriority", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["1", "2", "3", "4"]},
"description": {"en": "Priority 1-4", "de": "Priorität 1-4", "fr": "Priorité 1-4"}},
"description": "Priorität 1-4"},
{"name": "taskDueDateMs", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Due date (Unix ms)", "de": "Fälligkeit (ms)", "fr": "Échéance (ms)"}},
"description": "Fälligkeit (ms)"},
{"name": "taskAssigneeIds", "type": "object", "required": False, "frontendType": "json",
"description": {"en": "Assignee user ids", "de": "Zugewiesene", "fr": "Assignés"}},
"description": "Zugewiesene"},
{"name": "taskTimeEstimateMs", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Time estimate (ms)", "de": "Zeitschätzung (ms)", "fr": "Estimation (ms)"}},
"description": "Zeitschätzung (ms)"},
{"name": "taskTimeEstimateHours", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Time estimate (hours)", "de": "Zeitschätzung (h)", "fr": "Heures"}},
"description": "Zeitschätzung (h)"},
{"name": "customFieldValues", "type": "object", "required": False, "frontendType": "json",
"description": {"en": "Custom fields", "de": "Benutzerdefinierte Felder", "fr": "Champs personnalisés"}},
"description": "Benutzerdefinierte Felder"},
{"name": "taskFields", "type": "string", "required": False, "frontendType": "json",
"description": {"en": "Extra JSON (advanced)", "de": "Zusätzliches JSON", "fr": "JSON avancé"}},
"description": "Zusätzliches JSON"},
],
"inputs": 1,
"outputs": 1,
@ -128,19 +128,19 @@ CLICKUP_NODES = [
{
"id": "clickup.updateTask",
"category": "clickup",
"label": {"en": "Update task", "de": "Aufgabe aktualisieren", "fr": "Mettre à jour la tâche"},
"description": {"en": "Update task fields", "de": "Felder der Aufgabe ändern", "fr": "Mettre à jour les champs"},
"label": "Aufgabe aktualisieren",
"description": "Felder der Aufgabe ändern",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
"description": "ClickUp-Verbindung"},
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}},
"description": "Task-ID"},
{"name": "path", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Or path to task", "de": "Oder Pfad", "fr": "Ou chemin"}},
"description": "Oder Pfad"},
{"name": "taskUpdateEntries", "type": "object", "required": False, "frontendType": "keyValueRows",
"description": {"en": "Fields to update", "de": "Zu ändernde Felder", "fr": "Champs à mettre à jour"}},
"description": "Zu ändernde Felder"},
{"name": "taskUpdate", "type": "string", "required": False, "frontendType": "json",
"description": {"en": "JSON body (advanced)", "de": "JSON für API", "fr": "Corps JSON"}},
"description": "JSON für API"},
],
"inputs": 1,
"outputs": 1,
@ -153,17 +153,17 @@ CLICKUP_NODES = [
{
"id": "clickup.uploadAttachment",
"category": "clickup",
"label": {"en": "Upload attachment", "de": "Anhang hochladen", "fr": "Téléverser pièce jointe"},
"description": {"en": "Upload file to a task", "de": "Datei an Task anhängen", "fr": "Joindre un fichier"},
"label": "Anhang hochladen",
"description": "Datei an Task anhängen",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
"description": "ClickUp-Verbindung"},
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}},
"description": "Task-ID"},
{"name": "path", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Or path to task", "de": "Oder Pfad", "fr": "Ou chemin"}},
"description": "Oder Pfad"},
{"name": "fileName", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "File name", "de": "Dateiname", "fr": "Nom du fichier"}},
"description": "Dateiname"},
],
"inputs": 1,
"outputs": 1,

View file

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

View file

@ -5,23 +5,23 @@ EMAIL_NODES = [
{
"id": "email.checkEmail",
"category": "email",
"label": {"en": "Check Email", "de": "E-Mail prüfen", "fr": "Vérifier email"},
"description": {"en": "Check for new emails", "de": "Neue E-Mails prüfen", "fr": "Vérifier les nouveaux emails"},
"label": "E-Mail prüfen",
"description": "Neue E-Mails prüfen",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "Email account connection", "de": "E-Mail-Konto Verbindung", "fr": "Connexion compte email"}},
"description": "E-Mail-Konto Verbindung"},
{"name": "folder", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Folder (e.g. Inbox)", "de": "Ordner", "fr": "Dossier"}, "default": "Inbox"},
"description": "Ordner", "default": "Inbox"},
{"name": "limit", "type": "number", "required": False, "frontendType": "number",
"description": {"en": "Max emails to fetch", "de": "Max E-Mails", "fr": "Max emails"}, "default": 100},
"description": "Max E-Mails", "default": 100},
{"name": "fromAddress", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Only emails from this address", "de": "Nur von dieser Adresse", "fr": "Seulement de cette adresse"}, "default": ""},
"description": "Nur von dieser Adresse", "default": ""},
{"name": "subjectContains", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Subject must contain", "de": "Betreff muss enthalten", "fr": "Le sujet doit contenir"}, "default": ""},
"description": "Betreff muss enthalten", "default": ""},
{"name": "hasAttachment", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": {"en": "Only with attachments", "de": "Nur mit Anhängen", "fr": "Avec pièces jointes"}, "default": False},
"description": "Nur mit Anhängen", "default": False},
{"name": "filter", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Advanced: raw filter", "de": "Erweitert: Filter-Text", "fr": "Avancé: filtre brut"}, "default": ""},
"description": "Erweitert: Filter-Text", "default": ""},
],
"inputs": 1,
"outputs": 1,
@ -34,29 +34,29 @@ EMAIL_NODES = [
{
"id": "email.searchEmail",
"category": "email",
"label": {"en": "Search Email", "de": "E-Mail suchen", "fr": "Rechercher email"},
"description": {"en": "Search or find emails", "de": "E-Mails suchen", "fr": "Rechercher des emails"},
"label": "E-Mail suchen",
"description": "E-Mails suchen",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "Email account connection", "de": "E-Mail-Konto Verbindung", "fr": "Connexion compte email"}},
"description": "E-Mail-Konto Verbindung"},
{"name": "query", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Search term", "de": "Suchbegriff", "fr": "Terme de recherche"}, "default": ""},
"description": "Suchbegriff", "default": ""},
{"name": "folder", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Folder to search", "de": "Ordner", "fr": "Dossier"}, "default": "Inbox"},
"description": "Ordner", "default": "Inbox"},
{"name": "limit", "type": "number", "required": False, "frontendType": "number",
"description": {"en": "Max emails", "de": "Max E-Mails", "fr": "Max emails"}, "default": 100},
"description": "Max E-Mails", "default": 100},
{"name": "fromAddress", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "From address", "de": "Von Adresse", "fr": "De l'adresse"}, "default": ""},
"description": "Von Adresse", "default": ""},
{"name": "toAddress", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "To address", "de": "An Adresse", "fr": "À l'adresse"}, "default": ""},
"description": "An Adresse", "default": ""},
{"name": "subjectContains", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Subject contains", "de": "Betreff enthält", "fr": "Sujet contient"}, "default": ""},
"description": "Betreff enthält", "default": ""},
{"name": "bodyContains", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Body contains", "de": "Inhalt enthält", "fr": "Corps contient"}, "default": ""},
"description": "Inhalt enthält", "default": ""},
{"name": "hasAttachment", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": {"en": "With attachments", "de": "Mit Anhängen", "fr": "Avec pièces jointes"}, "default": False},
"description": "Mit Anhängen", "default": False},
{"name": "filter", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Advanced: raw KQL", "de": "Erweitert: KQL-Filter", "fr": "Avancé: filtre KQL"}, "default": ""},
"description": "Erweitert: KQL-Filter", "default": ""},
],
"inputs": 1,
"outputs": 1,
@ -69,17 +69,17 @@ EMAIL_NODES = [
{
"id": "email.draftEmail",
"category": "email",
"label": {"en": "Draft Email", "de": "E-Mail entwerfen", "fr": "Brouillon email"},
"description": {"en": "Create a draft email", "de": "E-Mail-Entwurf erstellen", "fr": "Créer un brouillon"},
"label": "E-Mail entwerfen",
"description": "E-Mail-Entwurf erstellen",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "Email account", "de": "E-Mail-Konto", "fr": "Compte email"}},
"description": "E-Mail-Konto"},
{"name": "subject", "type": "string", "required": True, "frontendType": "text",
"description": {"en": "Subject", "de": "Betreff", "fr": "Sujet"}},
"description": "Betreff"},
{"name": "body", "type": "string", "required": True, "frontendType": "textarea",
"description": {"en": "Body", "de": "Inhalt", "fr": "Corps"}},
"description": "Inhalt"},
{"name": "to", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Recipient(s)", "de": "Empfänger", "fr": "Destinataire(s)"}, "default": ""},
"description": "Empfänger", "default": ""},
],
"inputs": 1,
"outputs": 1,

View file

@ -5,26 +5,22 @@ FILE_NODES = [
{
"id": "file.create",
"category": "file",
"label": {"en": "Create File", "de": "Datei erstellen", "fr": "Créer fichier"},
"description": {
"en": "Create a file from context (text/markdown from AI).",
"de": "Erstellt eine Datei aus Kontext (Text/Markdown von KI).",
"fr": "Crée un fichier à partir du contexte.",
},
"label": "Datei erstellen",
"description": "Erstellt eine Datei aus Kontext (Text/Markdown von KI).",
"parameters": [
{"name": "contentSources", "type": "json", "required": False, "frontendType": "json",
"description": {"en": "Context source refs", "de": "Kontext-Quellen", "fr": "Sources de contexte"}, "default": []},
"description": "Kontext-Quellen", "default": []},
{"name": "outputFormat", "type": "string", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]},
"description": {"en": "Output format", "de": "Ausgabeformat", "fr": "Format de sortie"}, "default": "docx"},
"description": "Ausgabeformat", "default": "docx"},
{"name": "title", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Document title", "de": "Dokumenttitel", "fr": "Titre du document"}},
"description": "Dokumenttitel"},
{"name": "templateName", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["default", "corporate", "minimal"]},
"description": {"en": "Style preset", "de": "Stil-Vorlage", "fr": "Prését style"}},
"description": "Stil-Vorlage"},
{"name": "language", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["de", "en", "fr"]},
"description": {"en": "Language", "de": "Sprache", "fr": "Langue"}, "default": "de"},
"description": "Sprache", "default": "de"},
],
"inputs": 1,
"outputs": 1,

View file

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

View file

@ -5,19 +5,15 @@ INPUT_NODES = [
{
"id": "input.form",
"category": "input",
"label": {"en": "Form", "de": "Formular", "fr": "Formulaire"},
"description": {"en": "User fills out a form", "de": "Benutzer füllt ein Formular aus", "fr": "L'utilisateur remplit un formulaire"},
"label": "Formular",
"description": "Benutzer füllt ein Formular aus",
"parameters": [
{
"name": "fields",
"type": "json",
"required": True,
"frontendType": "fieldBuilder",
"description": {
"en": "Form fields: [{name, type, label, required, options?}]",
"de": "Formularfelder",
"fr": "Champs du formulaire",
},
"description": "Formularfelder",
"default": [],
},
],
@ -31,16 +27,16 @@ INPUT_NODES = [
{
"id": "input.approval",
"category": "input",
"label": {"en": "Approval", "de": "Genehmigung", "fr": "Approbation"},
"description": {"en": "User approves or rejects", "de": "Benutzer genehmigt oder lehnt ab", "fr": "L'utilisateur approuve ou rejette"},
"label": "Genehmigung",
"description": "Benutzer genehmigt oder lehnt ab",
"parameters": [
{"name": "title", "type": "string", "required": True, "frontendType": "text",
"description": {"en": "Approval title", "de": "Genehmigungstitel", "fr": "Titre"}},
"description": "Genehmigungstitel"},
{"name": "description", "type": "string", "required": False, "frontendType": "textarea",
"description": {"en": "What to approve", "de": "Was genehmigt werden soll", "fr": "Ce qu'il faut approuver"}},
"description": "Was genehmigt werden soll"},
{"name": "approvalType", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["generic", "document"]},
"description": {"en": "Type: document or generic", "de": "Typ: document oder generic", "fr": "Type"}, "default": "generic"},
"description": "Typ: document oder generic", "default": "generic"},
],
"inputs": 1,
"outputs": 1,
@ -52,18 +48,18 @@ INPUT_NODES = [
{
"id": "input.upload",
"category": "input",
"label": {"en": "Upload", "de": "Upload", "fr": "Téléversement"},
"description": {"en": "User uploads file(s)", "de": "Benutzer lädt Datei(en) hoch", "fr": "L'utilisateur téléverse des fichiers"},
"label": "Upload",
"description": "Benutzer lädt Datei(en) hoch",
"parameters": [
{"name": "accept", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Accept string for file input (e.g. .pdf,image/*)", "de": "Accept-String", "fr": "Chaîne accept"}, "default": ""},
"description": "Accept-String", "default": ""},
{"name": "allowedTypes", "type": "json", "required": False, "frontendType": "multiselect",
"frontendOptions": {"options": ["pdf", "docx", "xlsx", "pptx", "txt", "csv", "jpg", "png", "gif"]},
"description": {"en": "Selected file types", "de": "Ausgewählte Dateitypen", "fr": "Types sélectionnés"}, "default": []},
"description": "Ausgewählte Dateitypen", "default": []},
{"name": "maxSize", "type": "number", "required": False, "frontendType": "number",
"description": {"en": "Max file size in MB", "de": "Max. Dateigröße in MB", "fr": "Taille max en Mo"}, "default": 10},
"description": "Max. Dateigröße in MB", "default": 10},
{"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": {"en": "Allow multiple files", "de": "Mehrere Dateien erlauben", "fr": "Autoriser plusieurs fichiers"}, "default": False},
"description": "Mehrere Dateien erlauben", "default": False},
],
"inputs": 1,
"outputs": 1,
@ -75,13 +71,13 @@ INPUT_NODES = [
{
"id": "input.comment",
"category": "input",
"label": {"en": "Comment", "de": "Kommentar", "fr": "Commentaire"},
"description": {"en": "User adds a comment", "de": "Benutzer fügt einen Kommentar hinzu", "fr": "L'utilisateur ajoute un commentaire"},
"label": "Kommentar",
"description": "Benutzer fügt einen Kommentar hinzu",
"parameters": [
{"name": "placeholder", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Placeholder text", "de": "Platzhalter", "fr": "Texte indicatif"}, "default": ""},
"description": "Platzhalter", "default": ""},
{"name": "required", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": {"en": "Comment required", "de": "Kommentar erforderlich", "fr": "Commentaire requis"}, "default": True},
"description": "Kommentar erforderlich", "default": True},
],
"inputs": 1,
"outputs": 1,
@ -93,14 +89,14 @@ INPUT_NODES = [
{
"id": "input.review",
"category": "input",
"label": {"en": "Review", "de": "Prüfung", "fr": "Revue"},
"description": {"en": "User reviews content", "de": "Benutzer prüft Inhalt", "fr": "L'utilisateur révise le contenu"},
"label": "Prüfung",
"description": "Benutzer prüft Inhalt",
"parameters": [
{"name": "contentRef", "type": "string", "required": True, "frontendType": "text",
"description": {"en": "Reference to content", "de": "Referenz auf Inhalt", "fr": "Référence au contenu"}},
"description": "Referenz auf Inhalt"},
{"name": "reviewType", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["generic", "document"]},
"description": {"en": "Type of review", "de": "Art der Prüfung", "fr": "Type de revue"}, "default": "generic"},
"description": "Art der Prüfung", "default": "generic"},
],
"inputs": 1,
"outputs": 1,
@ -112,13 +108,13 @@ INPUT_NODES = [
{
"id": "input.selection",
"category": "input",
"label": {"en": "Selection", "de": "Auswahl", "fr": "Sélection"},
"description": {"en": "User selects from options", "de": "Benutzer wählt aus Optionen", "fr": "L'utilisateur choisit parmi les options"},
"label": "Auswahl",
"description": "Benutzer wählt aus Optionen",
"parameters": [
{"name": "options", "type": "json", "required": True, "frontendType": "keyValueRows",
"description": {"en": "Options: [{value, label}]", "de": "Optionen", "fr": "Options"}, "default": []},
"description": "Optionen", "default": []},
{"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": {"en": "Allow multiple selection", "de": "Mehrfachauswahl erlauben", "fr": "Sélection multiple"}, "default": False},
"description": "Mehrfachauswahl erlauben", "default": False},
],
"inputs": 1,
"outputs": 1,
@ -130,15 +126,15 @@ INPUT_NODES = [
{
"id": "input.confirmation",
"category": "input",
"label": {"en": "Confirmation", "de": "Bestätigung", "fr": "Confirmation"},
"description": {"en": "User confirms yes/no", "de": "Benutzer bestätigt Ja/Nein", "fr": "L'utilisateur confirme oui/non"},
"label": "Bestätigung",
"description": "Benutzer bestätigt Ja/Nein",
"parameters": [
{"name": "question", "type": "string", "required": True, "frontendType": "text",
"description": {"en": "Question to confirm", "de": "Zu bestätigende Frage", "fr": "Question à confirmer"}},
"description": "Zu bestätigende Frage"},
{"name": "confirmLabel", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Label for confirm button", "de": "Label für Bestätigen-Button", "fr": "Libellé confirmer"}, "default": "Confirm"},
"description": "Label für Bestätigen-Button", "default": "Confirm"},
{"name": "rejectLabel", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Label for reject button", "de": "Label für Ablehnen-Button", "fr": "Libellé refuser"}, "default": "Reject"},
"description": "Label für Ablehnen-Button", "default": "Reject"},
],
"inputs": 1,
"outputs": 1,

View file

@ -5,17 +5,17 @@ SHAREPOINT_NODES = [
{
"id": "sharepoint.findFile",
"category": "sharepoint",
"label": {"en": "Find File", "de": "Datei finden", "fr": "Trouver fichier"},
"description": {"en": "Find file by path or search", "de": "Datei nach Pfad oder Suche finden", "fr": "Trouver fichier par chemin ou recherche"},
"label": "Datei finden",
"description": "Datei nach Pfad oder Suche finden",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
"description": "SharePoint-Verbindung"},
{"name": "searchQuery", "type": "string", "required": True, "frontendType": "text",
"description": {"en": "Search query or path", "de": "Suchanfrage oder Pfad", "fr": "Requête ou chemin"}},
"description": "Suchanfrage oder Pfad"},
{"name": "site", "type": "string", "required": False, "frontendType": "text",
"description": {"en": "Optional site hint", "de": "Optionaler Site-Hinweis", "fr": "Indication de site"}, "default": ""},
"description": "Optionaler Site-Hinweis", "default": ""},
{"name": "maxResults", "type": "number", "required": False, "frontendType": "number",
"description": {"en": "Max results", "de": "Max Ergebnisse", "fr": "Max résultats"}, "default": 1000},
"description": "Max Ergebnisse", "default": 1000},
],
"inputs": 1,
"outputs": 1,
@ -28,14 +28,14 @@ SHAREPOINT_NODES = [
{
"id": "sharepoint.readFile",
"category": "sharepoint",
"label": {"en": "Read File", "de": "Datei lesen", "fr": "Lire fichier"},
"description": {"en": "Extract content from file", "de": "Inhalt aus Datei extrahieren", "fr": "Extraire le contenu du fichier"},
"label": "Datei lesen",
"description": "Inhalt aus Datei extrahieren",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
"description": "SharePoint-Verbindung"},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "File path", "de": "Dateipfad", "fr": "Chemin"}},
"description": "Dateipfad"},
],
"inputs": 1,
"outputs": 1,
@ -48,14 +48,14 @@ SHAREPOINT_NODES = [
{
"id": "sharepoint.uploadFile",
"category": "sharepoint",
"label": {"en": "Upload File", "de": "Datei hochladen", "fr": "Téléverser fichier"},
"description": {"en": "Upload file to SharePoint", "de": "Datei zu SharePoint hochladen", "fr": "Téléverser fichier vers SharePoint"},
"label": "Datei hochladen",
"description": "Datei zu SharePoint hochladen",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
"description": "SharePoint-Verbindung"},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Target folder path", "de": "Zielordner-Pfad", "fr": "Chemin du dossier cible"}},
"description": "Zielordner-Pfad"},
],
"inputs": 1,
"outputs": 1,
@ -68,14 +68,14 @@ SHAREPOINT_NODES = [
{
"id": "sharepoint.listFiles",
"category": "sharepoint",
"label": {"en": "List Files", "de": "Dateien auflisten", "fr": "Lister fichiers"},
"description": {"en": "List files in folder", "de": "Dateien in Ordner auflisten", "fr": "Lister les fichiers"},
"label": "Dateien auflisten",
"description": "Dateien in Ordner auflisten",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
"description": "SharePoint-Verbindung"},
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Folder path", "de": "Ordnerpfad", "fr": "Chemin du dossier"}, "default": "/"},
"description": "Ordnerpfad", "default": "/"},
],
"inputs": 1,
"outputs": 1,
@ -88,14 +88,14 @@ SHAREPOINT_NODES = [
{
"id": "sharepoint.downloadFile",
"category": "sharepoint",
"label": {"en": "Download File", "de": "Datei herunterladen", "fr": "Télécharger fichier"},
"description": {"en": "Download file from path", "de": "Datei vom Pfad herunterladen", "fr": "Télécharger le fichier"},
"label": "Datei herunterladen",
"description": "Datei vom Pfad herunterladen",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
"description": "SharePoint-Verbindung"},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Full file path", "de": "Vollständiger Dateipfad", "fr": "Chemin complet du fichier"}},
"description": "Vollständiger Dateipfad"},
],
"inputs": 1,
"outputs": 1,
@ -108,17 +108,17 @@ SHAREPOINT_NODES = [
{
"id": "sharepoint.copyFile",
"category": "sharepoint",
"label": {"en": "Copy File", "de": "Datei kopieren", "fr": "Copier fichier"},
"description": {"en": "Copy file to destination", "de": "Datei an Ziel kopieren", "fr": "Copier le fichier"},
"label": "Datei kopieren",
"description": "Datei an Ziel kopieren",
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
"description": "SharePoint-Verbindung"},
{"name": "sourcePath", "type": "string", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Source file path", "de": "Quelldatei-Pfad", "fr": "Chemin fichier source"}},
"description": "Quelldatei-Pfad"},
{"name": "destPath", "type": "string", "required": True, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "Destination folder", "de": "Zielordner", "fr": "Dossier cible"}},
"description": "Zielordner"},
],
"inputs": 1,
"outputs": 1,

View file

@ -5,12 +5,8 @@ TRIGGER_NODES = [
{
"id": "trigger.manual",
"category": "trigger",
"label": {"en": "Start", "de": "Start", "fr": "Départ"},
"description": {
"en": "Manual, API, or background triggers (webhook, email, …).",
"de": "Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …).",
"fr": "Manuel, API ou déclencheurs en arrière-plan.",
},
"label": "Start",
"description": "Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …).",
"parameters": [],
"inputs": 0,
"outputs": 1,
@ -22,19 +18,15 @@ TRIGGER_NODES = [
{
"id": "trigger.form",
"category": "trigger",
"label": {"en": "Start (form)", "de": "Start (Formular)", "fr": "Départ (formulaire)"},
"description": {
"en": "Form fields are filled at run time; configure fields on this node.",
"de": "Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node.",
"fr": "Les champs sont remplis au démarrage.",
},
"label": "Start (Formular)",
"description": "Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node.",
"parameters": [
{
"name": "formFields",
"type": "json",
"required": False,
"frontendType": "fieldBuilder",
"description": {"en": "Field definitions", "de": "Felddefinitionen", "fr": "Définitions"},
"description": "Felddefinitionen",
},
],
"inputs": 0,
@ -47,19 +39,15 @@ TRIGGER_NODES = [
{
"id": "trigger.schedule",
"category": "trigger",
"label": {"en": "Start (schedule)", "de": "Start (Zeitplan)", "fr": "Départ (planification)"},
"description": {
"en": "Cron expression for scheduled runs (configure on this node).",
"de": "Cron-Ausdruck für geplante Läufe.",
"fr": "Expression cron pour les exécutions planifiées.",
},
"label": "Start (Zeitplan)",
"description": "Cron-Ausdruck für geplante Läufe.",
"parameters": [
{
"name": "cron",
"type": "string",
"required": False,
"frontendType": "cron",
"description": {"en": "Cron expression", "de": "Cron-Ausdruck", "fr": "Expression cron"},
"description": "Cron-Ausdruck",
},
],
"inputs": 0,

View file

@ -5,21 +5,17 @@ TRUSTEE_NODES = [
{
"id": "trustee.refreshAccountingData",
"category": "trustee",
"label": {"en": "Refresh Accounting Data", "de": "Buchhaltungsdaten aktualisieren", "fr": "Actualiser données comptables"},
"description": {
"en": "Import/refresh accounting data from external system (e.g. Abacus).",
"de": "Buchhaltungsdaten aus externem System importieren/aktualisieren.",
"fr": "Importer/actualiser les données comptables.",
},
"label": "Buchhaltungsdaten aktualisieren",
"description": "Buchhaltungsdaten aus externem System importieren/aktualisieren.",
"parameters": [
{"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
"description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}},
"description": "Trustee Feature-Instanz-ID"},
{"name": "forceRefresh", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": {"en": "Force re-import", "de": "Import erzwingen", "fr": "Forcer la réimportation"}, "default": False},
"description": "Import erzwingen", "default": False},
{"name": "dateFrom", "type": "string", "required": False, "frontendType": "date",
"description": {"en": "Start date (YYYY-MM-DD)", "de": "Startdatum", "fr": "Date début"}, "default": ""},
"description": "Startdatum", "default": ""},
{"name": "dateTo", "type": "string", "required": False, "frontendType": "date",
"description": {"en": "End date (YYYY-MM-DD)", "de": "Enddatum", "fr": "Date fin"}, "default": ""},
"description": "Enddatum", "default": ""},
],
"inputs": 1,
"outputs": 1,
@ -32,22 +28,18 @@ TRUSTEE_NODES = [
{
"id": "trustee.extractFromFiles",
"category": "trustee",
"label": {"en": "Extract Documents", "de": "Dokumente extrahieren", "fr": "Extraire documents"},
"description": {
"en": "Extract document type and data from PDF/JPG via AI.",
"de": "Dokumenttyp und Daten aus PDF/JPG per AI extrahieren.",
"fr": "Extraire type et données de PDF/JPG par IA.",
},
"label": "Dokumente extrahieren",
"description": "Dokumenttyp und Daten aus PDF/JPG per AI extrahieren.",
"parameters": [
{"name": "connectionReference", "type": "string", "required": False, "frontendType": "userConnection",
"description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}, "default": ""},
"description": "SharePoint-Verbindung", "default": ""},
{"name": "sharepointFolder", "type": "string", "required": False, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": {"en": "SharePoint folder path", "de": "SharePoint-Ordnerpfad", "fr": "Chemin dossier SharePoint"}, "default": ""},
"description": "SharePoint-Ordnerpfad", "default": ""},
{"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
"description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}},
"description": "Trustee Feature-Instanz-ID"},
{"name": "prompt", "type": "string", "required": False, "frontendType": "textarea",
"description": {"en": "AI prompt for extraction", "de": "AI-Prompt für Extraktion", "fr": "Prompt IA"}, "default": ""},
"description": "AI-Prompt für Extraktion", "default": ""},
],
"inputs": 1,
"outputs": 1,
@ -60,17 +52,13 @@ TRUSTEE_NODES = [
{
"id": "trustee.processDocuments",
"category": "trustee",
"label": {"en": "Process Documents", "de": "Dokumente verarbeiten", "fr": "Traiter documents"},
"description": {
"en": "Create TrusteeDocument + TrusteePosition from extraction result.",
"de": "TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen.",
"fr": "Créer TrusteeDocument + TrusteePosition.",
},
"label": "Dokumente verarbeiten",
"description": "TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen.",
"parameters": [
{"name": "documentList", "type": "string", "required": True, "frontendType": "text",
"description": {"en": "Reference to extraction result", "de": "Referenz auf Ergebnis", "fr": "Référence au résultat"}},
"description": "Referenz auf Ergebnis"},
{"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
"description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}},
"description": "Trustee Feature-Instanz-ID"},
],
"inputs": 1,
"outputs": 1,
@ -83,17 +71,13 @@ TRUSTEE_NODES = [
{
"id": "trustee.syncToAccounting",
"category": "trustee",
"label": {"en": "Sync to Accounting", "de": "In Buchhaltung synchronisieren", "fr": "Synchroniser comptabilité"},
"description": {
"en": "Push trustee positions to accounting system.",
"de": "Trustee-Positionen in Buchhaltungssystem übertragen.",
"fr": "Transférer les positions vers la comptabilité.",
},
"label": "In Buchhaltung synchronisieren",
"description": "Trustee-Positionen in Buchhaltungssystem übertragen.",
"parameters": [
{"name": "documentList", "type": "string", "required": True, "frontendType": "text",
"description": {"en": "Reference to processed documents", "de": "Referenz auf Ergebnis", "fr": "Référence au résultat"}},
"description": "Referenz auf Ergebnis"},
{"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
"description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}},
"description": "Trustee Feature-Instanz-ID"},
],
"inputs": 1,
"outputs": 1,

View file

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

View file

@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
class PortField(BaseModel):
name: str
type: str # str, int, bool, List[str], List[Document], Dict[str,Any]
description: Dict[str, str] = {} # {en, de, fr}
description: str = ""
required: bool = True
@ -57,97 +57,97 @@ class OutputPortDef(BaseModel):
PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
"DocumentList": PortSchema(name="DocumentList", fields=[
PortField(name="documents", type="List[Document]",
description={"en": "List of documents", "de": "Dokumentenliste", "fr": "Liste de documents"}),
description="Dokumentenliste"),
]),
"FileList": PortSchema(name="FileList", fields=[
PortField(name="files", type="List[File]",
description={"en": "List of files", "de": "Dateiliste", "fr": "Liste de fichiers"}),
description="Dateiliste"),
]),
"EmailDraft": PortSchema(name="EmailDraft", fields=[
PortField(name="subject", type="str",
description={"en": "Subject", "de": "Betreff", "fr": "Sujet"}),
description="Betreff"),
PortField(name="body", type="str",
description={"en": "Body", "de": "Inhalt", "fr": "Corps"}),
description="Inhalt"),
PortField(name="to", type="List[str]",
description={"en": "Recipients", "de": "Empfänger", "fr": "Destinataires"}),
description="Empfänger"),
PortField(name="cc", type="List[str]", required=False,
description={"en": "CC", "de": "CC", "fr": "CC"}),
description="CC"),
PortField(name="attachments", type="List[Document]", required=False,
description={"en": "Attachments", "de": "Anhänge", "fr": "Pièces jointes"}),
description="Anhänge"),
]),
"EmailList": PortSchema(name="EmailList", fields=[
PortField(name="emails", type="List[Email]",
description={"en": "Emails", "de": "E-Mails", "fr": "Emails"}),
description="E-Mails"),
]),
"TaskList": PortSchema(name="TaskList", fields=[
PortField(name="tasks", type="List[Task]",
description={"en": "Tasks", "de": "Aufgaben", "fr": "Tâches"}),
description="Aufgaben"),
]),
"TaskResult": PortSchema(name="TaskResult", fields=[
PortField(name="success", type="bool",
description={"en": "Success", "de": "Erfolg", "fr": "Succès"}),
description="Erfolg"),
PortField(name="taskId", type="str",
description={"en": "Task ID", "de": "Aufgaben-ID", "fr": "ID tâche"}),
description="Aufgaben-ID"),
PortField(name="task", type="Dict",
description={"en": "Task data", "de": "Aufgabendaten", "fr": "Données tâche"}),
description="Aufgabendaten"),
]),
"FormPayload": PortSchema(name="FormPayload", fields=[
PortField(name="payload", type="Dict[str,Any]",
description={"en": "Form data", "de": "Formulardaten", "fr": "Données formulaire"}),
description="Formulardaten"),
]),
"AiResult": PortSchema(name="AiResult", fields=[
PortField(name="prompt", type="str",
description={"en": "Prompt", "de": "Prompt", "fr": "Invite"}),
description="Prompt"),
PortField(name="response", type="str",
description={"en": "Response text", "de": "Antworttext", "fr": "Texte réponse"}),
description="Antworttext"),
PortField(name="responseData", type="Dict", required=False,
description={"en": "Structured response", "de": "Strukturierte Antwort", "fr": "Réponse structurée"}),
description="Strukturierte Antwort"),
PortField(name="context", type="str",
description={"en": "Context", "de": "Kontext", "fr": "Contexte"}),
description="Kontext"),
PortField(name="documents", type="List[Document]",
description={"en": "Documents", "de": "Dokumente", "fr": "Documents"}),
description="Dokumente"),
]),
"BoolResult": PortSchema(name="BoolResult", fields=[
PortField(name="result", type="bool",
description={"en": "Result", "de": "Ergebnis", "fr": "Résultat"}),
description="Ergebnis"),
PortField(name="reason", type="str", required=False,
description={"en": "Reason", "de": "Begründung", "fr": "Raison"}),
description="Begründung"),
]),
"TextResult": PortSchema(name="TextResult", fields=[
PortField(name="text", type="str",
description={"en": "Text", "de": "Text", "fr": "Texte"}),
description="Text"),
]),
"LoopItem": PortSchema(name="LoopItem", fields=[
PortField(name="currentItem", type="Any",
description={"en": "Current item", "de": "Aktuelles Element", "fr": "Élément courant"}),
description="Aktuelles Element"),
PortField(name="currentIndex", type="int",
description={"en": "Current index", "de": "Aktueller Index", "fr": "Index courant"}),
description="Aktueller Index"),
PortField(name="items", type="List[Any]",
description={"en": "All items", "de": "Alle Elemente", "fr": "Tous les éléments"}),
description="Alle Elemente"),
PortField(name="count", type="int",
description={"en": "Total count", "de": "Gesamtanzahl", "fr": "Nombre total"}),
description="Gesamtanzahl"),
]),
"AggregateResult": PortSchema(name="AggregateResult", fields=[
PortField(name="items", type="List[Any]",
description={"en": "Collected items", "de": "Gesammelte Elemente", "fr": "Éléments collectés"}),
description="Gesammelte Elemente"),
PortField(name="count", type="int",
description={"en": "Count", "de": "Anzahl", "fr": "Nombre"}),
description="Anzahl"),
]),
"MergeResult": PortSchema(name="MergeResult", fields=[
PortField(name="inputs", type="Dict[int,Any]",
description={"en": "Inputs by port", "de": "Eingaben nach Port", "fr": "Entrées par port"}),
description="Eingaben nach Port"),
PortField(name="first", type="Any",
description={"en": "First available", "de": "Erstes verfügbares", "fr": "Premier disponible"}),
description="Erstes verfügbares"),
PortField(name="merged", type="Dict",
description={"en": "Merged data", "de": "Zusammengeführte Daten", "fr": "Données fusionnées"}),
description="Zusammengeführte Daten"),
]),
"ActionResult": PortSchema(name="ActionResult", fields=[
PortField(name="success", type="bool",
description={"en": "Success", "de": "Erfolg", "fr": "Succès"}),
description="Erfolg"),
PortField(name="error", type="str", required=False,
description={"en": "Error", "de": "Fehler", "fr": "Erreur"}),
description="Fehler"),
PortField(name="data", type="Dict", required=False,
description={"en": "Result data", "de": "Ergebnisdaten", "fr": "Données résultat"}),
description="Ergebnisdaten"),
]),
"Transit": PortSchema(name="Transit", fields=[]),
}
@ -479,10 +479,16 @@ def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
portFields = []
for f in fields_param:
if isinstance(f, dict) and f.get("name"):
_lab = f.get("label")
_desc = (
str(_lab.get("de") or _lab.get("en") or f["name"])
if isinstance(_lab, dict)
else str(_lab if _lab is not None else f["name"])
)
portFields.append(PortField(
name=f["name"],
type=f.get("type", "str"),
description=f.get("label", {}) if isinstance(f.get("label"), dict) else {"en": str(f.get("label", f["name"]))},
description=_desc,
required=f.get("required", False),
))
return PortSchema(name="FormPayload_dynamic", fields=portFields) if portFields else None
@ -499,6 +505,6 @@ def _deriveTransformSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
portFields.append(PortField(
name=m["outputField"],
type=m.get("type", "str"),
description={"en": m.get("label", m["outputField"])},
description=str(m.get("label", m["outputField"])),
))
return PortSchema(name="Transform_dynamic", fields=portFields) if portFields else None

View file

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

View file

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

View file

@ -12,14 +12,14 @@ logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "neutralization"
FEATURE_LABEL = {"en": "Neutralization", "de": "Neutralisierung", "fr": "Neutralisation"}
FEATURE_LABEL = "Neutralisierung"
FEATURE_ICON = "mdi-shield-check"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.neutralization.playground",
"label": {"en": "Playground", "de": "Spielwiese", "fr": "Bac à sable"},
"label": "Spielwiese",
"meta": {"area": "playground"}
}
]
@ -28,17 +28,17 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.neutralization.process.text",
"label": {"en": "Process Text", "de": "Text verarbeiten", "fr": "Traiter texte"},
"label": "Text verarbeiten",
"meta": {"endpoint": "/api/neutralization/process/text", "method": "POST"}
},
{
"objectKey": "resource.feature.neutralization.process.files",
"label": {"en": "Process Files", "de": "Dateien verarbeiten", "fr": "Traiter fichiers"},
"label": "Dateien verarbeiten",
"meta": {"endpoint": "/api/neutralization/process/files", "method": "POST"}
},
{
"objectKey": "resource.feature.neutralization.config.update",
"label": {"en": "Update Config", "de": "Konfiguration aktualisieren", "fr": "Mettre à jour config"},
"label": "Konfiguration aktualisieren",
"meta": {"endpoint": "/api/neutralization/config", "method": "PUT"}
},
]
@ -47,11 +47,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [
{
"roleLabel": "neutralization-viewer",
"description": {
"en": "Neutralization Viewer - View neutralization data (read-only)",
"de": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)",
"fr": "Visualiseur neutralisation - Consulter les données de neutralisation (lecture seule)",
},
"description": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
@ -59,11 +55,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "neutralization-user",
"description": {
"en": "Neutralization User - Use neutralization tools and manage own data",
"de": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten",
"fr": "Utilisateur neutralisation - Utiliser les outils et gérer ses propres données",
},
"description": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
@ -72,11 +64,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "neutralization-admin",
"description": {
"en": "Neutralization Administrator - Full access to neutralization settings and data",
"de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
"fr": "Administrateur neutralisation - Accès complet aux paramètres et données",
},
"description": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
@ -84,11 +72,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "neutralization-analyst",
"description": {
"en": "Neutralization Analyst - Analyze and process neutralization data",
"de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
"fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation",
},
"description": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
"accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
@ -163,7 +147,8 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
@ -180,7 +165,7 @@ def _syncTemplateRolesToDb() -> int:
else:
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,

View file

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

View file

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

View file

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

View file

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

View file

@ -10,14 +10,14 @@ import logging
# Feature metadata for RBAC catalog
FEATURE_CODE = "realestate"
FEATURE_LABEL = {"en": "Real Estate", "de": "Immobilien", "fr": "Immobilier"}
FEATURE_LABEL = "Immobilien"
FEATURE_ICON = "mdi-home-city"
# UI Objects for RBAC catalog (only map view)
UI_OBJECTS = [
{
"objectKey": "ui.feature.realestate.dashboard",
"label": {"en": "Map", "de": "Karte", "fr": "Carte"},
"label": "Karte",
"meta": {"area": "dashboard"}
},
]
@ -26,12 +26,12 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.realestate.project.create",
"label": {"en": "Create Project", "de": "Projekt erstellen", "fr": "Créer projet"},
"label": "Projekt erstellen",
"meta": {"endpoint": "/api/realestate/project", "method": "POST"}
},
{
"objectKey": "resource.feature.realestate.project.delete",
"label": {"en": "Delete Project", "de": "Projekt löschen", "fr": "Supprimer projet"},
"label": "Projekt löschen",
"meta": {"endpoint": "/api/realestate/project/{projectId}", "method": "DELETE"}
},
]
@ -41,11 +41,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [
{
"roleLabel": "realestate-viewer",
"description": {
"en": "Real Estate Viewer - View property information (read-only)",
"de": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)",
"fr": "Visualiseur immobilier - Consulter les informations immobilières (lecture seule)",
},
"description": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
@ -53,11 +49,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "realestate-user",
"description": {
"en": "Real Estate User - Create and manage own property records",
"de": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten",
"fr": "Utilisateur immobilier - Créer et gérer ses propres données immobilières",
},
"description": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
@ -66,11 +58,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "realestate-admin",
"description": {
"en": "Real Estate Administrator - Full access to all property data and settings",
"de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
"fr": "Administrateur immobilier - Accès complet aux données et paramètres",
},
"description": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
@ -80,11 +68,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "realestate-manager",
"description": {
"en": "Real Estate Manager - Manage properties and tenants",
"de": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
"fr": "Gestionnaire immobilier - Gérer les propriétés et locataires",
},
"description": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
@ -154,6 +138,7 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
db = rootInterface.db
@ -174,7 +159,7 @@ def _syncTemplateRolesToDb() -> int:
else:
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,

View file

@ -59,6 +59,8 @@ from modules.aicore.aicorePluginTavily import AiTavily
# Import attribute utilities for model schema
from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureRealEstate")
# Configure logger
logger = logging.getLogger(__name__)
@ -339,7 +341,7 @@ def update_project(
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
updated = interface.updateProjekt(projectId, data)
if not updated:
raise HTTPException(status_code=500, detail="Update failed")
raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
return updated
@ -360,7 +362,7 @@ def delete_project(
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
if not interface.deleteProjekt(projectId):
raise HTTPException(status_code=500, detail="Delete failed")
raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
# ----- Parcels CRUD -----
@ -496,7 +498,7 @@ def update_parcel(
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
updated = interface.updateParzelle(parcelId, data)
if not updated:
raise HTTPException(status_code=500, detail="Update failed")
raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
return updated
@ -517,7 +519,7 @@ def delete_parcel(
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
if not interface.deleteParzelle(parcelId):
raise HTTPException(status_code=500, detail="Delete failed")
raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
# ===== Helpers for Gemeinde/BZO routes =====
@ -885,7 +887,7 @@ async def process_command(
logger.warning(f"CSRF token missing for POST /api/realestate/command from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -893,7 +895,7 @@ async def process_command(
logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -903,7 +905,7 @@ async def process_command(
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Processing command request from user {context.user.id} (mandate: {context.mandateId})")
@ -957,7 +959,7 @@ def get_available_tables(
logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -965,7 +967,7 @@ def get_available_tables(
logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -975,7 +977,7 @@ def get_available_tables(
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Getting available tables for user {context.user.id} (mandate: {context.mandateId})")
@ -1066,7 +1068,7 @@ def get_table_data(
logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -1074,7 +1076,7 @@ def get_table_data(
logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -1084,7 +1086,7 @@ def get_table_data(
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Getting table data for '{table}' from user {context.user.id} (mandate: {context.mandateId})")
@ -1235,7 +1237,7 @@ async def create_table_record(
logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -1243,7 +1245,7 @@ async def create_table_record(
logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -1253,7 +1255,7 @@ async def create_table_record(
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Special handling for Projekt with parcel data
@ -1265,7 +1267,7 @@ async def create_table_record(
if not label:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="label is required"
detail=routeApiMsg("label is required")
)
status_prozess = data.get("statusProzess", "Eingang")
@ -1278,7 +1280,7 @@ async def create_table_record(
if not isinstance(parzellen_data, list):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="parzellen must be an array"
detail=routeApiMsg("parzellen must be an array")
)
elif "parzelle" in data:
# Single parcel
@ -1289,7 +1291,7 @@ async def create_table_record(
if not parzellen_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="parzelle or parzellen data is required"
detail=routeApiMsg("parzelle or parzellen data is required")
)
# Use helper function to create project with parcel data
@ -1402,7 +1404,7 @@ def get_parcels_wfs(
logger.error(f"Error fetching WFS parcels: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to fetch parcel data from WFS"
detail=routeApiMsg("Failed to fetch parcel data from WFS")
)
@ -1441,7 +1443,7 @@ async def search_parcel(
logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
logger.info(f"Searching parcel for user {context.user.id} (mandate: {context.mandateId}) with location: {location}")
@ -1817,7 +1819,7 @@ async def parcel_selection_summary(
if not csrf_token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
parcels = body.get("parcels", [])
if not parcels:
@ -1868,19 +1870,19 @@ async def add_adjacent_parcel(
if not csrf_token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
location = body.get("location")
selected_parcels = body.get("selected_parcels", [])
if not location or "x" not in location or "y" not in location:
raise HTTPException(status_code=400, detail="location with x,y required")
raise HTTPException(status_code=400, detail=routeApiMsg("location with x,y required"))
loc_str = f"{location['x']},{location['y']}"
connector = SwissTopoMapServerConnector()
parcel_data = await connector.search_parcel(loc_str)
if not parcel_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No parcel found at this location"
detail=routeApiMsg("No parcel found at this location")
)
extracted = connector.extract_parcel_attributes(parcel_data)
attributes = parcel_data.get("attributes", {})
@ -1932,7 +1934,7 @@ async def add_adjacent_parcel(
if not is_parcel_adjacent_to_selection(new_parcel_response, selected_parcels):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Nur angrenzende Parzellen können hinzugefügt werden"
detail=routeApiMsg("Nur angrenzende Parzellen können hinzugefügt werden")
)
bbox = parcel_data.get("bbox", [])
map_view["zoom_bounds"] = {
@ -2020,21 +2022,21 @@ async def add_parcel_to_project(
logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {context.user.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Validate CSRF token format
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
try:
int(csrf_token, 16)
except ValueError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Adding parcel to project {projekt_id} for user {context.user.id} (mandate: {context.mandateId})")

View file

@ -12,24 +12,24 @@ logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "teamsbot"
FEATURE_LABEL = {"en": "Teams Bot", "de": "Teams Bot", "fr": "Teams Bot"}
FEATURE_LABEL = "Teams Bot"
FEATURE_ICON = "mdi-headset"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.teamsbot.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"},
"label": "Dashboard",
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.teamsbot.sessions",
"label": {"en": "Sessions", "de": "Sitzungen", "fr": "Sessions"},
"label": "Sitzungen",
"meta": {"area": "sessions"}
},
{
"objectKey": "ui.feature.teamsbot.settings",
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Paramètres"},
"label": "Einstellungen",
"meta": {"area": "settings", "admin_only": True}
},
]
@ -38,7 +38,7 @@ UI_OBJECTS = [
DATA_OBJECTS = [
{
"objectKey": "data.feature.teamsbot.TeamsbotSession",
"label": {"en": "Session", "de": "Sitzung", "fr": "Session"},
"label": "Sitzung",
"meta": {
"table": "TeamsbotSession",
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
@ -48,7 +48,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.teamsbot.TeamsbotTranscript",
"label": {"en": "Transcript", "de": "Transkript", "fr": "Transcription"},
"label": "Transkript",
"meta": {
"table": "TeamsbotTranscript",
"fields": ["id", "sessionId", "speaker", "text", "timestamp"],
@ -58,7 +58,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.teamsbot.TeamsbotBotResponse",
"label": {"en": "Bot Response", "de": "Bot-Antwort", "fr": "Réponse du bot"},
"label": "Bot-Antwort",
"meta": {
"table": "TeamsbotBotResponse",
"fields": ["id", "sessionId", "responseText", "detectedIntent"],
@ -68,7 +68,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.teamsbot.*",
"label": {"en": "All Teams Bot Data", "de": "Alle Teams Bot Daten", "fr": "Toutes les données Teams Bot"},
"label": "Alle Teams Bot Daten",
"meta": {"wildcard": True, "description": "Wildcard for all teamsbot data tables"}
},
]
@ -77,22 +77,22 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.teamsbot.session.start",
"label": {"en": "Start Session", "de": "Sitzung starten", "fr": "Démarrer session"},
"label": "Sitzung starten",
"meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions", "method": "POST"}
},
{
"objectKey": "resource.feature.teamsbot.session.stop",
"label": {"en": "Stop Session", "de": "Sitzung beenden", "fr": "Arrêter session"},
"label": "Sitzung beenden",
"meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}/stop", "method": "POST"}
},
{
"objectKey": "resource.feature.teamsbot.session.delete",
"label": {"en": "Delete Session", "de": "Sitzung löschen", "fr": "Supprimer session"},
"label": "Sitzung löschen",
"meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}", "method": "DELETE"}
},
{
"objectKey": "resource.feature.teamsbot.config.edit",
"label": {"en": "Edit Configuration", "de": "Konfiguration bearbeiten", "fr": "Modifier configuration"},
"label": "Konfiguration bearbeiten",
"meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True}
},
]
@ -101,11 +101,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [
{
"roleLabel": "teamsbot-admin",
"description": {
"en": "Teams Bot Administrator - Full access to all sessions and settings",
"de": "Teams Bot Administrator - Vollzugriff auf alle Sitzungen und Einstellungen",
"fr": "Administrateur Teams Bot - Accès complet aux sessions et paramètres"
},
"description": "Teams Bot Administrator - Vollzugriff auf alle Sitzungen und Einstellungen",
"accessRules": [
# Full UI access (all views including settings)
{"context": "UI", "item": None, "view": True},
@ -120,11 +116,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "teamsbot-viewer",
"description": {
"en": "Teams Bot Viewer - View sessions and transcripts (read-only)",
"de": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
"fr": "Visualiseur Teams Bot - Consulter les sessions et transcriptions (lecture seule)",
},
"description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
@ -133,11 +125,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "teamsbot-user",
"description": {
"en": "Teams Bot User - Can start/stop sessions and view transcripts",
"de": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
"fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions",
},
"description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
"accessRules": [
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
@ -223,7 +211,8 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
templateRoles = [r for r in existingRoles if r.mandateId is None]
@ -239,7 +228,7 @@ def _syncTemplateRolesToDb() -> int:
else:
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,

View file

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

View file

@ -7,15 +7,16 @@ from typing import Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Organisation")
class TrusteeOrganisation(PowerOnModel):
"""Represents trustee organisations (companies) within the Trustee feature."""
id: str = Field( # Unique string label (PK), not UUID
description="Unique organisation identifier (label)",
json_schema_extra={
"label": "ID",
"frontend_type": "text",
"frontend_readonly": False, # Editable at creation, then readonly
"frontend_required": True
@ -24,6 +25,7 @@ class TrusteeOrganisation(PowerOnModel):
label: str = Field(
description="Company name",
json_schema_extra={
"label": "Bezeichnung",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True
@ -33,6 +35,7 @@ class TrusteeOrganisation(PowerOnModel):
default=True,
description="Whether the organisation is enabled",
json_schema_extra={
"label": "Aktiviert",
"frontend_type": "checkbox",
"frontend_readonly": False,
"frontend_required": False
@ -42,6 +45,7 @@ class TrusteeOrganisation(PowerOnModel):
default=None,
description="Mandate ID (system-level organisation)",
json_schema_extra={
"label": "Mandat",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -51,6 +55,7 @@ class TrusteeOrganisation(PowerOnModel):
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -59,25 +64,13 @@ class TrusteeOrganisation(PowerOnModel):
# System attributes are automatically set by DatabaseConnector:
# sysCreatedAt, sysModifiedAt, sysCreatedBy, sysModifiedBy (PowerOnModel)
registerModelLabels(
"TrusteeOrganisation",
{"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
},
)
@i18nModel("Rolle")
class TrusteeRole(PowerOnModel):
"""Defines roles within the Trustee feature."""
id: str = Field( # Unique string label (PK), not UUID
description="Unique role identifier (label)",
json_schema_extra={
"label": "ID",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True
@ -86,6 +79,7 @@ class TrusteeRole(PowerOnModel):
desc: str = Field(
description="Role description",
json_schema_extra={
"label": "Beschreibung",
"frontend_type": "textarea",
"frontend_readonly": False,
"frontend_required": True
@ -95,6 +89,7 @@ class TrusteeRole(PowerOnModel):
default=None,
description="Mandate ID",
json_schema_extra={
"label": "Mandat",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -104,6 +99,7 @@ class TrusteeRole(PowerOnModel):
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -111,25 +107,14 @@ class TrusteeRole(PowerOnModel):
)
# System attributes are automatically set by DatabaseConnector
registerModelLabels(
"TrusteeRole",
{"en": "Role", "fr": "Rôle", "de": "Rolle"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
},
)
@i18nModel("Zugriff")
class TrusteeAccess(PowerOnModel):
"""Defines user access to organisations with specific roles."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique access ID",
json_schema_extra={
"label": "ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -138,6 +123,7 @@ class TrusteeAccess(PowerOnModel):
organisationId: str = Field(
description="Reference to TrusteeOrganisation.id",
json_schema_extra={
"label": "Organisation",
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
@ -147,6 +133,7 @@ class TrusteeAccess(PowerOnModel):
roleId: str = Field(
description="Reference to TrusteeRole.id",
json_schema_extra={
"label": "Rolle",
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
@ -156,6 +143,7 @@ class TrusteeAccess(PowerOnModel):
userId: str = Field(
description="User ID assigned to this role",
json_schema_extra={
"label": "Benutzer",
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
@ -166,6 +154,7 @@ class TrusteeAccess(PowerOnModel):
default=None,
description="Optional reference to TrusteeContract.id. If None, access is for full organisation. If set, access is limited to this specific contract.",
json_schema_extra={
"label": "Vertrag (optional)",
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": False,
@ -177,6 +166,7 @@ class TrusteeAccess(PowerOnModel):
default=None,
description="Mandate ID",
json_schema_extra={
"label": "Mandat",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -186,6 +176,7 @@ class TrusteeAccess(PowerOnModel):
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -193,28 +184,14 @@ class TrusteeAccess(PowerOnModel):
)
# System attributes are automatically set by DatabaseConnector
registerModelLabels(
"TrusteeAccess",
{"en": "Access", "fr": "Accès", "de": "Zugriff"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
"roleId": {"en": "Role", "fr": "Rôle", "de": "Rolle"},
"userId": {"en": "User", "fr": "Utilisateur", "de": "Benutzer"},
"contractId": {"en": "Contract (optional)", "fr": "Contrat (optionnel)", "de": "Vertrag (optional)"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
},
)
@i18nModel("Vertrag")
class TrusteeContract(PowerOnModel):
"""Defines customer contracts within organisations."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique contract ID",
json_schema_extra={
"label": "ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -223,6 +200,7 @@ class TrusteeContract(PowerOnModel):
organisationId: str = Field(
description="Reference to TrusteeOrganisation.id (immutable after creation)",
json_schema_extra={
"label": "Organisation",
"frontend_type": "select",
"frontend_readonly": False, # Editable at creation, then readonly
"frontend_required": True,
@ -232,6 +210,7 @@ class TrusteeContract(PowerOnModel):
label: str = Field(
description="Label for the customer contract (e.g., 'Muster AG 2026')",
json_schema_extra={
"label": "Bezeichnung",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True
@ -241,6 +220,7 @@ class TrusteeContract(PowerOnModel):
default=True,
description="Whether the contract is enabled",
json_schema_extra={
"label": "Aktiviert",
"frontend_type": "checkbox",
"frontend_readonly": False,
"frontend_required": False
@ -250,6 +230,7 @@ class TrusteeContract(PowerOnModel):
default=None,
description="Mandate ID",
json_schema_extra={
"label": "Mandat",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -259,6 +240,7 @@ class TrusteeContract(PowerOnModel):
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -266,21 +248,6 @@ class TrusteeContract(PowerOnModel):
)
# System attributes are automatically set by DatabaseConnector
registerModelLabels(
"TrusteeContract",
{"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
},
)
class TrusteeDocumentTypeEnum(str, Enum):
"""Document type for trustee documents (expense extraction, ingest, sync)."""
INVOICE = "invoice"
@ -290,7 +257,7 @@ class TrusteeDocumentTypeEnum(str, Enum):
UNKNOWN = "unknown"
AUTO = "auto"
@i18nModel("Dokument")
class TrusteeDocument(PowerOnModel):
"""Contains document references for bookings.
@ -305,6 +272,7 @@ class TrusteeDocument(PowerOnModel):
default_factory=lambda: str(uuid.uuid4()),
description="Unique document ID",
json_schema_extra={
"label": "ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -314,6 +282,7 @@ class TrusteeDocument(PowerOnModel):
default=None,
description="Reference to central Files table (Files.id)",
json_schema_extra={
"label": "Datei-Referenz",
"frontend_type": "file_reference",
"frontend_readonly": False,
"frontend_required": False
@ -322,6 +291,7 @@ class TrusteeDocument(PowerOnModel):
documentName: str = Field(
description="File name (e.g., 'Beleg.pdf')",
json_schema_extra={
"label": "Dokumentname",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True
@ -331,6 +301,7 @@ class TrusteeDocument(PowerOnModel):
default="application/octet-stream",
description="MIME type of the document",
json_schema_extra={
"label": "MIME-Typ",
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
@ -341,6 +312,7 @@ class TrusteeDocument(PowerOnModel):
default=None,
description="Source type (e.g., 'sharepoint', 'upload', 'email')",
json_schema_extra={
"label": "Quelltyp",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -350,6 +322,7 @@ class TrusteeDocument(PowerOnModel):
default=None,
description="Original source location (e.g., SharePoint path)",
json_schema_extra={
"label": "Quellort",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -359,6 +332,7 @@ class TrusteeDocument(PowerOnModel):
default=None,
description="Mandate ID (auto-set from context)",
json_schema_extra={
"label": "Mandat",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
@ -369,6 +343,7 @@ class TrusteeDocument(PowerOnModel):
default=None,
description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
@ -379,6 +354,7 @@ class TrusteeDocument(PowerOnModel):
default=None,
description="Document type (e.g. invoice, expense_receipt, bank_document, contract); use TrusteeDocumentTypeEnum values",
json_schema_extra={
"label": "Dokumenttyp",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -388,6 +364,7 @@ class TrusteeDocument(PowerOnModel):
default=None,
description="External Beleg-ID in accounting system (e.g. RMA); set on first successful upload, reused on re-sync",
json_schema_extra={
"label": "Beleg-ID (Buchhaltung)",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
@ -396,25 +373,7 @@ class TrusteeDocument(PowerOnModel):
)
# System attributes are automatically set by DatabaseConnector
registerModelLabels(
"TrusteeDocument",
{"en": "Document", "fr": "Document", "de": "Dokument"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"fileId": {"en": "File Reference", "fr": "Référence du fichier", "de": "Datei-Referenz"},
"documentName": {"en": "Document Name", "fr": "Nom du document", "de": "Dokumentname"},
"documentMimeType": {"en": "MIME Type", "fr": "Type MIME", "de": "MIME-Typ"},
"sourceType": {"en": "Source Type", "fr": "Type de source", "de": "Quelltyp"},
"sourceLocation": {"en": "Source Location", "fr": "Emplacement source", "de": "Quellort"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
"documentType": {"en": "Document Type", "fr": "Type de document", "de": "Dokumenttyp"},
"externalBelegId": {"en": "Beleg ID (Accounting)", "fr": "ID Beleg (Comptabilité)", "de": "Beleg-ID (Buchhaltung)"},
},
)
@i18nModel("Position")
class TrusteePosition(PowerOnModel):
"""Contains booking positions (expense entries).
@ -425,6 +384,7 @@ class TrusteePosition(PowerOnModel):
default_factory=lambda: str(uuid.uuid4()),
description="Unique position ID",
json_schema_extra={
"label": "ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
@ -434,6 +394,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Reference to TrusteeDocument.id (Beleg / primary document)",
json_schema_extra={
"label": "Dokument",
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": False,
@ -444,6 +405,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Reference to TrusteeDocument.id (Bank-Referenz / second document)",
json_schema_extra={
"label": "Bank-Referenz",
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": False,
@ -454,6 +416,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Value date (ISO format: YYYY-MM-DD)",
json_schema_extra={
"label": "Valutadatum",
"frontend_type": "date",
"frontend_readonly": False,
"frontend_required": True
@ -463,6 +426,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Transaction timestamp (UTC timestamp in seconds)",
json_schema_extra={
"label": "Transaktionszeitpunkt",
"frontend_type": "timestamp",
"frontend_readonly": False,
"frontend_required": True
@ -472,6 +436,7 @@ class TrusteePosition(PowerOnModel):
default="",
description="Company name",
json_schema_extra={
"label": "Firma",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -481,6 +446,7 @@ class TrusteePosition(PowerOnModel):
default="",
description="Description",
json_schema_extra={
"label": "Beschreibung",
"frontend_type": "textarea",
"frontend_readonly": False,
"frontend_required": False
@ -490,6 +456,7 @@ class TrusteePosition(PowerOnModel):
default="",
description="Tags (comma-separated)",
json_schema_extra={
"label": "Tags",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -514,6 +481,7 @@ class TrusteePosition(PowerOnModel):
default=0.0,
description="Booking amount",
json_schema_extra={
"label": "Buchungsbetrag",
"frontend_type": "number",
"frontend_readonly": False,
"frontend_required": True
@ -538,6 +506,7 @@ class TrusteePosition(PowerOnModel):
default=0.0,
description="Original amount (manual input, no automatic currency conversion)",
json_schema_extra={
"label": "Originalbetrag",
"frontend_type": "number",
"frontend_readonly": False,
"frontend_required": True
@ -547,6 +516,7 @@ class TrusteePosition(PowerOnModel):
default=0.0,
description="VAT percentage",
json_schema_extra={
"label": "MwSt-Prozentsatz",
"frontend_type": "number",
"frontend_readonly": False,
"frontend_required": False
@ -556,6 +526,7 @@ class TrusteePosition(PowerOnModel):
default=0.0,
description="VAT amount (calculated: bookingAmount * vatPercentage / 100, can be manually overridden)",
json_schema_extra={
"label": "MwSt-Betrag",
"frontend_type": "number",
"frontend_readonly": False,
"frontend_required": False
@ -565,6 +536,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Debit account number (e.g. '4200' for expenses)",
json_schema_extra={
"label": "Soll-Konto",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -574,6 +546,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Credit account number (e.g. '1020' for bank)",
json_schema_extra={
"label": "Haben-Konto",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -583,6 +556,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Tax code for the accounting system",
json_schema_extra={
"label": "Steuercode",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -592,6 +566,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Cost center identifier",
json_schema_extra={
"label": "Kostenstelle",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -601,6 +576,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Booking reference (e.g. voucher number)",
json_schema_extra={
"label": "Buchungsreferenz",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -626,6 +602,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="IBAN of the payment recipient (from invoice / QR code)",
json_schema_extra={
"label": "Empfänger-IBAN",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -635,6 +612,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Bank or account holder name of the payment recipient",
json_schema_extra={
"label": "Empfänger-Name",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -644,6 +622,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="BIC / SWIFT code of the recipient bank",
json_schema_extra={
"label": "Empfänger-BIC",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -653,6 +632,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Structured payment reference (QR-Referenz, ESR, SCOR, Mitteilung)",
json_schema_extra={
"label": "Zahlungsreferenz",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False
@ -662,6 +642,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Payment due date (ISO format: YYYY-MM-DD)",
json_schema_extra={
"label": "Fälligkeitsdatum",
"frontend_type": "date",
"frontend_readonly": False,
"frontend_required": False
@ -671,6 +652,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Mandate ID (auto-set from context)",
json_schema_extra={
"label": "Mandat",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
@ -681,6 +663,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
@ -691,6 +674,7 @@ class TrusteePosition(PowerOnModel):
default=None,
description="External ID (UUID) of the synced record in the accounting system; set by sync, used for duplicate check",
json_schema_extra={
"label": "Buha-Sync-ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
@ -698,283 +682,118 @@ class TrusteePosition(PowerOnModel):
}
)
registerModelLabels(
"TrusteePosition",
{"en": "Position", "fr": "Position", "de": "Position"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"documentId": {"en": "Document", "fr": "Document", "de": "Dokument"},
"bankDocumentId": {"en": "Bank Reference", "fr": "Référence bancaire", "de": "Bank-Referenz"},
"valuta": {"en": "Value Date", "fr": "Date de valeur", "de": "Valutadatum"},
"transactionDateTime": {"en": "Transaction Date/Time", "fr": "Date/Heure de transaction", "de": "Transaktionszeitpunkt"},
"company": {"en": "Company", "fr": "Entreprise", "de": "Firma"},
"desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"},
"tags": {"en": "Tags", "fr": "Tags", "de": "Tags"},
"bookingCurrency": {"en": "Booking Currency", "fr": "Devise de comptabilisation", "de": "Buchungswährung"},
"bookingAmount": {"en": "Booking Amount", "fr": "Montant de comptabilisation", "de": "Buchungsbetrag"},
"originalCurrency": {"en": "Original Currency", "fr": "Devise d'origine", "de": "Originalwährung"},
"originalAmount": {"en": "Original Amount", "fr": "Montant d'origine", "de": "Originalbetrag"},
"vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"},
"vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"},
"debitAccountNumber": {"en": "Debit Account", "fr": "Compte débit", "de": "Soll-Konto"},
"creditAccountNumber": {"en": "Credit Account", "fr": "Compte crédit", "de": "Haben-Konto"},
"taxCode": {"en": "Tax Code", "fr": "Code TVA", "de": "Steuercode"},
"costCenter": {"en": "Cost Center", "fr": "Centre de coûts", "de": "Kostenstelle"},
"bookingReference": {"en": "Booking Reference", "fr": "Référence de réservation", "de": "Buchungsreferenz"},
"documentType": {"en": "Document Type", "fr": "Type de document", "de": "Dokumenttyp"},
"payeeIban": {"en": "Payee IBAN", "fr": "IBAN bénéficiaire", "de": "Empfänger-IBAN"},
"payeeName": {"en": "Payee Name", "fr": "Nom du bénéficiaire", "de": "Empfänger-Name"},
"payeeBic": {"en": "Payee BIC/SWIFT", "fr": "BIC/SWIFT bénéficiaire", "de": "Empfänger-BIC"},
"paymentReference": {"en": "Payment Reference", "fr": "Référence de paiement", "de": "Zahlungsreferenz"},
"dueDate": {"en": "Due Date", "fr": "Date d'échéance", "de": "Fälligkeitsdatum"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
"accountingSyncId": {"en": "Accounting Sync ID", "fr": "ID sync comptabilité", "de": "Buha-Sync-ID"},
},
)
# ── TrusteeData* tables (synced from external accounting apps for analysis) ──
@i18nModel("Konto (Sync)")
class TrusteeDataAccount(PowerOnModel):
"""Chart of accounts synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
accountNumber: str = Field(description="Account number (e.g. '1020')")
label: str = Field(default="", description="Account name")
accountType: Optional[str] = Field(default=None, description="asset / liability / equity / revenue / expense")
accountGroup: Optional[str] = Field(default=None, description="Account group/category")
currency: str = Field(default="CHF", description="Account currency")
isActive: bool = Field(default=True)
mandateId: Optional[str] = Field(default=None)
featureInstanceId: Optional[str] = Field(default=None)
registerModelLabels(
"TrusteeDataAccount",
{"en": "Account (Synced)", "de": "Konto (Sync)", "fr": "Compte (Sync)"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"accountNumber": {"en": "Account Number", "de": "Kontonummer", "fr": "Numéro de compte"},
"label": {"en": "Name", "de": "Bezeichnung", "fr": "Libellé"},
"accountType": {"en": "Type", "de": "Typ", "fr": "Type"},
"accountGroup": {"en": "Group", "de": "Gruppe", "fr": "Groupe"},
"currency": {"en": "Currency", "de": "Währung", "fr": "Devise"},
"isActive": {"en": "Active", "de": "Aktiv", "fr": "Actif"},
"mandateId": {"en": "Mandate", "de": "Mandat", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
},
)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
accountNumber: str = Field(description="Account number (e.g. '1020')", json_schema_extra={"label": "Kontonummer"})
label: str = Field(default="", description="Account name", json_schema_extra={"label": "Bezeichnung"})
accountType: Optional[str] = Field(default=None, description="asset / liability / equity / revenue / expense", json_schema_extra={"label": "Typ"})
accountGroup: Optional[str] = Field(default=None, description="Account group/category", json_schema_extra={"label": "Gruppe"})
currency: str = Field(default="CHF", description="Account currency", json_schema_extra={"label": "Währung"})
isActive: bool = Field(default=True, json_schema_extra={"label": "Aktiv"})
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
@i18nModel("Buchung (Sync)")
class TrusteeDataJournalEntry(PowerOnModel):
"""Journal entry header synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
externalId: Optional[str] = Field(default=None, description="ID in the source system")
bookingDate: Optional[str] = Field(default=None, description="Booking date (YYYY-MM-DD)")
reference: Optional[str] = Field(default=None, description="Booking reference / voucher number")
description: str = Field(default="", description="Booking text")
currency: str = Field(default="CHF")
totalAmount: float = Field(default=0.0, description="Total amount of entry")
mandateId: Optional[str] = Field(default=None)
featureInstanceId: Optional[str] = Field(default=None)
registerModelLabels(
"TrusteeDataJournalEntry",
{"en": "Journal Entry (Synced)", "de": "Buchung (Sync)", "fr": "Écriture (Sync)"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"},
"bookingDate": {"en": "Date", "de": "Datum", "fr": "Date"},
"reference": {"en": "Reference", "de": "Referenz", "fr": "Référence"},
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"currency": {"en": "Currency", "de": "Währung", "fr": "Devise"},
"totalAmount": {"en": "Amount", "de": "Betrag", "fr": "Montant"},
"mandateId": {"en": "Mandate", "de": "Mandat", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
},
)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
externalId: Optional[str] = Field(default=None, description="ID in the source system", json_schema_extra={"label": "Externe ID"})
bookingDate: Optional[str] = Field(default=None, description="Booking date (YYYY-MM-DD)", json_schema_extra={"label": "Datum"})
reference: Optional[str] = Field(default=None, description="Booking reference / voucher number", json_schema_extra={"label": "Referenz"})
description: str = Field(default="", description="Booking text", json_schema_extra={"label": "Beschreibung"})
currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
totalAmount: float = Field(default=0.0, description="Total amount of entry", json_schema_extra={"label": "Betrag"})
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
@i18nModel("Buchungszeile (Sync)")
class TrusteeDataJournalLine(PowerOnModel):
"""Journal entry line (debit/credit) synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id")
accountNumber: str = Field(description="Account number")
debitAmount: float = Field(default=0.0)
creditAmount: float = Field(default=0.0)
currency: str = Field(default="CHF")
taxCode: Optional[str] = Field(default=None)
costCenter: Optional[str] = Field(default=None)
description: str = Field(default="")
mandateId: Optional[str] = Field(default=None)
featureInstanceId: Optional[str] = Field(default=None)
registerModelLabels(
"TrusteeDataJournalLine",
{"en": "Journal Line (Synced)", "de": "Buchungszeile (Sync)", "fr": "Ligne écriture (Sync)"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"journalEntryId": {"en": "Journal Entry", "de": "Buchung", "fr": "Écriture"},
"accountNumber": {"en": "Account", "de": "Konto", "fr": "Compte"},
"debitAmount": {"en": "Debit", "de": "Soll", "fr": "Débit"},
"creditAmount": {"en": "Credit", "de": "Haben", "fr": "Crédit"},
"currency": {"en": "Currency", "de": "Währung", "fr": "Devise"},
"taxCode": {"en": "Tax Code", "de": "Steuercode", "fr": "Code TVA"},
"costCenter": {"en": "Cost Center", "de": "Kostenstelle", "fr": "Centre de coûts"},
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"mandateId": {"en": "Mandate", "de": "Mandat", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
},
)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id", json_schema_extra={"label": "Buchung"})
accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"})
debitAmount: float = Field(default=0.0, json_schema_extra={"label": "Soll"})
creditAmount: float = Field(default=0.0, json_schema_extra={"label": "Haben"})
currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
taxCode: Optional[str] = Field(default=None, json_schema_extra={"label": "Steuercode"})
costCenter: Optional[str] = Field(default=None, json_schema_extra={"label": "Kostenstelle"})
description: str = Field(default="", json_schema_extra={"label": "Beschreibung"})
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
@i18nModel("Kontakt (Sync)")
class TrusteeDataContact(PowerOnModel):
"""Customer or vendor synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
externalId: Optional[str] = Field(default=None, description="ID in the source system")
contactType: str = Field(default="customer", description="customer / vendor / both")
contactNumber: Optional[str] = Field(default=None, description="Customer/vendor number")
name: str = Field(default="", description="Name / company")
address: Optional[str] = Field(default=None)
zip: Optional[str] = Field(default=None)
city: Optional[str] = Field(default=None)
country: Optional[str] = Field(default=None)
email: Optional[str] = Field(default=None)
phone: Optional[str] = Field(default=None)
vatNumber: Optional[str] = Field(default=None)
mandateId: Optional[str] = Field(default=None)
featureInstanceId: Optional[str] = Field(default=None)
registerModelLabels(
"TrusteeDataContact",
{"en": "Contact (Synced)", "de": "Kontakt (Sync)", "fr": "Contact (Sync)"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"},
"contactType": {"en": "Type", "de": "Typ", "fr": "Type"},
"contactNumber": {"en": "Number", "de": "Nummer", "fr": "Numéro"},
"name": {"en": "Name", "de": "Name", "fr": "Nom"},
"address": {"en": "Address", "de": "Adresse", "fr": "Adresse"},
"zip": {"en": "ZIP", "de": "PLZ", "fr": "NPA"},
"city": {"en": "City", "de": "Ort", "fr": "Ville"},
"country": {"en": "Country", "de": "Land", "fr": "Pays"},
"email": {"en": "Email", "de": "E-Mail", "fr": "E-mail"},
"phone": {"en": "Phone", "de": "Telefon", "fr": "Téléphone"},
"vatNumber": {"en": "VAT Number", "de": "MWST-Nr.", "fr": "N° TVA"},
"mandateId": {"en": "Mandate", "de": "Mandat", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
},
)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
externalId: Optional[str] = Field(default=None, description="ID in the source system", json_schema_extra={"label": "Externe ID"})
contactType: str = Field(default="customer", description="customer / vendor / both", json_schema_extra={"label": "Typ"})
contactNumber: Optional[str] = Field(default=None, description="Customer/vendor number", json_schema_extra={"label": "Nummer"})
name: str = Field(default="", description="Name / company", json_schema_extra={"label": "Name"})
address: Optional[str] = Field(default=None, json_schema_extra={"label": "Adresse"})
zip: Optional[str] = Field(default=None, json_schema_extra={"label": "PLZ"})
city: Optional[str] = Field(default=None, json_schema_extra={"label": "Ort"})
country: Optional[str] = Field(default=None, json_schema_extra={"label": "Land"})
email: Optional[str] = Field(default=None, json_schema_extra={"label": "E-Mail"})
phone: Optional[str] = Field(default=None, json_schema_extra={"label": "Telefon"})
vatNumber: Optional[str] = Field(default=None, json_schema_extra={"label": "MWST-Nr."})
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
@i18nModel("Kontosaldo (Sync)")
class TrusteeDataAccountBalance(PowerOnModel):
"""Account balance per period, derived from journal lines or directly from accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
accountNumber: str = Field(description="Account number")
periodYear: int = Field(description="Fiscal year")
periodMonth: int = Field(default=0, description="Month (1-12); 0 = annual total")
openingBalance: float = Field(default=0.0)
debitTotal: float = Field(default=0.0)
creditTotal: float = Field(default=0.0)
closingBalance: float = Field(default=0.0)
currency: str = Field(default="CHF")
mandateId: Optional[str] = Field(default=None)
featureInstanceId: Optional[str] = Field(default=None)
registerModelLabels(
"TrusteeDataAccountBalance",
{"en": "Account Balance (Synced)", "de": "Kontosaldo (Sync)", "fr": "Solde compte (Sync)"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"accountNumber": {"en": "Account", "de": "Konto", "fr": "Compte"},
"periodYear": {"en": "Year", "de": "Jahr", "fr": "Année"},
"periodMonth": {"en": "Month", "de": "Monat", "fr": "Mois"},
"openingBalance": {"en": "Opening Balance", "de": "Eröffnungssaldo", "fr": "Solde d'ouverture"},
"debitTotal": {"en": "Debit Total", "de": "Soll-Umsatz", "fr": "Total débit"},
"creditTotal": {"en": "Credit Total", "de": "Haben-Umsatz", "fr": "Total crédit"},
"closingBalance": {"en": "Closing Balance", "de": "Schlusssaldo", "fr": "Solde de clôture"},
"currency": {"en": "Currency", "de": "Währung", "fr": "Devise"},
"mandateId": {"en": "Mandate", "de": "Mandat", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
},
)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"})
periodYear: int = Field(description="Fiscal year", json_schema_extra={"label": "Jahr"})
periodMonth: int = Field(default=0, description="Month (1-12); 0 = annual total", json_schema_extra={"label": "Monat"})
openingBalance: float = Field(default=0.0, json_schema_extra={"label": "Eröffnungssaldo"})
debitTotal: float = Field(default=0.0, json_schema_extra={"label": "Soll-Umsatz"})
creditTotal: float = Field(default=0.0, json_schema_extra={"label": "Haben-Umsatz"})
closingBalance: float = Field(default=0.0, json_schema_extra={"label": "Schlusssaldo"})
currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
@i18nModel("Buchhaltungs-Konfiguration")
class TrusteeAccountingConfig(PowerOnModel):
"""Per-instance accounting system configuration with encrypted credentials.
Each feature instance can connect to exactly one accounting system.
Credentials are stored encrypted (decrypted at runtime by the AccountingBridge).
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
featureInstanceId: str = Field(description="FK -> FeatureInstance.id (1:1)")
connectorType: str = Field(description="Connector type key, e.g. 'rma', 'bexio', 'abacus'")
displayLabel: str = Field(default="", description="User-visible label for this integration")
encryptedConfig: str = Field(default="", description="Encrypted JSON blob with connector credentials")
isActive: bool = Field(default=True)
lastSyncAt: Optional[float] = Field(default=None, description="Timestamp of last sync attempt")
lastSyncStatus: Optional[str] = Field(default=None, description="Last sync result: success, error, partial")
lastSyncErrorMessage: Optional[str] = Field(default=None, description="Error message when lastSyncStatus is error")
cachedChartOfAccounts: Optional[str] = Field(default=None, description="JSON-serialised chart of accounts cache (list of {accountNumber, label, accountType})")
chartCachedAt: Optional[float] = Field(default=None, description="Timestamp when cachedChartOfAccounts was last refreshed")
mandateId: Optional[str] = Field(default=None)
registerModelLabels(
"TrusteeAccountingConfig",
{"en": "Accounting Configuration", "de": "Buchhaltungs-Konfiguration", "fr": "Configuration comptable"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance", "de": "Feature-Instanz"},
"connectorType": {"en": "System", "fr": "Système", "de": "System"},
"displayLabel": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"isActive": {"en": "Active", "fr": "Actif", "de": "Aktiv"},
"lastSyncAt": {"en": "Last Sync", "fr": "Dernière sync.", "de": "Letzte Synchronisation"},
"lastSyncStatus": {"en": "Status", "fr": "Statut", "de": "Status"},
"lastSyncErrorMessage": {"en": "Error", "fr": "Erreur", "de": "Fehlermeldung"},
"cachedChartOfAccounts": {"en": "Cached Chart", "de": "Cached Kontoplan", "fr": "Plan comptable en cache"},
"chartCachedAt": {"en": "Chart Cached At", "de": "Kontoplan-Cache-Zeitpunkt", "fr": "Horodatage cache plan comptable"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
},
)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
featureInstanceId: str = Field(description="FK -> FeatureInstance.id (1:1)", json_schema_extra={"label": "Feature-Instanz"})
connectorType: str = Field(description="Connector type key, e.g. 'rma', 'bexio', 'abacus'", json_schema_extra={"label": "System"})
displayLabel: str = Field(default="", description="User-visible label for this integration", json_schema_extra={"label": "Bezeichnung"})
encryptedConfig: str = Field(default="", description="Encrypted JSON blob with connector credentials", json_schema_extra={"label": "Verschlüsselte Konfiguration"})
isActive: bool = Field(default=True, json_schema_extra={"label": "Aktiv"})
lastSyncAt: Optional[float] = Field(default=None, description="Timestamp of last sync attempt", json_schema_extra={"label": "Letzte Synchronisation"})
lastSyncStatus: Optional[str] = Field(default=None, description="Last sync result: success, error, partial", json_schema_extra={"label": "Status"})
lastSyncErrorMessage: Optional[str] = Field(default=None, description="Error message when lastSyncStatus is error", json_schema_extra={"label": "Fehlermeldung"})
cachedChartOfAccounts: Optional[str] = Field(default=None, description="JSON-serialised chart of accounts cache (list of {accountNumber, label, accountType})", json_schema_extra={"label": "Cached Kontoplan"})
chartCachedAt: Optional[float] = Field(default=None, description="Timestamp when cachedChartOfAccounts was last refreshed", json_schema_extra={"label": "Kontoplan-Cache-Zeitpunkt"})
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
@i18nModel("Buchhaltungs-Synchronisation")
class TrusteeAccountingSync(PowerOnModel):
"""Tracks which position was synced to which external system and when.
Used for duplicate prevention, audit trail, and retry logic.
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
positionId: str = Field(description="FK -> TrusteePosition.id")
featureInstanceId: str = Field(description="FK -> FeatureInstance.id")
connectorType: str = Field(description="Connector type at time of sync")
externalId: Optional[str] = Field(default=None, description="ID assigned by the external system")
externalReference: Optional[str] = Field(default=None, description="Reference in the external system")
syncStatus: str = Field(default="pending", description="pending | synced | error | cancelled")
syncDirection: str = Field(default="push", description="push (local->ext) or pull (ext->local)")
syncedAt: Optional[float] = Field(default=None, description="Timestamp of successful sync")
errorMessage: Optional[str] = Field(default=None)
bookingPayload: Optional[dict] = Field(default=None, description="Payload sent to the external system (audit)")
mandateId: Optional[str] = Field(default=None)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
positionId: str = Field(description="FK -> TrusteePosition.id", json_schema_extra={"label": "Position"})
featureInstanceId: str = Field(description="FK -> FeatureInstance.id", json_schema_extra={"label": "Feature-Instanz"})
connectorType: str = Field(description="Connector type at time of sync", json_schema_extra={"label": "System"})
externalId: Optional[str] = Field(default=None, description="ID assigned by the external system", json_schema_extra={"label": "Externe ID"})
externalReference: Optional[str] = Field(default=None, description="Reference in the external system", json_schema_extra={"label": "Externe Referenz"})
syncStatus: str = Field(default="pending", description="pending | synced | error | cancelled", json_schema_extra={"label": "Status"})
syncDirection: str = Field(default="push", description="push (local->ext) or pull (ext->local)", json_schema_extra={"label": "Richtung"})
syncedAt: Optional[float] = Field(default=None, description="Timestamp of successful sync", json_schema_extra={"label": "Synchronisiert am"})
errorMessage: Optional[str] = Field(default=None, json_schema_extra={"label": "Fehler"})
bookingPayload: Optional[dict] = Field(default=None, description="Payload sent to the external system (audit)", json_schema_extra={"label": "Buchungs-Payload"})
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
registerModelLabels(
"TrusteeAccountingSync",
{"en": "Accounting Sync", "de": "Buchhaltungs-Synchronisation", "fr": "Synchronisation comptable"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"positionId": {"en": "Position", "fr": "Position", "de": "Position"},
"connectorType": {"en": "System", "fr": "Système", "de": "System"},
"externalId": {"en": "External ID", "fr": "ID Externe", "de": "Externe ID"},
"syncStatus": {"en": "Status", "fr": "Statut", "de": "Status"},
"syncDirection": {"en": "Direction", "fr": "Direction", "de": "Richtung"},
"syncedAt": {"en": "Synced At", "fr": "Synchronisé à", "de": "Synchronisiert am"},
"errorMessage": {"en": "Error", "fr": "Erreur", "de": "Fehler"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
},
)

View file

@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "trustee"
FEATURE_LABEL = {"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"}
FEATURE_LABEL = "Treuhand"
FEATURE_ICON = "mdi-briefcase"
# UI Objects for RBAC catalog
@ -20,37 +20,47 @@ FEATURE_ICON = "mdi-briefcase"
UI_OBJECTS = [
{
"objectKey": "ui.feature.trustee.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"},
"label": "Dashboard",
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.trustee.positions",
"label": {"en": "Positions", "de": "Positionen", "fr": "Positions"},
"label": "Positionen",
"meta": {"area": "positions"}
},
{
"objectKey": "ui.feature.trustee.documents",
"label": {"en": "Documents", "de": "Dokumente", "fr": "Documents"},
"label": "Dokumente",
"meta": {"area": "documents"}
},
{
"objectKey": "ui.feature.trustee.expense-import",
"label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"},
"label": "Spesen Import",
"meta": {"area": "expense-import"}
},
{
"objectKey": "ui.feature.trustee.scan-upload",
"label": {"en": "Scan / Upload", "de": "Scannen / Hochladen", "fr": "Scanner / Téléverser"},
"label": "Scannen / Hochladen",
"meta": {"area": "scan-upload"}
},
{
"objectKey": "ui.feature.trustee.analyse",
"label": "Analyse & Reporting",
"meta": {"area": "analyse"}
},
{
"objectKey": "ui.feature.trustee.abschluss",
"label": "Abschluss & Prüfung",
"meta": {"area": "abschluss"}
},
{
"objectKey": "ui.feature.trustee.settings",
"label": {"en": "Accounting Settings", "de": "Buchhaltungs-Einstellungen", "fr": "Paramètres comptables"},
"label": "Buchhaltungs-Einstellungen",
"meta": {"area": "settings", "admin_only": True}
},
{
"objectKey": "ui.feature.trustee.instance-roles",
"label": {"en": "Instance Roles & Permissions", "de": "Instanz-Rollen & Berechtigungen", "fr": "Rôles et permissions d'instance"},
"label": "Instanz-Rollen & Berechtigungen",
"meta": {"area": "admin", "admin_only": True}
},
]
@ -60,7 +70,7 @@ UI_OBJECTS = [
DATA_OBJECTS = [
{
"objectKey": "data.feature.trustee.TrusteeOrganisation",
"label": {"en": "Organisation", "de": "Organisation", "fr": "Organisation"},
"label": "Organisation",
"meta": {
"table": "TrusteeOrganisation",
"fields": ["id", "label", "enabled"],
@ -70,7 +80,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.trustee.TrusteePosition",
"label": {"en": "Position", "de": "Position", "fr": "Position"},
"label": "Position",
"meta": {
"table": "TrusteePosition",
"fields": ["id", "label", "description", "organisationId"],
@ -80,12 +90,12 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.trustee.TrusteeDocument",
"label": {"en": "Document", "de": "Dokument", "fr": "Document"},
"label": "Dokument",
"meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]}
},
{
"objectKey": "data.feature.trustee.TrusteeAccountingConfig",
"label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"},
"label": "Buchhaltungs-Konfiguration",
"meta": {
"table": "TrusteeAccountingConfig",
"fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"],
@ -95,37 +105,37 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.trustee.TrusteeAccountingSync",
"label": {"en": "Accounting Sync", "de": "Buchhaltungs-Synchronisation", "fr": "Sync. comptable"},
"label": "Buchhaltungs-Synchronisation",
"meta": {"table": "TrusteeAccountingSync", "fields": ["id", "positionId", "syncStatus", "externalId"]}
},
{
"objectKey": "data.feature.trustee.TrusteeDataAccount",
"label": {"en": "Accounts (Synced)", "de": "Kontenplan (Sync)", "fr": "Plan comptable (Sync)"},
"label": "Kontenplan (Sync)",
"meta": {"table": "TrusteeDataAccount", "fields": ["id", "accountNumber", "label", "accountType", "accountGroup", "currency", "isActive"]}
},
{
"objectKey": "data.feature.trustee.TrusteeDataJournalEntry",
"label": {"en": "Journal Entries (Synced)", "de": "Buchungen (Sync)", "fr": "Écritures (Sync)"},
"label": "Buchungen (Sync)",
"meta": {"table": "TrusteeDataJournalEntry", "fields": ["id", "externalId", "bookingDate", "reference", "description", "currency", "totalAmount"]}
},
{
"objectKey": "data.feature.trustee.TrusteeDataJournalLine",
"label": {"en": "Journal Lines (Synced)", "de": "Buchungszeilen (Sync)", "fr": "Lignes écriture (Sync)"},
"label": "Buchungszeilen (Sync)",
"meta": {"table": "TrusteeDataJournalLine", "fields": ["id", "journalEntryId", "accountNumber", "debitAmount", "creditAmount", "currency", "taxCode", "costCenter", "description"]}
},
{
"objectKey": "data.feature.trustee.TrusteeDataContact",
"label": {"en": "Contacts (Synced)", "de": "Kontakte (Sync)", "fr": "Contacts (Sync)"},
"label": "Kontakte (Sync)",
"meta": {"table": "TrusteeDataContact", "fields": ["id", "externalId", "contactType", "contactNumber", "name", "address", "zip", "city", "country", "email", "phone", "vatNumber"]}
},
{
"objectKey": "data.feature.trustee.TrusteeDataAccountBalance",
"label": {"en": "Account Balances (Synced)", "de": "Kontosalden (Sync)", "fr": "Soldes comptes (Sync)"},
"label": "Kontosalden (Sync)",
"meta": {"table": "TrusteeDataAccountBalance", "fields": ["id", "accountNumber", "periodYear", "periodMonth", "openingBalance", "debitTotal", "creditTotal", "closingBalance", "currency"]}
},
{
"objectKey": "data.feature.trustee.*",
"label": {"en": "All Trustee Data", "de": "Alle Treuhand-Daten", "fr": "Toutes les données fiduciaires"},
"label": "Alle Treuhand-Daten",
"meta": {"wildcard": True, "description": "Wildcard for all trustee data tables"}
},
]
@ -135,127 +145,379 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.trustee.documents.create",
"label": {"en": "Upload Document", "de": "Dokument hochladen", "fr": "Télécharger document"},
"label": "Dokument hochladen",
"meta": {"endpoint": "/api/trustee/{instanceId}/documents", "method": "POST"}
},
{
"objectKey": "resource.feature.trustee.documents.update",
"label": {"en": "Update Document", "de": "Dokument aktualisieren", "fr": "Modifier document"},
"label": "Dokument aktualisieren",
"meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "PUT"}
},
{
"objectKey": "resource.feature.trustee.documents.delete",
"label": {"en": "Delete Document", "de": "Dokument löschen", "fr": "Supprimer document"},
"label": "Dokument löschen",
"meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "DELETE"}
},
{
"objectKey": "resource.feature.trustee.positions.create",
"label": {"en": "Create Position", "de": "Position erstellen", "fr": "Créer position"},
"label": "Position erstellen",
"meta": {"endpoint": "/api/trustee/{instanceId}/positions", "method": "POST"}
},
{
"objectKey": "resource.feature.trustee.positions.update",
"label": {"en": "Update Position", "de": "Position aktualisieren", "fr": "Modifier position"},
"label": "Position aktualisieren",
"meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "PUT"}
},
{
"objectKey": "resource.feature.trustee.positions.delete",
"label": {"en": "Delete Position", "de": "Position löschen", "fr": "Supprimer position"},
"label": "Position löschen",
"meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "DELETE"}
},
{
"objectKey": "resource.feature.trustee.instance-roles.manage",
"label": {"en": "Manage Instance Roles", "de": "Instanz-Rollen verwalten", "fr": "Gérer les rôles d'instance"},
"label": "Instanz-Rollen verwalten",
"meta": {"endpoint": "/api/trustee/{instanceId}/instance-roles", "method": "ALL", "admin_only": True}
},
{
"objectKey": "resource.feature.trustee.accounting.manage",
"label": {"en": "Manage Accounting Integration", "de": "Buchhaltungs-Integration verwalten", "fr": "Gérer l'intégration comptable"},
"label": "Buchhaltungs-Integration verwalten",
"meta": {"endpoint": "/api/trustee/{instanceId}/accounting/config", "method": "ALL", "admin_only": True}
},
{
"objectKey": "resource.feature.trustee.accounting.sync",
"label": {"en": "Sync to Accounting", "de": "Buchhaltung synchronisieren", "fr": "Synchroniser la comptabilité"},
"label": "Buchhaltung synchronisieren",
"meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync", "method": "POST"}
},
{
"objectKey": "resource.feature.trustee.accounting.view",
"label": {"en": "View Sync Status", "de": "Sync-Status einsehen", "fr": "Voir le statut de synchronisation"},
"label": "Sync-Status einsehen",
"meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync-status", "method": "GET"}
},
{
"objectKey": "resource.feature.trustee.workflows.view",
"label": "Workflows einsehen",
"meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "GET"}
},
{
"objectKey": "resource.feature.trustee.workflows.execute",
"label": "Workflows ausführen",
"meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"}
},
{
"objectKey": "resource.feature.trustee.workflows.manage",
"label": "Workflows verwalten",
"meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "ALL", "admin_only": True}
},
]
# Template roles for this feature with AccessRules
# Each role defines default UI and DATA permissions
# Note: UI item=None means ALL views, specific items restrict to named views
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
QUICK_ACTION_CATEGORIES = [
{"id": "import", "label": "Import & Verarbeitung", "sortOrder": 1},
{"id": "analyse", "label": "Analyse & Reporting", "sortOrder": 2},
{"id": "abschluss", "label": "Abschluss & Prüfung", "sortOrder": 3},
]
QUICK_ACTIONS = [
{
"id": "trustee-process-receipts",
"label": "Belege verarbeiten",
"description": "Belege aus SharePoint importieren, klassifizieren und verbuchen",
"icon": "mdi-file-document-check-outline",
"color": "#4CAF50",
"category": "import",
"actionType": "link",
"config": {"targetView": "expense-import"},
"requiredRoles": ["trustee-user", "trustee-accountant", "trustee-admin"],
"sortOrder": 1,
},
{
"id": "trustee-sync-accounting",
"label": "Daten synchronisieren",
"description": "Buchhaltungsdaten aus dem externen System aktualisieren",
"icon": "mdi-sync",
"color": "#FF9800",
"category": "import",
"actionType": "link",
"config": {"targetView": "settings"},
"requiredRoles": ["trustee-accountant", "trustee-admin"],
"sortOrder": 2,
},
{
"id": "trustee-upload-receipt",
"label": "Beleg hochladen",
"description": "Beleg scannen oder als Datei hochladen",
"icon": "mdi-camera-document-outline",
"color": "#607D8B",
"category": "import",
"actionType": "link",
"config": {"targetView": "scan-upload"},
"requiredRoles": ["trustee-user", "trustee-client", "trustee-accountant", "trustee-admin"],
"sortOrder": 3,
},
{
"id": "trustee-budget-comparison",
"label": "Budget-Vergleich",
"description": "Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel",
"icon": "mdi-chart-bar",
"color": "#2196F3",
"category": "analyse",
"actionType": "link",
"config": {"targetView": "analyse", "tab": "budget"},
"requiredRoles": ["trustee-accountant", "trustee-admin"],
"sortOrder": 4,
},
{
"id": "trustee-kpi-dashboard",
"label": "KPI-Dashboard",
"description": "Kennzahlen berechnen und visualisieren",
"icon": "mdi-view-dashboard-outline",
"color": "#9C27B0",
"category": "analyse",
"actionType": "link",
"config": {"targetView": "analyse", "tab": "kpi"},
"requiredRoles": ["trustee-accountant", "trustee-admin"],
"sortOrder": 5,
},
{
"id": "trustee-cashflow",
"label": "Cashflow-Rechnung",
"description": "Cashflow berechnen und analysieren",
"icon": "mdi-cash-multiple",
"color": "#009688",
"category": "analyse",
"actionType": "link",
"config": {"targetView": "analyse", "tab": "cashflow"},
"requiredRoles": ["trustee-accountant", "trustee-admin"],
"sortOrder": 6,
},
{
"id": "trustee-forecast",
"label": "Prognose erstellen",
"description": "Trend-Analyse und Prognose der nächsten Monate",
"icon": "mdi-chart-timeline-variant",
"color": "#E91E63",
"category": "analyse",
"actionType": "link",
"config": {"targetView": "analyse", "tab": "forecast"},
"requiredRoles": ["trustee-accountant", "trustee-admin"],
"sortOrder": 7,
},
{
"id": "trustee-year-end-check",
"label": "Jahresabschluss prüfen",
"description": "Automatische Prüfungen für den Jahresabschluss",
"icon": "mdi-clipboard-check-outline",
"color": "#795548",
"category": "abschluss",
"actionType": "link",
"config": {"targetView": "abschluss", "tab": "year-end"},
"requiredRoles": ["trustee-accountant", "trustee-admin"],
"sortOrder": 8,
},
]
# ---------------------------------------------------------------------------
# Template Workflows — bootstrapped into each new feature instance.
# Graphs use existing nodes: trigger.manual, trustee.refreshAccountingData, ai.prompt.
# The placeholder {{featureInstanceId}} is replaced by _copyTemplateWorkflows.
# ---------------------------------------------------------------------------
def _buildAnalysisWorkflowGraph(prompt: str) -> Dict[str, Any]:
"""Build a standard analysis graph: trigger → refreshAccountingData → ai.prompt."""
return {
"nodes": [
{"id": "trigger", "type": "trigger.manual", "label": "Start", "_method": "", "_action": "", "parameters": {}, "position": {"x": 0, "y": 0}},
{"id": "refresh", "type": "trustee.refreshAccountingData", "label": "Daten laden", "_method": "trustee", "_action": "refreshAccountingData",
"parameters": {"featureInstanceId": "{{featureInstanceId}}", "forceRefresh": False}, "position": {"x": 250, "y": 0}},
{"id": "analyse", "type": "ai.prompt", "label": "Analyse", "_method": "ai", "_action": "process",
"parameters": {"prompt": prompt, "simpleMode": False}, "position": {"x": 500, "y": 0}},
],
"connections": [
{"source": "trigger", "sourcePort": 0, "target": "refresh", "targetPort": 0},
{"source": "refresh", "sourcePort": 0, "target": "analyse", "targetPort": 0},
],
}
TEMPLATE_WORKFLOWS = [
{
"id": "trustee-receipt-import",
"label": "Beleg-Import Pipeline",
"description": "Belege extrahieren, verarbeiten und in Buchhaltung synchronisieren",
"tags": ["feature:trustee", "template:trustee-receipt-import"],
"graph": {
"nodes": [
{"id": "trigger", "type": "trigger.manual", "label": "Start", "_method": "", "_action": "", "parameters": {}, "position": {"x": 0, "y": 0}},
{"id": "extract", "type": "trustee.extractFromFiles", "label": "Dokumente extrahieren", "_method": "trustee", "_action": "extractFromFiles",
"parameters": {"featureInstanceId": "{{featureInstanceId}}", "prompt": ""}, "position": {"x": 250, "y": 0}},
{"id": "process", "type": "trustee.processDocuments", "label": "Verarbeiten", "_method": "trustee", "_action": "processDocuments",
"parameters": {"documentList": [], "featureInstanceId": "{{featureInstanceId}}"}, "position": {"x": 500, "y": 0}},
{"id": "sync", "type": "trustee.syncToAccounting", "label": "Synchronisieren", "_method": "trustee", "_action": "syncToAccounting",
"parameters": {"documentList": [], "featureInstanceId": "{{featureInstanceId}}"}, "position": {"x": 750, "y": 0}},
],
"connections": [
{"source": "trigger", "sourcePort": 0, "target": "extract", "targetPort": 0},
{"source": "extract", "sourcePort": 0, "target": "process", "targetPort": 0},
{"source": "process", "sourcePort": 0, "target": "sync", "targetPort": 0},
],
},
},
{
"id": "trustee-sync-accounting",
"label": "Buchhaltung synchronisieren",
"description": "Buchhaltungsdaten aus dem externen System aktualisieren",
"tags": ["feature:trustee", "template:trustee-sync-accounting"],
"graph": {
"nodes": [
{"id": "trigger", "type": "trigger.manual", "label": "Start", "_method": "", "_action": "", "parameters": {}, "position": {"x": 0, "y": 0}},
{"id": "refresh", "type": "trustee.refreshAccountingData", "label": "Daten aktualisieren", "_method": "trustee", "_action": "refreshAccountingData",
"parameters": {"featureInstanceId": "{{featureInstanceId}}", "forceRefresh": True}, "position": {"x": 250, "y": 0}},
],
"connections": [
{"source": "trigger", "sourcePort": 0, "target": "refresh", "targetPort": 0},
],
},
},
{
"id": "trustee-budget-comparison",
"label": "Budget-Vergleich",
"description": "Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel",
"tags": ["feature:trustee", "template:trustee-budget-comparison"],
"graph": _buildAnalysisWorkflowGraph(
"Ich möchte einen Budget-Soll/Ist-Vergleich durchführen. Bitte:\n"
"1. Frage mich nach der Budget-Datei (Excel) oder suche im Workspace nach einer Datei mit 'Budget' im Namen\n"
"2. Lade die aktuellen Buchhaltungsdaten (refreshTrusteeData falls nötig)\n"
"3. Vergleiche die Soll-Werte aus dem Budget mit den Ist-Werten aus der Buchhaltung pro Konto\n"
"4. Berechne die Abweichung (absolut und prozentual)\n"
"5. Erstelle ein Abweichungs-Chart (Balkendiagramm: Soll vs. Ist pro Konto)\n"
"6. Markiere kritische Abweichungen (>10%) und gib eine kurze Einschätzung"
),
},
{
"id": "trustee-kpi-dashboard",
"label": "KPI-Dashboard",
"description": "Kennzahlen berechnen und visualisieren",
"tags": ["feature:trustee", "template:trustee-kpi-dashboard"],
"graph": _buildAnalysisWorkflowGraph(
"Erstelle ein KPI-Dashboard basierend auf den aktuellen Buchhaltungsdaten. Berechne und visualisiere:\n"
"1. Bruttogewinn und Bruttogewinnmarge\n"
"2. EBIT (Betriebsergebnis)\n"
"3. Gewinnmarge (Reingewinn / Umsatz)\n"
"4. Eigenkapitalquote und Check auf hälftigen Kapitalverlust (OR Art. 725)\n"
"5. Liquiditätsgrad 1-3 (Cash Ratio, Quick Ratio, Current Ratio)\n"
"6. Überschuldungs-Check\n\n"
"Erstelle für jede Kennzahl einen kurzen Kommentar (gut/kritisch/Handlungsbedarf). "
"Erstelle mindestens 2 Charts: ein Übersichts-Chart der Margen und ein Liquiditäts-Chart."
),
},
{
"id": "trustee-cashflow",
"label": "Cashflow-Rechnung",
"description": "Cashflow berechnen und analysieren",
"tags": ["feature:trustee", "template:trustee-cashflow"],
"graph": _buildAnalysisWorkflowGraph(
"Erstelle eine Cashflow-Rechnung basierend auf den aktuellen Buchhaltungsdaten:\n"
"1. Operativer Cashflow: Starte vom Reingewinn, bereinige um nicht-cash-wirksame Positionen\n"
"2. Investitions-Cashflow: Investitionen in Sachanlagen, Finanzanlagen\n"
"3. Finanzierungs-Cashflow: Darlehensaufnahmen/-rückzahlungen, Dividenden, Kapitalerhöhungen\n"
"4. Netto-Cashflow und Veränderung der liquiden Mittel\n\n"
"Warne bei kritischen Werten. Erstelle ein Wasserfall-Chart oder gestapeltes Balkendiagramm."
),
},
{
"id": "trustee-forecast",
"label": "Prognose erstellen",
"description": "Trend-Analyse und Prognose der nächsten Monate",
"tags": ["feature:trustee", "template:trustee-forecast"],
"graph": _buildAnalysisWorkflowGraph(
"Erstelle eine Finanzprognose basierend auf den historischen Buchhaltungsdaten:\n"
"1. Analysiere die Umsatz- und Aufwandsentwicklung der letzten 6 Monate\n"
"2. Identifiziere Trends und Saisonalitäten\n"
"3. Prognostiziere Umsatz, Aufwand und Gewinn für die nächsten 3 Monate\n"
"4. Erstelle ein Chart mit Ist-Werten und Prognose-Korridor\n"
"5. Markiere Risiken\n\n"
"Nutze eine einfache lineare Extrapolation mit Saisonalitätskorrektur wo sinnvoll."
),
},
{
"id": "trustee-year-end-check",
"label": "Jahresabschluss prüfen",
"description": "Automatische Prüfungen für den Jahresabschluss",
"tags": ["feature:trustee", "template:trustee-year-end-check"],
"graph": _buildAnalysisWorkflowGraph(
"Führe eine automatische Jahresabschluss-Prüfung durch:\n"
"1. Saldovalidierung: Prüfe alle Bilanzkonten auf Plausibilität\n"
"2. Vorjahresvergleich: Vergleiche Bilanz- und ER-Positionen mit dem Vorjahr, markiere Abweichungen >20%\n"
"3. Abgrenzungen: Identifiziere potenzielle transitorische Aktiven/Passiven\n"
"4. Gesetzliche Prüfungen: Hälftiger Kapitalverlust (OR 725), Überschuldung, Mindestkapital\n"
"5. MWST-Plausibilisierung: Vorsteuer vs. geschätzter Aufwand, Umsatzsteuer vs. Umsatz\n\n"
"Erstelle eine Checkliste mit Status (OK / Warnung / Kritisch) pro Prüfpunkt."
),
},
]
TEMPLATE_ROLES = [
{
"roleLabel": "trustee-viewer",
"description": {
"en": "Trustee Viewer - View trustee data (read-only)",
"de": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)",
"fr": "Visualiseur fiduciaire - Consulter les données fiduciaires (lecture seule)",
},
"description": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
],
},
{
"roleLabel": "trustee-user",
"description": {
"en": "Trustee User - Create and manage own trustee records",
"de": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten",
"fr": "Utilisateur fiduciaire - Créer et gérer ses propres données fiduciaires",
},
"description": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.execute", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
],
},
{
"roleLabel": "trustee-admin",
"description": {
"en": "Trustee Administrator - Full access to all trustee data and settings",
"de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
"fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires",
},
"description": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
{"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.execute", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.manage", "view": True},
],
},
{
"roleLabel": "trustee-accountant",
"description": {
"en": "Trustee Accountant - Manage accounting and financial data",
"de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
"fr": "Comptable fiduciaire - Gérer les données comptables et financières",
},
"description": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.analyse", "view": True},
{"context": "UI", "item": "ui.feature.trustee.abschluss", "view": True},
{"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.execute", "view": True},
],
},
{
"roleLabel": "trustee-client",
"description": {
"en": "Trustee Client - View own accounting data and documents",
"de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
"fr": "Client fiduciaire - Consulter ses propres données comptables et documents",
},
"description": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
"accessRules": [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
@ -293,6 +555,21 @@ def getTemplateRoles() -> List[Dict[str, Any]]:
return TEMPLATE_ROLES
def getTemplateWorkflows() -> List[Dict[str, Any]]:
"""Return template workflow definitions for bootstrap on instance creation."""
return TEMPLATE_WORKFLOWS
def getQuickActions() -> List[Dict[str, Any]]:
"""Return quick action definitions for the Trustee dashboard."""
return QUICK_ACTIONS
def getQuickActionCategories() -> List[Dict[str, Any]]:
"""Return quick action category definitions."""
return QUICK_ACTION_CATEGORIES
def getDataObjects() -> List[Dict[str, Any]]:
"""Return DATA objects for RBAC catalog registration."""
return DATA_OBJECTS
@ -358,7 +635,8 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
# Get existing template roles for this feature (Pydantic models)
@ -378,7 +656,7 @@ def _syncTemplateRolesToDb() -> int:
# Create new template role
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None, # Global template
featureInstanceId=None,

View file

@ -37,6 +37,10 @@ from modules.datamodels.datamodelPagination import (
PaginationMetadata,
normalize_pagination_dict,
)
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureTrustee")
logger = logging.getLogger(__name__)
@ -116,6 +120,78 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
return str(instance.mandateId)
# ============================================================================
# QUICK ACTIONS ENDPOINT
# ============================================================================
@router.get("/{instanceId}/quick-actions")
@limiter.limit("60/minute")
def getQuickActions(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
language: str = Query(default="de", description="Language code for labels"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""Return RBAC-filtered quick actions for the Trustee dashboard."""
mandateId = _validateInstanceAccess(instanceId, context)
from .mainTrustee import QUICK_ACTIONS, QUICK_ACTION_CATEGORIES
userRoleLabels: set = set()
if context.hasSysAdminRole:
userRoleLabels.add("trustee-admin")
else:
rootInterface = getRootInterface()
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
for fa in featureAccesses:
if str(fa.featureInstanceId) == instanceId and fa.enabled:
roleIds = fa.roleIds if hasattr(fa, "roleIds") and fa.roleIds else []
for rid in roleIds:
role = rootInterface.getRole(str(rid))
if role and role.roleLabel:
userRoleLabels.add(role.roleLabel)
def _resolveText(multilingual, lang: str) -> str:
if isinstance(multilingual, str):
return multilingual
if isinstance(multilingual, dict):
return multilingual.get(lang) or multilingual.get("en") or multilingual.get("de") or next(iter(multilingual.values()), "")
return ""
filteredActions = []
for action in QUICK_ACTIONS:
required = set(action.get("requiredRoles", []))
if not userRoleLabels and not context.hasSysAdminRole:
continue
if context.hasSysAdminRole or required.intersection(userRoleLabels):
resolved = {
"id": action["id"],
"label": _resolveText(action.get("label", {}), language),
"description": _resolveText(action.get("description", {}), language),
"icon": action.get("icon", ""),
"color": action.get("color", ""),
"category": action.get("category", ""),
"actionType": action.get("actionType", ""),
"config": action.get("config", {}),
"sortOrder": action.get("sortOrder", 99),
}
if resolved["actionType"] == "agentPrompt" and "config" in resolved:
cfg = dict(resolved["config"])
if "uploadHint" in cfg:
cfg["uploadHint"] = _resolveText(cfg["uploadHint"], language)
resolved["config"] = cfg
filteredActions.append(resolved)
filteredActions.sort(key=lambda a: a["sortOrder"])
resolvedCategories = [
{"id": c["id"], "label": _resolveText(c.get("label", {}), language), "sortOrder": c.get("sortOrder", 99)}
for c in QUICK_ACTION_CATEGORIES
]
return {"actions": filteredActions, "categories": resolvedCategories}
# ============================================================================
# ATTRIBUTES ENDPOINT (for FormGeneratorTable)
# ============================================================================
@ -385,7 +461,7 @@ def create_organisation(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createOrganisation(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create organisation")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create organisation"))
return result
@ -408,7 +484,7 @@ def update_organisation(
result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update organisation")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update organisation"))
return result
@ -430,7 +506,7 @@ def delete_organisation(
success = interface.deleteOrganisation(orgId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete organisation")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete organisation"))
return {"message": f"Organisation {orgId} deleted"}
@ -498,7 +574,7 @@ def create_role(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createRole(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create role")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create role"))
return result
@ -521,7 +597,7 @@ def update_role(
result = interface.updateRole(roleId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update role")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update role"))
return result
@ -543,7 +619,7 @@ def delete_role(
success = interface.deleteRole(roleId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete role (may be in use)")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete role (may be in use)"))
return {"message": f"Role {roleId} deleted"}
@ -641,7 +717,7 @@ def create_access(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createAccess(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create access")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create access"))
return result
@ -664,7 +740,7 @@ def update_access(
result = interface.updateAccess(accessId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update access")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update access"))
return result
@ -686,7 +762,7 @@ def delete_access(
success = interface.deleteAccess(accessId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete access")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete access"))
return {"message": f"Access {accessId} deleted"}
@ -769,7 +845,7 @@ def create_contract(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createContract(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create contract")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create contract"))
return result
@ -792,7 +868,7 @@ def update_contract(
result = interface.updateContract(contractId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update contract (organisationId cannot be changed)")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update contract (organisationId cannot be changed)"))
return result
@ -814,7 +890,7 @@ def delete_contract(
success = interface.deleteContract(contractId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete contract")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete contract"))
return {"message": f"Contract {contractId} deleted"}
@ -938,7 +1014,7 @@ def get_document_data(
data = interface.getDocumentData(documentId)
if not data:
raise HTTPException(status_code=404, detail="Document data not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Document data not found"))
return StreamingResponse(
io.BytesIO(data),
@ -995,7 +1071,7 @@ async def create_document(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createDocument(body)
if not result:
raise HTTPException(status_code=400, detail="Failed to create document")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create document"))
return result
@ -1025,7 +1101,7 @@ async def upload_document(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createDocument(docData)
if not result:
raise HTTPException(status_code=400, detail="Failed to create document")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create document"))
return result
@ -1048,7 +1124,7 @@ def update_document(
result = interface.updateDocument(documentId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update document")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update document"))
return result
@ -1070,7 +1146,7 @@ def delete_document(
success = interface.deleteDocument(documentId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete document")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete document"))
return {"message": f"Document {documentId} deleted"}
@ -1220,7 +1296,7 @@ def create_position(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createPosition(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create position")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create position"))
return result
@ -1243,7 +1319,7 @@ def update_position(
result = interface.updatePosition(positionId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update position")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update position"))
return result
@ -1265,7 +1341,7 @@ def delete_position(
success = interface.deletePosition(positionId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete position")
raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete position"))
return {"message": f"Position {positionId} deleted"}
@ -1398,7 +1474,7 @@ async def save_accounting_config(
if not plainConfig:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="config is required for new integration (e.g. clientName, apiKey)."
detail=routeApiMsg("config is required for new integration (e.g. clientName, apiKey).")
)
encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
@ -1511,7 +1587,7 @@ async def sync_positions_to_accounting(
positionIds = data.get("positionIds", [])
if not positionIds:
raise HTTPException(status_code=400, detail="positionIds required")
raise HTTPException(status_code=400, detail=routeApiMsg("positionIds required"))
results = await bridge.pushBatchToAccounting(instanceId, positionIds)
failed = [r for r in results if not r.success]
@ -1678,8 +1754,6 @@ def get_positions_by_document(
# ===== Instance Roles Management =====
# These endpoints allow feature admins to manage instance-specific roles and their AccessRules
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
"""
@ -1711,7 +1785,7 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
if not hasAdminPermission:
raise HTTPException(
status_code=403,
detail="Keine Berechtigung zur Rollenverwaltung"
detail=routeApiMsg("Keine Berechtigung zur Rollenverwaltung")
)
return mandateId

View file

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

View file

@ -12,32 +12,28 @@ from typing import Dict, List, Any
logger = logging.getLogger(__name__)
FEATURE_CODE = "workspace"
FEATURE_LABEL = {"en": "AI Workspace", "de": "AI Workspace", "fr": "AI Workspace"}
FEATURE_LABEL = "AI Workspace"
FEATURE_ICON = "mdi-brain"
UI_OBJECTS = [
{
"objectKey": "ui.feature.workspace.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"},
"label": "Dashboard",
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.workspace.editor",
"label": {"en": "Editor", "de": "Editor", "fr": "Editeur"},
"label": "Editor",
"meta": {"area": "editor"}
},
{
"objectKey": "ui.feature.workspace.settings",
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Parametres"},
"label": "Einstellungen",
"meta": {"area": "settings"}
},
{
"objectKey": "ui.feature.workspace.rag-insights",
"label": {
"en": "Knowledge insights",
"de": "Wissens-Insights",
"fr": "Aperçu des connaissances",
},
"label": "Wissens-Insights",
"meta": {"area": "rag-insights"},
},
]
@ -45,37 +41,37 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.workspace.start",
"label": {"en": "Start Agent", "de": "Agent starten", "fr": "Demarrer agent"},
"label": "Agent starten",
"meta": {"endpoint": "/api/workspace/{instanceId}/start/stream", "method": "POST"}
},
{
"objectKey": "resource.feature.workspace.stop",
"label": {"en": "Stop Agent", "de": "Agent stoppen", "fr": "Arreter agent"},
"label": "Agent stoppen",
"meta": {"endpoint": "/api/workspace/{instanceId}/{workflowId}/stop", "method": "POST"}
},
{
"objectKey": "resource.feature.workspace.files",
"label": {"en": "Manage Files", "de": "Dateien verwalten", "fr": "Gerer fichiers"},
"label": "Dateien verwalten",
"meta": {"endpoint": "/api/workspace/{instanceId}/files", "method": "GET"}
},
{
"objectKey": "resource.feature.workspace.folders",
"label": {"en": "Manage Folders", "de": "Ordner verwalten", "fr": "Gerer dossiers"},
"label": "Ordner verwalten",
"meta": {"endpoint": "/api/workspace/{instanceId}/folders", "method": "GET"}
},
{
"objectKey": "resource.feature.workspace.datasources",
"label": {"en": "Data Sources", "de": "Datenquellen", "fr": "Sources de donnees"},
"label": "Datenquellen",
"meta": {"endpoint": "/api/workspace/{instanceId}/datasources", "method": "GET"}
},
{
"objectKey": "resource.feature.workspace.voice",
"label": {"en": "Voice Input/Output", "de": "Spracheingabe/-ausgabe", "fr": "Entree/sortie vocale"},
"label": "Spracheingabe/-ausgabe",
"meta": {"endpoint": "/api/workspace/{instanceId}/voice/*", "method": "POST"}
},
{
"objectKey": "resource.feature.workspace.edits",
"label": {"en": "Review File Edits", "de": "Datei-Aenderungen pruefen", "fr": "Verifier les modifications de fichiers"},
"label": "Datei-Aenderungen pruefen",
"meta": {"endpoint": "/api/workspace/{instanceId}/edit/*", "method": "POST"}
},
]
@ -83,11 +79,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [
{
"roleLabel": "workspace-viewer",
"description": {
"en": "Workspace Viewer - View workspace (read-only)",
"de": "Workspace Betrachter - Workspace ansehen (nur lesen)",
"fr": "Visualiseur Workspace - Consulter le workspace (lecture seule)"
},
"description": "Workspace Betrachter - Workspace ansehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.workspace.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.workspace.editor", "view": True},
@ -98,11 +90,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "workspace-user",
"description": {
"en": "Workspace User - Use AI workspace and tools",
"de": "Workspace Benutzer - AI Workspace und Tools nutzen",
"fr": "Utilisateur Workspace - Utiliser l'espace de travail AI et les outils"
},
"description": "Workspace Benutzer - AI Workspace und Tools nutzen",
"accessRules": [
{"context": "UI", "item": "ui.feature.workspace.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.workspace.editor", "view": True},
@ -120,11 +108,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "workspace-admin",
"description": {
"en": "Workspace Admin - All UI and API actions; data is always scoped to own records (same privacy as users)",
"de": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)",
"fr": "Administrateur Workspace - Toute l'UI et les API; donnees limitees a ses propres enregistrements"
},
"description": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
@ -194,6 +178,7 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
@ -211,7 +196,7 @@ def _syncTemplateRolesToDb() -> int:
else:
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,

View file

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

View file

@ -25,6 +25,7 @@ from modules.datamodels.datamodelRbac import (
AccessRuleContext,
Role,
)
from modules.datamodels.datamodelUtils import coerce_text_multilingual
from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelMembership import (
UserMandate,
@ -547,7 +548,7 @@ def initRoles(db: DatabaseConnector) -> None:
standardRoles = [
Role(
roleLabel="admin",
description={"en": "Administrator - Manage users and resources within mandate scope", "de": "Administrator - Benutzer und Ressourcen im Mandanten verwalten", "fr": "Administrateur - Gérer les utilisateurs et ressources dans le périmètre du mandat"},
description=coerce_text_multilingual("Administrator - Benutzer und Ressourcen im Mandanten verwalten"),
mandateId=None, # Global template role
featureInstanceId=None,
featureCode=None,
@ -555,7 +556,7 @@ def initRoles(db: DatabaseConnector) -> None:
),
Role(
roleLabel="user",
description={"en": "User - Standard user with access to own records", "de": "Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", "fr": "Utilisateur - Utilisateur standard avec accès à ses propres enregistrements"},
description="Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze",
mandateId=None, # Global template role
featureInstanceId=None,
featureCode=None,
@ -563,7 +564,7 @@ def initRoles(db: DatabaseConnector) -> None:
),
Role(
roleLabel="viewer",
description={"en": "Viewer - Read-only access to group records", "de": "Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze", "fr": "Visualiseur - Accès en lecture seule aux enregistrements du groupe"},
description=coerce_text_multilingual("Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze"),
mandateId=None, # Global template role
featureInstanceId=None,
featureCode=None,
@ -728,7 +729,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
newRole = Role(
id=newRoleId,
roleLabel=roleLabel,
description=templateRole.get("description", {}),
description=coerce_text_multilingual(templateRole.get("description", {})),
mandateId=mandateId,
featureInstanceId=None,
featureCode=None,
@ -797,11 +798,7 @@ def _initSysAdminRole(db: DatabaseConnector, mandateId: str) -> Optional[str]:
logger.info("Creating sysadmin role in root mandate")
sysadminRole = Role(
roleLabel="sysadmin",
description={
"en": "System Administrator - Full administrative access across all mandates",
"de": "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten",
"fr": "Administrateur système - Accès administratif complet à tous les mandats"
},
description=coerce_text_multilingual("System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten"),
mandateId=mandateId,
featureInstanceId=None,
featureCode=None,

View file

@ -15,6 +15,7 @@ from typing import List, Dict, Any, Optional
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.datamodels.datamodelRbac import Role, AccessRule
from modules.datamodels.datamodelUtils import coerce_text_multilingual
from modules.connectors.connectorDbPostgre import DatabaseConnector
logger = logging.getLogger(__name__)
@ -198,6 +199,9 @@ class FeatureInterface:
# Copy template roles if requested
if copyTemplateRoles:
self._copyTemplateRoles(featureCode, mandateId, instanceId)
# Copy template workflows (if feature defines TEMPLATE_WORKFLOWS)
self._copyTemplateWorkflows(featureCode, mandateId, instanceId)
cleanedRecord = dict(createdInstance)
return FeatureInstance(**cleanedRecord)
@ -206,6 +210,72 @@ class FeatureInterface:
logger.error(f"Error creating feature instance: {e}")
raise ValueError(f"Failed to create feature instance: {e}")
def _copyTemplateWorkflows(self, featureCode: str, mandateId: str, instanceId: str) -> int:
"""
Copy feature-specific template workflows to a new instance.
Loads TEMPLATE_WORKFLOWS from the feature module and creates
AutoWorkflow records in the graphicalEditor DB, scoped to
(mandateId, instanceId). The placeholder {{featureInstanceId}}
in graph parameters is replaced with the actual instanceId.
Args:
featureCode: Feature code (e.g. "trustee")
mandateId: Mandate ID
instanceId: New FeatureInstance ID
Returns:
Number of workflows copied
"""
import json
import importlib
try:
featureModule = importlib.import_module(f"modules.features.{featureCode}.main{featureCode.capitalize()}")
getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None)
if not getTemplateWorkflows:
return 0
templateWorkflows = getTemplateWorkflows()
if not templateWorkflows:
return 0
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
from modules.auth.authModels import SystemUser
systemUser = SystemUser()
geInterface = getGraphicalEditorInterface(systemUser, mandateId, instanceId)
copied = 0
for template in templateWorkflows:
graphJson = json.dumps(template.get("graph", {}))
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
graph = json.loads(graphJson)
labelDict = template.get("label", {})
label = labelDict.get("de") or labelDict.get("en") or str(labelDict) if isinstance(labelDict, dict) else str(labelDict)
geInterface.createWorkflow({
"label": label,
"graph": graph,
"tags": template.get("tags", [f"feature:{featureCode}"]),
"isTemplate": False,
"templateSourceId": template["id"],
"templateScope": "instance",
"active": True,
})
copied += 1
if copied > 0:
logger.info(f"Feature '{featureCode}': Copied {copied} template workflows to instance {instanceId}")
return copied
except ImportError:
logger.debug(f"No feature module found for '{featureCode}' — skipping workflow bootstrap")
return 0
except Exception as e:
logger.warning(f"Error copying template workflows for '{featureCode}' instance {instanceId}: {e}")
return 0
def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
"""
Copy feature-specific template roles to a new instance.
@ -268,7 +338,7 @@ class FeatureInterface:
newRole = Role(
id=newRoleId,
roleLabel=templateRole.get("roleLabel"),
description=templateRole.get("description", {}),
description=coerce_text_multilingual(templateRole.get("description", {})),
featureCode=featureCode,
mandateId=mandateId,
featureInstanceId=instanceId,
@ -354,7 +424,7 @@ class FeatureInterface:
newRole = Role(
id=newRoleId,
roleLabel=templateRole.get("roleLabel"),
description=templateRole.get("description", {}),
description=coerce_text_multilingual(templateRole.get("description", {})),
featureCode=featureCode,
mandateId=mandateId,
featureInstanceId=featureInstanceId,

View file

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

View file

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

View file

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

View file

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

View file

@ -24,6 +24,8 @@ from modules.datamodels.datamodelMembership import (
)
from modules.datamodels.datamodelFeatures import FeatureInstance, Feature
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeAdminUserAccessOverview")
# Configure logger
logger = logging.getLogger(__name__)
@ -116,7 +118,7 @@ def listUsersForOverview(
- List of user dictionaries with basic info
"""
if not _hasMandateAdminRole(context):
raise HTTPException(status_code=403, detail="Keine Berechtigung für die Benutzerzugriffsübersicht")
raise HTTPException(status_code=403, detail=routeApiMsg("Keine Berechtigung für die Benutzerzugriffsübersicht"))
try:
interface = getRootInterface()
@ -209,7 +211,7 @@ def getUserAccessOverview(
- Resource access (what resources the user can use)
"""
if not _hasMandateAdminRole(context):
raise HTTPException(status_code=403, detail="Keine Berechtigung für die Benutzerzugriffsübersicht")
raise HTTPException(status_code=403, detail=routeApiMsg("Keine Berechtigung für die Benutzerzugriffsübersicht"))
try:
interface = getRootInterface()
@ -239,7 +241,7 @@ def getUserAccessOverview(
break
if not userInAdminMandate:
raise HTTPException(status_code=403, detail="Benutzer gehört nicht zu Ihrem Mandate")
raise HTTPException(status_code=403, detail=routeApiMsg("Benutzer gehört nicht zu Ihrem Mandate"))
# Get user
user = interface.getUser(userId)
@ -528,7 +530,7 @@ def getEffectivePermissions(
if not context.hasSysAdminRole:
# Check if user has admin role in any mandate
if not _hasMandateAdminRole(context):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required"))
try:
interface = getRootInterface()
@ -550,7 +552,7 @@ def getEffectivePermissions(
break
if not adminMandateIds:
raise HTTPException(status_code=403, detail="Insufficient permissions")
raise HTTPException(status_code=403, detail=routeApiMsg("Insufficient permissions"))
userInAdminMandate = False
for mid in adminMandateIds:
@ -559,7 +561,7 @@ def getEffectivePermissions(
break
if not userInAdminMandate:
raise HTTPException(status_code=403, detail="Benutzer gehört nicht zu Ihrem Mandate")
raise HTTPException(status_code=403, detail=routeApiMsg("Benutzer gehört nicht zu Ihrem Mandate"))
# Get user
user = interface.getUser(userId)

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -25,6 +25,8 @@ from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.auditLogger import audit_logger
from modules.shared.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeGdpr")
logger = logging.getLogger(__name__)
@ -316,14 +318,14 @@ def delete_account(
if not confirmDeletion:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Deletion not confirmed. Set confirmDeletion=true to proceed."
detail=routeApiMsg("Deletion not confirmed. Set confirmDeletion=true to proceed.")
)
# Prevent SysAdmin self-deletion (safety measure)
if getattr(currentUser, "isSysAdmin", False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SysAdmin accounts cannot be self-deleted. Contact another SysAdmin."
detail=routeApiMsg("SysAdmin accounts cannot be self-deleted. Contact another SysAdmin.")
)
try:

View file

@ -38,8 +38,11 @@ from modules.datamodels.datamodelNotification import NotificationType
from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface
from modules.routes.routeNotifications import _createNotification
from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import _loadCache as _reloadI18nCache, apiRouteContext
from modules.shared.timeUtils import getUtcTimestamp
routeApiMsg = apiRouteContext("routeI18n")
logger = logging.getLogger(__name__)
router = APIRouter(
@ -270,16 +273,28 @@ async def _translateBatch(
finally:
aiObjects.billingCallback = None
_matchCapitalization(keysToTranslate, result)
return result
def _matchCapitalization(originals: Dict[str, str], translations: Dict[str, str]) -> None:
"""Ensure translations preserve the capitalisation pattern of the original key."""
for key, translated in translations.items():
if not key or not translated:
continue
if key[0].isupper() and translated[0].islower():
translations[key] = translated[0].upper() + translated[1:]
elif key[0].islower() and translated[0].isupper():
translations[key] = translated[0].lower() + translated[1:]
def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str:
userId = str(currentUser.id)
memberIds = _userMemberMandateIds(currentUser)
if not memberIds:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mindestens eine Mandats-Mitgliedschaft ist für die AI-Nutzung erforderlich.",
detail=routeApiMsg("Mindestens eine Mandats-Mitgliedschaft ist für die AI-Nutzung erforderlich."),
)
headerRaw = (
@ -289,7 +304,7 @@ def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str:
if headerRaw not in memberIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="X-Mandate-Id ist kein Mandat Ihrer Mitgliedschaft.",
detail=routeApiMsg("X-Mandate-Id ist kein Mandat Ihrer Mitgliedschaft."),
)
if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId):
return headerRaw
@ -298,7 +313,7 @@ def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str:
return mid
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="Nicht genügend AI-Guthaben (Mandats-Pool) für diese Aktion.",
detail=routeApiMsg("Nicht genügend AI-Guthaben (Mandats-Pool) für diese Aktion."),
)
@ -348,7 +363,7 @@ async def _readOptionalEntriesFromBody(request: Request) -> Optional[List[dict]]
if not isinstance(entries, list):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Feld «entries» muss ein JSON-Array sein.",
detail=routeApiMsg("Feld «entries» muss ein JSON-Array sein."),
)
result = []
for e in entries:
@ -363,11 +378,10 @@ async def _readOptionalEntriesFromBody(request: Request) -> Optional[List[dict]]
def _syncXxMaster(db, userId: Optional[str], incomingEntries: List[dict]) -> Dict[str, Any]:
"""Synchronise the xx base set with incoming entries (from build bundle or codebase scan).
"""Synchronise the xx base set with incoming UI entries.
- Keys in incoming but not in DB -> add
- Keys in DB but not in incoming -> remove
- Keys in both -> update context (value)
Only touches entries whose context is "ui". Gateway entries (api.*, table.*)
written by _syncRegistryToDb at boot are preserved untouched.
"""
if not incomingEntries:
logger.warning("i18n xx-sync: no entries — aborting")
@ -394,39 +408,45 @@ def _syncXxMaster(db, userId: Optional[str], incomingEntries: List[dict]) -> Dic
row = dict(rows[0])
curEntries = _rowEntries(row)
curByKey = {e["key"]: e for e in curEntries}
gatewayEntries = [e for e in curEntries if e.get("context", "ui") != "ui"]
curUiByKey = {e["key"]: e for e in curEntries if e.get("context", "ui") == "ui"}
incomingByKey = {e["key"]: e for e in incomingEntries}
incomingKeys = set(incomingByKey.keys())
dbKeys = set(curByKey.keys())
dbUiKeys = set(curUiByKey.keys())
added = sorted(incomingKeys - dbKeys)
removed = sorted(dbKeys - incomingKeys)
added = sorted(incomingKeys - dbUiKeys)
removed = sorted(dbUiKeys - incomingKeys)
newEntries = []
for e in incomingEntries:
newEntries.append({"context": e["context"], "key": e["key"], "value": e["value"]})
for e in curEntries:
if e["key"] not in incomingKeys:
continue
newUiEntries = [
{"context": e["context"], "key": e["key"], "value": e["value"]}
for e in incomingEntries
]
if not added and not removed and all(
curByKey.get(e["key"], {}).get("value") == e["value"]
and curByKey.get(e["key"], {}).get("context") == e["context"]
curUiByKey.get(e["key"], {}).get("value") == e["value"]
and curUiByKey.get(e["key"], {}).get("context") == e["context"]
for e in incomingEntries
):
return {"added": [], "removed": [], "entriesCount": len(newEntries)}
total = len(newUiEntries) + len(gatewayEntries)
return {"added": [], "removed": [], "entriesCount": total}
mergedEntries = gatewayEntries + newUiEntries
now = getUtcTimestamp()
row["entries"] = newEntries
row["entries"] = mergedEntries
if "keys" in row:
del row["keys"]
row["sysModifiedAt"] = now
row["sysModifiedBy"] = userId
db.recordModify(UiLanguageSet, "xx", row)
logger.info("i18n xx-master sync: +%d added, -%d removed, total=%d", len(added), len(removed), len(newEntries))
return {"added": added, "removed": removed, "entriesCount": len(newEntries)}
logger.info(
"i18n xx-master sync: +%d added, -%d removed (ui=%d, gateway=%d, total=%d)",
len(added), len(removed), len(newUiEntries), len(gatewayEntries), len(mergedEntries),
)
return {"added": added, "removed": removed, "entriesCount": len(mergedEntries)}
# --- Public -----------------------------------------------------------------
@ -439,6 +459,8 @@ async def list_language_codes():
out = []
for r in rows:
entries = _rowEntries(r)
uiCount = sum(1 for e in entries if e.get("context", "ui") == "ui")
gatewayCount = len(entries) - uiCount
out.append(
{
"code": r["id"],
@ -446,6 +468,8 @@ async def list_language_codes():
"status": r.get("status"),
"isDefault": bool(r.get("isDefault")),
"entriesCount": len(entries),
"uiCount": uiCount,
"gatewayCount": gatewayCount,
}
)
return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"]))
@ -456,7 +480,7 @@ async def get_language_set(code: str):
db = _publicMgmtDb()
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
return _row_to_public(rows[0])
@ -472,7 +496,7 @@ def _validate_iso2_code(code: str) -> str:
c = code.strip().lower()
if not re.fullmatch(r"[a-z]{2}", c):
raise HTTPException(
status_code=400, detail="Nur ISO-639-1 Zwei-Buchstaben-Codes erlaubt."
status_code=400, detail=routeApiMsg("Nur ISO-639-1 Zwei-Buchstaben-Codes erlaubt.")
)
return c
@ -530,6 +554,7 @@ async def _run_create_language_job_async(userId: str, code: str, label: str, cur
title="Sprachset erstellt",
message=f"Die Sprache «{label}» ({code}) wurde per KI übersetzt{statusHint}.",
)
await _reloadI18nCache()
logger.info("i18n create job done: code=%s, translated=%d/%d", code, len(translated), len(xxEntries))
except Exception as e:
logger.exception("create language job failed: %s", e)
@ -551,16 +576,16 @@ async def create_language_set(
mandateId = _resolveMandateIdForAiI18n(request, currentUser)
code = _validate_iso2_code(body.code)
if code == "xx":
raise HTTPException(status_code=400, detail="Das Basisset «xx» kann nicht manuell angelegt werden.")
raise HTTPException(status_code=400, detail=routeApiMsg("Das Basisset «xx» kann nicht manuell angelegt werden."))
db = _publicMgmtDb()
existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if existing:
raise HTTPException(status_code=409, detail="Dieses Sprachset existiert bereits.")
raise HTTPException(status_code=409, detail=routeApiMsg("Dieses Sprachset existiert bereits."))
xxEntries = _loadMasterXxEntries(db)
if not xxEntries:
raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden. Bitte zuerst UI-Keys einlesen.")
raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden. Bitte zuerst UI-Keys einlesen."))
resolvedLabel = (body.label or "").strip() if body.label else ""
if not resolvedLabel:
@ -594,54 +619,59 @@ async def create_language_set(
def _compute_language_sync_diff(db, code: str) -> dict:
"""Return key sync metrics before AI translate (no DB writes)."""
if code == "xx":
raise HTTPException(status_code=400, detail="Das xx-Set wird separat synchronisiert.")
raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird separat synchronisiert."))
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
xx_entries = _loadMasterXxEntries(db)
if not xx_entries:
raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden.")
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
xxEntries = _loadMasterXxEntries(db)
if not xxEntries:
raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden."))
row = dict(rows[0])
cur_entries = _rowEntries(row)
cur_by_key = {e["key"]: e for e in cur_entries}
xx_by_key = {e["key"]: e for e in xx_entries}
master_keys = set(xx_by_key.keys())
current_keys = set(cur_by_key.keys())
added_count = len(master_keys - current_keys)
removed_count = len(current_keys - master_keys)
curEntries = _rowEntries(row)
masterIds = {_entryId(e) for e in xxEntries}
currentIds = {_entryId(e) for e in curEntries}
return {
"code": code,
"addedCount": added_count,
"removedCount": removed_count,
"masterEntryCount": len(master_keys),
"currentEntryCount": len(current_keys),
"addedCount": len(masterIds - currentIds),
"removedCount": len(currentIds - masterIds),
"masterEntryCount": len(masterIds),
"currentEntryCount": len(currentIds),
}
def _entryId(e: dict) -> tuple:
"""Composite identifier for an i18n entry: (key, context)."""
return (e["key"], e.get("context", "ui"))
async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: Optional[User] = None) -> dict:
"""Synchronise a language set (incl. de) against the xx base set via AI."""
"""Synchronise a language set (incl. de) against the xx base set via AI.
Entries are identified by (key, context) the same text can appear
with different contexts (e.g. "ui" and "api.routeXyz").
"""
if code == "xx":
raise HTTPException(status_code=400, detail="Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.")
raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird über 'UI-Keys einlesen' aktualisiert."))
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
xxEntries = _loadMasterXxEntries(db)
if not xxEntries:
raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden.")
raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden."))
row = dict(rows[0])
curEntries = _rowEntries(row)
curByKey = {e["key"]: e for e in curEntries}
xxByKey = {e["key"]: e for e in xxEntries}
curById = {_entryId(e): e for e in curEntries}
xxById = {_entryId(e): e for e in xxEntries}
masterKeys = set(xxByKey.keys())
currentKeys = set(curByKey.keys())
removedKeys = sorted(currentKeys - masterKeys)
addedKeys = sorted(masterKeys - currentKeys)
masterIds = set(xxById.keys())
currentIds = set(curById.keys())
removedIds = currentIds - masterIds
addedIds = masterIds - currentIds
translatedCount = 0
if addedKeys:
toTranslate = {k: xxByKey[k].get("value", "") for k in addedKeys}
if addedIds:
toTranslate = {xxById[eid]["key"]: xxById[eid].get("value", "") for eid in addedIds}
langLabel = row.get("label") or code
billingCb = None
if adminUser:
@ -650,28 +680,29 @@ async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: O
billingCb = _makeBillingCallback(adminUser, memberIds[0])
try:
translated = await _translateBatch(toTranslate, langLabel, code, billingCallback=billingCb)
translatedCount = sum(1 for k in addedKeys if k in translated)
translatedCount = sum(1 for eid in addedIds if xxById[eid]["key"] in translated)
except Exception as e:
logger.error("AI translation during sync failed for %s: %s", code, e)
translated = {}
for k in addedKeys:
curByKey[k] = {
"context": xxByKey[k]["context"],
"key": k,
"value": translated.get(k, f"[{k}]"),
for eid in addedIds:
xxEntry = xxById[eid]
curById[eid] = {
"context": xxEntry["context"],
"key": xxEntry["key"],
"value": translated.get(xxEntry["key"], f"[{xxEntry['key']}]"),
}
for k in removedKeys:
del curByKey[k]
for eid in removedIds:
del curById[eid]
for k in masterKeys & currentKeys:
curByKey[k]["context"] = xxByKey[k]["context"]
for eid in masterIds & currentIds:
curById[eid]["context"] = xxById[eid]["context"]
newEntries = [curByKey[k] for k in sorted(curByKey.keys(), key=lambda x: x.lower())]
newEntries = sorted(curById.values(), key=lambda e: (e["key"].lower(), e.get("context", "")))
now = getUtcTimestamp()
untranslated = len(addedKeys) - translatedCount
untranslated = len(addedIds) - translatedCount
row["entries"] = newEntries
if "keys" in row:
del row["keys"]
@ -681,8 +712,8 @@ async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: O
db.recordModify(UiLanguageSet, code, row)
return {
"code": code,
"added": addedKeys,
"removed": removedKeys,
"added": sorted({xxById[eid]["key"] for eid in addedIds}),
"removed": sorted({eid[0] for eid in removedIds}),
"translated": translatedCount,
"entriesCount": len(newEntries),
}
@ -701,7 +732,9 @@ async def sync_xx_master(
db = getMgmtInterface(adminUser, mandateId=None).db
fromBody = await _readOptionalEntriesFromBody(request)
entries = fromBody if fromBody is not None else _scanCodebaseKeys()
return _syncXxMaster(db, str(adminUser.id), entries)
result = _syncXxMaster(db, str(adminUser.id), entries)
await _reloadI18nCache()
return result
@router.put("/sets/update-all")
@ -727,6 +760,7 @@ async def update_all_language_sets(
continue
res = await _syncLanguageWithXx(db, cid, str(adminUser.id), adminUser=adminUser)
results.append(res)
await _reloadI18nCache()
return {"xxSync": xxSync, "updated": results}
@ -738,7 +772,7 @@ async def get_language_sync_diff(
"""How many keys would be added/removed vs xx before running a full sync (SysAdmin)."""
c = code.strip().lower()
if c in ("update-all", "sync-xx", "sync-de"):
raise HTTPException(status_code=400, detail="Ungültiger Sprachcode.")
raise HTTPException(status_code=400, detail=routeApiMsg("Ungültiger Sprachcode."))
db = getMgmtInterface(adminUser, mandateId=None).db
return _compute_language_sync_diff(db, c)
@ -750,11 +784,13 @@ async def update_language_set(
):
c = code.strip().lower()
if c in ("update-all", "sync-xx", "sync-de"):
raise HTTPException(status_code=400, detail="Ungültiger Sprachcode.")
raise HTTPException(status_code=400, detail=routeApiMsg("Ungültiger Sprachcode."))
if c == "xx":
raise HTTPException(status_code=400, detail="Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.")
raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird über 'UI-Keys einlesen' aktualisiert."))
db = getMgmtInterface(adminUser, mandateId=None).db
return await _syncLanguageWithXx(db, c, str(adminUser.id), adminUser=adminUser)
result = await _syncLanguageWithXx(db, c, str(adminUser.id), adminUser=adminUser)
await _reloadI18nCache()
return result
@router.delete("/sets/{code}")
@ -768,7 +804,8 @@ async def delete_language_set(
db = getMgmtInterface(adminUser, mandateId=None).db
ok = db.recordDelete(UiLanguageSet, c)
if not ok:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
await _reloadI18nCache()
return {"deleted": c}
@ -780,7 +817,7 @@ async def download_language_set(
db = _publicMgmtDb()
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code.strip().lower()})
if not rows:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
payload = _row_to_public(rows[0])
raw = json.dumps(payload, ensure_ascii=False, indent=2)
return Response(
@ -828,7 +865,7 @@ async def import_language_sets(
adminUser: User = Depends(requireSysAdminRole),
):
if not file.filename or not file.filename.endswith(".json"):
raise HTTPException(status_code=400, detail="Nur .json-Dateien erlaubt.")
raise HTTPException(status_code=400, detail=routeApiMsg("Nur .json-Dateien erlaubt."))
try:
raw = await file.read()
@ -837,7 +874,7 @@ async def import_language_sets(
raise HTTPException(status_code=400, detail=f"Ungültiges JSON: {e}")
if not isinstance(data, list):
raise HTTPException(status_code=400, detail="JSON muss ein Array von Sprachsets sein.")
raise HTTPException(status_code=400, detail=routeApiMsg("JSON muss ein Array von Sprachsets sein."))
db = getMgmtInterface(adminUser, mandateId=None).db
now = getUtcTimestamp()
@ -893,4 +930,44 @@ async def import_language_sets(
created.append(code)
logger.info("i18n import: created=%s, updated=%s", created, updated)
await _reloadI18nCache()
return {"created": created, "updated": updated, "totalProcessed": len(created) + len(updated)}
# ---------------------------------------------------------------------------
# Phase 7b: translate-field — on-demand translation for TextMultilingual fields
# ---------------------------------------------------------------------------
_TRANSLATE_FIELD_MAX_LEN = 2000
class TranslateFieldRequest(BaseModel):
sourceText: str = Field(..., min_length=1, max_length=_TRANSLATE_FIELD_MAX_LEN)
sourceLang: str = Field(default="de", min_length=2, max_length=5)
targetLangs: List[str] = Field(..., min_length=1)
@router.post("/translate-field")
async def translateField(
body: TranslateFieldRequest,
request: Request,
currentUser: User = Depends(getCurrentUser),
):
"""Translate a single text into one or more target languages (for TextMultilingual fields)."""
targets = [c for c in body.targetLangs if c != body.sourceLang]
if not targets:
return {"translations": {}}
mandateId = _resolveMandateIdForAiI18n(request, currentUser)
billingCb = _makeBillingCallback(currentUser, mandateId)
results: Dict[str, str] = {}
for targetCode in targets:
targetLabel = _ISO_LABELS.get(targetCode, targetCode)
keysToTranslate = {body.sourceText: "TextMultilingual field"}
translated = await _translateBatch(keysToTranslate, targetLabel, targetCode, billingCb)
val = translated.get(body.sourceText, "")
if val:
results[targetCode] = val
return {"translations": results}

View file

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

View file

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

View file

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

View file

@ -64,6 +64,8 @@ from modules.routes.routeRealEstateScraping import (
# Import attribute utilities for model schema
from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeRealEstate")
# Configure logger
logger = logging.getLogger(__name__)
@ -308,7 +310,7 @@ async def update_project(
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
updated = interface.updateProjekt(projectId, data)
if not updated:
raise HTTPException(status_code=500, detail="Update failed")
raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
return updated
@ -329,7 +331,7 @@ async def delete_project(
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
if not interface.deleteProjekt(projectId):
raise HTTPException(status_code=500, detail="Delete failed")
raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
# ----- Parcels CRUD -----
@ -429,7 +431,7 @@ async def update_parcel(
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
updated = interface.updateParzelle(parcelId, data)
if not updated:
raise HTTPException(status_code=500, detail="Update failed")
raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
return updated
@ -450,7 +452,7 @@ async def delete_parcel(
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
if not interface.deleteParzelle(parcelId):
raise HTTPException(status_code=500, detail="Delete failed")
raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
# ============================================================================
@ -495,7 +497,7 @@ async def process_command(
logger.warning(f"CSRF token missing for POST /api/realestate/command from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -503,7 +505,7 @@ async def process_command(
logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -513,7 +515,7 @@ async def process_command(
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Processing command request from user {currentUser.id} (mandate: {currentUser.mandateId})")
@ -566,7 +568,7 @@ async def get_available_tables(
logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -574,7 +576,7 @@ async def get_available_tables(
logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -584,7 +586,7 @@ async def get_available_tables(
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Getting available tables for user {currentUser.id} (mandate: {currentUser.mandateId})")
@ -675,7 +677,7 @@ async def get_table_data(
logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -683,7 +685,7 @@ async def get_table_data(
logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -693,7 +695,7 @@ async def get_table_data(
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Getting table data for '{table}' from user {currentUser.id} (mandate: {currentUser.mandateId})")
@ -844,7 +846,7 @@ async def create_table_record(
logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -852,7 +854,7 @@ async def create_table_record(
logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -862,7 +864,7 @@ async def create_table_record(
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Special handling for Projekt with parcel data
@ -874,7 +876,7 @@ async def create_table_record(
if not label:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="label is required"
detail=routeApiMsg("label is required")
)
status_prozess = data.get("statusProzess", "Eingang")
@ -887,7 +889,7 @@ async def create_table_record(
if not isinstance(parzellen_data, list):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="parzellen must be an array"
detail=routeApiMsg("parzellen must be an array")
)
elif "parzelle" in data:
# Single parcel (backward compatibility)
@ -898,7 +900,7 @@ async def create_table_record(
if not parzellen_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="parzelle or parzellen data is required"
detail=routeApiMsg("parzelle or parzellen data is required")
)
# Use helper function to create project with parcel data
@ -1073,7 +1075,7 @@ async def search_parcel(
logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
logger.info(f"Searching parcel for user {currentUser.id} (mandate: {currentUser.mandateId}) with location: {location}")
@ -2059,21 +2061,21 @@ async def add_parcel_to_project(
logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Validate CSRF token format
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
try:
int(csrf_token, 16)
except ValueError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Adding parcel to project {projekt_id} for user {currentUser.id} (mandate: {currentUser.mandateId})")
@ -2294,7 +2296,7 @@ async def get_bzo_information(
logger.warning(f"CSRF token missing for GET /api/realestate/bzo-information from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -2302,7 +2304,7 @@ async def get_bzo_information(
logger.warning(f"Invalid CSRF token format for GET /api/realestate/bzo-information from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -2312,7 +2314,7 @@ async def get_bzo_information(
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/bzo-information from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {currentUser.mandateId})")

View file

@ -36,6 +36,8 @@ from modules.connectors.connectorOerebWfs import OerebWfsConnector
# Import Tavily connector for BZO document search
from modules.aicore.aicorePluginTavily import AiTavily
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeRealEstateScraping")
# Configure logger
logger = logging.getLogger(__name__)
@ -107,7 +109,7 @@ async def scrape_switzerland_route(
logger.warning(f"CSRF token missing for POST /api/realestate/scrape-switzerland from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -115,7 +117,7 @@ async def scrape_switzerland_route(
logger.warning(f"Invalid CSRF token format for POST /api/realestate/scrape-switzerland from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -125,7 +127,7 @@ async def scrape_switzerland_route(
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/scrape-switzerland from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Extract parameters from body with defaults
@ -137,19 +139,19 @@ async def scrape_switzerland_route(
if grid_size <= 0 or grid_size > 10000:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="grid_size must be between 0 and 10000 meters"
detail=routeApiMsg("grid_size must be between 0 and 10000 meters")
)
if max_concurrent <= 0 or max_concurrent > 200:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="max_concurrent must be between 1 and 200"
detail=routeApiMsg("max_concurrent must be between 1 and 200")
)
if batch_size <= 0 or batch_size > 1000:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="batch_size must be between 1 and 1000"
detail=routeApiMsg("batch_size must be between 1 and 1000")
)
logger.info(
@ -246,7 +248,7 @@ async def get_all_gemeinden(
logger.warning(f"CSRF token missing for GET /api/realestate/gemeinden from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -254,7 +256,7 @@ async def get_all_gemeinden(
logger.warning(f"Invalid CSRF token format for GET /api/realestate/gemeinden from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -264,7 +266,7 @@ async def get_all_gemeinden(
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/gemeinden from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Fetching all Gemeinden for user {currentUser.id} (mandate: {currentUser.mandateId}), only_current={only_current}")
@ -548,7 +550,7 @@ async def fetch_bzo_documents(
logger.warning(f"CSRF token missing for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
# Basic CSRF token format validation
@ -556,7 +558,7 @@ async def fetch_bzo_documents(
logger.warning(f"Invalid CSRF token format for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
# Validate token is hex string
@ -566,7 +568,7 @@ async def fetch_bzo_documents(
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format"
detail=routeApiMsg("Invalid CSRF token format")
)
logger.info(f"Starting BZO document fetch for user {currentUser.id} (mandate: {currentUser.mandateId})")

View file

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

View file

@ -19,6 +19,8 @@ from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatu
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeSecurityClickup")
logger = logging.getLogger(__name__)
@ -53,7 +55,7 @@ def _require_clickup_config():
if not CLIENT_ID or not CLIENT_SECRET or not REDIRECT_URI:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="ClickUp OAuth is not configured (Service_CLICKUP_CLIENT_ID, Service_CLICKUP_CLIENT_SECRET, Service_CLICKUP_OAUTH_REDIRECT_URI)",
detail=routeApiMsg("ClickUp OAuth is not configured (Service_CLICKUP_CLIENT_ID, Service_CLICKUP_CLIENT_SECRET, Service_CLICKUP_OAUTH_REDIRECT_URI)"),
)
@ -87,7 +89,7 @@ def auth_connect(
connection = conn
break
if not connection:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ClickUp connection not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("ClickUp connection not found"))
state_jwt = _issue_oauth_state(
{
@ -123,11 +125,11 @@ async def auth_connect_callback(
"""OAuth callback for ClickUp data connection."""
state_data = _parse_oauth_state(state)
if state_data.get("flow") != _FLOW_CONNECT:
raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback")
raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback"))
connection_id = state_data.get("connectionId")
user_id = state_data.get("userId")
if not connection_id or not user_id:
raise HTTPException(status_code=400, detail="Missing connection or user in OAuth state")
raise HTTPException(status_code=400, detail=routeApiMsg("Missing connection or user in OAuth state"))
_require_clickup_config()

View file

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

View file

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

View file

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

View file

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

View file

@ -23,6 +23,8 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService
from modules.security.rbac import RbacClass
from modules.security.rootAccess import getRootDbAppConnector
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeStore")
logger = logging.getLogger(__name__)
@ -327,7 +329,7 @@ def activateStoreFeature(
mandateId = data.mandateId
if not _isUserAdminInMandate(db, userId, mandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not admin in target mandate")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Not admin in target mandate"))
# ── 1. Resolve subscription & plan ──────────────────────────────
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS, SubscriptionStatusEnum
@ -353,7 +355,7 @@ def activateStoreFeature(
)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="Kein aktives Abonnement. Bitte zuerst ein Abo abschliessen.",
detail=routeApiMsg("Kein aktives Abonnement. Bitte zuerst ein Abo abschliessen."),
)
planKey = operative.get("planKey", "")
@ -382,7 +384,7 @@ def activateStoreFeature(
)
if not instance:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create feature instance")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Failed to create feature instance"))
instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
@ -460,12 +462,12 @@ def deactivateStoreFeature(
# Verify instance exists in mandate
instances = db.getRecordset(FeatureInstance, recordFilter={"id": instanceId, "mandateId": mandateId})
if not instances:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature instance not found in mandate")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Feature instance not found in mandate"))
# Find user's FeatureAccess
accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId})
if not accesses:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No active access found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("No active access found"))
featureAccessId = accesses[0].get("id")
db.recordDelete(FeatureAccess, featureAccessId)

View file

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

View file

@ -12,7 +12,7 @@ Navigation API Konzept:
import logging
from typing import Dict, List, Any, Optional
from fastapi import APIRouter, Depends, Request, Query
from fastapi import APIRouter, Depends, Request
from slowapi import Limiter
from slowapi.util import get_remote_address
@ -130,11 +130,11 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
def _buildDynamicBlock(
userId: str,
language: str,
isSysAdmin: bool
) -> Optional[Dict[str, Any]]:
"""
Build the dynamic features block with mandates, features, and instances.
Labels are German base texts (i18n keys). Frontend translates via t().
Returns None if user has no feature instances.
"""
@ -181,21 +181,29 @@ def _buildDynamicBlock(
if featureKey not in featuresMap:
feature = featureInterface.getFeature(instance.featureCode)
# Handle featureLabel - could be a dict or a Pydantic model (TextMultilingual)
# Handle featureLabel — TextMultilingual dict, plain str (German key), or legacy object
if feature and hasattr(feature, 'label'):
featureLabel = feature.label
# Convert Pydantic model to dict if needed
if hasattr(featureLabel, 'model_dump'):
featureLabel = featureLabel.model_dump()
elif isinstance(featureLabel, str):
pass
elif not isinstance(featureLabel, dict):
# Fallback: try to access as attributes
featureLabel = {"de": getattr(featureLabel, 'de', instance.featureCode), "en": getattr(featureLabel, 'en', instance.featureCode)}
featureLabel = {
"de": getattr(featureLabel, 'de', instance.featureCode),
"en": getattr(featureLabel, 'en', instance.featureCode),
}
else:
featureLabel = {"de": instance.featureCode, "en": instance.featureCode}
if isinstance(featureLabel, str):
resolvedFeatureLabel = featureLabel
else:
resolvedFeatureLabel = featureLabel.get("de", featureLabel.get("en", instance.featureCode))
featuresMap[featureKey] = {
"uiComponent": f"feature.{instance.featureCode}",
"uiLabel": featureLabel.get(language, featureLabel.get("en", instance.featureCode)),
"uiLabel": resolvedFeatureLabel,
"order": 10,
"instances": [],
"_mandateId": mandateId,
@ -228,9 +236,8 @@ def _buildDynamicBlock(
# Build path for this view
viewPath = f"/mandates/{mandateId}/{instance.featureCode}/{instance.id}/{viewName}"
# Get label in requested language
label = uiObj.get("label", {})
uiLabel = label.get(language, label.get("en", viewName))
uiLabel = label.get("de", label.get("en", viewName)) if isinstance(label, dict) else label
views.append({
"uiComponent": f"page.feature.{instance.featureCode}.{viewName}",
@ -347,7 +354,6 @@ def _getInstanceViewPermissions(
def _filterItems(
items: List[Dict[str, Any]],
language: str,
isSysAdmin: bool,
roleIds: List[str],
hasGlobalPermission: bool
@ -361,19 +367,18 @@ def _filterItems(
if item.get("sysAdminOnly") and not isSysAdmin:
continue
if item.get("public"):
filteredItems.append(_formatBlockItem(item, language))
filteredItems.append(_formatBlockItem(item))
continue
if isSysAdmin:
filteredItems.append(_formatBlockItem(item, language))
filteredItems.append(_formatBlockItem(item))
continue
if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]):
filteredItems.append(_formatBlockItem(item, language))
filteredItems.append(_formatBlockItem(item))
filteredItems.sort(key=lambda i: i["order"])
return filteredItems
def _buildStaticBlocks(
language: str,
isSysAdmin: bool,
roleIds: List[str],
hasGlobalPermission: bool
@ -381,8 +386,8 @@ def _buildStaticBlocks(
"""
Build static navigation blocks from NAVIGATION_SECTIONS.
Returns list of blocks with items filtered by permissions.
Supports subgroups within sections.
Labels/titles are plain German strings (i18n base keys).
The frontend translates them via t().
"""
blocks = []
@ -397,12 +402,12 @@ def _buildStaticBlocks(
filteredSubgroups = []
for subgroup in section["subgroups"]:
subItems = _filterItems(
subgroup.get("items", []), language, isSysAdmin, roleIds, hasGlobalPermission
subgroup.get("items", []), isSysAdmin, roleIds, hasGlobalPermission
)
if subItems:
filteredSubgroups.append({
"id": subgroup["id"],
"title": subgroup["title"].get(language, subgroup["title"].get("en", subgroup["id"])),
"title": subgroup["title"],
"order": subgroup.get("order", 50),
"items": subItems,
})
@ -412,28 +417,28 @@ def _buildStaticBlocks(
topLevelItems = []
if hasItems:
topLevelItems = _filterItems(
section["items"], language, isSysAdmin, roleIds, hasGlobalPermission
section["items"], isSysAdmin, roleIds, hasGlobalPermission
)
if filteredSubgroups or topLevelItems:
blocks.append({
"type": "static",
"id": section["id"],
"title": section["title"].get(language, section["title"].get("en", section["id"])),
"title": section["title"],
"order": section.get("order", 50),
"items": topLevelItems,
"subgroups": filteredSubgroups,
})
else:
filteredItems = _filterItems(
section.get("items", []), language, isSysAdmin, roleIds, hasGlobalPermission
section.get("items", []), isSysAdmin, roleIds, hasGlobalPermission
)
if filteredItems:
blocks.append({
"type": "static",
"id": section["id"],
"title": section["title"].get(language, section["title"].get("en", section["id"])),
"title": section["title"],
"order": section.get("order", 50),
"items": filteredItems,
})
@ -441,19 +446,19 @@ def _buildStaticBlocks(
return blocks
def _formatBlockItem(item: Dict[str, Any], language: str) -> Dict[str, Any]:
def _formatBlockItem(item: Dict[str, Any]) -> Dict[str, Any]:
"""
Format a navigation item for the new API response.
Format a navigation item for the API response.
Uses new field names: uiComponent, uiLabel, uiPath
Does NOT include icon (UI maps via uiComponent)
Labels are plain German strings (i18n base keys).
The frontend translates them via t().
"""
objectKey = item["objectKey"]
uiComponent = _objectKeyToUiComponent(objectKey)
return {
"uiComponent": uiComponent,
"uiLabel": item["label"].get(language, item["label"].get("en", item["id"])),
"uiLabel": item["label"],
"uiPath": item["path"],
"order": item.get("order", 50),
"objectKey": objectKey,
@ -464,52 +469,15 @@ def _formatBlockItem(item: Dict[str, Any], language: str) -> Dict[str, Any]:
@limiter.limit("60/minute")
def get_navigation(
request: Request,
language: str = Query("de", description="Language for labels (en, de, fr)"),
reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get unified navigation structure with blocks.
Single Source of Truth für Navigation - UI rendert nur was es erhält.
All labels are German base texts (i18n keys).
The frontend translates them via t().
Endpoint: GET /api/navigation
Block order:
- System (10)
- Dynamic/Features (15) - only if user has feature instances
- Workflows (20)
- Basisdaten (30)
- Migrate (40)
- Administration (200)
Response format:
{
"language": "de",
"blocks": [
{
"type": "static",
"id": "system",
"title": "SYSTEM",
"order": 10,
"items": [
{
"uiComponent": "page.system.home",
"uiLabel": "Übersicht",
"uiPath": "/",
"order": 10,
"objectKey": "ui.system.home"
}
]
},
{
"type": "dynamic",
"id": "features",
"title": "MEINE FEATURES",
"order": 15,
"mandates": [...]
}
]
}
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
@ -526,11 +494,11 @@ def get_navigation(
hasGlobalPermission = _checkUiPermission(roleIds, "_global_check")
# Build static blocks from NAVIGATION_SECTIONS
blocks = _buildStaticBlocks(language, isSysAdmin, roleIds, hasGlobalPermission)
blocks = _buildStaticBlocks(isSysAdmin, roleIds, hasGlobalPermission)
# Build dynamic block (features) if user has feature instances
if userId:
dynamicBlock = _buildDynamicBlock(userId, language, isSysAdmin)
dynamicBlock = _buildDynamicBlock(userId, isSysAdmin)
if dynamicBlock:
blocks.append(dynamicBlock)
@ -538,14 +506,12 @@ def get_navigation(
blocks.sort(key=lambda b: b["order"])
return {
"language": language,
"blocks": blocks,
}
except Exception as e:
logger.error(f"Error getting navigation: {e}")
return {
"language": language,
"blocks": [],
"error": str(e),
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -36,42 +36,44 @@ class AttributeDefinition(BaseModel):
placeholder: Optional[str] = None
# Global registry for model labels
MODEL_LABELS: Dict[str, Dict[str, Dict[str, str]]] = {}
def registerModelLabels(modelName: str, modelLabel: Dict[str, str], labels: Dict[str, Dict[str, str]]):
"""
Register labels for a model's attributes and the model itself.
Args:
modelName: Name of the model class
modelLabel: Dictionary mapping language codes to model labels
e.g. {"en": "Prompt", "fr": "Invite"}
labels: Dictionary mapping attribute names to their translations
e.g. {"name": {"en": "Name", "fr": "Nom"}}
"""
MODEL_LABELS[modelName] = {"model": modelLabel, "attributes": labels}
def _getModelLabelEntry(modelName: str) -> Dict[str, Any]:
"""Resolve label data produced by @i18nModel (see modules.shared.i18nRegistry.MODEL_LABELS)."""
try:
from modules.shared.i18nRegistry import MODEL_LABELS as i18nModelLabels
except ImportError:
return {}
return i18nModelLabels.get(modelName) or {}
def getModelLabels(modelName: str, language: str = "en") -> Dict[str, str]:
"""
Get labels for a model's attributes in the specified language.
"""Get labels for a model's attributes in the specified language.
Args:
modelName: Name of the model class
language: Language code (default: "en")
Returns:
Dictionary mapping attribute names to their labels in the specified language
Reads @i18nModel registration (German base strings); non-German languages use the i18n cache.
Attribute values are strings; dict-shaped entries are still accepted for unusual callers.
"""
modelData = MODEL_LABELS.get(modelName, {})
modelData = _getModelLabelEntry(modelName)
attributeLabels = modelData.get("attributes", {})
return {
attr: translations.get(language, translations.get("en", attr))
for attr, translations in attributeLabels.items()
}
result: Dict[str, str] = {}
for attr, translations in attributeLabels.items():
if isinstance(translations, dict):
result[attr] = translations.get(language, translations.get("en", attr))
elif isinstance(translations, str):
result[attr] = _resolveLabel(translations, language)
else:
result[attr] = attr
return result
def _resolveLabel(germanText: str, language: str) -> str:
"""Resolve a German base label to the requested language via i18n cache."""
if language == "de":
return germanText
try:
from modules.shared.i18nRegistry import _CACHE
return _CACHE.get(language, {}).get(germanText, germanText)
except ImportError:
return germanText
def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Dict[str, str]:
@ -87,19 +89,14 @@ def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Di
def getModelLabel(modelName: str, language: str = "en") -> str:
"""
Get the label for a model in the specified language.
Args:
modelName: Name of the model class
language: Language code (default: "en")
Returns:
Model label in the specified language, or model name if no label exists
"""
modelData = MODEL_LABELS.get(modelName, {})
"""Get the label for a model in the specified language (see getModelLabels)."""
modelData = _getModelLabelEntry(modelName)
modelLabel = modelData.get("model", {})
return modelLabel.get(language, modelLabel.get("en", modelName))
if isinstance(modelLabel, dict):
return modelLabel.get(language, modelLabel.get("en", modelName))
elif isinstance(modelLabel, str):
return _resolveLabel(modelLabel, language)
return modelName
def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]:

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