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")
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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="",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Reference in a new issue