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