# 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 T = TypeVar('T') # --------------------------------------------------------------------------- # Table Grouping models # --------------------------------------------------------------------------- class TableGroupNode(BaseModel): """ A single node in a user-defined group tree for a FormGeneratorTable. Items belong to exactly one group (no multi-membership). Groups can be nested to arbitrary depth via subGroups. """ id: str name: str itemIds: List[str] = Field(default_factory=list) subGroups: List['TableGroupNode'] = Field(default_factory=list) order: int = 0 isExpanded: bool = True TableGroupNode.model_rebuild() class TableGrouping(BaseModel): """ Persisted grouping configuration for one (user, contextKey) pair. Stored in table_groupings in poweron_app (auto-created). contextKey convention: API path without /api/ prefix and without trailing slash. Examples: "connections", "prompts", "admin/users", "trustee/{instanceId}/documents" """ id: str userId: str contextKey: str rootGroups: List[TableGroupNode] = Field(default_factory=list) updatedAt: Optional[float] = None class SortField(BaseModel): """ Single sort field configuration. """ 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. Grouping extensions (both optional — omit when not using grouping): groupId — Scope the request to items belonging to this group. The backend resolves it to an itemIds IN-filter before applying normal pagination/search/filter logic. Also applied for mode=ids and mode=filterValues so that bulk-select and filter-dropdowns respect the group scope. saveGroupTree — If present the backend persists this tree for the current (user, contextKey) pair *before* fetching, then returns the confirmed tree in the response groupTree field. Omit on every request that does not change the group tree. """ page: int = Field(ge=1, description="Current page number (1-based)") pageSize: int = Field(ge=1, le=1000, description="Number of items per page") sort: List[SortField] = Field(default_factory=list, description="List of sort fields in priority order") filters: Optional[Dict[str, Any]] = Field( default=None, 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""" ) groupId: Optional[str] = Field( default=None, description="Scope request to items of this group (resolved server-side to itemIds IN-filter)", ) saveGroupTree: Optional[List[Dict[str, Any]]] = Field( default=None, description="If set, persist this group tree before fetching (optimistic save)", ) class PaginationRequest(BaseModel): """ 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. groupTree is included when the endpoint supports table grouping and the current user has a saved group tree for the requested contextKey. It is None when grouping is not configured for the endpoint or the user has not created any groups yet. Frontend must treat None as an empty tree. """ items: List[T] = Field(..., description="Array of items for current page") pagination: Optional[PaginationMetadata] = Field(..., description="Pagination metadata (None if pagination not applied)") groupTree: Optional[List[TableGroupNode]] = Field( default=None, description="Current group tree for this (user, contextKey) pair — None if no grouping configured", ) model_config = ConfigDict(arbitrary_types_allowed=True) def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]: """ Normalize pagination dictionary to handle frontend variations. Moves top-level "search" field into filters if present. Grouping fields (groupId, saveGroupTree) are passed through as-is. Args: pagination_dict: Raw pagination dictionary from frontend Returns: Normalized pagination dictionary ready for PaginationParams parsing """ if not pagination_dict: return pagination_dict # Create a copy to avoid modifying the original normalized = dict(pagination_dict) # Ensure required fields have sensible defaults if "page" not in normalized: normalized["page"] = 1 if "pageSize" not in normalized: normalized["pageSize"] = 25 # Move top-level "search" into filters if present if "search" in normalized: if "filters" not in normalized or normalized["filters"] is None: normalized["filters"] = {} normalized["filters"]["search"] = normalized.pop("search") # groupId / saveGroupTree are valid PaginationParams fields — pass through unchanged. # No transformation needed; Pydantic will validate them. return normalized