fixed udb foldertree
This commit is contained in:
parent
3adbd1da29
commit
c30c18fc71
8 changed files with 119 additions and 32 deletions
|
|
@ -1041,7 +1041,7 @@ class DatabaseConnector:
|
||||||
colType = fields.get(key, "TEXT")
|
colType = fields.get(key, "TEXT")
|
||||||
logger.debug(f"_buildPaginationClauses: filter key='{key}' val={val!r} type(val)={type(val).__name__} colType={colType}")
|
logger.debug(f"_buildPaginationClauses: filter key='{key}' val={val!r} type(val)={type(val).__name__} colType={colType}")
|
||||||
if val is None:
|
if val is None:
|
||||||
where_parts.append(f'"{key}" IS NULL')
|
where_parts.append(f'("{key}" IS NULL OR "{key}" = \'\')')
|
||||||
continue
|
continue
|
||||||
if isinstance(val, dict):
|
if isinstance(val, dict):
|
||||||
op = val.get("operator", "equals")
|
op = val.get("operator", "equals")
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class FileItem(PowerOnModel):
|
||||||
mandateId: Optional[str] = Field(
|
mandateId: Optional[str] = Field(
|
||||||
default="",
|
default="",
|
||||||
description="ID of the mandate this file belongs to",
|
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(
|
featureInstanceId: Optional[str] = Field(
|
||||||
default="",
|
default="",
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ class FeatureAccess(PowerOnModel):
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
description="FK → FeatureInstance.id (CASCADE DELETE)",
|
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(
|
enabled: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
|
|
@ -78,7 +78,7 @@ class UserMandateRole(PowerOnModel):
|
||||||
)
|
)
|
||||||
userMandateId: str = Field(
|
userMandateId: str = Field(
|
||||||
description="FK → UserMandate.id (CASCADE DELETE)",
|
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(
|
roleId: str = Field(
|
||||||
description="FK → Role.id (CASCADE DELETE)",
|
description="FK → Role.id (CASCADE DELETE)",
|
||||||
|
|
@ -99,7 +99,7 @@ class FeatureAccessRole(PowerOnModel):
|
||||||
)
|
)
|
||||||
featureAccessId: str = Field(
|
featureAccessId: str = Field(
|
||||||
description="FK → FeatureAccess.id (CASCADE DELETE)",
|
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(
|
roleId: str = Field(
|
||||||
description="FK → Role.id (CASCADE DELETE)",
|
description="FK → Role.id (CASCADE DELETE)",
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ class Role(PowerOnModel):
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
|
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(
|
featureCode: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict
|
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.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.i18nRegistry import i18nModel
|
from modules.shared.i18nRegistry import i18nModel
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -58,6 +58,23 @@ class TextMultilingual(BaseModel):
|
||||||
|
|
||||||
xx: str = Field(description="Source/default text (required)")
|
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')
|
@field_validator('xx')
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validateXxRequired(cls, v):
|
def _validateXxRequired(cls, v):
|
||||||
|
|
|
||||||
|
|
@ -950,12 +950,19 @@ class ComponentObjects:
|
||||||
if recordFilter:
|
if recordFilter:
|
||||||
filterDict.update(recordFilter)
|
filterDict.update(recordFilter)
|
||||||
return self.db.getRecordset(FileItem, recordFilter=filterDict)
|
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]:
|
def getAllFiles(self, pagination: Optional[PaginationParams] = None) -> Union[List[FileItem], PaginatedResult]:
|
||||||
"""
|
"""
|
||||||
Returns files owned by the current user (user-scoped, not RBAC-based).
|
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:
|
Args:
|
||||||
pagination: Optional pagination parameters. If None, returns all items.
|
pagination: Optional pagination parameters. If None, returns all items.
|
||||||
|
|
@ -964,7 +971,6 @@ class ComponentObjects:
|
||||||
If pagination is None: List[FileItem]
|
If pagination is None: List[FileItem]
|
||||||
If pagination is provided: PaginatedResult with items and metadata
|
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}
|
recordFilter = {"sysCreatedBy": self.userId}
|
||||||
|
|
||||||
def _convertFileItems(files):
|
def _convertFileItems(files):
|
||||||
|
|
@ -992,20 +998,39 @@ class ComponentObjects:
|
||||||
logger.warning(f"Skipping invalid file record: {str(e)}")
|
logger.warning(f"Skipping invalid file record: {str(e)}")
|
||||||
continue
|
continue
|
||||||
return fileItems
|
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:
|
if pagination is None:
|
||||||
allFiles = self._getFilesByCurrentUser()
|
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(
|
result = self.db.getRecordsetPaginated(
|
||||||
FileItem,
|
FileItem,
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
recordFilter=recordFilter
|
recordFilter=recordFilter,
|
||||||
)
|
)
|
||||||
|
|
||||||
items = _convertFileItems(result["items"])
|
items = _convertFileItems(result["items"])
|
||||||
|
|
||||||
return PaginatedResult(
|
return PaginatedResult(
|
||||||
items=items,
|
items=items,
|
||||||
totalItems=result["totalItems"],
|
totalItems=result["totalItems"],
|
||||||
|
|
@ -1255,11 +1280,45 @@ class ComponentObjects:
|
||||||
return folders[0] if folders else None
|
return folders[0] if folders else None
|
||||||
|
|
||||||
def listFolders(self, parentId: Optional[str] = None) -> List[Dict[str, Any]]:
|
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 ""}
|
recordFilter = {"sysCreatedBy": self.userId or ""}
|
||||||
if parentId is not None:
|
if parentId is not None:
|
||||||
recordFilter["parentId"] = parentId
|
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]:
|
def createFolder(self, name: str, parentId: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Create a new folder with unique name validation."""
|
"""Create a new folder with unique name validation."""
|
||||||
|
|
|
||||||
|
|
@ -408,21 +408,16 @@ def list_feature_instances(
|
||||||
context: RequestContext = Depends(getRequestContext)
|
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.
|
With X-Mandate-Id: returns instances for that mandate.
|
||||||
Supports server-side pagination, filtering, sorting, and search.
|
Without X-Mandate-Id: returns all instances the user has access to
|
||||||
|
(via FeatureAccess records). Used for FK resolution in tables.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
featureCode: Optional filter by feature code
|
featureCode: Optional filter by feature code
|
||||||
pagination: JSON-encoded PaginationParams (page, pageSize, sort, filters)
|
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:
|
try:
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
|
|
@ -437,10 +432,25 @@ def list_feature_instances(
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
featureInterface = getFeatureInterface(rootInterface.db)
|
featureInterface = getFeatureInterface(rootInterface.db)
|
||||||
|
|
||||||
instances = featureInterface.getFeatureInstancesForMandate(
|
if context.mandateId:
|
||||||
mandateId=str(context.mandateId),
|
instances = featureInterface.getFeatureInstancesForMandate(
|
||||||
featureCode=featureCode
|
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]
|
items = [inst.model_dump() for inst in instances]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,16 @@ class AttributeDefinition(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
required: bool = False
|
required: bool = False
|
||||||
default: Any = None
|
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
|
validation: Optional[Dict[str, Any]] = None
|
||||||
ui: Optional[Dict[str, Any]] = None
|
ui: Optional[Dict[str, Any]] = None
|
||||||
# New frontend metadata fields
|
|
||||||
readonly: bool = False
|
readonly: bool = False
|
||||||
editable: bool = True
|
editable: bool = True
|
||||||
visible: bool = True
|
visible: bool = True
|
||||||
order: int = 0
|
order: int = 0
|
||||||
placeholder: Optional[str] = None
|
placeholder: Optional[str] = None
|
||||||
|
fkSource: Optional[str] = None
|
||||||
|
fkDisplayField: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _getModelLabelEntry(modelName: str) -> Dict[str, Any]:
|
def _getModelLabelEntry(modelName: str) -> Dict[str, Any]:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue