228 lines
9.1 KiB
Python
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
|