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>