"""Pagination utilities and standardized query parameter handling. This module provides reusable utilities for implementing consistent pagination across all endpoints. All paginated endpoints should use these utilities to ensure a uniform API contract. Standard Pagination Contract: Query parameters: page (1-based), page_size (1-500) Response: PaginatedListResponse[T] with items and pagination metadata Usage in routers: ```python from app.utils.pagination import PAGINATION_DEFAULTS, create_pagination_metadata @router.get("/items") async def get_items( page: int = Query(default=PAGINATION_DEFAULTS["page"], ge=1), page_size: int = Query( default=PAGINATION_DEFAULTS["page_size"], ge=1, le=PAGINATION_DEFAULTS["max_page_size"], ), ): items = [...] total = 100 pagination = create_pagination_metadata(total, page, page_size) return MyListResponse(items=items, pagination=pagination) ``` """ from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from app.models.response import PaginationMetadata __all__ = ["PAGINATION_DEFAULTS", "get_offset", "compute_total_pages", "create_pagination_metadata"] # Standardized pagination defaults PAGINATION_DEFAULTS: Final[dict[str, int]] = { "page": 1, "page_size": 100, "max_page_size": 500, } def get_offset(page: int, page_size: int) -> int: """Calculate the database offset for a given page and page size. Args: page: 1-based page number. page_size: Items per page. Returns: 0-based database offset (number of items to skip). Raises: ValueError: If page or page_size is invalid (< 1). Example: ```python # Page 1, size 10 → offset 0 assert get_offset(1, 10) == 0 # Page 2, size 10 → offset 10 assert get_offset(2, 10) == 10 # Page 3, size 50 → offset 100 assert get_offset(3, 50) == 100 ``` """ if page < 1: raise ValueError(f"page must be >= 1, got {page}") if page_size < 1: raise ValueError(f"page_size must be >= 1, got {page_size}") return (page - 1) * page_size def compute_total_pages(total: int, page_size: int) -> int: """Calculate the total number of pages needed. Args: total: Total number of items across all pages. page_size: Items per page. Returns: The number of pages required to hold all items. Always at least 1, even when total is 0 (an empty page is still a page). Raises: ValueError: If page_size is invalid (< 1). Example: ```python assert compute_total_pages(0, 10) == 1 assert compute_total_pages(10, 10) == 1 assert compute_total_pages(11, 10) == 2 assert compute_total_pages(25, 5) == 5 ``` """ if page_size < 1: raise ValueError(f"page_size must be >= 1, got {page_size}") if total == 0: return 1 # Ceiling division: (total + page_size - 1) // page_size return (total + page_size - 1) // page_size def create_pagination_metadata(total: int, page: int, page_size: int) -> "PaginationMetadata": """Create pagination metadata with computed derived fields. This factory function computes all pagination-related information in a single place, ensuring consistency across all paginated endpoints. Args: total: Total number of items across all pages. page: Current page number (1-based). page_size: Items per page. Returns: :class:`~app.models.response.PaginationMetadata` with computed fields: - total_pages: Computed total number of pages needed - has_next_page: Whether there is a next page - has_prev_page: Whether there is a previous page Example: ```python metadata = create_pagination_metadata(total=150, page=2, page_size=50) assert metadata.total_pages == 3 assert metadata.has_next_page is True assert metadata.has_prev_page is True ``` """ from app.models.response import PaginationMetadata total_pages = compute_total_pages(total, page_size) has_next_page = page < total_pages has_prev_page = page > 1 return PaginationMetadata( page=page, page_size=page_size, total=total, total_pages=total_pages, has_next_page=has_next_page, has_prev_page=has_prev_page, )