# 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