From 72d3175f49928b242f45c81b4eafab0dfefd7171 Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 29 Apr 2026 18:16:02 +0200 Subject: [PATCH] Gruppierung im Formgenerator fertig --- modules/datamodels/datamodelPagination.py | 84 +++++++++++-- modules/interfaces/interfaceDbApp.py | 53 +++++++++ modules/routes/routeDataConnections.py | 75 ++++++------ modules/routes/routeDataFiles.py | 27 +++-- modules/routes/routeDataMandates.py | 76 ++++++------ modules/routes/routeDataPrompts.py | 59 +++++---- modules/routes/routeDataUsers.py | 58 +++++---- modules/routes/routeHelpers.py | 139 ++++++++++++++++++++++ 8 files changed, 424 insertions(+), 147 deletions(-) diff --git a/modules/datamodels/datamodelPagination.py b/modules/datamodels/datamodelPagination.py index 2719327b..7bda7717 100644 --- a/modules/datamodels/datamodelPagination.py +++ b/modules/datamodels/datamodelPagination.py @@ -13,6 +13,42 @@ import math T = TypeVar('T') +# --------------------------------------------------------------------------- +# Table Grouping models +# --------------------------------------------------------------------------- + +class TableGroupNode(BaseModel): + """ + A single node in a user-defined group tree for a FormGeneratorTable. + + Items belong to exactly one group (no multi-membership). + Groups can be nested to arbitrary depth via subGroups. + """ + id: str + name: str + itemIds: List[str] = Field(default_factory=list) + subGroups: List['TableGroupNode'] = Field(default_factory=list) + order: int = 0 + isExpanded: bool = True + +TableGroupNode.model_rebuild() + + +class TableGrouping(BaseModel): + """ + Persisted grouping configuration for one (user, contextKey) pair. + Stored in table_groupings in poweron_app (auto-created). + + contextKey convention: API path without /api/ prefix and without trailing slash. + Examples: "connections", "prompts", "admin/users", "trustee/{instanceId}/documents" + """ + id: str + userId: str + contextKey: str + rootGroups: List[TableGroupNode] = Field(default_factory=list) + updatedAt: Optional[float] = None + + class SortField(BaseModel): """ Single sort field configuration. @@ -24,12 +60,23 @@ class SortField(BaseModel): class PaginationParams(BaseModel): """ Complete pagination state including page, sorting, and filters. + + Grouping extensions (both optional — omit when not using grouping): + groupId — Scope the request to items belonging to this group. + The backend resolves it to an itemIds IN-filter before + applying normal pagination/search/filter logic. + Also applied for mode=ids and mode=filterValues so that + bulk-select and filter-dropdowns respect the group scope. + saveGroupTree — If present the backend persists this tree for the current + (user, contextKey) pair *before* fetching, then returns + the confirmed tree in the response groupTree field. + Omit on every request that does not change the group tree. """ page: int = Field(ge=1, description="Current page number (1-based)") pageSize: int = Field(ge=1, le=1000, description="Number of items per page") sort: List[SortField] = Field(default_factory=list, description="List of sort fields in priority order") filters: Optional[Dict[str, Any]] = Field( - default=None, + default=None, description="""Filter criteria dictionary. Supports: - General search: {"search": "text"} - searches across all text fields (case-insensitive) - Field-specific filters: @@ -38,6 +85,14 @@ class PaginationParams(BaseModel): - Supported operators: equals/eq, contains, startsWith, endsWith, gt, gte, lt, lte, in, notIn - Multiple filters are combined with AND logic""" ) + groupId: Optional[str] = Field( + default=None, + description="Scope request to items of this group (resolved server-side to itemIds IN-filter)", + ) + saveGroupTree: Optional[List[Dict[str, Any]]] = Field( + default=None, + description="If set, persist this group tree before fetching (optimistic save)", + ) class PaginationRequest(BaseModel): @@ -74,10 +129,19 @@ class PaginationMetadata(BaseModel): class PaginatedResponse(BaseModel, Generic[T]): """ Response containing paginated data and metadata. + + groupTree is included when the endpoint supports table grouping and the + current user has a saved group tree for the requested contextKey. + It is None when grouping is not configured for the endpoint or the user + has not created any groups yet. Frontend must treat None as an empty tree. """ items: List[T] = Field(..., description="Array of items for current page") pagination: Optional[PaginationMetadata] = Field(..., description="Pagination metadata (None if pagination not applied)") - + groupTree: Optional[List[TableGroupNode]] = Field( + default=None, + description="Current group tree for this (user, contextKey) pair — None if no grouping configured", + ) + model_config = ConfigDict(arbitrary_types_allowed=True) @@ -85,29 +149,33 @@ def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any] """ Normalize pagination dictionary to handle frontend variations. Moves top-level "search" field into filters if present. - + Grouping fields (groupId, saveGroupTree) are passed through as-is. + Args: pagination_dict: Raw pagination dictionary from frontend - + Returns: Normalized pagination dictionary ready for PaginationParams parsing """ if not pagination_dict: return pagination_dict - + # Create a copy to avoid modifying the original normalized = dict(pagination_dict) - + # Ensure required fields have sensible defaults if "page" not in normalized: normalized["page"] = 1 if "pageSize" not in normalized: normalized["pageSize"] = 25 - + # Move top-level "search" into filters if present if "search" in normalized: if "filters" not in normalized or normalized["filters"] is None: normalized["filters"] = {} normalized["filters"]["search"] = normalized.pop("search") - + + # groupId / saveGroupTree are valid PaginationParams fields — pass through unchanged. + # No transformation needed; Pydantic will validate them. + return normalized diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index e7c7e6be..6f1d9487 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -4027,6 +4027,59 @@ class AppObjects: logger.error(f"Error deleting role {roleId}: {str(e)}") raise + # ------------------------------------------------------------------------- + # Table Grouping (user-defined groups for FormGeneratorTable instances) + # ------------------------------------------------------------------------- + + def getTableGrouping(self, contextKey: str): + """ + Load the group tree for the current user and the given contextKey. + + Returns a TableGrouping instance or None if no grouping has been saved yet. + contextKey identifies the table instance, e.g. "connections", "prompts", + "admin/users", "trustee/{instanceId}/documents". + """ + from modules.datamodels.datamodelPagination import TableGrouping + try: + records = self.db.getRecordset( + TableGrouping, + recordFilter={"userId": str(self.userId), "contextKey": contextKey}, + ) + if not records: + return None + row = records[0] + return TableGrouping.model_validate(row) if isinstance(row, dict) else row + except Exception as e: + logger.error(f"getTableGrouping failed for user={self.userId} key={contextKey}: {e}") + return None + + def upsertTableGrouping(self, contextKey: str, rootGroups: list): + """ + Create or replace the group tree for the current user and contextKey. + + rootGroups is a list of TableGroupNode-compatible dicts (the full tree). + Returns the saved TableGrouping instance. + """ + from modules.datamodels.datamodelPagination import TableGrouping + from modules.shared.timeUtils import getUtcTimestamp + try: + existing = self.getTableGrouping(contextKey) + data = { + "id": existing.id if existing else str(uuid.uuid4()), + "userId": str(self.userId), + "contextKey": contextKey, + "rootGroups": rootGroups, + "updatedAt": getUtcTimestamp(), + } + if existing: + self.db.recordModify(TableGrouping, existing.id, data) + else: + self.db.recordCreate(TableGrouping, data) + return TableGrouping.model_validate(data) + except Exception as e: + logger.error(f"upsertTableGrouping failed for user={self.userId} key={contextKey}: {e}") + raise + # Public Methods diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 51549d6a..124d2fb4 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -152,10 +152,28 @@ async def get_connections( - GET /api/connections/?mode=filterValues&column=status - GET /api/connections/?mode=ids """ - from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels + from modules.routes.routeHelpers import ( + handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels, + handleGroupingInRequest, applyGroupScopeFilter, + ) + + CONTEXT_KEY = "connections" + + # Parse pagination params early — needed for grouping in all modes + paginationParams = None + if pagination: + try: + paginationDict = json.loads(pagination) + if paginationDict: + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") + + interface = getInterface(currentUser) + groupCtx = handleGroupingInRequest(paginationParams, interface, CONTEXT_KEY) def _buildEnhancedItems(): - interface = getInterface(currentUser) connections = interface.getUserConnections(currentUser.id) items = [] for connection in connections: @@ -182,6 +200,7 @@ async def get_connections( try: items = _buildEnhancedItems() enrichRowsWithFkLabels(items, UserConnection) + items = applyGroupScopeFilter(items, groupCtx.itemIds) return handleFilterValuesInMemory(items, column, pagination) except Exception as e: logger.error(f"Error getting filter values for connections: {str(e)}") @@ -189,63 +208,40 @@ async def get_connections( if mode == "ids": try: - return handleIdsInMemory(_buildEnhancedItems(), pagination) + items = applyGroupScopeFilter(_buildEnhancedItems(), groupCtx.itemIds) + return handleIdsInMemory(items, pagination) except Exception as e: logger.error(f"Error getting IDs for connections: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) try: - interface = getInterface(currentUser) - # NOTE: Cannot use db.getRecordsetPaginated() here because each connection # is enriched with computed tokenStatus/tokenExpiresAt (requires per-row DB lookup). # Token refresh also may trigger re-fetch. Connections per user are typically < 10, # so in-memory pagination is acceptable. - - # Parse pagination parameter - paginationParams = None - if pagination: - try: - paginationDict = json.loads(pagination) - if paginationDict: - # Normalize pagination dict (handles top-level "search" field) - paginationDict = normalize_pagination_dict(paginationDict) - paginationParams = PaginationParams(**paginationDict) - except (json.JSONDecodeError, ValueError) as e: - raise HTTPException( - status_code=400, - detail=f"Invalid pagination parameter: {str(e)}" - ) - + # SECURITY FIX: All users (including admins) can only see their own connections - # This prevents admin from seeing other users' connections and causing confusion connections = interface.getUserConnections(currentUser.id) - + # Perform silent token refresh for expired OAuth connections try: refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id) if refresh_result.get("refreshed", 0) > 0: logger.info(f"Silently refreshed {refresh_result['refreshed']} tokens for user {currentUser.id}") - # Re-fetch connections to get updated token status connections = interface.getUserConnections(currentUser.id) except Exception as e: logger.warning(f"Silent token refresh failed for user {currentUser.id}: {str(e)}") - # Continue with original connections even if refresh fails - - # Enhance each connection with token status information and convert to dict + enhanced_connections_dict = [] for connection in connections: - # Get token status for this connection tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id) - - # Convert to dict for filtering/sorting connection_dict = { "id": connection.id, "userId": connection.userId, "authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority), "externalId": connection.externalId, "externalUsername": connection.externalUsername or "", - "externalEmail": connection.externalEmail, # Keep None instead of converting to empty string + "externalEmail": connection.externalEmail, "status": connection.status.value if hasattr(connection.status, 'value') else str(connection.status), "connectedAt": connection.connectedAt, "lastChecked": connection.lastChecked, @@ -254,24 +250,26 @@ async def get_connections( "tokenExpiresAt": tokenExpiresAt } enhanced_connections_dict.append(connection_dict) - + enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection) + enhanced_connections_dict = applyGroupScopeFilter(enhanced_connections_dict, groupCtx.itemIds) if paginationParams is None: return { "items": enhanced_connections_dict, "pagination": None, + "groupTree": groupCtx.groupTree, } - + # Apply filtering if provided if paginationParams.filters: component_interface = ComponentObjects() component_interface.setUserContext(currentUser) enhanced_connections_dict = component_interface._applyFilters( - enhanced_connections_dict, + enhanced_connections_dict, paginationParams.filters ) - + # Apply sorting if provided if paginationParams.sort: component_interface = ComponentObjects() @@ -280,14 +278,14 @@ async def get_connections( enhanced_connections_dict, paginationParams.sort ) - + totalItems = len(enhanced_connections_dict) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 - + startIdx = (paginationParams.page - 1) * paginationParams.pageSize endIdx = startIdx + paginationParams.pageSize paged_connections = enhanced_connections_dict[startIdx:endIdx] - + return { "items": paged_connections, "pagination": PaginationMetadata( @@ -298,6 +296,7 @@ async def get_connections( sort=paginationParams.sort, filters=paginationParams.filters ).model_dump(), + "groupTree": groupCtx.groupTree, } except HTTPException: diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 3abccdc4..8168d8d2 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -279,7 +279,6 @@ def get_files( try: paginationDict = json.loads(pagination) if paginationDict: - # Normalize pagination dict (handles top-level "search" field) paginationDict = normalize_pagination_dict(paginationDict) paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: @@ -287,25 +286,33 @@ def get_files( status_code=400, detail=f"Invalid pagination parameter: {str(e)}" ) - + from modules.routes.routeHelpers import ( handleIdsMode, handleFilterValuesInMemory, + handleGroupingInRequest, applyGroupScopeFilter, ) + import modules.interfaces.interfaceDbApp as _appIface managementInterface = interfaceDbManagement.getInterface( currentUser, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None ) + appInterface = _appIface.getInterface(currentUser) + groupCtx = handleGroupingInRequest(paginationParams, appInterface, "files/list") + + def _filesToDicts(fileItems): + return [f.model_dump() if hasattr(f, "model_dump") else (dict(f) if not isinstance(f, dict) else f) for f in fileItems] if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") allFiles = managementInterface.getAllFiles() items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else []) - itemDicts = [f.model_dump() if hasattr(f, "model_dump") else (dict(f) if not isinstance(f, dict) else f) for f in items] + itemDicts = _filesToDicts(items) enrichRowsWithFkLabels(itemDicts, FileItem) + itemDicts = applyGroupScopeFilter(itemDicts, groupCtx.itemIds) return handleFilterValuesInMemory(itemDicts, column, pagination) if mode == "ids": @@ -315,10 +322,6 @@ def get_files( recordFilter = None if paginationParams and paginationParams.filters and "folderId" in paginationParams.filters: fVal = paginationParams.filters.get("folderId") - # For a concrete folderId we use recordFilter (exact equality). - # For null / empty (= "root") we keep it in pagination.filters so the - # connector applies `IS NULL OR = ''` – files predating the folderId - # fix were stored with an empty string instead of NULL. if fVal is None or (isinstance(fVal, str) and fVal.strip() == ""): paginationParams.filters["folderId"] = None else: @@ -327,11 +330,8 @@ def get_files( result = managementInterface.getAllFiles(pagination=paginationParams, recordFilter=recordFilter) - def _filesToDicts(items): - return [f.model_dump() if hasattr(f, "model_dump") else (dict(f) if not isinstance(f, dict) else f) for f in items] - if paginationParams: - enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem) + enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem), groupCtx.itemIds) return { "items": enriched, "pagination": PaginationMetadata( @@ -342,11 +342,12 @@ def get_files( sort=paginationParams.sort, filters=paginationParams.filters ).model_dump(), + "groupTree": groupCtx.groupTree, } else: items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result]) - enriched = enrichRowsWithFkLabels(_filesToDicts(items), FileItem) - return {"items": enriched, "pagination": None} + enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(items), FileItem), groupCtx.itemIds) + return {"items": enriched, "pagination": None, "groupTree": groupCtx.groupTree} except HTTPException: raise except Exception as e: diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index ef058ed9..47eaee02 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -112,8 +112,8 @@ def get_mandates( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required") ) - - # Parse pagination parameter + + # Parse pagination parameter early — needed for grouping in all modes paginationParams = None if pagination: try: @@ -126,14 +126,24 @@ def get_mandates( status_code=400, detail=f"Invalid pagination parameter: {str(e)}" ) - + from modules.routes.routeHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, handleFilterValuesMode, handleIdsMode, parseCrossFilterPagination, + handleGroupingInRequest, applyGroupScopeFilter, ) appInterface = interfaceDbApp.getRootInterface() + groupCtx = handleGroupingInRequest(paginationParams, appInterface, "mandates") + + def _mandateItemsForAdmin(): + items = [] + for mid in adminMandateIds: + m = appInterface.getMandate(mid) + if m and getattr(m, "enabled", True): + items.append(m.model_dump() if hasattr(m, 'model_dump') else m if isinstance(m, dict) else vars(m)) + return items if mode == "filterValues": if not column: @@ -144,54 +154,42 @@ def get_mandates( values = appInterface.db.getDistinctColumnValues(Mandate, column, crossPagination) return JSONResponse(content=sorted(values, key=lambda v: str(v).lower())) else: - mandateItems = [] - for mid in adminMandateIds: - m = appInterface.getMandate(mid) - if m and getattr(m, "enabled", True): - mandateItems.append(m.model_dump() if hasattr(m, 'model_dump') else m if isinstance(m, dict) else vars(m)) + mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds) return handleFilterValuesInMemory(mandateItems, column, pagination) if mode == "ids": if isPlatformAdmin: return handleIdsMode(appInterface.db, Mandate, pagination) else: - mandateItems = [] - for mid in adminMandateIds: - m = appInterface.getMandate(mid) - if m and getattr(m, "enabled", True): - mandateItems.append(m.model_dump() if hasattr(m, 'model_dump') else m if isinstance(m, dict) else vars(m)) + mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds) return handleIdsInMemory(mandateItems, pagination) if isPlatformAdmin: result = appInterface.getAllMandates(pagination=paginationParams) - else: - allMandates = [] - for mandateId in adminMandateIds: - mandate = appInterface.getMandate(mandateId) - if mandate and getattr(mandate, "enabled", True): - mandateDict = mandate if isinstance(mandate, dict) else mandate.model_dump() if hasattr(mandate, 'model_dump') else vars(mandate) - allMandates.append(mandateDict) - result = allMandates - paginationParams = None - - if paginationParams and hasattr(result, 'items'): - return PaginatedResponse( - items=result.items, - pagination=PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=result.totalItems, - totalPages=result.totalPages, - sort=paginationParams.sort, - filters=paginationParams.filters + items = result.items if hasattr(result, 'items') else (result if isinstance(result, list) else []) + items = applyGroupScopeFilter( + [i.model_dump() if hasattr(i, 'model_dump') else (i if isinstance(i, dict) else vars(i)) for i in items], + groupCtx.itemIds, + ) + if paginationParams and hasattr(result, 'items'): + return PaginatedResponse( + items=items, + pagination=PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=result.totalItems, + totalPages=result.totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters + ), + groupTree=groupCtx.groupTree, ) - ) + else: + return PaginatedResponse(items=items, pagination=None, groupTree=groupCtx.groupTree) else: - items = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else result) - return PaginatedResponse( - items=items, - pagination=None - ) + mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds) + return PaginatedResponse(items=mandateItems, pagination=None, groupTree=groupCtx.groupTree) + except HTTPException: raise except Exception as e: diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index ee99b912..84559ebb 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -44,27 +44,15 @@ def get_prompts( - filterValues: distinct values for a column (cross-filtered) - ids: all IDs matching current filters """ - from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels + from modules.routes.routeHelpers import ( + handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels, + handleGroupingInRequest, applyGroupScopeFilter, + ) + from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - def _promptsToEnrichedDicts(promptItems): - dicts = [r.model_dump() if hasattr(r, 'model_dump') else (dict(r) if not isinstance(r, dict) else r) for r in promptItems] - enrichRowsWithFkLabels(dicts, Prompt) - return dicts - - if mode == "filterValues": - if not column: - raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - managementInterface = interfaceDbManagement.getInterface(currentUser) - result = managementInterface.getAllPrompts(pagination=None) - items = _promptsToEnrichedDicts(result) - return handleFilterValuesInMemory(items, column, pagination) - - if mode == "ids": - managementInterface = interfaceDbManagement.getInterface(currentUser) - result = managementInterface.getAllPrompts(pagination=None) - items = _promptsToEnrichedDicts(result) - return handleIdsInMemory(items, pagination) + CONTEXT_KEY = "prompts" + # Parse pagination params early — needed for grouping in all modes paginationParams = None if pagination: try: @@ -74,12 +62,35 @@ def get_prompts( paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") - + + appInterface = getAppInterface(currentUser) + groupCtx = handleGroupingInRequest(paginationParams, appInterface, CONTEXT_KEY) + + def _promptsToEnrichedDicts(promptItems): + dicts = [r.model_dump() if hasattr(r, 'model_dump') else (dict(r) if not isinstance(r, dict) else r) for r in promptItems] + enrichRowsWithFkLabels(dicts, Prompt) + return dicts + managementInterface = interfaceDbManagement.getInterface(currentUser) + + if mode == "filterValues": + if not column: + raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") + result = managementInterface.getAllPrompts(pagination=None) + items = _promptsToEnrichedDicts(result) + items = applyGroupScopeFilter(items, groupCtx.itemIds) + return handleFilterValuesInMemory(items, column, pagination) + + if mode == "ids": + result = managementInterface.getAllPrompts(pagination=None) + items = _promptsToEnrichedDicts(result) + items = applyGroupScopeFilter(items, groupCtx.itemIds) + return handleIdsInMemory(items, pagination) + result = managementInterface.getAllPrompts(pagination=paginationParams) - + if paginationParams: - items = _promptsToEnrichedDicts(result.items) + items = applyGroupScopeFilter(_promptsToEnrichedDicts(result.items), groupCtx.itemIds) return { "items": items, "pagination": PaginationMetadata( @@ -90,12 +101,14 @@ def get_prompts( sort=paginationParams.sort, filters=paginationParams.filters ).model_dump(), + "groupTree": groupCtx.groupTree, } else: - items = _promptsToEnrichedDicts(result) + items = applyGroupScopeFilter(_promptsToEnrichedDicts(result), groupCtx.itemIds) return { "items": items, "pagination": None, + "groupTree": groupCtx.groupTree, } diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index 6d72b763..25d20c39 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -208,6 +208,21 @@ def get_users( - GET /api/users/ (no pagination - returns all users in mandate) - GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]} """ + # Parse pagination early — needed for grouping in all modes + _paginationParams = None + if pagination: + try: + _pd = json.loads(pagination) + if _pd: + _pd = normalize_pagination_dict(_pd) + _paginationParams = PaginationParams(**_pd) + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") + + from modules.routes.routeHelpers import handleGroupingInRequest as _handleGrouping, applyGroupScopeFilter as _applyGroupScope + _appInterfaceForGrouping = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId) + _groupCtx = _handleGrouping(_paginationParams, _appInterfaceForGrouping, "users") + if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") @@ -217,27 +232,15 @@ def get_users( return _getUserFilterOrIds(context, pagination, idsMode=True) try: - paginationParams = None - if pagination: - try: - paginationDict = json.loads(pagination) - if paginationDict: - paginationDict = normalize_pagination_dict(paginationDict) - paginationParams = PaginationParams(**paginationDict) - except (json.JSONDecodeError, ValueError) as e: - raise HTTPException( - status_code=400, - detail=f"Invalid pagination parameter: {str(e)}" - ) - - appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId) + paginationParams = _paginationParams + appInterface = _appInterfaceForGrouping if context.mandateId: # Get users for specific mandate using getUsersByMandate result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams) - + if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User) + enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds) return { "items": enriched, "pagination": PaginationMetadata( @@ -248,17 +251,18 @@ def get_users( sort=paginationParams.sort, filters=paginationParams.filters ).model_dump(), + "groupTree": _groupCtx.groupTree, } else: users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else [] - enriched = enrichRowsWithFkLabels(_usersToDicts(users), User) - return {"items": enriched, "pagination": None} + enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(users), User), _groupCtx.itemIds) + return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree} elif context.isPlatformAdmin: # PlatformAdmin without mandateId — DB-level pagination via interface result = appInterface.getAllUsers(paginationParams) - + if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User) + enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds) return { "items": enriched, "pagination": PaginationMetadata( @@ -269,11 +273,12 @@ def get_users( sort=paginationParams.sort, filters=paginationParams.filters ).model_dump(), + "groupTree": _groupCtx.groupTree, } else: users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else []) - enriched = enrichRowsWithFkLabels(_usersToDicts(users), User) - return {"items": enriched, "pagination": None} + enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(users), User), _groupCtx.itemIds) + return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree} else: # Non-SysAdmin without mandateId: aggregate users across all admin mandates rootInterface = getRootInterface() @@ -313,16 +318,16 @@ def get_users( ] from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper - filteredUsers = _applyFiltersAndSortHelper(allUsers, paginationParams) + filteredUsers = _applyGroupScope(_applyFiltersAndSortHelper(allUsers, paginationParams), _groupCtx.itemIds) enriched = enrichRowsWithFkLabels(filteredUsers, User) - + if paginationParams: import math totalItems = len(enriched) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 startIdx = (paginationParams.page - 1) * paginationParams.pageSize endIdx = startIdx + paginationParams.pageSize - + return { "items": enriched[startIdx:endIdx], "pagination": PaginationMetadata( @@ -333,9 +338,10 @@ def get_users( sort=paginationParams.sort, filters=paginationParams.filters ).model_dump(), + "groupTree": _groupCtx.groupTree, } else: - return {"items": enriched, "pagination": None} + return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree} except HTTPException: raise except Exception as e: diff --git a/modules/routes/routeHelpers.py b/modules/routes/routeHelpers.py index 37bfa3b2..0f0b8ea7 100644 --- a/modules/routes/routeHelpers.py +++ b/modules/routes/routeHelpers.py @@ -701,3 +701,142 @@ def paginateInMemory( offset = (paginationParams.page - 1) * paginationParams.pageSize pageItems = items[offset:offset + paginationParams.pageSize] return pageItems, totalItems + + +# --------------------------------------------------------------------------- +# Table Grouping helpers +# --------------------------------------------------------------------------- + +from dataclasses import dataclass, field as dc_field + + +@dataclass +class GroupingContext: + """ + Result of handleGroupingInRequest. + Carries the group tree for the response and the resolved item-ID set for + group-scope filtering (None = no active group scope). + """ + groupTree: Optional[list] # List[TableGroupNode] serialised as dicts — for response + itemIds: Optional[set] # Set[str] when groupId was set, else None + + +def _collectItemIds(nodes: list, groupId: str) -> Optional[set]: + """ + Recursively search *nodes* for a node whose id == groupId and collect + all itemIds from it and all its descendant subGroups. + Returns None if the group is not found. + """ + for node in nodes: + nodeId = node.get("id") if isinstance(node, dict) else getattr(node, "id", None) + if nodeId == groupId: + ids: set = set() + _collectAllIds(node, ids) + return ids + subGroups = node.get("subGroups", []) if isinstance(node, dict) else getattr(node, "subGroups", []) + result = _collectItemIds(subGroups, groupId) + if result is not None: + return result + return None + + +def _collectAllIds(node, ids: set) -> None: + """Collect itemIds from a node and all its descendants into ids.""" + nodeItemIds = node.get("itemIds", []) if isinstance(node, dict) else getattr(node, "itemIds", []) + for iid in nodeItemIds: + ids.add(str(iid)) + subGroups = node.get("subGroups", []) if isinstance(node, dict) else getattr(node, "subGroups", []) + for child in subGroups: + _collectAllIds(child, ids) + + +def handleGroupingInRequest( + paginationParams: Optional[PaginationParams], + interface, + contextKey: str, +) -> GroupingContext: + """ + Central grouping handler — call at the start of every list route that + supports table grouping. + + Steps (in order): + 1. If paginationParams.saveGroupTree is set: + persist the new tree via interface.upsertTableGrouping, then clear + saveGroupTree from paginationParams so it is not treated as a filter. + 2. Load the current group tree from the DB (used in step 3 and response). + 3. If paginationParams.groupId is set: + resolve it to a Set[str] of itemIds (including all sub-groups), + then clear groupId from paginationParams so it is not treated as a + normal filter field. + 4. Return a GroupingContext with groupTree (for the response) and itemIds + (for applyGroupScopeFilter). + + The caller does NOT need to handle any grouping logic itself — just call + applyGroupScopeFilter(items, groupCtx.itemIds) and embed groupCtx.groupTree + in the response dict. + """ + from modules.datamodels.datamodelPagination import TableGroupNode + + groupTree = None + itemIds = None + + if paginationParams is None: + try: + existing = interface.getTableGrouping(contextKey) + if existing: + groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in existing.rootGroups] + except Exception as e: + logger.warning(f"handleGroupingInRequest: getTableGrouping failed: {e}") + return GroupingContext(groupTree=groupTree, itemIds=None) + + # Step 1: persist saveGroupTree if present + if paginationParams.saveGroupTree is not None: + try: + saved = interface.upsertTableGrouping(contextKey, paginationParams.saveGroupTree) + groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in saved.rootGroups] + except Exception as e: + logger.error(f"handleGroupingInRequest: upsertTableGrouping failed: {e}") + paginationParams.saveGroupTree = None + + # Step 2: load current tree (only if not already set from save above) + if groupTree is None: + try: + existing = interface.getTableGrouping(contextKey) + if existing: + groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in existing.rootGroups] + except Exception as e: + logger.warning(f"handleGroupingInRequest: getTableGrouping failed: {e}") + + # Step 3: resolve groupId to itemIds set + if paginationParams.groupId is not None: + targetGroupId = paginationParams.groupId + paginationParams.groupId = None # remove so it is not treated as a normal filter + if groupTree: + itemIds = _collectItemIds(groupTree, targetGroupId) + if itemIds is None: + logger.warning( + f"handleGroupingInRequest: groupId={targetGroupId!r} not found in tree " + f"for contextKey={contextKey!r} — returning empty set" + ) + itemIds = set() # unknown group → show nothing rather than everything + else: + # groupId sent but no tree saved yet → return empty (nothing belongs to any group) + logger.warning( + f"handleGroupingInRequest: groupId={targetGroupId!r} set but no tree exists " + f"for contextKey={contextKey!r} — returning empty set" + ) + itemIds = set() + + return GroupingContext(groupTree=groupTree, itemIds=itemIds) + + +def applyGroupScopeFilter(items: List[Dict[str, Any]], itemIds: Optional[set]) -> List[Dict[str, Any]]: + """ + Filter items to those whose "id" field is in itemIds. + Returns items unchanged when itemIds is None (no active group scope). + Works for both normal list items and for mode=ids / mode=filterValues flows + — call it before handleIdsInMemory / handleFilterValuesInMemory. + """ + if itemIds is None: + return items + return [item for item in items if str(item.get("id", "")) in itemIds]