Gruppierung im Formgenerator fertig
This commit is contained in:
parent
ce671f61b6
commit
72d3175f49
8 changed files with 424 additions and 147 deletions
|
|
@ -13,6 +13,42 @@ import math
|
||||||
T = TypeVar('T')
|
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):
|
class SortField(BaseModel):
|
||||||
"""
|
"""
|
||||||
Single sort field configuration.
|
Single sort field configuration.
|
||||||
|
|
@ -24,6 +60,17 @@ class SortField(BaseModel):
|
||||||
class PaginationParams(BaseModel):
|
class PaginationParams(BaseModel):
|
||||||
"""
|
"""
|
||||||
Complete pagination state including page, sorting, and filters.
|
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)")
|
page: int = Field(ge=1, description="Current page number (1-based)")
|
||||||
pageSize: int = Field(ge=1, le=1000, description="Number of items per page")
|
pageSize: int = Field(ge=1, le=1000, description="Number of items per page")
|
||||||
|
|
@ -38,6 +85,14 @@ class PaginationParams(BaseModel):
|
||||||
- Supported operators: equals/eq, contains, startsWith, endsWith, gt, gte, lt, lte, in, notIn
|
- Supported operators: equals/eq, contains, startsWith, endsWith, gt, gte, lt, lte, in, notIn
|
||||||
- Multiple filters are combined with AND logic"""
|
- 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):
|
class PaginationRequest(BaseModel):
|
||||||
|
|
@ -74,9 +129,18 @@ class PaginationMetadata(BaseModel):
|
||||||
class PaginatedResponse(BaseModel, Generic[T]):
|
class PaginatedResponse(BaseModel, Generic[T]):
|
||||||
"""
|
"""
|
||||||
Response containing paginated data and metadata.
|
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")
|
items: List[T] = Field(..., description="Array of items for current page")
|
||||||
pagination: Optional[PaginationMetadata] = Field(..., description="Pagination metadata (None if pagination not applied)")
|
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)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
|
@ -85,6 +149,7 @@ def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]
|
||||||
"""
|
"""
|
||||||
Normalize pagination dictionary to handle frontend variations.
|
Normalize pagination dictionary to handle frontend variations.
|
||||||
Moves top-level "search" field into filters if present.
|
Moves top-level "search" field into filters if present.
|
||||||
|
Grouping fields (groupId, saveGroupTree) are passed through as-is.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pagination_dict: Raw pagination dictionary from frontend
|
pagination_dict: Raw pagination dictionary from frontend
|
||||||
|
|
@ -110,4 +175,7 @@ def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]
|
||||||
normalized["filters"] = {}
|
normalized["filters"] = {}
|
||||||
normalized["filters"]["search"] = normalized.pop("search")
|
normalized["filters"]["search"] = normalized.pop("search")
|
||||||
|
|
||||||
|
# groupId / saveGroupTree are valid PaginationParams fields — pass through unchanged.
|
||||||
|
# No transformation needed; Pydantic will validate them.
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
|
||||||
|
|
@ -4027,6 +4027,59 @@ class AppObjects:
|
||||||
logger.error(f"Error deleting role {roleId}: {str(e)}")
|
logger.error(f"Error deleting role {roleId}: {str(e)}")
|
||||||
raise
|
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
|
# Public Methods
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,10 +152,28 @@ async def get_connections(
|
||||||
- GET /api/connections/?mode=filterValues&column=status
|
- GET /api/connections/?mode=filterValues&column=status
|
||||||
- GET /api/connections/?mode=ids
|
- 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():
|
def _buildEnhancedItems():
|
||||||
interface = getInterface(currentUser)
|
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
connections = interface.getUserConnections(currentUser.id)
|
||||||
items = []
|
items = []
|
||||||
for connection in connections:
|
for connection in connections:
|
||||||
|
|
@ -182,6 +200,7 @@ async def get_connections(
|
||||||
try:
|
try:
|
||||||
items = _buildEnhancedItems()
|
items = _buildEnhancedItems()
|
||||||
enrichRowsWithFkLabels(items, UserConnection)
|
enrichRowsWithFkLabels(items, UserConnection)
|
||||||
|
items = applyGroupScopeFilter(items, groupCtx.itemIds)
|
||||||
return handleFilterValuesInMemory(items, column, pagination)
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting filter values for connections: {str(e)}")
|
logger.error(f"Error getting filter values for connections: {str(e)}")
|
||||||
|
|
@ -189,36 +208,19 @@ async def get_connections(
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
try:
|
try:
|
||||||
return handleIdsInMemory(_buildEnhancedItems(), pagination)
|
items = applyGroupScopeFilter(_buildEnhancedItems(), groupCtx.itemIds)
|
||||||
|
return handleIdsInMemory(items, pagination)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting IDs for connections: {str(e)}")
|
logger.error(f"Error getting IDs for connections: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
# NOTE: Cannot use db.getRecordsetPaginated() here because each connection
|
# NOTE: Cannot use db.getRecordsetPaginated() here because each connection
|
||||||
# is enriched with computed tokenStatus/tokenExpiresAt (requires per-row DB lookup).
|
# is enriched with computed tokenStatus/tokenExpiresAt (requires per-row DB lookup).
|
||||||
# Token refresh also may trigger re-fetch. Connections per user are typically < 10,
|
# Token refresh also may trigger re-fetch. Connections per user are typically < 10,
|
||||||
# so in-memory pagination is acceptable.
|
# 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
|
# 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)
|
connections = interface.getUserConnections(currentUser.id)
|
||||||
|
|
||||||
# Perform silent token refresh for expired OAuth connections
|
# Perform silent token refresh for expired OAuth connections
|
||||||
|
|
@ -226,26 +228,20 @@ async def get_connections(
|
||||||
refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id)
|
refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id)
|
||||||
if refresh_result.get("refreshed", 0) > 0:
|
if refresh_result.get("refreshed", 0) > 0:
|
||||||
logger.info(f"Silently refreshed {refresh_result['refreshed']} tokens for user {currentUser.id}")
|
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)
|
connections = interface.getUserConnections(currentUser.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Silent token refresh failed for user {currentUser.id}: {str(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 = []
|
enhanced_connections_dict = []
|
||||||
for connection in connections:
|
for connection in connections:
|
||||||
# Get token status for this connection
|
|
||||||
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
||||||
|
|
||||||
# Convert to dict for filtering/sorting
|
|
||||||
connection_dict = {
|
connection_dict = {
|
||||||
"id": connection.id,
|
"id": connection.id,
|
||||||
"userId": connection.userId,
|
"userId": connection.userId,
|
||||||
"authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority),
|
"authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority),
|
||||||
"externalId": connection.externalId,
|
"externalId": connection.externalId,
|
||||||
"externalUsername": connection.externalUsername or "",
|
"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),
|
"status": connection.status.value if hasattr(connection.status, 'value') else str(connection.status),
|
||||||
"connectedAt": connection.connectedAt,
|
"connectedAt": connection.connectedAt,
|
||||||
"lastChecked": connection.lastChecked,
|
"lastChecked": connection.lastChecked,
|
||||||
|
|
@ -256,11 +252,13 @@ async def get_connections(
|
||||||
enhanced_connections_dict.append(connection_dict)
|
enhanced_connections_dict.append(connection_dict)
|
||||||
|
|
||||||
enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection)
|
enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection)
|
||||||
|
enhanced_connections_dict = applyGroupScopeFilter(enhanced_connections_dict, groupCtx.itemIds)
|
||||||
|
|
||||||
if paginationParams is None:
|
if paginationParams is None:
|
||||||
return {
|
return {
|
||||||
"items": enhanced_connections_dict,
|
"items": enhanced_connections_dict,
|
||||||
"pagination": None,
|
"pagination": None,
|
||||||
|
"groupTree": groupCtx.groupTree,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Apply filtering if provided
|
# Apply filtering if provided
|
||||||
|
|
@ -298,6 +296,7 @@ async def get_connections(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
|
"groupTree": groupCtx.groupTree,
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
|
||||||
|
|
@ -279,7 +279,6 @@ def get_files(
|
||||||
try:
|
try:
|
||||||
paginationDict = json.loads(pagination)
|
paginationDict = json.loads(pagination)
|
||||||
if paginationDict:
|
if paginationDict:
|
||||||
# Normalize pagination dict (handles top-level "search" field)
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
paginationParams = PaginationParams(**paginationDict)
|
paginationParams = PaginationParams(**paginationDict)
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
|
@ -291,21 +290,29 @@ def get_files(
|
||||||
from modules.routes.routeHelpers import (
|
from modules.routes.routeHelpers import (
|
||||||
handleIdsMode,
|
handleIdsMode,
|
||||||
handleFilterValuesInMemory,
|
handleFilterValuesInMemory,
|
||||||
|
handleGroupingInRequest, applyGroupScopeFilter,
|
||||||
)
|
)
|
||||||
|
import modules.interfaces.interfaceDbApp as _appIface
|
||||||
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
currentUser,
|
currentUser,
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId 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 mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
allFiles = managementInterface.getAllFiles()
|
allFiles = managementInterface.getAllFiles()
|
||||||
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
|
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)
|
enrichRowsWithFkLabels(itemDicts, FileItem)
|
||||||
|
itemDicts = applyGroupScopeFilter(itemDicts, groupCtx.itemIds)
|
||||||
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
|
|
@ -315,10 +322,6 @@ def get_files(
|
||||||
recordFilter = None
|
recordFilter = None
|
||||||
if paginationParams and paginationParams.filters and "folderId" in paginationParams.filters:
|
if paginationParams and paginationParams.filters and "folderId" in paginationParams.filters:
|
||||||
fVal = paginationParams.filters.get("folderId")
|
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() == ""):
|
if fVal is None or (isinstance(fVal, str) and fVal.strip() == ""):
|
||||||
paginationParams.filters["folderId"] = None
|
paginationParams.filters["folderId"] = None
|
||||||
else:
|
else:
|
||||||
|
|
@ -327,11 +330,8 @@ def get_files(
|
||||||
|
|
||||||
result = managementInterface.getAllFiles(pagination=paginationParams, recordFilter=recordFilter)
|
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:
|
if paginationParams:
|
||||||
enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem)
|
enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem), groupCtx.itemIds)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -342,11 +342,12 @@ def get_files(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
|
"groupTree": groupCtx.groupTree,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result])
|
items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result])
|
||||||
enriched = enrichRowsWithFkLabels(_filesToDicts(items), FileItem)
|
enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(items), FileItem), groupCtx.itemIds)
|
||||||
return {"items": enriched, "pagination": None}
|
return {"items": enriched, "pagination": None, "groupTree": groupCtx.groupTree}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ def get_mandates(
|
||||||
detail=routeApiMsg("Admin role required")
|
detail=routeApiMsg("Admin role required")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse pagination parameter
|
# Parse pagination parameter early — needed for grouping in all modes
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -131,9 +131,19 @@ def get_mandates(
|
||||||
handleFilterValuesInMemory, handleIdsInMemory,
|
handleFilterValuesInMemory, handleIdsInMemory,
|
||||||
handleFilterValuesMode, handleIdsMode,
|
handleFilterValuesMode, handleIdsMode,
|
||||||
parseCrossFilterPagination,
|
parseCrossFilterPagination,
|
||||||
|
handleGroupingInRequest, applyGroupScopeFilter,
|
||||||
)
|
)
|
||||||
|
|
||||||
appInterface = interfaceDbApp.getRootInterface()
|
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 mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
|
|
@ -144,39 +154,26 @@ def get_mandates(
|
||||||
values = appInterface.db.getDistinctColumnValues(Mandate, column, crossPagination)
|
values = appInterface.db.getDistinctColumnValues(Mandate, column, crossPagination)
|
||||||
return JSONResponse(content=sorted(values, key=lambda v: str(v).lower()))
|
return JSONResponse(content=sorted(values, key=lambda v: str(v).lower()))
|
||||||
else:
|
else:
|
||||||
mandateItems = []
|
mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
|
||||||
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))
|
|
||||||
return handleFilterValuesInMemory(mandateItems, column, pagination)
|
return handleFilterValuesInMemory(mandateItems, column, pagination)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
if isPlatformAdmin:
|
if isPlatformAdmin:
|
||||||
return handleIdsMode(appInterface.db, Mandate, pagination)
|
return handleIdsMode(appInterface.db, Mandate, pagination)
|
||||||
else:
|
else:
|
||||||
mandateItems = []
|
mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
|
||||||
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))
|
|
||||||
return handleIdsInMemory(mandateItems, pagination)
|
return handleIdsInMemory(mandateItems, pagination)
|
||||||
|
|
||||||
if isPlatformAdmin:
|
if isPlatformAdmin:
|
||||||
result = appInterface.getAllMandates(pagination=paginationParams)
|
result = appInterface.getAllMandates(pagination=paginationParams)
|
||||||
else:
|
items = result.items if hasattr(result, 'items') else (result if isinstance(result, list) else [])
|
||||||
allMandates = []
|
items = applyGroupScopeFilter(
|
||||||
for mandateId in adminMandateIds:
|
[i.model_dump() if hasattr(i, 'model_dump') else (i if isinstance(i, dict) else vars(i)) for i in items],
|
||||||
mandate = appInterface.getMandate(mandateId)
|
groupCtx.itemIds,
|
||||||
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'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items=result.items,
|
items=items,
|
||||||
pagination=PaginationMetadata(
|
pagination=PaginationMetadata(
|
||||||
currentPage=paginationParams.page,
|
currentPage=paginationParams.page,
|
||||||
pageSize=paginationParams.pageSize,
|
pageSize=paginationParams.pageSize,
|
||||||
|
|
@ -184,14 +181,15 @@ def get_mandates(
|
||||||
totalPages=result.totalPages,
|
totalPages=result.totalPages,
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
)
|
),
|
||||||
|
groupTree=groupCtx.groupTree,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
items = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else result)
|
return PaginatedResponse(items=items, pagination=None, groupTree=groupCtx.groupTree)
|
||||||
return PaginatedResponse(
|
else:
|
||||||
items=items,
|
mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
|
||||||
pagination=None
|
return PaginatedResponse(items=mandateItems, pagination=None, groupTree=groupCtx.groupTree)
|
||||||
)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -44,27 +44,15 @@ def get_prompts(
|
||||||
- filterValues: distinct values for a column (cross-filtered)
|
- filterValues: distinct values for a column (cross-filtered)
|
||||||
- ids: all IDs matching current filters
|
- 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):
|
CONTEXT_KEY = "prompts"
|
||||||
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)
|
|
||||||
|
|
||||||
|
# Parse pagination params early — needed for grouping in all modes
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -75,11 +63,34 @@ def get_prompts(
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(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)
|
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)
|
result = managementInterface.getAllPrompts(pagination=paginationParams)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
items = _promptsToEnrichedDicts(result.items)
|
items = applyGroupScopeFilter(_promptsToEnrichedDicts(result.items), groupCtx.itemIds)
|
||||||
return {
|
return {
|
||||||
"items": items,
|
"items": items,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -90,12 +101,14 @@ def get_prompts(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
|
"groupTree": groupCtx.groupTree,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
items = _promptsToEnrichedDicts(result)
|
items = applyGroupScopeFilter(_promptsToEnrichedDicts(result), groupCtx.itemIds)
|
||||||
return {
|
return {
|
||||||
"items": items,
|
"items": items,
|
||||||
"pagination": None,
|
"pagination": None,
|
||||||
|
"groupTree": groupCtx.groupTree,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,21 @@ def get_users(
|
||||||
- GET /api/users/ (no pagination - returns all users in mandate)
|
- GET /api/users/ (no pagination - returns all users in mandate)
|
||||||
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
|
- 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 mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
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)
|
return _getUserFilterOrIds(context, pagination, idsMode=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
paginationParams = None
|
paginationParams = _paginationParams
|
||||||
if pagination:
|
appInterface = _appInterfaceForGrouping
|
||||||
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)
|
|
||||||
|
|
||||||
if context.mandateId:
|
if context.mandateId:
|
||||||
# Get users for specific mandate using getUsersByMandate
|
# Get users for specific mandate using getUsersByMandate
|
||||||
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
|
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
|
||||||
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User)
|
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -248,17 +251,18 @@ def get_users(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
|
"groupTree": _groupCtx.groupTree,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else []
|
users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else []
|
||||||
enriched = enrichRowsWithFkLabels(_usersToDicts(users), User)
|
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(users), User), _groupCtx.itemIds)
|
||||||
return {"items": enriched, "pagination": None}
|
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
|
||||||
elif context.isPlatformAdmin:
|
elif context.isPlatformAdmin:
|
||||||
# PlatformAdmin without mandateId — DB-level pagination via interface
|
# PlatformAdmin without mandateId — DB-level pagination via interface
|
||||||
result = appInterface.getAllUsers(paginationParams)
|
result = appInterface.getAllUsers(paginationParams)
|
||||||
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User)
|
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -269,11 +273,12 @@ def get_users(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
|
"groupTree": _groupCtx.groupTree,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else [])
|
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else [])
|
||||||
enriched = enrichRowsWithFkLabels(_usersToDicts(users), User)
|
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(users), User), _groupCtx.itemIds)
|
||||||
return {"items": enriched, "pagination": None}
|
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
|
||||||
else:
|
else:
|
||||||
# Non-SysAdmin without mandateId: aggregate users across all admin mandates
|
# Non-SysAdmin without mandateId: aggregate users across all admin mandates
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
|
|
@ -313,7 +318,7 @@ def get_users(
|
||||||
]
|
]
|
||||||
|
|
||||||
from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper
|
from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper
|
||||||
filteredUsers = _applyFiltersAndSortHelper(allUsers, paginationParams)
|
filteredUsers = _applyGroupScope(_applyFiltersAndSortHelper(allUsers, paginationParams), _groupCtx.itemIds)
|
||||||
enriched = enrichRowsWithFkLabels(filteredUsers, User)
|
enriched = enrichRowsWithFkLabels(filteredUsers, User)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -333,9 +338,10 @@ def get_users(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
|
"groupTree": _groupCtx.groupTree,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {"items": enriched, "pagination": None}
|
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -701,3 +701,142 @@ def paginateInMemory(
|
||||||
offset = (paginationParams.page - 1) * paginationParams.pageSize
|
offset = (paginationParams.page - 1) * paginationParams.pageSize
|
||||||
pageItems = items[offset:offset + paginationParams.pageSize]
|
pageItems = items[offset:offset + paginationParams.pageSize]
|
||||||
return pageItems, totalItems
|
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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue