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')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue