Desarrollo de Paginación Dinámica y Rutas con Expresiones Regulares en Tornado

Configuración de Rutas y Slicing Básico de Datos

Tornado permite definir rutas utilizando expresiones regulares, lo que facilita la captura de parámetros dinámicos directamente desde la URL. A continuación, se establece la configuración inicial de la aplicación y el enrutamiento para manejar la paginación.


import tornado.web
import tornado.ioloop
from handlers import ItemHandler

settings = {
    "template_path": "templates",
    "static_path": "static",
}

app = tornado.web.Application([
    # Captura de parámetro numérico para la página mediante regex
    (r"/items/(?P<page_num>\d+)", ItemHandler),
], **settings)

if __name__ == "__main__":
    app.listen(8888)
    tornado.ioloop.IOLoop.current().start()

El controlador procesa el parámetro de la página, realiza el slicing de los datos y los envía a la plantilla. Se utiliza un almacén de datos simulado para demostrar la lógica.


import tornado.web

MOCK_DB = [{"username": f"user_{i}", "email": f"user_{i}@example.com"} for i in range(45)]

class ItemHandler(tornado.web.RequestHandler):
    def get(self, page_num):
        per_page = 5
        try:
            current_page = int(page_num)
        except ValueError:
            current_page = 1
            
        if current_page < 1:
            current_page = 1
            
        total_pages = (len(MOCK_DB) + per_page - 1) // per_page
        if current_page > total_pages and total_pages > 0:
            current_page = 1

        offset = (current_page - 1) * per_page
        limit = offset + per_page
        page_data = MOCK_DB[offset:limit]
        
        self.render("items.html", records=page_data)

    def post(self, page_num):
        new_user = self.get_argument("username", "")
        new_email = self.get_argument("email", "")
        if new_user and new_email:
            MOCK_DB.append({"username": new_user, "email": new_email})
        
        self.redirect(f"/items/{page_num}")

Persistencia de Estado y Mitigación de XSS

Al enviar un formulario mediante POST, es crucial redirigir al usuario a la página exacta en la que se encontraba, en lugar de reiniciar la vista a la página 1. Además, Tornado protege automáticamente contra ataques XSS (Cross-Site Scripting) escapando el contenido en las plantillas. Si es necesario renderizar HTML confiable sin escapar, se debe utilizar la directiva {% raw %}.



<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Gestión de Items</title>
</head>
<body>
    <h2>Agregar Nuevo Item</h2>
    <!-- El action mantiene el número de página actual -->
    <form action="/items/{{ current_page }}" method="post">
        <input type="text" name="username" placeholder="Usuario" required>
        <input type="email" name="email" placeholder="Email" required>
        <button type="submit">Guardar</button>
    </form>

    <h2>Listado de Items</h2>
    <table border="1">
        <thead>
            <tr><th>Usuario</th><th>Email</th></tr>
        </thead>
        <tbody>
            {% for record in records %}
            <tr>
                <!-- Uso de raw para renderizar sin escape (precaución con datos de usuario) -->
                <td>{% raw record['username'] %}</td>
                <td>{{ record['email'] }}</td>
            </tr>
            {% end %}
        </tbody>
    </table>
</body>
</html>

Lógica de Ventana Deslizante para Navegación

Mostrar todos los números de página cuando el dataset es grande degrada la experiencia de usuario. Se implementa una lógica de ventana deslizante que muestra un máximo de 11 enlaces de página, manteniendo el enlace activo resaltado y ajustando los rangos dinámicamente según la página actual.


import math

class Paginator:
    def __init__(self, total_items, current_page, per_page=10, window_size=11):
        self.total_items = total_items
        self.per_page = per_page
        self.window_size = window_size
        self.total_pages = math.ceil(total_items / per_page) if total_items > 0 else 1
        
        try:
            self.current_page = int(current_page)
        except (ValueError, TypeError):
            self.current_page = 1
            
        if self.current_page < 1:
            self.current_page = 1
        elif self.current_page > self.total_pages:
            self.current_page = self.total_pages

    @property
    def offset(self):
        return (self.current_page - 1) * self.per_page

    @property
    def limit(self):
        return self.offset + self.per_page

    def get_slice(self, dataset):
        return dataset[self.offset:self.limit]

    def generate_html(self, base_url):
        half_window = self.window_size // 2
        start_page = max(1, self.current_page - half_window)
        end_page = min(self.total_pages, start_page + self.window_size - 1)
        
        if end_page - start_page + 1 < self.window_size:
            start_page = max(1, end_page - self.window_size + 1)

        links = []
        for i in range(start_page, end_page + 1):
            active_class = "active" if i == self.current_page else ""
            links.append(f"<a class='{active_class}' href='/{base_url}{i}'>{i}</a>")

        return "".join(links)

Refactorización Orientada a Objetos y Compnoente Avanzado

Para mejorar la mantenibilidad, la lógica de paginación se encapsula completamente en una clase reutilizable. Esta versión final incluye controles de navegación completos: Primera página, Anterior, Siguiente, Última página, y un campo de entrada con JavaScript para saltar a una página específica.


import math

class AdvancedPaginator:
    def __init__(self, total_items, current_page, per_page=10, window_size=11):
        self.total_items = total_items
        self.per_page = per_page
        self.window_size = window_size
        self.total_pages = math.ceil(total_items / per_page) if total_items > 0 else 1
        
        try:
            self.current_page = int(current_page)
        except (ValueError, TypeError):
            self.current_page = 1
            
        self.current_page = max(1, min(self.current_page, self.total_pages))

    @property
    def offset(self):
        return (self.current_page - 1) * self.per_page

    def get_slice(self, dataset):
        return dataset[self.offset : self.offset + self.per_page]

    def generate_html(self, base_url):
        half_window = self.window_size // 2
        start_page = max(1, self.current_page - half_window)
        end_page = min(self.total_pages, start_page + self.window_size - 1)
        
        if end_page - start_page + 1 < self.window_size:
            start_page = max(1, end_page - self.window_size + 1)

        html_parts = []
        
        # Controles de límites
        html_parts.append(f"<a href='/{base_url}1'>Primera</a>")
        prev_page = max(1, self.current_page - 1)
        html_parts.append(f"<a href='/{base_url}{prev_page}'>Anterior</a>")

        # Números de página
        for i in range(start_page, end_page + 1):
            css_class = "active" if i == self.current_page else ""
            html_parts.append(f"<a class='{css_class}' href='/{base_url}{i}'>{i}</a>")

        # Controles de límites
        next_page = min(self.total_pages, self.current_page + 1)
        html_parts.append(f"<a href='/{base_url}{next_page}'>Siguiente</a>")
        html_parts.append(f"<a href='/{base_url}{self.total_pages}'>Última</a>")
        
        # Salto de página
        html_parts.append(f"<input type='number' id='jumpPage' min='1' max='{self.total_pages}' style='width:50px'>")
        html_parts.append(f"<button onclick='jumpToPage(\"{base_url}\")'>Ir</button>")
        html_parts.append("<script>function jumpToPage(base){var p=document.getElementById('jumpPage').value;if(p>0)location.href='/'+base+p;}</script>")

        return "".join(html_parts)

El controlador de Tornado se simplifica drásticamente al delegar la lógica a la clase AdvancedPaginator.


import tornado.web
from utils.paginator import AdvancedPaginator

MOCK_DB = [{"username": f"user_{i}", "email": f"user_{i}@example.com"} for i in range(150)]

class ItemHandler(tornado.web.RequestHandler):
    def get(self, page_num="1"):
        paginator = AdvancedPaginator(len(MOCK_DB), page_num, per_page=10)
        page_data = paginator.get_slice(MOCK_DB)
        pagination_html = paginator.generate_html("items/")
        
        self.render("items.html", 
                    records=page_data, 
                    pagination=pagination_html, 
                    current_page=paginator.current_page)

    def post(self, page_num="1"):
        new_user = self.get_argument("username", "")
        new_email = self.get_argument("email", "")
        if new_user and new_email:
            MOCK_DB.append({"username": new_user, "email": new_email})
        
        self.redirect(f"/items/{page_num}")

La plentilla final integra los estilos CSS para resaltar la página activa y randeriza el bloque HTML generado por el paginador utilizando la directiva {% raw %}.



<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Gestión de Items</title>
    <style>
        .pagination { margin-top: 20px; }
        .pagination a { 
            padding: 5px 10px; margin: 2px; text-decoration: none; 
            background: #f0f0f0; border: 1px solid #ccc; 
        }
        .pagination a.active { 
            background: #007bff; color: white; font-weight: bold; 
            border-color: #007bff;
        }
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
    </style>
</head>
<body>
    <h2>Agregar Nuevo Item</h2>
    <form action="/items/{{ current_page }}" method="post">
        <input type="text" name="username" placeholder="Usuario" required>
        <input type="email" name="email" placeholder="Email" required>
        <button type="submit">Guardar</button>
    </form>

    <h2>Listado de Items</h2>
    <table>
        <thead>
            <tr><th>Usuario</th><th>Email</th></tr>
        </thead>
        <tbody>
            {% for record in records %}
            <tr>
                <td>{{ record['username'] }}</td>
                <td>{{ record['email'] }}</td>
            </tr>
            {% end %}
        </tbody>
    </table>

    <div class="pagination">
        {% raw pagination %}
    </div>
</body>
</html>

Etiquetas: Tornado Python Paginación EnrutamientoRegex PrevencionXSS

Publicado el 7-30 16:29