Saltar a contenido

Paginación personalizada#

iThome Ironman 2024

Este es el artículo número 25 de la serie de tutoriales de Django Ninja.

En el artículo anterior presentamos el paginador integrado de Django Ninja y lo usamos para implementar una función de paginación simple.

Aunque el PageNumberPagination integrado es ciertamente conveniente, en muchas ocasiones aún necesitaremos algunas funciones de personalización.

Para lograr este objetivo, necesitas personalizar una clase de paginación.

Sin embargo, no te preocupes: esta personalización no es desde cero. Se trata de heredar de la clase base de paginación proporcionada por Django Ninja y luego realizar nuestro propio «procesamiento».

Este artículo te enseñará cómo hacerlo.

Todos los cambios de código de este artículo se pueden consultar en este PR.

Proyecto de ejemplo en GitHub#

👉 Django-Ninja-Tutorial


Requerimientos de personalización#

Además de la paginación básica, también deseamos poder:

  • Permitir que el cliente elija la cantidad de datos que se muestran por página, limitando el rango opcional entre 1 y 100.
  • Añadir dos campos nuevos en la respuesta para mostrar la información de paginación actual:
    • Número de página actual (page).
    • Cantidad mostrada por página (per_page).

Este es, sin duda, un requerimiento muy común. Implementaremos estas funciones a través de una clase de paginación personalizada.

¡Sin más preámbulos, comencemos directamente!

Implementación: clase de paginación personalizada#

El paginador (clase de paginación) generalmente se utiliza para todo el proyecto, por lo que no es adecuado colocarlo en el directorio de una app de Django. Tampoco se debe colocar en el api.py del proyecto como se hace con los exception handlers, ya que causaría referencias circulares.

Por lo tanto, creé un nuevo módulo de Python en el directorio del proyecto NinjaForum: pagination.py.

En este nuevo módulo, escribí directamente una clase de paginación llamada CustomPagination, como se muestra a continuación:

from typing import Any

from django.db.models.query import QuerySet
from ninja import Field, Schema
from ninja.pagination import PaginationBase

class CustomPagination(PaginationBase):
    class Input(Schema):
        page: int = Field(1, ge=1)
        per_page: int = Field(10, ge=1, le=100)

    class Output(Schema):
        items: list
        page: int = Field(examples=[1])
        per_page: int = Field(examples=[10])
        total: int = Field(examples=[100])

    def paginate_queryset(
        self,
        queryset: QuerySet,
        pagination: Input,
        **params: Any,
    ) -> dict[str, Any]:
        start = (pagination.page - 1) * pagination.per_page
        end = start + pagination.per_page
        return {
            'items': queryset[start:end],
            'page': pagination.page,
            'per_page': pagination.per_page,
            'total': queryset.count(),
        }

Esta clase de paginación nos permite utilizar parámetros de consulta (page y per_page) para determinar el tamaño de página y el número de página. Además, la respuesta incluye dos nuevos campos con los mismos nombres como información adicional de paginación.


Explicación de la clase de paginación personalizada#

Aunque el código parece tener muchos detalles, después de leerlo detenidamente verás que en realidad no es difícil de entender.

Debido a la limitación de espacio, solo seleccionaremos algunos puntos clave para explicar.

Punto clave 1: La estructura general proviene de la clase heredada PaginationBase#

La primera duda probablemente sea: «¿Y yo cómo iba a saber que una clase de paginación se escribe así?».

Es cierto, por supuesto que no lo sabíamos, así que debemos consultar la documentación oficial y el código fuente.

De la documentación oficial podemos aprender que se debe heredar de una clase llamada PaginationBase. Sin embargo, la descripción de esa clase en la documentación aún es un poco escueta, por lo que es necesario revisar el código fuente para conocer información más detallada.

Luego, imitamos y sobrescribimos algunos atributos y métodos de la clase; eso es básicamente todo.

Punto clave 2: Input Schema#

class Input(Schema):
    page: int = Field(1, ge=1)
    per_page: int = Field(10, ge=1, le=100)
Seguro puedes notar que este Schema se utiliza para definir y validar los parámetros de consulta URL relacionados con la paginación.

Además, la clase Input se pasa como un argumento al método paginate_queryset como parte de la lógica de paginación.

Cada atributo en Input representa un parámetro de consulta (limitado a lo relacionado con paginación), ¡y también se puede usar Field para configurar detalles!

El Field aquí es el Field de Pydantic, el cual presentamos en detalle en la entrega 18. Nos permite establecer valores por defecto, ejemplos para la documentación y reglas básicas de validación para cada parámetro.

En este ejemplo, el valor por defecto de page es 1 y debe ser mayor o igual a 1; el valor por defecto de per_page es 10 y debe estar entre 1 y 100. Esto garantiza que nuestros parámetros de paginación se mantengan siempre dentro de un rango razonable.

La misma lógica se aplica a Output, el cual determina el formato que la respuesta HTTP «debería tener», siendo equivalente al Schema de respuesta de paginación.

Punto clave 3: El método paginate_queryset#

Este método es el núcleo de todas las clases de paginación, ya que implementa la lógica de paginación concreta.

Su primer parámetro es self, lo que muestra que es un «método de instancia».

Lo más digno de notar es el segundo parámetro: queryset, que en realidad es el valor devuelto por la función view, y su tipo debe ser un QuerySet.

paginate_queryset utilizará el conocido «slicing e indexación» para "recortar" el QuerySet recibido. Esta es una funcionalidad que Django implementa para sus QuerySet, cuyo comportamiento es similar al de contenedores de Python como list o tuple.

Cuando responde al cliente, obtenemos el QuerySet recortado y el formato de respuesta personalizado.


Probar la paginación personalizada#

Una vez escrita la clase personalizada anterior, la función view solo necesita añadir una línea más: @paginate(CustomPagination). Omitiremos ese código aquí.

¡Veamos los resultados directamente! Utilicé los parámetros de consulta /?page=2&per_page=5 (página 2, 5 registros por página):

¡Muy ideal!

¿Qué sucedería si la cantidad por página se establece en más de 100?

{
    "detail": [
        {
            "type": "less_than_equal",
            "loc": [
                "query",
                "per_page"
            ],
            "msg": "Input should be less than or equal to 100",
            "ctx": {
                "le": 100
            }
        }
    ]
}

La respuesta es una respuesta 422.


Resumen de la función de paginación#

A través de estos dos artículos, hemos mostrado cómo implementar la paginación en Django Ninja, desde un método integrado sencillo hasta una clase de paginación personalizada más compleja.

Según las necesidades de tu proyecto, puedes elegir la estrategia de paginación adecuada, permitiendo que cada respuesta se presente al usuario de la forma más idónea.


¿Por qué las «respuestas con múltiples códigos de estado» no son prácticas?#

¿Recuerdas la pista previa que dejamos en las entregas 13 y 21?

En «Entrega 13: Respuestas (Parte 1) Cómo Django Ninja maneja las respuestas HTTP» mencioné:

Pero siento que esta configuración de «respuestas con múltiples códigos de estado» no es muy práctica en la práctica. ¿Por qué? Lo discutiremos más adelante.

Recordando un poco, las «respuestas con múltiples códigos de estado» se refieren a este uso:

@api.post(
    ...,
    response={200: Token, 401: Message, 402: Message}  # Aquí
)

Y luego, dentro de la función view, devolver diferentes retornos según las distintas situaciones.

En «Entrega 21: Manejo de errores (Parte 1) HttpError y respuestas HTTP personalizadas» volví a decir:

Esto se ve realmente bien y es muy intuitivo; así es como solía escribirlo cuando trabajaba con Django REST framework.

Sin embargo, esta forma de escribir en Django Ninja chocará contra un muro al usar el «decorador de paginación».

Por el momento aún no es el momento adecuado; lo dejaremos en claro más adelante en la «Entrega 25: Paginación (Parte 2) Clase de paginación personalizada».

¡Pues aquí lo tenemos!

La razón es muy simple y la clave reside en lo mencionado en el «Punto clave 3: El método paginate_queryset» de este artículo:

Lo más digno de notar es el segundo parámetro: queryset, que en realidad es el valor devuelto por la función view, y su tipo debe ser QuerySet.

¡Porque dentro del método paginate_queryset, el tipo del segundo parámetro debe ser QuerySet!

Dentro de paginate_queryset, tratamos y manipulamos este parámetro como un QuerySet. Si lo que se pasa no es un QuerySet, la lógica de paginación fallará.

El conflicto entre las «respuestas con múltiples códigos de estado» y el método paginate_queryset#

Sin embargo, en las respuestas con múltiples códigos de estado, el tipo retornado no necesariamente es un QuerySet; es muy probable que sea una tuple.

Te daré un ejemplo sencillo para que lo entiendas. Cambiemos la API «Obtener la lista de artículos» a esto:

@api.get(
    path="/posts",
    response={200: list[PostResponse], 404: ErrorMessage}
)
@paginate(CustomPagination)
def get_posts(...) -> QuerySet[Post] | tuple[int, dict]:
    posts = Post.objects.all()
    if not posts.exists():
        return 404, {"message": "No se encontraron artículos que coincidan con los criterios"}
    return posts

Este ejemplo muestra claramente el conflicto entre las «respuestas con múltiples códigos de estado» y el paginador:

  • Cuando el resultado de la consulta es normal, la función view retorna un QuerySet (es decir, posts), el cual se le entrega al paginador para paginar, y todo funciona perfectamente.
  • Cuando no se encuentran artículos, la función view intentará devolver una tuple (dado que las «respuestas no 200» en Django Ninja deben llevar un código de estado, por lo que es una tuple y no un QuerySet).

Esto provocará un error en el método paginate_queryset, ya que espera recibir un QuerySet y las operaciones internas posteriores se basan en esa premisa.


Si todas las APIs del proyecto no requieren paginación, usar «respuestas con múltiples códigos de estado» para manejar respuestas «no 200» es completamente viable.

Pero basta con que una sola API requiera paginación para que esa API con paginación, con el fin de evitar el conflicto mencionado, deba cambiar al método mencionado en la entrega 21: raise HttpError.

Considerando la consistencia general del proyecto, las demás APIs también deberían adoptar la forma raise HttpError.

Dado que las necesidades de paginación son tan comunes, las «respuestas con múltiples códigos de estado» terminan convirtiéndose en una opción superflua e inútil.