El projecte Django de la lliçó anterior té els fonaments — model Llibre migrat, admin funcionant, una vista de salutació — però els visitants encara no veuen res d'útil. Avui es completa l'app cataleg fins a deixar-la a l'altura de la versió Flask: catàleg navegable, fitxa de llibre amb preu de soci, i una alta de llibres pública amb formulari que es valida sol. Pel camí aprendràs l'idioma de consultes de l'ORM (QuerySets), les plantilles Django (descobriràs que ja gairebé les saps), el patró POST-redirect-GET, els missatges flash i què és aquest CSRF del qual Django et protegeix sense demanar permís. Al final, la taula que resumeix el mòdul sencer: mateixa funcionalitat, dos camins — i Papyrus, per fi, obert al món.

Contingut

  1. QuerySets: preguntar a la base de dades en Python
  2. Llibre.DoesNotExist i get_object_or_404
  3. Vistes amb plantilles: render i el context
  4. URLs amb nom
  5. Plantilles Django: herència, {% url %} i filtres
  6. La fitxa de llibre: estoc i preu de soci
  7. Formularis amb ModelForm: l'alta de llibres
  8. clean_preu: validació personalitzada
  9. POST-redirect-GET i missatges flash
  10. CSRF: de què et protegeix aquest token
  11. Flask vs Django: mateixa funcionalitat, dos camins
  12. Desplegament: menció honesta i tancament del mòdul

QuerySets: preguntar a la base de dades en Python

A Flask, el catàleg era un diccionari en memòria i cercaves amb les teves funcions de M3. A Django, les dades viuen a la base de dades i es consulten a través de Llibre.objects, el manager del model. Els seus mètodes retornen QuerySets — col·leccions mandroses que es comporten com llistes — o instàncies:

Mètode Retorna Exemple
objects.all() QuerySet amb tot Llibre.objects.all()
objects.filter(...) QuerySet amb els que compleixen Llibre.objects.filter(estoc__gt=0)
objects.exclude(...) QuerySet amb els que NO compleixen Llibre.objects.exclude(estoc=0)
objects.get(...) Una instància (o excepció) Llibre.objects.get(titol="Hamlet")
objects.count() Enter Llibre.objects.count()4
objects.order_by(...) QuerySet ordenat Llibre.objects.order_by("preu")

La sintaxi camp__operador (doble guió baix) és el vocabulari de condicions: estoc__gt=0 (greater than, més gran que zero), titol__icontains="odi" (conté, sense distingir majúscules — perfecte per al cercador), preu__lte=15 (menor o igual). Compara mentalment amb M4: [ll for ll in cataleg.values() if ll.estoc > 0] i Llibre.objects.filter(estoc__gt=0) expressen el mateix; la diferència és que el QuerySet el resol la base de dades, que per això està optimitzada.

Llibre.DoesNotExist i get_object_or_404

objects.get() és la versió Django del nostre obtenir_llibre() de M7, amb la seva mateixa filosofia: si no hi ha resultat, llança una excepcióLlibre.DoesNotExist. I la traducció excepció → 404 que a Flask vas muntar amb errorhandler, Django la porta feta en una drecera que faràs servir constantment:

from django.shortcuts import get_object_or_404

llibre = get_object_or_404(Llibre, titol="Hamlet")
# = intenta objects.get(); si salta Llibre.DoesNotExist, respon 404 i llestos

Reconeix el patró: és la teva parella LlibreNoTrobatError + errorhandler de 10-03, empaquetada en una funció. Els frameworks convergeixen en les mateixes idees perquè els problemes són els mateixos.

Vistes amb plantilles: render i el context

Adeu a l'HTML incrustat en HttpResponse. La vista del catàleg, a cataleg/views.py:

from django.shortcuts import render, get_object_or_404
from .models import Llibre

def inici(request):
    llibres = Llibre.objects.order_by("titol")
    return render(request, "cataleg/inici.html", {"llibres": llibres})

def detall(request, titol):
    llibre = get_object_or_404(Llibre, titol=titol)
    return render(request, "cataleg/detall.html", {"llibre": llibre})

render(request, plantilla, context) és el render_template de Flask amb dos matisos: el request viatja com a primer argument (costum Django) i el context és un diccionari explícit en lloc d'arguments amb nom. Les plantilles d'una app viuen a cataleg/templates/cataleg/ — la subcarpeta repetida evita col·lisions de noms entre apps; és estrany la primera vegada i automàtic la segona.

URLs amb nom

Connectem les vistes a papyrus_web/urls.py, ara amb el paràmetre name:

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", views.inici, name="inici"),
    path("llibre/<str:titol>/", views.detall, name="detall"),
    path("alta/", views.alta_llibre, name="alta"),   # l'escrivim de seguida
]

<str:titol> és la variable de ruta — el /llibre/<titol> de Flask amb el tipus explícit al davant. El name és la millora silenciosa: les plantilles enllaçaran per nom ({% url 'detall' llibre.titol %}), de manera que si demà canvies llibre/ per fitxa/, tots els enllaços del lloc s'actualitzen sols. URLs escrites a mà per la plantilla: deute tècnic; URLs per nom: manteniment gratis.

Plantilles Django: herència, {% url %} i filtres

Bones notícies: el motor de plantilles de Django és tan semblant a Jinja2 (10-02) que aquesta secció és mig repàs — {{ }} imprimeix, {% %} és lògica, {% extends %}/{% block %} hereten. cataleg/templates/cataleg/base.html:

<!DOCTYPE html>
<html lang="ca">
<head>
    <meta charset="utf-8">
    <title>{% block titol %}Papyrus{% endblock %}</title>
</head>
<body>
    <header><a href="{% url 'inici' %}">Papyrus</a> — llibreria de barri</header>
    <main>{% block contingut %}{% endblock %}</main>
</body>
</html>

I inici.html, amb la taula del catàleg:

{% extends "cataleg/base.html" %}
{% block titol %}Catàleg — Papyrus{% endblock %}
{% block contingut %}
<h1>Catàleg ({{ llibres|length }} títols)</h1>
<table>
    {% for llibre in llibres %}
    <tr>
        <td><a href="{% url 'detall' llibre.titol %}">{{ llibre.titol }}</a></td>
        <td>{{ llibre.preu|floatformat:2 }} €</td>
        <td>{% if llibre.hi_ha_estoc %}{{ llibre.estoc }}{% else %}Esgotat{% endif %}</td>
    </tr>
    {% empty %}
    <tr><td>El catàleg està buit.</td></tr>
    {% endfor %}
</table>
<p><a href="{% url 'alta' %}">Dona d'alta un llibre</a></p>
{% endblock %}

Les diferències amb Jinja2, que són poques i puntuals:

Aspecte Jinja2 (Flask) Plantilles Django
Cridar mètodes llibre.hi_ha_estoc() amb parèntesis llibre.hi_ha_estoc sense parèntesis (els posa Django)
Formatar números `{{ "%.2f" format(x) }}`
Enllaços interns url_for('vista', ...) en Python etiqueta {% url 'nom' arg %} a la plantilla
Bucle buit {% else %} dins del for {% empty %}
Lògica permesa Gairebé qualsevol expressió Python Deliberadament limitada (la lògica, a la vista)

Aquesta última fila és filosofia Django en miniatura: si una plantilla necessita calcular, el càlcul pertany a la vista o al model. MTV amb disciplina.

La fitxa de llibre: estoc i preu de soci

cataleg/templates/cataleg/detall.html — l'aparador que veurà la Júlia des del mòbil:

{% extends "cataleg/base.html" %}
{% block titol %}{{ llibre.titol }} — Papyrus{% endblock %}
{% block contingut %}
<h1>{{ llibre.titol }}</h1>
<ul>
    <li>Preu: {{ llibre.preu_final|floatformat:2 }} €</li>
    <li>Preu de soci: {{ llibre.preu_soci|floatformat:2 }} €</li>
    <li>{% if llibre.hi_ha_estoc %}En estoc: {{ llibre.estoc }} exemplars{% else %}Esgotat{% endif %}</li>
</ul>
<a href="{% url 'inici' %}">← Torna al catàleg</a>
{% endblock %}

Petit peatge del "sense parèntesis": la plantilla no pot passar soci=True, així que afegim al model un mètode sense arguments que delega en el de sempre:

# a cataleg/models.py, dins de Llibre
def preu_soci(self):
    return self.preu_final(soci=True)

La regla de M5 intacta: el càlcul viu al model, la plantilla només el mostra. Hamlet ensenya 10.35 € de preu final i 9.83 € per a socis — el número canònic que la suite de M9 fa tres mòduls que vigila.

Formularis amb ModelForm: l'alta de llibres

A Flask (10-03) vas validar l'alta a mà: comprovar camps, convertir tipus, respondre 400 amb missatge. Django genera tot això des del model. Crea cataleg/forms.py:

from django import forms
from .models import Llibre

class LlibreForm(forms.ModelForm):
    class Meta:
        model = Llibre
        fields = ["titol", "preu", "estoc"]

Això és tot: ModelForm llegeix els camps del model (CharField, DecimalField, IntegerField) i fabrica el formulari HTML, la conversió de tipus i la validació. La vista que el fa servir:

from django.shortcuts import redirect
from django.contrib import messages
from .forms import LlibreForm

def alta_llibre(request):
    if request.method == "POST":
        form = LlibreForm(request.POST)
        if form.is_valid():
            llibre = form.save()                    # persisteix: el regal, un altre cop
            messages.success(request, f"«{llibre.titol}» afegit al cataleg.")
            return redirect("detall", titol=llibre.titol)
    else:
        form = LlibreForm()
    return render(request, "cataleg/alta.html", {"form": form})

I la seva plantilla, mínima:

{% extends "cataleg/base.html" %}
{% block contingut %}
<h1>Alta de llibre</h1>
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Desa</button>
</form>
{% endblock %}

{{ form.as_p }} pinta cada camp amb la seva etiqueta, el seu valor previ i els seus errors de validació en paràgrafs. form.is_valid() executa totes les comprovacions; si alguna cosa falla, el render final torna a ensenyar el formulari amb els missatges al costat de cada camp — el cicle complet que a Flask vas programar amb try/except i respostes 400, aquí de sèrie.

clean_preu: validació personalitzada

La validació automàtica cobreix tipus i longituds, però "el preu ha de ser positiu" és regla de negoci nostra — la mateixa que la dataclass de M5 defensava i que el POST de 10-03 comprovava a mà. A Django s'enganxa amb un mètode clean_<camp> al formulari:

class LlibreForm(forms.ModelForm):
    class Meta:
        model = Llibre
        fields = ["titol", "preu", "estoc"]

    def clean_preu(self):
        preu = self.cleaned_data["preu"]
        if preu <= 0:
            raise forms.ValidationError("El preu ha de ser mes gran que zero.")
        return preu

Django crida clean_preu durant is_valid(); si llances ValidationError (una excepció amb ofici, com les de M7), el missatge apareix al costat del camp. L'usuari veu "El preu ha de ser mes gran que zero." al costat de la casella — sense que hagis escrit ni una línia de presentació d'errors.

POST-redirect-GET i missatges flash

Torna a mirar la vista alta_llibre: després de desar, no renderitza cap plantilla — fa redirect(...). És el patró POST-redirect-GET i evita un clàssic vergonyós: si respons al POST amb una pàgina i l'usuari prem F5, el navegador reenvia el POST i el llibre es crea dues vegades. Redirigint, el que es recarrega és un GET innocu. Grava-t'ho: després d'un POST amb èxit, sempre redirect.

I com avisem de l'èxit si marxem a una altra pàgina? Amb els missatges flash: messages.success(...) deixa una nota que sobreviu exactament una petició. A base.html, un forat per mostrar-les:

{% if messages %}
    {% for missatge in messages %}<p class="avis">{{ missatge }}</p>{% endfor %}
{% endif %}

L'usuari aterra a la fitxa del llibre acabat de crear amb el seu «Faust II» afegit al cataleg. a dalt. Alta → redirecció → confirmació: l'estàndard d'or dels formularis web.

CSRF: de què et protegeix aquest token

Et devia l'explicació del {% csrf_token %}. CSRF (Cross-Site Request Forgery, falsificació de petició en lloc creuat) és un atac real: la Marta ha iniciat sessió a Papyrus; visita després una web maliciosa que conté un formulari ocult apuntant a https://papyrus.example/alta/ que s'autoenvia. Sense protecció, el navegador de la Marta — amb les seves credencials de sessió — executaria el POST sense que ella ho sàpiga: la web maliciosa hauria actuat en nom seu.

La defensa: Django inclou a cada formulari legítim un token secret i irrepetible (això és el que imprimeix {% csrf_token %}) i rebutja amb 403 Forbidden qualsevol POST que no el porti. La web atacant no pot conèixer el token, així que el seu formulari fals mor a la porta. Per a tu la regla és simple: tot <form method="post"> porta {% csrf_token %}; si oblides el token, el 403 t'ho recordarà. És la "seguretat de sèrie" que prometia la taula de frameworks de 10-01, funcionant sense que la configuris.

Flask vs Django: mateixa funcionalitat, dos camins

Papyrus web existeix ara per duplicat, i aquesta duplicitat és el millor resum del mòdul:

Necessitat Camí Flask (10-02/10-03) Camí Django (10-04/10-05)
Definir rutes @app.route (decorador, 08-02) path(...) a urls.py, amb nom
Vista Funció que retorna HTML/JSON Funció (request) → render/redirect
Dades Paquet papyrus + JSON de M6 Model Llibre + ORM + SQLite
Consultar cercar_llibre() (M3), dict de M4 Llibre.objects.filter/get (QuerySets)
"No existeix" → 404 LlibreNoTrobatError + errorhandler (M7) Llibre.DoesNotExist + get_object_or_404
Plantilles Jinja2: {{ }}, {% %}, herència Motor Django: gairebé idèntic, més estricte
Formularis i validació A mà: get_json(), try/except, 400 ModelForm + clean_<camp>
Panell de gestió L'hauries de construir Admin de sèrie (tres línies)
Persistir una alta desar_cataleg() (M6) form.save()

La moralitat no és "Django guanya": és que Flask t'ha ensenyat què fan els frameworks i Django t'ha ensenyat a acceptar-ho fet. Sabent tots dos, triaràs amb criteri — i aprendràs qualsevol altre framework (FastAPI, o el que triomfi d'aquí a cinc anys) en dies, perquè els conceptes són aquests.

Desplegament: menció honesta i tancament del mòdul

Queda l'última milla, i aquest curs te la deu amb honestedat en lloc de fingir que no existeix: tant flask run com runserver són servidors de desenvolupament. Publicar Papyrus a internet de debò implica un servidor d'aplicació (gunicorn és el clàssic) darrere d'un servidor web (nginx), DEBUG = False a settings.py (amb DEBUG = True una pàgina d'error ensenya les teves entranyes al món), la SECRET_KEY i les credencials fora del codi — en variables d'entorn —, ALLOWED_HOSTS configurat i HTTPS. Cada peça té documentació excel·lent i cap no cap en aquest mòdul: quan arribi el dia, cerca "Django deployment checklist". Avui n'hi ha prou que sàpigues que la llista existeix i què conté.

I amb això, el ganxo del mòdul 9 queda saldat: Papyrus ja no viu només a l'ordinador de l'Anna. En Lluís consulta l'estoc des del navegador de casa seva; la Júlia obre la fitxa de Hamlet al mòbil i veu el preu de soci; la Marta gestiona l'inventari des de l'admin sense demanar res a ningú. La llibreria de barri té aparador a la xarxa.

Errors Comuns i Consells

  • TemplateDoesNotExist: revisa la doble carpeta — la plantilla ha de ser a cataleg/templates/cataleg/inici.html i referenciar-se com "cataleg/inici.html". És l'entrebanc número u.
  • 403 Forbidden en enviar un formulari: falta {% csrf_token %} dins del <form>. Ara ja saps que aquest error és Django protegint-te, no molestant-te.
  • Fer servir objects.get() a pèl a les vistes: si no existeix, Llibre.DoesNotExist sense capturar = error 500. A les vistes, get_object_or_404; el get() despullat, només quan tu controlis l'excepció.
  • Respondre al POST amb render en lloc de redirect: funciona... fins que algú recarrega la pàgina i duplica l'alta. POST amb èxit → redirect, sense excepcions.
  • Lògica a les plantilles: si trobes a faltar poder calcular a la plantilla, és el senyal que aquest codi pertany al model (com preu_soci) o a la vista. Django et limita a propòsit.
  • {{ llibre.hi_ha_estoc() }} amb parèntesis: error de sintaxi de plantilla. Al motor de Django els mètodes s'invoquen sense parèntesis — i per això només serveixen mètodes sense arguments obligatoris.

Exercicis

Exercici 1: el cercador, versió Django

Afegeix la vista cercar (URL /cercar/, name cercar) que llegeixi request.GET.get("titol", "") i faci servir Llibre.objects.filter(titol__icontains=terme) per retornar coincidències parcials a la plantilla. Afegeix el formulari GET a inici.html. Fixa-t'hi: la versió Flask de 10-02 només trobava títols exactes — aquí l'ORM et dona cerca parcial gratis.

Exercici 2: no venem gratis ni regalat

Amplia LlibreForm amb clean_estoc que rebutgi estocs negatius amb el missatge "L'estoc no pot ser negatiu." i comprova al navegador que el formulari el mostra al costat del camp sense perdre els altres valors teclejats.

Exercici 3: només disponibles

Afegeix la vista disponibles (URL /disponibles/, name disponibles) que llisti únicament llibres amb estoc, reutilitzant la plantilla inici.html (passa-li el QuerySet filtrat com a llibres). Enllaça-la des de base.html amb {% url %}.

Solucions

Exercici 1:

def cercar(request):
    terme = request.GET.get("titol", "").strip()
    llibres = Llibre.objects.filter(titol__icontains=terme) if terme else []
    return render(request, "cataleg/cercar.html",
                  {"terme": terme, "llibres": llibres})
<form action="{% url 'cercar' %}" method="get">
    <input type="text" name="titol" placeholder="Títol...">
    <button type="submit">Cerca</button>
</form>

request.GET és el request.args de Flask. icontains fa que "odi" trobi L'Odissea: compara amb cercar_llibre, que exigia el títol clavat.

Exercici 2:

def clean_estoc(self):
    estoc = self.cleaned_data["estoc"]
    if estoc < 0:
        raise forms.ValidationError("L'estoc no pot ser negatiu.")
    return estoc

En reenviar el formulari invàlid, Django repinta cada camp amb el que l'usuari va escriure i l'error sota el camp culpable: tota aquesta experiència d'usuari ve amb {{ form.as_p }}.

Exercici 3:

def disponibles(request):
    llibres = Llibre.objects.filter(estoc__gt=0).order_by("titol")
    return render(request, "cataleg/inici.html", {"llibres": llibres})
<header>
    <a href="{% url 'inici' %}">Papyrus</a> ·
    <a href="{% url 'disponibles' %}">Només disponibles</a>
</header>

Reutilitzar inici.html amb un altre context és MTV pur: mateixa presentació, dades diferents. Si demà el disseny canvia, es toca un sol fitxer.

Conclusió

El mòdul 10 ha complert la seva promesa: Papyrus ha sortit de l'ordinador de l'Anna i ara s'obre des del navegador d'en Lluís i el mòbil de la Júlia. Ho ha fet dues vegades, i aquesta ha estat la lliçó gran. Amb Flask has vist cada peça al descobert: @app.route com el decorador de 08-02, Jinja2 omplint HTML, asdict() i jsonify servint el catàleg en JSON, i LlibreNoTrobatError traduïda a 404 amb un errorhandler — el mòdul 7 parlant HTTP. Amb Django has acceptat les bateries: el model Llibre que persisteix sol, QuerySets en lloc de bucles, get_object_or_404, un ModelForm que valida amb clean_preu com validava la teva dataclass, POST-redirect-GET, missatges flash i un CSRF que et protegeix sense preguntar — més l'admin que va convertir la Marta en gestora del catàleg amb tres línies. Mateixa funcionalitat, dos camins, i tu amb criteri per triar. Però obrir la llibreria al món té una conseqüència que l'Anna encara no veu venir: una web genera dades. Cada venda, cada cerca, cada visita a una fitxa deixa rastre — quins títols es consulten i no es compren?, quin dia de la setmana ven més?, quant d'estoc convé demanar de cara a Sant Jordi? Respondre això ja no és programar la botiga: és entendre els seus números. Aquest ofici es diu ciència de dades, les seves eines són NumPy, pandas i Matplotlib, i és exactament el mòdul 11.

Curs de Programació en Python

Mòdul 1: Introducció a Python

Mòdul 2: Estructures de Control

Mòdul 3: Funcions i Mòduls

Mòdul 4: Estructures de Dades

Mòdul 5: Programació Orientada a Objectes

Mòdul 6: Gestió de Fitxers

Mòdul 7: Gestió d'Errors i Excepcions

Mòdul 8: Temes Avançats

Mòdul 9: Proves i Depuració

Mòdul 10: Desenvolupament Web amb Python

Mòdul 11: Ciència de Dades amb Python

Mòdul 12: Projecte Final

© Copyright 2026. Tots els drets reservats