harmonized module names

This commit is contained in:
ValueOn AG 2026-01-19 09:18:37 +01:00
parent 5c0ab3f893
commit ccc41e7023
100 changed files with 3756 additions and 432 deletions

6
app.py
View file

@ -292,6 +292,10 @@ async def lifespan(app: FastAPI):
# --- Init Managers --- # --- Init Managers ---
await featuresLifecycle.start(eventUser) await featuresLifecycle.start(eventUser)
eventManager.start() eventManager.start()
# Register audit log cleanup scheduler
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler()
yield yield
@ -444,7 +448,7 @@ app.include_router(sharepointRouter)
from modules.routes.routeDataAutomation import router as automationRouter from modules.routes.routeDataAutomation import router as automationRouter
app.include_router(automationRouter) app.include_router(automationRouter)
from modules.routes.routeFeatureAutomation import router as adminAutomationEventsRouter from modules.routes.routeFeatureWorkflow import router as adminAutomationEventsRouter
app.include_router(adminAutomationEventsRouter) app.include_router(adminAutomationEventsRouter)
from modules.routes.routeRbac import router as rbacRouter from modules.routes.routeRbac import router as rbacRouter

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,208 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Audit Log Data Model for database-based audit logging.
This model stores security-relevant audit events for GDPR compliance and security monitoring.
GDPR-Relevant Events:
- User access: login, logout, failed login attempts
- Data access: create, read, update, delete operations on personal data
- Security events: password changes, token refresh, session management
- Key access: encryption/decryption of sensitive data
- GDPR actions: data export, data portability, account deletion
- Mandate/permission changes: user added/removed from mandates, role changes
"""
from typing import Optional
from pydantic import BaseModel, Field
from enum import Enum
import uuid
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.attributeUtils import registerModelLabels
class AuditCategory(str, Enum):
"""Categories for audit log entries"""
ACCESS = "access" # Login/logout events
KEY = "key" # Encryption key access
DATA = "data" # Data CRUD operations
SECURITY = "security" # Security-related events
GDPR = "gdpr" # GDPR-specific actions
PERMISSION = "permission" # Permission/role changes
SYSTEM = "system" # System-level events
class AuditAction(str, Enum):
"""Actions for audit log entries"""
# Access actions
LOGIN = "login"
LOGIN_FAILED = "login_failed"
LOGOUT = "logout"
TOKEN_REFRESH = "token_refresh"
TOKEN_REVOKE = "token_revoke"
SESSION_EXPIRED = "session_expired"
# Key actions
KEY_ENCODE = "encode"
KEY_DECODE = "decode"
KEY_ACCESS = "key_access"
# Data actions
DATA_CREATE = "create"
DATA_READ = "read"
DATA_UPDATE = "update"
DATA_DELETE = "delete"
DATA_EXPORT = "export"
# Security actions
PASSWORD_CHANGE = "password_change"
PASSWORD_RESET = "password_reset"
MFA_ENABLED = "mfa_enabled"
MFA_DISABLED = "mfa_disabled"
# GDPR actions
GDPR_DATA_EXPORT = "gdpr_data_export"
GDPR_DATA_PORTABILITY = "gdpr_data_portability"
GDPR_ACCOUNT_DELETION = "gdpr_account_deletion"
GDPR_CONSENT_UPDATE = "gdpr_consent_update"
# Permission actions
USER_ADDED_TO_MANDATE = "user_added_to_mandate"
USER_REMOVED_FROM_MANDATE = "user_removed_from_mandate"
ROLE_ASSIGNED = "role_assigned"
ROLE_REVOKED = "role_revoked"
FEATURE_ACCESS_GRANTED = "feature_access_granted"
FEATURE_ACCESS_REVOKED = "feature_access_revoked"
# System actions
SYSTEM_STARTUP = "system_startup"
SYSTEM_SHUTDOWN = "system_shutdown"
CONFIG_CHANGE = "config_change"
class AuditLogEntry(BaseModel):
"""
Audit log entry for database storage.
Stores all security-relevant events for compliance and monitoring.
Entries are immutable once created (append-only audit trail).
"""
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}
)
# 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}
)
# 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}
)
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}
)
# Context
mandateId: Optional[str] = Field(
default=None,
description="Mandate context (if applicable)",
json_schema_extra={"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}
)
# 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}
)
action: str = Field(
description="Specific action performed",
json_schema_extra={"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}
)
resourceId: Optional[str] = Field(
default=None,
description="ID of the affected resource",
json_schema_extra={"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}
)
# 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}
)
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}
)
# Outcome
success: bool = Field(
default=True,
description="Whether the action was successful",
json_schema_extra={"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}
)
# 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

@ -14,6 +14,12 @@ class ChatStat(BaseModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
mandateId: str = Field(
description="ID of the mandate this stat belongs to"
)
featureInstanceId: str = Field(
description="ID of the feature instance this stat belongs to"
)
workflowId: Optional[str] = Field( workflowId: Optional[str] = Field(
None, description="Foreign key to workflow (for workflow stats)" None, description="Foreign key to workflow (for workflow stats)"
) )
@ -33,6 +39,8 @@ registerModelLabels(
{"en": "Chat Statistics", "fr": "Statistiques de chat"}, {"en": "Chat Statistics", "fr": "Statistiques de chat"},
{ {
"id": {"en": "ID", "fr": "ID"}, "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é"},
"workflowId": {"en": "Workflow ID", "fr": "ID du workflow"}, "workflowId": {"en": "Workflow ID", "fr": "ID du workflow"},
"processingTime": {"en": "Processing Time", "fr": "Temps de traitement"}, "processingTime": {"en": "Processing Time", "fr": "Temps de traitement"},
"bytesSent": {"en": "Bytes Sent", "fr": "Octets envoyés"}, "bytesSent": {"en": "Bytes Sent", "fr": "Octets envoyés"},
@ -49,6 +57,12 @@ class ChatLog(BaseModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
mandateId: str = Field(
description="ID of the mandate this log belongs to"
)
featureInstanceId: str = Field(
description="ID of the feature instance this log belongs to"
)
workflowId: str = Field(description="Foreign key to workflow") workflowId: str = Field(description="Foreign key to workflow")
message: str = Field(description="Log message") message: str = Field(description="Log message")
type: str = Field(description="Log type (info, warning, error, etc.)") type: str = Field(description="Log type (info, warning, error, etc.)")
@ -79,6 +93,8 @@ registerModelLabels(
{"en": "Chat Log", "fr": "Journal de chat"}, {"en": "Chat Log", "fr": "Journal de chat"},
{ {
"id": {"en": "ID", "fr": "ID"}, "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é"},
"workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"}, "workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"},
"message": {"en": "Message", "fr": "Message"}, "message": {"en": "Message", "fr": "Message"},
"type": {"en": "Type", "fr": "Type"}, "type": {"en": "Type", "fr": "Type"},
@ -94,6 +110,12 @@ class ChatDocument(BaseModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
mandateId: str = Field(
description="ID of the mandate this document belongs to"
)
featureInstanceId: str = Field(
description="ID of the feature instance this document belongs to"
)
messageId: str = Field(description="Foreign key to message") messageId: str = Field(description="Foreign key to message")
fileId: str = Field(description="Foreign key to file") fileId: str = Field(description="Foreign key to file")
fileName: str = Field(description="Name of the file") fileName: str = Field(description="Name of the file")
@ -112,6 +134,8 @@ registerModelLabels(
{"en": "Chat Document", "fr": "Document de chat"}, {"en": "Chat Document", "fr": "Document de chat"},
{ {
"id": {"en": "ID", "fr": "ID"}, "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é"},
"messageId": {"en": "Message ID", "fr": "ID du message"}, "messageId": {"en": "Message ID", "fr": "ID du message"},
"fileId": {"en": "File ID", "fr": "ID du fichier"}, "fileId": {"en": "File ID", "fr": "ID du fichier"},
"fileName": {"en": "File Name", "fr": "Nom du fichier"}, "fileName": {"en": "File Name", "fr": "Nom du fichier"},
@ -200,6 +224,12 @@ class ChatMessage(BaseModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
mandateId: str = Field(
description="ID of the mandate this message belongs to"
)
featureInstanceId: str = Field(
description="ID of the feature instance this message belongs to"
)
workflowId: str = Field(description="Foreign key to workflow") workflowId: str = Field(description="Foreign key to workflow")
parentMessageId: Optional[str] = Field( parentMessageId: Optional[str] = Field(
None, description="Parent message ID for threading" None, description="Parent message ID for threading"
@ -251,6 +281,8 @@ registerModelLabels(
{"en": "Chat Message", "fr": "Message de chat"}, {"en": "Chat Message", "fr": "Message de chat"},
{ {
"id": {"en": "ID", "fr": "ID"}, "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é"},
"workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"}, "workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"},
"parentMessageId": {"en": "Parent Message ID", "fr": "ID du message parent"}, "parentMessageId": {"en": "Parent Message ID", "fr": "ID du message parent"},
"documents": {"en": "Documents", "fr": "Documents"}, "documents": {"en": "Documents", "fr": "Documents"},
@ -296,6 +328,7 @@ registerModelLabels(
class ChatWorkflow(BaseModel): class ChatWorkflow(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this workflow belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: str = Field(description="ID of the mandate this workflow belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: str = Field(description="ID of the feature instance this workflow belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "running", "label": {"en": "Running", "fr": "En cours"}}, {"value": "running", "label": {"en": "Running", "fr": "En cours"}},
{"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}}, {"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}},
@ -370,6 +403,7 @@ registerModelLabels(
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"status": {"en": "Status", "fr": "Statut"}, "status": {"en": "Status", "fr": "Statut"},
"name": {"en": "Name", "fr": "Nom"}, "name": {"en": "Name", "fr": "Nom"},
"currentRound": {"en": "Current Round", "fr": "Tour actuel"}, "currentRound": {"en": "Current Round", "fr": "Tour actuel"},
@ -993,6 +1027,7 @@ registerModelLabels(
class AutomationDefinition(BaseModel): class AutomationDefinition(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: str = Field(description="ID of the feature instance this automation belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
label: str = Field(description="User-friendly name", json_schema_extra={"frontend_type": "text", "frontend_required": True}) label: str = Field(description="User-friendly name", json_schema_extra={"frontend_type": "text", "frontend_required": True})
schedule: str = Field(description="Cron schedule pattern", json_schema_extra={"frontend_type": "select", "frontend_required": True, "frontend_options": [ schedule: str = Field(description="Cron schedule pattern", json_schema_extra={"frontend_type": "select", "frontend_required": True, "frontend_options": [
{"value": "0 */4 * * *", "label": {"en": "Every 4 hours", "fr": "Toutes les 4 heures"}}, {"value": "0 */4 * * *", "label": {"en": "Every 4 hours", "fr": "Toutes les 4 heures"}},
@ -1013,6 +1048,7 @@ registerModelLabels(
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"label": {"en": "Label", "fr": "Libellé"}, "label": {"en": "Label", "fr": "Libellé"},
"schedule": {"en": "Schedule", "fr": "Planification"}, "schedule": {"en": "Schedule", "fr": "Planification"},
"template": {"en": "Template", "fr": "Modèle"}, "template": {"en": "Template", "fr": "Modèle"},

View file

@ -13,6 +13,7 @@ import base64
class FileItem(BaseModel): class FileItem(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: str = Field(description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: str = Field(description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileName: str = Field(description="Name of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) 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}) 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}) fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
@ -25,6 +26,7 @@ registerModelLabels(
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"fileName": {"en": "fileName", "fr": "Nom de fichier"}, "fileName": {"en": "fileName", "fr": "Nom de fichier"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"}, "mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"fileHash": {"en": "File Hash", "fr": "Hash du fichier"}, "fileHash": {"en": "File Hash", "fr": "Hash du fichier"},

View file

@ -45,6 +45,10 @@ class MessagingSubscription(BaseModel):
description="ID of the mandate this subscription belongs to", description="ID of the mandate this subscription belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
) )
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}
)
description: Optional[str] = Field( description: Optional[str] = Field(
default=None, default=None,
description="Description of the subscription", description="Description of the subscription",
@ -92,6 +96,7 @@ registerModelLabels(
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"}, "subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"subscriptionLabel": {"en": "Subscription Label", "fr": "Label d'abonnement"}, "subscriptionLabel": {"en": "Subscription Label", "fr": "Label d'abonnement"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "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"}, "description": {"en": "Description", "fr": "Description"},
"isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"}, "isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"},
"enabled": {"en": "Enabled", "fr": "Activé"}, "enabled": {"en": "Enabled", "fr": "Activé"},
@ -110,6 +115,14 @@ class MessagingSubscriptionRegistration(BaseModel):
description="Unique ID of the registration", description="Unique ID of the registration",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
) )
mandateId: str = Field(
description="ID of the mandate this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
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}
)
subscriptionId: str = Field( subscriptionId: str = Field(
description="ID of the subscription this registration belongs to", description="ID of the subscription this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
@ -161,6 +174,8 @@ registerModelLabels(
{"en": "Messaging Registration", "fr": "Inscription à la messagerie"}, {"en": "Messaging Registration", "fr": "Inscription à la messagerie"},
{ {
"id": {"en": "ID", "fr": "ID"}, "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"}, "subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"channel": {"en": "Channel", "fr": "Canal"}, "channel": {"en": "Channel", "fr": "Canal"},
@ -179,6 +194,14 @@ class MessagingDelivery(BaseModel):
description="Unique ID of the delivery", description="Unique ID of the delivery",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
) )
mandateId: str = Field(
description="ID of the mandate this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
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}
)
subscriptionId: str = Field( subscriptionId: str = Field(
description="ID of the subscription this delivery belongs to", description="ID of the subscription this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
@ -239,6 +262,8 @@ registerModelLabels(
{"en": "Messaging Delivery", "fr": "Livraison de messagerie"}, {"en": "Messaging Delivery", "fr": "Livraison de messagerie"},
{ {
"id": {"en": "ID", "fr": "ID"}, "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"}, "subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"channel": {"en": "Channel", "fr": "Canal"}, "channel": {"en": "Channel", "fr": "Canal"},

View file

@ -11,6 +11,7 @@ from modules.shared.attributeUtils import registerModelLabels
class DataNeutraliserConfig(BaseModel): class DataNeutraliserConfig(BaseModel):
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}) 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}) 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}) 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}) enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "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}) 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})
@ -22,6 +23,7 @@ registerModelLabels(
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, "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"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"enabled": {"en": "Enabled", "fr": "Activé"}, "enabled": {"en": "Enabled", "fr": "Activé"},
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"}, "namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
@ -33,6 +35,7 @@ registerModelLabels(
class DataNeutralizerAttributes(BaseModel): class DataNeutralizerAttributes(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the attribute mapping (used as UID in neutralized files)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) 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}) 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}) 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}) 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}) 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})
@ -43,6 +46,7 @@ registerModelLabels(
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, "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"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"originalText": {"en": "Original Text", "fr": "Texte original"}, "originalText": {"en": "Original Text", "fr": "Texte original"},
"fileId": {"en": "File ID", "fr": "ID de fichier"}, "fileId": {"en": "File ID", "fr": "ID de fichier"},

View file

@ -123,6 +123,12 @@ class Dokument(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance this document belongs to",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field( label: str = Field(
description="Document label", description="Document label",
frontend_type="text", frontend_type="text",
@ -207,6 +213,12 @@ class Land(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field( label: str = Field(
description="Country name (e.g. 'Schweiz')", description="Country name (e.g. 'Schweiz')",
frontend_type="text", frontend_type="text",
@ -251,6 +263,12 @@ class Kanton(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field( label: str = Field(
description="Canton name (e.g. 'Zürich')", description="Canton name (e.g. 'Zürich')",
frontend_type="text", frontend_type="text",
@ -302,6 +320,12 @@ class Gemeinde(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field( label: str = Field(
description="Municipality name (e.g. 'Zürich')", description="Municipality name (e.g. 'Zürich')",
frontend_type="text", frontend_type="text",
@ -359,6 +383,12 @@ class Parzelle(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
# Grunddaten # Grunddaten
label: str = Field( label: str = Field(
@ -579,6 +609,12 @@ class Projekt(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field( label: str = Field(
description="Project designation", description="Project designation",
frontend_type="text", frontend_type="text",
@ -643,6 +679,7 @@ registerModelLabels(
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"}, "statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, "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"},
}, },
) )
@ -653,6 +690,7 @@ registerModelLabels(
"id": {"en": "ID", "fr": "ID", "de": "ID"}, "id": {"en": "ID", "fr": "ID", "de": "ID"},
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, "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"},
}, },
) )
@ -662,6 +700,8 @@ registerModelLabels(
{ {
"id": {"en": "ID", "fr": "ID", "de": "ID"}, "id": {"en": "ID", "fr": "ID", "de": "ID"},
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, "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

@ -44,6 +44,15 @@ class TrusteeOrganisation(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector: # System attributes are automatically set by DatabaseConnector:
# _createdAt, _modifiedAt, _createdBy, _modifiedBy # _createdAt, _modifiedAt, _createdBy, _modifiedBy
@ -56,6 +65,7 @@ registerModelLabels(
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"}, "enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
@ -87,6 +97,15 @@ class TrusteeRole(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -97,6 +116,7 @@ registerModelLabels(
"id": {"en": "ID", "fr": "ID", "de": "ID"}, "id": {"en": "ID", "fr": "ID", "de": "ID"},
"desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"}, "desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
@ -159,6 +179,15 @@ class TrusteeAccess(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -172,6 +201,7 @@ registerModelLabels(
"userId": {"en": "User", "fr": "Utilisateur", "de": "Benutzer"}, "userId": {"en": "User", "fr": "Utilisateur", "de": "Benutzer"},
"contractId": {"en": "Contract (optional)", "fr": "Contrat (optionnel)", "de": "Vertrag (optional)"}, "contractId": {"en": "Contract (optional)", "fr": "Contrat (optionnel)", "de": "Vertrag (optional)"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
@ -222,6 +252,15 @@ class TrusteeContract(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -234,6 +273,7 @@ registerModelLabels(
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"}, "enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
@ -309,6 +349,15 @@ class TrusteeDocument(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -323,6 +372,7 @@ registerModelLabels(
"documentName": {"en": "Document Name", "fr": "Nom du document", "de": "Dokumentname"}, "documentName": {"en": "Document Name", "fr": "Nom du document", "de": "Dokumentname"},
"documentMimeType": {"en": "MIME Type", "fr": "Type MIME", "de": "MIME-Typ"}, "documentMimeType": {"en": "MIME Type", "fr": "Type MIME", "de": "MIME-Typ"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
@ -477,6 +527,15 @@ class TrusteePosition(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -499,6 +558,7 @@ registerModelLabels(
"vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"}, "vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"},
"vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"}, "vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
@ -562,6 +622,15 @@ class TrusteePositionDocument(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -575,5 +644,6 @@ registerModelLabels(
"documentId": {"en": "Document", "fr": "Document", "de": "Dokument"}, "documentId": {"en": "Document", "fr": "Document", "de": "Dokument"},
"positionId": {"en": "Position", "fr": "Position", "de": "Position"}, "positionId": {"en": "Position", "fr": "Position", "de": "Position"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )

View file

@ -12,6 +12,7 @@ class VoiceSettings(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(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="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
@ -28,6 +29,7 @@ registerModelLabels(
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"sttLanguage": {"en": "STT Language", "fr": "Langue STT"}, "sttLanguage": {"en": "STT Language", "fr": "Langue STT"},
"ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"}, "ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"},
"ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"}, "ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"},

View file

@ -14,7 +14,7 @@ from modules.datamodels.datamodelDocref import DocumentReferenceList
# Forward references for circular imports (use string annotations) # Forward references for circular imports (use string annotations)
if TYPE_CHECKING: if TYPE_CHECKING:
from modules.datamodels.datamodelChat import ChatDocument, ActionResult from modules.datamodels.datamodelChatbot import ChatDocument, ActionResult
from modules.datamodels.datamodelExtraction import ExtractionOptions from modules.datamodels.datamodelExtraction import ExtractionOptions

View file

@ -4,7 +4,7 @@
from typing import Optional, Any, Union, List, Dict, Callable, Awaitable from typing import Optional, Any, Union, List, Dict, Callable, Awaitable
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelChat import ActionResult from modules.datamodels.datamodelChatbot import ActionResult
from modules.shared.frontendTypes import FrontendType from modules.shared.frontendTypes import FrontendType
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels

View file

@ -13,7 +13,7 @@ import asyncio
import re import re
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum, ChatLog, ChatDocument from modules.datamodels.datamodelChatbot import ChatWorkflow, UserInputRequest, WorkflowModeEnum, ChatLog, ChatDocument
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference
@ -335,7 +335,7 @@ async def _emit_log_and_event(
# Emit event directly for streaming (using correct signature) # Emit event directly for streaming (using correct signature)
if created_log and event_manager: if created_log and event_manager:
try: try:
from modules.datamodels.datamodelChat import ChatLog from modules.datamodels.datamodelChatbot import ChatLog
# Convert to dict if it's a Pydantic model # Convert to dict if it's a Pydantic model
if hasattr(created_log, "model_dump"): if hasattr(created_log, "model_dump"):
log_dict = created_log.model_dump() log_dict = created_log.model_dump()

View file

@ -23,7 +23,7 @@ from modules.datamodels.datamodelRealEstate import (
Land, Land,
) )
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface from modules.interfaces.interfaceDbRealEstate import getInterface as getRealEstateInterface
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -12,7 +12,7 @@ import logging
import json import json
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum, AutomationDefinition from modules.datamodels.datamodelChatbot import ChatWorkflow, UserInputRequest, WorkflowModeEnum, AutomationDefinition
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager

View file

@ -3,7 +3,7 @@
""" """
Utility functions for automation feature. Utility functions for automation feature.
Moved from interfaces/interfaceDbChatObjects.py. Moved from interfaces/interfaceDbChatbot.py.
""" """
import json import json

View file

@ -16,7 +16,7 @@ from modules.security.rbac import RbacClass
from modules.datamodels.datamodelRbac import AccessRuleContext from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelChat import ( from modules.datamodels.datamodelChatbot import (
ChatDocument, ChatDocument,
ChatStat, ChatStat,
ChatLog, ChatLog,
@ -178,18 +178,20 @@ class ChatObjects:
Uses the JSON connector for data access with added language support. Uses the JSON connector for data access with added language support.
""" """
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None): def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Initializes the Chat Interface. """Initializes the Chat Interface.
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header) mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
""" """
# Initialize variables # Initialize variables
self.currentUser = currentUser # Store User object directly self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id if currentUser else None self.userId = currentUser.id if currentUser else None
# Use mandateId from parameter (Request-Context), not from user object # Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.rbac = None # RBAC interface self.rbac = None # RBAC interface
# Initialize services # Initialize services
@ -200,7 +202,7 @@ class ChatObjects:
# Set user context if provided # Set user context if provided
if currentUser: if currentUser:
self.setUserContext(currentUser, mandateId=mandateId) self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
# ===== Generic Utility Methods ===== # ===== Generic Utility Methods =====
@ -263,17 +265,19 @@ class ChatObjects:
def _initializeServices(self): def _initializeServices(self):
pass pass
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface. """Sets the user context for the interface.
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header) mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
""" """
self.currentUser = currentUser # Store User object directly self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id self.userId = currentUser.id
# Use mandateId from parameter (Request-Context), not from user object # Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId: if not self.userId:
raise ValueError("Invalid user context: id is required") raise ValueError("Invalid user context: id is required")
@ -603,10 +607,12 @@ class ChatObjects:
If pagination is None: List[Dict[str, Any]] If pagination is None: List[Dict[str, Any]]
If pagination is provided: PaginatedResult with items and metadata If pagination is provided: PaginatedResult with items and metadata
""" """
# Use RBAC filtering # Use RBAC filtering with featureInstanceId for instance-level isolation
filteredWorkflows = getRecordsetWithRBAC(self.db, filteredWorkflows = getRecordsetWithRBAC(self.db,
ChatWorkflow, ChatWorkflow,
self.currentUser self.currentUser,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
) )
# If no pagination requested, return all items (no sorting - frontend handles it) # If no pagination requested, return all items (no sorting - frontend handles it)
@ -638,11 +644,13 @@ class ChatObjects:
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]: def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
"""Returns a workflow by ID if user has access.""" """Returns a workflow by ID if user has access."""
# Use RBAC filtering # Use RBAC filtering with featureInstanceId for instance-level isolation
workflows = getRecordsetWithRBAC(self.db, workflows = getRecordsetWithRBAC(self.db,
ChatWorkflow, ChatWorkflow,
self.currentUser, self.currentUser,
recordFilter={"id": workflowId} recordFilter={"id": workflowId},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
) )
if not workflows: if not workflows:
@ -689,6 +697,12 @@ class ChatObjects:
if "lastActivity" not in workflowData: if "lastActivity" not in workflowData:
workflowData["lastActivity"] = currentTime workflowData["lastActivity"] = currentTime
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in workflowData or not workflowData["mandateId"]:
workflowData["mandateId"] = self.mandateId
if "featureInstanceId" not in workflowData or not workflowData["featureInstanceId"]:
workflowData["featureInstanceId"] = self.featureInstanceId
# Use generic field separation based on ChatWorkflow model # Use generic field separation based on ChatWorkflow model
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData) simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
@ -1009,6 +1023,12 @@ class ChatObjects:
if "actionNumber" not in messageData: if "actionNumber" not in messageData:
messageData["actionNumber"] = workflow.currentAction messageData["actionNumber"] = workflow.currentAction
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in messageData or not messageData["mandateId"]:
messageData["mandateId"] = self.mandateId
if "featureInstanceId" not in messageData or not messageData["featureInstanceId"]:
messageData["featureInstanceId"] = self.featureInstanceId
# Use generic field separation based on ChatMessage model # Use generic field separation based on ChatMessage model
simpleFields, objectFields = self._separateObjectFields(ChatMessage, messageData) simpleFields, objectFields = self._separateObjectFields(ChatMessage, messageData)
@ -1303,6 +1323,12 @@ class ChatObjects:
def createDocument(self, documentData: Dict[str, Any]) -> ChatDocument: def createDocument(self, documentData: Dict[str, Any]) -> ChatDocument:
"""Creates a document for a message in normalized table.""" """Creates a document for a message in normalized table."""
try: try:
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in documentData or not documentData["mandateId"]:
documentData["mandateId"] = self.mandateId
if "featureInstanceId" not in documentData or not documentData["featureInstanceId"]:
documentData["featureInstanceId"] = self.featureInstanceId
# Validate and normalize document data to dict # Validate and normalize document data to dict
document = ChatDocument(**documentData) document = ChatDocument(**documentData)
logger.debug(f"Creating document in database: fileName={document.fileName}, fileId={document.fileId}, messageId={document.messageId}") logger.debug(f"Creating document in database: fileName={document.fileName}, fileId={document.fileId}, messageId={document.messageId}")
@ -1422,6 +1448,12 @@ class ChatObjects:
if "timestamp" not in logData: if "timestamp" not in logData:
logData["timestamp"] = getUtcTimestamp() logData["timestamp"] = getUtcTimestamp()
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in logData or not logData["mandateId"]:
logData["mandateId"] = self.mandateId
if "featureInstanceId" not in logData or not logData["featureInstanceId"]:
logData["featureInstanceId"] = self.featureInstanceId
# Add status information if not present # Add status information if not present
if "status" not in logData and "type" in logData: if "status" not in logData and "type" in logData:
if logData["type"] == "error": if logData["type"] == "error":
@ -1508,6 +1540,12 @@ class ChatObjects:
if "workflowId" not in statData: if "workflowId" not in statData:
raise ValueError("workflowId is required in statData") raise ValueError("workflowId is required in statData")
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in statData or not statData["mandateId"]:
statData["mandateId"] = self.mandateId
if "featureInstanceId" not in statData or not statData["featureInstanceId"]:
statData["featureInstanceId"] = self.featureInstanceId
# Validate the stat data against ChatStat model # Validate the stat data against ChatStat model
stat = ChatStat(**statData) stat = ChatStat(**statData)
@ -1768,9 +1806,11 @@ class ChatObjects:
if "id" not in automationData or not automationData["id"]: if "id" not in automationData or not automationData["id"]:
automationData["id"] = str(uuid.uuid4()) automationData["id"] = str(uuid.uuid4())
# Ensure mandateId is set # Ensure mandateId and featureInstanceId are set for proper data isolation
if "mandateId" not in automationData: if "mandateId" not in automationData:
automationData["mandateId"] = self.mandateId automationData["mandateId"] = self.mandateId
if "featureInstanceId" not in automationData:
automationData["featureInstanceId"] = self.featureInstanceId
# Ensure database connector has correct userId context # Ensure database connector has correct userId context
# The connector should have been initialized with userId, but ensure it's updated # The connector should have been initialized with userId, but ensure it's updated
@ -1894,7 +1934,7 @@ class ChatObjects:
logger.error(f"Error notifying automation change: {str(e)}") logger.error(f"Error notifying automation change: {str(e)}")
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None) -> 'ChatObjects': def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
""" """
Returns a ChatObjects instance for the current user. Returns a ChatObjects instance for the current user.
Handles initialization of database and records. Handles initialization of database and records.
@ -1902,20 +1942,22 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] =
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
""" """
if not currentUser: if not currentUser:
raise ValueError("Invalid user context: user is required") raise ValueError("Invalid user context: user is required")
effectiveMandateId = str(mandateId) if mandateId else None effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Create context key # Create context key including featureInstanceId for proper isolation
contextKey = f"{effectiveMandateId}_{currentUser.id}" contextKey = f"{effectiveMandateId}_{effectiveFeatureInstanceId}_{currentUser.id}"
# Create new instance if not exists # Create new instance if not exists
if contextKey not in _chatInterfaces: if contextKey not in _chatInterfaces:
_chatInterfaces[contextKey] = ChatObjects(currentUser, mandateId=effectiveMandateId) _chatInterfaces[contextKey] = ChatObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else: else:
# Update user context if needed # Update user context if needed
_chatInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId) _chatInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
return _chatInterfaces[contextKey] return _chatInterfaces[contextKey]

View file

@ -76,12 +76,13 @@ class ComponentObjects:
# Initialize standard records if needed # Initialize standard records if needed
self._initRecords() self._initRecords()
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface. """Sets the user context for the interface.
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header) mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
""" """
if not currentUser: if not currentUser:
logger.info("Initializing interface without user context") logger.info("Initializing interface without user context")
@ -91,6 +92,7 @@ class ComponentObjects:
self.userId = currentUser.id self.userId = currentUser.id
# Use mandateId from parameter (Request-Context), not from user object # Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId: if not self.userId:
raise ValueError("Invalid user context: id is required") raise ValueError("Invalid user context: id is required")
@ -986,12 +988,14 @@ class ComponentObjects:
fileSize = len(content) fileSize = len(content)
fileHash = hashlib.sha256(content).hexdigest() fileHash = hashlib.sha256(content).hexdigest()
# Use mandateId from context # Use mandateId and featureInstanceId from context for proper data isolation
mandateId = self.mandateId mandateId = self.mandateId
featureInstanceId = self.featureInstanceId
# Create FileItem instance # Create FileItem instance
fileItem = FileItem( fileItem = FileItem(
mandateId=mandateId, mandateId=mandateId,
featureInstanceId=featureInstanceId,
fileName=uniqueName, fileName=uniqueName,
mimeType=mimeType, mimeType=mimeType,
fileSize=fileSize, fileSize=fileSize,
@ -1327,9 +1331,11 @@ class ComponentObjects:
if "userId" not in settingsData: if "userId" not in settingsData:
settingsData["userId"] = self.userId settingsData["userId"] = self.userId
# Ensure mandateId is set from context # Ensure mandateId and featureInstanceId are set from context
if "mandateId" not in settingsData: if "mandateId" not in settingsData:
settingsData["mandateId"] = self.mandateId settingsData["mandateId"] = self.mandateId
if "featureInstanceId" not in settingsData:
settingsData["featureInstanceId"] = self.featureInstanceId
# Check if settings already exist for this user # Check if settings already exist for this user
existingSettings = self.getVoiceSettings(settingsData["userId"]) existingSettings = self.getVoiceSettings(settingsData["userId"])
@ -1501,9 +1507,11 @@ class ComponentObjects:
if not all(c.isalpha() or c == "_" for c in subscriptionId): if not all(c.isalpha() or c == "_" for c in subscriptionId):
raise ValueError("subscriptionId must contain only letters and underscores") raise ValueError("subscriptionId must contain only letters and underscores")
# Set mandateId from context # Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in subscriptionData: if "mandateId" not in subscriptionData:
subscriptionData["mandateId"] = self.mandateId subscriptionData["mandateId"] = self.mandateId
if "featureInstanceId" not in subscriptionData:
subscriptionData["featureInstanceId"] = self.featureInstanceId
createdRecord = self.db.recordCreate(MessagingSubscription, subscriptionData) createdRecord = self.db.recordCreate(MessagingSubscription, subscriptionData)
if not createdRecord or not createdRecord.get("id"): if not createdRecord or not createdRecord.get("id"):
@ -1605,6 +1613,12 @@ class ComponentObjects:
if "userId" not in registrationData: if "userId" not in registrationData:
registrationData["userId"] = self.userId registrationData["userId"] = self.userId
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in registrationData:
registrationData["mandateId"] = self.mandateId
if "featureInstanceId" not in registrationData:
registrationData["featureInstanceId"] = self.featureInstanceId
createdRecord = self.db.recordCreate(MessagingSubscriptionRegistration, registrationData) createdRecord = self.db.recordCreate(MessagingSubscriptionRegistration, registrationData)
if not createdRecord or not createdRecord.get("id"): if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create registration record") raise ValueError("Failed to create registration record")
@ -1679,6 +1693,13 @@ class ComponentObjects:
def createDelivery(self, delivery: MessagingDelivery) -> Dict[str, Any]: def createDelivery(self, delivery: MessagingDelivery) -> Dict[str, Any]:
"""Creates a new delivery record.""" """Creates a new delivery record."""
deliveryData = delivery.model_dump() if isinstance(delivery, MessagingDelivery) else delivery deliveryData = delivery.model_dump() if isinstance(delivery, MessagingDelivery) else delivery
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in deliveryData or not deliveryData["mandateId"]:
deliveryData["mandateId"] = self.mandateId
if "featureInstanceId" not in deliveryData or not deliveryData["featureInstanceId"]:
deliveryData["featureInstanceId"] = self.featureInstanceId
createdRecord = self.db.recordCreate(MessagingDelivery, deliveryData) createdRecord = self.db.recordCreate(MessagingDelivery, deliveryData)
if not createdRecord or not createdRecord.get("id"): if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create delivery record") raise ValueError("Failed to create delivery record")
@ -1748,7 +1769,7 @@ class ComponentObjects:
return MessagingDelivery(**filteredDeliveries[0]) if filteredDeliveries else None return MessagingDelivery(**filteredDeliveries[0]) if filteredDeliveries else None
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None) -> 'ComponentObjects': def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects':
""" """
Returns a ComponentObjects instance. Returns a ComponentObjects instance.
If currentUser is provided, initializes with user context. If currentUser is provided, initializes with user context.
@ -1757,8 +1778,10 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] =
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
""" """
effectiveMandateId = str(mandateId) if mandateId else None effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Create new instance if not exists # Create new instance if not exists
if "default" not in _instancesManagement: if "default" not in _instancesManagement:
@ -1767,7 +1790,7 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] =
interface = _instancesManagement["default"] interface = _instancesManagement["default"]
if currentUser: if currentUser:
interface.setUserContext(currentUser, mandateId=effectiveMandateId) interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else: else:
logger.info("Returning interface without user context") logger.info("Returning interface without user context")

View file

@ -39,17 +39,19 @@ class RealEstateObjects:
Handles CRUD operations on Real Estate entities. Handles CRUD operations on Real Estate entities.
""" """
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None): def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Initializes the Real Estate Interface. """Initializes the Real Estate Interface.
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header) mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
""" """
self.currentUser = currentUser self.currentUser = currentUser
self.userId = currentUser.id if currentUser else None self.userId = currentUser.id if currentUser else None
# Use mandateId from parameter (Request-Context), not from user object # Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.rbac = None # RBAC interface self.rbac = None # RBAC interface
# Initialize database # Initialize database
@ -57,7 +59,7 @@ class RealEstateObjects:
# Set user context if provided # Set user context if provided
if currentUser: if currentUser:
self.setUserContext(currentUser, mandateId=mandateId) self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
def _initializeDatabase(self): def _initializeDatabase(self):
"""Initialize PostgreSQL database connection.""" """Initialize PostgreSQL database connection."""
@ -107,17 +109,19 @@ class RealEstateObjects:
logger.warning(f"Error ensuring supporting tables exist: {e}") logger.warning(f"Error ensuring supporting tables exist: {e}")
# Don't raise - tables will be created on-demand anyway # Don't raise - tables will be created on-demand anyway
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface. """Sets the user context for the interface.
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header) mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
""" """
self.currentUser = currentUser self.currentUser = currentUser
self.userId = currentUser.id self.userId = currentUser.id
# Use mandateId from parameter (Request-Context), not from user object # Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId: if not self.userId:
raise ValueError("Invalid user context: id is required") raise ValueError("Invalid user context: id is required")
@ -145,9 +149,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Projekt, "create"): if not self.checkRbacPermission(Projekt, "create"):
raise PermissionError(f"User {self.userId} cannot create projects") raise PermissionError(f"User {self.userId} cannot create projects")
# Ensure mandateId is set # Ensure mandateId and featureInstanceId are set for proper data isolation
if not projekt.mandateId: if not projekt.mandateId:
projekt.mandateId = self.mandateId projekt.mandateId = self.mandateId
if not projekt.featureInstanceId:
projekt.featureInstanceId = self.featureInstanceId
# Save to database - use mode='json' to ensure nested Pydantic models are serialized # Save to database - use mode='json' to ensure nested Pydantic models are serialized
self.db.recordCreate(Projekt, projekt.model_dump(mode='json')) self.db.recordCreate(Projekt, projekt.model_dump(mode='json'))
@ -231,8 +237,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Parzelle, "create"): if not self.checkRbacPermission(Parzelle, "create"):
raise PermissionError(f"User {self.userId} cannot create plots") raise PermissionError(f"User {self.userId} cannot create plots")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not parzelle.mandateId: if not parzelle.mandateId:
parzelle.mandateId = self.mandateId parzelle.mandateId = self.mandateId
if not parzelle.featureInstanceId:
parzelle.featureInstanceId = self.featureInstanceId
# Use mode='json' to ensure nested Pydantic models (like GeoPolylinie) are serialized # Use mode='json' to ensure nested Pydantic models (like GeoPolylinie) are serialized
self.db.recordCreate(Parzelle, parzelle.model_dump(mode='json')) self.db.recordCreate(Parzelle, parzelle.model_dump(mode='json'))
@ -438,8 +447,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Dokument, "create"): if not self.checkRbacPermission(Dokument, "create"):
raise PermissionError(f"User {self.userId} cannot create documents") raise PermissionError(f"User {self.userId} cannot create documents")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not dokument.mandateId: if not dokument.mandateId:
dokument.mandateId = self.mandateId dokument.mandateId = self.mandateId
if not dokument.featureInstanceId:
dokument.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Dokument, dokument.model_dump()) self.db.recordCreate(Dokument, dokument.model_dump())
@ -504,8 +516,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Gemeinde, "create"): if not self.checkRbacPermission(Gemeinde, "create"):
raise PermissionError(f"User {self.userId} cannot create municipalities") raise PermissionError(f"User {self.userId} cannot create municipalities")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not gemeinde.mandateId: if not gemeinde.mandateId:
gemeinde.mandateId = self.mandateId gemeinde.mandateId = self.mandateId
if not gemeinde.featureInstanceId:
gemeinde.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Gemeinde, gemeinde.model_dump()) self.db.recordCreate(Gemeinde, gemeinde.model_dump())
@ -570,8 +585,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Kanton, "create"): if not self.checkRbacPermission(Kanton, "create"):
raise PermissionError(f"User {self.userId} cannot create cantons") raise PermissionError(f"User {self.userId} cannot create cantons")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not kanton.mandateId: if not kanton.mandateId:
kanton.mandateId = self.mandateId kanton.mandateId = self.mandateId
if not kanton.featureInstanceId:
kanton.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Kanton, kanton.model_dump()) self.db.recordCreate(Kanton, kanton.model_dump())
@ -636,8 +654,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Land, "create"): if not self.checkRbacPermission(Land, "create"):
raise PermissionError(f"User {self.userId} cannot create countries") raise PermissionError(f"User {self.userId} cannot create countries")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not land.mandateId: if not land.mandateId:
land.mandateId = self.mandateId land.mandateId = self.mandateId
if not land.featureInstanceId:
land.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Land, land.model_dump()) self.db.recordCreate(Land, land.model_dump())
@ -792,7 +813,7 @@ class RealEstateObjects:
raise raise
def getInterface(currentUser: User, mandateId: Optional[str] = None) -> RealEstateObjects: def getInterface(currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> RealEstateObjects:
""" """
Factory function to get or create a Real Estate interface instance for a user. Factory function to get or create a Real Estate interface instance for a user.
Uses singleton pattern per user. Uses singleton pattern per user.
@ -800,16 +821,19 @@ def getInterface(currentUser: User, mandateId: Optional[str] = None) -> RealEsta
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
""" """
effectiveMandateId = str(mandateId) if mandateId else None effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
userKey = f"{currentUser.id}_{effectiveMandateId}" # Include featureInstanceId in key for proper isolation
userKey = f"{currentUser.id}_{effectiveMandateId}_{effectiveFeatureInstanceId}"
if userKey not in _realEstateInterfaces: if userKey not in _realEstateInterfaces:
_realEstateInterfaces[userKey] = RealEstateObjects(currentUser, mandateId=effectiveMandateId) _realEstateInterfaces[userKey] = RealEstateObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else: else:
# Update user context if needed # Update user context if needed
_realEstateInterfaces[userKey].setUserContext(currentUser, mandateId=effectiveMandateId) _realEstateInterfaces[userKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
return _realEstateInterfaces[userKey] return _realEstateInterfaces[userKey]

View file

@ -33,12 +33,13 @@ logger = logging.getLogger(__name__)
_trusteeInterfaces = {} _trusteeInterfaces = {}
def getInterface(currentUser: User, mandateId: Optional[Union[str, uuid.UUID]] = None) -> "TrusteeObjects": def getInterface(currentUser: User, mandateId: Optional[Union[str, uuid.UUID]] = None, featureInstanceId: Optional[str] = None) -> "TrusteeObjects":
"""Get or create a TrusteeObjects instance for the given user context. """Get or create a TrusteeObjects instance for the given user context.
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
""" """
global _trusteeInterfaces global _trusteeInterfaces
@ -46,14 +47,16 @@ def getInterface(currentUser: User, mandateId: Optional[Union[str, uuid.UUID]] =
raise ValueError("Valid user context required") raise ValueError("Valid user context required")
effectiveMandateId = str(mandateId) if mandateId else None effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
cacheKey = f"{currentUser.id}_{effectiveMandateId}" # Include featureInstanceId in cache key for proper isolation
cacheKey = f"{currentUser.id}_{effectiveMandateId}_{effectiveFeatureInstanceId}"
if cacheKey not in _trusteeInterfaces: if cacheKey not in _trusteeInterfaces:
_trusteeInterfaces[cacheKey] = TrusteeObjects(currentUser, mandateId=effectiveMandateId) _trusteeInterfaces[cacheKey] = TrusteeObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else: else:
# Update user context if needed # Update user context if needed
_trusteeInterfaces[cacheKey].setUserContext(currentUser, mandateId=effectiveMandateId) _trusteeInterfaces[cacheKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
return _trusteeInterfaces[cacheKey] return _trusteeInterfaces[cacheKey]
@ -64,17 +67,19 @@ class TrusteeObjects:
Manages trustee organisations, roles, access, contracts, documents, and positions. Manages trustee organisations, roles, access, contracts, documents, and positions.
""" """
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None): def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Initializes the Trustee Interface. """Initializes the Trustee Interface.
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header) mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
""" """
self.currentUser = currentUser self.currentUser = currentUser
self.userId = currentUser.id if currentUser else None self.userId = currentUser.id if currentUser else None
# Use mandateId from parameter (Request-Context), not from user object # Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.rbac = None self.rbac = None
# Initialize database # Initialize database
@ -82,14 +87,15 @@ class TrusteeObjects:
# Set user context if provided # Set user context if provided
if currentUser: if currentUser:
self.setUserContext(currentUser, mandateId=mandateId) self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface. """Sets the user context for the interface.
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header) mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
""" """
if not currentUser: if not currentUser:
logger.info("Initializing interface without user context") logger.info("Initializing interface without user context")
@ -99,6 +105,7 @@ class TrusteeObjects:
self.userId = currentUser.id self.userId = currentUser.id
# Use mandateId from parameter (Request-Context), not from user object # Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId: if not self.userId:
raise ValueError("Invalid user context: id is required") raise ValueError("Invalid user context: id is required")
@ -204,8 +211,10 @@ class TrusteeObjects:
logger.warning(f"User {self.userId} lacks permission to create organisation") logger.warning(f"User {self.userId} lacks permission to create organisation")
return None return None
# Set mandateId from current user # Set mandateId and featureInstanceId from context for proper data isolation
data["mandateId"] = self.mandateId data["mandateId"] = self.mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
# Validate ID format (alphanumeric, hyphens, underscores, 3-50 chars) # Validate ID format (alphanumeric, hyphens, underscores, 3-50 chars)
orgId = data.get("id", "") orgId = data.get("id", "")
@ -307,6 +316,8 @@ class TrusteeObjects:
return None return None
data["mandateId"] = self.mandateId data["mandateId"] = self.mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
roleId = data.get("id", "") roleId = data.get("id", "")
if not roleId: if not roleId:
@ -414,6 +425,8 @@ class TrusteeObjects:
return None return None
data["mandateId"] = self.mandateId data["mandateId"] = self.mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
import uuid import uuid
accessId = data.get("id") or str(uuid.uuid4()) accessId = data.get("id") or str(uuid.uuid4())
@ -603,6 +616,8 @@ class TrusteeObjects:
return None return None
data["mandateId"] = self.mandateId data["mandateId"] = self.mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
import uuid import uuid
contractId = data.get("id") or str(uuid.uuid4()) contractId = data.get("id") or str(uuid.uuid4())
@ -729,6 +744,8 @@ class TrusteeObjects:
return None return None
data["mandateId"] = self.mandateId data["mandateId"] = self.mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
import uuid import uuid
documentId = data.get("id") or str(uuid.uuid4()) documentId = data.get("id") or str(uuid.uuid4())
@ -879,6 +896,8 @@ class TrusteeObjects:
return None return None
data["mandateId"] = self.mandateId data["mandateId"] = self.mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
# Calculate VAT amount if not provided # Calculate VAT amount if not provided
if "vatAmount" not in data or data.get("vatAmount") == 0: if "vatAmount" not in data or data.get("vatAmount") == 0:
@ -1028,6 +1047,8 @@ class TrusteeObjects:
return None return None
data["mandateId"] = self.mandateId data["mandateId"] = self.mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
import uuid import uuid
linkId = data.get("id") or str(uuid.uuid4()) linkId = data.get("id") or str(uuid.uuid4())

View file

@ -13,9 +13,9 @@ import logging
import json import json
# Import interfaces and models # Import interfaces and models
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface from modules.interfaces.interfaceDbChatbot import getInterface as getChatInterface
from modules.auth import getCurrentUser, limiter from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelChat import AutomationDefinition, ChatWorkflow from modules.datamodels.datamodelChatbot import AutomationDefinition, ChatWorkflow
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.features.workflow import executeAutomation from modules.features.workflow import executeAutomation

View file

@ -472,12 +472,15 @@ async def addUserToMandate(
roleIds=data.roleIds roleIds=data.roleIds
) )
# 8. Audit # 8. Audit - Log permission change with IP address
audit_logger.logSecurityEvent( audit_logger.logPermissionChange(
userId=str(context.user.id), userId=str(context.user.id),
mandateId=mandateId, mandateId=mandateId,
action="user_added_to_mandate", action="user_added_to_mandate",
details=f"targetUser={data.targetUserId}, roles={data.roleIds}" targetUserId=data.targetUserId,
details=f"Roles assigned: {data.roleIds}",
resourceType="UserMandate",
resourceId=str(userMandate.id)
) )
logger.info( logger.info(
@ -557,12 +560,14 @@ async def removeUserFromMandate(
# Delete UserMandate (CASCADE will delete UserMandateRole entries) # Delete UserMandate (CASCADE will delete UserMandateRole entries)
rootInterface.deleteUserMandate(targetUserId, mandateId) rootInterface.deleteUserMandate(targetUserId, mandateId)
# Audit # Audit - Log permission change
audit_logger.logSecurityEvent( audit_logger.logPermissionChange(
userId=str(context.user.id), userId=str(context.user.id),
mandateId=mandateId, mandateId=mandateId,
action="user_removed_from_mandate", action="user_removed_from_mandate",
details=f"targetUser={targetUserId}" targetUserId=targetUserId,
details="User removed from mandate",
resourceType="UserMandate"
) )
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {mandateId}") logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {mandateId}")
@ -657,12 +662,15 @@ async def updateUserRolesInMandate(
for roleId in roleIds: for roleId in roleIds:
rootInterface.addRoleToUserMandate(str(membership.id), roleId) rootInterface.addRoleToUserMandate(str(membership.id), roleId)
# Audit # Audit - Log role assignment change
audit_logger.logSecurityEvent( audit_logger.logPermissionChange(
userId=str(context.user.id), userId=str(context.user.id),
mandateId=mandateId, mandateId=mandateId,
action="user_roles_updated_in_mandate", action="role_assigned",
details=f"targetUser={targetUserId}, newRoles={roleIds}" targetUserId=targetUserId,
details=f"New roles: {roleIds}",
resourceType="UserMandateRole",
resourceId=str(membership.id)
) )
logger.info( logger.info(

View file

@ -360,7 +360,9 @@ async def reset_user_password(
userId=str(context.user.id), userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system", mandateId=str(context.mandateId) if context.mandateId else "system",
action="password_reset", action="password_reset",
details=f"Reset password for user {userId}" details=f"Reset password for user {userId}",
ipAddress=request.client.host if request.client else None,
success=True
) )
except Exception: except Exception:
pass pass
@ -439,7 +441,9 @@ async def change_password(
userId=str(context.user.id), userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system", mandateId=str(context.mandateId) if context.mandateId else "system",
action="password_change", action="password_change",
details="User changed their own password" details="User changed their own password",
ipAddress=request.client.host if request.client else None,
success=True
) )
except Exception: except Exception:
pass pass

View file

@ -13,10 +13,10 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Reques
from modules.auth import limiter, getRequestContext, RequestContext from modules.auth import limiter, getRequestContext, RequestContext
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
# Import models # Import models
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelChatbot import ChatWorkflow, UserInputRequest, WorkflowModeEnum
# Import workflow control functions # Import workflow control functions
from modules.features.workflow import chatStart, chatStop from modules.features.workflow import chatStart, chatStop
@ -32,7 +32,7 @@ router = APIRouter(
) )
def _getServiceChat(context: RequestContext): def _getServiceChat(context: RequestContext):
return interfaceDbChatObjects.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) return interfaceDbChatbot.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
# Workflow start endpoint # Workflow start endpoint
@router.post("/start", response_model=ChatWorkflow) @router.post("/start", response_model=ChatWorkflow)

View file

@ -18,11 +18,11 @@ from modules.shared.timeUtils import parseTimestamp
from modules.auth import limiter, getRequestContext, RequestContext from modules.auth import limiter, getRequestContext, RequestContext
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
# Import models # Import models
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelChatbot import ChatWorkflow, UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse
# Import chatbot feature # Import chatbot feature
@ -43,7 +43,7 @@ router = APIRouter(
) )
def _getServiceChat(context: RequestContext): def _getServiceChat(context: RequestContext):
return interfaceDbChatObjects.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) return interfaceDbChatbot.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
# Chatbot streaming endpoint (SSE) # Chatbot streaming endpoint (SSE)
@router.post("/start/stream") @router.post("/start/stream")

View file

@ -26,7 +26,7 @@ from modules.datamodels.datamodelRealEstate import (
) )
# Import interfaces # Import interfaces
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface from modules.interfaces.interfaceDbRealEstate import getInterface as getRealEstateInterface
# Import feature logic for AI-powered commands # Import feature logic for AI-powered commands
from modules.features.realEstate.mainRealEstate import ( from modules.features.realEstate.mainRealEstate import (

View file

@ -3,6 +3,10 @@
""" """
Routes for Trustee feature data management. Routes for Trustee feature data management.
Implements CRUD operations for organisations, roles, access, contracts, documents, and positions. Implements CRUD operations for organisations, roles, access, contracts, documents, and positions.
URL Structure: /api/trustee/{instanceId}/{entity}
- instanceId is the FeatureInstance ID (required for all operations)
- This ensures proper multi-tenant isolation at the URL level
""" """
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response
@ -14,7 +18,9 @@ import json
import io import io
from modules.auth import limiter, getRequestContext, RequestContext from modules.auth import limiter, getRequestContext, RequestContext
from modules.interfaces.interfaceDbTrusteeObjects import getInterface from modules.interfaces.interfaceDbTrustee import getInterface
from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.datamodels.datamodelTrustee import ( from modules.datamodels.datamodelTrustee import (
TrusteeOrganisation, TrusteeOrganisation,
TrusteeRole, TrusteeRole,
@ -59,22 +65,72 @@ def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
return None return None
async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
"""
Validate that the user has access to the feature instance.
Returns the mandateId for the instance.
Args:
instanceId: The FeatureInstance ID from URL
context: The request context with user info
Returns:
mandateId of the instance
Raises:
HTTPException 404 if instance not found
HTTPException 403 if user doesn't have access
"""
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(
status_code=404,
detail=f"Feature instance '{instanceId}' not found"
)
# Verify it's a trustee instance
if instance.featureCode != "trustee":
raise HTTPException(
status_code=400,
detail=f"Instance '{instanceId}' is not a trustee instance"
)
# Verify user has access to this instance
if not context.isSysAdmin:
# Check if user has FeatureAccess for this instance
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(
str(fa.featureInstanceId) == instanceId and fa.enabled
for fa in featureAccesses
)
if not hasAccess:
raise HTTPException(
status_code=403,
detail=f"Access denied to feature instance '{instanceId}'"
)
return str(instance.mandateId)
# ===== Organisation Routes ===== # ===== Organisation Routes =====
@router.get("/organisations", response_model=PaginatedResponse[TrusteeOrganisation]) @router.get("/{instanceId}/organisations", response_model=PaginatedResponse[TrusteeOrganisation])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getOrganisations( async def getOrganisations(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteeOrganisation]: ) -> PaginatedResponse[TrusteeOrganisation]:
"""Get all organisations with optional pagination.""" """Get all organisations for a feature instance with optional pagination."""
logger = logging.getLogger(__name__) mandateId = await _validateInstanceAccess(instanceId, context)
logger.debug(f"getOrganisations called for user {context.user.id}, mandateId: {context.mandateId}")
paginationParams = _parsePagination(pagination) paginationParams = _parsePagination(pagination)
interface = getInterface(context.user, mandateId=context.mandateId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.getAllOrganisations(paginationParams) result = interface.getAllOrganisations(paginationParams)
logger.debug(f"getOrganisations returned {len(result.items)} items")
if paginationParams: if paginationParams:
return PaginatedResponse( return PaginatedResponse(
@ -91,46 +147,55 @@ async def getOrganisations(
return PaginatedResponse(items=result.items, pagination=None) return PaginatedResponse(items=result.items, pagination=None)
@router.get("/organisations/{orgId}", response_model=TrusteeOrganisation) @router.get("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation)
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getOrganisation( async def getOrganisation(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(..., description="Organisation ID"), orgId: str = Path(..., description="Organisation ID"),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeOrganisation: ) -> TrusteeOrganisation:
"""Get a single organisation by ID.""" """Get a single organisation by ID."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
org = interface.getOrganisation(orgId) org = interface.getOrganisation(orgId)
if not org: if not org:
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found") raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
return org return org
@router.post("/organisations", response_model=TrusteeOrganisation, status_code=201) @router.post("/{instanceId}/organisations", response_model=TrusteeOrganisation, status_code=201)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def createOrganisation( async def createOrganisation(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteeOrganisation = Body(...), data: TrusteeOrganisation = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeOrganisation: ) -> TrusteeOrganisation:
"""Create a new organisation.""" """Create a new organisation."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createOrganisation(data.model_dump()) result = interface.createOrganisation(data.model_dump())
if not result: if not result:
raise HTTPException(status_code=400, detail="Failed to create organisation") raise HTTPException(status_code=400, detail="Failed to create organisation")
return result return result
@router.put("/organisations/{orgId}", response_model=TrusteeOrganisation) @router.put("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def updateOrganisation( async def updateOrganisation(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(..., description="Organisation ID"), orgId: str = Path(..., description="Organisation ID"),
data: TrusteeOrganisation = Body(...), data: TrusteeOrganisation = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeOrganisation: ) -> TrusteeOrganisation:
"""Update an organisation.""" """Update an organisation."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getOrganisation(orgId) existing = interface.getOrganisation(orgId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found") raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
@ -141,15 +206,18 @@ async def updateOrganisation(
return result return result
@router.delete("/organisations/{orgId}") @router.delete("/{instanceId}/organisations/{orgId}")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def deleteOrganisation( async def deleteOrganisation(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(..., description="Organisation ID"), orgId: str = Path(..., description="Organisation ID"),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete an organisation.""" """Delete an organisation."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getOrganisation(orgId) existing = interface.getOrganisation(orgId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found") raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
@ -162,16 +230,19 @@ async def deleteOrganisation(
# ===== Role Routes ===== # ===== Role Routes =====
@router.get("/roles", response_model=PaginatedResponse[TrusteeRole]) @router.get("/{instanceId}/roles", response_model=PaginatedResponse[TrusteeRole])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getRoles( async def getRoles(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None), pagination: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteeRole]: ) -> PaginatedResponse[TrusteeRole]:
"""Get all roles with optional pagination.""" """Get all roles with optional pagination."""
mandateId = await _validateInstanceAccess(instanceId, context)
paginationParams = _parsePagination(pagination) paginationParams = _parsePagination(pagination)
interface = getInterface(context.user, mandateId=context.mandateId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.getAllRoles(paginationParams) result = interface.getAllRoles(paginationParams)
if paginationParams: if paginationParams:
@ -189,46 +260,55 @@ async def getRoles(
return PaginatedResponse(items=result.items, pagination=None) return PaginatedResponse(items=result.items, pagination=None)
@router.get("/roles/{roleId}", response_model=TrusteeRole) @router.get("/{instanceId}/roles/{roleId}", response_model=TrusteeRole)
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getRole( async def getRole(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeRole: ) -> TrusteeRole:
"""Get a single role by ID.""" """Get a single role by ID."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
role = interface.getRole(roleId) role = interface.getRole(roleId)
if not role: if not role:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found") raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
return role return role
@router.post("/roles", response_model=TrusteeRole, status_code=201) @router.post("/{instanceId}/roles", response_model=TrusteeRole, status_code=201)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def createRole( async def createRole(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteeRole = Body(...), data: TrusteeRole = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeRole: ) -> TrusteeRole:
"""Create a new role (sysadmin only).""" """Create a new role (sysadmin only)."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createRole(data.model_dump()) result = interface.createRole(data.model_dump())
if not result: if not result:
raise HTTPException(status_code=400, detail="Failed to create role") raise HTTPException(status_code=400, detail="Failed to create role")
return result return result
@router.put("/roles/{roleId}", response_model=TrusteeRole) @router.put("/{instanceId}/roles/{roleId}", response_model=TrusteeRole)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def updateRole( async def updateRole(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(...), roleId: str = Path(...),
data: TrusteeRole = Body(...), data: TrusteeRole = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeRole: ) -> TrusteeRole:
"""Update a role (sysadmin only).""" """Update a role (sysadmin only)."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getRole(roleId) existing = interface.getRole(roleId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found") raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
@ -239,15 +319,18 @@ async def updateRole(
return result return result
@router.delete("/roles/{roleId}") @router.delete("/{instanceId}/roles/{roleId}")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def deleteRole( async def deleteRole(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(...), roleId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a role (sysadmin only, fails if in use).""" """Delete a role (sysadmin only, fails if in use)."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getRole(roleId) existing = interface.getRole(roleId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found") raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
@ -260,16 +343,19 @@ async def deleteRole(
# ===== Access Routes ===== # ===== Access Routes =====
@router.get("/access", response_model=PaginatedResponse[TrusteeAccess]) @router.get("/{instanceId}/access", response_model=PaginatedResponse[TrusteeAccess])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getAllAccess( async def getAllAccess(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None), pagination: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteeAccess]: ) -> PaginatedResponse[TrusteeAccess]:
"""Get all access records with optional pagination.""" """Get all access records with optional pagination."""
mandateId = await _validateInstanceAccess(instanceId, context)
paginationParams = _parsePagination(pagination) paginationParams = _parsePagination(pagination)
interface = getInterface(context.user, mandateId=context.mandateId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.getAllAccess(paginationParams) result = interface.getAllAccess(paginationParams)
if paginationParams: if paginationParams:
@ -287,70 +373,85 @@ async def getAllAccess(
return PaginatedResponse(items=result.items, pagination=None) return PaginatedResponse(items=result.items, pagination=None)
@router.get("/access/{accessId}", response_model=TrusteeAccess) @router.get("/{instanceId}/access/{accessId}", response_model=TrusteeAccess)
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getAccess( async def getAccess(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
accessId: str = Path(...), accessId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeAccess: ) -> TrusteeAccess:
"""Get a single access record by ID.""" """Get a single access record by ID."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
access = interface.getAccess(accessId) access = interface.getAccess(accessId)
if not access: if not access:
raise HTTPException(status_code=404, detail=f"Access {accessId} not found") raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
return access return access
@router.get("/access/organisation/{orgId}", response_model=List[TrusteeAccess]) @router.get("/{instanceId}/access/organisation/{orgId}", response_model=List[TrusteeAccess])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getAccessByOrganisation( async def getAccessByOrganisation(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(...), orgId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> List[TrusteeAccess]: ) -> List[TrusteeAccess]:
"""Get all access records for an organisation.""" """Get all access records for an organisation."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return interface.getAccessByOrganisation(orgId) return interface.getAccessByOrganisation(orgId)
@router.get("/access/user/{userId}", response_model=List[TrusteeAccess]) @router.get("/{instanceId}/access/user/{userId}", response_model=List[TrusteeAccess])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getAccessByUser( async def getAccessByUser(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
userId: str = Path(...), userId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> List[TrusteeAccess]: ) -> List[TrusteeAccess]:
"""Get all access records for a user.""" """Get all access records for a user."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return interface.getAccessByUser(userId) return interface.getAccessByUser(userId)
@router.post("/access", response_model=TrusteeAccess, status_code=201) @router.post("/{instanceId}/access", response_model=TrusteeAccess, status_code=201)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def createAccess( async def createAccess(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteeAccess = Body(...), data: TrusteeAccess = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeAccess: ) -> TrusteeAccess:
"""Create a new access record.""" """Create a new access record."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createAccess(data.model_dump()) result = interface.createAccess(data.model_dump())
if not result: if not result:
raise HTTPException(status_code=400, detail="Failed to create access") raise HTTPException(status_code=400, detail="Failed to create access")
return result return result
@router.put("/access/{accessId}", response_model=TrusteeAccess) @router.put("/{instanceId}/access/{accessId}", response_model=TrusteeAccess)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def updateAccess( async def updateAccess(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
accessId: str = Path(...), accessId: str = Path(...),
data: TrusteeAccess = Body(...), data: TrusteeAccess = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeAccess: ) -> TrusteeAccess:
"""Update an access record.""" """Update an access record."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getAccess(accessId) existing = interface.getAccess(accessId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Access {accessId} not found") raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
@ -361,15 +462,18 @@ async def updateAccess(
return result return result
@router.delete("/access/{accessId}") @router.delete("/{instanceId}/access/{accessId}")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def deleteAccess( async def deleteAccess(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
accessId: str = Path(...), accessId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete an access record.""" """Delete an access record."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getAccess(accessId) existing = interface.getAccess(accessId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Access {accessId} not found") raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
@ -382,16 +486,19 @@ async def deleteAccess(
# ===== Contract Routes ===== # ===== Contract Routes =====
@router.get("/contracts", response_model=PaginatedResponse[TrusteeContract]) @router.get("/{instanceId}/contracts", response_model=PaginatedResponse[TrusteeContract])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getContracts( async def getContracts(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None), pagination: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteeContract]: ) -> PaginatedResponse[TrusteeContract]:
"""Get all contracts with optional pagination.""" """Get all contracts with optional pagination."""
mandateId = await _validateInstanceAccess(instanceId, context)
paginationParams = _parsePagination(pagination) paginationParams = _parsePagination(pagination)
interface = getInterface(context.user, mandateId=context.mandateId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.getAllContracts(paginationParams) result = interface.getAllContracts(paginationParams)
if paginationParams: if paginationParams:
@ -409,58 +516,70 @@ async def getContracts(
return PaginatedResponse(items=result.items, pagination=None) return PaginatedResponse(items=result.items, pagination=None)
@router.get("/contracts/{contractId}", response_model=TrusteeContract) @router.get("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract)
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getContract( async def getContract(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
contractId: str = Path(...), contractId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeContract: ) -> TrusteeContract:
"""Get a single contract by ID.""" """Get a single contract by ID."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
contract = interface.getContract(contractId) contract = interface.getContract(contractId)
if not contract: if not contract:
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found") raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
return contract return contract
@router.get("/contracts/organisation/{orgId}", response_model=List[TrusteeContract]) @router.get("/{instanceId}/contracts/organisation/{orgId}", response_model=List[TrusteeContract])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getContractsByOrganisation( async def getContractsByOrganisation(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(...), orgId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> List[TrusteeContract]: ) -> List[TrusteeContract]:
"""Get all contracts for an organisation.""" """Get all contracts for an organisation."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return interface.getContractsByOrganisation(orgId) return interface.getContractsByOrganisation(orgId)
@router.post("/contracts", response_model=TrusteeContract, status_code=201) @router.post("/{instanceId}/contracts", response_model=TrusteeContract, status_code=201)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def createContract( async def createContract(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteeContract = Body(...), data: TrusteeContract = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeContract: ) -> TrusteeContract:
"""Create a new contract.""" """Create a new contract."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createContract(data.model_dump()) result = interface.createContract(data.model_dump())
if not result: if not result:
raise HTTPException(status_code=400, detail="Failed to create contract") raise HTTPException(status_code=400, detail="Failed to create contract")
return result return result
@router.put("/contracts/{contractId}", response_model=TrusteeContract) @router.put("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def updateContract( async def updateContract(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
contractId: str = Path(...), contractId: str = Path(...),
data: TrusteeContract = Body(...), data: TrusteeContract = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeContract: ) -> TrusteeContract:
"""Update a contract (organisationId is immutable).""" """Update a contract (organisationId is immutable)."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getContract(contractId) existing = interface.getContract(contractId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found") raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
@ -471,15 +590,18 @@ async def updateContract(
return result return result
@router.delete("/contracts/{contractId}") @router.delete("/{instanceId}/contracts/{contractId}")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def deleteContract( async def deleteContract(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
contractId: str = Path(...), contractId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a contract.""" """Delete a contract."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getContract(contractId) existing = interface.getContract(contractId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found") raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
@ -492,16 +614,19 @@ async def deleteContract(
# ===== Document Routes ===== # ===== Document Routes =====
@router.get("/documents", response_model=PaginatedResponse[TrusteeDocument]) @router.get("/{instanceId}/documents", response_model=PaginatedResponse[TrusteeDocument])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getDocuments( async def getDocuments(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None), pagination: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteeDocument]: ) -> PaginatedResponse[TrusteeDocument]:
"""Get all documents (metadata only) with optional pagination.""" """Get all documents (metadata only) with optional pagination."""
mandateId = await _validateInstanceAccess(instanceId, context)
paginationParams = _parsePagination(pagination) paginationParams = _parsePagination(pagination)
interface = getInterface(context.user, mandateId=context.mandateId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.getAllDocuments(paginationParams) result = interface.getAllDocuments(paginationParams)
if paginationParams: if paginationParams:
@ -519,30 +644,36 @@ async def getDocuments(
return PaginatedResponse(items=result.items, pagination=None) return PaginatedResponse(items=result.items, pagination=None)
@router.get("/documents/{documentId}", response_model=TrusteeDocument) @router.get("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument)
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getDocument( async def getDocument(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...), documentId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeDocument: ) -> TrusteeDocument:
"""Get document metadata by ID.""" """Get document metadata by ID."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
doc = interface.getDocument(documentId) doc = interface.getDocument(documentId)
if not doc: if not doc:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found") raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
return doc return doc
@router.get("/documents/{documentId}/data") @router.get("/{instanceId}/documents/{documentId}/data")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def getDocumentData( async def getDocumentData(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...), documentId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
): ):
"""Download document binary data.""" """Download document binary data."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
doc = interface.getDocument(documentId) doc = interface.getDocument(documentId)
if not doc: if not doc:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found") raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
@ -558,43 +689,52 @@ async def getDocumentData(
) )
@router.get("/documents/contract/{contractId}", response_model=List[TrusteeDocument]) @router.get("/{instanceId}/documents/contract/{contractId}", response_model=List[TrusteeDocument])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getDocumentsByContract( async def getDocumentsByContract(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
contractId: str = Path(...), contractId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> List[TrusteeDocument]: ) -> List[TrusteeDocument]:
"""Get all documents for a contract.""" """Get all documents for a contract."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return interface.getDocumentsByContract(contractId) return interface.getDocumentsByContract(contractId)
@router.post("/documents", response_model=TrusteeDocument, status_code=201) @router.post("/{instanceId}/documents", response_model=TrusteeDocument, status_code=201)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def createDocument( async def createDocument(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteeDocument = Body(...), data: TrusteeDocument = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeDocument: ) -> TrusteeDocument:
"""Create a new document.""" """Create a new document."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createDocument(data.model_dump()) result = interface.createDocument(data.model_dump())
if not result: if not result:
raise HTTPException(status_code=400, detail="Failed to create document") raise HTTPException(status_code=400, detail="Failed to create document")
return result return result
@router.put("/documents/{documentId}", response_model=TrusteeDocument) @router.put("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def updateDocument( async def updateDocument(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...), documentId: str = Path(...),
data: TrusteeDocument = Body(...), data: TrusteeDocument = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteeDocument: ) -> TrusteeDocument:
"""Update document metadata.""" """Update document metadata."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getDocument(documentId) existing = interface.getDocument(documentId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found") raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
@ -605,15 +745,18 @@ async def updateDocument(
return result return result
@router.delete("/documents/{documentId}") @router.delete("/{instanceId}/documents/{documentId}")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def deleteDocument( async def deleteDocument(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...), documentId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a document.""" """Delete a document."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getDocument(documentId) existing = interface.getDocument(documentId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found") raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
@ -626,16 +769,19 @@ async def deleteDocument(
# ===== Position Routes ===== # ===== Position Routes =====
@router.get("/positions", response_model=PaginatedResponse[TrusteePosition]) @router.get("/{instanceId}/positions", response_model=PaginatedResponse[TrusteePosition])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getPositions( async def getPositions(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None), pagination: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteePosition]: ) -> PaginatedResponse[TrusteePosition]:
"""Get all positions with optional pagination.""" """Get all positions with optional pagination."""
mandateId = await _validateInstanceAccess(instanceId, context)
paginationParams = _parsePagination(pagination) paginationParams = _parsePagination(pagination)
interface = getInterface(context.user, mandateId=context.mandateId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.getAllPositions(paginationParams) result = interface.getAllPositions(paginationParams)
if paginationParams: if paginationParams:
@ -653,70 +799,85 @@ async def getPositions(
return PaginatedResponse(items=result.items, pagination=None) return PaginatedResponse(items=result.items, pagination=None)
@router.get("/positions/{positionId}", response_model=TrusteePosition) @router.get("/{instanceId}/positions/{positionId}", response_model=TrusteePosition)
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getPosition( async def getPosition(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
positionId: str = Path(...), positionId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteePosition: ) -> TrusteePosition:
"""Get a single position by ID.""" """Get a single position by ID."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
position = interface.getPosition(positionId) position = interface.getPosition(positionId)
if not position: if not position:
raise HTTPException(status_code=404, detail=f"Position {positionId} not found") raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
return position return position
@router.get("/positions/contract/{contractId}", response_model=List[TrusteePosition]) @router.get("/{instanceId}/positions/contract/{contractId}", response_model=List[TrusteePosition])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getPositionsByContract( async def getPositionsByContract(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
contractId: str = Path(...), contractId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> List[TrusteePosition]: ) -> List[TrusteePosition]:
"""Get all positions for a contract.""" """Get all positions for a contract."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return interface.getPositionsByContract(contractId) return interface.getPositionsByContract(contractId)
@router.get("/positions/organisation/{orgId}", response_model=List[TrusteePosition]) @router.get("/{instanceId}/positions/organisation/{orgId}", response_model=List[TrusteePosition])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getPositionsByOrganisation( async def getPositionsByOrganisation(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(...), orgId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> List[TrusteePosition]: ) -> List[TrusteePosition]:
"""Get all positions for an organisation.""" """Get all positions for an organisation."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return interface.getPositionsByOrganisation(orgId) return interface.getPositionsByOrganisation(orgId)
@router.post("/positions", response_model=TrusteePosition, status_code=201) @router.post("/{instanceId}/positions", response_model=TrusteePosition, status_code=201)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def createPosition( async def createPosition(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteePosition = Body(...), data: TrusteePosition = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteePosition: ) -> TrusteePosition:
"""Create a new position.""" """Create a new position."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createPosition(data.model_dump()) result = interface.createPosition(data.model_dump())
if not result: if not result:
raise HTTPException(status_code=400, detail="Failed to create position") raise HTTPException(status_code=400, detail="Failed to create position")
return result return result
@router.put("/positions/{positionId}", response_model=TrusteePosition) @router.put("/{instanceId}/positions/{positionId}", response_model=TrusteePosition)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def updatePosition( async def updatePosition(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
positionId: str = Path(...), positionId: str = Path(...),
data: TrusteePosition = Body(...), data: TrusteePosition = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteePosition: ) -> TrusteePosition:
"""Update a position.""" """Update a position."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getPosition(positionId) existing = interface.getPosition(positionId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Position {positionId} not found") raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
@ -727,15 +888,18 @@ async def updatePosition(
return result return result
@router.delete("/positions/{positionId}") @router.delete("/{instanceId}/positions/{positionId}")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def deletePosition( async def deletePosition(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
positionId: str = Path(...), positionId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a position.""" """Delete a position."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getPosition(positionId) existing = interface.getPosition(positionId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Position {positionId} not found") raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
@ -748,16 +912,19 @@ async def deletePosition(
# ===== Position-Document Link Routes ===== # ===== Position-Document Link Routes =====
@router.get("/position-documents", response_model=PaginatedResponse[TrusteePositionDocument]) @router.get("/{instanceId}/position-documents", response_model=PaginatedResponse[TrusteePositionDocument])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getPositionDocuments( async def getPositionDocuments(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None), pagination: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteePositionDocument]: ) -> PaginatedResponse[TrusteePositionDocument]:
"""Get all position-document links with optional pagination.""" """Get all position-document links with optional pagination."""
mandateId = await _validateInstanceAccess(instanceId, context)
paginationParams = _parsePagination(pagination) paginationParams = _parsePagination(pagination)
interface = getInterface(context.user, mandateId=context.mandateId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.getAllPositionDocuments(paginationParams) result = interface.getAllPositionDocuments(paginationParams)
if paginationParams: if paginationParams:
@ -775,69 +942,84 @@ async def getPositionDocuments(
return PaginatedResponse(items=result.items, pagination=None) return PaginatedResponse(items=result.items, pagination=None)
@router.get("/position-documents/{linkId}", response_model=TrusteePositionDocument) @router.get("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument)
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getPositionDocument( async def getPositionDocument(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
linkId: str = Path(...), linkId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteePositionDocument: ) -> TrusteePositionDocument:
"""Get a single position-document link by ID.""" """Get a single position-document link by ID."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
link = interface.getPositionDocument(linkId) link = interface.getPositionDocument(linkId)
if not link: if not link:
raise HTTPException(status_code=404, detail=f"Link {linkId} not found") raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
return link return link
@router.get("/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument]) @router.get("/{instanceId}/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getDocumentsForPosition( async def getDocumentsForPosition(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
positionId: str = Path(...), positionId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> List[TrusteePositionDocument]: ) -> List[TrusteePositionDocument]:
"""Get all document links for a position.""" """Get all document links for a position."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return interface.getDocumentsForPosition(positionId) return interface.getDocumentsForPosition(positionId)
@router.get("/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument]) @router.get("/{instanceId}/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def getPositionsForDocument( async def getPositionsForDocument(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...), documentId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> List[TrusteePositionDocument]: ) -> List[TrusteePositionDocument]:
"""Get all position links for a document.""" """Get all position links for a document."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return interface.getPositionsForDocument(documentId) return interface.getPositionsForDocument(documentId)
@router.post("/position-documents", response_model=TrusteePositionDocument, status_code=201) @router.post("/{instanceId}/position-documents", response_model=TrusteePositionDocument, status_code=201)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def createPositionDocument( async def createPositionDocument(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteePositionDocument = Body(...), data: TrusteePositionDocument = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> TrusteePositionDocument: ) -> TrusteePositionDocument:
"""Create a new position-document link.""" """Create a new position-document link."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createPositionDocument(data.model_dump()) result = interface.createPositionDocument(data.model_dump())
if not result: if not result:
raise HTTPException(status_code=400, detail="Failed to create link") raise HTTPException(status_code=400, detail="Failed to create link")
return result return result
@router.delete("/position-documents/{linkId}") @router.delete("/{instanceId}/position-documents/{linkId}")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def deletePositionDocument( async def deletePositionDocument(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
linkId: str = Path(...), linkId: str = Path(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a position-document link.""" """Delete a position-document link."""
interface = getInterface(context.user, mandateId=context.mandateId) mandateId = await _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
existing = interface.getPositionDocument(linkId) existing = interface.getPositionDocument(linkId)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Link {linkId} not found") raise HTTPException(status_code=404, detail=f"Link {linkId} not found")

View file

@ -11,7 +11,7 @@ from fastapi import status
import logging import logging
# Import interfaces and models # Import interfaces and models
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
# Configure logger # Configure logger
@ -75,7 +75,7 @@ async def sync_all_automation_events(
This will register/remove events based on active flags. This will register/remove events based on active flags.
""" """
try: try:
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface from modules.interfaces.interfaceDbChatbot import getInterface as getChatInterface
from modules.interfaces.interfaceDbAppObjects import getRootInterface from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.features.workflow import syncAutomationEvents from modules.features.workflow import syncAutomationEvents
@ -126,7 +126,7 @@ async def remove_event(
# Update automation's eventId if it exists # Update automation's eventId if it exists
if eventId.startswith("automation."): if eventId.startswith("automation."):
automation_id = eventId.replace("automation.", "") automation_id = eventId.replace("automation.", "")
chatInterface = interfaceDbChatObjects.getInterface(context.user) chatInterface = interfaceDbChatbot.getInterface(context.user)
automation = chatInterface.getAutomationDefinition(automation_id) automation = chatInterface.getAutomationDefinition(automation_id)
if automation and getattr(automation, "eventId", None) == eventId: if automation and getattr(automation, "eventId", None) == eventId:
chatInterface.updateAutomationDefinition(automation_id, {"eventId": None}) chatInterface.updateAutomationDefinition(automation_id, {"eventId": None})

View file

@ -204,12 +204,13 @@ async def exportUserData(
for inv in invitationsUsed for inv in invitationsUsed
] ]
# Audit log # Audit log - GDPR Article 15 data export
audit_logger.logSecurityEvent( audit_logger.logGdprEvent(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId="system", mandateId="system",
action="gdpr_data_export", action="gdpr_data_export",
details="User requested data export (Article 15)" details="User requested data export (GDPR Article 15 - Right of Access)",
ipAddress=request.client.host if request.client else None
) )
logger.info(f"User {currentUser.id} exported personal data (GDPR Art. 15)") logger.info(f"User {currentUser.id} exported personal data (GDPR Art. 15)")
@ -304,12 +305,13 @@ async def exportPortableData(
"about": portableData "about": portableData
} }
# Audit log # Audit log - GDPR Article 20 data portability
audit_logger.logSecurityEvent( audit_logger.logGdprEvent(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId="system", mandateId="system",
action="gdpr_data_portability", action="gdpr_data_portability",
details="User requested portable data export (Article 20)" details="User requested portable data export (GDPR Article 20 - Right to Data Portability)",
ipAddress=request.client.host if request.client else None
) )
logger.info(f"User {currentUser.id} exported portable data (GDPR Art. 20)") logger.info(f"User {currentUser.id} exported portable data (GDPR Art. 20)")
@ -431,12 +433,13 @@ async def deleteAccount(
rootInterface.db.recordDelete(User, str(currentUser.id)) rootInterface.db.recordDelete(User, str(currentUser.id))
deletedData.append("User account deleted") deletedData.append("User account deleted")
# Audit log (before user is deleted) # Audit log (before user is deleted) - GDPR Article 17 account deletion
audit_logger.logSecurityEvent( audit_logger.logGdprEvent(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId="system", mandateId="system",
action="gdpr_account_deletion", action="gdpr_account_deletion",
details=f"User deleted own account (Article 17). Data: {', '.join(deletedData)}" details=f"User deleted own account (GDPR Article 17 - Right to Erasure). Data: {', '.join(deletedData)}",
ipAddress=request.client.host if request.client else None
) )
logger.info(f"User {currentUser.id} deleted own account (GDPR Art. 17)") logger.info(f"User {currentUser.id} deleted own account (GDPR Art. 17)")

View file

@ -624,7 +624,10 @@ async def logout(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId="system", mandateId="system",
action="logout", action="logout",
successInfo="google_auth_logout" successInfo="google_auth_logout",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
) )
except Exception: except Exception:
# Don't fail if audit logging fails # Don't fail if audit logging fails

View file

@ -142,7 +142,10 @@ async def login(
userId=str(user.id), userId=str(user.id),
mandateId="system", mandateId="system",
action="login", action="login",
successInfo="local_auth_success" successInfo="local_auth_success",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
) )
except Exception: except Exception:
# Don't fail if audit logging fails # Don't fail if audit logging fails
@ -171,10 +174,13 @@ async def login(
try: try:
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess( audit_logger.logUserAccess(
userId="unknown", userId=formData.username or "unknown",
mandateId="unknown", mandateId="system",
action="login", action="login_failed",
successInfo=f"failed: {error_msg}" successInfo=f"failed: {error_msg}",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=False
) )
except Exception: except Exception:
# Don't fail if audit logging fails # Don't fail if audit logging fails
@ -438,7 +444,10 @@ async def logout(request: Request, response: Response, currentUser: User = Depen
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId="system", mandateId="system",
action="logout", action="logout",
successInfo=f"revoked_tokens: {revoked}" successInfo=f"revoked_tokens: {revoked}",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
) )
except Exception: except Exception:
# Don't fail if audit logging fails # Don't fail if audit logging fails

View file

@ -634,7 +634,10 @@ async def logout(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId="system", mandateId="system",
action="logout", action="logout",
successInfo="microsoft_auth_logout" successInfo="microsoft_auth_logout",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
) )
except Exception: except Exception:
# Don't fail if audit logging fails # Don't fail if audit logging fails

View file

@ -14,12 +14,12 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Respon
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getCurrentUser
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
from modules.interfaces.interfaceDbChatObjects import getInterface from modules.interfaces.interfaceDbChatbot import getInterface
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
# Import models # Import models
from modules.datamodels.datamodelChat import ( from modules.datamodels.datamodelChatbot import (
ChatWorkflow, ChatWorkflow,
ChatMessage, ChatMessage,
ChatLog, ChatLog,
@ -45,7 +45,7 @@ router = APIRouter(
) )
def getServiceChat(currentUser: User): def getServiceChat(currentUser: User):
return interfaceDbChatObjects.getInterface(currentUser) return interfaceDbChatbot.getInterface(currentUser)
# Consolidated endpoint for getting all workflows # Consolidated endpoint for getting all workflows
@router.get("/", response_model=PaginatedResponse[ChatWorkflow]) @router.get("/", response_model=PaginatedResponse[ChatWorkflow])

View file

@ -3,7 +3,7 @@
from typing import Any, Optional from typing import Any, Optional
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelChatbot import ChatWorkflow
class PublicService: class PublicService:
"""Lightweight proxy exposing only public callable attributes of a target. """Lightweight proxy exposing only public callable attributes of a target.
@ -49,7 +49,7 @@ class Services:
# Initialize interfaces with explicit mandateId # Initialize interfaces with explicit mandateId
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface from modules.interfaces.interfaceDbChatbot import getInterface as getChatInterface
self.interfaceDbChat = getChatInterface(user, mandateId=mandateId) self.interfaceDbChat = getChatInterface(user, mandateId=mandateId)
from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface
@ -58,7 +58,7 @@ class Services:
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId) self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId)
from modules.interfaces.interfaceDbTrusteeObjects import getInterface as getTrusteeInterface from modules.interfaces.interfaceDbTrustee import getInterface as getTrusteeInterface
self.interfaceDbTrustee = getTrusteeInterface(user, mandateId=mandateId) self.interfaceDbTrustee = getTrusteeInterface(user, mandateId=mandateId)
# Expose RBAC directly on services for convenience # Expose RBAC directly on services for convenience

View file

@ -6,7 +6,7 @@ import re
import time import time
import base64 import base64
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
from modules.datamodels.datamodelChat import PromptPlaceholder, ChatDocument from modules.datamodels.datamodelChatbot import PromptPlaceholder, ChatDocument
from modules.services.serviceExtraction.mainServiceExtraction import ExtractionService from modules.services.serviceExtraction.mainServiceExtraction import ExtractionService
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.datamodels.datamodelExtraction import ContentPart, DocumentIntent from modules.datamodels.datamodelExtraction import ContentPart, DocumentIntent

View file

@ -14,7 +14,7 @@ import logging
import base64 import base64
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelChatbot import ChatDocument
from modules.datamodels.datamodelExtraction import ContentPart, DocumentIntent from modules.datamodels.datamodelExtraction import ContentPart, DocumentIntent
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped from modules.workflows.processing.shared.stateTools import checkWorkflowStopped

View file

@ -12,7 +12,7 @@ import json
import logging import logging
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelChatbot import ChatDocument
from modules.datamodels.datamodelExtraction import DocumentIntent from modules.datamodels.datamodelExtraction import DocumentIntent
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped from modules.workflows.processing.shared.stateTools import checkWorkflowStopped

View file

@ -3,7 +3,7 @@
import logging import logging
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelUam import User, UserConnection from modules.datamodels.datamodelUam import User, UserConnection
from modules.datamodels.datamodelChat import ChatDocument, ChatMessage, ChatStat, ChatLog from modules.datamodels.datamodelChatbot import ChatDocument, ChatMessage, ChatStat, ChatLog
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.shared.progressLogger import ProgressLogger from modules.shared.progressLogger import ProgressLogger

View file

@ -11,7 +11,7 @@ import json
from .subRegistry import ExtractorRegistry, ChunkerRegistry from .subRegistry import ExtractorRegistry, ChunkerRegistry
from .subPipeline import runExtraction from .subPipeline import runExtraction
from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart, MergeStrategy, ExtractionOptions, PartResult, DocumentIntent from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart, MergeStrategy, ExtractionOptions, PartResult, DocumentIntent
from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelChatbot import ChatDocument
from modules.datamodels.datamodelAi import AiCallResponse, AiCallRequest, AiCallOptions, OperationTypeEnum, AiModelCall from modules.datamodels.datamodelAi import AiCallResponse, AiCallRequest, AiCallOptions, OperationTypeEnum, AiModelCall
from modules.aicore.aicoreModelRegistry import modelRegistry from modules.aicore.aicoreModelRegistry import modelRegistry
from modules.aicore.aicoreModelSelector import modelSelector from modules.aicore.aicoreModelSelector import modelSelector

View file

@ -6,7 +6,7 @@ import base64
import traceback import traceback
from typing import Any, Dict, List, Optional, Callable from typing import Any, Dict, List, Optional, Callable
from modules.datamodels.datamodelDocument import RenderedDocument from modules.datamodels.datamodelDocument import RenderedDocument
from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelChatbot import ChatDocument
from modules.services.serviceGeneration.subDocumentUtility import ( from modules.services.serviceGeneration.subDocumentUtility import (
getFileExtension, getFileExtension,
getMimeTypeFromExtension, getMimeTypeFromExtension,

View file

@ -157,11 +157,11 @@ class UtilsService:
def storeDebugMessageAndDocuments(self, message, currentUser): def storeDebugMessageAndDocuments(self, message, currentUser):
""" """
Wrapper to store debug messages and documents via interfaceDbChatObjects. Wrapper to store debug messages and documents via interfaceDbChatbot.
Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChatObjects. Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChatbot.
""" """
try: try:
from modules.interfaces.interfaceDbChatObjects import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments from modules.interfaces.interfaceDbChatbot import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments
_storeDebugMessageAndDocuments(message, currentUser) _storeDebugMessageAndDocuments(message, currentUser)
except Exception: except Exception:
# Silent fail to never break main flow # Silent fail to never break main flow

View file

@ -4,201 +4,471 @@
Audit Logging System for PowerOn Gateway Audit Logging System for PowerOn Gateway
This module provides centralized audit logging functionality for security events, This module provides centralized audit logging functionality for security events,
user actions, and system access patterns. user actions, and system access patterns. Logs are stored in the database for
GDPR compliance and security monitoring.
GDPR Requirements Addressed:
- Article 5(1)(f): Integrity and confidentiality - secure audit trail
- Article 17: Right to erasure - audit log retention with automatic cleanup
- Article 30: Records of processing activities - comprehensive event logging
""" """
import logging import logging
import os
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from logging.handlers import RotatingFileHandler
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
class DailyRotatingFileHandler(RotatingFileHandler):
"""
A rotating file handler that automatically switches to a new file when the date changes.
The log file name includes the current date and switches at midnight.
"""
def __init__(self, logDir, filenamePrefix, maxBytes=10485760, backupCount=5, **kwargs):
self.logDir = logDir
self.filenamePrefix = filenamePrefix
self.currentDate = None
self.currentFile = None
# Initialize with today's file
self._updateFileIfNeeded()
# Call parent constructor with current file
super().__init__(self.currentFile, maxBytes=maxBytes, backupCount=backupCount, **kwargs)
def _updateFileIfNeeded(self):
"""Update the log file if the date has changed"""
today = datetime.now().strftime("%Y%m%d")
if self.currentDate != today:
self.currentDate = today
newFile = os.path.join(self.logDir, f"{self.filenamePrefix}_{today}.log")
if self.currentFile != newFile:
self.currentFile = newFile
return True
return False
def emit(self, record):
"""Emit a log record, switching files if date has changed"""
# Check if we need to switch to a new file
if self._updateFileIfNeeded():
# Close current file and open new one
if self.stream:
self.stream.close()
self.stream = None
# Update the baseFilename for the parent class
self.baseFilename = self.currentFile
# Reopen the stream
if not self.delay:
self.stream = self._open()
# Call parent emit method
super().emit(record)
class AuditLogger: class AuditLogger:
"""Centralized audit logging system""" """
Centralized audit logging system with database storage.
Logs security-relevant events to PostgreSQL for:
- GDPR compliance
- Security monitoring
- Access tracking
- Incident investigation
"""
def __init__(self): def __init__(self):
self.logger = None self._db = None
self._setupAuditLogger() self._modelClass = None
self._initialized = False
def _setupAuditLogger(self): self._fallbackToStdout = True
"""Setup the audit logger with daily file rotation"""
def _ensureInitialized(self) -> bool:
"""Lazily initialize database connection to avoid circular imports."""
if self._initialized:
return self._db is not None
self._initialized = True
try: try:
# Get log directory from config from modules.datamodels.datamodelAudit import AuditLogEntry
logDir = APP_CONFIG.get("APP_LOGGING_LOG_DIR", "./") from modules.connectors.connectorDbPostgre import DatabaseConnector
if not os.path.isabs(logDir):
# If relative path, make it relative to the gateway directory
gatewayDir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
logDir = os.path.join(gatewayDir, logDir)
# Ensure log directory exists self._modelClass = AuditLogEntry
os.makedirs(logDir, exist_ok=True)
# Create audit logger # Get database configuration
self.logger = logging.getLogger('audit') dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
self.logger.setLevel(logging.INFO) dbDatabase = "poweron_app" # Store audit logs in the main app database
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Remove any existing handlers to avoid duplicates # Create database connector with system user context
for handler in self.logger.handlers[:]: self._db = DatabaseConnector(
self.logger.removeHandler(handler) dbHost=dbHost,
dbDatabase=dbDatabase,
# Create daily rotating file handler for audit log dbUser=dbUser,
rotationSize = int(APP_CONFIG.get("APP_LOGGING_ROTATION_SIZE", 10485760)) # Default: 10MB dbPassword=dbPassword,
backupCount = int(APP_CONFIG.get("APP_LOGGING_BACKUP_COUNT", 5)) dbPort=dbPort,
userId="system" # Audit logs are created by system
fileHandler = DailyRotatingFileHandler(
logDir=logDir,
filenamePrefix="log_audit",
maxBytes=rotationSize,
backupCount=backupCount
) )
# Create formatter for audit log # Initialize database and ensure table exists
auditFormatter = logging.Formatter( self._db.initDbSystem()
fmt="%(asctime)s | %(message)s", self._db._ensureTableExists(AuditLogEntry)
datefmt="%Y-%m-%d %H:%M:%S"
)
fileHandler.setFormatter(auditFormatter)
# Add handler to logger logger.info("AuditLogger database connection initialized successfully")
self.logger.addHandler(fileHandler) return True
# Prevent propagation to root logger
self.logger.propagate = False
except Exception as e: except Exception as e:
# Fallback to standard logger if audit setup fails logger.warning(f"AuditLogger database initialization failed, using fallback logging: {e}")
self.logger = logging.getLogger(__name__) self._db = None
self.logger.error(f"Failed to setup audit logger: {str(e)}") return False
def logEvent(self, def _logToFallback(self, entry: Dict[str, Any]) -> None:
userId: str, """Log to standard logger as fallback when database is unavailable."""
mandateId: str, if self._fallbackToStdout:
category: str, fallbackMsg = (
action: str, f"AUDIT | {entry.get('timestamp', '')} | "
details: str = "", f"{entry.get('userId', '')} | {entry.get('mandateId', '')} | "
timestamp: Optional[datetime] = None) -> None: f"{entry.get('category', '')} | {entry.get('action', '')} | "
f"{entry.get('details', '')}"
)
logging.getLogger('audit.fallback').info(fallbackMsg)
def logEvent(
self,
userId: str,
mandateId: Optional[str] = None,
category: str = "system",
action: str = "",
details: str = "",
featureInstanceId: Optional[str] = None,
resourceType: Optional[str] = None,
resourceId: Optional[str] = None,
ipAddress: Optional[str] = None,
userAgent: Optional[str] = None,
success: bool = True,
errorMessage: Optional[str] = None,
username: Optional[str] = None,
timestamp: Optional[float] = None
) -> Optional[str]:
""" """
Log an audit event Log an audit event to the database.
Args: Args:
userId: User identifier userId: User identifier (or 'system' for system events)
mandateId: Mandate identifier (can be empty if not applicable) mandateId: Mandate context (can be None for system-level events)
category: Event category (e.g., 'key', 'access', 'data') category: Event category (access, key, data, security, gdpr, permission, system)
action: Specific action (e.g., 'decode', 'login', 'logout') action: Specific action performed
details: Additional details about the event details: Additional details about the event
featureInstanceId: Feature instance context (if applicable)
resourceType: Type of resource affected
resourceId: ID of the affected resource
ipAddress: Client IP address
userAgent: Client user agent
success: Whether the action was successful
errorMessage: Error message if action failed
username: Username at the time of event (for historical reference)
timestamp: Optional custom timestamp (defaults to current time) timestamp: Optional custom timestamp (defaults to current time)
Returns:
ID of the created audit log entry, or None if logging failed
""" """
try: try:
if not self.logger: # Prepare the entry data
return entryData = {
"timestamp": timestamp if timestamp else getUtcTimestamp(),
# Use provided timestamp or current time "userId": userId or "unknown",
if timestamp is None: "username": username,
timestamp = datetime.now() "mandateId": mandateId,
"featureInstanceId": featureInstanceId,
# Format the audit log entry "category": category,
# Format: timestamp | userid | mandateid | category | action | details "action": action,
auditEntry = f"{userId} | {mandateId} | {category} | {action} | {details}" "resourceType": resourceType,
"resourceId": resourceId,
# Log the event "details": details if details else None,
self.logger.info(auditEntry) "ipAddress": ipAddress,
"userAgent": userAgent,
"success": success,
"errorMessage": errorMessage
}
# Try to write to database
if self._ensureInitialized() and self._db:
from modules.datamodels.datamodelAudit import AuditLogEntry
entry = AuditLogEntry(**entryData)
created = self._db.recordCreate(AuditLogEntry, entry.model_dump())
if created and created.get("id"):
return created["id"]
else:
self._logToFallback(entryData)
return None
else:
# Use fallback logging
self._logToFallback(entryData)
return None
except Exception as e: except Exception as e:
# Use standard logger as fallback logger.error(f"Failed to log audit event: {e}")
logging.getLogger(__name__).error(f"Failed to log audit event: {str(e)}") # Try fallback
try:
self._logToFallback(entryData)
except Exception:
pass
return None
def logKeyAccess(self, userId: str, mandateId: str, keyName: str, action: str) -> None: # ===== Convenience Methods for Common Event Types =====
"""Log key access events (decode/encode)"""
self.logEvent( def logKeyAccess(
self,
userId: str,
mandateId: str,
keyName: str,
action: str,
ipAddress: Optional[str] = None
) -> Optional[str]:
"""Log key access events (encode/decode)."""
return self.logEvent(
userId=userId, userId=userId,
mandateId=mandateId, mandateId=mandateId,
category="key", category="key",
action=action, action=action,
details=keyName details=f"Key: {keyName}",
resourceType="EncryptionKey",
resourceId=keyName,
ipAddress=ipAddress
) )
def logUserAccess(self, userId: str, mandateId: str, action: str, successInfo: str = "") -> None: def logUserAccess(
"""Log user access events (login/logout)""" self,
self.logEvent( userId: str,
mandateId: str,
action: str,
successInfo: str = "",
ipAddress: Optional[str] = None,
userAgent: Optional[str] = None,
success: bool = True
) -> Optional[str]:
"""Log user access events (login/logout)."""
return self.logEvent(
userId=userId, userId=userId,
mandateId=mandateId, mandateId=mandateId,
category="access", category="access",
action=action, action=action,
details=successInfo details=successInfo,
ipAddress=ipAddress,
userAgent=userAgent,
success=success
) )
def logDataAccess(self, userId: str, mandateId: str, action: str, details: str = "") -> None: def logDataAccess(
"""Log data access events""" self,
self.logEvent( userId: str,
mandateId: str,
action: str,
details: str = "",
resourceType: Optional[str] = None,
resourceId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> Optional[str]:
"""Log data access events (CRUD operations)."""
return self.logEvent(
userId=userId, userId=userId,
mandateId=mandateId, mandateId=mandateId,
category="data", category="data",
action=action, action=action,
details=details details=details,
resourceType=resourceType,
resourceId=resourceId,
featureInstanceId=featureInstanceId
) )
def logSecurityEvent(self, userId: str, mandateId: str, action: str, details: str = "") -> None: def logSecurityEvent(
"""Log security-related events""" self,
self.logEvent( userId: str,
mandateId: str,
action: str,
details: str = "",
ipAddress: Optional[str] = None,
success: bool = True,
errorMessage: Optional[str] = None
) -> Optional[str]:
"""Log security-related events."""
return self.logEvent(
userId=userId, userId=userId,
mandateId=mandateId, mandateId=mandateId,
category="security", category="security",
action=action, action=action,
details=details details=details,
ipAddress=ipAddress,
success=success,
errorMessage=errorMessage
) )
def logGdprEvent(
self,
userId: str,
mandateId: str,
action: str,
details: str = "",
ipAddress: Optional[str] = None
) -> Optional[str]:
"""Log GDPR-specific events (data export, deletion, etc.)."""
return self.logEvent(
userId=userId,
mandateId=mandateId,
category="gdpr",
action=action,
details=details,
ipAddress=ipAddress
)
def logPermissionChange(
self,
userId: str,
mandateId: str,
action: str,
targetUserId: str,
details: str = "",
resourceType: Optional[str] = None,
resourceId: Optional[str] = None
) -> Optional[str]:
"""Log permission/role changes."""
return self.logEvent(
userId=userId,
mandateId=mandateId,
category="permission",
action=action,
details=f"Target user: {targetUserId}. {details}",
resourceType=resourceType,
resourceId=resourceId
)
# ===== Audit Log Query Methods =====
def getAuditLogs(
self,
userId: Optional[str] = None,
mandateId: Optional[str] = None,
category: Optional[str] = None,
action: Optional[str] = None,
fromTimestamp: Optional[float] = None,
toTimestamp: Optional[float] = None,
limit: int = 100
) -> list:
"""
Query audit logs from database.
Args:
userId: Filter by user ID
mandateId: Filter by mandate ID
category: Filter by category
action: Filter by action
fromTimestamp: Filter events after this timestamp
toTimestamp: Filter events before this timestamp
limit: Maximum number of records to return
Returns:
List of audit log entries
"""
if not self._ensureInitialized() or not self._db:
return []
try:
from modules.datamodels.datamodelAudit import AuditLogEntry
# Build filter
recordFilter = {}
if userId:
recordFilter["userId"] = userId
if mandateId:
recordFilter["mandateId"] = mandateId
if category:
recordFilter["category"] = category
if action:
recordFilter["action"] = action
# Query database
records = self._db.getRecordset(
AuditLogEntry,
recordFilter=recordFilter if recordFilter else None,
orderBy="timestamp DESC"
)
# Apply timestamp filtering in Python (PostgreSQL connector may not support $gt/$lt)
if fromTimestamp or toTimestamp:
filteredRecords = []
for record in records:
ts = record.get("timestamp", 0)
if fromTimestamp and ts < fromTimestamp:
continue
if toTimestamp and ts > toTimestamp:
continue
filteredRecords.append(record)
records = filteredRecords
# Apply limit
return records[:limit]
except Exception as e:
logger.error(f"Failed to query audit logs: {e}")
return []
# ===== Cleanup Methods =====
def cleanupOldEntries(self, retentionDays: int = 365) -> int:
"""
Remove audit log entries older than the retention period.
GDPR Note: Audit logs should be retained for a reasonable period
for security and compliance purposes, but not indefinitely.
Default retention is 1 year (365 days).
Args:
retentionDays: Number of days to retain audit logs
Returns:
Number of entries deleted
"""
if not self._ensureInitialized() or not self._db:
logger.warning("Cannot cleanup audit logs: database not initialized")
return 0
try:
from modules.datamodels.datamodelAudit import AuditLogEntry
import time
# Calculate cutoff timestamp
cutoffTimestamp = time.time() - (retentionDays * 24 * 60 * 60)
# Query old entries
allRecords = self._db.getRecordset(AuditLogEntry)
oldRecords = [r for r in allRecords if r.get("timestamp", 0) < cutoffTimestamp]
# Delete old entries
deletedCount = 0
for record in oldRecords:
recordId = record.get("id")
if recordId:
if self._db.recordDelete(AuditLogEntry, recordId):
deletedCount += 1
logger.info(f"Audit log cleanup: removed {deletedCount} entries older than {retentionDays} days")
# Log the cleanup action itself
self.logEvent(
userId="system",
mandateId="system",
category="system",
action="audit_cleanup",
details=f"Removed {deletedCount} entries older than {retentionDays} days"
)
return deletedCount
except Exception as e:
logger.error(f"Failed to cleanup audit logs: {e}")
return 0
# Global audit logger instance # Global audit logger instance
audit_logger = AuditLogger() audit_logger = AuditLogger()
# ===== Scheduler Integration =====
async def runAuditLogCleanup() -> None:
"""
Scheduled task to cleanup old audit log entries.
Called by the event scheduler.
"""
try:
retentionDays = int(APP_CONFIG.get("AUDIT_LOG_RETENTION_DAYS", 365))
deletedCount = audit_logger.cleanupOldEntries(retentionDays=retentionDays)
logger.info(f"Scheduled audit log cleanup completed: {deletedCount} entries removed")
except Exception as e:
logger.error(f"Scheduled audit log cleanup failed: {e}")
def registerAuditLogCleanupScheduler() -> None:
"""
Register the audit log cleanup job with the event scheduler.
Should be called during application startup.
"""
try:
from modules.shared.eventManagement import eventManager
# Run cleanup daily at 3 AM
eventManager.registerCron(
jobId="audit_log_cleanup",
func=runAuditLogCleanup,
cronKwargs={
"hour": "3",
"minute": "0"
}
)
logger.info("Audit log cleanup scheduler registered (daily at 03:00)")
except Exception as e:
logger.error(f"Failed to register audit log cleanup scheduler: {e}")

View file

@ -3,7 +3,7 @@
import logging import logging
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult from modules.datamodels.datamodelChatbot import ActionResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -4,7 +4,7 @@
import logging import logging
import time import time
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelExtraction import ContentPart
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData

View file

@ -4,7 +4,7 @@
import logging import logging
import time import time
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelExtraction import ContentPart
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData

View file

@ -5,7 +5,7 @@ import logging
import time import time
import json import json
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
from modules.datamodels.datamodelAi import AiCallOptions from modules.datamodels.datamodelAi import AiCallOptions
from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelExtraction import ContentPart

View file

@ -3,7 +3,7 @@
import logging import logging
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult from modules.datamodels.datamodelChatbot import ActionResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -3,7 +3,7 @@
import logging import logging
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult from modules.datamodels.datamodelChatbot import ActionResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -5,7 +5,7 @@ import logging
import time import time
import re import re
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -11,7 +11,7 @@ import json
import time import time
from typing import Dict, Any from typing import Dict, Any
from modules.workflows.methods.methodBase import action from modules.workflows.methods.methodBase import action
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
from modules.connectors.connectorPreprocessor import PreprocessorConnector from modules.connectors.connectorPreprocessor import PreprocessorConnector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -4,7 +4,7 @@
import logging import logging
import time import time
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
from modules.datamodels.datamodelDocref import DocumentReferenceList from modules.datamodels.datamodelDocref import DocumentReferenceList
from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy, ContentExtracted, ContentPart from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy, ContentExtracted, ContentPart

View file

@ -4,7 +4,7 @@
import logging import logging
import json import json
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -4,7 +4,7 @@
import logging import logging
import time import time
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
from modules.datamodels.datamodelDocref import DocumentReferenceList from modules.datamodels.datamodelDocref import DocumentReferenceList
from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart

View file

@ -5,7 +5,7 @@ import logging
import json import json
import aiohttp import aiohttp
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -5,7 +5,7 @@ import logging
import json import json
import uuid import uuid
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -9,7 +9,7 @@ import csv as csv_module
from io import StringIO from io import StringIO
from datetime import datetime, UTC from datetime import datetime, UTC
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -9,7 +9,7 @@ import csv as csv_module
from io import BytesIO from io import BytesIO
from datetime import datetime, UTC from datetime import datetime, UTC
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -4,7 +4,7 @@
import logging import logging
import json import json
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -4,7 +4,7 @@
import logging import logging
import json import json
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -4,7 +4,7 @@
import logging import logging
import json import json
from typing import Dict, Any, List from typing import Dict, Any, List
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import json
import io import io
import pandas as pd import pandas as pd
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import json
import pandas as pd import pandas as pd
from io import BytesIO from io import BytesIO
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import json
import base64 import base64
import requests import requests
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import time
import json import json
import requests import requests
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -5,7 +5,7 @@ import logging
import json import json
import requests import requests
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import time
import json import json
import requests import requests
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import time
import json import json
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -4,7 +4,7 @@
import logging import logging
import json import json
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import json
import base64 import base64
import os import os
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import time
import json import json
import urllib.parse import urllib.parse
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -4,7 +4,7 @@
import logging import logging
import json import json
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import time
import json import json
import urllib.parse import urllib.parse
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import time
import json import json
import base64 import base64
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@ import time
import json import json
import urllib.parse import urllib.parse
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -5,7 +5,7 @@ import logging
import json import json
import base64 import base64
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChatbot import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -5,8 +5,8 @@
import logging import logging
from typing import Dict, Any, List from typing import Dict, Any, List
from modules.datamodels.datamodelChat import ActionResult, ActionItem, TaskStep from modules.datamodels.datamodelChatbot import ActionResult, ActionItem, TaskStep
from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelChatbot import ChatWorkflow
from modules.workflows.processing.shared.methodDiscovery import methods from modules.workflows.processing.shared.methodDiscovery import methods
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped from modules.workflows.processing.shared.stateTools import checkWorkflowStopped

View file

@ -5,8 +5,8 @@
import logging import logging
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from modules.datamodels.datamodelChat import TaskPlan, TaskStep, ActionResult, ReviewResult from modules.datamodels.datamodelChatbot import TaskPlan, TaskStep, ActionResult, ReviewResult
from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelChatbot import ChatWorkflow
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,7 +6,7 @@
import json import json
import logging import logging
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan from modules.datamodels.datamodelChatbot import TaskStep, TaskContext, TaskPlan
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, ProcessingModeEnum, PriorityEnum from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, ProcessingModeEnum, PriorityEnum
from modules.workflows.processing.shared.promptGenerationTaskplan import ( from modules.workflows.processing.shared.promptGenerationTaskplan import (
generateTaskPlanningPrompt generateTaskPlanningPrompt
@ -51,7 +51,7 @@ class TaskPlanner:
# Analyze user intent to obtain cleaned user objective for planning # Analyze user intent to obtain cleaned user objective for planning
# SKIP intent analysis for AUTOMATION mode - it uses predefined JSON plans # SKIP intent analysis for AUTOMATION mode - it uses predefined JSON plans
from modules.datamodels.datamodelChat import WorkflowModeEnum from modules.datamodels.datamodelChatbot import WorkflowModeEnum
workflowMode = getattr(workflow, 'workflowMode', None) workflowMode = getattr(workflow, 'workflowMode', None)
skipIntentionAnalysis = (workflowMode == WorkflowModeEnum.WORKFLOW_AUTOMATION) skipIntentionAnalysis = (workflowMode == WorkflowModeEnum.WORKFLOW_AUTOMATION)

View file

@ -7,11 +7,11 @@ import json
import logging import logging
import uuid import uuid
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from modules.datamodels.datamodelChat import ( from modules.datamodels.datamodelChatbot import (
TaskStep, TaskContext, TaskResult, ActionItem, TaskStatus, TaskStep, TaskContext, TaskResult, ActionItem, TaskStatus,
TaskPlan, ActionResult TaskPlan, ActionResult
) )
from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelChatbot import ChatWorkflow
from modules.workflows.processing.modes.modeBase import BaseMode from modules.workflows.processing.modes.modeBase import BaseMode
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
from modules.shared.timeUtils import parseTimestamp from modules.shared.timeUtils import parseTimestamp

View file

@ -6,8 +6,8 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import logging import logging
from typing import List, Dict, Any from typing import List, Dict, Any
from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskResult, ActionItem from modules.datamodels.datamodelChatbot import TaskStep, TaskContext, TaskResult, ActionItem
from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelChatbot import ChatWorkflow
from modules.workflows.processing.core.taskPlanner import TaskPlanner from modules.workflows.processing.core.taskPlanner import TaskPlanner
from modules.workflows.processing.core.actionExecutor import ActionExecutor from modules.workflows.processing.core.actionExecutor import ActionExecutor
from modules.workflows.processing.core.messageCreator import MessageCreator from modules.workflows.processing.core.messageCreator import MessageCreator

View file

@ -9,11 +9,11 @@ import re
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Dict, Any from typing import List, Dict, Any
from modules.datamodels.datamodelChat import ( from modules.datamodels.datamodelChatbot import (
TaskStep, TaskContext, TaskResult, ActionItem, TaskStatus, TaskStep, TaskContext, TaskResult, ActionItem, TaskStatus,
ActionResult, Observation, ObservationPreview, ReviewResult ActionResult, Observation, ObservationPreview, ReviewResult
) )
from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelChatbot import ChatWorkflow
from modules.workflows.processing.modes.modeBase import BaseMode from modules.workflows.processing.modes.modeBase import BaseMode
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
from modules.shared.timeUtils import parseTimestamp from modules.shared.timeUtils import parseTimestamp
@ -893,7 +893,7 @@ class DynamicMode(BaseMode):
async def _refineDecide(self, context: TaskContext, observation: Observation) -> ReviewResult: async def _refineDecide(self, context: TaskContext, observation: Observation) -> ReviewResult:
"""Refine: decide continue or stop, with reason""" """Refine: decide continue or stop, with reason"""
# Create proper ReviewContext for extractReviewContent # Create proper ReviewContext for extractReviewContent
from modules.datamodels.datamodelChat import ReviewContext from modules.datamodels.datamodelChatbot import ReviewContext
# Convert observation to dict for extractReviewContent (temporary compatibility) # Convert observation to dict for extractReviewContent (temporary compatibility)
observationDict = { observationDict = {
'success': observation.success, 'success': observation.success,
@ -1042,7 +1042,7 @@ class DynamicMode(BaseMode):
# Parse response using structured parsing with ReviewResult model # Parse response using structured parsing with ReviewResult model
from modules.shared.jsonUtils import parseJsonWithModel from modules.shared.jsonUtils import parseJsonWithModel
from modules.datamodels.datamodelChat import ReviewResult from modules.datamodels.datamodelChatbot import ReviewResult
if not resp: if not resp:
return ReviewResult( return ReviewResult(

View file

@ -5,7 +5,7 @@
import logging import logging
from typing import List, Optional from typing import List, Optional
from modules.datamodels.datamodelChat import TaskStep, ActionResult, Observation from modules.datamodels.datamodelChatbot import TaskStep, ActionResult, Observation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -348,7 +348,7 @@ def extractReviewContent(context: Any) -> str:
elif hasattr(context, 'observation') and context.observation: elif hasattr(context, 'observation') and context.observation:
# For observation data, show full content but handle documents specially # For observation data, show full content but handle documents specially
# Handle both Pydantic Observation model and dict format # Handle both Pydantic Observation model and dict format
from modules.datamodels.datamodelChat import Observation from modules.datamodels.datamodelChatbot import Observation
if isinstance(context.observation, Observation): if isinstance(context.observation, Observation):
# Convert Pydantic model to dict # Convert Pydantic model to dict
@ -371,7 +371,7 @@ def extractReviewContent(context: Any) -> str:
# For observation data in stepResult, show full content but handle documents specially # For observation data in stepResult, show full content but handle documents specially
observation = context.stepResult['observation'] observation = context.stepResult['observation']
# Handle both Pydantic Observation model and dict format # Handle both Pydantic Observation model and dict format
from modules.datamodels.datamodelChat import Observation from modules.datamodels.datamodelChatbot import Observation
if isinstance(observation, Observation): if isinstance(observation, Observation):
# Convert Pydantic model to dict # Convert Pydantic model to dict
@ -452,10 +452,10 @@ def extractLatestRefinementFeedback(context: Any) -> str:
# First check for ERROR level logs in workflow # First check for ERROR level logs in workflow
if hasattr(context, 'workflow') and context.workflow: if hasattr(context, 'workflow') and context.workflow:
try: try:
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
from modules.interfaces.interfaceDbAppObjects import getRootInterface from modules.interfaces.interfaceDbAppObjects import getRootInterface
rootInterface = getRootInterface() rootInterface = getRootInterface()
interfaceDbChat = interfaceDbChatObjects.getInterface(rootInterface.currentUser) interfaceDbChat = interfaceDbChatbot.getInterface(rootInterface.currentUser)
# Get workflow logs # Get workflow logs
chatData = interfaceDbChat.getUnifiedChatData(context.workflow.id, None) chatData = interfaceDbChat.getUnifiedChatData(context.workflow.id, None)

View file

@ -7,7 +7,7 @@ Handles prompt templates for dynamic mode action handling.
import json import json
from typing import Any, List from typing import Any, List
from modules.datamodels.datamodelChat import PromptBundle, PromptPlaceholder from modules.datamodels.datamodelChatbot import PromptBundle, PromptPlaceholder
from modules.workflows.processing.shared.placeholderFactory import ( from modules.workflows.processing.shared.placeholderFactory import (
extractUserPrompt, extractUserPrompt,
extractUserLanguage, extractUserLanguage,

View file

@ -7,7 +7,7 @@ Handles prompt templates and extraction functions for task planning phase.
import logging import logging
from typing import Dict, Any, List from typing import Dict, Any, List
from modules.datamodels.datamodelChat import PromptBundle, PromptPlaceholder from modules.datamodels.datamodelChatbot import PromptBundle, PromptPlaceholder
from modules.workflows.processing.shared.placeholderFactory import ( from modules.workflows.processing.shared.placeholderFactory import (
extractUserPrompt, extractUserPrompt,
extractAvailableDocumentsSummary, extractAvailableDocumentsSummary,

View file

@ -6,9 +6,9 @@
import logging import logging
import json import json
from typing import Dict, Any, Optional, List, TYPE_CHECKING from typing import Dict, Any, Optional, List, TYPE_CHECKING
from modules.datamodels import datamodelChat from modules.datamodels import datamodelChatbot
from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan, ActionResult, ActionDocument, ChatDocument, ChatMessage from modules.datamodels.datamodelChatbot import TaskStep, TaskContext, TaskPlan, ActionResult, ActionDocument, ChatDocument, ChatMessage
from modules.datamodels.datamodelChat import ChatWorkflow, WorkflowModeEnum from modules.datamodels.datamodelChatbot import ChatWorkflow, WorkflowModeEnum
from modules.workflows.processing.modes.modeBase import BaseMode from modules.workflows.processing.modes.modeBase import BaseMode
from modules.workflows.processing.modes.modeDynamic import DynamicMode from modules.workflows.processing.modes.modeDynamic import DynamicMode
from modules.workflows.processing.modes.modeAutomation import AutomationMode from modules.workflows.processing.modes.modeAutomation import AutomationMode
@ -102,7 +102,7 @@ class WorkflowProcessor:
self.services.chat.progressLogFinish(operationId, False) self.services.chat.progressLogFinish(operationId, False)
raise raise
async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext) -> datamodelChat.TaskResult: async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext) -> datamodelChatbot.TaskResult:
"""Execute a task step using the appropriate mode""" """Execute a task step using the appropriate mode"""
import time import time
@ -494,7 +494,7 @@ class WorkflowProcessor:
# Create ActionResult with response # Create ActionResult with response
# For fast path, we create a simple text document with the response # For fast path, we create a simple text document with the response
from modules.datamodels.datamodelChat import ActionDocument from modules.datamodels.datamodelChatbot import ActionDocument
responseDoc = ActionDocument( responseDoc = ActionDocument(
documentName="fast_path_response.txt", documentName="fast_path_response.txt",
@ -626,7 +626,7 @@ class WorkflowProcessor:
ChatMessage with persisted documents ChatMessage with persisted documents
""" """
try: try:
from modules.datamodels.datamodelChat import ChatMessage, ChatDocument, ActionDocument from modules.datamodels.datamodelChatbot import ChatMessage, ChatDocument, ActionDocument
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
# Check workflow status # Check workflow status

View file

@ -6,14 +6,14 @@ import uuid
import asyncio import asyncio
import json import json
from modules.datamodels.datamodelChat import ( from modules.datamodels.datamodelChatbot import (
UserInputRequest, UserInputRequest,
ChatMessage, ChatMessage,
ChatWorkflow, ChatWorkflow,
ChatDocument, ChatDocument,
WorkflowModeEnum WorkflowModeEnum
) )
from modules.datamodels.datamodelChat import TaskContext from modules.datamodels.datamodelChatbot import TaskContext
from modules.workflows.processing.workflowProcessor import WorkflowProcessor from modules.workflows.processing.workflowProcessor import WorkflowProcessor
from modules.workflows.processing.shared.stateTools import WorkflowStoppedException, checkWorkflowStopped from modules.workflows.processing.shared.stateTools import WorkflowStoppedException, checkWorkflowStopped
@ -606,7 +606,7 @@ The following is the user's original input message. Analyze intent, normalize th
# Collect file info # Collect file info
fileInfo = self.services.chat.getFileInfo(fileItem.id) fileInfo = self.services.chat.getFileInfo(fileItem.id)
from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelChatbot import ChatDocument
doc = ChatDocument( doc = ChatDocument(
fileId=fileItem.id, fileId=fileItem.id,
fileName=fileInfo.get("fileName", fileName) if fileInfo else fileName, fileName=fileInfo.get("fileName", fileName) if fileInfo else fileName,
@ -792,7 +792,7 @@ The following is the user's original input message. Analyze intent, normalize th
# Collect file info # Collect file info
fileInfo = self.services.chat.getFileInfo(fileItem.id) fileInfo = self.services.chat.getFileInfo(fileItem.id)
from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelChatbot import ChatDocument
doc = ChatDocument( doc = ChatDocument(
fileId=fileItem.id, fileId=fileItem.id,
fileName=fileInfo.get("fileName", fileName) if fileInfo else fileName, fileName=fileInfo.get("fileName", fileName) if fileInfo else fileName,
@ -921,7 +921,7 @@ The following is the user's original input message. Analyze intent, normalize th
# Persist task result for cross-task/round document references # Persist task result for cross-task/round document references
# Convert ChatTaskResult to WorkflowTaskResult for persistence # Convert ChatTaskResult to WorkflowTaskResult for persistence
from modules.datamodels.datamodelWorkflow import TaskResult as WorkflowTaskResult from modules.datamodels.datamodelWorkflow import TaskResult as WorkflowTaskResult
from modules.datamodels.datamodelChat import ActionResult from modules.datamodels.datamodelChatbot import ActionResult
# Get final ActionResult from task execution (last action result) # Get final ActionResult from task execution (last action result)
finalActionResult = None finalActionResult = None

View file

@ -85,7 +85,7 @@ class AIModelsTester:
self.services.extraction = ExtractionService(self.services) self.services.extraction = ExtractionService(self.services)
# Create a minimal workflow context # Create a minimal workflow context
from modules.datamodels.datamodelChat import ChatWorkflow, WorkflowModeEnum from modules.datamodels.datamodelChatbot import ChatWorkflow, WorkflowModeEnum
import uuid import uuid
self.services.currentWorkflow = ChatWorkflow( self.services.currentWorkflow = ChatWorkflow(

View file

@ -18,7 +18,7 @@ if _gateway_path not in sys.path:
sys.path.insert(0, _gateway_path) sys.path.insert(0, _gateway_path)
from modules.datamodels.datamodelAi import OperationTypeEnum from modules.datamodels.datamodelAi import OperationTypeEnum
from modules.datamodels.datamodelChat import ChatWorkflow, ChatDocument, WorkflowModeEnum from modules.datamodels.datamodelChatbot import ChatWorkflow, ChatDocument, WorkflowModeEnum
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
@ -94,8 +94,8 @@ class MethodAiOperationsTester:
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
# Import and initialize services - use the same approach as routeChatPlayground # Import and initialize services - use the same approach as routeChatPlayground
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
# Import and initialize services # Import and initialize services
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
@ -174,7 +174,7 @@ class MethodAiOperationsTester:
imageData = f.read() imageData = f.read()
# Create a ChatDocument # Create a ChatDocument
from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelChatbot import ChatDocument
import uuid import uuid
testImageDoc = ChatDocument( testImageDoc = ChatDocument(
@ -186,7 +186,7 @@ class MethodAiOperationsTester:
) )
# Create a message with this document # Create a message with this document
from modules.datamodels.datamodelChat import ChatMessage from modules.datamodels.datamodelChatbot import ChatMessage
import time import time
testMessage = ChatMessage( testMessage = ChatMessage(
@ -201,8 +201,8 @@ class MethodAiOperationsTester:
# Save message to database # Save message to database
if self.services.workflow: if self.services.workflow:
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
messageDict = testMessage.model_dump() messageDict = testMessage.model_dump()
interfaceDbChat.createMessage(messageDict) interfaceDbChat.createMessage(messageDict)
@ -283,8 +283,8 @@ class MethodAiOperationsTester:
maxSteps=5 maxSteps=5
) )
# Save workflow to database # Save workflow to database
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
workflowDict = testWorkflow.model_dump() workflowDict = testWorkflow.model_dump()
interfaceDbChat.createWorkflow(workflowDict) interfaceDbChat.createWorkflow(workflowDict)

View file

@ -42,10 +42,10 @@ class AIBehaviorTester:
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
# Create and save workflow in database using the interface # Create and save workflow in database using the interface
from modules.datamodels.datamodelChat import ChatWorkflow, WorkflowModeEnum from modules.datamodels.datamodelChatbot import ChatWorkflow, WorkflowModeEnum
import uuid import uuid
import time import time
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
currentTimestamp = time.time() currentTimestamp = time.time()
@ -67,7 +67,7 @@ class AIBehaviorTester:
) )
# SAVE workflow to database so it exists for access control # SAVE workflow to database so it exists for access control
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
workflowDict = testWorkflow.model_dump() workflowDict = testWorkflow.model_dump()
interfaceDbChat.createWorkflow(workflowDict) interfaceDbChat.createWorkflow(workflowDict)

View file

@ -20,10 +20,10 @@ if _gateway_path not in sys.path:
# Import the service initialization # Import the service initialization
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelChatbot import UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.features.workflow import chatStart from modules.features.workflow import chatStart
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
class WorkflowWithDocumentsTester: class WorkflowWithDocumentsTester:
@ -192,7 +192,7 @@ class WorkflowWithDocumentsTester:
return False return False
# Get current workflow status # Get current workflow status
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
currentWorkflow = interfaceDbChat.getWorkflow(self.workflow.id) currentWorkflow = interfaceDbChat.getWorkflow(self.workflow.id)
if not currentWorkflow: if not currentWorkflow:
@ -225,7 +225,7 @@ class WorkflowWithDocumentsTester:
if not self.workflow: if not self.workflow:
return {"error": "No workflow to analyze"} return {"error": "No workflow to analyze"}
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
workflow = interfaceDbChat.getWorkflow(self.workflow.id) workflow = interfaceDbChat.getWorkflow(self.workflow.id)
if not workflow: if not workflow:

View file

@ -22,10 +22,10 @@ if _gateway_path not in sys.path:
# Import the service initialization # Import the service initialization
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelChatbot import UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.features.workflow import chatStart from modules.features.workflow import chatStart
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
class WorkflowPromptVariationsTester: class WorkflowPromptVariationsTester:
@ -115,7 +115,7 @@ class WorkflowPromptVariationsTester:
return False return False
# Get current workflow status # Get current workflow status
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
currentWorkflow = interfaceDbChat.getWorkflow(workflow.id) currentWorkflow = interfaceDbChat.getWorkflow(workflow.id)
if not currentWorkflow: if not currentWorkflow:
@ -140,7 +140,7 @@ class WorkflowPromptVariationsTester:
def _analyzeWorkflowResults(self, workflow: Any) -> Dict[str, Any]: def _analyzeWorkflowResults(self, workflow: Any) -> Dict[str, Any]:
"""Analyze workflow results and extract information.""" """Analyze workflow results and extract information."""
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
workflow = interfaceDbChat.getWorkflow(workflow.id) workflow = interfaceDbChat.getWorkflow(workflow.id)
if not workflow: if not workflow:

View file

@ -21,10 +21,10 @@ if _gateway_path not in sys.path:
# Import the service initialization # Import the service initialization
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelChatbot import UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.features.workflow import chatStart from modules.features.workflow import chatStart
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
class DocumentGenerationFormatsTester: class DocumentGenerationFormatsTester:
@ -251,7 +251,7 @@ class DocumentGenerationFormatsTester:
startTime = time.time() startTime = time.time()
lastStatus = None lastStatus = None
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
if timeout is None: if timeout is None:
print("Waiting indefinitely (no timeout)") print("Waiting indefinitely (no timeout)")
@ -296,7 +296,7 @@ class DocumentGenerationFormatsTester:
if not self.workflow: if not self.workflow:
return {"error": "No workflow to analyze"} return {"error": "No workflow to analyze"}
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
workflow = interfaceDbChat.getWorkflow(self.workflow.id) workflow = interfaceDbChat.getWorkflow(self.workflow.id)
if not workflow: if not workflow:

View file

@ -21,10 +21,10 @@ if _gateway_path not in sys.path:
# Import the service initialization # Import the service initialization
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelChatbot import UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.features.workflow import chatStart from modules.features.workflow import chatStart
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
class DocumentGenerationFormatsTester10: class DocumentGenerationFormatsTester10:
@ -248,7 +248,7 @@ class DocumentGenerationFormatsTester10:
startTime = time.time() startTime = time.time()
lastStatus = None lastStatus = None
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
if timeout is None: if timeout is None:
print("Waiting indefinitely (no timeout)") print("Waiting indefinitely (no timeout)")
@ -293,7 +293,7 @@ class DocumentGenerationFormatsTester10:
if not self.workflow: if not self.workflow:
return {"error": "No workflow to analyze"} return {"error": "No workflow to analyze"}
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
workflow = interfaceDbChat.getWorkflow(self.workflow.id) workflow = interfaceDbChat.getWorkflow(self.workflow.id)
if not workflow: if not workflow:

View file

@ -23,10 +23,10 @@ if _gateway_path not in sys.path:
# Import the service initialization # Import the service initialization
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelChatbot import UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.features.workflow import chatStart from modules.features.workflow import chatStart
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
class CodeGenerationFormatsTester11: class CodeGenerationFormatsTester11:
@ -190,7 +190,7 @@ class CodeGenerationFormatsTester11:
startTime = time.time() startTime = time.time()
lastStatus = None lastStatus = None
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
if timeout is None: if timeout is None:
print("Waiting indefinitely (no timeout)") print("Waiting indefinitely (no timeout)")
@ -235,7 +235,7 @@ class CodeGenerationFormatsTester11:
if not self.workflow: if not self.workflow:
return {"error": "No workflow to analyze"} return {"error": "No workflow to analyze"}
interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser)
workflow = interfaceDbChat.getWorkflow(self.workflow.id) workflow = interfaceDbChat.getWorkflow(self.workflow.id)
if not workflow: if not workflow:

View file

@ -10,7 +10,7 @@ import pytest
import uuid import uuid
from unittest.mock import Mock, AsyncMock, patch from unittest.mock import Mock, AsyncMock, patch
from modules.datamodels.datamodelChat import ChatWorkflow, TaskContext, TaskStep from modules.datamodels.datamodelChatbot import ChatWorkflow, TaskContext, TaskStep
from modules.datamodels.datamodelWorkflow import ActionDefinition from modules.datamodels.datamodelWorkflow import ActionDefinition
from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentListReference, DocumentItemReference from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentListReference, DocumentItemReference

View file

@ -9,7 +9,7 @@ Tests state increment methods, helper methods, and updateFromSelection.
import pytest import pytest
import uuid import uuid
from modules.datamodels.datamodelChat import ChatWorkflow, TaskContext, TaskStep from modules.datamodels.datamodelChatbot import ChatWorkflow, TaskContext, TaskStep
from modules.datamodels.datamodelWorkflow import ActionDefinition from modules.datamodels.datamodelWorkflow import ActionDefinition

View file

@ -15,7 +15,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
from modules.datamodels.datamodelWorkflow import ActionDefinition, AiResponse from modules.datamodels.datamodelWorkflow import ActionDefinition, AiResponse
from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentListReference from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentListReference
from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelChatbot import ChatWorkflow
from modules.shared.jsonUtils import parseJsonWithModel from modules.shared.jsonUtils import parseJsonWithModel

508
tool_db_export_migration.py Normal file
View file

@ -0,0 +1,508 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Datenbank Export-Tool für Migration.
Dieses Script exportiert alle Daten aus ALLEN PowerOn PostgreSQL-Datenbanken
in eine JSON-Datei, die als Migrationsdatensatz verwendet werden kann.
Datenbanken:
- poweron_app (User, Mandate, RBAC, Features, etc.)
- poweron_chat (Chat-Konversationen und Nachrichten)
- poweron_management (Workflows, Prompts, Connections, etc.)
- poweron_realestate (Real Estate Daten)
- poweron_trustee (Trustee Daten)
Verwendung:
python tool_db_export_migration.py [--output <pfad>] [--pretty]
Optionen:
--output, -o Pfad zur Ausgabedatei (Standard: migration_export_<timestamp>.json)
--pretty, -p JSON formatiert ausgeben (für bessere Lesbarkeit)
--exclude Komma-getrennte Liste von Tabellen, die ausgeschlossen werden sollen
--include-meta System-Metadaten (_createdAt, _modifiedAt, etc.) beibehalten
--db Nur bestimmte Datenbank(en) exportieren (komma-getrennt)
"""
import os
import sys
import json
import argparse
import logging
from datetime import datetime
from typing import Dict, List, Any, Optional
from pathlib import Path
import psycopg2
import psycopg2.extras
# Logging konfigurieren
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# Alle PowerOn Datenbanken
ALL_DATABASES = [
"poweron_app", # Haupt-App: User, Mandate, RBAC, Features
"poweron_chat", # Chat-Konversationen
"poweron_management", # Workflows, Prompts, Connections
"poweron_realestate", # Real Estate
"poweron_trustee", # Trustee
]
def _loadEnvConfig() -> Dict[str, str]:
"""Lädt die Konfiguration direkt aus der .env Datei."""
config = {}
envPath = Path(__file__).parent / '.env'
if not envPath.exists():
logger.warning(f"Environment file not found at {envPath}")
return config
# Versuche verschiedene Encodings
encodings = ['utf-8', 'utf-8-sig', 'latin-1', 'cp1252']
for encoding in encodings:
try:
with open(envPath, 'r', encoding=encoding) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
key, value = line.split('=', 1)
config[key.strip()] = value.strip()
# Erfolgreich geladen
return config
except UnicodeDecodeError:
continue
except Exception as e:
logger.error(f"Error loading .env file with {encoding}: {e}")
continue
logger.error(f"Could not load .env file with any encoding")
return config
# Globale Konfiguration laden
_ENV_CONFIG = _loadEnvConfig()
def _getConfigValue(key: str, default: str = None) -> str:
"""Holt einen Konfigurationswert."""
return _ENV_CONFIG.get(key, os.environ.get(key, default))
def _databaseExists(dbDatabase: str) -> bool:
"""Prüft ob eine Datenbank existiert."""
dbHost = _getConfigValue("DB_HOST", "localhost")
dbUser = _getConfigValue("DB_USER")
dbPassword = _getConfigValue("DB_PASSWORD_SECRET")
dbPort = int(_getConfigValue("DB_PORT", "5432"))
try:
# Verbinde zur postgres Datenbank um zu prüfen
conn = psycopg2.connect(
host=dbHost,
port=dbPort,
database="postgres",
user=dbUser,
password=dbPassword
)
conn.autocommit = True
with conn.cursor() as cursor:
cursor.execute(
"SELECT 1 FROM pg_database WHERE datname = %s",
(dbDatabase,)
)
exists = cursor.fetchone() is not None
conn.close()
return exists
except Exception as e:
logger.error(f"Fehler beim Prüfen der Datenbank {dbDatabase}: {e}")
return False
def _getDbConnection(dbDatabase: str):
"""Erstellt eine Verbindung zu einer spezifischen PostgreSQL-Datenbank."""
# Erst prüfen ob Datenbank existiert
if not _databaseExists(dbDatabase):
logger.warning(f"Datenbank '{dbDatabase}' existiert nicht - übersprungen")
return None
dbHost = _getConfigValue("DB_HOST", "localhost")
dbUser = _getConfigValue("DB_USER")
dbPassword = _getConfigValue("DB_PASSWORD_SECRET")
dbPort = int(_getConfigValue("DB_PORT", "5432"))
try:
conn = psycopg2.connect(
host=dbHost,
port=dbPort,
database=dbDatabase,
user=dbUser,
password=dbPassword,
cursor_factory=psycopg2.extras.RealDictCursor
)
conn.set_client_encoding('UTF8')
return conn
except Exception as e:
logger.error(f"Datenbankverbindung zu {dbDatabase} fehlgeschlagen: {e}")
raise
def _getTables(conn) -> List[str]:
"""Gibt alle Tabellennamen in der Datenbank zurück."""
with conn.cursor() as cursor:
cursor.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
""")
tables = [row["table_name"] for row in cursor.fetchall()]
return tables
def _getTableData(conn, tableName: str, includeMeta: bool = False) -> List[Dict[str, Any]]:
"""Liest alle Daten aus einer Tabelle."""
with conn.cursor() as cursor:
cursor.execute(f'SELECT * FROM "{tableName}"')
rows = cursor.fetchall()
records = []
for row in rows:
record = dict(row)
# Optional: System-Metadaten entfernen
if not includeMeta:
metaFields = ["_createdAt", "_modifiedAt", "_createdBy", "_modifiedBy"]
for field in metaFields:
record.pop(field, None)
# Konvertiere JSONB-Felder (sind bereits als Dict/List von psycopg2)
for key, value in record.items():
if isinstance(value, (int, float)):
record[key] = float(value) if isinstance(value, float) else int(value)
records.append(record)
return records
def _getTableRowCount(conn, tableName: str) -> int:
"""Zählt die Anzahl der Zeilen in einer Tabelle."""
with conn.cursor() as cursor:
cursor.execute(f'SELECT COUNT(*) as count FROM "{tableName}"')
result = cursor.fetchone()
return result["count"] if result else 0
def _exportSingleDatabase(
dbDatabase: str,
excludeTables: List[str],
includeMeta: bool
) -> Optional[Dict[str, Any]]:
"""Exportiert eine einzelne Datenbank."""
conn = _getDbConnection(dbDatabase)
if conn is None:
return None
try:
allTables = _getTables(conn)
# System-Tabellen ausschliessen
systemTables = ["_system"]
tablesToExport = [
t for t in allTables
if t not in systemTables and t not in excludeTables
]
dbExport = {
"tables": {},
"summary": {},
"tableCount": len(tablesToExport),
"totalRecords": 0
}
for tableName in tablesToExport:
try:
records = _getTableData(conn, tableName, includeMeta)
rowCount = len(records)
dbExport["totalRecords"] += rowCount
dbExport["tables"][tableName] = records
dbExport["summary"][tableName] = {"recordCount": rowCount}
if rowCount > 0:
logger.info(f" {tableName}: {rowCount} Datensätze")
except Exception as e:
logger.error(f" Fehler bei Tabelle {tableName}: {e}")
dbExport["tables"][tableName] = []
dbExport["summary"][tableName] = {"recordCount": 0, "error": str(e)}
return dbExport
finally:
conn.close()
def exportDatabase(
outputPath: Optional[str] = None,
prettyPrint: bool = False,
excludeTables: Optional[List[str]] = None,
includeMeta: bool = False,
onlyDatabases: Optional[List[str]] = None
) -> str:
"""
Exportiert alle Datenbanken in eine JSON-Datei.
Args:
outputPath: Pfad zur Ausgabedatei (optional)
prettyPrint: JSON formatiert ausgeben
excludeTables: Liste von Tabellen, die ausgeschlossen werden sollen
includeMeta: System-Metadaten beibehalten
onlyDatabases: Nur diese Datenbanken exportieren
Returns:
Pfad zur erstellten Exportdatei
"""
excludeTables = excludeTables or []
# Welche Datenbanken exportieren?
databasesToExport = onlyDatabases if onlyDatabases else ALL_DATABASES
# Standard-Ausgabepfad generieren (im Log-Ordner)
if not outputPath:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
logDir = _getConfigValue("APP_LOGGING_LOG_DIR")
if logDir and os.path.isabs(logDir):
outputDir = logDir
else:
outputDir = os.path.join(os.path.dirname(__file__), "local", "logs")
os.makedirs(outputDir, exist_ok=True)
outputPath = os.path.join(outputDir, f"migration_export_{timestamp}.json")
logger.info(f"Starte Export von {len(databasesToExport)} Datenbank(en)...")
logger.info(f"Datenbanken: {', '.join(databasesToExport)}")
# Export-Struktur erstellen
exportData = {
"meta": {
"exportedAt": datetime.utcnow().isoformat() + "Z",
"exportedFrom": _getConfigValue("APP_ENV_LABEL", "unknown"),
"version": "1.0",
"databaseCount": 0,
"totalTables": 0,
"totalRecords": 0,
"excludedTables": excludeTables,
"includesMeta": includeMeta
},
"databases": {}
}
# Jede Datenbank exportieren
for dbName in databasesToExport:
logger.info(f"Exportiere Datenbank: {dbName}")
dbExport = _exportSingleDatabase(dbName, excludeTables, includeMeta)
if dbExport is not None:
exportData["databases"][dbName] = dbExport
exportData["meta"]["databaseCount"] += 1
exportData["meta"]["totalTables"] += dbExport["tableCount"]
exportData["meta"]["totalRecords"] += dbExport["totalRecords"]
logger.info(f" -> {dbExport['tableCount']} Tabellen, {dbExport['totalRecords']} Datensätze")
else:
logger.info(f" -> Übersprungen (existiert nicht)")
# JSON-Datei schreiben
logger.info(f"Schreibe Exportdatei: {outputPath}")
with open(outputPath, "w", encoding="utf-8") as f:
if prettyPrint:
json.dump(exportData, f, indent=2, ensure_ascii=False, default=str)
else:
json.dump(exportData, f, ensure_ascii=False, default=str)
# Dateigrösse berechnen
fileSize = os.path.getsize(outputPath)
fileSizeStr = _formatFileSize(fileSize)
logger.info(f"Export abgeschlossen!")
logger.info(f" Datenbanken: {exportData['meta']['databaseCount']}")
logger.info(f" Tabellen: {exportData['meta']['totalTables']}")
logger.info(f" Datensätze: {exportData['meta']['totalRecords']}")
logger.info(f" Dateigrösse: {fileSizeStr}")
logger.info(f" Ausgabedatei: {outputPath}")
return outputPath
def _formatFileSize(sizeBytes: int) -> str:
"""Formatiert Dateigrösse in lesbares Format."""
for unit in ['B', 'KB', 'MB', 'GB']:
if sizeBytes < 1024:
return f"{sizeBytes:.2f} {unit}"
sizeBytes /= 1024
return f"{sizeBytes:.2f} TB"
def printDatabaseSummary():
"""Zeigt eine Zusammenfassung aller Datenbanken an."""
print("\n" + "=" * 70)
print("DATENBANK ZUSAMMENFASSUNG - ALLE POWEREON DATENBANKEN")
print("=" * 70)
print(f"Umgebung: {_getConfigValue('APP_ENV_LABEL', 'unknown')}")
print(f"Host: {_getConfigValue('DB_HOST', 'localhost')}")
print("=" * 70)
grandTotalRecords = 0
grandTotalTables = 0
for dbName in ALL_DATABASES:
print(f"\n{dbName}")
print("-" * 70)
conn = _getDbConnection(dbName)
if conn is None:
print(" (Datenbank existiert nicht)")
continue
try:
tables = _getTables(conn)
dbTotalRecords = 0
print(f" {'Tabelle':<45} {'Datensätze':>15}")
print(f" {'-' * 45} {'-' * 15}")
for tableName in tables:
if tableName.startswith("_"):
continue # System-Tabellen überspringen
count = _getTableRowCount(conn, tableName)
dbTotalRecords += count
if count > 0: # Nur nicht-leere Tabellen anzeigen
print(f" {tableName:<45} {count:>15}")
print(f" {'-' * 45} {'-' * 15}")
print(f" {'Gesamt':<45} {dbTotalRecords:>15}")
grandTotalRecords += dbTotalRecords
grandTotalTables += len([t for t in tables if not t.startswith("_")])
finally:
conn.close()
print("\n" + "=" * 70)
print(f"GESAMTÜBERSICHT")
print(f" Datenbanken: {len(ALL_DATABASES)}")
print(f" Tabellen: {grandTotalTables}")
print(f" Datensätze: {grandTotalRecords}")
print("=" * 70 + "\n")
def main():
parser = argparse.ArgumentParser(
description="Exportiert alle PowerOn Datenbank-Daten für Migration",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Datenbanken:
poweron_app - User, Mandate, RBAC, Features
poweron_chat - Chat-Konversationen
poweron_management - Workflows, Prompts, Connections
poweron_realestate - Real Estate Daten
poweron_trustee - Trustee Daten
Beispiele:
python tool_db_export_migration.py
python tool_db_export_migration.py --pretty
python tool_db_export_migration.py -o backup.json --pretty
python tool_db_export_migration.py --db poweron_app,poweron_chat
python tool_db_export_migration.py --exclude Token,AuthEvent --include-meta
python tool_db_export_migration.py --summary
"""
)
parser.add_argument(
"-o", "--output",
help="Pfad zur Ausgabedatei",
type=str,
default=None
)
parser.add_argument(
"-p", "--pretty",
help="JSON formatiert ausgeben",
action="store_true"
)
parser.add_argument(
"--exclude",
help="Komma-getrennte Liste von Tabellen zum Ausschliessen",
type=str,
default=""
)
parser.add_argument(
"--include-meta",
help="System-Metadaten (_createdAt, etc.) beibehalten",
action="store_true"
)
parser.add_argument(
"--db",
help="Nur bestimmte Datenbank(en) exportieren (komma-getrennt)",
type=str,
default=""
)
parser.add_argument(
"--summary",
help="Nur Zusammenfassung anzeigen (kein Export)",
action="store_true"
)
args = parser.parse_args()
# Nur Zusammenfassung anzeigen
if args.summary:
printDatabaseSummary()
return
# Exclude-Liste parsen
excludeTables = []
if args.exclude:
excludeTables = [t.strip() for t in args.exclude.split(",") if t.strip()]
# Datenbank-Liste parsen
onlyDatabases = None
if args.db:
onlyDatabases = [db.strip() for db in args.db.split(",") if db.strip()]
# Export durchführen
try:
outputPath = exportDatabase(
outputPath=args.output,
prettyPrint=args.pretty,
excludeTables=excludeTables,
includeMeta=args.include_meta,
onlyDatabases=onlyDatabases
)
print(f"\n Export erfolgreich: {outputPath}\n")
except Exception as e:
logger.error(f"Export fehlgeschlagen: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

612
tool_db_import_migration.py Normal file
View file

@ -0,0 +1,612 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Datenbank Import-Tool für Migration.
Dieses Script importiert Daten aus einer JSON-Migrationsdatei
in ALLE PowerOn PostgreSQL-Datenbanken.
ACHTUNG: Dieses Script kann bestehende Daten überschreiben!
Bitte vor dem Import ein Backup erstellen.
Datenbanken:
- poweron_app (User, Mandate, RBAC, Features, etc.)
- poweron_chat (Chat-Konversationen und Nachrichten)
- poweron_management (Workflows, Prompts, Connections, etc.)
- poweron_realestate (Real Estate Daten)
- poweron_trustee (Trustee Daten)
Verwendung:
python tool_db_import_migration.py <import_file.json> [--dry-run] [--force]
Optionen:
--dry-run Simuliert den Import ohne Änderungen
--force Bestätigung überspringen
--clear-first Tabellen vor dem Import leeren
--only-tables Komma-getrennte Liste von Tabellen (nur diese importieren)
--only-db Komma-getrennte Liste von Datenbanken (nur diese importieren)
"""
import os
import sys
import json
import argparse
import logging
import time
from datetime import datetime
from typing import Dict, List, Any, Optional
from pathlib import Path
import psycopg2
import psycopg2.extras
# Logging konfigurieren
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# Alle PowerOn Datenbanken
ALL_DATABASES = [
"poweron_app",
"poweron_chat",
"poweron_management",
"poweron_realestate",
"poweron_trustee",
]
def _loadEnvConfig() -> Dict[str, str]:
"""Lädt die Konfiguration direkt aus der .env Datei."""
config = {}
envPath = Path(__file__).parent / '.env'
if not envPath.exists():
logger.warning(f"Environment file not found at {envPath}")
return config
# Versuche verschiedene Encodings
encodings = ['utf-8', 'utf-8-sig', 'latin-1', 'cp1252']
for encoding in encodings:
try:
with open(envPath, 'r', encoding=encoding) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
key, value = line.split('=', 1)
config[key.strip()] = value.strip()
# Erfolgreich geladen
return config
except UnicodeDecodeError:
continue
except Exception as e:
logger.error(f"Error loading .env file with {encoding}: {e}")
continue
logger.error(f"Could not load .env file with any encoding")
return config
# Globale Konfiguration laden
_ENV_CONFIG = _loadEnvConfig()
def _getConfigValue(key: str, default: str = None) -> str:
"""Holt einen Konfigurationswert."""
return _ENV_CONFIG.get(key, os.environ.get(key, default))
def _getUtcTimestamp() -> float:
"""Gibt den aktuellen UTC-Timestamp zurück."""
return time.time()
def _databaseExists(dbDatabase: str) -> bool:
"""Prüft ob eine Datenbank existiert."""
dbHost = _getConfigValue("DB_HOST", "localhost")
dbUser = _getConfigValue("DB_USER")
dbPassword = _getConfigValue("DB_PASSWORD_SECRET")
dbPort = int(_getConfigValue("DB_PORT", "5432"))
try:
conn = psycopg2.connect(
host=dbHost,
port=dbPort,
database="postgres",
user=dbUser,
password=dbPassword
)
conn.autocommit = True
with conn.cursor() as cursor:
cursor.execute(
"SELECT 1 FROM pg_database WHERE datname = %s",
(dbDatabase,)
)
exists = cursor.fetchone() is not None
conn.close()
return exists
except Exception as e:
logger.error(f"Fehler beim Prüfen der Datenbank {dbDatabase}: {e}")
return False
def _getDbConnection(dbDatabase: str, autocommit: bool = False):
"""Erstellt eine Verbindung zu einer spezifischen PostgreSQL-Datenbank."""
# Erst prüfen ob Datenbank existiert
if not _databaseExists(dbDatabase):
logger.warning(f"Datenbank '{dbDatabase}' existiert nicht")
return None
dbHost = _getConfigValue("DB_HOST", "localhost")
dbUser = _getConfigValue("DB_USER")
dbPassword = _getConfigValue("DB_PASSWORD_SECRET")
dbPort = int(_getConfigValue("DB_PORT", "5432"))
try:
conn = psycopg2.connect(
host=dbHost,
port=dbPort,
database=dbDatabase,
user=dbUser,
password=dbPassword,
cursor_factory=psycopg2.extras.RealDictCursor
)
conn.set_client_encoding('UTF8')
conn.autocommit = autocommit
return conn
except Exception as e:
logger.error(f"Datenbankverbindung zu {dbDatabase} fehlgeschlagen: {e}")
raise
def _getExistingTables(conn) -> List[str]:
"""Gibt alle Tabellennamen in der Datenbank zurück."""
with conn.cursor() as cursor:
cursor.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
""")
tables = [row["table_name"] for row in cursor.fetchall()]
return tables
def _getTableColumns(conn, tableName: str) -> List[str]:
"""Gibt alle Spalten einer Tabelle zurück."""
with conn.cursor() as cursor:
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s AND table_schema = 'public'
""", (tableName,))
columns = [row["column_name"] for row in cursor.fetchall()]
return columns
def _clearTable(conn, tableName: str):
"""Löscht alle Daten aus einer Tabelle."""
with conn.cursor() as cursor:
cursor.execute(f'DELETE FROM "{tableName}"')
def _insertRecord(conn, tableName: str, record: Dict[str, Any], existingColumns: List[str]) -> bool:
"""Fügt einen Datensatz in eine Tabelle ein (UPSERT)."""
filteredRecord = {k: v for k, v in record.items() if k in existingColumns}
if not filteredRecord:
return False
# Metadaten hinzufügen falls nicht vorhanden
currentTime = _getUtcTimestamp()
if "_createdAt" not in filteredRecord and "_createdAt" in existingColumns:
filteredRecord["_createdAt"] = currentTime
if "_modifiedAt" in existingColumns:
filteredRecord["_modifiedAt"] = currentTime
columns = list(filteredRecord.keys())
values = []
for col in columns:
value = filteredRecord[col]
if isinstance(value, (dict, list)):
values.append(json.dumps(value))
else:
values.append(value)
colNames = ", ".join([f'"{col}"' for col in columns])
placeholders = ", ".join(["%s"] * len(columns))
updateCols = [col for col in columns if col not in ["id", "_createdAt", "_createdBy"]]
updateClause = ", ".join([f'"{col}" = EXCLUDED."{col}"' for col in updateCols])
if updateClause:
sql = f'''
INSERT INTO "{tableName}" ({colNames})
VALUES ({placeholders})
ON CONFLICT ("id") DO UPDATE SET {updateClause}
'''
else:
sql = f'''
INSERT INTO "{tableName}" ({colNames})
VALUES ({placeholders})
ON CONFLICT ("id") DO NOTHING
'''
try:
with conn.cursor() as cursor:
cursor.execute(sql, values)
return True
except Exception as e:
logger.error(f"Fehler beim Einfügen in {tableName}: {e}")
return False
def loadMigrationFile(filePath: str) -> Dict[str, Any]:
"""Lädt die Migrationsdatei."""
logger.info(f"Lade Migrationsdatei: {filePath}")
if not os.path.exists(filePath):
raise FileNotFoundError(f"Datei nicht gefunden: {filePath}")
with open(filePath, "r", encoding="utf-8") as f:
data = json.load(f)
# Validierung - unterstütze beide Formate (alt: tables, neu: databases)
if "databases" not in data and "tables" not in data:
raise ValueError("Ungültiges Migrationsformat: 'databases' oder 'tables' erforderlich")
return data
def _importSingleDatabase(
dbName: str,
dbData: Dict[str, Any],
dryRun: bool,
clearFirst: bool,
onlyTables: Optional[List[str]]
) -> Dict[str, Any]:
"""Importiert Daten in eine einzelne Datenbank."""
stats = {
"imported": {},
"skipped": {},
"errors": {},
"totalImported": 0,
"totalSkipped": 0,
"totalErrors": 0
}
conn = _getDbConnection(dbName)
if conn is None:
logger.warning(f" Datenbank '{dbName}' existiert nicht - übersprungen")
return stats
try:
existingTables = _getExistingTables(conn)
tables = dbData.get("tables", {})
tablesToImport = list(tables.keys())
if onlyTables:
tablesToImport = [t for t in tablesToImport if t in onlyTables]
for tableName in tablesToImport:
records = tables[tableName]
if tableName not in existingTables:
logger.warning(f" Tabelle '{tableName}' existiert nicht - übersprungen")
stats["skipped"][tableName] = len(records)
stats["totalSkipped"] += len(records)
continue
if dryRun:
stats["imported"][tableName] = len(records)
stats["totalImported"] += len(records)
continue
if clearFirst:
_clearTable(conn, tableName)
existingColumns = _getTableColumns(conn, tableName)
imported = 0
errors = 0
for record in records:
if _insertRecord(conn, tableName, record, existingColumns):
imported += 1
else:
errors += 1
stats["imported"][tableName] = imported
stats["totalImported"] += imported
if errors > 0:
stats["errors"][tableName] = errors
stats["totalErrors"] += errors
if imported > 0:
logger.info(f" {tableName}: {imported} importiert, {errors} Fehler")
if not dryRun:
conn.commit()
else:
conn.rollback()
return stats
except Exception as e:
conn.rollback()
logger.error(f" Import fehlgeschlagen: {e}")
raise
finally:
conn.close()
def importDatabase(
filePath: str,
dryRun: bool = False,
clearFirst: bool = False,
onlyTables: Optional[List[str]] = None,
onlyDatabases: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Importiert Daten aus einer Migrationsdatei.
Args:
filePath: Pfad zur Migrationsdatei
dryRun: Nur simulieren
clearFirst: Tabellen vor Import leeren
onlyTables: Nur diese Tabellen importieren
onlyDatabases: Nur diese Datenbanken importieren
Returns:
Import-Statistiken
"""
migrationData = loadMigrationFile(filePath)
meta = migrationData.get("meta", {})
logger.info(f"Migrationsdatei geladen:")
logger.info(f" Exportiert am: {meta.get('exportedAt', 'unbekannt')}")
logger.info(f" Quelle: {meta.get('exportedFrom', 'unbekannt')}")
stats = {
"databases": {},
"totalImported": 0,
"totalSkipped": 0,
"totalErrors": 0
}
# Neues Format (mehrere Datenbanken)
if "databases" in migrationData:
databases = migrationData["databases"]
logger.info(f" Datenbanken: {len(databases)}")
logger.info(f" Tabellen: {meta.get('totalTables', 'unbekannt')}")
logger.info(f" Datensätze: {meta.get('totalRecords', 'unbekannt')}")
for dbName, dbData in databases.items():
if onlyDatabases and dbName not in onlyDatabases:
continue
logger.info(f"Importiere Datenbank: {dbName}")
dbStats = _importSingleDatabase(dbName, dbData, dryRun, clearFirst, onlyTables)
stats["databases"][dbName] = dbStats
stats["totalImported"] += dbStats["totalImported"]
stats["totalSkipped"] += dbStats["totalSkipped"]
stats["totalErrors"] += dbStats["totalErrors"]
# Altes Format (einzelne Datenbank - poweron_app)
elif "tables" in migrationData:
logger.info(" Format: Legacy (einzelne Datenbank)")
dbName = "poweron_app"
dbData = {"tables": migrationData["tables"]}
if not onlyDatabases or dbName in onlyDatabases:
logger.info(f"Importiere Datenbank: {dbName}")
dbStats = _importSingleDatabase(dbName, dbData, dryRun, clearFirst, onlyTables)
stats["databases"][dbName] = dbStats
stats["totalImported"] = dbStats["totalImported"]
stats["totalSkipped"] = dbStats["totalSkipped"]
stats["totalErrors"] = dbStats["totalErrors"]
if dryRun:
logger.info("Dry-Run: Keine Änderungen vorgenommen")
return stats
def printImportPreview(filePath: str):
"""Zeigt eine Vorschau der zu importierenden Daten."""
migrationData = loadMigrationFile(filePath)
meta = migrationData.get("meta", {})
print("\n" + "=" * 70)
print("IMPORT VORSCHAU")
print("=" * 70)
print(f"Datei: {filePath}")
print(f"Exportiert am: {meta.get('exportedAt', 'unbekannt')}")
print(f"Quelle: {meta.get('exportedFrom', 'unbekannt')}")
# Neues Format
if "databases" in migrationData:
databases = migrationData["databases"]
print(f"Datenbanken: {len(databases)}")
print("=" * 70)
grandTotal = 0
for dbName, dbData in databases.items():
tables = dbData.get("tables", {})
dbTotal = sum(len(records) for records in tables.values())
grandTotal += dbTotal
print(f"\n{dbName} ({dbTotal} Datensätze)")
print("-" * 70)
print(f" {'Tabelle':<45} {'Datensätze':>15}")
print(f" {'-' * 45} {'-' * 15}")
for tableName, records in sorted(tables.items()):
if len(records) > 0:
print(f" {tableName:<45} {len(records):>15}")
print("\n" + "=" * 70)
print(f"GESAMT: {grandTotal} Datensätze")
# Altes Format
elif "tables" in migrationData:
tables = migrationData["tables"]
print(f"Format: Legacy (poweron_app)")
print("-" * 70)
print(f"{'Tabelle':<45} {'Datensätze':>15}")
print("-" * 70)
totalRecords = 0
for tableName, records in sorted(tables.items()):
count = len(records)
totalRecords += count
if count > 0:
print(f"{tableName:<45} {count:>15}")
print("-" * 70)
print(f"{'GESAMT':<45} {totalRecords:>15}")
print("=" * 70 + "\n")
def main():
parser = argparse.ArgumentParser(
description="Importiert Datenbank-Daten aus einer Migrationsdatei",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Datenbanken:
poweron_app - User, Mandate, RBAC, Features
poweron_chat - Chat-Konversationen
poweron_management - Workflows, Prompts, Connections
poweron_realestate - Real Estate Daten
poweron_trustee - Trustee Daten
Beispiele:
python tool_db_import_migration.py migration_export.json --dry-run
python tool_db_import_migration.py migration_export.json --preview
python tool_db_import_migration.py migration_export.json --force
python tool_db_import_migration.py migration_export.json --clear-first --force
python tool_db_import_migration.py migration_export.json --only-db poweron_app
python tool_db_import_migration.py migration_export.json --only-tables UserInDB,Mandate
"""
)
parser.add_argument(
"import_file",
help="Pfad zur Migrationsdatei (JSON)",
type=str
)
parser.add_argument(
"--dry-run",
help="Simuliert den Import ohne Änderungen",
action="store_true"
)
parser.add_argument(
"--force",
help="Bestätigung überspringen",
action="store_true"
)
parser.add_argument(
"--clear-first",
help="Tabellen vor dem Import leeren",
action="store_true"
)
parser.add_argument(
"--only-tables",
help="Nur diese Tabellen importieren (komma-getrennt)",
type=str,
default=""
)
parser.add_argument(
"--only-db",
help="Nur diese Datenbank(en) importieren (komma-getrennt)",
type=str,
default=""
)
parser.add_argument(
"--preview",
help="Nur Vorschau anzeigen (kein Import)",
action="store_true"
)
args = parser.parse_args()
# Nur Vorschau anzeigen
if args.preview:
printImportPreview(args.import_file)
return
# Listen parsen
onlyTables = None
if args.only_tables:
onlyTables = [t.strip() for t in args.only_tables.split(",") if t.strip()]
onlyDatabases = None
if args.only_db:
onlyDatabases = [db.strip() for db in args.only_db.split(",") if db.strip()]
# Bestätigung einholen
if not args.dry_run and not args.force:
printImportPreview(args.import_file)
if args.clear_first:
print("WARNUNG: --clear-first wird ALLE bestehenden Daten in den Zieltabellen löschen!")
response = input("\nMöchten Sie den Import starten? [y/N]: ")
if response.lower() not in ["y", "yes", "j", "ja"]:
print("Import abgebrochen.")
return
# Import durchführen
try:
if args.dry_run:
logger.info("=== DRY-RUN MODUS ===")
stats = importDatabase(
filePath=args.import_file,
dryRun=args.dry_run,
clearFirst=args.clear_first,
onlyTables=onlyTables,
onlyDatabases=onlyDatabases
)
print("\n" + "=" * 70)
print("IMPORT ERGEBNIS")
print("=" * 70)
print(f"Importiert: {stats['totalImported']} Datensätze")
print(f"Übersprungen: {stats['totalSkipped']} Datensätze")
print(f"Fehler: {stats['totalErrors']} Datensätze")
if args.dry_run:
print("\n(Dry-Run: Keine tatsächlichen Änderungen vorgenommen)")
else:
print("\n Import erfolgreich abgeschlossen!")
print("=" * 70 + "\n")
except Exception as e:
logger.error(f"Import fehlgeschlagen: {e}")
sys.exit(1)
if __name__ == "__main__":
main()