fix: completely fixed grouping to be like clickup grouping, removed wrong mechanisms
This commit is contained in:
parent
9ae2ffc415
commit
5455e09367
15 changed files with 1227 additions and 810 deletions
3
app.py
3
app.py
|
|
@ -600,6 +600,9 @@ app.include_router(promptRouter)
|
||||||
from modules.routes.routeDataConnections import router as connectionsRouter
|
from modules.routes.routeDataConnections import router as connectionsRouter
|
||||||
app.include_router(connectionsRouter)
|
app.include_router(connectionsRouter)
|
||||||
|
|
||||||
|
from modules.routes.routeTableViews import router as tableViewsRouter
|
||||||
|
app.include_router(tableViewsRouter)
|
||||||
|
|
||||||
from modules.routes.routeSecurityLocal import router as localRouter
|
from modules.routes.routeSecurityLocal import router as localRouter
|
||||||
app.include_router(localRouter)
|
app.include_router(localRouter)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,50 +9,95 @@ All models use camelStyle naming convention for consistency with frontend.
|
||||||
from typing import List, Dict, Any, Optional, Generic, TypeVar
|
from typing import List, Dict, Any, Optional, Generic, TypeVar
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
import math
|
import math
|
||||||
|
import uuid
|
||||||
|
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Table Grouping models
|
# Group layout models (Strategy B — derived from Views, purely presentational)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TableGroupNode(BaseModel):
|
class GroupByLevel(BaseModel):
|
||||||
|
"""One level of a multi-level grouping definition, stored inside a TableListView config."""
|
||||||
|
field: str = Field(..., description="Field key to group by")
|
||||||
|
nullLabel: str = Field(default="—", description="Display label for null/empty values")
|
||||||
|
direction: str = Field(
|
||||||
|
default="asc",
|
||||||
|
description="Order of group bands at this level: 'asc' or 'desc'",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupBand(BaseModel):
|
||||||
"""
|
"""
|
||||||
A single node in a user-defined group tree for a FormGeneratorTable.
|
A contiguous block of rows that share the same group path, intersecting the current page.
|
||||||
|
|
||||||
Items belong to exactly one group (no multi-membership).
|
startRowIndex and rowCount are 0-based indices relative to the current page's items[].
|
||||||
Groups can be nested to arbitrary depth via subGroups.
|
|
||||||
"""
|
"""
|
||||||
id: str
|
path: List[str] = Field(..., description="Hierarchical group key (one entry per level)")
|
||||||
name: str
|
label: str = Field(..., description="Display label for this band (last path element)")
|
||||||
itemIds: List[str] = Field(default_factory=list)
|
startRowIndex: int = Field(..., description="0-based start index within items[] on this page")
|
||||||
subGroups: List['TableGroupNode'] = Field(default_factory=list)
|
rowCount: int = Field(..., description="Number of items in this band on this page")
|
||||||
order: int = 0
|
|
||||||
isExpanded: bool = True
|
|
||||||
|
|
||||||
TableGroupNode.model_rebuild()
|
|
||||||
|
|
||||||
|
|
||||||
class TableGrouping(BaseModel):
|
class GroupLayout(BaseModel):
|
||||||
"""
|
"""
|
||||||
Persisted grouping configuration for one (user, contextKey) pair.
|
Grouping structure for the current response page.
|
||||||
Stored in table_groupings in poweron_app (auto-created).
|
Included only when the effective view has groupByLevels configured.
|
||||||
|
The frontend renders group header rows by iterating bands and inserting
|
||||||
|
headers before each startRowIndex.
|
||||||
|
"""
|
||||||
|
levels: List[str] = Field(..., description="Ordered field keys that define the grouping hierarchy")
|
||||||
|
bands: List[GroupBand] = Field(..., description="Bands intersecting the current page, in order")
|
||||||
|
|
||||||
|
|
||||||
|
class AppliedViewMeta(BaseModel):
|
||||||
|
"""Minimal metadata about the view that was applied to this response."""
|
||||||
|
viewKey: Optional[str] = None
|
||||||
|
displayName: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Persisted view model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TableListView(BaseModel):
|
||||||
|
"""
|
||||||
|
A saved table view for one (userId, contextKey) pair.
|
||||||
|
|
||||||
|
config schema (schemaVersion=1):
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"filters": {}, # same structure as PaginationParams.filters
|
||||||
|
"sort": [], # same structure as PaginationParams.sort
|
||||||
|
"groupByLevels": [ # ordered grouping levels
|
||||||
|
{"field": "scope", "nullLabel": "—", "direction": "asc"}
|
||||||
|
],
|
||||||
|
"collapsedSectionKeys": [], # optional: section UI (stable group keys)
|
||||||
|
"collapsedGroupKeys": [], # optional: inline group bands (path.join('///'))
|
||||||
|
}
|
||||||
|
|
||||||
contextKey convention: API path without /api/ prefix and without trailing slash.
|
contextKey convention: API path without /api/ prefix and without trailing slash.
|
||||||
Examples: "connections", "prompts", "admin/users", "trustee/{instanceId}/documents"
|
Examples: "connections", "prompts", "admin/users", "files/list"
|
||||||
|
|
||||||
|
viewKey is a user-defined slug, unique per (userId, mandateId, contextKey).
|
||||||
"""
|
"""
|
||||||
id: str
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str
|
userId: str
|
||||||
|
mandateId: Optional[str] = None
|
||||||
contextKey: str
|
contextKey: str
|
||||||
rootGroups: List[TableGroupNode] = Field(default_factory=list)
|
viewKey: str
|
||||||
|
displayName: str
|
||||||
|
config: Dict[str, Any] = Field(default_factory=dict)
|
||||||
updatedAt: Optional[float] = None
|
updatedAt: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sort and pagination models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class SortField(BaseModel):
|
class SortField(BaseModel):
|
||||||
"""
|
"""Single sort field configuration."""
|
||||||
Single sort field configuration.
|
|
||||||
"""
|
|
||||||
field: str = Field(..., description="Field name to sort by")
|
field: str = Field(..., description="Field name to sort by")
|
||||||
direction: str = Field(..., description="Sort direction: 'asc' or 'desc'")
|
direction: str = Field(..., description="Sort direction: 'asc' or 'desc'")
|
||||||
|
|
||||||
|
|
@ -61,16 +106,13 @@ 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):
|
View extension (optional):
|
||||||
groupId — Scope the request to items belonging to this group.
|
viewKey — Slug of a saved TableListView for this (user, contextKey) pair.
|
||||||
The backend resolves it to an itemIds IN-filter before
|
The server loads the view, merges its filters/sort/groupByLevels
|
||||||
applying normal pagination/search/filter logic.
|
into the effective query (request fields take priority over view
|
||||||
Also applied for mode=ids and mode=filterValues so that
|
defaults for explicitly provided fields), and returns groupLayout
|
||||||
bulk-select and filter-dropdowns respect the group scope.
|
in the response when groupByLevels is non-empty.
|
||||||
saveGroupTree — If present the backend persists this tree for the current
|
Omit or set to None for the default (ungrouped) view.
|
||||||
(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")
|
||||||
|
|
@ -85,13 +127,16 @@ 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(
|
viewKey: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Scope request to items of this group (resolved server-side to itemIds IN-filter)",
|
description="Slug of a saved view to load; server merges view config into effective query",
|
||||||
)
|
)
|
||||||
saveGroupTree: Optional[List[Dict[str, Any]]] = Field(
|
groupByLevels: Optional[List[GroupByLevel]] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="If set, persist this group tree before fetching (optimistic save)",
|
description=(
|
||||||
|
"When set (including an empty list), replaces the saved view's groupByLevels for this request. "
|
||||||
|
"Omit entirely to use grouping from the view only."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -130,16 +175,22 @@ 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
|
groupLayout is included when the effective view has groupByLevels configured.
|
||||||
current user has a saved group tree for the requested contextKey.
|
It describes how to render group header rows in the current page's items[].
|
||||||
It is None when grouping is not configured for the endpoint or the user
|
Omitted (None) when no grouping is active.
|
||||||
has not created any groups yet. Frontend must treat None as an empty tree.
|
|
||||||
|
appliedView describes which saved view was merged into this response,
|
||||||
|
allowing the frontend to synchronise its view selector.
|
||||||
"""
|
"""
|
||||||
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(
|
groupLayout: Optional[GroupLayout] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Current group tree for this (user, contextKey) pair — None if no grouping configured",
|
description="Group band structure for this page (None if no grouping active)",
|
||||||
|
)
|
||||||
|
appliedView: Optional[AppliedViewMeta] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Metadata about the view applied to this response",
|
||||||
)
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
@ -148,34 +199,30 @@ class PaginatedResponse(BaseModel, Generic[T]):
|
||||||
def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]:
|
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.
|
|
||||||
Grouping fields (groupId, saveGroupTree) are passed through as-is.
|
|
||||||
|
|
||||||
Args:
|
- Moves top-level "search" field into filters if present.
|
||||||
pagination_dict: Raw pagination dictionary from frontend
|
- Silently drops legacy fields (groupId, saveGroupTree) that were part of the
|
||||||
|
old tree-grouping implementation so old clients do not cause validation errors.
|
||||||
Returns:
|
- Passes viewKey through unchanged.
|
||||||
Normalized pagination dictionary ready for PaginationParams parsing
|
|
||||||
"""
|
"""
|
||||||
if not pagination_dict:
|
if not pagination_dict:
|
||||||
return pagination_dict
|
return pagination_dict
|
||||||
|
|
||||||
# Create a copy to avoid modifying the original
|
|
||||||
normalized = dict(pagination_dict)
|
normalized = dict(pagination_dict)
|
||||||
|
|
||||||
# Ensure required fields have sensible defaults
|
|
||||||
if "page" not in normalized:
|
if "page" not in normalized:
|
||||||
normalized["page"] = 1
|
normalized["page"] = 1
|
||||||
if "pageSize" not in normalized:
|
if "pageSize" not in normalized:
|
||||||
normalized["pageSize"] = 25
|
normalized["pageSize"] = 25
|
||||||
|
|
||||||
# Move top-level "search" into filters if present
|
# Move top-level "search" into filters
|
||||||
if "search" in normalized:
|
if "search" in normalized:
|
||||||
if "filters" not in normalized or normalized["filters"] is None:
|
if "filters" not in normalized or normalized["filters"] is None:
|
||||||
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.
|
# Drop legacy tree-grouping fields — harmless if already absent
|
||||||
# No transformation needed; Pydantic will validate them.
|
normalized.pop("groupId", None)
|
||||||
|
normalized.pop("saveGroupTree", None)
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
|
||||||
|
|
@ -4028,58 +4028,92 @@ class AppObjects:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Table Grouping (user-defined groups for FormGeneratorTable instances)
|
# Table List Views (saved display presets: filters, sort, groupByLevels)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
def getTableGrouping(self, contextKey: str):
|
def getTableListViews(self, contextKey: str) -> list:
|
||||||
"""
|
"""Return all saved views for the current user and contextKey."""
|
||||||
Load the group tree for the current user and the given contextKey.
|
from modules.datamodels.datamodelPagination import TableListView
|
||||||
|
|
||||||
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:
|
try:
|
||||||
records = self.db.getRecordset(
|
rows = self.db.getRecordset(
|
||||||
TableGrouping,
|
TableListView,
|
||||||
recordFilter={"userId": str(self.userId), "contextKey": contextKey},
|
recordFilter={"userId": str(self.userId), "contextKey": contextKey},
|
||||||
)
|
)
|
||||||
if not records:
|
result = []
|
||||||
return None
|
for row in (rows or []):
|
||||||
row = records[0]
|
try:
|
||||||
return TableGrouping.model_validate(row) if isinstance(row, dict) else row
|
result.append(TableListView.model_validate(row) if isinstance(row, dict) else row)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"getTableGrouping failed for user={self.userId} key={contextKey}: {e}")
|
logger.error(f"getTableListViews failed for user={self.userId} context={contextKey}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def getTableListView(self, contextKey: str, viewKey: str):
|
||||||
|
"""Return one view by viewKey or None if not found."""
|
||||||
|
from modules.datamodels.datamodelPagination import TableListView
|
||||||
|
try:
|
||||||
|
rows = self.db.getRecordset(
|
||||||
|
TableListView,
|
||||||
|
recordFilter={"userId": str(self.userId), "contextKey": contextKey, "viewKey": viewKey},
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
row = rows[0]
|
||||||
|
return TableListView.model_validate(row) if isinstance(row, dict) else row
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"getTableListView failed for user={self.userId} key={viewKey}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def upsertTableGrouping(self, contextKey: str, rootGroups: list):
|
def createTableListView(self, contextKey: str, viewKey: str, displayName: str, config: dict):
|
||||||
"""
|
"""Create a new view. Raises ValueError if viewKey already exists for this context."""
|
||||||
Create or replace the group tree for the current user and contextKey.
|
from modules.datamodels.datamodelPagination import TableListView
|
||||||
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
if self.getTableListView(contextKey=contextKey, viewKey=viewKey) is not None:
|
||||||
|
raise ValueError(f"View '{viewKey}' already exists for context '{contextKey}'")
|
||||||
|
data = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"userId": str(self.userId),
|
||||||
|
"contextKey": contextKey,
|
||||||
|
"viewKey": viewKey,
|
||||||
|
"displayName": displayName,
|
||||||
|
"config": config,
|
||||||
|
"updatedAt": getUtcTimestamp(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
self.db.recordCreate(TableListView, data)
|
||||||
|
return TableListView.model_validate(data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"createTableListView failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
rootGroups is a list of TableGroupNode-compatible dicts (the full tree).
|
def updateTableListView(self, viewId: str, updates: dict):
|
||||||
Returns the saved TableGrouping instance.
|
"""Update an existing view by its primary key id."""
|
||||||
"""
|
from modules.datamodels.datamodelPagination import TableListView
|
||||||
from modules.datamodels.datamodelPagination import TableGrouping
|
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
try:
|
try:
|
||||||
existing = self.getTableGrouping(contextKey)
|
updates = {**updates, "updatedAt": getUtcTimestamp()}
|
||||||
data = {
|
self.db.recordModify(TableListView, viewId, updates)
|
||||||
"id": existing.id if existing else str(uuid.uuid4()),
|
rows = self.db.getRecordset(TableListView, recordFilter={"id": viewId})
|
||||||
"userId": str(self.userId),
|
if rows:
|
||||||
"contextKey": contextKey,
|
row = rows[0]
|
||||||
"rootGroups": rootGroups,
|
return TableListView.model_validate(row) if isinstance(row, dict) else row
|
||||||
"updatedAt": getUtcTimestamp(),
|
return None
|
||||||
}
|
|
||||||
if existing:
|
|
||||||
self.db.recordModify(TableGrouping, existing.id, data)
|
|
||||||
else:
|
|
||||||
self.db.recordCreate(TableGrouping, data)
|
|
||||||
return TableGrouping.model_validate(data)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"upsertTableGrouping failed for user={self.userId} key={contextKey}: {e}")
|
logger.error(f"updateTableListView failed for id={viewId}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def deleteTableListView(self, viewId: str) -> bool:
|
||||||
|
"""Delete a view by primary key id. Returns True on success."""
|
||||||
|
from modules.datamodels.datamodelPagination import TableListView
|
||||||
|
try:
|
||||||
|
self.db.recordDelete(TableListView, viewId)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"deleteTableListView failed for id={viewId}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Public Methods
|
# Public Methods
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1532,44 +1532,8 @@ class ComponentObjects:
|
||||||
raise FileDeletionError(f"Error deleting files in batch: {str(e)}")
|
raise FileDeletionError(f"Error deleting files in batch: {str(e)}")
|
||||||
|
|
||||||
def _ensureFeatureInstanceGroup(self, featureInstanceId: str, contextKey: str = "files/list") -> Optional[str]:
|
def _ensureFeatureInstanceGroup(self, featureInstanceId: str, contextKey: str = "files/list") -> Optional[str]:
|
||||||
"""Return the groupId of the default group for a feature instance.
|
"""Stub — file group tree removed. Returns None."""
|
||||||
Creates the group if it doesn't exist yet."""
|
return None
|
||||||
try:
|
|
||||||
import modules.interfaces.interfaceDbApp as _appIface
|
|
||||||
appInterface = _appIface.getInterface(self._currentUser)
|
|
||||||
existing = appInterface.getTableGrouping(contextKey)
|
|
||||||
nodes = [n.model_dump() if hasattr(n, 'model_dump') else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])]
|
|
||||||
# Look for group with name matching featureInstanceId
|
|
||||||
def _find(nds):
|
|
||||||
for nd in nds:
|
|
||||||
nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
|
|
||||||
nmeta = nd.get("meta", {}) if isinstance(nd, dict) else getattr(nd, "meta", {})
|
|
||||||
if (nmeta or {}).get("featureInstanceId") == featureInstanceId:
|
|
||||||
return nid
|
|
||||||
subs = nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", [])
|
|
||||||
result = _find(subs)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
return None
|
|
||||||
found = _find(nodes)
|
|
||||||
if found:
|
|
||||||
return found
|
|
||||||
# Create new group
|
|
||||||
import uuid
|
|
||||||
newId = str(uuid.uuid4())
|
|
||||||
newGroup = {
|
|
||||||
"id": newId,
|
|
||||||
"name": featureInstanceId,
|
|
||||||
"itemIds": [],
|
|
||||||
"subGroups": [],
|
|
||||||
"meta": {"featureInstanceId": featureInstanceId},
|
|
||||||
}
|
|
||||||
nodes.append(newGroup)
|
|
||||||
appInterface.upsertTableGrouping(contextKey, nodes)
|
|
||||||
return newId
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"_ensureFeatureInstanceGroup failed: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def copyFile(self, sourceFileId: str, newFileName: Optional[str] = None) -> FileItem:
|
def copyFile(self, sourceFileId: str, newFileName: Optional[str] = None) -> FileItem:
|
||||||
"""Create a full duplicate of a file (FileItem + FileData)."""
|
"""Create a full duplicate of a file (FileItem + FileData)."""
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ Features:
|
||||||
- Admin endpoints: Manage settings, add credits, view all accounts
|
- Admin endpoints: Manage settings, add credits, view all accounts
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query, Header
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query, Header, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -24,7 +24,13 @@ from modules.interfaces.interfaceDbBilling import getInterface as getBillingInte
|
||||||
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import (
|
||||||
|
PaginationParams,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationMetadata,
|
||||||
|
normalize_pagination_dict,
|
||||||
|
AppliedViewMeta,
|
||||||
|
)
|
||||||
from modules.datamodels.datamodelBilling import (
|
from modules.datamodels.datamodelBilling import (
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
BillingTransaction,
|
BillingTransaction,
|
||||||
|
|
@ -478,50 +484,193 @@ def getBalanceForMandate(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transactions", response_model=List[TransactionResponse])
|
def _normalize_billing_tx_dict(t: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Make billing transaction rows JSON/grouping-safe (datetimes → str, enums → str)."""
|
||||||
|
from datetime import date as date_cls, datetime as dt_cls
|
||||||
|
|
||||||
|
r = dict(t)
|
||||||
|
for k, v in list(r.items()):
|
||||||
|
if isinstance(v, dt_cls):
|
||||||
|
r[k] = v.isoformat()
|
||||||
|
elif isinstance(v, date_cls):
|
||||||
|
r[k] = v.isoformat()
|
||||||
|
for ek in ("transactionType", "referenceType"):
|
||||||
|
if ek in r and r[ek] is not None and not isinstance(r[ek], str):
|
||||||
|
ev = r[ek]
|
||||||
|
r[ek] = getattr(ev, "value", None) or str(ev)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _load_billing_user_transactions_normalized(billingService) -> List[Dict[str, Any]]:
|
||||||
|
raw = billingService.getTransactionHistory(limit=5000)
|
||||||
|
return [_normalize_billing_tx_dict(t) for t in raw]
|
||||||
|
|
||||||
|
|
||||||
|
def _view_user_transactions_filtered_list(
|
||||||
|
billing_interface,
|
||||||
|
load_mandate_ids: Optional[List[str]],
|
||||||
|
effective_scope: str,
|
||||||
|
personal_user_id: Optional[str],
|
||||||
|
pagination_params: PaginationParams,
|
||||||
|
ctx_user,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Up to 5000 rows: SQL window + in-memory filters/sort (incl. enriched columns)."""
|
||||||
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
|
|
||||||
|
bulk_params = pagination_params.model_copy(deep=True)
|
||||||
|
bulk_params.page = 1
|
||||||
|
bulk_params.pageSize = 5000
|
||||||
|
bulk_result = billing_interface.getTransactionsForMandatesPaginated(
|
||||||
|
mandateIds=load_mandate_ids,
|
||||||
|
pagination=bulk_params,
|
||||||
|
scope=effective_scope,
|
||||||
|
userId=personal_user_id,
|
||||||
|
)
|
||||||
|
all_items = [_normalize_billing_tx_dict(dict(x)) for x in bulk_result.items]
|
||||||
|
comp = ComponentObjects()
|
||||||
|
comp.setUserContext(ctx_user)
|
||||||
|
if pagination_params.filters:
|
||||||
|
all_items = comp._applyFilters(all_items, pagination_params.filters)
|
||||||
|
if pagination_params.sort:
|
||||||
|
all_items = comp._applySorting(all_items, pagination_params.sort)
|
||||||
|
return all_items
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def getTransactions(
|
def getTransactions(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = Query(default=50, ge=1, le=500),
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
ctx: RequestContext = Depends(getRequestContext)
|
pagination: Optional[str] = Query(
|
||||||
|
None,
|
||||||
|
description="JSON PaginationParams for table UI (filters, sort, viewKey, groupByLevels).",
|
||||||
|
),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' | 'ids' with pagination"),
|
||||||
|
column: Optional[str] = Query(None, description="Column for mode=filterValues"),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get transaction history across all mandates the user belongs to.
|
Get transaction history across all mandates the user belongs to.
|
||||||
|
|
||||||
|
Without ``pagination`` query: legacy behaviour — returns a JSON array of
|
||||||
|
transactions (`limit`/`offset` window).
|
||||||
|
|
||||||
|
With ``pagination`` JSON: returns ``{ items, pagination, groupLayout?, appliedView? }``.
|
||||||
|
Table list views use contextKey ``billing/transactions``.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingService = getBillingService(
|
billingService = getBillingService(
|
||||||
ctx.user,
|
ctx.user,
|
||||||
ctx.mandateId,
|
ctx.mandateId,
|
||||||
featureCode="billing"
|
featureCode="billing",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch enough transactions for pagination
|
if pagination:
|
||||||
|
from modules.routes.routeHelpers import (
|
||||||
|
applyViewToParams,
|
||||||
|
buildGroupLayout,
|
||||||
|
effective_group_by_levels,
|
||||||
|
handleFilterValuesInMemory,
|
||||||
|
handleIdsInMemory,
|
||||||
|
resolveView,
|
||||||
|
)
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
|
|
||||||
|
CONTEXT_KEY = "billing/transactions"
|
||||||
|
|
||||||
|
try:
|
||||||
|
paginationDict = json.loads(pagination)
|
||||||
|
if not paginationDict:
|
||||||
|
raise ValueError("empty pagination")
|
||||||
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
|
paginationParams = PaginationParams(**paginationDict)
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||||
|
|
||||||
|
appInterface = getAppInterface(ctx.user)
|
||||||
|
viewKey = paginationParams.viewKey
|
||||||
|
viewConfig, viewDisplayName = resolveView(appInterface, CONTEXT_KEY, viewKey)
|
||||||
|
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
|
||||||
|
paginationParams = applyViewToParams(paginationParams, viewConfig)
|
||||||
|
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
|
||||||
|
|
||||||
|
all_items = _load_billing_user_transactions_normalized(billingService)
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
return handleFilterValuesInMemory(all_items, column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
return handleIdsInMemory(all_items, pagination)
|
||||||
|
|
||||||
|
comp = ComponentObjects()
|
||||||
|
comp.setUserContext(ctx.user)
|
||||||
|
if paginationParams.filters:
|
||||||
|
all_items = comp._applyFilters(all_items, paginationParams.filters)
|
||||||
|
if paginationParams.sort:
|
||||||
|
all_items = comp._applySorting(all_items, paginationParams.sort)
|
||||||
|
|
||||||
|
totalItems = len(all_items)
|
||||||
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
|
|
||||||
|
if not groupByLevels:
|
||||||
|
pstart = (paginationParams.page - 1) * paginationParams.pageSize
|
||||||
|
page_items = all_items[pstart : pstart + paginationParams.pageSize]
|
||||||
|
group_layout = None
|
||||||
|
else:
|
||||||
|
page_items, group_layout = buildGroupLayout(
|
||||||
|
all_items,
|
||||||
|
groupByLevels,
|
||||||
|
paginationParams.page,
|
||||||
|
paginationParams.pageSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp: Dict[str, Any] = {
|
||||||
|
"items": page_items,
|
||||||
|
"pagination": PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=totalItems,
|
||||||
|
totalPages=totalPages,
|
||||||
|
sort=paginationParams.sort,
|
||||||
|
filters=paginationParams.filters,
|
||||||
|
).model_dump(),
|
||||||
|
}
|
||||||
|
if group_layout:
|
||||||
|
resp["groupLayout"] = group_layout.model_dump()
|
||||||
|
if viewMeta:
|
||||||
|
resp["appliedView"] = viewMeta.model_dump()
|
||||||
|
return JSONResponse(content=resp)
|
||||||
|
|
||||||
transactions = billingService.getTransactionHistory(limit=offset + limit)
|
transactions = billingService.getTransactionHistory(limit=offset + limit)
|
||||||
|
result: List[TransactionResponse] = []
|
||||||
# Convert to response model
|
for t in transactions[offset : offset + limit]:
|
||||||
result = []
|
result.append(
|
||||||
for t in transactions[offset:offset + limit]:
|
TransactionResponse(
|
||||||
result.append(TransactionResponse(
|
id=t.get("id"),
|
||||||
id=t.get("id"),
|
accountId=t.get("accountId"),
|
||||||
accountId=t.get("accountId"),
|
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
||||||
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
amount=t.get("amount", 0.0),
|
||||||
amount=t.get("amount", 0.0),
|
description=t.get("description", ""),
|
||||||
description=t.get("description", ""),
|
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
||||||
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
workflowId=t.get("workflowId"),
|
||||||
workflowId=t.get("workflowId"),
|
featureCode=t.get("featureCode"),
|
||||||
featureCode=t.get("featureCode"),
|
featureInstanceId=t.get("featureInstanceId"),
|
||||||
featureInstanceId=t.get("featureInstanceId"),
|
aicoreProvider=t.get("aicoreProvider"),
|
||||||
aicoreProvider=t.get("aicoreProvider"),
|
aicoreModel=t.get("aicoreModel"),
|
||||||
aicoreModel=t.get("aicoreModel"),
|
createdByUserId=t.get("createdByUserId"),
|
||||||
createdByUserId=t.get("createdByUserId"),
|
sysCreatedAt=t.get("sysCreatedAt"),
|
||||||
sysCreatedAt=t.get("sysCreatedAt"),
|
mandateId=t.get("mandateId"),
|
||||||
mandateId=t.get("mandateId"),
|
mandateName=t.get("mandateName"),
|
||||||
mandateName=t.get("mandateName")
|
)
|
||||||
))
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting billing transactions: {e}")
|
logger.error(f"Error getting billing transactions: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
@ -1757,7 +1906,7 @@ def getUserViewStatistics(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/view/users/transactions", response_model=PaginatedResponse[UserTransactionResponse])
|
@router.get("/view/users/transactions", response_model=PaginatedResponse[UserTransactionResponse])
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("120/minute")
|
||||||
def getUserViewTransactions(
|
def getUserViewTransactions(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
|
@ -1808,7 +1957,6 @@ def getUserViewTransactions(
|
||||||
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")
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
crossFilterParams = parseCrossFilterPagination(column, pagination)
|
crossFilterParams = parseCrossFilterPagination(column, pagination)
|
||||||
values = billingInterface.getTransactionDistinctValues(
|
values = billingInterface.getTransactionDistinctValues(
|
||||||
mandateIds=loadMandateIds,
|
mandateIds=loadMandateIds,
|
||||||
|
|
@ -1820,7 +1968,6 @@ def getUserViewTransactions(
|
||||||
return JSONResponse(content=values)
|
return JSONResponse(content=values)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
@ -1835,6 +1982,66 @@ def getUserViewTransactions(
|
||||||
) if hasattr(billingInterface, 'getTransactionIds') else []
|
) if hasattr(billingInterface, 'getTransactionIds') else []
|
||||||
return JSONResponse(content=ids)
|
return JSONResponse(content=ids)
|
||||||
|
|
||||||
|
if mode == "groupSummary":
|
||||||
|
if not pagination:
|
||||||
|
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
|
||||||
|
import json as _json
|
||||||
|
from collections import defaultdict
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
from modules.routes.routeHelpers import (
|
||||||
|
applyViewToParams,
|
||||||
|
effective_group_by_levels,
|
||||||
|
resolveView,
|
||||||
|
)
|
||||||
|
|
||||||
|
pagination_dict = _json.loads(pagination)
|
||||||
|
pagination_dict = normalize_pagination_dict(pagination_dict)
|
||||||
|
summary_params = PaginationParams(**pagination_dict)
|
||||||
|
CONTEXT_KEY = "billing/view/users/transactions"
|
||||||
|
app_interface = getAppInterface(ctx.user)
|
||||||
|
summary_vk = summary_params.viewKey
|
||||||
|
summary_view_cfg, _ = resolveView(app_interface, CONTEXT_KEY, summary_vk)
|
||||||
|
summary_params = applyViewToParams(summary_params, summary_view_cfg)
|
||||||
|
levels = effective_group_by_levels(summary_params, summary_view_cfg)
|
||||||
|
if not levels or not levels[0].get("field"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="groupByLevels[0].field required for groupSummary",
|
||||||
|
)
|
||||||
|
field = levels[0]["field"]
|
||||||
|
null_label = str(levels[0].get("nullLabel") or "—")
|
||||||
|
all_rows = _view_user_transactions_filtered_list(
|
||||||
|
billingInterface,
|
||||||
|
loadMandateIds,
|
||||||
|
scope,
|
||||||
|
personalUserId,
|
||||||
|
summary_params,
|
||||||
|
ctx.user,
|
||||||
|
)
|
||||||
|
counts: Dict[str, int] = defaultdict(int)
|
||||||
|
labels: Dict[str, str] = {}
|
||||||
|
null_key = "\x00NULL"
|
||||||
|
for item in all_rows:
|
||||||
|
raw = item.get(field)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
nk = null_key
|
||||||
|
labels[nk] = null_label
|
||||||
|
else:
|
||||||
|
nk = str(raw)
|
||||||
|
if nk not in labels:
|
||||||
|
labels[nk] = nk
|
||||||
|
counts[nk] += 1
|
||||||
|
groups_out: List[Dict[str, Any]] = []
|
||||||
|
for nk in sorted(counts.keys(), key=lambda x: (x == null_key, labels.get(x, x).lower())):
|
||||||
|
groups_out.append(
|
||||||
|
{
|
||||||
|
"value": None if nk == null_key else nk,
|
||||||
|
"label": labels.get(nk, nk),
|
||||||
|
"totalCount": counts[nk],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return JSONResponse(content={"groups": groups_out})
|
||||||
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
@ -1847,15 +2054,21 @@ def getUserViewTransactions(
|
||||||
if not paginationParams:
|
if not paginationParams:
|
||||||
paginationParams = PaginationParams(page=1, pageSize=50)
|
paginationParams = PaginationParams(page=1, pageSize=50)
|
||||||
|
|
||||||
result = billingInterface.getTransactionsForMandatesPaginated(
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
mandateIds=loadMandateIds,
|
from modules.routes.routeHelpers import (
|
||||||
pagination=paginationParams,
|
applyViewToParams,
|
||||||
scope=effectiveScope,
|
buildGroupLayout,
|
||||||
userId=personalUserId,
|
effective_group_by_levels,
|
||||||
|
resolveView,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"SQL-paginated {result.totalItems} transactions for user {ctx.user.id} "
|
CONTEXT_KEY = "billing/view/users/transactions"
|
||||||
f"(scope={scope}, mandateId={mandateId}, page={paginationParams.page})")
|
appInterface = getAppInterface(ctx.user)
|
||||||
|
viewKey = paginationParams.viewKey
|
||||||
|
viewConfig, viewDisplayName = resolveView(appInterface, CONTEXT_KEY, viewKey)
|
||||||
|
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
|
||||||
|
paginationParams = applyViewToParams(paginationParams, viewConfig)
|
||||||
|
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
|
||||||
|
|
||||||
def _toResponse(d):
|
def _toResponse(d):
|
||||||
return UserTransactionResponse(
|
return UserTransactionResponse(
|
||||||
|
|
@ -1875,9 +2088,56 @@ def getUserViewTransactions(
|
||||||
mandateId=d.get("mandateId"),
|
mandateId=d.get("mandateId"),
|
||||||
mandateName=d.get("mandateName"),
|
mandateName=d.get("mandateName"),
|
||||||
userId=d.get("userId"),
|
userId=d.get("userId"),
|
||||||
userName=d.get("userName")
|
userName=d.get("userName"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if groupByLevels:
|
||||||
|
all_items = _view_user_transactions_filtered_list(
|
||||||
|
billingInterface,
|
||||||
|
loadMandateIds,
|
||||||
|
effectiveScope,
|
||||||
|
personalUserId,
|
||||||
|
paginationParams,
|
||||||
|
ctx.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
totalItems = len(all_items)
|
||||||
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
|
page_items, group_layout = buildGroupLayout(
|
||||||
|
all_items,
|
||||||
|
groupByLevels,
|
||||||
|
paginationParams.page,
|
||||||
|
paginationParams.pageSize,
|
||||||
|
)
|
||||||
|
resp: Dict[str, Any] = {
|
||||||
|
"items": [_toResponse(d).model_dump(mode="json") for d in page_items],
|
||||||
|
"pagination": PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=totalItems,
|
||||||
|
totalPages=totalPages,
|
||||||
|
sort=paginationParams.sort,
|
||||||
|
filters=paginationParams.filters,
|
||||||
|
).model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
if group_layout:
|
||||||
|
resp["groupLayout"] = group_layout.model_dump(mode="json")
|
||||||
|
if viewMeta:
|
||||||
|
resp["appliedView"] = viewMeta.model_dump(mode="json")
|
||||||
|
return JSONResponse(content=resp)
|
||||||
|
|
||||||
|
result = billingInterface.getTransactionsForMandatesPaginated(
|
||||||
|
mandateIds=loadMandateIds,
|
||||||
|
pagination=paginationParams,
|
||||||
|
scope=effectiveScope,
|
||||||
|
userId=personalUserId,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"SQL-paginated {result.totalItems} transactions for user {ctx.user.id} "
|
||||||
|
f"(scope={scope}, mandateId={mandateId}, page={paginationParams.page})"
|
||||||
|
)
|
||||||
|
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items=[_toResponse(d) for d in result.items],
|
items=[_toResponse(d) for d in result.items],
|
||||||
pagination=PaginationMetadata(
|
pagination=PaginationMetadata(
|
||||||
|
|
@ -1887,7 +2147,7 @@ def getUserViewTransactions(
|
||||||
totalPages=result.totalPages,
|
totalPages=result.totalPages,
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters,
|
filters=paginationParams.filters,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import logging
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token
|
||||||
|
|
@ -154,12 +155,12 @@ async def get_connections(
|
||||||
"""
|
"""
|
||||||
from modules.routes.routeHelpers import (
|
from modules.routes.routeHelpers import (
|
||||||
handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels,
|
handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels,
|
||||||
handleGroupingInRequest, applyGroupScopeFilter,
|
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
|
||||||
)
|
)
|
||||||
|
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
||||||
|
|
||||||
CONTEXT_KEY = "connections"
|
CONTEXT_KEY = "connections"
|
||||||
|
|
||||||
# Parse pagination params early — needed for grouping in all modes
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -171,7 +172,13 @@ async def get_connections(
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(currentUser)
|
||||||
groupCtx = handleGroupingInRequest(paginationParams, interface, CONTEXT_KEY)
|
|
||||||
|
# Resolve view and merge config into params
|
||||||
|
viewKey = paginationParams.viewKey if paginationParams else None
|
||||||
|
viewConfig, viewDisplayName = resolveView(interface, CONTEXT_KEY, viewKey)
|
||||||
|
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
|
||||||
|
paginationParams = applyViewToParams(paginationParams, viewConfig)
|
||||||
|
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
|
||||||
|
|
||||||
def _buildEnhancedItems():
|
def _buildEnhancedItems():
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
connections = interface.getUserConnections(currentUser.id)
|
||||||
|
|
@ -200,7 +207,6 @@ 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)}")
|
||||||
|
|
@ -208,19 +214,60 @@ async def get_connections(
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
try:
|
try:
|
||||||
items = applyGroupScopeFilter(_buildEnhancedItems(), groupCtx.itemIds)
|
return handleIdsInMemory(_buildEnhancedItems(), pagination)
|
||||||
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:
|
if mode == "groupSummary":
|
||||||
# NOTE: Cannot use db.getRecordsetPaginated() here because each connection
|
if not pagination:
|
||||||
# is enriched with computed tokenStatus/tokenExpiresAt (requires per-row DB lookup).
|
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
|
||||||
# Token refresh also may trigger re-fetch. Connections per user are typically < 10,
|
from modules.routes.routeHelpers import (
|
||||||
# so in-memory pagination is acceptable.
|
apply_strategy_b_filters_and_sort,
|
||||||
|
build_group_summary_groups,
|
||||||
|
)
|
||||||
|
if not groupByLevels or not groupByLevels[0].get("field"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="groupByLevels[0].field required for groupSummary",
|
||||||
|
)
|
||||||
|
field = groupByLevels[0]["field"]
|
||||||
|
null_label = str(groupByLevels[0].get("nullLabel") or "—")
|
||||||
|
connections = interface.getUserConnections(currentUser.id)
|
||||||
|
try:
|
||||||
|
refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id)
|
||||||
|
if refresh_result.get("refreshed", 0) > 0:
|
||||||
|
logger.info(
|
||||||
|
"Silently refreshed %s tokens for user %s (groupSummary)",
|
||||||
|
refresh_result["refreshed"],
|
||||||
|
currentUser.id,
|
||||||
|
)
|
||||||
|
connections = interface.getUserConnections(currentUser.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Silent token refresh failed for user {currentUser.id}: {str(e)}")
|
||||||
|
enhanced_connections_dict = []
|
||||||
|
for connection in connections:
|
||||||
|
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
||||||
|
enhanced_connections_dict.append({
|
||||||
|
"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,
|
||||||
|
"status": connection.status.value if hasattr(connection.status, 'value') else str(connection.status),
|
||||||
|
"connectedAt": connection.connectedAt,
|
||||||
|
"lastChecked": connection.lastChecked,
|
||||||
|
"expiresAt": connection.expiresAt,
|
||||||
|
"tokenStatus": tokenStatus,
|
||||||
|
"tokenExpiresAt": tokenExpiresAt
|
||||||
|
})
|
||||||
|
enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection)
|
||||||
|
filtered = apply_strategy_b_filters_and_sort(enhanced_connections_dict, paginationParams, currentUser)
|
||||||
|
groups_out = build_group_summary_groups(filtered, field, null_label)
|
||||||
|
return JSONResponse(content={"groups": groups_out})
|
||||||
|
|
||||||
# SECURITY FIX: All users (including admins) can only see their own connections
|
try:
|
||||||
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
|
||||||
|
|
@ -235,7 +282,7 @@ async def get_connections(
|
||||||
enhanced_connections_dict = []
|
enhanced_connections_dict = []
|
||||||
for connection in connections:
|
for connection in connections:
|
||||||
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
||||||
connection_dict = {
|
enhanced_connections_dict.append({
|
||||||
"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),
|
||||||
|
|
@ -248,46 +295,31 @@ async def get_connections(
|
||||||
"expiresAt": connection.expiresAt,
|
"expiresAt": connection.expiresAt,
|
||||||
"tokenStatus": tokenStatus,
|
"tokenStatus": tokenStatus,
|
||||||
"tokenExpiresAt": tokenExpiresAt
|
"tokenExpiresAt": tokenExpiresAt
|
||||||
}
|
})
|
||||||
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, "pagination": None}
|
||||||
"items": enhanced_connections_dict,
|
|
||||||
"pagination": None,
|
|
||||||
"groupTree": groupCtx.groupTree,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Apply filtering if provided
|
# Apply filtering and sorting over full list (Strategy B)
|
||||||
|
component_interface = ComponentObjects()
|
||||||
|
component_interface.setUserContext(currentUser)
|
||||||
if paginationParams.filters:
|
if paginationParams.filters:
|
||||||
component_interface = ComponentObjects()
|
enhanced_connections_dict = component_interface._applyFilters(enhanced_connections_dict, paginationParams.filters)
|
||||||
component_interface.setUserContext(currentUser)
|
|
||||||
enhanced_connections_dict = component_interface._applyFilters(
|
|
||||||
enhanced_connections_dict,
|
|
||||||
paginationParams.filters
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply sorting if provided
|
|
||||||
if paginationParams.sort:
|
if paginationParams.sort:
|
||||||
component_interface = ComponentObjects()
|
enhanced_connections_dict = component_interface._applySorting(enhanced_connections_dict, paginationParams.sort)
|
||||||
component_interface.setUserContext(currentUser)
|
|
||||||
enhanced_connections_dict = component_interface._applySorting(
|
|
||||||
enhanced_connections_dict,
|
|
||||||
paginationParams.sort
|
|
||||||
)
|
|
||||||
|
|
||||||
totalItems = len(enhanced_connections_dict)
|
totalItems = len(enhanced_connections_dict)
|
||||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
|
|
||||||
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
# Strategy B grouping: operates on full filtered+sorted list, then slices
|
||||||
endIdx = startIdx + paginationParams.pageSize
|
page_items, groupLayout = buildGroupLayout(
|
||||||
paged_connections = enhanced_connections_dict[startIdx:endIdx]
|
enhanced_connections_dict, groupByLevels, paginationParams.page, paginationParams.pageSize
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
response: dict = {
|
||||||
"items": paged_connections,
|
"items": page_items,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
currentPage=paginationParams.page,
|
currentPage=paginationParams.page,
|
||||||
pageSize=paginationParams.pageSize,
|
pageSize=paginationParams.pageSize,
|
||||||
|
|
@ -296,9 +328,13 @@ async def get_connections(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
"groupTree": groupCtx.groupTree,
|
|
||||||
}
|
}
|
||||||
|
if groupLayout:
|
||||||
|
response["groupLayout"] = groupLayout.model_dump()
|
||||||
|
if viewMeta:
|
||||||
|
response["appliedView"] = viewMeta.model_dump()
|
||||||
|
return response
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from fastapi.responses import JSONResponse
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
||||||
|
|
@ -500,9 +501,10 @@ def get_files(
|
||||||
from modules.routes.routeHelpers import (
|
from modules.routes.routeHelpers import (
|
||||||
handleIdsMode,
|
handleIdsMode,
|
||||||
handleFilterValuesInMemory,
|
handleFilterValuesInMemory,
|
||||||
handleGroupingInRequest, applyGroupScopeFilter,
|
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
|
||||||
)
|
)
|
||||||
import modules.interfaces.interfaceDbApp as _appIface
|
import modules.interfaces.interfaceDbApp as _appIface
|
||||||
|
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
||||||
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
currentUser,
|
currentUser,
|
||||||
|
|
@ -510,11 +512,40 @@ def get_files(
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
)
|
)
|
||||||
appInterface = _appIface.getInterface(currentUser)
|
appInterface = _appIface.getInterface(currentUser)
|
||||||
groupCtx = handleGroupingInRequest(paginationParams, appInterface, "files/list")
|
|
||||||
|
# Resolve view and merge config into params
|
||||||
|
viewKey = paginationParams.viewKey if paginationParams else None
|
||||||
|
viewConfig, viewDisplayName = resolveView(appInterface, "files/list", viewKey)
|
||||||
|
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
|
||||||
|
paginationParams = applyViewToParams(paginationParams, viewConfig)
|
||||||
|
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
|
||||||
|
|
||||||
def _filesToDicts(fileItems):
|
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]
|
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 == "groupSummary":
|
||||||
|
if not pagination:
|
||||||
|
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
|
||||||
|
from modules.routes.routeHelpers import (
|
||||||
|
apply_strategy_b_filters_and_sort,
|
||||||
|
build_group_summary_groups,
|
||||||
|
)
|
||||||
|
if not groupByLevels or not groupByLevels[0].get("field"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="groupByLevels[0].field required for groupSummary",
|
||||||
|
)
|
||||||
|
field = groupByLevels[0]["field"]
|
||||||
|
null_label = str(groupByLevels[0].get("nullLabel") or "—")
|
||||||
|
allFiles = managementInterface.getAllFiles()
|
||||||
|
allItems = enrichRowsWithFkLabels(
|
||||||
|
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
||||||
|
FileItem,
|
||||||
|
)
|
||||||
|
filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
||||||
|
groups_out = build_group_summary_groups(filtered, field, null_label)
|
||||||
|
return JSONResponse(content={"groups": groups_out})
|
||||||
|
|
||||||
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")
|
||||||
|
|
@ -522,33 +553,72 @@ def get_files(
|
||||||
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 = _filesToDicts(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":
|
||||||
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
||||||
return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter)
|
return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter)
|
||||||
|
|
||||||
result = managementInterface.getAllFiles(pagination=paginationParams)
|
if not groupByLevels:
|
||||||
|
# No grouping: let DB handle pagination directly (fastest path)
|
||||||
|
result = managementInterface.getAllFiles(pagination=paginationParams)
|
||||||
|
if paginationParams and hasattr(result, 'items'):
|
||||||
|
enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem)
|
||||||
|
resp: dict = {
|
||||||
|
"items": enriched,
|
||||||
|
"pagination": PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=result.totalItems,
|
||||||
|
totalPages=result.totalPages,
|
||||||
|
sort=paginationParams.sort,
|
||||||
|
filters=paginationParams.filters
|
||||||
|
).model_dump(),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result])
|
||||||
|
resp = {"items": enrichRowsWithFkLabels(_filesToDicts(items), FileItem), "pagination": None}
|
||||||
|
if viewMeta:
|
||||||
|
resp["appliedView"] = viewMeta.model_dump()
|
||||||
|
return resp
|
||||||
|
|
||||||
if paginationParams:
|
# Strategy B grouping: load full list, group, then slice
|
||||||
enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem), groupCtx.itemIds)
|
allFiles = managementInterface.getAllFiles()
|
||||||
return {
|
allItems = enrichRowsWithFkLabels(
|
||||||
"items": enriched,
|
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
||||||
"pagination": PaginationMetadata(
|
FileItem,
|
||||||
currentPage=paginationParams.page,
|
)
|
||||||
pageSize=paginationParams.pageSize,
|
|
||||||
totalItems=result.totalItems,
|
from modules.routes.routeHelpers import apply_strategy_b_filters_and_sort
|
||||||
totalPages=result.totalPages,
|
if paginationParams.filters or paginationParams.sort:
|
||||||
sort=paginationParams.sort,
|
allItems = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
||||||
filters=paginationParams.filters
|
|
||||||
).model_dump(),
|
if not paginationParams:
|
||||||
"groupTree": groupCtx.groupTree,
|
resp = {"items": allItems, "pagination": None}
|
||||||
}
|
if viewMeta:
|
||||||
else:
|
resp["appliedView"] = viewMeta.model_dump()
|
||||||
items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result])
|
return resp
|
||||||
enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(items), FileItem), groupCtx.itemIds)
|
|
||||||
return {"items": enriched, "pagination": None, "groupTree": groupCtx.groupTree}
|
totalItems = len(allItems)
|
||||||
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
|
page_items, groupLayout = buildGroupLayout(allItems, groupByLevels, paginationParams.page, paginationParams.pageSize)
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
"items": page_items,
|
||||||
|
"pagination": PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=totalItems,
|
||||||
|
totalPages=totalPages,
|
||||||
|
sort=paginationParams.sort,
|
||||||
|
filters=paginationParams.filters
|
||||||
|
).model_dump(),
|
||||||
|
}
|
||||||
|
if groupLayout:
|
||||||
|
resp["groupLayout"] = groupLayout.model_dump()
|
||||||
|
if viewMeta:
|
||||||
|
resp["appliedView"] = viewMeta.model_dump()
|
||||||
|
return resp
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -559,34 +629,11 @@ def get_files(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _addFileToGroup(appInterface, fileId: str, groupId: str, contextKey: str = "files/list"):
|
def _LEGACY_addFileToGroup_REMOVED():
|
||||||
"""Add a file to a group in the persisted groupTree (upsert)."""
|
"""Removed — file-group tree no longer exists. Use multi-select bulk operations."""
|
||||||
from modules.routes.routeHelpers import _collectItemIds
|
pass
|
||||||
try:
|
|
||||||
existing = appInterface.getTableGrouping(contextKey)
|
|
||||||
if not existing:
|
|
||||||
return
|
|
||||||
nodes = [n.model_dump() if hasattr(n, 'model_dump') else n for n in existing.rootGroups]
|
|
||||||
def _add(nds):
|
|
||||||
for nd in nds:
|
|
||||||
nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
|
|
||||||
if nid == groupId:
|
|
||||||
itemIds = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", []))
|
|
||||||
if fileId not in itemIds:
|
|
||||||
itemIds.append(fileId)
|
|
||||||
if isinstance(nd, dict):
|
|
||||||
nd["itemIds"] = itemIds
|
|
||||||
else:
|
|
||||||
nd.itemIds = itemIds
|
|
||||||
return True
|
|
||||||
subs = nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", [])
|
|
||||||
if _add(subs):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
_add(nodes)
|
|
||||||
appInterface.upsertTableGrouping(contextKey, nodes)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"_addFileToGroup failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
||||||
|
|
@ -596,7 +643,6 @@ async def upload_file(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
workflowId: Optional[str] = Form(None),
|
workflowId: Optional[str] = Form(None),
|
||||||
featureInstanceId: Optional[str] = Form(None),
|
featureInstanceId: Optional[str] = Form(None),
|
||||||
groupId: Optional[str] = Form(None),
|
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
|
|
@ -630,12 +676,6 @@ async def upload_file(
|
||||||
managementInterface.updateFile(fileItem.id, {"featureInstanceId": featureInstanceId})
|
managementInterface.updateFile(fileItem.id, {"featureInstanceId": featureInstanceId})
|
||||||
fileItem.featureInstanceId = featureInstanceId
|
fileItem.featureInstanceId = featureInstanceId
|
||||||
|
|
||||||
# Add to group if groupId was provided
|
|
||||||
if groupId:
|
|
||||||
import modules.interfaces.interfaceDbApp as _appIface
|
|
||||||
appInterface = _appIface.getInterface(currentUser)
|
|
||||||
_addFileToGroup(appInterface, fileItem.id, groupId)
|
|
||||||
|
|
||||||
# Determine response message based on duplicate type
|
# Determine response message based on duplicate type
|
||||||
if duplicateType == "exact_duplicate":
|
if duplicateType == "exact_duplicate":
|
||||||
message = f"File '{file.filename}' already exists with identical content. Reusing existing file."
|
message = f"File '{file.filename}' already exists with identical content. Reusing existing file."
|
||||||
|
|
@ -843,82 +883,68 @@ def batchDownload(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# ── Group bulk endpoints ──────────────────────────────────────────────────────
|
# ── Bulk file operations (replace former group-based bulk routes) ─────────────
|
||||||
|
|
||||||
def _get_group_item_ids(contextKey: str, groupId: str, appInterface) -> set:
|
@router.post("/bulk/scope")
|
||||||
"""Collect all file IDs in a group and its sub-groups from the stored groupTree."""
|
@limiter.limit("30/minute")
|
||||||
from modules.routes.routeHelpers import _collectItemIds
|
def bulk_set_scope(
|
||||||
try:
|
|
||||||
existing = appInterface.getTableGrouping(contextKey)
|
|
||||||
if not existing:
|
|
||||||
return set()
|
|
||||||
nodes = [n.model_dump() if hasattr(n, 'model_dump') else n for n in existing.rootGroups]
|
|
||||||
result = _collectItemIds(nodes, groupId)
|
|
||||||
return result or set()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"_get_group_item_ids failed for groupId={groupId}: {e}")
|
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/groups/{groupId}/scope")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def patch_group_scope(
|
|
||||||
request: Request,
|
request: Request,
|
||||||
groupId: str = Path(..., description="Group ID"),
|
|
||||||
body: dict = Body(...),
|
body: dict = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""Set scope for all files in a group (recursive)."""
|
"""Set scope for a list of files by their IDs."""
|
||||||
scope = body.get("scope")
|
fileIds: list = body.get("fileIds") or []
|
||||||
if not scope:
|
scope: str = body.get("scope") or ""
|
||||||
raise HTTPException(status_code=400, detail="scope is required")
|
if not fileIds:
|
||||||
|
raise HTTPException(status_code=400, detail="fileIds is required")
|
||||||
|
validScopes = {"personal", "featureInstance", "mandate", "global"}
|
||||||
|
if scope not in validScopes:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid scope. Must be one of {validScopes}")
|
||||||
|
if scope == "global" and not context.isSysAdmin:
|
||||||
|
raise HTTPException(status_code=403, detail="Only sysadmins can set global scope")
|
||||||
try:
|
try:
|
||||||
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)
|
|
||||||
fileIds = _get_group_item_ids("files/list", groupId, appInterface)
|
|
||||||
updated = 0
|
updated = 0
|
||||||
for fid in fileIds:
|
for fid in fileIds:
|
||||||
try:
|
try:
|
||||||
managementInterface.updateFile(fid, {"scope": scope})
|
managementInterface.updateFile(fid, {"scope": scope})
|
||||||
updated += 1
|
updated += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"patch_group_scope: failed to update file {fid}: {e}")
|
logger.error(f"bulk_set_scope: failed for file {fid}: {e}")
|
||||||
return {"groupId": groupId, "scope": scope, "filesUpdated": updated}
|
return {"scope": scope, "filesUpdated": updated}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"patch_group_scope error: {e}")
|
logger.error(f"bulk_set_scope error: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/groups/{groupId}/neutralize")
|
@router.post("/bulk/neutralize")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("30/minute")
|
||||||
def patch_group_neutralize(
|
def bulk_set_neutralize(
|
||||||
request: Request,
|
request: Request,
|
||||||
groupId: str = Path(..., description="Group ID"),
|
|
||||||
body: dict = Body(...),
|
body: dict = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""Toggle neutralize for all files in a group (recursive, incl. knowledge purge/reindex)."""
|
"""Set neutralize flag for a list of files by their IDs (incl. knowledge purge/reindex)."""
|
||||||
|
fileIds: list = body.get("fileIds") or []
|
||||||
neutralize = body.get("neutralize")
|
neutralize = body.get("neutralize")
|
||||||
|
if not fileIds:
|
||||||
|
raise HTTPException(status_code=400, detail="fileIds is required")
|
||||||
if neutralize is None:
|
if neutralize is None:
|
||||||
raise HTTPException(status_code=400, detail="neutralize is required")
|
raise HTTPException(status_code=400, detail="neutralize is required")
|
||||||
try:
|
try:
|
||||||
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)
|
|
||||||
fileIds = _get_group_item_ids("files/list", groupId, appInterface)
|
|
||||||
updated = 0
|
updated = 0
|
||||||
for fid in fileIds:
|
for fid in fileIds:
|
||||||
try:
|
try:
|
||||||
|
|
@ -929,39 +955,37 @@ def patch_group_neutralize(
|
||||||
kIface = interfaceDbKnowledge.getInterface(currentUser)
|
kIface = interfaceDbKnowledge.getInterface(currentUser)
|
||||||
kIface.purgeFileKnowledge(fid)
|
kIface.purgeFileKnowledge(fid)
|
||||||
except Exception as ke:
|
except Exception as ke:
|
||||||
logger.warning(f"patch_group_neutralize: knowledge purge failed for {fid}: {ke}")
|
logger.warning(f"bulk_set_neutralize: knowledge purge failed for {fid}: {ke}")
|
||||||
updated += 1
|
updated += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"patch_group_neutralize: failed for file {fid}: {e}")
|
logger.error(f"bulk_set_neutralize: failed for file {fid}: {e}")
|
||||||
return {"groupId": groupId, "neutralize": neutralize, "filesUpdated": updated}
|
return {"neutralize": neutralize, "filesUpdated": updated}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"patch_group_neutralize error: {e}")
|
logger.error(f"bulk_set_neutralize error: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/groups/{groupId}/download")
|
@router.post("/bulk/download-zip")
|
||||||
@limiter.limit("20/minute")
|
@limiter.limit("10/minute")
|
||||||
async def download_group_zip(
|
async def bulk_download_zip(
|
||||||
request: Request,
|
request: Request,
|
||||||
groupId: str = Path(..., description="Group ID"),
|
body: dict = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""Download all files in a group as a ZIP archive."""
|
"""Download a list of files as a ZIP archive."""
|
||||||
import io, zipfile
|
import io, zipfile
|
||||||
|
fileIds: list = body.get("fileIds") or []
|
||||||
|
if not fileIds:
|
||||||
|
raise HTTPException(status_code=400, detail="fileIds is required")
|
||||||
try:
|
try:
|
||||||
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)
|
|
||||||
fileIds = _get_group_item_ids("files/list", groupId, appInterface)
|
|
||||||
if not fileIds:
|
|
||||||
raise HTTPException(status_code=404, detail="Group not found or empty")
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
for fid in fileIds:
|
for fid in fileIds:
|
||||||
|
|
@ -969,63 +993,21 @@ async def download_group_zip(
|
||||||
fileMeta = managementInterface.getFile(fid)
|
fileMeta = managementInterface.getFile(fid)
|
||||||
fileData = managementInterface.getFileData(fid)
|
fileData = managementInterface.getFileData(fid)
|
||||||
if fileMeta and fileData:
|
if fileMeta and fileData:
|
||||||
name = (fileMeta.get("fileName") if isinstance(fileMeta, dict) else getattr(fileMeta, "fileName", fid)) or fid
|
name = (getattr(fileMeta, "fileName", None) or fid)
|
||||||
zf.writestr(name, fileData)
|
zf.writestr(name, fileData)
|
||||||
except Exception as fe:
|
except Exception as fe:
|
||||||
logger.warning(f"download_group_zip: skipping file {fid}: {fe}")
|
logger.warning(f"bulk_download_zip: skipping file {fid}: {fe}")
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
buf,
|
buf,
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
headers={"Content-Disposition": f'attachment; filename="group-{groupId}.zip"'},
|
headers={"Content-Disposition": 'attachment; filename="files.zip"'},
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"download_group_zip error: {e}")
|
logger.error(f"bulk_download_zip error: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/groups/{groupId}")
|
|
||||||
@limiter.limit("30/minute")
|
|
||||||
def delete_group(
|
|
||||||
request: Request,
|
|
||||||
groupId: str = Path(..., description="Group ID"),
|
|
||||||
deleteItems: bool = Query(False, description="If true, also delete all files in the group"),
|
|
||||||
currentUser: User = Depends(getCurrentUser),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""Remove a group from the groupTree. Optionally delete all its files."""
|
|
||||||
try:
|
|
||||||
import modules.interfaces.interfaceDbApp as _appIface
|
|
||||||
appInterface = _appIface.getInterface(currentUser)
|
|
||||||
fileIds = _get_group_item_ids("files/list", groupId, appInterface)
|
|
||||||
# Remove group from tree
|
|
||||||
existing = appInterface.getTableGrouping("files/list")
|
|
||||||
if existing:
|
|
||||||
from modules.routes.routeHelpers import _removeGroupFromTree
|
|
||||||
newRoots = _removeGroupFromTree([n.model_dump() if hasattr(n, 'model_dump') else n for n in existing.rootGroups], groupId)
|
|
||||||
appInterface.upsertTableGrouping("files/list", newRoots)
|
|
||||||
# Optionally delete files
|
|
||||||
deletedFiles = 0
|
|
||||||
if deleteItems:
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
|
||||||
currentUser,
|
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
|
||||||
)
|
|
||||||
for fid in fileIds:
|
|
||||||
try:
|
|
||||||
managementInterface.deleteFile(fid)
|
|
||||||
deletedFiles += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"delete_group: failed to delete file {fid}: {e}")
|
|
||||||
return {"groupId": groupId, "deletedFiles": deletedFiles}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"delete_group error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,11 +131,9 @@ 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():
|
def _mandateItemsForAdmin():
|
||||||
items = []
|
items = []
|
||||||
|
|
@ -154,23 +152,18 @@ 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 = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
|
return handleFilterValuesInMemory(_mandateItemsForAdmin(), 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 = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
|
return handleIdsInMemory(_mandateItemsForAdmin(), pagination)
|
||||||
return handleIdsInMemory(mandateItems, pagination)
|
|
||||||
|
|
||||||
if isPlatformAdmin:
|
if isPlatformAdmin:
|
||||||
result = appInterface.getAllMandates(pagination=paginationParams)
|
result = appInterface.getAllMandates(pagination=paginationParams)
|
||||||
items = result.items if hasattr(result, 'items') else (result if isinstance(result, list) else [])
|
items = result.items if hasattr(result, 'items') else (result if isinstance(result, list) else [])
|
||||||
items = applyGroupScopeFilter(
|
items = [i.model_dump() if hasattr(i, 'model_dump') else (i if isinstance(i, dict) else vars(i)) for i in items]
|
||||||
[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'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items=items,
|
items=items,
|
||||||
|
|
@ -182,13 +175,11 @@ def get_mandates(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
),
|
),
|
||||||
groupTree=groupCtx.groupTree,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return PaginatedResponse(items=items, pagination=None, groupTree=groupCtx.groupTree)
|
return PaginatedResponse(items=items, pagination=None)
|
||||||
else:
|
else:
|
||||||
mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
|
return PaginatedResponse(items=_mandateItemsForAdmin(), pagination=None)
|
||||||
return PaginatedResponse(items=mandateItems, pagination=None, groupTree=groupCtx.groupTree)
|
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getCurrentUser
|
||||||
|
|
@ -46,13 +48,13 @@ def get_prompts(
|
||||||
"""
|
"""
|
||||||
from modules.routes.routeHelpers import (
|
from modules.routes.routeHelpers import (
|
||||||
handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels,
|
handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels,
|
||||||
handleGroupingInRequest, applyGroupScopeFilter,
|
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
|
||||||
)
|
)
|
||||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
||||||
|
|
||||||
CONTEXT_KEY = "prompts"
|
CONTEXT_KEY = "prompts"
|
||||||
|
|
||||||
# Parse pagination params early — needed for grouping in all modes
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -64,7 +66,13 @@ def get_prompts(
|
||||||
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)
|
appInterface = getAppInterface(currentUser)
|
||||||
groupCtx = handleGroupingInRequest(paginationParams, appInterface, CONTEXT_KEY)
|
|
||||||
|
# Resolve view and merge config into params
|
||||||
|
viewKey = paginationParams.viewKey if paginationParams else None
|
||||||
|
viewConfig, viewDisplayName = resolveView(appInterface, CONTEXT_KEY, viewKey)
|
||||||
|
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
|
||||||
|
paginationParams = applyViewToParams(paginationParams, viewConfig)
|
||||||
|
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
|
||||||
|
|
||||||
def _promptsToEnrichedDicts(promptItems):
|
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]
|
dicts = [r.model_dump() if hasattr(r, 'model_dump') else (dict(r) if not isinstance(r, dict) else r) for r in promptItems]
|
||||||
|
|
@ -73,43 +81,98 @@ def get_prompts(
|
||||||
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||||
|
|
||||||
|
if mode == "groupSummary":
|
||||||
|
if not pagination:
|
||||||
|
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
|
||||||
|
from modules.routes.routeHelpers import (
|
||||||
|
apply_strategy_b_filters_and_sort,
|
||||||
|
build_group_summary_groups,
|
||||||
|
)
|
||||||
|
if not groupByLevels or not groupByLevels[0].get("field"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="groupByLevels[0].field required for groupSummary",
|
||||||
|
)
|
||||||
|
field = groupByLevels[0]["field"]
|
||||||
|
null_label = str(groupByLevels[0].get("nullLabel") or "—")
|
||||||
|
result = managementInterface.getAllPrompts(pagination=None)
|
||||||
|
allItems = _promptsToEnrichedDicts(
|
||||||
|
result if isinstance(result, list) else (result.items if hasattr(result, "items") else [])
|
||||||
|
)
|
||||||
|
filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
||||||
|
groups_out = build_group_summary_groups(filtered, field, null_label)
|
||||||
|
return JSONResponse(content={"groups": groups_out})
|
||||||
|
|
||||||
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")
|
||||||
result = managementInterface.getAllPrompts(pagination=None)
|
result = managementInterface.getAllPrompts(pagination=None)
|
||||||
items = _promptsToEnrichedDicts(result)
|
return handleFilterValuesInMemory(_promptsToEnrichedDicts(result), column, pagination)
|
||||||
items = applyGroupScopeFilter(items, groupCtx.itemIds)
|
|
||||||
return handleFilterValuesInMemory(items, column, pagination)
|
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
result = managementInterface.getAllPrompts(pagination=None)
|
result = managementInterface.getAllPrompts(pagination=None)
|
||||||
items = _promptsToEnrichedDicts(result)
|
return handleIdsInMemory(_promptsToEnrichedDicts(result), pagination)
|
||||||
items = applyGroupScopeFilter(items, groupCtx.itemIds)
|
|
||||||
return handleIdsInMemory(items, pagination)
|
|
||||||
|
|
||||||
result = managementInterface.getAllPrompts(pagination=paginationParams)
|
if not groupByLevels:
|
||||||
|
# No grouping: let DB handle pagination directly
|
||||||
|
result = managementInterface.getAllPrompts(pagination=paginationParams)
|
||||||
|
if paginationParams and hasattr(result, 'items'):
|
||||||
|
response: dict = {
|
||||||
|
"items": _promptsToEnrichedDicts(result.items),
|
||||||
|
"pagination": PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=result.totalItems,
|
||||||
|
totalPages=result.totalPages,
|
||||||
|
sort=paginationParams.sort,
|
||||||
|
filters=paginationParams.filters
|
||||||
|
).model_dump(),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
response = {"items": _promptsToEnrichedDicts(result if isinstance(result, list) else [result]), "pagination": None}
|
||||||
|
if viewMeta:
|
||||||
|
response["appliedView"] = viewMeta.model_dump()
|
||||||
|
return response
|
||||||
|
|
||||||
if paginationParams:
|
# Strategy B grouping: load all, filter+sort in-memory, group, then slice
|
||||||
items = applyGroupScopeFilter(_promptsToEnrichedDicts(result.items), groupCtx.itemIds)
|
result = managementInterface.getAllPrompts(pagination=None)
|
||||||
return {
|
allItems = _promptsToEnrichedDicts(result if isinstance(result, list) else (result.items if hasattr(result, 'items') else []))
|
||||||
"items": items,
|
|
||||||
"pagination": PaginationMetadata(
|
if not paginationParams:
|
||||||
currentPage=paginationParams.page,
|
response = {"items": allItems, "pagination": None}
|
||||||
pageSize=paginationParams.pageSize,
|
if viewMeta:
|
||||||
totalItems=result.totalItems,
|
response["appliedView"] = viewMeta.model_dump()
|
||||||
totalPages=result.totalPages,
|
return response
|
||||||
sort=paginationParams.sort,
|
|
||||||
filters=paginationParams.filters
|
if paginationParams.filters or paginationParams.sort:
|
||||||
).model_dump(),
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
"groupTree": groupCtx.groupTree,
|
comp = ComponentObjects()
|
||||||
}
|
comp.setUserContext(currentUser)
|
||||||
else:
|
if paginationParams.filters:
|
||||||
items = applyGroupScopeFilter(_promptsToEnrichedDicts(result), groupCtx.itemIds)
|
allItems = comp._applyFilters(allItems, paginationParams.filters)
|
||||||
return {
|
if paginationParams.sort:
|
||||||
"items": items,
|
allItems = comp._applySorting(allItems, paginationParams.sort)
|
||||||
"pagination": None,
|
|
||||||
"groupTree": groupCtx.groupTree,
|
totalItems = len(allItems)
|
||||||
}
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
|
page_items, groupLayout = buildGroupLayout(allItems, groupByLevels, paginationParams.page, paginationParams.pageSize)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"items": page_items,
|
||||||
|
"pagination": PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=totalItems,
|
||||||
|
totalPages=totalPages,
|
||||||
|
sort=paginationParams.sort,
|
||||||
|
filters=paginationParams.filters
|
||||||
|
).model_dump(),
|
||||||
|
}
|
||||||
|
if groupLayout:
|
||||||
|
response["groupLayout"] = groupLayout.model_dump()
|
||||||
|
if viewMeta:
|
||||||
|
response["appliedView"] = viewMeta.model_dump()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=Prompt)
|
@router.post("", response_model=Prompt)
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,6 @@ 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
|
_paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -219,10 +218,6 @@ def get_users(
|
||||||
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)}")
|
||||||
|
|
||||||
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")
|
||||||
|
|
@ -233,14 +228,12 @@ def get_users(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
paginationParams = _paginationParams
|
paginationParams = _paginationParams
|
||||||
appInterface = _appInterfaceForGrouping
|
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
|
||||||
|
|
||||||
if context.mandateId:
|
|
||||||
# Get users for specific mandate using getUsersByMandate
|
|
||||||
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
|
|
||||||
|
|
||||||
|
if context.mandateId:
|
||||||
|
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds)
|
enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -251,18 +244,14 @@ 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 = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(users), User), _groupCtx.itemIds)
|
return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None}
|
||||||
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
|
|
||||||
elif context.isPlatformAdmin:
|
elif context.isPlatformAdmin:
|
||||||
# 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 = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds)
|
enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -273,18 +262,13 @@ 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 = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(users), User), _groupCtx.itemIds)
|
return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None}
|
||||||
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
|
|
||||||
else:
|
else:
|
||||||
# Non-SysAdmin without mandateId: aggregate users across all admin mandates
|
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
||||||
|
|
||||||
# Find mandates where user has admin role
|
|
||||||
adminMandateIds = []
|
adminMandateIds = []
|
||||||
for um in userMandates:
|
for um in userMandates:
|
||||||
umId = getattr(um, 'id', None)
|
umId = getattr(um, 'id', None)
|
||||||
|
|
@ -297,13 +281,10 @@ def get_users(
|
||||||
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
adminMandateIds.append(str(mandateId))
|
adminMandateIds.append(str(mandateId))
|
||||||
break
|
break
|
||||||
|
|
||||||
if not adminMandateIds:
|
if not adminMandateIds:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("No admin access to any mandate"))
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=routeApiMsg("No admin access to any mandate")
|
|
||||||
)
|
|
||||||
|
|
||||||
from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel
|
from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel
|
||||||
allUM = rootInterface.db.getRecordset(UserMandateModel, recordFilter={"mandateId": adminMandateIds})
|
allUM = rootInterface.db.getRecordset(UserMandateModel, recordFilter={"mandateId": adminMandateIds})
|
||||||
uniqueUserIds = list({
|
uniqueUserIds = list({
|
||||||
|
|
@ -312,13 +293,10 @@ def get_users(
|
||||||
if (um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
|
if (um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
|
||||||
})
|
})
|
||||||
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
|
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
|
||||||
allUsers = [
|
allUsers = [u.model_dump() if hasattr(u, 'model_dump') else vars(u) for u in batchUsers.values()]
|
||||||
u.model_dump() if hasattr(u, 'model_dump') else vars(u)
|
|
||||||
for u in batchUsers.values()
|
|
||||||
]
|
|
||||||
|
|
||||||
from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper
|
from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper
|
||||||
filteredUsers = _applyGroupScope(_applyFiltersAndSortHelper(allUsers, paginationParams), _groupCtx.itemIds)
|
filteredUsers = _applyFiltersAndSortHelper(allUsers, paginationParams)
|
||||||
enriched = enrichRowsWithFkLabels(filteredUsers, User)
|
enriched = enrichRowsWithFkLabels(filteredUsers, User)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -327,7 +305,6 @@ def get_users(
|
||||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||||
endIdx = startIdx + paginationParams.pageSize
|
endIdx = startIdx + paginationParams.pageSize
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"items": enriched[startIdx:endIdx],
|
"items": enriched[startIdx:endIdx],
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -338,10 +315,9 @@ 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, "groupTree": _groupCtx.groupTree}
|
return {"items": enriched, "pagination": None}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -704,154 +704,260 @@ def paginateInMemory(
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Table Grouping helpers
|
# View resolution and Strategy B grouping engine
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
from dataclasses import dataclass, field as dc_field
|
def resolveView(interface, contextKey: str, viewKey: Optional[str]):
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GroupingContext:
|
|
||||||
"""
|
"""
|
||||||
Result of handleGroupingInRequest.
|
Load a TableListView for the current user and contextKey.
|
||||||
Carries the group tree for the response and the resolved item-ID set for
|
|
||||||
group-scope filtering (None = no active group scope).
|
Returns (config_dict, display_name):
|
||||||
|
- (None, None) when viewKey is None / empty
|
||||||
|
- (config, str | None) otherwise — config may be {}; display_name from the row
|
||||||
|
|
||||||
|
Raises HTTPException(404) when viewKey is explicitly set but the view
|
||||||
|
does not exist (prevents silent fallback to ungrouped behaviour).
|
||||||
"""
|
"""
|
||||||
groupTree: Optional[list] # List[TableGroupNode] serialised as dicts — for response
|
from fastapi import HTTPException
|
||||||
itemIds: Optional[set] # Set[str] when groupId was set, else None
|
if not viewKey:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
view = interface.getTableListView(contextKey=contextKey, viewKey=viewKey)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"resolveView: store lookup failed for key={viewKey!r} context={contextKey!r}: {e}")
|
||||||
|
view = None
|
||||||
|
if view is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"View '{viewKey}' not found for context '{contextKey}'")
|
||||||
|
cfg = view.config or {}
|
||||||
|
dname = getattr(view, "displayName", None) or None
|
||||||
|
return cfg, dname
|
||||||
|
|
||||||
|
|
||||||
def _collectItemIds(nodes: list, groupId: str) -> Optional[set]:
|
def effective_group_by_levels(
|
||||||
|
pagination_params: Optional["PaginationParams"],
|
||||||
|
view_config: Optional[dict],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Recursively search *nodes* for a node whose id == groupId and collect
|
Choose grouping levels for this request.
|
||||||
all itemIds from it and all its descendant subGroups.
|
|
||||||
Returns None if the group is not found.
|
If the client sends ``groupByLevels`` (including ``[]``), it wins over the
|
||||||
|
saved view. If the key is omitted (``None``), use the view's levels.
|
||||||
"""
|
"""
|
||||||
for node in nodes:
|
if pagination_params is not None:
|
||||||
nodeId = node.get("id") if isinstance(node, dict) else getattr(node, "id", None)
|
req = getattr(pagination_params, "groupByLevels", None)
|
||||||
if nodeId == groupId:
|
if req is not None:
|
||||||
ids: set = set()
|
out: List[Dict[str, Any]] = []
|
||||||
_collectAllIds(node, ids)
|
for lvl in req:
|
||||||
return ids
|
if hasattr(lvl, "model_dump"):
|
||||||
subGroups = node.get("subGroups", []) if isinstance(node, dict) else getattr(node, "subGroups", [])
|
out.append(lvl.model_dump())
|
||||||
result = _collectItemIds(subGroups, groupId)
|
elif isinstance(lvl, dict):
|
||||||
if result is not None:
|
out.append(dict(lvl))
|
||||||
return result
|
else:
|
||||||
return None
|
out.append(dict(lvl)) # type: ignore[arg-type]
|
||||||
|
return out
|
||||||
|
vc = (view_config or {}).get("groupByLevels") if view_config else None
|
||||||
|
return list(vc or [])
|
||||||
|
|
||||||
|
|
||||||
def _collectAllIds(node, ids: set) -> None:
|
def applyViewToParams(params: Optional["PaginationParams"], viewConfig: Optional[dict]) -> Optional["PaginationParams"]:
|
||||||
"""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 _removeGroupFromTree(nodes: list, groupId: str) -> list:
|
|
||||||
"""Remove a group node (and all descendants) from the tree by id."""
|
|
||||||
result = []
|
|
||||||
for node in nodes:
|
|
||||||
nodeId = node.get("id") if isinstance(node, dict) else getattr(node, "id", None)
|
|
||||||
if nodeId == groupId:
|
|
||||||
continue # skip this node (remove it)
|
|
||||||
subGroups = node.get("subGroups", []) if isinstance(node, dict) else getattr(node, "subGroups", [])
|
|
||||||
filtered_sub = _removeGroupFromTree(subGroups, groupId)
|
|
||||||
if isinstance(node, dict):
|
|
||||||
node = {**node, "subGroups": filtered_sub}
|
|
||||||
result.append(node)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def handleGroupingInRequest(
|
|
||||||
paginationParams: Optional[PaginationParams],
|
|
||||||
interface,
|
|
||||||
contextKey: str,
|
|
||||||
) -> GroupingContext:
|
|
||||||
"""
|
"""
|
||||||
Central grouping handler — call at the start of every list route that
|
Merge a view's saved configuration into PaginationParams.
|
||||||
supports table grouping.
|
|
||||||
|
|
||||||
Steps (in order):
|
Priority: explicit request fields win over view defaults.
|
||||||
1. If paginationParams.saveGroupTree is set:
|
- sort: use request sort if non-empty, otherwise view sort
|
||||||
persist the new tree via interface.upsertTableGrouping, then clear
|
- filters: deep-merge (request filters win per-key)
|
||||||
saveGroupTree from paginationParams so it is not treated as a filter.
|
- pageSize: use request value (already set by normalize_pagination_dict)
|
||||||
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
|
Returns the (mutated) params, or a new minimal PaginationParams when
|
||||||
applyGroupScopeFilter(items, groupCtx.itemIds) and embed groupCtx.groupTree
|
params is None (so callers always get a valid object).
|
||||||
in the response dict.
|
|
||||||
"""
|
"""
|
||||||
from modules.datamodels.datamodelPagination import TableGroupNode
|
from modules.datamodels.datamodelPagination import PaginationParams, SortField
|
||||||
|
if not viewConfig:
|
||||||
|
return params
|
||||||
|
|
||||||
groupTree = None
|
if params is None:
|
||||||
itemIds = None
|
params = PaginationParams(page=1, pageSize=25)
|
||||||
|
|
||||||
if paginationParams is None:
|
# Sort: request wins if non-empty
|
||||||
|
if not params.sort and viewConfig.get("sort"):
|
||||||
try:
|
try:
|
||||||
existing = interface.getTableGrouping(contextKey)
|
params.sort = [
|
||||||
if existing:
|
SortField(**s) if isinstance(s, dict) else s
|
||||||
groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in existing.rootGroups]
|
for s in viewConfig["sort"]
|
||||||
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"handleGroupingInRequest: getTableGrouping failed: {e}")
|
logger.warning(f"applyViewToParams: could not parse view sort: {e}")
|
||||||
return GroupingContext(groupTree=groupTree, itemIds=None)
|
|
||||||
|
|
||||||
# Step 1: persist saveGroupTree if present
|
# Filters: deep-merge (request filters take priority per-key)
|
||||||
if paginationParams.saveGroupTree is not None:
|
viewFilters = viewConfig.get("filters") or {}
|
||||||
try:
|
if viewFilters:
|
||||||
saved = interface.upsertTableGrouping(contextKey, paginationParams.saveGroupTree)
|
merged = dict(viewFilters)
|
||||||
groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in saved.rootGroups]
|
if params.filters:
|
||||||
except Exception as e:
|
merged.update(params.filters)
|
||||||
logger.error(f"handleGroupingInRequest: upsertTableGrouping failed: {e}")
|
params.filters = merged
|
||||||
paginationParams.saveGroupTree = None
|
|
||||||
|
|
||||||
# Step 2: load current tree (only if not already set from save above)
|
return params
|
||||||
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:
|
def apply_strategy_b_filters_and_sort(
|
||||||
targetGroupId = paginationParams.groupId
|
items: List[Dict[str, Any]],
|
||||||
paginationParams.groupId = None # remove so it is not treated as a normal filter
|
pagination_params: Optional[PaginationParams],
|
||||||
if groupTree:
|
current_user: Any,
|
||||||
itemIds = _collectItemIds(groupTree, targetGroupId)
|
) -> List[Dict[str, Any]]:
|
||||||
if itemIds is None:
|
"""
|
||||||
logger.warning(
|
Shared in-memory filter + sort pass for Strategy B (files/prompts/connections lists).
|
||||||
f"handleGroupingInRequest: groupId={targetGroupId!r} not found in tree "
|
"""
|
||||||
f"for contextKey={contextKey!r} — returning empty set"
|
if not pagination_params:
|
||||||
)
|
return list(items)
|
||||||
itemIds = set() # unknown group → show nothing rather than everything
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
|
|
||||||
|
comp = ComponentObjects()
|
||||||
|
comp.setUserContext(current_user)
|
||||||
|
out = list(items)
|
||||||
|
if pagination_params.filters:
|
||||||
|
out = comp._applyFilters(out, pagination_params.filters)
|
||||||
|
if pagination_params.sort:
|
||||||
|
out = comp._applySorting(out, pagination_params.sort)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_group_summary_groups(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
field: str,
|
||||||
|
null_label: str = "—",
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Build {"value", "label", "totalCount"} for mode=groupSummary (single grouping level).
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
counts: Dict[str, int] = defaultdict(int)
|
||||||
|
display_by_key: Dict[str, str] = {}
|
||||||
|
null_key = "\x00NULL"
|
||||||
|
label_attr = f"{field}Label"
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
raw = item.get(field)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
nk = null_key
|
||||||
|
display = null_label
|
||||||
else:
|
else:
|
||||||
# groupId sent but no tree saved yet → return empty (nothing belongs to any group)
|
nk = str(raw)
|
||||||
logger.warning(
|
display = None
|
||||||
f"handleGroupingInRequest: groupId={targetGroupId!r} set but no tree exists "
|
lbl = item.get(label_attr)
|
||||||
f"for contextKey={contextKey!r} — returning empty set"
|
if lbl is not None and lbl != "":
|
||||||
)
|
display = str(lbl)
|
||||||
itemIds = set()
|
if display is None:
|
||||||
|
display = nk
|
||||||
|
counts[nk] += 1
|
||||||
|
if nk not in display_by_key:
|
||||||
|
display_by_key[nk] = display
|
||||||
|
|
||||||
return GroupingContext(groupTree=groupTree, itemIds=itemIds)
|
ordered_keys = sorted(
|
||||||
|
counts.keys(),
|
||||||
|
key=lambda x: (x == null_key, str(display_by_key.get(x, x)).lower()),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": None if nk == null_key else nk,
|
||||||
|
"label": display_by_key.get(nk, nk),
|
||||||
|
"totalCount": counts[nk],
|
||||||
|
}
|
||||||
|
for nk in ordered_keys
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def applyGroupScopeFilter(items: List[Dict[str, Any]], itemIds: Optional[set]) -> List[Dict[str, Any]]:
|
def buildGroupLayout(
|
||||||
|
all_items: List[Dict[str, Any]],
|
||||||
|
groupByLevels: List[Dict[str, Any]],
|
||||||
|
page: int,
|
||||||
|
pageSize: int,
|
||||||
|
) -> tuple:
|
||||||
"""
|
"""
|
||||||
Filter items to those whose "id" field is in itemIds.
|
Apply multi-level grouping to all_items, slice to the requested page,
|
||||||
Returns items unchanged when itemIds is None (no active group scope).
|
and return (page_items, GroupLayout | None).
|
||||||
Works for both normal list items and for mode=ids / mode=filterValues flows
|
|
||||||
— call it before handleIdsInMemory / handleFilterValuesInMemory.
|
Strategy B: grouping operates on the full filtered+sorted candidate list.
|
||||||
|
Items are stably re-sorted by the group path so that members of the same
|
||||||
|
group are always contiguous (preserving the existing per-group sort order
|
||||||
|
from the caller).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
all_items: fully filtered and user-sorted list of row dicts.
|
||||||
|
groupByLevels: list of {"field": str, "nullLabel": str, "direction": "asc"|"desc"} dicts.
|
||||||
|
page, pageSize: 1-based page index and page size.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
(page_items, GroupLayout | None)
|
||||||
"""
|
"""
|
||||||
if itemIds is None:
|
from functools import cmp_to_key
|
||||||
return items
|
from modules.datamodels.datamodelPagination import GroupBand, GroupLayout
|
||||||
return [item for item in items if str(item.get("id", "")) in itemIds]
|
|
||||||
|
if not groupByLevels:
|
||||||
|
offset = (page - 1) * pageSize
|
||||||
|
return all_items[offset:offset + pageSize], None
|
||||||
|
|
||||||
|
levels = [lvl.get("field", "") for lvl in groupByLevels if lvl.get("field")]
|
||||||
|
if not levels:
|
||||||
|
offset = (page - 1) * pageSize
|
||||||
|
return all_items[offset:offset + pageSize], None
|
||||||
|
|
||||||
|
nullLabels = {lvl.get("field", ""): lvl.get("nullLabel", "—") for lvl in groupByLevels}
|
||||||
|
|
||||||
|
def _path_key(item: dict) -> tuple:
|
||||||
|
return tuple(
|
||||||
|
str(item.get(f) or "") if item.get(f) is not None else nullLabels.get(f, "—")
|
||||||
|
for f in levels
|
||||||
|
)
|
||||||
|
|
||||||
|
def _item_cmp(a: dict, b: dict) -> int:
|
||||||
|
pa, pb = _path_key(a), _path_key(b)
|
||||||
|
for i in range(len(levels)):
|
||||||
|
if pa[i] != pb[i]:
|
||||||
|
asc = (groupByLevels[i].get("direction") or "asc").lower() != "desc"
|
||||||
|
if pa[i] < pb[i]:
|
||||||
|
return -1 if asc else 1
|
||||||
|
return 1 if asc else -1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Sort by group path (per-level asc/desc); order within same path stays stable in Py3.12+
|
||||||
|
all_items.sort(key=cmp_to_key(_item_cmp))
|
||||||
|
|
||||||
|
# Build global band list from the full sorted list
|
||||||
|
bands_global: List[dict] = []
|
||||||
|
current_path: Optional[tuple] = None
|
||||||
|
current_start = 0
|
||||||
|
for i, item in enumerate(all_items):
|
||||||
|
path = _path_key(item)
|
||||||
|
if path != current_path:
|
||||||
|
if current_path is not None:
|
||||||
|
bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": i})
|
||||||
|
current_path = path
|
||||||
|
current_start = i
|
||||||
|
if current_path is not None:
|
||||||
|
bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": len(all_items)})
|
||||||
|
|
||||||
|
# Slice to page
|
||||||
|
page_start = (page - 1) * pageSize
|
||||||
|
page_end = page_start + pageSize
|
||||||
|
page_items = all_items[page_start:page_end]
|
||||||
|
|
||||||
|
# Find bands that have at least one row on this page
|
||||||
|
bands_on_page: List[GroupBand] = []
|
||||||
|
for band in bands_global:
|
||||||
|
inter_start = max(band["startIdx"], page_start)
|
||||||
|
inter_end = min(band["endIdx"], page_end)
|
||||||
|
if inter_start >= inter_end:
|
||||||
|
continue
|
||||||
|
path_list = band["path"]
|
||||||
|
bands_on_page.append(GroupBand(
|
||||||
|
path=path_list,
|
||||||
|
label=path_list[-1] if path_list else "—",
|
||||||
|
startRowIndex=inter_start - page_start,
|
||||||
|
rowCount=inter_end - inter_start,
|
||||||
|
))
|
||||||
|
|
||||||
|
group_layout = GroupLayout(levels=levels, bands=bands_on_page) if bands_on_page else GroupLayout(levels=levels, bands=[])
|
||||||
|
return page_items, group_layout
|
||||||
|
|
|
||||||
177
modules/routes/routeTableViews.py
Normal file
177
modules/routes/routeTableViews.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
CRUD endpoints for saved table views (TableListView).
|
||||||
|
|
||||||
|
A view stores a named preset of filters, sort order, and groupByLevels for a
|
||||||
|
specific table (identified by contextKey). Views are per-user and optionally
|
||||||
|
per-mandate.
|
||||||
|
|
||||||
|
Route prefix: /api/table-views
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request
|
||||||
|
from fastapi import status
|
||||||
|
|
||||||
|
from modules.auth import limiter, getCurrentUser
|
||||||
|
from modules.datamodels.datamodelUam import User
|
||||||
|
from modules.datamodels.datamodelPagination import TableListView
|
||||||
|
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/table-views",
|
||||||
|
tags=["Table Views"],
|
||||||
|
responses={404: {"description": "Not found"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ownedOrRaise(view: Optional[TableListView], viewId: str, userId: str):
|
||||||
|
"""Raise 404 when view is missing; ownership is implicitly guaranteed by the
|
||||||
|
interface layer (views are always queried with the current userId)."""
|
||||||
|
if view is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"View '{viewId}' not found")
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List views for a context
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def list_views(
|
||||||
|
request: Request,
|
||||||
|
contextKey: str = Query(..., description="Table context key, e.g. 'connections', 'files/list'"),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
):
|
||||||
|
"""List all saved views for the current user and contextKey."""
|
||||||
|
iface = interfaceDbApp.getInterface(currentUser)
|
||||||
|
views = iface.getTableListViews(contextKey=contextKey)
|
||||||
|
return [v.model_dump() if hasattr(v, "model_dump") else v for v in views]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Get one view
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/{viewKey}")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def get_view(
|
||||||
|
request: Request,
|
||||||
|
viewKey: str = Path(..., description="View slug"),
|
||||||
|
contextKey: str = Query(..., description="Table context key"),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
):
|
||||||
|
"""Return a single saved view by its viewKey."""
|
||||||
|
iface = interfaceDbApp.getInterface(currentUser)
|
||||||
|
view = iface.getTableListView(contextKey=contextKey, viewKey=viewKey)
|
||||||
|
if view is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"View '{viewKey}' not found for context '{contextKey}'")
|
||||||
|
return view.model_dump() if hasattr(view, "model_dump") else view
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create a view
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def create_view(
|
||||||
|
request: Request,
|
||||||
|
body: dict = Body(...),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new saved view.
|
||||||
|
|
||||||
|
Body fields:
|
||||||
|
- contextKey (required): table context key
|
||||||
|
- viewKey (required): short slug, unique per (user, contextKey)
|
||||||
|
- displayName (required): human-readable label
|
||||||
|
- config (optional): view config dict with keys:
|
||||||
|
schemaVersion, filters, sort, groupByLevels
|
||||||
|
"""
|
||||||
|
contextKey = body.get("contextKey")
|
||||||
|
viewKey = body.get("viewKey")
|
||||||
|
displayName = body.get("displayName")
|
||||||
|
config = body.get("config") or {}
|
||||||
|
|
||||||
|
if not contextKey:
|
||||||
|
raise HTTPException(status_code=400, detail="contextKey is required")
|
||||||
|
if not viewKey:
|
||||||
|
raise HTTPException(status_code=400, detail="viewKey is required")
|
||||||
|
if not displayName:
|
||||||
|
raise HTTPException(status_code=400, detail="displayName is required")
|
||||||
|
|
||||||
|
iface = interfaceDbApp.getInterface(currentUser)
|
||||||
|
try:
|
||||||
|
view = iface.createTableListView(
|
||||||
|
contextKey=contextKey,
|
||||||
|
viewKey=viewKey,
|
||||||
|
displayName=displayName,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
return view.model_dump() if hasattr(view, "model_dump") else view
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=409, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"create_view failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create view")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Update a view (by id)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.put("/{viewId}")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def update_view(
|
||||||
|
request: Request,
|
||||||
|
viewId: str = Path(..., description="View primary-key id (not viewKey)"),
|
||||||
|
body: dict = Body(...),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update an existing view.
|
||||||
|
|
||||||
|
Updatable fields: displayName, viewKey, config.
|
||||||
|
The contextKey cannot be changed after creation.
|
||||||
|
"""
|
||||||
|
allowed = {"displayName", "viewKey", "config"}
|
||||||
|
updates = {k: v for k, v in body.items() if k in allowed}
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail=f"No updatable fields provided. Allowed: {allowed}")
|
||||||
|
|
||||||
|
iface = interfaceDbApp.getInterface(currentUser)
|
||||||
|
try:
|
||||||
|
updated = iface.updateTableListView(viewId=viewId, updates=updates)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"update_view failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update view")
|
||||||
|
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"View id='{viewId}' not found")
|
||||||
|
return updated.model_dump() if hasattr(updated, "model_dump") else updated
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delete a view (by id)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.delete("/{viewId}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def delete_view(
|
||||||
|
request: Request,
|
||||||
|
viewId: str = Path(..., description="View primary-key id"),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
):
|
||||||
|
"""Delete a saved view by its primary-key id."""
|
||||||
|
iface = interfaceDbApp.getInterface(currentUser)
|
||||||
|
deleted = iface.deleteTableListView(viewId=viewId)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail=f"View id='{viewId}' not found or could not be deleted")
|
||||||
|
|
@ -61,34 +61,8 @@ async def _getOrCreateInstanceGroup(
|
||||||
featureInstanceId: str,
|
featureInstanceId: str,
|
||||||
contextKey: str = "files/list",
|
contextKey: str = "files/list",
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Return groupId of the default group for a feature instance; create if needed."""
|
"""Stub — file group tree removed. Returns None; callers that checked the result will skip group assignment."""
|
||||||
try:
|
return None
|
||||||
existing = appInterface.getTableGrouping(contextKey)
|
|
||||||
nodes = [
|
|
||||||
n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n))
|
|
||||||
for n in (existing.rootGroups if existing else [])
|
|
||||||
]
|
|
||||||
|
|
||||||
def _find(nds):
|
|
||||||
for nd in nds:
|
|
||||||
meta = nd.get("meta", {}) if isinstance(nd, dict) else getattr(nd, "meta", {})
|
|
||||||
if (meta or {}).get("featureInstanceId") == featureInstanceId:
|
|
||||||
return nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
|
|
||||||
found = _find(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", []))
|
|
||||||
if found:
|
|
||||||
return found
|
|
||||||
return None
|
|
||||||
|
|
||||||
found = _find(nodes)
|
|
||||||
if found:
|
|
||||||
return found
|
|
||||||
newId = str(uuid.uuid4())
|
|
||||||
nodes.append({"id": newId, "name": featureInstanceId, "itemIds": [], "subGroups": [], "meta": {"featureInstanceId": featureInstanceId}})
|
|
||||||
appInterface.upsertTableGrouping(contextKey, nodes)
|
|
||||||
return newId
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"_getOrCreateInstanceGroup: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _getOrCreateTempGroup(
|
async def _getOrCreateTempGroup(
|
||||||
|
|
@ -96,8 +70,8 @@ async def _getOrCreateTempGroup(
|
||||||
sessionId: str,
|
sessionId: str,
|
||||||
contextKey: str = "files/list",
|
contextKey: str = "files/list",
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Return groupId of a temporary group for a session; create if needed."""
|
"""Stub — file group tree removed. Returns None."""
|
||||||
return await _getOrCreateInstanceGroup(appInterface, f"_temp_{sessionId}", contextKey)
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _attachFileAsChatDocument(
|
def _attachFileAsChatDocument(
|
||||||
|
|
|
||||||
|
|
@ -312,52 +312,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
|
||||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||||||
if fiId:
|
if fiId:
|
||||||
dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
||||||
if args.get("groupId"):
|
# File group tree removed — groupId arg and instance-group assignment no longer apply
|
||||||
try:
|
|
||||||
appIface = chatService.interfaceDbApp
|
|
||||||
existing = appIface.getTableGrouping("files/list")
|
|
||||||
nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])]
|
|
||||||
def _addToGroup(nds, gid, fid):
|
|
||||||
for nd in nds:
|
|
||||||
nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
|
|
||||||
if nid == gid:
|
|
||||||
ids = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", []))
|
|
||||||
if fid not in ids:
|
|
||||||
ids.append(fid)
|
|
||||||
if isinstance(nd, dict):
|
|
||||||
nd["itemIds"] = ids
|
|
||||||
return True
|
|
||||||
if _addToGroup(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", []), gid, fid):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
_addToGroup(nodes, args["groupId"], fileItem.id)
|
|
||||||
appIface.upsertTableGrouping("files/list", nodes)
|
|
||||||
except Exception as _ge:
|
|
||||||
logger.warning(f"writeFile: failed to add file to group {args['groupId']}: {_ge}")
|
|
||||||
elif fiId:
|
|
||||||
try:
|
|
||||||
appIface = chatService.interfaceDbApp
|
|
||||||
instanceGroupId = await _getOrCreateInstanceGroup(appIface, fiId)
|
|
||||||
if instanceGroupId:
|
|
||||||
existing = appIface.getTableGrouping("files/list")
|
|
||||||
nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])]
|
|
||||||
def _addToGroup2(nds, gid, fid):
|
|
||||||
for nd in nds:
|
|
||||||
nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
|
|
||||||
if nid == gid:
|
|
||||||
ids = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", []))
|
|
||||||
if fid not in ids:
|
|
||||||
ids.append(fid)
|
|
||||||
if isinstance(nd, dict):
|
|
||||||
nd["itemIds"] = ids
|
|
||||||
return True
|
|
||||||
if _addToGroup2(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", []), gid, fid):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
_addToGroup2(nodes, instanceGroupId, fileItem.id)
|
|
||||||
appIface.upsertTableGrouping("files/list", nodes)
|
|
||||||
except Exception as _ge:
|
|
||||||
logger.warning(f"writeFile: failed to add file to instance group for {fiId}: {_ge}")
|
|
||||||
if args.get("tags"):
|
if args.get("tags"):
|
||||||
dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]})
|
dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]})
|
||||||
|
|
||||||
|
|
@ -746,136 +701,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
|
||||||
readOnly=False
|
readOnly=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Group tools (replaces folder-based tools) ----
|
# Group tree tools removed — file grouping now uses view-based display grouping (TableListView)
|
||||||
|
|
||||||
async def _listGroups(args: Dict[str, Any], context: Dict[str, Any]):
|
|
||||||
contextKey = args.get("contextKey", "files/list")
|
|
||||||
try:
|
|
||||||
chatService = services.chat
|
|
||||||
appInterface = chatService.interfaceDbApp
|
|
||||||
existing = appInterface.getTableGrouping(contextKey)
|
|
||||||
if not existing:
|
|
||||||
return ToolResult(toolCallId="", toolName="listGroups", success=True, data="No groups found.")
|
|
||||||
|
|
||||||
def _flatten(nodes, depth=0):
|
|
||||||
result = []
|
|
||||||
for n in nodes:
|
|
||||||
nd = n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n))
|
|
||||||
result.append({"id": nd.get("id"), "name": nd.get("name"), "depth": depth, "itemCount": len(nd.get("itemIds", []))})
|
|
||||||
result.extend(_flatten(nd.get("subGroups", []), depth + 1))
|
|
||||||
return result
|
|
||||||
|
|
||||||
groups = _flatten(existing.rootGroups)
|
|
||||||
lines = "\n".join(
|
|
||||||
f"{' ' * g['depth']}- {g['name']} (id: {g['id']}, items: {g['itemCount']})"
|
|
||||||
for g in groups
|
|
||||||
) if groups else "No groups found."
|
|
||||||
return ToolResult(toolCallId="", toolName="listGroups", success=True, data=lines)
|
|
||||||
except Exception as e:
|
|
||||||
return ToolResult(toolCallId="", toolName="listGroups", success=False, error=str(e))
|
|
||||||
|
|
||||||
async def _listItemsInGroup(args: Dict[str, Any], context: Dict[str, Any]):
|
|
||||||
groupId = args.get("groupId", "")
|
|
||||||
contextKey = args.get("contextKey", "files/list")
|
|
||||||
if not groupId:
|
|
||||||
return ToolResult(toolCallId="", toolName="listItemsInGroup", success=False, error="groupId is required")
|
|
||||||
try:
|
|
||||||
from modules.routes.routeHelpers import _collectItemIds
|
|
||||||
chatService = services.chat
|
|
||||||
appInterface = chatService.interfaceDbApp
|
|
||||||
existing = appInterface.getTableGrouping(contextKey)
|
|
||||||
if not existing:
|
|
||||||
return ToolResult(toolCallId="", toolName="listItemsInGroup", success=True, data="No groups found.")
|
|
||||||
nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in existing.rootGroups]
|
|
||||||
ids = _collectItemIds(nodes, groupId)
|
|
||||||
itemList = list(ids) if ids else []
|
|
||||||
return ToolResult(
|
|
||||||
toolCallId="", toolName="listItemsInGroup", success=True,
|
|
||||||
data="\n".join(f"- {fid}" for fid in itemList) if itemList else "No items in group.",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return ToolResult(toolCallId="", toolName="listItemsInGroup", success=False, error=str(e))
|
|
||||||
|
|
||||||
async def _addItemsToGroup(args: Dict[str, Any], context: Dict[str, Any]):
|
|
||||||
groupId = args.get("groupId", "")
|
|
||||||
itemIds = args.get("itemIds", [])
|
|
||||||
contextKey = args.get("contextKey", "files/list")
|
|
||||||
if not groupId:
|
|
||||||
return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error="groupId is required")
|
|
||||||
if not itemIds:
|
|
||||||
return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error="itemIds is required")
|
|
||||||
try:
|
|
||||||
chatService = services.chat
|
|
||||||
appInterface = chatService.interfaceDbApp
|
|
||||||
existing = appInterface.getTableGrouping(contextKey)
|
|
||||||
nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])]
|
|
||||||
|
|
||||||
def _add(nds):
|
|
||||||
for nd in nds:
|
|
||||||
nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
|
|
||||||
if nid == groupId:
|
|
||||||
existing_ids = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", []))
|
|
||||||
for fid in itemIds:
|
|
||||||
if fid not in existing_ids:
|
|
||||||
existing_ids.append(fid)
|
|
||||||
if isinstance(nd, dict):
|
|
||||||
nd["itemIds"] = existing_ids
|
|
||||||
return True
|
|
||||||
if _add(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", [])):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
found = _add(nodes)
|
|
||||||
if not found:
|
|
||||||
return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error=f"Group {groupId} not found")
|
|
||||||
appInterface.upsertTableGrouping(contextKey, nodes)
|
|
||||||
return ToolResult(
|
|
||||||
toolCallId="", toolName="addItemsToGroup", success=True,
|
|
||||||
data=f"Added {len(itemIds)} item(s) to group {groupId}",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error=str(e))
|
|
||||||
|
|
||||||
registry.register(
|
|
||||||
"listGroups", _listGroups,
|
|
||||||
description="List all groups in the file grouping tree. Groups replace folders for organising files.",
|
|
||||||
parameters={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"contextKey": {"type": "string", "description": "Grouping context key (default: 'files/list')"},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
readOnly=True
|
|
||||||
)
|
|
||||||
|
|
||||||
registry.register(
|
|
||||||
"listItemsInGroup", _listItemsInGroup,
|
|
||||||
description="List all file IDs assigned to a specific group (includes sub-groups recursively).",
|
|
||||||
parameters={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"groupId": {"type": "string", "description": "The group ID to inspect"},
|
|
||||||
"contextKey": {"type": "string", "description": "Grouping context key (default: 'files/list')"},
|
|
||||||
},
|
|
||||||
"required": ["groupId"]
|
|
||||||
},
|
|
||||||
readOnly=True
|
|
||||||
)
|
|
||||||
|
|
||||||
registry.register(
|
|
||||||
"addItemsToGroup", _addItemsToGroup,
|
|
||||||
description="Add one or more file IDs to an existing group.",
|
|
||||||
parameters={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"groupId": {"type": "string", "description": "The group ID to add files to"},
|
|
||||||
"itemIds": {"type": "array", "items": {"type": "string"}, "description": "List of file IDs to add"},
|
|
||||||
"contextKey": {"type": "string", "description": "Grouping context key (default: 'files/list')"},
|
|
||||||
},
|
|
||||||
"required": ["groupId", "itemIds"]
|
|
||||||
},
|
|
||||||
readOnly=False
|
|
||||||
)
|
|
||||||
|
|
||||||
registry.register(
|
registry.register(
|
||||||
"replaceInFile", _replaceInFile,
|
"replaceInFile", _replaceInFile,
|
||||||
|
|
|
||||||
|
|
@ -523,34 +523,12 @@ class ChatService:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def listGroups(self, contextKey: str = "files/list") -> list:
|
def listGroups(self, contextKey: str = "files/list") -> list:
|
||||||
"""List all groups in the groupTree for the current context."""
|
"""Stub — file group tree removed. Returns empty list."""
|
||||||
try:
|
return []
|
||||||
existing = self.interfaceDbApp.getTableGrouping(contextKey)
|
|
||||||
if not existing:
|
|
||||||
return []
|
|
||||||
def _flatten(nodes, depth=0):
|
|
||||||
result = []
|
|
||||||
for n in nodes:
|
|
||||||
nd = n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n))
|
|
||||||
result.append({"id": nd.get("id"), "name": nd.get("name"), "depth": depth, "itemCount": len(nd.get("itemIds", []))})
|
|
||||||
result.extend(_flatten(nd.get("subGroups", []), depth + 1))
|
|
||||||
return result
|
|
||||||
return _flatten(existing.rootGroups)
|
|
||||||
except Exception as e:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def listFilesInGroup(self, groupId: str, contextKey: str = "files/list") -> list:
|
def listFilesInGroup(self, groupId: str, contextKey: str = "files/list") -> list:
|
||||||
"""List file IDs in a specific group (recursive)."""
|
"""Stub — file group tree removed. Returns empty list."""
|
||||||
try:
|
return []
|
||||||
from modules.routes.routeHelpers import _collectItemIds
|
|
||||||
existing = self.interfaceDbApp.getTableGrouping(contextKey)
|
|
||||||
if not existing:
|
|
||||||
return []
|
|
||||||
nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in existing.rootGroups]
|
|
||||||
ids = _collectItemIds(nodes, groupId)
|
|
||||||
return list(ids) if ids else []
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# ---- DataSource CRUD ----
|
# ---- DataSource CRUD ----
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue