gateway/modules/datamodels/datamodelPagination.py

228 lines
9.1 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Pagination models for server-side pagination, sorting, and filtering.
All models use camelStyle naming convention for consistency with frontend.
"""
from typing import List, Dict, Any, Optional, Generic, TypeVar
from pydantic import BaseModel, Field, ConfigDict
import math
import uuid
T = TypeVar('T')
# ---------------------------------------------------------------------------
# Group layout models (Strategy B — derived from Views, purely presentational)
# ---------------------------------------------------------------------------
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 contiguous block of rows that share the same group path, intersecting the current page.
startRowIndex and rowCount are 0-based indices relative to the current page's items[].
"""
path: List[str] = Field(..., description="Hierarchical group key (one entry per level)")
label: str = Field(..., description="Display label for this band (last path element)")
startRowIndex: int = Field(..., description="0-based start index within items[] on this page")
rowCount: int = Field(..., description="Number of items in this band on this page")
class GroupLayout(BaseModel):
"""
Grouping structure for the current response page.
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.
Examples: "connections", "prompts", "admin/users", "files/list"
viewKey is a user-defined slug, unique per (userId, mandateId, contextKey).
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str
mandateId: Optional[str] = None
contextKey: str
viewKey: str
displayName: str
config: Dict[str, Any] = Field(default_factory=dict)
updatedAt: Optional[float] = None
# ---------------------------------------------------------------------------
# Sort and pagination models
# ---------------------------------------------------------------------------
class SortField(BaseModel):
"""Single sort field configuration."""
field: str = Field(..., description="Field name to sort by")
direction: str = Field(..., description="Sort direction: 'asc' or 'desc'")
class PaginationParams(BaseModel):
"""
Complete pagination state including page, sorting, and filters.
View extension (optional):
viewKey — Slug of a saved TableListView for this (user, contextKey) pair.
The server loads the view, merges its filters/sort/groupByLevels
into the effective query (request fields take priority over view
defaults for explicitly provided fields), and returns groupLayout
in the response when groupByLevels is non-empty.
Omit or set to None for the default (ungrouped) view.
"""
page: int = Field(ge=1, description="Current page number (1-based)")
pageSize: int = Field(ge=1, le=1000, description="Number of items per page")
sort: List[SortField] = Field(default_factory=list, description="List of sort fields in priority order")
filters: Optional[Dict[str, Any]] = Field(
default=None,
description="""Filter criteria dictionary. Supports:
- General search: {"search": "text"} - searches across all text fields (case-insensitive)
- Field-specific filters:
- Simple equals: {"status": "running"}
- With operator: {"status": {"operator": "equals", "value": "running"}}
- Supported operators: equals/eq, contains, startsWith, endsWith, gt, gte, lt, lte, in, notIn
- Multiple filters are combined with AND logic"""
)
viewKey: Optional[str] = Field(
default=None,
description="Slug of a saved view to load; server merges view config into effective query",
)
groupByLevels: Optional[List[GroupByLevel]] = Field(
default=None,
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."
),
)
class PaginationRequest(BaseModel):
"""
Pagination request parameters sent from frontend to backend.
All fields are optional. If pagination=None, no pagination is applied.
"""
pagination: Optional[PaginationParams] = None
class PaginatedResult(BaseModel):
"""
Internal result structure from interface layer.
Used when pagination is requested.
"""
items: List[Any]
totalItems: int
totalPages: int # Calculated as: math.ceil(totalItems / pageSize)
class PaginationMetadata(BaseModel):
"""
Pagination metadata returned to frontend for rendering controls.
Contains all information needed to render pagination UI and handle user interactions.
"""
currentPage: int = Field(..., description="Current page number (1-based)")
pageSize: int = Field(..., description="Number of items per page")
totalItems: int = Field(..., description="Total number of items across all pages (after filters)")
totalPages: int = Field(..., description="Total number of pages (calculated from totalItems / pageSize)")
sort: List[SortField] = Field(..., description="Current sort configuration applied")
filters: Optional[Dict[str, Any]] = Field(default=None, description="Current filters applied (mirrors request filters)")
class PaginatedResponse(BaseModel, Generic[T]):
"""
Response containing paginated data and metadata.
groupLayout is included when the effective view has groupByLevels configured.
It describes how to render group header rows in the current page's items[].
Omitted (None) when no grouping is active.
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")
pagination: Optional[PaginationMetadata] = Field(..., description="Pagination metadata (None if pagination not applied)")
groupLayout: Optional[GroupLayout] = Field(
default=None,
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)
def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
Normalize pagination dictionary to handle frontend variations.
- Moves top-level "search" field into filters if present.
- Silently drops legacy fields (groupId, saveGroupTree) that were part of the
old tree-grouping implementation so old clients do not cause validation errors.
- Passes viewKey through unchanged.
"""
if not pagination_dict:
return pagination_dict
normalized = dict(pagination_dict)
if "page" not in normalized:
normalized["page"] = 1
if "pageSize" not in normalized:
normalized["pageSize"] = 25
# Move top-level "search" into filters
if "search" in normalized:
if "filters" not in normalized or normalized["filters"] is None:
normalized["filters"] = {}
normalized["filters"]["search"] = normalized.pop("search")
# Drop legacy tree-grouping fields — harmless if already absent
normalized.pop("groupId", None)
normalized.pop("saveGroupTree", None)
return normalized