El proyecto Django de la lección anterior tiene los cimientos — modelo Libro migrado, admin funcionando, una vista de saludo — pero los visitantes aún no ven nada útil. Hoy se completa la app catalogo hasta dejarla a la altura de la versión Flask: catálogo navegable, ficha de libro con precio de socio, y un alta de libros pública con formulario que se valida solo. Por el camino aprenderás el idioma de consultas del ORM (QuerySets), las plantillas Django (descubrirás que ya casi las sabes), el patrón POST-redirect-GET, los mensajes flash y qué es ese CSRF del que Django te protege sin pedir permiso. Al final, la tabla que resume el módulo entero: misma funcionalidad, dos caminos — y Papyrus, por fin, abierto al mundo.
Contenido
- QuerySets: preguntar a la base de datos en Python
Libro.DoesNotExistyget_object_or_404- Vistas con plantillas:
rendery el contexto - URLs con nombre
- Plantillas Django: herencia,
{% url %}y filtros - La ficha de libro: stock y precio de socio
- Formularios con
ModelForm: el alta de libros clean_precio: validación personalizada- POST-redirect-GET y mensajes flash
- CSRF: de qué te protege ese token
- Flask vs Django: misma funcionalidad, dos caminos
- Despliegue: mención honesta y cierre del módulo
QuerySets: preguntar a la base de datos en Python
En Flask, el catálogo era un diccionario en memoria y buscabas con tus funciones de M3. En Django, los datos viven en la base de datos y se consultan a través de Libro.objects, el manager del modelo. Sus métodos devuelven QuerySets — colecciones perezosas que se comportan como listas — o instancias:
| Método | Devuelve | Ejemplo |
|---|---|---|
objects.all() |
QuerySet con todo | Libro.objects.all() |
objects.filter(...) |
QuerySet con los que cumplen | Libro.objects.filter(stock__gt=0) |
objects.exclude(...) |
QuerySet con los que NO cumplen | Libro.objects.exclude(stock=0) |
objects.get(...) |
Una instancia (o excepción) | Libro.objects.get(titulo="Hamlet") |
objects.count() |
Entero | Libro.objects.count() → 4 |
objects.order_by(...) |
QuerySet ordenado | Libro.objects.order_by("precio") |
La sintaxis campo__operador (doble guion bajo) es el vocabulario de condiciones: stock__gt=0 (greater than, mayor que cero), titulo__icontains="odi" (contiene, sin distinguir mayúsculas — perfecto para el buscador), precio__lte=15 (menor o igual). Compara mentalmente con M4: [l for l in catalogo.values() if l.stock > 0] y Libro.objects.filter(stock__gt=0) expresan lo mismo; la diferencia es que el QuerySet lo resuelve la base de datos, que para eso está optimizada.
Libro.DoesNotExist y get_object_or_404
objects.get() es la versión Django de nuestro obtener_libro() de M7, con su misma filosofía: si no hay resultado, lanza una excepción — Libro.DoesNotExist. Y la traducción excepción → 404 que en Flask montaste con errorhandler, Django la trae hecha en un atajo que usarás constantemente:
from django.shortcuts import get_object_or_404
libro = get_object_or_404(Libro, titulo="Hamlet")
# = intenta objects.get(); si salta Libro.DoesNotExist, responde 404 y yaReconoce el patrón: es tu pareja LibroNoEncontradoError + errorhandler de 10-03, empaquetada en una función. Los frameworks convergen en las mismas ideas porque los problemas son los mismos.
Vistas con plantillas: render y el contexto
Adiós al HTML incrustado en HttpResponse. La vista del catálogo, en catalogo/views.py:
from django.shortcuts import render, get_object_or_404
from .models import Libro
def inicio(request):
libros = Libro.objects.order_by("titulo")
return render(request, "catalogo/inicio.html", {"libros": libros})
def detalle(request, titulo):
libro = get_object_or_404(Libro, titulo=titulo)
return render(request, "catalogo/detalle.html", {"libro": libro})render(request, plantilla, contexto) es el render_template de Flask con dos matices: el request viaja como primer argumento (costumbre Django) y el contexto es un diccionario explícito en vez de argumentos con nombre. Las plantillas de una app viven en catalogo/templates/catalogo/ — la subcarpeta repetida evita colisiones de nombres entre apps; es raro la primera vez y automático la segunda.
URLs con nombre
Conectamos las vistas en papyrus_web/urls.py, ahora con el parámetro name:
urlpatterns = [
path("admin/", admin.site.urls),
path("", views.inicio, name="inicio"),
path("libro/<str:titulo>/", views.detalle, name="detalle"),
path("alta/", views.alta_libro, name="alta"), # la escribimos enseguida
]<str:titulo> es la variable de ruta — el /libro/<titulo> de Flask con el tipo explícito delante. El name es la mejora silenciosa: las plantillas enlazarán por nombre ({% url 'detalle' libro.titulo %}), de modo que si mañana cambias libro/ por ficha/, todos los enlaces del sitio se actualizan solos. URLs escritas a mano por la plantilla: deuda técnica; URLs por nombre: mantenimiento gratis.
Plantillas Django: herencia, {% url %} y filtros
Buenas noticias: el motor de plantillas de Django es tan parecido a Jinja2 (10-02) que esta sección es medio repaso — {{ }} imprime, {% %} es lógica, {% extends %}/{% block %} heredan. catalogo/templates/catalogo/base.html:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{% block titulo %}Papyrus{% endblock %}</title>
</head>
<body>
<header><a href="{% url 'inicio' %}">Papyrus</a> — librería de barrio</header>
<main>{% block contenido %}{% endblock %}</main>
</body>
</html>Y inicio.html, con la tabla del catálogo:
{% extends "catalogo/base.html" %}
{% block titulo %}Catálogo — Papyrus{% endblock %}
{% block contenido %}
<h1>Catálogo ({{ libros|length }} títulos)</h1>
<table>
{% for libro in libros %}
<tr>
<td><a href="{% url 'detalle' libro.titulo %}">{{ libro.titulo }}</a></td>
<td>{{ libro.precio|floatformat:2 }} €</td>
<td>{% if libro.hay_stock %}{{ libro.stock }}{% else %}Agotado{% endif %}</td>
</tr>
{% empty %}
<tr><td>El catálogo está vacío.</td></tr>
{% endfor %}
</table>
<p><a href="{% url 'alta' %}">Dar de alta un libro</a></p>
{% endblock %}Las diferencias con Jinja2, que son pocas y puntuales:
| Aspecto | Jinja2 (Flask) | Plantillas Django |
|---|---|---|
| Llamar métodos | libro.hay_stock() con paréntesis |
libro.hay_stock sin paréntesis (los pone Django) |
| Formatear números | `{{ "%.2f" | format(x) }}` |
| Enlaces internos | url_for('vista', ...) en Python |
etiqueta {% url 'nombre' arg %} en la plantilla |
| Bucle vacío | {% else %} dentro del for |
{% empty %} |
| Lógica permitida | Casi cualquier expresión Python | Deliberadamente limitada (la lógica, a la vista) |
Esa última fila es filosofía Django en miniatura: si una plantilla necesita calcular, el cálculo pertenece a la vista o al modelo. MTV con disciplina.
La ficha de libro: stock y precio de socio
catalogo/templates/catalogo/detalle.html — el escaparate que verá Júlia desde el móvil:
{% extends "catalogo/base.html" %}
{% block titulo %}{{ libro.titulo }} — Papyrus{% endblock %}
{% block contenido %}
<h1>{{ libro.titulo }}</h1>
<ul>
<li>Precio: {{ libro.precio_final|floatformat:2 }} €</li>
<li>Precio socio: {{ libro.precio_socio|floatformat:2 }} €</li>
<li>{% if libro.hay_stock %}En stock: {{ libro.stock }} ejemplares{% else %}Agotado{% endif %}</li>
</ul>
<a href="{% url 'inicio' %}">← Volver al catálogo</a>
{% endblock %}Pequeño peaje del "sin paréntesis": la plantilla no puede pasar socio=True, así que añadimos al modelo un método sin argumentos que delega en el de siempre:
# en catalogo/models.py, dentro de Libro
def precio_socio(self):
return self.precio_final(socio=True)La regla de M5 intacta: el cálculo vive en el modelo, la plantilla solo lo muestra. Hamlet enseña 10.35 € de precio final y 9.83 € para socios — el número canónico que la suite de M9 lleva tres módulos vigilando.
Formularios con ModelForm: el alta de libros
En Flask (10-03) validaste el alta a mano: comprobar campos, convertir tipos, responder 400 con mensaje. Django genera todo eso desde el modelo. Crea catalogo/forms.py:
from django import forms
from .models import Libro
class LibroForm(forms.ModelForm):
class Meta:
model = Libro
fields = ["titulo", "precio", "stock"]Eso es todo: ModelForm lee los campos del modelo (CharField, DecimalField, IntegerField) y fabrica el formulario HTML, la conversión de tipos y la validación. La vista que lo usa:
from django.shortcuts import redirect
from django.contrib import messages
from .forms import LibroForm
def alta_libro(request):
if request.method == "POST":
form = LibroForm(request.POST)
if form.is_valid():
libro = form.save() # persiste: el regalo, otra vez
messages.success(request, f"«{libro.titulo}» añadido al catálogo.")
return redirect("detalle", titulo=libro.titulo)
else:
form = LibroForm()
return render(request, "catalogo/alta.html", {"form": form})Y su plantilla, mínima:
{% extends "catalogo/base.html" %}
{% block contenido %}
<h1>Alta de libro</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Guardar</button>
</form>
{% endblock %}{{ form.as_p }} pinta cada campo con su etiqueta, su valor previo y sus errores de validación en párrafos. form.is_valid() ejecuta todas las comprobaciones; si algo falla, el render final reenseña el formulario con los mensajes junto a cada campo — el ciclo completo que en Flask programaste con try/except y respuestas 400, aquí de serie.
clean_precio: validación personalizada
La validación automática cubre tipos y longitudes, pero "el precio debe ser positivo" es regla de negocio nuestra — la misma que la dataclass de M5 defendía y que el POST de 10-03 comprobaba a mano. En Django se engancha con un método clean_<campo> en el formulario:
class LibroForm(forms.ModelForm):
class Meta:
model = Libro
fields = ["titulo", "precio", "stock"]
def clean_precio(self):
precio = self.cleaned_data["precio"]
if precio <= 0:
raise forms.ValidationError("El precio debe ser mayor que cero.")
return precioDjango llama a clean_precio durante is_valid(); si lanzas ValidationError (una excepción con oficio, como las de M7), el mensaje aparece junto al campo. El usuario ve "El precio debe ser mayor que cero." al lado de la casilla — sin que escribieras ni una línea de presentación de errores.
POST-redirect-GET y mensajes flash
Vuelve a mirar la vista alta_libro: tras guardar, no renderiza una plantilla — hace redirect(...). Es el patrón POST-redirect-GET y evita un clásico bochornoso: si respondes al POST con una página y el usuario pulsa F5, el navegador reenvía el POST y el libro se crea dos veces. Redirigiendo, lo que se recarga es un GET inocuo. Grábatelo: después de un POST con éxito, siempre redirect.
¿Y cómo avisamos del éxito si nos vamos a otra página? Con los mensajes flash: messages.success(...) deja una nota que sobrevive exactamente una petición. En base.html, un hueco para mostrarlas:
{% if messages %}
{% for mensaje in messages %}<p class="aviso">{{ mensaje }}</p>{% endfor %}
{% endif %}El usuario aterriza en la ficha del libro recién creado con su «"Fausto II" añadido al catálogo.» arriba. Alta → redirección → confirmación: el estándar de oro de los formularios web.
CSRF: de qué te protege ese token
Te debía la explicación del {% csrf_token %}. CSRF (Cross-Site Request Forgery, falsificación de petición en sitio cruzado) es un ataque real: Marta ha iniciado sesión en Papyrus; visita después una web maliciosa que contiene un formulario oculto apuntando a https://papyrus.example/alta/ que se autoenvía. Sin protección, el navegador de Marta — con sus credenciales de sesión — ejecutaría el POST sin que ella lo sepa: la web maliciosa habría actuado en su nombre.
La defensa: Django incluye en cada formulario legítimo un token secreto e irrepetible (eso imprime {% csrf_token %}) y rechaza con 403 Forbidden cualquier POST que no lo traiga. La web atacante no puede conocer el token, así que su formulario falso muere en la puerta. Para ti la regla es simple: todo <form method="post"> lleva {% csrf_token %}; si olvidas el token, el 403 te lo recordará. Es la "seguridad de serie" que prometía la tabla de frameworks de 10-01, funcionando sin que la configures.
Flask vs Django: misma funcionalidad, dos caminos
Papyrus web existe ahora por duplicado, y esa duplicidad es el mejor resumen del módulo:
| Necesidad | Camino Flask (10-02/10-03) | Camino Django (10-04/10-05) |
|---|---|---|
| Definir rutas | @app.route (decorador, 08-02) |
path(...) en urls.py, con nombre |
| Vista | Función que devuelve HTML/JSON | Función (request) → render/redirect |
| Datos | Paquete papyrus + JSON de M6 |
Modelo Libro + ORM + SQLite |
| Consultar | buscar_libro() (M3), dict de M4 |
Libro.objects.filter/get (QuerySets) |
| "No existe" → 404 | LibroNoEncontradoError + errorhandler (M7) |
Libro.DoesNotExist + get_object_or_404 |
| Plantillas | Jinja2: {{ }}, {% %}, herencia |
Motor Django: casi idéntico, más estricto |
| Formularios y validación | A mano: get_json(), try/except, 400 |
ModelForm + clean_<campo> |
| Panel de gestión | Tendrías que construirlo | Admin de serie (tres líneas) |
| Persistir un alta | guardar_catalogo() (M6) |
form.save() |
La moraleja no es "Django gana": es que Flask te enseñó lo que los frameworks hacen y Django te enseñó a aceptarlo hecho. Sabiendo ambos, elegirás con criterio — y aprenderás cualquier otro framework (FastAPI, o el que triunfe dentro de cinco años) en días, porque los conceptos son estos.
Despliegue: mención honesta y cierre del módulo
Queda la última milla, y este curso te la debe con honestidad en lugar de fingir que no existe: tanto flask run como runserver son servidores de desarrollo. Publicar Papyrus en internet de verdad implica un servidor de aplicación (gunicorn es el clásico) detrás de un servidor web (nginx), DEBUG = False en settings.py (con DEBUG = True una página de error enseña tus entrañas al mundo), la SECRET_KEY y las credenciales fuera del código — en variables de entorno —, ALLOWED_HOSTS configurado y HTTPS. Cada pieza tiene documentación excelente y ninguna cabe en este módulo: cuando llegue el día, busca "Django deployment checklist". Hoy basta con que sepas que la lista existe y qué contiene.
Y con eso, el gancho del módulo 9 queda saldado: Papyrus ya no vive solo en el ordenador de Ana. Luis consulta el stock desde el navegador de su casa; Júlia abre la ficha de Hamlet en el móvil y ve el precio de socio; Marta gestiona el inventario desde el admin sin pedirle nada a nadie. La librería de barrio tiene escaparate en la red.
Errores Comunes y Consejos
TemplateDoesNotExist: revisa la doble carpeta — la plantilla debe estar encatalogo/templates/catalogo/inicio.htmly referenciarse como"catalogo/inicio.html". Es el tropiezo número uno.403 Forbiddenal enviar un formulario: falta{% csrf_token %}dentro del<form>. Ahora ya sabes que ese error es Django protegiéndote, no molestándote.- Usar
objects.get()a pelo en vistas: si no existe,Libro.DoesNotExistsin capturar = error 500. En vistas,get_object_or_404;get()desnudo, solo cuando tú controles la excepción. - Responder al POST con
renderen vez deredirect: funciona... hasta que alguien recarga la página y duplica el alta. POST con éxito → redirect, sin excepciones. - Lógica en las plantillas: si echas de menos poder calcular en la plantilla, es la señal de que ese código pertenece al modelo (como
precio_socio) o a la vista. Django te limita a propósito. {{ libro.hay_stock() }}con paréntesis: error de sintaxis de plantilla. En el motor de Django los métodos se invocan sin paréntesis — y por eso solo sirven métodos sin argumentos obligatorios.
Ejercicios
Ejercicio 1: el buscador, versión Django
Añade la vista buscar (URL /buscar/, name buscar) que lea request.GET.get("titulo", "") y use Libro.objects.filter(titulo__icontains=termino) para devolver coincidencias parciales a la plantilla. Añade el formulario GET a inicio.html. Fíjate: la versión Flask de 10-02 solo encontraba títulos exactos — aquí el ORM te da búsqueda parcial gratis.
Ejercicio 2: no vendemos gratis ni regalado
Amplía LibroForm con clean_stock que rechace stocks negativos con el mensaje "El stock no puede ser negativo." y comprueba en el navegador que el formulario lo muestra junto al campo sin perder los demás valores tecleados.
Ejercicio 3: solo disponibles
Añade la vista disponibles (URL /disponibles/, name disponibles) que liste únicamente libros con stock, reutilizando la plantilla inicio.html (pásale el QuerySet filtrado como libros). Enlázala desde base.html con {% url %}.
Soluciones
Ejercicio 1:
def buscar(request):
termino = request.GET.get("titulo", "").strip()
libros = Libro.objects.filter(titulo__icontains=termino) if termino else []
return render(request, "catalogo/buscar.html",
{"termino": termino, "libros": libros})<form action="{% url 'buscar' %}" method="get">
<input type="text" name="titulo" placeholder="Título...">
<button type="submit">Buscar</button>
</form>request.GET es el request.args de Flask. icontains hace que "odi" encuentre La Odisea: compara con buscar_libro, que exigía el título clavado.
Ejercicio 2:
def clean_stock(self):
stock = self.cleaned_data["stock"]
if stock < 0:
raise forms.ValidationError("El stock no puede ser negativo.")
return stockAl reenviar el formulario inválido, Django repinta cada campo con lo que el usuario escribió y el error debajo del campo culpable: toda esa experiencia de usuario viene con {{ form.as_p }}.
Ejercicio 3:
def disponibles(request):
libros = Libro.objects.filter(stock__gt=0).order_by("titulo")
return render(request, "catalogo/inicio.html", {"libros": libros})<header>
<a href="{% url 'inicio' %}">Papyrus</a> ·
<a href="{% url 'disponibles' %}">Solo disponibles</a>
</header>Reutilizar inicio.html con otro contexto es MTV puro: misma presentación, distintos datos. Si mañana el diseño cambia, se toca un solo fichero.
Conclusión
El módulo 10 cumplió su promesa: Papyrus salió del ordenador de Ana y ahora se abre desde el navegador de Luis y el móvil de Júlia. Lo hizo dos veces, y esa fue la lección grande. Con Flask viste cada pieza al descubierto: @app.route como el decorador de 08-02, Jinja2 rellenando HTML, asdict() y jsonify sirviendo el catálogo en JSON, y LibroNoEncontradoError traducida a 404 con un errorhandler — el módulo 7 hablando HTTP. Con Django aceptaste las baterías: el modelo Libro que persiste solo, QuerySets en vez de bucles, get_object_or_404, un ModelForm que valida con clean_precio como validaba tu dataclass, POST-redirect-GET, mensajes flash y un CSRF que te protege sin preguntar — más el admin que convirtió a Marta en gestora del catálogo con tres líneas. Misma funcionalidad, dos caminos, y tú con criterio para elegir. Pero abrir la librería al mundo tiene una consecuencia que Ana aún no ve venir: una web genera datos. Cada venta, cada búsqueda, cada visita a una ficha deja rastro — ¿qué títulos se consultan y no se compran?, ¿qué día de la semana vende más?, ¿cuánto stock conviene pedir de cara a Sant Jordi? Responder a eso ya no es programar la tienda: es entender sus números. Ese oficio se llama ciencia de datos, sus herramientas son NumPy, pandas y Matplotlib, y es exactamente el módulo 11.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
