From 75484c0f7360ff78dc3b87a73d794e2942eb9eff Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sat, 28 Mar 2026 18:12:37 +0100
Subject: [PATCH] BREAKING CHANGE API and persisted records use PowerOnModel
system fields: - sysCreatedAt, sysCreatedBy, sysModifiedAt, sysModifiedBy
Removed legacy JSON/DB field names: - _createdAt, _createdBy, _modifiedAt,
_modifiedBy Frontend (frontend_nyla) and gateway call sites were updated
accordingly. Database: - Bootstrap runs idempotent backfill
(_migrateSystemFieldColumns) from old underscore columns and selected
business duplicates into sys* where sys* IS NULL. - Re-run app bootstrap
against each PostgreSQL database after deploy. - Optional: DROP INDEX IF
EXISTS "idx_invitation_createdby" if an old index remains; new index:
idx_invitation_syscreatedby on Invitation(sysCreatedBy). Tests: - RBAC
integration tests aligned with current GROUP mandate filter and
UserMandate-based UserConnection GROUP clause; buildRbacWhereClause(...,
mandateId=...) must be passed explicitly (same as production request
context).
---
modules/connectors/connectorDbPostgre.py | 110 ++++++++--------
modules/datamodels/datamodelBase.py | 68 ++++++++++
modules/datamodels/datamodelBilling.py | 5 +-
modules/datamodels/datamodelChat.py | 9 +-
modules/datamodels/datamodelDataSource.py | 6 +-
.../datamodels/datamodelFeatureDataSource.py | 6 +-
modules/datamodels/datamodelFeatures.py | 5 +-
modules/datamodels/datamodelFileFolder.py | 6 +-
modules/datamodels/datamodelFiles.py | 11 +-
modules/datamodels/datamodelInvitation.py | 15 +--
modules/datamodels/datamodelKnowledge.py | 13 +-
modules/datamodels/datamodelMembership.py | 9 +-
modules/datamodels/datamodelMessaging.py | 47 +------
modules/datamodels/datamodelNotification.py | 10 +-
modules/datamodels/datamodelRbac.py | 5 +-
modules/datamodels/datamodelSecurity.py | 9 +-
modules/datamodels/datamodelSubscription.py | 3 +-
modules/datamodels/datamodelUam.py | 15 ++-
modules/datamodels/datamodelUtils.py | 6 +-
.../automation/datamodelFeatureAutomation.py | 6 +-
.../automation/interfaceFeatureAutomation.py | 31 +++--
.../automation/routeFeatureAutomation.py | 4 +-
.../datamodelFeatureAutomation2.py | 5 +-
.../automation2/routeFeatureAutomation2.py | 8 +-
.../chatbot/interfaceFeatureChatbot.py | 12 +-
.../features/commcoach/datamodelCommcoach.py | 31 ++---
.../datamodelFeatureNeutralizer.py | 3 +-
.../realEstate/datamodelFeatureRealEstate.py | 9 +-
.../features/teamsbot/datamodelTeamsbot.py | 24 ++--
.../trustee/datamodelFeatureTrustee.py | 34 +++--
.../trustee/interfaceFeatureTrustee.py | 14 +-
.../workspace/datamodelFeatureWorkspace.py | 3 +-
modules/features/workspace/mainWorkspace.py | 2 +-
modules/interfaces/interfaceBootstrap.py | 123 +++++++++++++++++-
modules/interfaces/interfaceDbApp.py | 119 ++++++++---------
modules/interfaces/interfaceDbBilling.py | 12 +-
modules/interfaces/interfaceDbChat.py | 9 +-
modules/interfaces/interfaceDbManagement.py | 63 +++++----
modules/interfaces/interfaceFeatures.py | 14 +-
modules/interfaces/interfaceRbac.py | 22 ++--
modules/migration/migrateRootUsers.py | 2 +-
modules/routes/routeAdminAutomationEvents.py | 4 +-
modules/routes/routeAdminAutomationLogs.py | 4 +-
modules/routes/routeAdminRbacRules.py | 2 +-
modules/routes/routeBilling.py | 14 +-
modules/routes/routeDataFiles.py | 2 +-
modules/security/rbac.py | 2 +-
.../services/serviceAgent/mainServiceAgent.py | 10 +-
.../serviceKnowledge/mainServiceKnowledge.py | 2 +-
modules/shared/attributeUtils.py | 14 +-
modules/shared/dbMultiTenantOptimizations.py | 2 +-
modules/shared/gdprDeletion.py | 10 +-
modules/workflows/automation/mainWorkflow.py | 8 +-
scripts/script_db_export_migration.py | 11 +-
tests/integration/rbac/test_rbac_database.py | 116 ++++++++++-------
55 files changed, 624 insertions(+), 485 deletions(-)
create mode 100644 modules/datamodels/datamodelBase.py
diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py
index 67cceb45..e168467b 100644
--- a/modules/connectors/connectorDbPostgre.py
+++ b/modules/connectors/connectorDbPostgre.py
@@ -12,6 +12,7 @@ import threading
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.configuration import APP_CONFIG
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
@@ -20,7 +21,7 @@ logger = logging.getLogger(__name__)
# No mapping needed - table name = Pydantic model name exactly
-class SystemTable(BaseModel):
+class SystemTable(PowerOnModel):
"""Data model for system table entries"""
table_name: str = Field(
@@ -178,7 +179,7 @@ def _get_cached_connector(
userId: str = None,
) -> "DatabaseConnector":
"""Return cached DatabaseConnector for same (host, database, port) to avoid duplicate PostgreSQL inits.
- Uses contextvars for userId so concurrent requests sharing the same connector get correct _createdBy/_modifiedBy.
+ Uses contextvars for userId so concurrent requests sharing the same connector get correct sysCreatedBy/sysModifiedBy.
"""
port = int(dbPort) if dbPort is not None else 5432
key = (dbHost, dbDatabase, port)
@@ -327,8 +328,10 @@ class DatabaseConnector:
id SERIAL PRIMARY KEY,
table_name VARCHAR(255) UNIQUE NOT NULL,
initial_id VARCHAR(255) NOT NULL,
- _createdAt DOUBLE PRECISION,
- _modifiedAt DOUBLE PRECISION
+ "sysCreatedAt" DOUBLE PRECISION,
+ "sysCreatedBy" VARCHAR(255),
+ "sysModifiedAt" DOUBLE PRECISION,
+ "sysModifiedBy" VARCHAR(255)
)
""")
conn.close()
@@ -416,7 +419,7 @@ class DatabaseConnector:
for table_name, initial_id in data.items():
cursor.execute(
"""
- INSERT INTO "_system" ("table_name", "initial_id", "_modifiedAt")
+ INSERT INTO "_system" ("table_name", "initial_id", "sysModifiedAt")
VALUES (%s, %s, %s)
""",
(table_name, initial_id, getUtcTimestamp()),
@@ -448,8 +451,10 @@ class DatabaseConnector:
CREATE TABLE "{self._systemTableName}" (
"table_name" VARCHAR(255) PRIMARY KEY,
"initial_id" VARCHAR(255),
- "_createdAt" DOUBLE PRECISION,
- "_modifiedAt" DOUBLE PRECISION
+ "sysCreatedAt" DOUBLE PRECISION,
+ "sysCreatedBy" VARCHAR(255),
+ "sysModifiedAt" DOUBLE PRECISION,
+ "sysModifiedBy" VARCHAR(255)
)
""")
logger.info("System table created successfully")
@@ -464,10 +469,16 @@ class DatabaseConnector:
)
existing_columns = [row["column_name"] for row in cursor.fetchall()]
- if "_modifiedAt" not in existing_columns:
- cursor.execute(
- f'ALTER TABLE "{self._systemTableName}" ADD COLUMN "_modifiedAt" DOUBLE PRECISION'
- )
+ for sys_col, sys_sql in [
+ ("sysCreatedAt", "DOUBLE PRECISION"),
+ ("sysCreatedBy", "VARCHAR(255)"),
+ ("sysModifiedAt", "DOUBLE PRECISION"),
+ ("sysModifiedBy", "VARCHAR(255)"),
+ ]:
+ if sys_col not in existing_columns:
+ cursor.execute(
+ f'ALTER TABLE "{self._systemTableName}" ADD COLUMN "{sys_col}" {sys_sql}'
+ )
return True
except Exception as e:
@@ -518,11 +529,7 @@ class DatabaseConnector:
# Desired columns based on model
model_fields = _get_model_fields(model_class)
- desired_columns = (
- set(["id"])
- | set(model_fields.keys())
- | {"_createdAt", "_modifiedAt", "_createdBy", "_modifiedBy"}
- )
+ desired_columns = set(["id"]) | set(model_fields.keys())
# Add missing columns
for col in sorted(desired_columns - existing_columns):
@@ -530,12 +537,6 @@ class DatabaseConnector:
if col in ["id"]:
continue # primary key exists already
sql_type = model_fields.get(col)
- if col in ["_createdAt"]:
- sql_type = "DOUBLE PRECISION"
- elif col in ["_modifiedAt"]:
- sql_type = "DOUBLE PRECISION"
- elif col in ["_createdBy", "_modifiedBy"]:
- sql_type = "VARCHAR(255)"
if not sql_type:
sql_type = "TEXT"
try:
@@ -594,16 +595,6 @@ class DatabaseConnector:
if field_name != "id": # Skip id, already defined
columns.append(f'"{field_name}" {sql_type}')
- # Add metadata columns
- columns.extend(
- [
- '"_createdAt" DOUBLE PRECISION',
- '"_modifiedAt" DOUBLE PRECISION',
- '"_createdBy" VARCHAR(255)',
- '"_modifiedBy" VARCHAR(255)',
- ]
- )
-
# Create table
sql = f'CREATE TABLE IF NOT EXISTS "{table}" ({", ".join(columns)})'
cursor.execute(sql)
@@ -626,11 +617,7 @@ class DatabaseConnector:
"""Save record to normalized table with explicit columns."""
# Get columns from Pydantic model instead of database schema
fields = _get_model_fields(model_class)
- columns = (
- ["id"]
- + [field for field in fields.keys() if field != "id"]
- + ["_createdAt", "_createdBy", "_modifiedAt", "_modifiedBy"]
- )
+ columns = ["id"] + [field for field in fields.keys() if field != "id"]
if not columns:
logger.error(f"No columns found for table {table}")
@@ -648,7 +635,7 @@ class DatabaseConnector:
value = filtered_record.get(col)
# Handle timestamp fields - store as Unix timestamps (floats) for consistency
- if col in ["_createdAt", "_modifiedAt"] and value is not None:
+ if col in ["sysCreatedAt", "sysModifiedAt"] and value is not None:
if isinstance(value, str):
# Try to parse string as timestamp
try:
@@ -690,7 +677,7 @@ class DatabaseConnector:
[
f'"{col}" = EXCLUDED."{col}"'
for col in columns[1:]
- if col not in ["_createdAt", "_createdBy"]
+ if col not in ["sysCreatedAt", "sysCreatedBy"]
]
)
@@ -742,17 +729,18 @@ class DatabaseConnector:
if effective_user_id is None:
effective_user_id = self.userId
currentTime = getUtcTimestamp()
- # Set _createdAt and _createdBy if this is a new record (record doesn't have _createdAt)
- if "_createdAt" not in record:
- record["_createdAt"] = currentTime
+ # Set sysCreatedAt/sysCreatedBy on first persist; always refresh modified fields.
+ # Use falsy check: model_dump() always includes sysCreatedAt key (often None).
+ if not record.get("sysCreatedAt"):
+ record["sysCreatedAt"] = currentTime
if effective_user_id:
- record["_createdBy"] = effective_user_id
- elif "_createdBy" not in record or not record.get("_createdBy"):
+ record["sysCreatedBy"] = effective_user_id
+ elif not record.get("sysCreatedBy"):
if effective_user_id:
- record["_createdBy"] = effective_user_id
- record["_modifiedAt"] = currentTime
+ record["sysCreatedBy"] = effective_user_id
+ record["sysModifiedAt"] = currentTime
if effective_user_id:
- record["_modifiedBy"] = effective_user_id
+ record["sysModifiedBy"] = effective_user_id
with self.connection.cursor() as cursor:
self._save_record(cursor, table, recordId, record, model_class)
@@ -840,6 +828,26 @@ class DatabaseConnector:
logger.error(f"Error removing initial ID for table {table}: {e}")
return False
+ def buildRbacWhereClause(
+ self,
+ permissions: UserPermissions,
+ currentUser: User,
+ table: str,
+ mandateId: Optional[str] = None,
+ featureInstanceId: Optional[str] = None,
+ ) -> Optional[Dict[str, Any]]:
+ """Delegate to interfaceRbac.buildRbacWhereClause (tests and call sites use connector as entry)."""
+ from modules.interfaces.interfaceRbac import buildRbacWhereClause as _buildRbacWhereClause
+
+ return _buildRbacWhereClause(
+ permissions,
+ currentUser,
+ table,
+ self,
+ mandateId=mandateId,
+ featureInstanceId=featureInstanceId,
+ )
+
def updateContext(self, userId: str) -> None:
"""Updates the context of the database connector.
Sets both instance userId and contextvar for request-scoped use when connector is shared.
@@ -992,10 +1000,6 @@ class DatabaseConnector:
Returns (where_clause, order_clause, limit_clause, values, count_values).
"""
fields = _get_model_fields(model_class)
- fields["_createdAt"] = "DOUBLE PRECISION"
- fields["_modifiedAt"] = "DOUBLE PRECISION"
- fields["_createdBy"] = "TEXT"
- fields["_modifiedBy"] = "TEXT"
validColumns = set(fields.keys())
where_parts: List[str] = []
values: List[Any] = []
@@ -1190,10 +1194,6 @@ class DatabaseConnector:
"""
table = model_class.__name__
fields = _get_model_fields(model_class)
- fields["_createdAt"] = "DOUBLE PRECISION"
- fields["_modifiedAt"] = "DOUBLE PRECISION"
- fields["_createdBy"] = "TEXT"
- fields["_modifiedBy"] = "TEXT"
if column not in fields:
return []
diff --git a/modules/datamodels/datamodelBase.py b/modules/datamodels/datamodelBase.py
new file mode 100644
index 00000000..862f177b
--- /dev/null
+++ b/modules/datamodels/datamodelBase.py
@@ -0,0 +1,68 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Base Pydantic model with system-managed fields (DB + API + UI metadata)."""
+
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+from modules.shared.attributeUtils import registerModelLabels
+
+
+class PowerOnModel(BaseModel):
+ sysCreatedAt: Optional[float] = Field(
+ default=None,
+ description="Record creation timestamp (UTC, set by system)",
+ json_schema_extra={
+ "frontend_type": "timestamp",
+ "frontend_readonly": True,
+ "frontend_required": False,
+ "frontend_visible": False,
+ "system": True,
+ },
+ )
+ sysCreatedBy: Optional[str] = Field(
+ default=None,
+ description="User ID who created this record (set by system)",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False,
+ "frontend_visible": False,
+ "system": True,
+ },
+ )
+ sysModifiedAt: Optional[float] = Field(
+ default=None,
+ description="Record last modification timestamp (UTC, set by system)",
+ json_schema_extra={
+ "frontend_type": "timestamp",
+ "frontend_readonly": True,
+ "frontend_required": False,
+ "frontend_visible": False,
+ "system": True,
+ },
+ )
+ sysModifiedBy: Optional[str] = Field(
+ default=None,
+ description="User ID who last modified this record (set by system)",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False,
+ "frontend_visible": False,
+ "system": True,
+ },
+ )
+
+
+registerModelLabels(
+ "PowerOnModel",
+ {"en": "Base Record", "de": "Basisdatensatz"},
+ {
+ "sysCreatedAt": {"en": "Created At", "de": "Erstellt am", "fr": "Cree le"},
+ "sysCreatedBy": {"en": "Created By", "de": "Erstellt von", "fr": "Cree par"},
+ "sysModifiedAt": {"en": "Modified At", "de": "Geaendert am", "fr": "Modifie le"},
+ "sysModifiedBy": {"en": "Modified By", "de": "Geaendert von", "fr": "Modifie par"},
+ },
+)
diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py
index 995ac75d..a61faa59 100644
--- a/modules/datamodels/datamodelBilling.py
+++ b/modules/datamodels/datamodelBilling.py
@@ -6,6 +6,7 @@ from typing import List, Dict, Any, Optional
from enum import Enum
from datetime import date, datetime, timezone
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
import uuid
@@ -48,7 +49,7 @@ class PeriodTypeEnum(str, Enum):
YEAR = "YEAR"
-class BillingAccount(BaseModel):
+class BillingAccount(PowerOnModel):
"""Billing account for mandate or user-mandate combination."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
@@ -78,7 +79,7 @@ registerModelLabels(
)
-class BillingTransaction(BaseModel):
+class BillingTransaction(PowerOnModel):
"""Single billing transaction (credit, debit, adjustment)."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py
index 7002187a..7154e57e 100644
--- a/modules/datamodels/datamodelChat.py
+++ b/modules/datamodels/datamodelChat.py
@@ -5,12 +5,13 @@
from typing import List, Dict, Any, Optional
from enum import Enum
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid
-class ChatLog(BaseModel):
+class ChatLog(PowerOnModel):
"""Log entries for chat workflows. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
@@ -56,7 +57,7 @@ registerModelLabels(
)
-class ChatDocument(BaseModel):
+class ChatDocument(PowerOnModel):
"""Documents attached to chat messages. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
@@ -163,7 +164,7 @@ registerModelLabels(
)
-class ChatMessage(BaseModel):
+class ChatMessage(PowerOnModel):
"""Messages in chat workflows. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
@@ -260,7 +261,7 @@ registerModelLabels(
)
-class ChatWorkflow(BaseModel):
+class ChatWorkflow(PowerOnModel):
"""Chat workflow container. User-owned, no mandate context."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
diff --git a/modules/datamodels/datamodelDataSource.py b/modules/datamodels/datamodelDataSource.py
index 47578b03..51a324ca 100644
--- a/modules/datamodels/datamodelDataSource.py
+++ b/modules/datamodels/datamodelDataSource.py
@@ -8,12 +8,12 @@ Google Drive folder, FTP directory, etc.) for agent-accessible data containers.
from typing import Dict, Any, Optional
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
-from modules.shared.timeUtils import getUtcTimestamp
import uuid
-class DataSource(BaseModel):
+class DataSource(PowerOnModel):
"""Configured external data source linked to a UserConnection."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
connectionId: str = Field(description="FK to UserConnection")
@@ -29,7 +29,6 @@ class DataSource(BaseModel):
userId: str = Field(default="", description="Owner user ID")
autoSync: bool = Field(default=False, description="Automatically sync on schedule")
lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp")
- createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
@@ -62,7 +61,6 @@ registerModelLabels(
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"},
"lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"},
- "createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
},
diff --git a/modules/datamodels/datamodelFeatureDataSource.py b/modules/datamodels/datamodelFeatureDataSource.py
index 5aa834eb..80ceb03c 100644
--- a/modules/datamodels/datamodelFeatureDataSource.py
+++ b/modules/datamodels/datamodelFeatureDataSource.py
@@ -8,12 +8,12 @@ so the agent can query structured feature data (e.g. TrusteePosition rows).
from typing import Optional
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
-from modules.shared.timeUtils import getUtcTimestamp
import uuid
-class FeatureDataSource(BaseModel):
+class FeatureDataSource(PowerOnModel):
"""A feature-instance table attached as data source in the AI workspace."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
featureInstanceId: str = Field(description="FK to FeatureInstance")
@@ -24,7 +24,6 @@ class FeatureDataSource(BaseModel):
mandateId: str = Field(default="", description="Mandate scope")
userId: str = Field(default="", description="Owner user ID")
workspaceInstanceId: str = Field(description="Workspace instance where this source is used")
- createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
@@ -55,6 +54,5 @@ registerModelLabels(
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"workspaceInstanceId": {"en": "Workspace", "de": "Workspace", "fr": "Espace de travail"},
- "createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
},
)
diff --git a/modules/datamodels/datamodelFeatures.py b/modules/datamodels/datamodelFeatures.py
index 0a5dc441..3134a18e 100644
--- a/modules/datamodels/datamodelFeatures.py
+++ b/modules/datamodels/datamodelFeatures.py
@@ -5,11 +5,12 @@
import uuid
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual
-class Feature(BaseModel):
+class Feature(PowerOnModel):
"""
Feature-Definition (global, z.B. 'trustee', 'chatbot').
Features sind die verfügbaren Funktionalitäten der Plattform.
@@ -40,7 +41,7 @@ registerModelLabels(
)
-class FeatureInstance(BaseModel):
+class FeatureInstance(PowerOnModel):
"""
Instanz eines Features in einem Mandanten.
Ein Mandant kann mehrere Instanzen desselben Features haben.
diff --git a/modules/datamodels/datamodelFileFolder.py b/modules/datamodels/datamodelFileFolder.py
index b7a19915..23cd197b 100644
--- a/modules/datamodels/datamodelFileFolder.py
+++ b/modules/datamodels/datamodelFileFolder.py
@@ -4,18 +4,17 @@
from typing import Optional
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
-from modules.shared.timeUtils import getUtcTimestamp
import uuid
-class FileFolder(BaseModel):
+class FileFolder(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
name: str = Field(description="Folder name", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
parentId: Optional[str] = Field(default=None, description="Parent folder ID (null = root)", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
mandateId: Optional[str] = Field(default=None, description="Mandate context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(default=None, description="Feature instance context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
- createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
registerModelLabels(
@@ -27,6 +26,5 @@ registerModelLabels(
"parentId": {"en": "Parent Folder", "fr": "Dossier parent"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
- "createdAt": {"en": "Created At", "fr": "Créé le"},
},
)
diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py
index f95a0ef1..b8a44d2c 100644
--- a/modules/datamodels/datamodelFiles.py
+++ b/modules/datamodels/datamodelFiles.py
@@ -3,15 +3,14 @@
"""File-related datamodels: FileItem, FilePreview, FileData."""
from typing import Dict, Any, List, Optional, Union
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
-from modules.shared.timeUtils import getUtcTimestamp
import uuid
import base64
-class FileItem(BaseModel):
- model_config = ConfigDict(extra='allow') # Preserve system fields (_createdBy, _createdAt, etc.)
+class FileItem(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"})
@@ -19,7 +18,6 @@ class FileItem(BaseModel):
mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileSize: int = Field(description="Size of the file in bytes", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False})
- creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the file was created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
tags: Optional[List[str]] = Field(default=None, description="Tags for categorization and search", json_schema_extra={"frontend_type": "tags", "frontend_readonly": False, "frontend_required": False})
folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
@@ -51,7 +49,6 @@ registerModelLabels(
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"fileHash": {"en": "File Hash", "fr": "Hash du fichier"},
"fileSize": {"en": "File Size", "fr": "Taille du fichier"},
- "creationDate": {"en": "Creation Date", "fr": "Date de création"},
"tags": {"en": "Tags", "fr": "Tags"},
"folderId": {"en": "Folder ID", "fr": "ID du dossier"},
"description": {"en": "Description", "fr": "Description"},
@@ -88,7 +85,7 @@ registerModelLabels(
},
)
-class FileData(BaseModel):
+class FileData(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
data: str = Field(description="File data content")
base64Encoded: bool = Field(description="Whether the data is base64 encoded")
diff --git a/modules/datamodels/datamodelInvitation.py b/modules/datamodels/datamodelInvitation.py
index 472318af..709e5021 100644
--- a/modules/datamodels/datamodelInvitation.py
+++ b/modules/datamodels/datamodelInvitation.py
@@ -9,11 +9,11 @@ import uuid
import secrets
from typing import Optional, List
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
-from modules.shared.timeUtils import getUtcTimestamp
-class Invitation(BaseModel):
+class Invitation(PowerOnModel):
"""
Einladungs-Token für neue User.
Ermöglicht Self-Service Onboarding zu Mandanten und Feature-Instanzen.
@@ -56,15 +56,6 @@ class Invitation(BaseModel):
description="Email address to send invitation link (optional)",
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
)
- createdBy: str = Field(
- description="User ID of the person who created the invitation",
- json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
- )
- createdAt: float = Field(
- default_factory=getUtcTimestamp,
- description="When the invitation was created (UTC timestamp)",
- json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
- )
expiresAt: float = Field(
description="When the invitation expires (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
@@ -121,8 +112,6 @@ registerModelLabels(
"roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"},
"targetUsername": {"en": "Target Username", "de": "Ziel-Benutzername", "fr": "Nom d'utilisateur cible"},
"email": {"en": "Email (optional)", "de": "E-Mail (optional)", "fr": "Email (optionnel)"},
- "createdBy": {"en": "Created By", "de": "Erstellt von", "fr": "Créé par"},
- "createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
"usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"},
"usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"},
diff --git a/modules/datamodels/datamodelKnowledge.py b/modules/datamodels/datamodelKnowledge.py
index e9dcc857..3742a84b 100644
--- a/modules/datamodels/datamodelKnowledge.py
+++ b/modules/datamodels/datamodelKnowledge.py
@@ -12,12 +12,13 @@ Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector.
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid
-class FileContentIndex(BaseModel):
+class FileContentIndex(PowerOnModel):
"""Structural index of a file's content objects. Created without AI.
Lives in the Instance Layer; optionally promoted to Shared Layer via isShared."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key (typically = fileId)")
@@ -73,7 +74,7 @@ registerModelLabels(
)
-class ContentChunk(BaseModel):
+class ContentChunk(PowerOnModel):
"""Persisted content chunk with embedding vector. Reusable across workflows.
Scalar content object (or chunk thereof) with pgvector embedding."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
@@ -111,7 +112,7 @@ registerModelLabels(
)
-class RoundMemory(BaseModel):
+class RoundMemory(PowerOnModel):
"""Persistent per-round memory for agent tool results, file refs, and decisions.
Stored after each agent round so that RAG can retrieve relevant context
@@ -135,7 +136,6 @@ class RoundMemory(BaseModel):
description="Embedding of summary for semantic retrieval",
json_schema_extra={"db_type": "vector(1536)"},
)
- createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
registerModelLabels(
@@ -151,12 +151,11 @@ registerModelLabels(
"fullData": {"en": "Full Data", "fr": "Données complètes"},
"fileIds": {"en": "File IDs", "fr": "IDs de fichier"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
- "createdAt": {"en": "Created At", "fr": "Créé le"},
},
)
-class WorkflowMemory(BaseModel):
+class WorkflowMemory(PowerOnModel):
"""Workflow-scoped key-value cache for entities and facts.
Extracted during agent rounds, persisted for cross-round and cross-workflow reuse."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
@@ -166,7 +165,6 @@ class WorkflowMemory(BaseModel):
key: str = Field(description="Key identifier (e.g. 'entity:companyName')")
value: str = Field(description="Extracted value")
source: str = Field(default="extraction", description="Origin: extraction, tool, conversation, summary")
- createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
embedding: Optional[List[float]] = Field(
default=None, description="Optional embedding for semantic lookup",
json_schema_extra={"db_type": "vector(1536)"}
@@ -184,7 +182,6 @@ registerModelLabels(
"key": {"en": "Key", "fr": "Clé"},
"value": {"en": "Value", "fr": "Valeur"},
"source": {"en": "Source", "fr": "Source"},
- "createdAt": {"en": "Created At", "fr": "Créé le"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
},
)
diff --git a/modules/datamodels/datamodelMembership.py b/modules/datamodels/datamodelMembership.py
index 5e8b8814..ce753d15 100644
--- a/modules/datamodels/datamodelMembership.py
+++ b/modules/datamodels/datamodelMembership.py
@@ -9,10 +9,11 @@ Rollen werden über Junction Tables verknüpft für saubere CASCADE DELETE.
import uuid
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
-class UserMandate(BaseModel):
+class UserMandate(PowerOnModel):
"""
User-Mitgliedschaft in einem Mandanten.
Kein User gehört direkt zu einem Mandanten - Zugehörigkeit wird über dieses Model gesteuert.
@@ -50,7 +51,7 @@ registerModelLabels(
)
-class FeatureAccess(BaseModel):
+class FeatureAccess(PowerOnModel):
"""
User-Zugriff auf eine Feature-Instanz.
Definiert welche User auf welche Feature-Instanzen zugreifen können.
@@ -88,7 +89,7 @@ registerModelLabels(
)
-class UserMandateRole(BaseModel):
+class UserMandateRole(PowerOnModel):
"""
Junction Table: UserMandate zu Role.
Ermöglicht CASCADE DELETE auf Datenbankebene.
@@ -119,7 +120,7 @@ registerModelLabels(
)
-class FeatureAccessRole(BaseModel):
+class FeatureAccessRole(PowerOnModel):
"""
Junction Table: FeatureAccess zu Role.
Ermöglicht CASCADE DELETE auf Datenbankebene.
diff --git a/modules/datamodels/datamodelMessaging.py b/modules/datamodels/datamodelMessaging.py
index 1c2206b7..ebacc9d4 100644
--- a/modules/datamodels/datamodelMessaging.py
+++ b/modules/datamodels/datamodelMessaging.py
@@ -6,8 +6,8 @@ import uuid
from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
-from modules.shared.timeUtils import getUtcTimestamp
class MessagingChannel(str, Enum):
@@ -26,7 +26,7 @@ class DeliveryStatus(str, Enum):
FAILED = "failed"
-class MessagingSubscription(BaseModel):
+class MessagingSubscription(PowerOnModel):
"""Data model for messaging subscriptions"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@@ -64,26 +64,6 @@ class MessagingSubscription(BaseModel):
description="Whether the subscription is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
- creationDate: float = Field(
- default_factory=getUtcTimestamp,
- description="When the subscription was created (UTC timestamp in seconds)",
- json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
- )
- lastModified: float = Field(
- default_factory=getUtcTimestamp,
- description="When the subscription was last modified (UTC timestamp in seconds)",
- json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
- )
- createdBy: Optional[str] = Field(
- default=None,
- description="User ID who created the subscription",
- json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
- )
- modifiedBy: Optional[str] = Field(
- default=None,
- description="User ID who last modified the subscription",
- json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
- )
model_config = ConfigDict(use_enum_values=True)
@@ -100,10 +80,6 @@ registerModelLabels(
"description": {"en": "Description", "fr": "Description"},
"isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"},
"enabled": {"en": "Enabled", "fr": "Activé"},
- "creationDate": {"en": "Creation Date", "fr": "Date de création"},
- "lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
- "createdBy": {"en": "Created By", "fr": "Créé par"},
- "modifiedBy": {"en": "Modified By", "fr": "Modifié par"},
},
)
@@ -155,16 +131,6 @@ class MessagingSubscriptionRegistration(BaseModel):
description="Whether this registration is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
- creationDate: float = Field(
- default_factory=getUtcTimestamp,
- description="When the registration was created (UTC timestamp in seconds)",
- json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
- )
- lastModified: float = Field(
- default_factory=getUtcTimestamp,
- description="When the registration was last modified (UTC timestamp in seconds)",
- json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
- )
model_config = ConfigDict(use_enum_values=True)
@@ -181,8 +147,6 @@ registerModelLabels(
"channel": {"en": "Channel", "fr": "Canal"},
"channelConfig": {"en": "Channel Config", "fr": "Configuration du canal"},
"enabled": {"en": "Enabled", "fr": "Activé"},
- "creationDate": {"en": "Creation Date", "fr": "Date de création"},
- "lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
},
)
@@ -248,11 +212,6 @@ class MessagingDelivery(BaseModel):
description="When the delivery was sent (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
)
- creationDate: float = Field(
- default_factory=getUtcTimestamp,
- description="When the delivery record was created (UTC timestamp in seconds)",
- json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
- )
model_config = ConfigDict(use_enum_values=True)
@@ -270,7 +229,6 @@ registerModelLabels(
"status": {"en": "Status", "fr": "Statut"},
"errorMessage": {"en": "Error Message", "fr": "Message d'erreur"},
"sentAt": {"en": "Sent At", "fr": "Envoyé le"},
- "creationDate": {"en": "Creation Date", "fr": "Date de création"},
},
)
@@ -349,4 +307,3 @@ class MessagingSubscriptionExecutionResult(BaseModel):
description="Error message if execution failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
)
- model_config = ConfigDict(extra="allow") # Allow additional fields for custom results
diff --git a/modules/datamodels/datamodelNotification.py b/modules/datamodels/datamodelNotification.py
index b1475767..f5af0f55 100644
--- a/modules/datamodels/datamodelNotification.py
+++ b/modules/datamodels/datamodelNotification.py
@@ -9,8 +9,8 @@ import uuid
from typing import Optional, List
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
-from modules.shared.timeUtils import getUtcTimestamp
class NotificationType(str, Enum):
@@ -43,7 +43,7 @@ class NotificationAction(BaseModel):
)
-class UserNotification(BaseModel):
+class UserNotification(PowerOnModel):
"""
In-app notification for a user.
Supports actionable notifications with accept/decline buttons.
@@ -137,11 +137,6 @@ class UserNotification(BaseModel):
)
# Timestamps
- createdAt: float = Field(
- default_factory=getUtcTimestamp,
- description="When the notification was created (UTC timestamp)",
- json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
- )
readAt: Optional[float] = Field(
default=None,
description="When the notification was read (UTC timestamp)",
@@ -177,7 +172,6 @@ registerModelLabels(
"actions": {"en": "Actions", "de": "Aktionen", "fr": "Actions"},
"actionTaken": {"en": "Action Taken", "de": "Durchgeführte Aktion", "fr": "Action effectuée"},
"actionResult": {"en": "Action Result", "de": "Aktions-Ergebnis", "fr": "Résultat de l'action"},
- "createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"readAt": {"en": "Read At", "de": "Gelesen am", "fr": "Lu le"},
"actionedAt": {"en": "Actioned At", "de": "Bearbeitet am", "fr": "Traité le"},
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
diff --git a/modules/datamodels/datamodelRbac.py b/modules/datamodels/datamodelRbac.py
index 978c3be6..b9e0cb91 100644
--- a/modules/datamodels/datamodelRbac.py
+++ b/modules/datamodels/datamodelRbac.py
@@ -13,6 +13,7 @@ import uuid
from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual
from modules.datamodels.datamodelUam import AccessLevel
@@ -25,7 +26,7 @@ class AccessRuleContext(str, Enum):
RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.)
-class Role(BaseModel):
+class Role(PowerOnModel):
"""
Data model for RBAC roles.
@@ -90,7 +91,7 @@ registerModelLabels(
)
-class AccessRule(BaseModel):
+class AccessRule(PowerOnModel):
"""
Data model for access control rules.
diff --git a/modules/datamodels/datamodelSecurity.py b/modules/datamodels/datamodelSecurity.py
index 5caafe1b..dc8c26e6 100644
--- a/modules/datamodels/datamodelSecurity.py
+++ b/modules/datamodels/datamodelSecurity.py
@@ -11,6 +11,7 @@ Multi-Tenant Design:
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict, model_validator
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
from .datamodelUam import AuthAuthority
@@ -30,7 +31,7 @@ class TokenPurpose(str, Enum):
DATA_CONNECTION = "dataConnection"
-class Token(BaseModel):
+class Token(PowerOnModel):
"""
Authentication Token model.
@@ -55,9 +56,6 @@ class Token(BaseModel):
description="When the token expires (UTC timestamp in seconds)"
)
tokenRefresh: Optional[str] = None
- createdAt: Optional[float] = Field(
- None, description="When the token was created (UTC timestamp in seconds)"
- )
status: TokenStatus = Field(
default=TokenStatus.ACTIVE, description="Token status: active/revoked"
)
@@ -106,7 +104,6 @@ registerModelLabels(
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"},
- "createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
"revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"},
@@ -116,7 +113,7 @@ registerModelLabels(
)
-class AuthEvent(BaseModel):
+class AuthEvent(PowerOnModel):
"""Authentication event for audit logging."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index 8f5fd824..fa9f2c87 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -10,6 +10,7 @@ from typing import Dict, List, Optional
from enum import Enum
from datetime import datetime, timezone
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
import uuid
@@ -124,7 +125,7 @@ registerModelLabels(
# Instance: MandateSubscription
# ============================================================================
-class MandateSubscription(BaseModel):
+class MandateSubscription(PowerOnModel):
"""A subscription instance bound to a specific mandate.
See wiki/concepts/Subscription-State-Machine.md for state transitions."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py
index 3e1250c7..5a057639 100644
--- a/modules/datamodels/datamodelUam.py
+++ b/modules/datamodels/datamodelUam.py
@@ -13,6 +13,7 @@ import uuid
from typing import Optional, List, Dict
from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
@@ -65,7 +66,7 @@ class MandateType(str, Enum):
COMPANY = "company"
-class Mandate(BaseModel):
+class Mandate(PowerOnModel):
"""
Mandate (Mandant/Tenant) model.
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
@@ -145,7 +146,7 @@ registerModelLabels(
)
-class UserConnection(BaseModel):
+class UserConnection(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"})
@@ -202,7 +203,7 @@ registerModelLabels(
)
-class User(BaseModel):
+class User(PowerOnModel):
"""
User model.
@@ -289,6 +290,11 @@ class User(BaseModel):
description="Primary authentication authority",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}
)
+ roleLabels: List[str] = Field(
+ default_factory=list,
+ description="Role labels (from DB or enriched when loading users)",
+ json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False},
+ )
registerModelLabels(
@@ -303,6 +309,7 @@ registerModelLabels(
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
"authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
+ "roleLabels": {"en": "Role Labels", "de": "Rollen-Labels", "fr": "Libellés de rôles"},
},
)
@@ -325,7 +332,7 @@ registerModelLabels(
)
-class UserVoicePreferences(BaseModel):
+class UserVoicePreferences(PowerOnModel):
"""User-level voice/language preferences, shared across all features."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
userId: str = Field(description="User ID")
diff --git a/modules/datamodels/datamodelUtils.py b/modules/datamodels/datamodelUtils.py
index 614d6592..1088cb31 100644
--- a/modules/datamodels/datamodelUtils.py
+++ b/modules/datamodels/datamodelUtils.py
@@ -3,13 +3,13 @@
"""Utility datamodels: Prompt, TextMultilingual."""
from typing import Dict, Optional
-from pydantic import BaseModel, ConfigDict, Field, field_validator
+from pydantic import BaseModel, Field, field_validator
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
import uuid
-class Prompt(BaseModel):
- model_config = ConfigDict(extra='allow') # Preserve system fields (_createdBy, _createdAt, etc.)
+class Prompt(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(default="", description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
isSystem: bool = Field(default=False, description="System prompt visible to all users (read-only for non-SysAdmin)", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False})
diff --git a/modules/features/automation/datamodelFeatureAutomation.py b/modules/features/automation/datamodelFeatureAutomation.py
index 732f3163..8ea4a300 100644
--- a/modules/features/automation/datamodelFeatureAutomation.py
+++ b/modules/features/automation/datamodelFeatureAutomation.py
@@ -4,6 +4,7 @@
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual
import uuid
@@ -48,7 +49,7 @@ registerModelLabels(
)
-class AutomationTemplate(BaseModel):
+class AutomationTemplate(PowerOnModel):
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).
System-Templates (isSystem=True): Nur durch SysAdmin aenderbar. Alle User koennen lesen.
@@ -82,9 +83,6 @@ class AutomationTemplate(BaseModel):
description="Feature instance ID (null for system templates, set for instance-scoped templates)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
- # System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt
-
-
registerModelLabels(
"AutomationTemplate",
{"en": "Automation Template", "ge": "Automation-Vorlage", "fr": "Modèle d'automatisation"},
diff --git a/modules/features/automation/interfaceFeatureAutomation.py b/modules/features/automation/interfaceFeatureAutomation.py
index 4091bc28..a4f90a51 100644
--- a/modules/features/automation/interfaceFeatureAutomation.py
+++ b/modules/features/automation/interfaceFeatureAutomation.py
@@ -22,6 +22,13 @@ from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
+
+def _automationDefinitionPayload(data: Dict[str, Any]) -> Dict[str, Any]:
+ """Strip connector/enrichment keys; only fields defined on AutomationDefinition."""
+ allowed = AutomationDefinition.model_fields.keys()
+ return {k: v for k, v in (data or {}).items() if k in allowed}
+
+
# Singleton factory for Automation instances
_automationInterfaces = {}
@@ -100,7 +107,7 @@ class AutomationObjects:
if recordId:
record = self.db.getRecordset(model, recordFilter={"id": recordId})
if record:
- return record[0].get("_createdBy") == self.userId
+ return record[0].get("sysCreatedBy") == self.userId
else:
return False # Record not found = no access
return True # No recordId needed (e.g., for CREATE)
@@ -130,7 +137,7 @@ class AutomationObjects:
featureInstanceIds = set()
for automation in automations:
- createdBy = automation.get("_createdBy")
+ createdBy = automation.get("sysCreatedBy")
if createdBy:
userIds.add(createdBy)
@@ -186,8 +193,8 @@ class AutomationObjects:
# Enrich each automation with the fetched data
# SECURITY: Never show a fallback name — if lookup fails, show empty string
for automation in automations:
- createdBy = automation.get("_createdBy")
- automation["_createdByUserName"] = usersMap.get(createdBy, "") if createdBy else ""
+ createdBy = automation.get("sysCreatedBy")
+ automation["sysCreatedByUserName"] = usersMap.get(createdBy, "") if createdBy else ""
mandateId = automation.get("mandateId")
automation["mandateName"] = mandatesMap.get(mandateId, "") if mandateId else ""
@@ -295,7 +302,7 @@ class AutomationObjects:
Args:
automationId: ID of the automation to get
- includeSystemFields: If True, returns raw dict with system fields (_createdBy, etc).
+ includeSystemFields: If True, returns raw dict with system fields (sysCreatedBy, etc).
If False (default), returns Pydantic model without system fields.
"""
try:
@@ -330,7 +337,7 @@ class AutomationObjects:
return AutomationWithSystemFields(automation)
# Clean metadata fields and return Pydantic model
- cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
+ cleanedRecord = _automationDefinitionPayload(automation)
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting automation definition: {str(e)}")
@@ -365,7 +372,7 @@ class AutomationObjects:
# Ensure database connector has correct userId context
if not self.userId:
- logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}")
+ logger.error(f"createAutomationDefinition: userId is not set! Cannot set sysCreatedBy. currentUser={self.currentUser}")
elif hasattr(self.db, 'updateContext'):
try:
self.db.updateContext(self.userId)
@@ -386,7 +393,7 @@ class AutomationObjects:
self._notifyAutomationChanged()
# Clean metadata fields and return Pydantic model
- cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
+ cleanedRecord = _automationDefinitionPayload(createdAutomation)
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating automation definition: {str(e)}")
@@ -446,7 +453,7 @@ class AutomationObjects:
self._notifyAutomationChanged()
# Clean metadata fields and return Pydantic model
- cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
+ cleanedRecord = _automationDefinitionPayload(updatedAutomation)
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error updating automation definition: {str(e)}")
@@ -561,7 +568,7 @@ class AutomationObjects:
# Collect unique user IDs
userIds = set()
for template in templates:
- createdBy = template.get("_createdBy")
+ createdBy = template.get("sysCreatedBy")
if createdBy:
userIds.add(createdBy)
@@ -585,8 +592,8 @@ class AutomationObjects:
# Apply to templates — SECURITY: no fallback, empty if not found
for template in templates:
- createdBy = template.get("_createdBy")
- template["_createdByUserName"] = userNameMap.get(createdBy, "") if createdBy else ""
+ createdBy = template.get("sysCreatedBy")
+ template["sysCreatedByUserName"] = userNameMap.get(createdBy, "") if createdBy else ""
except Exception as e:
logger.warning(f"Could not enrich templates with user names: {e}")
diff --git a/modules/features/automation/routeFeatureAutomation.py b/modules/features/automation/routeFeatureAutomation.py
index 48f53eea..c6343b25 100644
--- a/modules/features/automation/routeFeatureAutomation.py
+++ b/modules/features/automation/routeFeatureAutomation.py
@@ -77,8 +77,8 @@ def get_automations(
# If pagination was requested, result is PaginatedResult
# If no pagination, result is List[Dict]
- # Note: Using JSONResponse to bypass Pydantic validation which would filter out _createdBy
- # The enriched fields (_createdByUserName, mandateName) are not in the Pydantic model
+ # Note: Using JSONResponse to bypass Pydantic validation which would filter out sysCreatedBy
+ # The enriched fields (sysCreatedByUserName, mandateName) are not in the Pydantic model
from fastapi.responses import JSONResponse
if paginationParams:
diff --git a/modules/features/automation2/datamodelFeatureAutomation2.py b/modules/features/automation2/datamodelFeatureAutomation2.py
index f505c7d0..99d3b292 100644
--- a/modules/features/automation2/datamodelFeatureAutomation2.py
+++ b/modules/features/automation2/datamodelFeatureAutomation2.py
@@ -4,6 +4,7 @@
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
import uuid
@@ -52,7 +53,7 @@ registerModelLabels(
)
-class Automation2WorkflowRun(BaseModel):
+class Automation2WorkflowRun(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
@@ -98,7 +99,7 @@ registerModelLabels(
)
-class Automation2HumanTask(BaseModel):
+class Automation2HumanTask(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
diff --git a/modules/features/automation2/routeFeatureAutomation2.py b/modules/features/automation2/routeFeatureAutomation2.py
index 996c3cb6..5b087f83 100644
--- a/modules/features/automation2/routeFeatureAutomation2.py
+++ b/modules/features/automation2/routeFeatureAutomation2.py
@@ -359,7 +359,7 @@ def get_workflows(
active_run = None
last_started_at = None
for r in runs:
- ts = r.get("_createdAt")
+ ts = r.get("sysCreatedAt")
if ts and (last_started_at is None or ts > last_started_at):
last_started_at = ts
if r.get("status") in ("running", "paused"):
@@ -375,7 +375,7 @@ def get_workflows(
"runStatus": active_run.get("status") if active_run else None,
"stuckAtNodeId": stuck_at_node_id,
"stuckAtNodeLabel": stuck_at_node_label or stuck_at_node_id or "",
- "createdAt": wf.get("_createdAt"),
+ "createdAt": wf.get("sysCreatedAt"),
"lastStartedAt": last_started_at,
})
return {"workflows": enriched}
@@ -536,7 +536,7 @@ def get_tasks(
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get tasks - by default those assigned to current user, or all if no assignee filter.
- Enriches each task with workflowLabel and createdAt (_createdAt).
+ Enriches each task with workflowLabel and createdAt (from sysCreatedAt).
"""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
@@ -549,7 +549,7 @@ def get_tasks(
enriched.append({
**t,
"workflowLabel": wf.get("label", t.get("workflowId", "")) if wf else t.get("workflowId", ""),
- "createdAt": t.get("_createdAt"),
+ "createdAt": t.get("sysCreatedAt"),
})
return {"tasks": enriched}
diff --git a/modules/features/chatbot/interfaceFeatureChatbot.py b/modules/features/chatbot/interfaceFeatureChatbot.py
index 4a03bec9..151a96ce 100644
--- a/modules/features/chatbot/interfaceFeatureChatbot.py
+++ b/modules/features/chatbot/interfaceFeatureChatbot.py
@@ -20,6 +20,7 @@ from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelChat import UserInputRequest
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
# =============================================================================
@@ -27,7 +28,7 @@ from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
# =============================================================================
-class ChatbotDocument(BaseModel):
+class ChatbotDocument(PowerOnModel):
"""Documents attached to chatbot messages."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
messageId: str = Field(description="Foreign key to message")
@@ -41,7 +42,7 @@ class ChatbotDocument(BaseModel):
actionId: Optional[str] = Field(None, description="ID of the action that created this document")
-class ChatbotMessage(BaseModel):
+class ChatbotMessage(PowerOnModel):
"""Messages in chatbot conversations. Must match bridge format in memory.py."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
conversationId: str = Field(description="Foreign key to conversation")
@@ -64,7 +65,7 @@ class ChatbotMessage(BaseModel):
actionProgress: Optional[str] = Field(None, description="Action progress status")
-class ChatbotLog(BaseModel):
+class ChatbotLog(PowerOnModel):
"""Log entries for chatbot conversations."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
conversationId: str = Field(description="Foreign key to conversation")
@@ -85,7 +86,7 @@ class ChatbotWorkflowModeEnum(str, Enum):
WORKFLOW_CHATBOT = "Chatbot"
-class ChatbotConversation(BaseModel):
+class ChatbotConversation(PowerOnModel):
"""Chatbot conversation container. Per feature-instance isolation via featureInstanceId."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
featureInstanceId: str = Field(description="Feature instance ID for per-instance isolation")
@@ -328,9 +329,8 @@ class ChatObjects:
objectFields[fieldName] = value
else:
# Field not in model - treat as scalar if simple, otherwise filter out
- # BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector
+ # Underscore-prefixed keys (e.g. UI meta) pass through; sys* live on PowerOnModel subclasses
if fieldName.startswith("_"):
- # Metadata fields should be passed through to connector
simpleFields[fieldName] = value
elif isinstance(value, (str, int, float, bool, type(None))):
simpleFields[fieldName] = value
diff --git a/modules/features/commcoach/datamodelCommcoach.py b/modules/features/commcoach/datamodelCommcoach.py
index bd94f173..635ba19a 100644
--- a/modules/features/commcoach/datamodelCommcoach.py
+++ b/modules/features/commcoach/datamodelCommcoach.py
@@ -7,6 +7,8 @@ Pydantic models for coaching contexts, sessions, messages, tasks, scores, and us
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
from enum import Enum
+
+from modules.datamodels.datamodelBase import PowerOnModel
import uuid
@@ -73,7 +75,7 @@ class CoachingScoreTrend(str, Enum):
# Database Models
# ============================================================================
-class CoachingContext(BaseModel):
+class CoachingContext(PowerOnModel):
"""A coaching context/dossier representing a topic the user is working on."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str = Field(description="Owner user ID (strict ownership)")
@@ -91,11 +93,9 @@ class CoachingContext(BaseModel):
lastSessionAt: Optional[str] = Field(default=None)
rollingOverview: Optional[str] = Field(default=None, description="AI summary of older sessions for long context history")
rollingOverviewUpToSessionCount: Optional[int] = Field(default=None, description="Session count covered by rollingOverview")
- createdAt: Optional[str] = Field(default=None)
- updatedAt: Optional[str] = Field(default=None)
-class CoachingSession(BaseModel):
+class CoachingSession(PowerOnModel):
"""A single coaching conversation session within a context."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext")
@@ -115,11 +115,9 @@ class CoachingSession(BaseModel):
emailSent: bool = Field(default=False)
startedAt: Optional[str] = Field(default=None)
endedAt: Optional[str] = Field(default=None)
- createdAt: Optional[str] = Field(default=None)
- updatedAt: Optional[str] = Field(default=None)
-class CoachingMessage(BaseModel):
+class CoachingMessage(PowerOnModel):
"""A single message in a coaching session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
sessionId: str = Field(description="FK to CoachingSession")
@@ -130,10 +128,9 @@ class CoachingMessage(BaseModel):
contentType: CoachingMessageContentType = Field(default=CoachingMessageContentType.TEXT)
audioRef: Optional[str] = Field(default=None, description="Reference to audio file")
metadata: Optional[str] = Field(default=None, description="JSON: token count, voice info, etc.")
- createdAt: Optional[str] = Field(default=None)
-class CoachingTask(BaseModel):
+class CoachingTask(PowerOnModel):
"""A task/checklist item assigned within a coaching context."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext")
@@ -146,11 +143,9 @@ class CoachingTask(BaseModel):
priority: CoachingTaskPriority = Field(default=CoachingTaskPriority.MEDIUM)
dueDate: Optional[str] = Field(default=None)
completedAt: Optional[str] = Field(default=None)
- createdAt: Optional[str] = Field(default=None)
- updatedAt: Optional[str] = Field(default=None)
-class CoachingScore(BaseModel):
+class CoachingScore(PowerOnModel):
"""A competence score for a dimension, recorded after a session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext")
@@ -161,10 +156,9 @@ class CoachingScore(BaseModel):
score: float = Field(ge=0.0, le=100.0)
trend: CoachingScoreTrend = Field(default=CoachingScoreTrend.STABLE)
evidence: Optional[str] = Field(default=None, description="AI reasoning for the score")
- createdAt: Optional[str] = Field(default=None)
-class CoachingUserProfile(BaseModel):
+class CoachingUserProfile(PowerOnModel):
"""Per-user coaching profile and preferences."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str = Field(description="Owner user ID")
@@ -178,15 +172,13 @@ class CoachingUserProfile(BaseModel):
totalSessions: int = Field(default=0)
totalMinutes: int = Field(default=0)
lastSessionAt: Optional[str] = Field(default=None)
- createdAt: Optional[str] = Field(default=None)
- updatedAt: Optional[str] = Field(default=None)
# ============================================================================
# Iteration 2: Personas
# ============================================================================
-class CoachingPersona(BaseModel):
+class CoachingPersona(PowerOnModel):
"""A roleplay persona for coaching sessions."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str = Field(description="Owner user ID ('system' for builtins)")
@@ -199,15 +191,13 @@ class CoachingPersona(BaseModel):
gender: Optional[str] = Field(default=None, description="m or f")
category: str = Field(default="builtin", description="'builtin' or 'custom'")
isActive: bool = Field(default=True)
- createdAt: Optional[str] = Field(default=None)
- updatedAt: Optional[str] = Field(default=None)
# ============================================================================
# Iteration 2: Badges / Gamification
# ============================================================================
-class CoachingBadge(BaseModel):
+class CoachingBadge(PowerOnModel):
"""An achievement badge awarded to a user."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str = Field(description="Owner user ID")
@@ -215,7 +205,6 @@ class CoachingBadge(BaseModel):
instanceId: str = Field(description="Feature instance ID")
badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'")
awardedAt: Optional[str] = Field(default=None)
- createdAt: Optional[str] = Field(default=None)
# ============================================================================
diff --git a/modules/features/neutralization/datamodelFeatureNeutralizer.py b/modules/features/neutralization/datamodelFeatureNeutralizer.py
index 3aea7632..a8ed5981 100644
--- a/modules/features/neutralization/datamodelFeatureNeutralizer.py
+++ b/modules/features/neutralization/datamodelFeatureNeutralizer.py
@@ -6,6 +6,7 @@ import uuid
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
@@ -16,7 +17,7 @@ class DataScope(str, Enum):
GLOBAL = "global"
-class DataNeutraliserConfig(BaseModel):
+class DataNeutraliserConfig(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
diff --git a/modules/features/realEstate/datamodelFeatureRealEstate.py b/modules/features/realEstate/datamodelFeatureRealEstate.py
index 31efbc07..8f136056 100644
--- a/modules/features/realEstate/datamodelFeatureRealEstate.py
+++ b/modules/features/realEstate/datamodelFeatureRealEstate.py
@@ -7,6 +7,7 @@ Implements a general Swiss architecture planning data model.
from typing import List, Dict, Any, Optional, ForwardRef
from enum import Enum
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid
@@ -178,7 +179,7 @@ class Dokument(BaseModel):
)
-class Kontext(BaseModel):
+class Kontext(PowerOnModel):
"""Supporting data object for flexible additional information."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@@ -248,7 +249,7 @@ class Land(BaseModel):
)
-class Kanton(BaseModel):
+class Kanton(PowerOnModel):
"""Cantonal level administrative entity."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@@ -368,7 +369,7 @@ class Gemeinde(BaseModel):
ParzelleRef = ForwardRef('Parzelle')
-class Parzelle(BaseModel):
+class Parzelle(PowerOnModel):
"""Represents a plot with all building law properties."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@@ -594,7 +595,7 @@ class Parzelle(BaseModel):
)
-class Projekt(BaseModel):
+class Projekt(PowerOnModel):
"""Core object representing a construction project."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
diff --git a/modules/features/teamsbot/datamodelTeamsbot.py b/modules/features/teamsbot/datamodelTeamsbot.py
index bc17642f..f19b4c6c 100644
--- a/modules/features/teamsbot/datamodelTeamsbot.py
+++ b/modules/features/teamsbot/datamodelTeamsbot.py
@@ -9,6 +9,8 @@ from pydantic import BaseModel, Field
from enum import Enum
import uuid
+from modules.datamodels.datamodelBase import PowerOnModel
+
# ============================================================================
# Enums
@@ -72,7 +74,7 @@ class TeamsbotTransferMode(str, Enum):
# Database Models (stored in PostgreSQL)
# ============================================================================
-class TeamsbotSession(BaseModel):
+class TeamsbotSession(PowerOnModel):
"""A Teams Bot meeting session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
instanceId: str = Field(description="Feature instance ID (FK)")
@@ -90,11 +92,9 @@ class TeamsbotSession(BaseModel):
errorMessage: Optional[str] = Field(default=None, description="Error message if status is ERROR")
transcriptSegmentCount: int = Field(default=0, description="Number of transcript segments in this session")
botResponseCount: int = Field(default=0, description="Number of bot responses in this session")
- creationDate: Optional[str] = Field(default=None, description="ISO timestamp of record creation")
- lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
-class TeamsbotTranscript(BaseModel):
+class TeamsbotTranscript(PowerOnModel):
"""A single transcript segment from the meeting."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Transcript segment ID")
sessionId: str = Field(description="Session ID (FK)")
@@ -105,10 +105,9 @@ class TeamsbotTranscript(BaseModel):
language: Optional[str] = Field(default=None, description="Detected language code (e.g., de-DE)")
isFinal: bool = Field(default=True, description="Whether this is a final or interim result")
source: Optional[str] = Field(default=None, description="Source: caption, audioCapture, chat, chatHistory, speakerHint")
- creationDate: Optional[str] = Field(default=None, description="ISO timestamp of record creation")
-class TeamsbotBotResponse(BaseModel):
+class TeamsbotBotResponse(PowerOnModel):
"""A bot response generated during a meeting session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Response ID")
sessionId: str = Field(description="Session ID (FK)")
@@ -121,14 +120,13 @@ class TeamsbotBotResponse(BaseModel):
processingTime: float = Field(default=0.0, description="Processing time in seconds")
priceCHF: float = Field(default=0.0, description="Cost of this AI call in CHF")
timestamp: Optional[str] = Field(default=None, description="ISO timestamp of the response")
- creationDate: Optional[str] = Field(default=None, description="ISO timestamp of record creation")
# ============================================================================
# System Bot Accounts (stored in PostgreSQL, credentials encrypted)
# ============================================================================
-class TeamsbotSystemBot(BaseModel):
+class TeamsbotSystemBot(PowerOnModel):
"""A system bot account for authenticated meeting joins.
Credentials are stored encrypted in the database, NOT in the UI-visible config.
Only mandate admins can manage system bots."""
@@ -138,15 +136,13 @@ class TeamsbotSystemBot(BaseModel):
email: str = Field(description="Microsoft account email")
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
isActive: bool = Field(default=True, description="Whether this bot account is active")
- creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation")
- lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
# ============================================================================
# User Account Credentials (stored in PostgreSQL, credentials encrypted)
# ============================================================================
-class TeamsbotUserAccount(BaseModel):
+class TeamsbotUserAccount(PowerOnModel):
"""Saved Microsoft credentials for 'Mein Account' joins.
Each user can store their own MS credentials per mandate.
Password is encrypted; on login only MFA confirmation is needed."""
@@ -156,15 +152,13 @@ class TeamsbotUserAccount(BaseModel):
email: str = Field(description="Microsoft account email")
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
displayName: Optional[str] = Field(default=None, description="Display name derived from MS account")
- creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation")
- lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
# ============================================================================
# Per-User Settings (stored in PostgreSQL, per user per instance)
# ============================================================================
-class TeamsbotUserSettings(BaseModel):
+class TeamsbotUserSettings(PowerOnModel):
"""Per-user settings for the Teams Bot feature.
Each user has their own settings per feature instance.
These override the instance-level defaults (TeamsbotConfig)."""
@@ -182,8 +176,6 @@ class TeamsbotUserSettings(BaseModel):
triggerCooldownSeconds: Optional[int] = Field(default=None, description="Trigger cooldown override")
contextWindowSegments: Optional[int] = Field(default=None, description="Context window override")
debugMode: Optional[bool] = Field(default=None, description="Debug mode override")
- creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation")
- lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
# ============================================================================
diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py
index 538414a0..0889e361 100644
--- a/modules/features/trustee/datamodelFeatureTrustee.py
+++ b/modules/features/trustee/datamodelFeatureTrustee.py
@@ -5,11 +5,13 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
+
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
import uuid
-class TrusteeOrganisation(BaseModel):
+class TrusteeOrganisation(PowerOnModel):
"""Represents trustee organisations (companies) within the Trustee feature."""
id: str = Field( # Unique string label (PK), not UUID
description="Unique organisation identifier (label)",
@@ -55,7 +57,7 @@ class TrusteeOrganisation(BaseModel):
}
)
# System attributes are automatically set by DatabaseConnector:
- # _createdAt, _modifiedAt, _createdBy, _modifiedBy
+ # sysCreatedAt, sysModifiedAt, sysCreatedBy, sysModifiedBy (PowerOnModel)
registerModelLabels(
@@ -71,7 +73,7 @@ registerModelLabels(
)
-class TrusteeRole(BaseModel):
+class TrusteeRole(PowerOnModel):
"""Defines roles within the Trustee feature."""
id: str = Field( # Unique string label (PK), not UUID
description="Unique role identifier (label)",
@@ -122,7 +124,7 @@ registerModelLabels(
)
-class TrusteeAccess(BaseModel):
+class TrusteeAccess(PowerOnModel):
"""Defines user access to organisations with specific roles."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@@ -207,7 +209,7 @@ registerModelLabels(
)
-class TrusteeContract(BaseModel):
+class TrusteeContract(PowerOnModel):
"""Defines customer contracts within organisations."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@@ -289,7 +291,7 @@ class TrusteeDocumentTypeEnum(str, Enum):
AUTO = "auto"
-class TrusteeDocument(BaseModel):
+class TrusteeDocument(PowerOnModel):
"""Contains document references for bookings.
Documents reference files in the central Files table via fileId.
@@ -413,7 +415,7 @@ registerModelLabels(
)
-class TrusteePosition(BaseModel):
+class TrusteePosition(PowerOnModel):
"""Contains booking positions (expense entries).
A position can have up to two document references: documentId (Beleg) and bankDocumentId (Bank-Referenz).
@@ -696,10 +698,6 @@ class TrusteePosition(BaseModel):
}
)
- # Allow extra fields like _createdAt from database
- model_config = {"extra": "allow"}
-
-
registerModelLabels(
"TrusteePosition",
{"en": "Position", "fr": "Position", "de": "Position"},
@@ -739,7 +737,7 @@ registerModelLabels(
# ── TrusteeData* tables (synced from external accounting apps for analysis) ──
-class TrusteeDataAccount(BaseModel):
+class TrusteeDataAccount(PowerOnModel):
"""Chart of accounts synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
accountNumber: str = Field(description="Account number (e.g. '1020')")
@@ -769,7 +767,7 @@ registerModelLabels(
)
-class TrusteeDataJournalEntry(BaseModel):
+class TrusteeDataJournalEntry(PowerOnModel):
"""Journal entry header synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
externalId: Optional[str] = Field(default=None, description="ID in the source system")
@@ -799,7 +797,7 @@ registerModelLabels(
)
-class TrusteeDataJournalLine(BaseModel):
+class TrusteeDataJournalLine(PowerOnModel):
"""Journal entry line (debit/credit) synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id")
@@ -833,7 +831,7 @@ registerModelLabels(
)
-class TrusteeDataContact(BaseModel):
+class TrusteeDataContact(PowerOnModel):
"""Customer or vendor synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
externalId: Optional[str] = Field(default=None, description="ID in the source system")
@@ -873,7 +871,7 @@ registerModelLabels(
)
-class TrusteeDataAccountBalance(BaseModel):
+class TrusteeDataAccountBalance(PowerOnModel):
"""Account balance per period, derived from journal lines or directly from accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
accountNumber: str = Field(description="Account number")
@@ -907,7 +905,7 @@ registerModelLabels(
)
-class TrusteeAccountingConfig(BaseModel):
+class TrusteeAccountingConfig(PowerOnModel):
"""Per-instance accounting system configuration with encrypted credentials.
Each feature instance can connect to exactly one accounting system.
@@ -946,7 +944,7 @@ registerModelLabels(
)
-class TrusteeAccountingSync(BaseModel):
+class TrusteeAccountingSync(PowerOnModel):
"""Tracks which position was synced to which external system and when.
Used for duplicate prevention, audit trail, and retry logic.
diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py
index b9a95005..7ed6fcff 100644
--- a/modules/features/trustee/interfaceFeatureTrustee.py
+++ b/modules/features/trustee/interfaceFeatureTrustee.py
@@ -1152,7 +1152,7 @@ class TrusteeObjects:
logger.warning(f"Document {documentId} not found")
return None
- createdBy = existing.get("_createdBy")
+ createdBy = existing.get("sysCreatedBy")
# Check system RBAC permission (userreport can only edit their own records)
if not self.checkCombinedPermission(TrusteeDocument, "update", recordCreatedBy=createdBy):
@@ -1178,7 +1178,7 @@ class TrusteeObjects:
logger.warning(f"Document {documentId} not found")
return False
- createdBy = existing.get("_createdBy")
+ createdBy = existing.get("sysCreatedBy")
if not self.checkCombinedPermission(TrusteeDocument, "delete", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to delete document")
@@ -1198,7 +1198,7 @@ class TrusteeObjects:
def _toTrusteePositionOrDelete(self, rawRecord: Dict[str, Any], deleteCorrupt: bool = True) -> Optional[TrusteePosition]:
"""Build TrusteePosition safely; optionally delete irreparably corrupt records."""
- cleanRecord = {k: v for k, v in (rawRecord or {}).items() if not k.startswith("_") or k == "_createdAt"}
+ cleanRecord = {k: v for k, v in (rawRecord or {}).items() if not k.startswith("_") or k == "sysCreatedAt"}
if not cleanRecord:
return None
@@ -1271,7 +1271,7 @@ class TrusteeObjects:
"""Get all positions with RBAC filtering and optional DB-level pagination.
Filtering, sorting, and pagination are handled at the SQL level.
- Post-processing cleans internal fields (keeps _createdAt) and validates
+ Post-processing cleans internal fields (keeps sysCreatedAt) and validates
each record via _toTrusteePositionOrDelete (corrupt rows are deleted).
NOTE(post-process): totalItems may slightly overcount when corrupt legacy
@@ -1288,7 +1288,7 @@ class TrusteeObjects:
featureCode=self.FEATURE_CODE
)
- keepFields = {'_createdAt'}
+ keepFields = {'sysCreatedAt'}
def _cleanAndValidate(records):
items = []
@@ -1369,7 +1369,7 @@ class TrusteeObjects:
logger.warning(f"Position {positionId} not found")
return None
- createdBy = existing.get("_createdBy")
+ createdBy = existing.get("sysCreatedBy")
# Check system RBAC permission (userreport can only edit their own records)
if not self.checkCombinedPermission(TrusteePosition, "update", recordCreatedBy=createdBy):
@@ -1391,7 +1391,7 @@ class TrusteeObjects:
logger.warning(f"Position {positionId} not found")
return False
- createdBy = existing.get("_createdBy")
+ createdBy = existing.get("sysCreatedBy")
if not self.checkCombinedPermission(TrusteePosition, "delete", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to delete position")
diff --git a/modules/features/workspace/datamodelFeatureWorkspace.py b/modules/features/workspace/datamodelFeatureWorkspace.py
index 7c718d67..d7c292db 100644
--- a/modules/features/workspace/datamodelFeatureWorkspace.py
+++ b/modules/features/workspace/datamodelFeatureWorkspace.py
@@ -4,11 +4,12 @@
from typing import Optional
from pydantic import BaseModel, Field
+from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
import uuid
-class WorkspaceUserSettings(BaseModel):
+class WorkspaceUserSettings(PowerOnModel):
"""Per-user workspace settings. None values mean 'use instance default'."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="User ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
diff --git a/modules/features/workspace/mainWorkspace.py b/modules/features/workspace/mainWorkspace.py
index c502a82e..5ef9b399 100644
--- a/modules/features/workspace/mainWorkspace.py
+++ b/modules/features/workspace/mainWorkspace.py
@@ -128,7 +128,7 @@ TEMPLATE_ROLES = [
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
- # DATA: never ALL in shared instances — every role (including admin) sees only _createdBy = self
+ # DATA: never ALL in shared instances — every role (including admin) sees only sysCreatedBy = self
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
]
},
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 0a3e24ad..0fb48ffe 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -11,7 +11,7 @@ Multi-Tenant Design:
"""
import logging
-from typing import Optional, Dict
+from typing import Optional, Dict, Set, Tuple
from passlib.context import CryptContext
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
@@ -38,6 +38,120 @@ pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
# Cache für Role-IDs (roleLabel -> roleId)
_roleIdCache: Dict[str, str] = {}
+# Historical PostgreSQL column identifiers (pre-sys*). Used only in _migrateSystemFieldColumns SQL.
+_LEGACY_SYS_PAIR_RENAMES: Tuple[Tuple[str, str], ...] = (
+ ("_createdAt", "sysCreatedAt"),
+ ("_createdBy", "sysCreatedBy"),
+ ("_modifiedAt", "sysModifiedAt"),
+ ("_modifiedBy", "sysModifiedBy"),
+)
+
+
+def _getPublicTableColumns(db: DatabaseConnector, tableName: str) -> Set[str]:
+ """Column names for a quoted PostgreSQL table (exact case in information_schema)."""
+ try:
+ with db.connection.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT column_name FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = %s
+ """,
+ (tableName,),
+ )
+ return {row["column_name"] for row in cursor.fetchall()}
+ except Exception as e:
+ logger.warning(f"_getPublicTableColumns failed for {tableName}: {e}")
+ return set()
+
+
+def _migrateSystemFieldColumns(db: DatabaseConnector) -> None:
+ """Backfill sys* from older physical columns and business duplicates where sys* IS NULL (idempotent)."""
+ businessFieldMigrations: Dict[str, Dict[str, str]] = {
+ "FileFolder": {"createdAt": "sysCreatedAt"},
+ "FileItem": {"creationDate": "sysCreatedAt"},
+ "Invitation": {"createdAt": "sysCreatedAt", "createdBy": "sysCreatedBy"},
+ "FeatureDataSource": {"createdAt": "sysCreatedAt"},
+ "DataSource": {"createdAt": "sysCreatedAt"},
+ "UserNotification": {"createdAt": "sysCreatedAt"},
+ "Token": {"createdAt": "sysCreatedAt"},
+ "MessagingSubscription": {"createdBy": "sysCreatedBy", "modifiedBy": "sysModifiedBy"},
+ "CoachingContext": {"createdAt": "sysCreatedAt"},
+ "CoachingSession": {"createdAt": "sysCreatedAt", "updatedAt": "sysModifiedAt"},
+ "CoachingMessage": {"createdAt": "sysCreatedAt"},
+ "CoachingTask": {"createdAt": "sysCreatedAt", "updatedAt": "sysModifiedAt"},
+ "CoachingScore": {"createdAt": "sysCreatedAt"},
+ "CoachingUserProfile": {"createdAt": "sysCreatedAt", "updatedAt": "sysModifiedAt"},
+ "CoachingPersona": {"createdAt": "sysCreatedAt", "updatedAt": "sysModifiedAt"},
+ "CoachingBadge": {"createdAt": "sysCreatedAt"},
+ "TeamsbotSession": {"creationDate": "sysCreatedAt", "lastModified": "sysModifiedAt"},
+ "TeamsbotTranscript": {"creationDate": "sysCreatedAt"},
+ "TeamsbotBotResponse": {"creationDate": "sysCreatedAt"},
+ "TeamsbotSystemBot": {"creationDate": "sysCreatedAt", "lastModified": "sysModifiedAt"},
+ "TeamsbotUserAccount": {"creationDate": "sysCreatedAt", "lastModified": "sysModifiedAt"},
+ "TeamsbotUserSettings": {"creationDate": "sysCreatedAt", "lastModified": "sysModifiedAt"},
+ "_system": {
+ k: v
+ for k, v in _LEGACY_SYS_PAIR_RENAMES
+ if k in ("_createdAt", "_modifiedAt")
+ },
+ }
+
+ try:
+ db._ensure_connection()
+ with db.connection.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT table_name FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
+ """
+ )
+ tableNames = [row["table_name"] for row in cursor.fetchall()]
+
+ totalUpdates = 0
+ for table in tableNames:
+ cols = _getPublicTableColumns(db, table)
+ if not cols:
+ continue
+
+ for old_col, new_col in _LEGACY_SYS_PAIR_RENAMES:
+ if old_col in cols and new_col in cols:
+ try:
+ with db.connection.cursor() as cursor:
+ cursor.execute(
+ f'UPDATE "{table}" SET "{new_col}" = "{old_col}" '
+ f'WHERE "{new_col}" IS NULL AND "{old_col}" IS NOT NULL'
+ )
+ totalUpdates += cursor.rowcount
+ db.connection.commit()
+ except Exception as e:
+ db.connection.rollback()
+ logger.debug(f"Column migrate skip {table}.{old_col}->{new_col}: {e}")
+
+ biz = businessFieldMigrations.get(table)
+ if biz:
+ for old_col, new_col in biz.items():
+ if old_col in cols and new_col in cols:
+ try:
+ with db.connection.cursor() as cursor:
+ cursor.execute(
+ f'UPDATE "{table}" SET "{new_col}" = "{old_col}" '
+ f'WHERE "{new_col}" IS NULL AND "{old_col}" IS NOT NULL'
+ )
+ totalUpdates += cursor.rowcount
+ db.connection.commit()
+ except Exception as e:
+ db.connection.rollback()
+ logger.debug(f"Business field migrate skip {table}.{old_col}->{new_col}: {e}")
+
+ if totalUpdates:
+ logger.info(f"_migrateSystemFieldColumns: backfilled {totalUpdates} cell(s) on {db.dbDatabase}")
+ except Exception as e:
+ logger.error(f"_migrateSystemFieldColumns failed: {e}")
+ try:
+ db.connection.rollback()
+ except Exception:
+ pass
+
def initBootstrap(db: DatabaseConnector) -> None:
"""
@@ -50,6 +164,9 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Initialize root mandate
mandateId = initRootMandate(db)
+
+ # Backfill sys* columns from legacy _* / duplicate business fields (idempotent)
+ _migrateSystemFieldColumns(db)
# Migrate existing mandate records: description -> label
_migrateMandateDescriptionToLabel(db)
@@ -146,13 +263,13 @@ def initAutomationTemplates(dbApp: DatabaseConnector, adminUserId: Optional[str]
"""
Seed initial automation templates from subAutomationTemplates.py.
Only runs if no templates exist yet (bootstrap).
- Creates templates with _createdBy = admin user (SysAdmin privilege).
+ Creates templates with sysCreatedBy = admin user (SysAdmin privilege).
NOTE: AutomationTemplate lives in poweron_automation database, not poweron_app!
Args:
dbApp: Database connector for poweron_app (used to get admin user if needed)
- adminUserId: Admin user ID for _createdBy field
+ adminUserId: Admin user ID for sysCreatedBy field
"""
import json
from modules.features.automation.subAutomationTemplates import AUTOMATION_TEMPLATES
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 183bedb6..2a6b0f78 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -187,12 +187,8 @@ class AppObjects:
# Complex objects that should be filtered out
objectFields[fieldName] = value
else:
- # Field not in model - treat as scalar if simple, otherwise filter out
- # BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector
- if fieldName.startswith("_"):
- # Metadata fields should be passed through to connector
- simpleFields[fieldName] = value
- elif isinstance(value, (str, int, float, bool, type(None))):
+ # Field not in model - pass through scalars; nested objects go to objectFields
+ if isinstance(value, (str, int, float, bool, type(None))):
simpleFields[fieldName] = value
else:
objectFields[fieldName] = value
@@ -528,7 +524,7 @@ class AppObjects:
items = []
for record in result["items"]:
- cleanedUser = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedUser = dict(record)
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
items.append(User(**cleanedUser))
@@ -560,7 +556,7 @@ class AppObjects:
# Return first matching user (should be unique)
userDict = users[0]
# Filter out database-specific fields
- cleanedUser = {k: v for k, v in userDict.items() if not k.startswith("_")}
+ cleanedUser = dict(userDict)
# Ensure roleLabels is always a list, not None
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
@@ -586,7 +582,7 @@ class AppObjects:
# User already filtered by RBAC, just clean fields
user_dict = users[0]
- cleanedUser = {k: v for k, v in user_dict.items() if not k.startswith("_")}
+ cleanedUser = dict(user_dict)
# Ensure roleLabels is always a list, not None
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
@@ -648,12 +644,10 @@ class AppObjects:
if not self._verifyPassword(password, userRecord["hashedPassword"]):
raise ValueError("Invalid password")
- # Return clean User object (without password hash and internal fields)
- cleanedUser = {k: v for k, v in userRecord.items() if not k.startswith("_") and k != "hashedPassword" and k != "resetToken" and k != "resetTokenExpires"}
- # Ensure roleLabels is always a list
- if cleanedUser.get("roleLabels") is None:
- cleanedUser["roleLabels"] = []
- return User(**cleanedUser)
+ user = User.model_validate(userRecord)
+ if user.roleLabels is None:
+ return user.model_copy(update={"roleLabels": []})
+ return user
def createUser(
self,
@@ -877,7 +871,7 @@ class AppObjects:
result = []
for userRecord in users:
- cleanedUser = {k: v for k, v in userRecord.items() if not k.startswith("_")}
+ cleanedUser = dict(userRecord)
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
result.append(User(**cleanedUser))
@@ -917,7 +911,7 @@ class AppObjects:
)
if users:
- cleanedUser = {k: v for k, v in users[0].items() if not k.startswith("_")}
+ cleanedUser = dict(users[0])
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
return User(**cleanedUser)
@@ -978,7 +972,7 @@ class AppObjects:
)
if users:
- cleanedUser = {k: v for k, v in users[0].items() if not k.startswith("_")}
+ cleanedUser = dict(users[0])
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
return User(**cleanedUser)
@@ -1041,7 +1035,7 @@ class AppObjects:
logger.warning(f"Reset token expired for user {userRecord.get('id')}")
return None
- cleanedUser = {k: v for k, v in userRecord.items() if not k.startswith("_")}
+ cleanedUser = dict(userRecord)
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
return User(**cleanedUser)
@@ -1329,7 +1323,7 @@ class AppObjects:
# Filter out database-specific fields
filteredMandates = []
for mandate in allMandates:
- cleanedMandate = {k: v for k, v in mandate.items() if not k.startswith("_")}
+ cleanedMandate = dict(mandate)
filteredMandates.append(cleanedMandate)
# If no pagination requested, return all items
@@ -1378,7 +1372,7 @@ class AppObjects:
# Filter out database-specific fields
filteredMandates = []
for mandate in mandates:
- cleanedMandate = {k: v for k, v in mandate.items() if not k.startswith("_")}
+ cleanedMandate = dict(mandate)
filteredMandates.append(cleanedMandate)
if not filteredMandates:
return None
@@ -1794,7 +1788,7 @@ class AppObjects:
)
if not records:
return None
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return UserMandate(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting UserMandate: {e}")
@@ -1817,7 +1811,7 @@ class AppObjects:
)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(UserMandate(**cleanedRecord))
return result
except Exception as e:
@@ -1869,7 +1863,7 @@ class AppObjects:
self._ensureUserBillingAccount(userId, mandateId)
self._syncSubscriptionQuantity(mandateId)
- cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
+ cleanedRecord = dict(createdRecord)
return UserMandate(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating UserMandate: {e}")
@@ -1999,7 +1993,7 @@ class AppObjects:
)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(UserMandate(**cleanedRecord))
return result
except Exception as e:
@@ -2023,7 +2017,7 @@ class AppObjects:
)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(UserMandateRole(**cleanedRecord))
return result
except Exception as e:
@@ -2120,7 +2114,7 @@ class AppObjects:
recordFilter={"userMandateId": userMandateId, "roleId": roleId}
)
if existing:
- cleanedRecord = {k: v for k, v in existing[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(existing[0])
return UserMandateRole(**cleanedRecord)
userMandateRole = UserMandateRole(
@@ -2128,7 +2122,7 @@ class AppObjects:
roleId=roleId
)
createdRecord = self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
- cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
+ cleanedRecord = dict(createdRecord)
return UserMandateRole(**cleanedRecord)
except Exception as e:
logger.error(f"Error adding role to UserMandate: {e}")
@@ -2193,7 +2187,7 @@ class AppObjects:
)
if not records:
return None
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return FeatureAccess(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting FeatureAccess: {e}")
@@ -2216,7 +2210,7 @@ class AppObjects:
)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(FeatureAccess(**cleanedRecord))
return result
except Exception as e:
@@ -2240,7 +2234,7 @@ class AppObjects:
)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(FeatureAccess(**cleanedRecord))
return result
except Exception as e:
@@ -2289,7 +2283,7 @@ class AppObjects:
)
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
- cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
+ cleanedRecord = dict(createdRecord)
return FeatureAccess(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating FeatureAccess: {e}")
@@ -2427,7 +2421,7 @@ class AppObjects:
try:
records = self.db.getRecordset(Invitation, recordFilter={"id": invitationId})
if records:
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return Invitation(**cleanedRecord)
return None
except Exception as e:
@@ -2447,7 +2441,7 @@ class AppObjects:
try:
records = self.db.getRecordset(Invitation, recordFilter={"token": token})
if records:
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return Invitation(**cleanedRecord)
return None
except Exception as e:
@@ -2468,7 +2462,7 @@ class AppObjects:
records = self.db.getRecordset(Invitation, recordFilter={"mandateId": mandateId})
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(Invitation(**cleanedRecord))
return result
except Exception as e:
@@ -2486,10 +2480,10 @@ class AppObjects:
List of Invitation objects
"""
try:
- records = self.db.getRecordset(Invitation, recordFilter={"createdBy": creatorId})
+ records = self.db.getRecordset(Invitation, recordFilter={"sysCreatedBy": creatorId})
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(Invitation(**cleanedRecord))
return result
except Exception as e:
@@ -2510,7 +2504,7 @@ class AppObjects:
records = self.db.getRecordset(Invitation, recordFilter={"usedBy": usedById})
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(Invitation(**cleanedRecord))
return result
except Exception as e:
@@ -2531,7 +2525,7 @@ class AppObjects:
records = self.db.getRecordset(Invitation, recordFilter={"targetUsername": targetUsername})
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(Invitation(**cleanedRecord))
return result
except Exception as e:
@@ -2558,13 +2552,10 @@ class AppObjects:
items = []
for record in result["items"]:
- cleanedRecord = {
- k: v for k, v in record.items()
- if not k.startswith("_") and k not in ["hashedPassword", "resetToken", "resetTokenExpires"]
- }
- if cleanedRecord.get("roleLabels") is None:
- cleanedRecord["roleLabels"] = []
- items.append(User(**cleanedRecord))
+ user = User.model_validate(record)
+ if user.roleLabels is None:
+ user = user.model_copy(update={"roleLabels": []})
+ items.append(user)
if pagination is None:
return items
@@ -2593,7 +2584,7 @@ class AppObjects:
try:
records = self.db.getRecordset(UserMandate, recordFilter={"id": userMandateId})
if records:
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return UserMandate(**cleanedRecord)
return None
except Exception as e:
@@ -2614,7 +2605,7 @@ class AppObjects:
records = self.db.getRecordset(UserMandateRole, recordFilter={"roleId": roleId})
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(UserMandateRole(**cleanedRecord))
return result
except Exception as e:
@@ -2634,7 +2625,7 @@ class AppObjects:
try:
records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId})
if records:
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return FeatureInstance(**cleanedRecord)
return None
except Exception as e:
@@ -2654,7 +2645,7 @@ class AppObjects:
try:
records = self.db.getRecordset(Feature, recordFilter={"code": featureCode})
if records:
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return Feature(**cleanedRecord)
return None
except Exception as e:
@@ -2679,7 +2670,7 @@ class AppObjects:
records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(FeatureInstance(**cleanedRecord))
return result
except Exception as e:
@@ -2703,7 +2694,7 @@ class AppObjects:
try:
records = self.db.getRecordset(UserNotification, recordFilter={"id": notificationId})
if records:
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return UserNotification(**cleanedRecord)
return None
except Exception as e:
@@ -2734,10 +2725,10 @@ class AppObjects:
records = self.db.getRecordset(UserNotification, recordFilter=recordFilter)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(UserNotification(**cleanedRecord))
- # Sort by createdAt descending
- result.sort(key=lambda x: x.createdAt or 0, reverse=True)
+ # Sort by sysCreatedAt descending
+ result.sort(key=lambda x: x.sysCreatedAt or 0, reverse=True)
if limit:
result = result[:limit]
return result
@@ -2762,7 +2753,7 @@ class AppObjects:
try:
records = self.db.getRecordset(AccessRule, recordFilter={"id": ruleId})
if records:
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return AccessRule(**cleanedRecord)
return None
except Exception as e:
@@ -2783,7 +2774,7 @@ class AppObjects:
records = self.db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(AccessRule(**cleanedRecord))
return result
except Exception as e:
@@ -2804,7 +2795,7 @@ class AppObjects:
records = self.db.getRecordset(Role, recordFilter={"featureInstanceId": featureInstanceId})
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(Role(**cleanedRecord))
return result
except Exception as e:
@@ -2829,7 +2820,7 @@ class AppObjects:
records = self.db.getRecordset(Role, recordFilter=recordFilter)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(Role(**cleanedRecord))
return result
except Exception as e:
@@ -3028,7 +3019,7 @@ class AppObjects:
)
result = []
for token_dict in tokens:
- cleanedRecord = {k: v for k, v in token_dict.items() if not k.startswith("_")}
+ cleanedRecord = dict(token_dict)
result.append(Token(**cleanedRecord))
return result
except Exception as e:
@@ -3049,7 +3040,7 @@ class AppObjects:
)
result = []
for token_dict in tokens:
- cleanedRecord = {k: v for k, v in token_dict.items() if not k.startswith("_")}
+ cleanedRecord = dict(token_dict)
result.append(Token(**cleanedRecord))
return result
except Exception as e:
@@ -3363,7 +3354,7 @@ class AppObjects:
# Filter out database-specific fields
filteredRules = []
for rule in rules:
- cleanedRule = {k: v for k, v in rule.items() if not k.startswith("_")}
+ cleanedRule = dict(rule)
filteredRules.append(cleanedRule)
# If no pagination requested, return all items
@@ -3547,7 +3538,7 @@ class AppObjects:
Role,
recordFilter={"mandateId": mandateId, "featureInstanceId": None}
)
- return [Role(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in roles]
+ return [Role(**dict(r)) for r in roles]
except Exception as e:
logger.error(f"Error getting roles for mandate {mandateId}: {e}")
return []
@@ -3568,7 +3559,7 @@ class AppObjects:
items = []
for record in result["items"]:
- cleanedRole = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRole = dict(record)
items.append(Role(**cleanedRole))
if pagination is None:
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index 2db71bb4..343e2215 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -674,7 +674,7 @@ class BillingObjects:
if startDate or endDate:
filtered = []
for t in results:
- createdAt = t.get("_createdAt")
+ createdAt = t.get("sysCreatedAt")
if createdAt:
tDate = createdAt.date() if isinstance(createdAt, datetime) else createdAt
if startDate and tDate < startDate:
@@ -684,7 +684,7 @@ class BillingObjects:
filtered.append(t)
results = filtered
- results.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
+ results.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
return results[offset:offset + limit]
except Exception as e:
@@ -739,7 +739,7 @@ class BillingObjects:
transactions = self.getTransactions(account["id"], limit=limit)
allTransactions.extend(transactions)
- allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
+ allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
return allTransactions[:limit]
# =========================================================================
@@ -1244,7 +1244,7 @@ class BillingObjects:
except Exception as e:
logger.error(f"Error getting transactions for user: {e}")
- allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
+ allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
return allTransactions[:limit]
# =========================================================================
@@ -1361,7 +1361,7 @@ class BillingObjects:
logger.error(f"Error getting mandate transactions: {e}")
# Sort by creation date descending and limit
- allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
+ allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
return allTransactions[:limit]
# =========================================================================
@@ -1549,5 +1549,5 @@ class BillingObjects:
logger.error(f"Error getting user transactions for mandates: {e}")
# Sort by creation date descending and limit
- allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
+ allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
return allTransactions[:limit]
diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py
index b0d4aff3..192cbad4 100644
--- a/modules/interfaces/interfaceDbChat.py
+++ b/modules/interfaces/interfaceDbChat.py
@@ -251,9 +251,8 @@ class ChatObjects:
objectFields[fieldName] = value
else:
# Field not in model - treat as scalar if simple, otherwise filter out
- # BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector
+ # Underscore-prefixed keys (e.g. UI meta) pass through; sys* live on PowerOnModel subclasses
if fieldName.startswith("_"):
- # Metadata fields should be passed through to connector
simpleFields[fieldName] = value
elif isinstance(value, (str, int, float, bool, type(None))):
simpleFields[fieldName] = value
@@ -885,7 +884,7 @@ class ChatObjects:
"role": msg.get("role", "assistant"),
"status": msg.get("status", "step"),
"sequenceNr": msg.get("sequenceNr", 0),
- "publishedAt": msg.get("publishedAt") or msg.get("_createdAt") or msg.get("timestamp") or 0,
+ "publishedAt": msg.get("publishedAt") or msg.get("sysCreatedAt") or msg.get("timestamp") or 0,
"success": msg.get("success"),
"actionId": msg.get("actionId"),
"actionMethod": msg.get("actionMethod"),
@@ -1268,7 +1267,7 @@ class ChatObjects:
# CASCADE DELETE: Delete all related data first
# 1. Delete message documents (but NOT the files themselves)
- # Bypass RBAC -- workflow access already verified, child records may have different _createdBy
+ # Bypass RBAC -- workflow access already verified, child records may have different sysCreatedBy
existing_docs = self.db.getRecordset(ChatDocument, recordFilter={"messageId": messageId})
for doc in existing_docs:
self.db.recordDelete(ChatDocument, doc["id"])
@@ -1296,7 +1295,7 @@ class ChatObjects:
# Get documents for this message from normalized table
- # Bypass RBAC -- workflow access already verified, child records may have different _createdBy
+ # Bypass RBAC -- workflow access already verified, child records may have different sysCreatedBy
documents = self.db.getRecordset(ChatDocument, recordFilter={"messageId": messageId})
if not documents:
diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py
index 58fd6926..28842958 100644
--- a/modules/interfaces/interfaceDbManagement.py
+++ b/modules/interfaces/interfaceDbManagement.py
@@ -175,12 +175,7 @@ class ComponentObjects:
# Complex objects that should be filtered out
objectFields[fieldName] = value
else:
- # Field not in model - treat as scalar if simple, otherwise filter out
- # BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector
- if fieldName.startswith("_"):
- # Metadata fields should be passed through to connector
- simpleFields[fieldName] = value
- elif isinstance(value, (str, int, float, bool, type(None))):
+ if isinstance(value, (str, int, float, bool, type(None))):
simpleFields[fieldName] = value
else:
objectFields[fieldName] = value
@@ -609,7 +604,7 @@ class ComponentObjects:
"""
isSysAdmin = self._isSysAdmin()
for prompt in prompts:
- isOwner = prompt.get("_createdBy") == self.userId
+ isOwner = prompt.get("sysCreatedBy") == self.userId
prompt["_permissions"] = {
"canUpdate": isOwner or isSysAdmin,
"canDelete": isOwner or isSysAdmin
@@ -621,13 +616,13 @@ class ComponentObjects:
Visibility rules:
- SysAdmin: ALL prompts
- - Regular user: own prompts (_createdBy) + system prompts (isSystem=True)
+ - Regular user: own prompts (sysCreatedBy) + system prompts (isSystem=True)
"""
if self._isSysAdmin():
return self.db.getRecordset(Prompt)
# Get own prompts
- ownPrompts = self.db.getRecordset(Prompt, recordFilter={"_createdBy": self.userId})
+ ownPrompts = self.db.getRecordset(Prompt, recordFilter={"sysCreatedBy": self.userId})
# Get system prompts
systemPrompts = self.db.getRecordset(Prompt, recordFilter={"isSystem": True})
@@ -716,7 +711,7 @@ class ComponentObjects:
# Visibility check for non-SysAdmin: must be owner or system prompt
if not self._isSysAdmin():
- isOwner = prompt.get("_createdBy") == self.userId
+ isOwner = prompt.get("sysCreatedBy") == self.userId
isSystem = prompt.get("isSystem", False)
if not isOwner and not isSystem:
return None
@@ -747,7 +742,7 @@ class ComponentObjects:
raise ValueError(f"Prompt {promptId} not found")
# Permission check: owner or SysAdmin
- isOwner = (getattr(prompt, '_createdBy', None) == self.userId)
+ isOwner = (getattr(prompt, 'sysCreatedBy', None) == self.userId)
if not self._isSysAdmin() and not isOwner:
raise PermissionError(f"No permission to update prompt {promptId}")
@@ -784,7 +779,7 @@ class ComponentObjects:
return False
# Permission check: owner or SysAdmin
- isOwner = (getattr(prompt, '_createdBy', None) == self.userId)
+ isOwner = (getattr(prompt, 'sysCreatedBy', None) == self.userId)
if not self._isSysAdmin() and not isOwner:
raise PermissionError(f"No permission to delete prompt {promptId}")
@@ -798,7 +793,7 @@ class ComponentObjects:
def checkForDuplicateFile(self, fileHash: str, fileName: str) -> Optional[FileItem]:
"""Checks if a file with the same hash AND fileName already exists for the current user.
- Duplicate = same user (_createdBy) + same fileHash + same fileName.
+ Duplicate = same user (sysCreatedBy) + same fileHash + same fileName.
Same hash with different name is allowed (intentional copy by user).
Uses direct DB query (not RBAC) because files are isolated per user.
"""
@@ -809,7 +804,7 @@ class ComponentObjects:
matchingFiles = self.db.getRecordset(
FileItem,
recordFilter={
- "_createdBy": self.userId,
+ "sysCreatedBy": self.userId,
"fileHash": fileHash,
"fileName": fileName
}
@@ -908,7 +903,7 @@ class ComponentObjects:
def _getFilesByCurrentUser(self, recordFilter: Dict[str, Any] = None) -> List[Dict[str, Any]]:
"""Files are always user-scoped. Returns only files owned by the current user,
regardless of role (including SysAdmin). This bypasses RBAC intentionally."""
- filterDict = {"_createdBy": self.userId}
+ filterDict = {"sysCreatedBy": self.userId}
if recordFilter:
filterDict.update(recordFilter)
return self.db.getRecordset(FileItem, recordFilter=filterDict)
@@ -927,7 +922,7 @@ class ComponentObjects:
If pagination is provided: PaginatedResult with items and metadata
"""
# User-scoping filter: every user only sees their own files (bypasses RBAC SysAdmin override)
- recordFilter = {"_createdBy": self.userId}
+ recordFilter = {"sysCreatedBy": self.userId}
def _convertFileItems(files):
fileItems = []
@@ -974,7 +969,7 @@ class ComponentObjects:
def getFile(self, fileId: str) -> Optional[FileItem]:
"""Returns a file by ID if it belongs to the current user (user-scoped)."""
- # Files are always user-scoped: filter by _createdBy (bypasses RBAC SysAdmin override)
+ # Files are always user-scoped: filter by sysCreatedBy (bypasses RBAC SysAdmin override)
filteredFiles = self._getFilesByCurrentUser(recordFilter={"id": fileId})
if not filteredFiles:
@@ -1151,7 +1146,7 @@ class ComponentObjects:
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
cursor.execute(
- 'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
+ 'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(uniqueIds, self.userId or ""),
)
accessibleIds = [row["id"] for row in cursor.fetchall()]
@@ -1162,7 +1157,7 @@ class ComponentObjects:
cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (accessibleIds,))
cursor.execute(
- 'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
+ 'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(accessibleIds, self.userId or ""),
)
deletedFiles = cursor.rowcount
@@ -1207,12 +1202,12 @@ class ComponentObjects:
def getFolder(self, folderId: str) -> Optional[Dict[str, Any]]:
"""Returns a folder by ID if it belongs to the current user."""
- folders = self.db.getRecordset(FileFolder, recordFilter={"id": folderId, "_createdBy": self.userId or ""})
+ folders = self.db.getRecordset(FileFolder, recordFilter={"id": folderId, "sysCreatedBy": self.userId or ""})
return folders[0] if folders else None
def listFolders(self, parentId: Optional[str] = None) -> List[Dict[str, Any]]:
"""List folders for current user, optionally filtered by parentId."""
- recordFilter = {"_createdBy": self.userId or ""}
+ recordFilter = {"sysCreatedBy": self.userId or ""}
if parentId is not None:
recordFilter["parentId"] = parentId
return self.db.getRecordset(FileFolder, recordFilter=recordFilter)
@@ -1261,7 +1256,7 @@ class ComponentObjects:
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
cursor.execute(
- 'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
+ 'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(uniqueIds, self.userId or ""),
)
accessibleIds = [row["id"] for row in cursor.fetchall()]
@@ -1270,8 +1265,8 @@ class ComponentObjects:
raise FileNotFoundError(f"Files not found or not accessible: {missingIds}")
cursor.execute(
- 'UPDATE "FileItem" SET "folderId" = %s, "_modifiedAt" = %s, "_modifiedBy" = %s '
- 'WHERE "id" = ANY(%s) AND "_createdBy" = %s',
+ 'UPDATE "FileItem" SET "folderId" = %s, "sysModifiedAt" = %s, "sysModifiedBy" = %s '
+ 'WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds, self.userId or ""),
)
movedFiles = cursor.rowcount
@@ -1300,7 +1295,7 @@ class ComponentObjects:
existingInTarget = self.db.getRecordset(
FileFolder,
- recordFilter={"parentId": targetParentId or "", "_createdBy": self.userId or ""},
+ recordFilter={"parentId": targetParentId or "", "sysCreatedBy": self.userId or ""},
)
existingNames = {f.get("name"): f.get("id") for f in existingInTarget}
movingNames: Dict[str, str] = {}
@@ -1321,8 +1316,8 @@ class ComponentObjects:
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
cursor.execute(
- 'UPDATE "FileFolder" SET "parentId" = %s, "_modifiedAt" = %s, "_modifiedBy" = %s '
- 'WHERE "id" = ANY(%s) AND "_createdBy" = %s',
+ 'UPDATE "FileFolder" SET "parentId" = %s, "sysModifiedAt" = %s, "sysModifiedBy" = %s '
+ 'WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(targetParentId, getUtcTimestamp(), self.userId or "", uniqueIds, self.userId or ""),
)
movedFolders = cursor.rowcount
@@ -1340,7 +1335,7 @@ class ComponentObjects:
if not folder:
raise FileNotFoundError(f"Folder {folderId} not found")
- childFolders = self.db.getRecordset(FileFolder, recordFilter={"parentId": folderId, "_createdBy": self.userId or ""})
+ childFolders = self.db.getRecordset(FileFolder, recordFilter={"parentId": folderId, "sysCreatedBy": self.userId or ""})
childFiles = self._getFilesByCurrentUser(recordFilter={"folderId": folderId})
if not recursive and (childFolders or childFiles):
@@ -1389,7 +1384,7 @@ class ComponentObjects:
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
cursor.execute(
- 'SELECT "id" FROM "FileFolder" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
+ 'SELECT "id" FROM "FileFolder" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(uniqueIds, self.userId or ""),
)
rootAccessibleIds = [row["id"] for row in cursor.fetchall()]
@@ -1402,12 +1397,12 @@ class ComponentObjects:
WITH RECURSIVE folder_tree AS (
SELECT "id"
FROM "FileFolder"
- WHERE "id" = ANY(%s) AND "_createdBy" = %s
+ WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s
UNION ALL
SELECT child."id"
FROM "FileFolder" child
INNER JOIN folder_tree ft ON child."parentId" = ft."id"
- WHERE child."_createdBy" = %s
+ WHERE child."sysCreatedBy" = %s
)
SELECT DISTINCT "id" FROM folder_tree
""",
@@ -1416,7 +1411,7 @@ class ComponentObjects:
allFolderIds = [row["id"] for row in cursor.fetchall()]
cursor.execute(
- 'SELECT "id" FROM "FileItem" WHERE "folderId" = ANY(%s) AND "_createdBy" = %s',
+ 'SELECT "id" FROM "FileItem" WHERE "folderId" = ANY(%s) AND "sysCreatedBy" = %s',
(allFolderIds, self.userId or ""),
)
allFileIds = [row["id"] for row in cursor.fetchall()]
@@ -1424,7 +1419,7 @@ class ComponentObjects:
if allFileIds:
cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (allFileIds,))
cursor.execute(
- 'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
+ 'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(allFileIds, self.userId or ""),
)
deletedFiles = cursor.rowcount
@@ -1432,7 +1427,7 @@ class ComponentObjects:
deletedFiles = 0
cursor.execute(
- 'DELETE FROM "FileFolder" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
+ 'DELETE FROM "FileFolder" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(allFolderIds, self.userId or ""),
)
deletedFolders = cursor.rowcount
diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py
index 56311f01..6616218d 100644
--- a/modules/interfaces/interfaceFeatures.py
+++ b/modules/interfaces/interfaceFeatures.py
@@ -57,7 +57,7 @@ class FeatureInterface:
records = self.db.getRecordset(Feature, recordFilter={"code": featureCode})
if not records:
return None
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return Feature(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting feature {featureCode}: {e}")
@@ -74,7 +74,7 @@ class FeatureInterface:
records = self.db.getRecordset(Feature)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(Feature(**cleanedRecord))
return result
except Exception as e:
@@ -120,7 +120,7 @@ class FeatureInterface:
records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId})
if not records:
return None
- cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
+ cleanedRecord = dict(records[0])
return FeatureInstance(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting feature instance {instanceId}: {e}")
@@ -144,7 +144,7 @@ class FeatureInterface:
records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(FeatureInstance(**cleanedRecord))
return result
except Exception as e:
@@ -199,7 +199,7 @@ class FeatureInterface:
if copyTemplateRoles:
self._copyTemplateRoles(featureCode, mandateId, instanceId)
- cleanedRecord = {k: v for k, v in createdInstance.items() if not k.startswith("_")}
+ cleanedRecord = dict(createdInstance)
return FeatureInstance(**cleanedRecord)
except Exception as e:
@@ -435,7 +435,7 @@ class FeatureInterface:
updated = self.db.recordModify(FeatureInstance, instanceId, filteredData)
if updated:
- cleanedRecord = {k: v for k, v in updated.items() if not k.startswith("_")}
+ cleanedRecord = dict(updated)
return FeatureInstance(**cleanedRecord)
return None
except Exception as e:
@@ -484,7 +484,7 @@ class FeatureInterface:
records = self.db.getRecordset(Role, recordFilter=recordFilter)
result = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = dict(record)
result.append(Role(**cleanedRecord))
return result
except Exception as e:
diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py
index e65cd5ab..947a6e2d 100644
--- a/modules/interfaces/interfaceRbac.py
+++ b/modules/interfaces/interfaceRbac.py
@@ -17,7 +17,7 @@ Data Namespace Structure:
GROUP-Berechtigung:
- data.uam.*: GROUP filtert nach Mandant (via UserMandate)
-- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich _createdBy
+- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich sysCreatedBy
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
"""
@@ -146,7 +146,7 @@ def getRecordsetWithRBAC(
mandateId: Explicit mandate context (from request header). Required for GROUP access.
featureInstanceId: Explicit feature instance context
enrichPermissions: If True, adds _permissions field to each record with row-level
- permissions { canUpdate, canDelete } based on RBAC rules and _createdBy
+ permissions { canUpdate, canDelete } based on RBAC rules and sysCreatedBy
featureCode: Optional feature code for feature-specific tables (e.g., "trustee").
If None, table is treated as a system table.
@@ -657,7 +657,7 @@ def buildRbacWhereClause(
# shared featureInstance (stale RBAC rules or merged roles). Same as MY.
namespaceAll = TABLE_NAMESPACE.get(table, "system")
if featureInstanceId and namespaceAll == "chat":
- userIdFieldAll = "_createdBy"
+ userIdFieldAll = "sysCreatedBy"
if table == "UserInDB":
userIdFieldAll = "id"
elif table == "UserConnection":
@@ -671,7 +671,7 @@ def buildRbacWhereClause(
return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None
- # My records - filter by _createdBy or userId field
+ # My records - filter by sysCreatedBy or userId field
if readLevel == AccessLevel.MY:
# Try common field names for creator
userIdField = None
@@ -680,7 +680,7 @@ def buildRbacWhereClause(
elif table == "UserConnection":
userIdField = "userId"
else:
- userIdField = "_createdBy"
+ userIdField = "sysCreatedBy"
conditions = list(baseConditions)
values = list(baseValues)
@@ -707,7 +707,7 @@ def buildRbacWhereClause(
if featureInstanceId and readLevel == AccessLevel.GROUP:
conditions = list(baseConditions)
values = list(baseValues)
- conditions.append('"_createdBy" = %s')
+ conditions.append('"sysCreatedBy" = %s')
values.append(currentUser.id)
return {"condition": " AND ".join(conditions), "values": values}
return {"condition": " AND ".join(baseConditions), "values": baseValues}
@@ -829,7 +829,7 @@ def _enrichRecordsWithPermissions(
Logic:
- AccessLevel.ALL ('a'): User can update/delete all records
- - AccessLevel.MY ('m'): User can only update/delete records where _createdBy == userId
+ - AccessLevel.MY ('m'): User can only update/delete records where sysCreatedBy == userId
- AccessLevel.GROUP ('g'): Same as MY for now (group-level ownership)
- AccessLevel.NONE ('n'): User cannot update/delete any records
@@ -846,7 +846,7 @@ def _enrichRecordsWithPermissions(
for record in records:
recordCopy = dict(record)
- createdBy = record.get("_createdBy")
+ createdBy = record.get("sysCreatedBy")
# Determine canUpdate
canUpdate = _checkRowPermission(permissions.update, userId, createdBy)
@@ -873,7 +873,7 @@ def _checkRowPermission(
Args:
accessLevel: The permission level (ALL, MY, GROUP, NONE)
userId: Current user's ID
- recordCreatedBy: The _createdBy value of the record
+ recordCreatedBy: The sysCreatedBy value of the record
Returns:
True if user has permission, False otherwise
@@ -884,9 +884,9 @@ def _checkRowPermission(
if accessLevel == AccessLevel.ALL:
return True
- # MY and GROUP: Check ownership via _createdBy
+ # MY and GROUP: Check ownership via sysCreatedBy
if accessLevel in (AccessLevel.MY, AccessLevel.GROUP):
- # If record has no _createdBy, allow access (can't verify ownership)
+ # If record has no sysCreatedBy, allow access (can't verify ownership)
if not recordCreatedBy:
return True
# If no userId, can't verify - deny
diff --git a/modules/migration/migrateRootUsers.py b/modules/migration/migrateRootUsers.py
index a048e614..69d1b7af 100644
--- a/modules/migration/migrateRootUsers.py
+++ b/modules/migration/migrateRootUsers.py
@@ -80,7 +80,7 @@ def _migrateDataRecords(db, oldInstanceId: str, newInstanceId: str, userId: str)
cursor.execute(
f'UPDATE "{tableName}" '
f'SET "featureInstanceId" = %s '
- f'WHERE "featureInstanceId" = %s AND "_createdBy" = %s',
+ f'WHERE "featureInstanceId" = %s AND "sysCreatedBy" = %s',
(newInstanceId, oldInstanceId, userId),
)
count = cursor.rowcount
diff --git a/modules/routes/routeAdminAutomationEvents.py b/modules/routes/routeAdminAutomationEvents.py
index 47d3ac9c..553c66d3 100644
--- a/modules/routes/routeAdminAutomationEvents.py
+++ b/modules/routes/routeAdminAutomationEvents.py
@@ -112,12 +112,12 @@ def _buildEnrichedAutomationEvents(currentUser: User) -> List[Dict[str, Any]]:
if automation:
if isinstance(automation, dict):
job["name"] = automation.get("label", "")
- job["createdBy"] = _resolveUsername(automation.get("_createdBy", ""))
+ job["createdBy"] = _resolveUsername(automation.get("sysCreatedBy", ""))
job["mandate"] = _resolveMandateLabel(automation.get("mandateId", ""))
job["featureInstance"] = _resolveFeatureLabel(automation.get("featureInstanceId", ""))
else:
job["name"] = getattr(automation, "label", "")
- job["createdBy"] = _resolveUsername(getattr(automation, "_createdBy", ""))
+ job["createdBy"] = _resolveUsername(getattr(automation, "sysCreatedBy", ""))
job["mandate"] = _resolveMandateLabel(getattr(automation, "mandateId", ""))
job["featureInstance"] = _resolveFeatureLabel(getattr(automation, "featureInstanceId", ""))
else:
diff --git a/modules/routes/routeAdminAutomationLogs.py b/modules/routes/routeAdminAutomationLogs.py
index 8b4d897b..479d0df3 100644
--- a/modules/routes/routeAdminAutomationLogs.py
+++ b/modules/routes/routeAdminAutomationLogs.py
@@ -91,14 +91,14 @@ def _buildFlattenedExecutionLogs(currentUser: User) -> List[Dict[str, Any]]:
automationLabel = automation.get("label", "")
mandateId = automation.get("mandateId", "")
featureInstanceId = automation.get("featureInstanceId", "")
- createdBy = automation.get("_createdBy", "")
+ createdBy = automation.get("sysCreatedBy", "")
logs = automation.get("executionLogs") or []
else:
automationId = getattr(automation, "id", "")
automationLabel = getattr(automation, "label", "")
mandateId = getattr(automation, "mandateId", "")
featureInstanceId = getattr(automation, "featureInstanceId", "")
- createdBy = getattr(automation, "_createdBy", "")
+ createdBy = getattr(automation, "sysCreatedBy", "")
logs = getattr(automation, "executionLogs", None) or []
mandateName = _resolveMandateLabel(mandateId)
diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py
index 3778d227..16336fae 100644
--- a/modules/routes/routeAdminRbacRules.py
+++ b/modules/routes/routeAdminRbacRules.py
@@ -1477,7 +1477,7 @@ def cleanup_duplicate_access_rules(
for sig, rules in rulesBySignature.items():
if len(rules) > 1:
# Sort by creation time (keep oldest)
- rules.sort(key=lambda r: r.get("_createdAt", 0))
+ rules.sort(key=lambda r: r.get("sysCreatedAt", 0))
keepRule = rules[0]
deleteRules = rules[1:]
diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py
index 04412752..88ec0cc6 100644
--- a/modules/routes/routeBilling.py
+++ b/modules/routes/routeBilling.py
@@ -564,7 +564,7 @@ def getTransactions(
aicoreProvider=t.get("aicoreProvider"),
aicoreModel=t.get("aicoreModel"),
createdByUserId=t.get("createdByUserId"),
- createdAt=t.get("_createdAt"),
+ createdAt=t.get("sysCreatedAt"),
mandateId=t.get("mandateId"),
mandateName=t.get("mandateName")
))
@@ -1421,7 +1421,7 @@ def _enrichTransactionRows(transactions) -> List[Dict[str, Any]]:
aicoreProvider=t.get("aicoreProvider"),
aicoreModel=t.get("aicoreModel"),
createdByUserId=t.get("createdByUserId"),
- createdAt=t.get("_createdAt")
+ createdAt=t.get("sysCreatedAt")
)
result.append(row.model_dump())
@@ -1465,7 +1465,7 @@ def _buildTransactionsList(ctx: RequestContext, targetMandateId: str) -> List[Di
aicoreProvider=t.get("aicoreProvider"),
aicoreModel=t.get("aicoreModel"),
createdByUserId=t.get("createdByUserId"),
- createdAt=t.get("_createdAt")
+ createdAt=t.get("sysCreatedAt")
)
result.append(row.model_dump())
@@ -1641,7 +1641,7 @@ def getMandateViewTransactions(
aicoreProvider=t.get("aicoreProvider"),
aicoreModel=t.get("aicoreModel"),
createdByUserId=t.get("createdByUserId"),
- createdAt=t.get("_createdAt"),
+ createdAt=t.get("sysCreatedAt"),
mandateId=t.get("mandateId"),
mandateName=t.get("mandateName")
))
@@ -1796,7 +1796,7 @@ def getUserViewStatistics(
skippedNotDebit = 0
for t in allTransactions:
- createdAt = t.get("_createdAt")
+ createdAt = t.get("sysCreatedAt")
if not createdAt:
skippedNoDate += 1
continue
@@ -1972,7 +1972,7 @@ def getUserViewTransactions(
"aicoreProvider": t.get("aicoreProvider"),
"aicoreModel": t.get("aicoreModel"),
"createdByUserId": t.get("createdByUserId"),
- "createdAt": t.get("_createdAt"),
+ "createdAt": t.get("sysCreatedAt"),
"mandateId": t.get("mandateId"),
"mandateName": t.get("mandateName"),
"userId": t.get("userId"),
@@ -2069,7 +2069,7 @@ def getUserViewTransactionsFilterValues(
"aicoreProvider": t.get("aicoreProvider"),
"aicoreModel": t.get("aicoreModel"),
"createdByUserId": t.get("createdByUserId"),
- "createdAt": t.get("_createdAt"),
+ "createdAt": t.get("sysCreatedAt"),
"mandateId": t.get("mandateId"),
"mandateName": t.get("mandateName"),
"userId": t.get("userId"),
diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py
index 5f71bb47..e95da174 100644
--- a/modules/routes/routeDataFiles.py
+++ b/modules/routes/routeDataFiles.py
@@ -266,7 +266,7 @@ def get_file_filter_values(
pass
try:
- recordFilter = {"_createdBy": managementInterface.userId}
+ recordFilter = {"sysCreatedBy": managementInterface.userId}
values = managementInterface.db.getDistinctColumnValues(
FileItem, column, crossFilterPagination, recordFilter
)
diff --git a/modules/security/rbac.py b/modules/security/rbac.py
index f1d83252..9199e73b 100644
--- a/modules/security/rbac.py
+++ b/modules/security/rbac.py
@@ -261,7 +261,7 @@ class RbacClass:
# No mandate context: load roles from ALL user's mandates.
# Required for user-owned namespaces (files, chat, automation) that
# are accessed without mandate context (e.g., /api/files/ endpoints).
- # Data isolation is still enforced by _createdBy WHERE clause.
+ # Data isolation is still enforced by sysCreatedBy WHERE clause.
allUserMandates = self.dbApp.getRecordset(
UserMandate,
recordFilter={"userId": user.id, "enabled": True}
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index f9e72ea6..539d3672 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -441,13 +441,13 @@ def _buildWorkflowHintItems(
import time as _time
now = _time.time()
- others.sort(key=lambda w: w.get("_createdAt") or w.get("startedAt") or 0, reverse=True)
+ others.sort(key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0, reverse=True)
others = others[:10]
items = []
for wf in others:
name = wf.get("name") or "(unnamed)"
- createdAt = wf.get("_createdAt") or wf.get("startedAt") or 0
+ createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0
ageSec = now - createdAt if createdAt else 0
if ageSec < 3600:
ageStr = f"{int(ageSec / 60)}m ago"
@@ -3188,7 +3188,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
allWorkflows = chatInterface.getWorkflows() or []
allWorkflows.sort(
- key=lambda w: w.get("_createdAt") or w.get("startedAt") or 0,
+ key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0,
reverse=True,
)
allWorkflows = allWorkflows[:50]
@@ -3197,7 +3197,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
for wf in allWorkflows:
wfId = wf.get("id", "")
name = wf.get("name") or "(unnamed)"
- createdAt = wf.get("_createdAt") or wf.get("startedAt") or 0
+ createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0
lastActivity = wf.get("lastActivity") or createdAt
msgs = chatInterface.getMessages(wfId) or []
@@ -3275,7 +3275,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
items.append({
"role": raw.get("role", ""),
"message": content,
- "publishedAt": raw.get("publishedAt") or raw.get("_createdAt") or 0,
+ "publishedAt": raw.get("publishedAt") or raw.get("sysCreatedAt") or 0,
})
header = f"Workflow {targetWorkflowId}: {len(allMsgs)} total messages"
diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
index 14a01557..7d85edcc 100644
--- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
+++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
@@ -124,7 +124,7 @@ class KnowledgeService:
_fileScope = _get("scope")
if _fileScope:
index.scope = _fileScope
- _fileCreatedBy = _get("_createdBy")
+ _fileCreatedBy = _get("sysCreatedBy")
if _fileCreatedBy:
index.userId = str(_fileCreatedBy)
except Exception:
diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py
index 863d7f36..239e214d 100644
--- a/modules/shared/attributeUtils.py
+++ b/modules/shared/attributeUtils.py
@@ -74,6 +74,18 @@ def getModelLabels(modelName: str, language: str = "en") -> Dict[str, str]:
}
+def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Dict[str, str]:
+ """Merge attribute labels from model MRO (base classes first, subclass overrides)."""
+ try:
+ baseIdx = modelClass.__mro__.index(BaseModel)
+ except ValueError:
+ return getModelLabels(modelClass.__name__, userLanguage)
+ merged: Dict[str, str] = {}
+ for cls in reversed(modelClass.__mro__[:baseIdx]):
+ merged.update(getModelLabels(cls.__name__, userLanguage))
+ return merged
+
+
def getModelLabel(modelName: str, language: str = "en") -> str:
"""
Get the label for a model in the specified language.
@@ -106,7 +118,7 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
attributes = []
model_name = modelClass.__name__
- labels = getModelLabels(model_name, userLanguage)
+ labels = _mergedAttributeLabels(modelClass, userLanguage)
model_label = getModelLabel(model_name, userLanguage)
# Pydantic v2 only
diff --git a/modules/shared/dbMultiTenantOptimizations.py b/modules/shared/dbMultiTenantOptimizations.py
index f3c2de98..95ad6cae 100644
--- a/modules/shared/dbMultiTenantOptimizations.py
+++ b/modules/shared/dbMultiTenantOptimizations.py
@@ -74,7 +74,7 @@ _INDEXES = [
# Invitation indexes
("Invitation", "idx_invitation_mandate", ["mandateId"]),
- ("Invitation", "idx_invitation_createdby", ["createdBy"]),
+ ("Invitation", "idx_invitation_syscreatedby", ["sysCreatedBy"]),
]
# Unique indexes (separate list)
diff --git a/modules/shared/gdprDeletion.py b/modules/shared/gdprDeletion.py
index 034b627a..99e09313 100644
--- a/modules/shared/gdprDeletion.py
+++ b/modules/shared/gdprDeletion.py
@@ -35,8 +35,8 @@ USER_COLUMNS = [
"createdBy",
"usedBy",
"revokedBy",
- "_createdBy",
- "_modifiedBy",
+ "sysCreatedBy",
+ "sysModifiedBy",
]
@@ -284,12 +284,12 @@ def _anonymizeRecords(
# Build WHERE clause for primary key
whereClause = " AND ".join([f'"{pk}" = %s' for pk in pkColumns])
- # Check if table has _modifiedAt column
+ # Check if table has sysModifiedAt column
columns = _getTableColumns(dbConnector, tableName)
- hasModifiedAt = "_modifiedAt" in columns
+ hasModifiedAt = "sysModifiedAt" in columns
if hasModifiedAt:
- query = f'UPDATE "{tableName}" SET "{columnName}" = %s, "_modifiedAt" = %s WHERE {whereClause}'
+ query = f'UPDATE "{tableName}" SET "{columnName}" = %s, "sysModifiedAt" = %s WHERE {whereClause}'
params = [anonymousValue, getUtcTimestamp()]
else:
query = f'UPDATE "{tableName}" SET "{columnName}" = %s WHERE {whereClause}'
diff --git a/modules/workflows/automation/mainWorkflow.py b/modules/workflows/automation/mainWorkflow.py
index 19473c01..dc387926 100644
--- a/modules/workflows/automation/mainWorkflow.py
+++ b/modules/workflows/automation/mainWorkflow.py
@@ -76,7 +76,7 @@ async def executeAutomation(automationId: str, automation, creatorUser: User, se
Args:
automationId: ID of automation to execute
- automation: Pre-loaded automation object (with system fields like _createdBy)
+ automation: Pre-loaded automation object (with system fields like sysCreatedBy)
creatorUser: The user who created the automation (workflow runs in this context)
services: Services instance (used for interfaceDbApp etc.)
@@ -302,10 +302,10 @@ def createAutomationEventHandler(automationId: str, eventUser):
logger.warning(f"Automation {automationId} not found or not active, skipping execution")
return
- # Get creator user ID from automation's _createdBy system field
- creatorUserId = getattr(automation, "_createdBy", None)
+ # Get creator user ID from automation's sysCreatedBy system field
+ creatorUserId = getattr(automation, "sysCreatedBy", None)
if not creatorUserId:
- logger.error(f"Automation {automationId} has no creator user (_createdBy missing)")
+ logger.error(f"Automation {automationId} has no creator user (sysCreatedBy missing)")
return
# Get creator user from database (using SysAdmin access)
diff --git a/scripts/script_db_export_migration.py b/scripts/script_db_export_migration.py
index 9fc8a910..e5961e23 100644
--- a/scripts/script_db_export_migration.py
+++ b/scripts/script_db_export_migration.py
@@ -24,7 +24,7 @@ Optionen:
Die Struktur-Datei wird automatisch als _structure.json erstellt
--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
+ --include-meta System-Metadaten (sysCreatedAt, sysModifiedAt, etc.) beibehalten
--db Nur bestimmte Datenbank(en) exportieren (komma-getrennt)
"""
@@ -245,7 +245,12 @@ def _getTableData(conn, tableName: str, includeMeta: bool = False) -> List[Dict[
# Optional: System-Metadaten entfernen
if not includeMeta:
- metaFields = ["_createdAt", "_modifiedAt", "_createdBy", "_modifiedBy"]
+ metaFields = [
+ "sysCreatedAt",
+ "sysModifiedAt",
+ "sysCreatedBy",
+ "sysModifiedBy",
+ ]
for field in metaFields:
record.pop(field, None)
@@ -789,7 +794,7 @@ Beispiele:
parser.add_argument(
"--include-meta",
- help="System-Metadaten (_createdAt, etc.) beibehalten",
+ help="System-Metadaten (sysCreatedAt, sysModifiedAt, sysCreatedBy, sysModifiedBy) beibehalten",
action="store_true"
)
diff --git a/tests/integration/rbac/test_rbac_database.py b/tests/integration/rbac/test_rbac_database.py
index 1c081953..72eb1b26 100644
--- a/tests/integration/rbac/test_rbac_database.py
+++ b/tests/integration/rbac/test_rbac_database.py
@@ -50,7 +50,6 @@ class TestRbacDatabaseFiltering:
id="test_user_all",
username="testuser",
roleLabels=["sysadmin"],
- mandateId="test_mandate_all"
)
whereClause = db.buildRbacWhereClause(permissions, user, "SomeTable")
@@ -73,13 +72,12 @@ class TestRbacDatabaseFiltering:
id="test_user_my",
username="testuser",
roleLabels=["user"],
- mandateId="test_mandate_my"
)
whereClause = db.buildRbacWhereClause(permissions, user, "SomeTable")
assert whereClause is not None
- assert whereClause["condition"] == '"_createdBy" = %s'
+ assert whereClause["condition"] == '"sysCreatedBy" = %s'
assert whereClause["values"] == ["test_user_my"]
def testBuildRbacWhereClauseGroupAccess(self, db):
@@ -93,17 +91,19 @@ class TestRbacDatabaseFiltering:
delete=AccessLevel.GROUP
)
+ mandate_id = "test_mandate_group"
user = User(
id="test_user_group",
username="testuser",
roleLabels=["admin"],
- mandateId="test_mandate_group"
)
- whereClause = db.buildRbacWhereClause(permissions, user, "SomeTable")
+ whereClause = db.buildRbacWhereClause(
+ permissions, user, "SomeTable", mandateId=mandate_id
+ )
assert whereClause is not None
- assert whereClause["condition"] == '"mandateId" = %s'
+ assert whereClause["condition"] == '("mandateId" = %s OR "mandateId" IS NULL)'
assert whereClause["values"] == ["test_mandate_group"]
def testBuildRbacWhereClauseNoAccess(self, db):
@@ -121,7 +121,6 @@ class TestRbacDatabaseFiltering:
id="test_user_none",
username="testuser",
roleLabels=["viewer"],
- mandateId="test_mandate_none"
)
whereClause = db.buildRbacWhereClause(permissions, user, "SomeTable")
@@ -145,7 +144,6 @@ class TestRbacDatabaseFiltering:
id="test_user_in_db",
username="testuser",
roleLabels=["user"],
- mandateId="test_mandate_in_db"
)
whereClause = db.buildRbacWhereClause(permissions, user, "UserInDB")
@@ -156,56 +154,84 @@ class TestRbacDatabaseFiltering:
assert whereClause["values"] == ["test_user_in_db"]
def testBuildRbacWhereClauseUserConnectionTable(self, db):
- """Test WHERE clause building for UserConnection table with GROUP access."""
- # Create test users in the same mandate for GROUP access testing
- from modules.datamodels.datamodelUam import UserInDB
- testMandateId = "test_mandate_group"
-
- # Create test users
- user1 = UserInDB(
- id="test_user1",
- username="testuser1",
- mandateId=testMandateId
- )
- user2 = UserInDB(
- id="test_user2",
- username="testuser2",
- mandateId=testMandateId
- )
-
+ """GROUP on UserConnection resolves member userIds via UserMandate (multi-tenant)."""
+ from modules.datamodels.datamodelUam import UserInDB, Mandate
+ from modules.datamodels.datamodelMembership import UserMandate
+
+ testMandateId = "rbac_test_mandate_uc"
+ user1Id = "rbac_test_user_uc1"
+ user2Id = "rbac_test_user_uc2"
+ userMandateIds = []
+
try:
- user1Data = user1.model_dump()
- user1Data["id"] = user1.id
- user2Data = user2.model_dump()
- user2Data["id"] = user2.id
- db.recordCreate(UserInDB, user1Data)
- db.recordCreate(UserInDB, user2Data)
-
+ mandate = Mandate(
+ id=testMandateId,
+ name="RBAC test mandate",
+ label="RBAC test",
+ )
+ mandatePayload = mandate.model_dump()
+ mandatePayload["id"] = mandate.id
+ db.recordCreate(Mandate, mandatePayload)
+
+ for uid, uname in (
+ (user1Id, "rbac_uc_user1"),
+ (user2Id, "rbac_uc_user2"),
+ ):
+ u = UserInDB(
+ id=uid,
+ username=uname,
+ email=f"{uid}@example.com",
+ hashedPassword="not-used",
+ )
+ payload = u.model_dump()
+ payload["id"] = u.id
+ db.recordCreate(UserInDB, payload)
+
+ for uid in (user1Id, user2Id):
+ um = UserMandate(userId=uid, mandateId=testMandateId, enabled=True)
+ umPayload = um.model_dump()
+ umPayload["id"] = um.id
+ createdUm = db.recordCreate(UserMandate, umPayload)
+ if createdUm and createdUm.get("id"):
+ userMandateIds.append(createdUm["id"])
+ else:
+ userMandateIds.append(um.id)
+
permissions = UserPermissions(
view=True,
read=AccessLevel.GROUP,
create=AccessLevel.GROUP,
update=AccessLevel.GROUP,
- delete=AccessLevel.GROUP
+ delete=AccessLevel.GROUP,
)
-
+
user = User(
- id="test_user1",
- username="testuser1",
+ id=user1Id,
+ username="rbac_uc_user1",
roleLabels=["admin"],
- mandateId=testMandateId
)
-
- whereClause = db.buildRbacWhereClause(permissions, user, "UserConnection")
-
+
+ whereClause = db.buildRbacWhereClause(
+ permissions, user, "UserConnection", mandateId=testMandateId
+ )
+
assert whereClause is not None
+ assert whereClause["condition"] != "1 = 0"
assert "userId" in whereClause["condition"]
assert "IN" in whereClause["condition"]
- assert len(whereClause["values"]) >= 2
+ assert set(whereClause["values"]) == {user1Id, user2Id}
finally:
- # Cleanup test users
+ for umId in userMandateIds:
+ try:
+ db.recordDelete(UserMandate, umId)
+ except Exception:
+ pass
+ for uid in (user1Id, user2Id):
+ try:
+ db.recordDelete(UserInDB, uid)
+ except Exception:
+ pass
try:
- db.recordDelete(UserInDB, "test_user1")
- db.recordDelete(UserInDB, "test_user2")
- except:
+ db.recordDelete(Mandate, testMandateId)
+ except Exception:
pass