From c30c18fc719ac2e26223a93aac1287a4df2c094e Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 12 Apr 2026 00:29:00 +0200
Subject: [PATCH] fixed udb foldertree
---
modules/connectors/connectorDbPostgre.py | 2 +-
modules/datamodels/datamodelFiles.py | 2 +-
modules/datamodels/datamodelMembership.py | 6 +-
modules/datamodels/datamodelRbac.py | 2 +-
modules/datamodels/datamodelUtils.py | 19 ++++-
modules/interfaces/interfaceDbManagement.py | 79 ++++++++++++++++++---
modules/routes/routeAdminFeatures.py | 36 ++++++----
modules/shared/attributeUtils.py | 5 +-
8 files changed, 119 insertions(+), 32 deletions(-)
diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py
index 2f62756a..e92d7b6f 100644
--- a/modules/connectors/connectorDbPostgre.py
+++ b/modules/connectors/connectorDbPostgre.py
@@ -1041,7 +1041,7 @@ class DatabaseConnector:
colType = fields.get(key, "TEXT")
logger.debug(f"_buildPaginationClauses: filter key='{key}' val={val!r} type(val)={type(val).__name__} colType={colType}")
if val is None:
- where_parts.append(f'"{key}" IS NULL')
+ where_parts.append(f'("{key}" IS NULL OR "{key}" = \'\')')
continue
if isinstance(val, dict):
op = val.get("operator", "equals")
diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py
index 4f843eac..8a423fe5 100644
--- a/modules/datamodels/datamodelFiles.py
+++ b/modules/datamodels/datamodelFiles.py
@@ -21,7 +21,7 @@ class FileItem(PowerOnModel):
mandateId: Optional[str] = Field(
default="",
description="ID of the mandate this file belongs to",
- json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
+ json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"},
)
featureInstanceId: Optional[str] = Field(
default="",
diff --git a/modules/datamodels/datamodelMembership.py b/modules/datamodels/datamodelMembership.py
index 0cf8468f..29fe5881 100644
--- a/modules/datamodels/datamodelMembership.py
+++ b/modules/datamodels/datamodelMembership.py
@@ -56,7 +56,7 @@ class FeatureAccess(PowerOnModel):
)
featureInstanceId: str = Field(
description="FK → FeatureInstance.id (CASCADE DELETE)",
- json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
+ json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"}
)
enabled: bool = Field(
default=True,
@@ -78,7 +78,7 @@ class UserMandateRole(PowerOnModel):
)
userMandateId: str = Field(
description="FK → UserMandate.id (CASCADE DELETE)",
- json_schema_extra={"label": "Benutzer-Mandant", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/user-mandates/", "frontend_fk_display_field": "userId"}
+ json_schema_extra={"label": "Benutzer-Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
@@ -99,7 +99,7 @@ class FeatureAccessRole(PowerOnModel):
)
featureAccessId: str = Field(
description="FK → FeatureAccess.id (CASCADE DELETE)",
- json_schema_extra={"label": "Feature-Zugang", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-access/", "frontend_fk_display_field": "userId"}
+ json_schema_extra={"label": "Feature-Zugang", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
diff --git a/modules/datamodels/datamodelRbac.py b/modules/datamodels/datamodelRbac.py
index 1dd69dae..d43b825e 100644
--- a/modules/datamodels/datamodelRbac.py
+++ b/modules/datamodels/datamodelRbac.py
@@ -62,7 +62,7 @@ class Role(PowerOnModel):
featureInstanceId: Optional[str] = Field(
default=None,
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
- json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
+ json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"}
)
featureCode: Optional[str] = Field(
default=None,
diff --git a/modules/datamodels/datamodelUtils.py b/modules/datamodels/datamodelUtils.py
index bdef6cb2..0c134ed2 100644
--- a/modules/datamodels/datamodelUtils.py
+++ b/modules/datamodels/datamodelUtils.py
@@ -5,7 +5,7 @@
import json
from typing import Any, Dict
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
import uuid
@@ -58,6 +58,23 @@ class TextMultilingual(BaseModel):
xx: str = Field(description="Source/default text (required)")
+ @model_validator(mode='before')
+ @classmethod
+ def _ensureXx(cls, data: Any) -> Any:
+ """Derive xx from existing language keys when missing (legacy DB rows)."""
+ if not isinstance(data, dict):
+ return data
+ if data.get('xx') and isinstance(data['xx'], str) and data['xx'].strip():
+ return data
+ fallback = data.get('de') or data.get('en')
+ if not fallback or not isinstance(fallback, str) or not fallback.strip():
+ for v in data.values():
+ if v and isinstance(v, str) and v.strip():
+ fallback = v
+ break
+ data['xx'] = fallback.strip() if fallback and isinstance(fallback, str) else '—'
+ return data
+
@field_validator('xx')
@classmethod
def _validateXxRequired(cls, v):
diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py
index 0774fe00..c9bafe19 100644
--- a/modules/interfaces/interfaceDbManagement.py
+++ b/modules/interfaces/interfaceDbManagement.py
@@ -950,12 +950,19 @@ class ComponentObjects:
if recordFilter:
filterDict.update(recordFilter)
return self.db.getRecordset(FileItem, recordFilter=filterDict)
+
+ def _isMandatelessFile(self, file: Dict[str, Any]) -> bool:
+ """A file has no mandate context if mandateId is empty, None, or missing."""
+ mid = file.get("mandateId")
+ return not mid or (isinstance(mid, str) and not mid.strip())
def getAllFiles(self, pagination: Optional[PaginationParams] = None) -> Union[List[FileItem], PaginatedResult]:
"""
Returns files owned by the current user (user-scoped, not RBAC-based).
- Every user (including SysAdmin) only sees their own files.
- Supports optional pagination, sorting, and filtering via database-level queries.
+
+ Mandate scoping:
+ - With mandateId: files of that mandate + files without mandate reference
+ - Without mandateId: only files without mandate reference
Args:
pagination: Optional pagination parameters. If None, returns all items.
@@ -964,7 +971,6 @@ class ComponentObjects:
If pagination is None: List[FileItem]
If pagination is provided: PaginatedResult with items and metadata
"""
- # User-scoping filter: every user only sees their own files (bypasses RBAC SysAdmin override)
recordFilter = {"sysCreatedBy": self.userId}
def _convertFileItems(files):
@@ -992,20 +998,39 @@ class ComponentObjects:
logger.warning(f"Skipping invalid file record: {str(e)}")
continue
return fileItems
+
+ def _mandateFilter(files):
+ """Apply mandate scoping:
+ - With mandate context: files of that mandate + mandateless files
+ - Without mandate context: ALL files (user already scoped by sysCreatedBy)"""
+ if self.mandateId:
+ return [f for f in files
+ if self._isMandatelessFile(f)
+ or f.get("mandateId") == self.mandateId]
+ return files
if pagination is None:
allFiles = self._getFilesByCurrentUser()
- return _convertFileItems(allFiles)
+ return _convertFileItems(_mandateFilter(allFiles))
- # Database-level pagination: filtering, sorting, and LIMIT/OFFSET happen in SQL
+ # Mandate scoping cannot be expressed as a single recordFilter (OR logic),
+ # so we get the IDs of allowed files first, then use DB pagination on those.
+ allFiles = self._getFilesByCurrentUser()
+ allowedIds = [f.get("id") for f in _mandateFilter(allFiles) if f.get("id")]
+
+ if not allowedIds:
+ return PaginatedResult(items=[], totalItems=0, totalPages=0)
+
+ # DB-level pagination with ID whitelist + original user filter
+ recordFilter["id"] = allowedIds
result = self.db.getRecordsetPaginated(
FileItem,
pagination=pagination,
- recordFilter=recordFilter
+ recordFilter=recordFilter,
)
-
+
items = _convertFileItems(result["items"])
-
+
return PaginatedResult(
items=items,
totalItems=result["totalItems"],
@@ -1255,11 +1280,45 @@ class ComponentObjects:
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."""
+ """List folders for current user, optionally filtered by parentId.
+ Each folder is enriched with ``fileCount`` (number of direct files)."""
recordFilter = {"sysCreatedBy": self.userId or ""}
if parentId is not None:
recordFilter["parentId"] = parentId
- return self.db.getRecordset(FileFolder, recordFilter=recordFilter)
+ folders = self.db.getRecordset(FileFolder, recordFilter=recordFilter)
+
+ if not folders:
+ return folders
+
+ folderIds = [f["id"] for f in folders if f.get("id")]
+ fileCounts: Dict[str, int] = {}
+ try:
+ mandateClause = ""
+ mandateValues: list = []
+ if self.mandateId:
+ mandateClause = (
+ ' AND ("mandateId" = %s OR "mandateId" IS NULL OR "mandateId" = \'\')'
+ )
+ mandateValues = [self.mandateId]
+
+ with self.db.connection.cursor() as cursor:
+ cursor.execute(
+ 'SELECT "folderId", COUNT(*) AS cnt '
+ 'FROM "FileItem" '
+ 'WHERE "sysCreatedBy" = %s AND "folderId" = ANY(%s)'
+ + mandateClause +
+ ' GROUP BY "folderId"',
+ [self.userId or "", folderIds] + mandateValues,
+ )
+ for row in cursor.fetchall():
+ fileCounts[row["folderId"]] = row["cnt"]
+ except Exception as e:
+ logger.warning(f"Could not count files per folder: {e}")
+
+ for folder in folders:
+ folder["fileCount"] = fileCounts.get(folder.get("id", ""), 0)
+
+ return folders
def createFolder(self, name: str, parentId: Optional[str] = None) -> Dict[str, Any]:
"""Create a new folder with unique name validation."""
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index 37118426..519efe11 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -408,21 +408,16 @@ def list_feature_instances(
context: RequestContext = Depends(getRequestContext)
):
"""
- List feature instances for the current mandate.
+ List feature instances.
- Returns instances the user has access to within the selected mandate.
- Supports server-side pagination, filtering, sorting, and search.
+ With X-Mandate-Id: returns instances for that mandate.
+ Without X-Mandate-Id: returns all instances the user has access to
+ (via FeatureAccess records). Used for FK resolution in tables.
Args:
featureCode: Optional filter by feature code
pagination: JSON-encoded PaginationParams (page, pageSize, sort, filters)
"""
- if not context.mandateId:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=routeApiMsg("X-Mandate-Id header is required")
- )
-
try:
paginationParams = None
if pagination:
@@ -437,10 +432,25 @@ def list_feature_instances(
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
- instances = featureInterface.getFeatureInstancesForMandate(
- mandateId=str(context.mandateId),
- featureCode=featureCode
- )
+ if context.mandateId:
+ instances = featureInterface.getFeatureInstancesForMandate(
+ mandateId=str(context.mandateId),
+ featureCode=featureCode
+ )
+ else:
+ featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
+ seen = set()
+ instances = []
+ for fa in featureAccesses:
+ instId = str(fa.featureInstanceId)
+ if instId in seen:
+ continue
+ seen.add(instId)
+ inst = featureInterface.getFeatureInstance(instId)
+ if inst and inst.enabled:
+ if featureCode and inst.featureCode != featureCode:
+ continue
+ instances.append(inst)
items = [inst.model_dump() for inst in instances]
diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py
index 898ca85f..5b7486e9 100644
--- a/modules/shared/attributeUtils.py
+++ b/modules/shared/attributeUtils.py
@@ -25,15 +25,16 @@ class AttributeDefinition(BaseModel):
description: Optional[str] = None
required: bool = False
default: Any = None
- options: Optional[Union[str, List[Any]]] = None # Can be a string reference (e.g., "user.role") or a list of options
+ options: Optional[Union[str, List[Any]]] = None
validation: Optional[Dict[str, Any]] = None
ui: Optional[Dict[str, Any]] = None
- # New frontend metadata fields
readonly: bool = False
editable: bool = True
visible: bool = True
order: int = 0
placeholder: Optional[str] = None
+ fkSource: Optional[str] = None
+ fkDisplayField: Optional[str] = None
def _getModelLabelEntry(modelName: str) -> Dict[str, Any]: