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

  1. QuerySets: preguntar a la base de datos en Python
  2. Libro.DoesNotExist y get_object_or_404
  3. Vistas con plantillas: render y el contexto
  4. URLs con nombre
  5. Plantillas Django: herencia, {% url %} y filtros
  6. La ficha de libro: stock y precio de socio
  7. Formularios con ModelForm: el alta de libros
  8. clean_precio: validación personalizada
  9. POST-redirect-GET y mensajes flash
  10. CSRF: de qué te protege ese token
  11. Flask vs Django: misma funcionalidad, dos caminos
  12. 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ónLibro.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 ya

Reconoce 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 precio

Django 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 en catalogo/templates/catalogo/inicio.html y referenciarse como "catalogo/inicio.html". Es el tropiezo número uno.
  • 403 Forbidden al 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.DoesNotExist sin capturar = error 500. En vistas, get_object_or_404; get() desnudo, solo cuando tú controles la excepción.
  • Responder al POST con render en vez de redirect: 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 stock

Al 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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados