fixed udb foldertree

This commit is contained in:
ValueOn AG 2026-04-12 00:29:00 +02:00
parent 3adbd1da29
commit c30c18fc71
8 changed files with 119 additions and 32 deletions

View file

@ -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")

View file

@ -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="",

View file

@ -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)",

View file

@ -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,

View file

@ -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):

View file

@ -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."""

View file

@ -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]

View file

@ -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]: