S'ha acabat la teoria. En aquesta lliçó, Papyrus obre la seva primera porta al món: instal·laràs Flask, escriuràs una aplicació web completa en cinc línies i, en obrir el navegador, veuràs el catàleg de l'Anna — L'Odissea, Hamlet, El Quixot, Faust — servit per HTTP des de la teva pròpia màquina. Pel camí descobriràs que ja coneixies la peça central de Flask sense saber-ho (@app.route és un decorador dels de 08-02), aprendràs a generar HTML amb plantilles Jinja2 en lloc de concatenar cadenes, i construiràs un cercador que reutilitza, sense tocar-ne ni una línia, el cercar_llibre() que vas escriure a M3 i vas blindar amb tests a M9. Aquest és el missatge profund de la lliçó: la web és una capa nova sobre el codi de sempre.

Contingut

  1. Instal·lació a l'entorn virtual
  2. L'aplicació mínima: cinc línies, línia a línia
  3. @app.route és un decorador (i tu ja saps què vol dir això)
  4. El servidor de desenvolupament i el perill de debug=True
  5. Rutes amb variables: /llibre/<titol>
  6. Plantilles Jinja2: HTML amb forats
  7. Herència de plantilles: base.html
  8. Fitxers estàtics: un CSS mínim
  9. El cercador: un formulari GET sobre cercar_llibre()
  10. Estructura de projecte raonable

Instal·lació a l'entorn virtual

Com qualsevol paquet des de M1: entorn virtual activat, pip install:

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install flask

Comprova la instal·lació amb flask --version. Treballarem en una carpeta papyrus_web_flask/ que conté, al costat del nou codi web, el paquet papyrus/ de M3–M9 tal qual i el seu dades/cataleg.json. No copiïs funcions: copia (o enllaça) el paquet sencer; la suite de pytest de M9 continua passant i continua protegint-te.

L'aplicació mínima: cinc línies, línia a línia

Crea app.py:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def inici():
    return "<h1>Papyrus, llibreria de barri</h1><p>Benvingut.</p>"

Cada línia mereix la seva explicació:

  • from flask import Flask — importa la classe central del framework.
  • app = Flask(__name__) — crea l'aplicació. __name__ (M3: val "__main__" o el nom del mòdul) diu a Flask on és el teu codi, perquè sàpiga localitzar les carpetes templates/ i static/ relatives a ell.
  • @app.route("/") — registra la funció per a la ruta / (l'arrel del lloc). És la peça màgica i la desmuntem a la secció següent.
  • def inici(): — una funció Python normal i corrent. En argot Flask s'anomena vista (view).
  • return "<h1>...</h1>" — el que la vista retorna es converteix en el cos de la resposta HTTP, amb codi 200 OK automàtic. El navegador rep aquest HTML i el pinta.

Això és una aplicació web sencera: rep peticions GET / i respon HTML. Compara-la mentalment amb la lliçó 10-01: Flask està fent de fontaner HTTP (parsejar la petició, muntar la resposta, capçaleres, codi d'estat) perquè tu només escriguis la funció.

@app.route és un decorador (i tu ja saps què vol dir això)

Atura't a @app.route("/"). Aquesta sintaxi és exactament la dels decoradors amb arguments de la lliçó 08-02: una funció que rep la teva funció i la registra o l'embolcalla. Sense sucre sintàctic, el codi equival a:

def inici():
    return "<h1>Papyrus...</h1>"

inici = app.route("/")(inici)   # el que fa el @ per sota (08-02)

app.route("/") no executa la teva vista: l'apunta en una taula interna — "quan arribi una petició a /, crida inici()" — i te la retorna intacta. És el patró decorador de registre que vas veure a 08-02, el mateix que feia servir @pytest.fixture a M9. Quan al seu dia et vas preguntar per a què servien els decoradors més enllà de cronometrar(): per a això. Frameworks sencers s'hi construeixen al damunt.

flowchart LR
    A["GET /llibre/Hamlet"] --> B{"Taula de rutes de Flask"}
    B -->|"/"| C["inici()"]
    B -->|"/llibre/&lt;titol&gt;"| D["fitxa_llibre('Hamlet')"]
    B -->|"sense coincidència"| E["404 automàtic"]
    D --> F["return HTML → resposta 200"]

El servidor de desenvolupament i el perill de debug=True

Per arrencar l'aplicació hi ha dues maneres equivalents. La moderna, des del terminal:

flask --app app run --debug

O afegint al final d'app.py el bloc if __name__ == "__main__": de M3:

if __name__ == "__main__":
    app.run(debug=True)

i executant python app.py. En tots dos casos veuràs alguna cosa com:

 * Running on http://127.0.0.1:5000
 * Debug mode: on

Obre http://127.0.0.1:5000 al navegador: aquí tens la teva pàgina. 127.0.0.1 és la teva pròpia màquina (localhost) i 5000 el port — les peces d'URL de 10-01. El mode debug et dona dos regals durant el desenvolupament: recàrrega automàtica en desar canvis (adeu a rearrencar a mà) i pàgines d'error amb el traceback complet (M7) al navegador, fins i tot amb una consola interactiva.

I aquí hi ha també el perill, dit sense embuts: debug=True mai en un servidor accessible des de fora. Aquesta consola interactiva del navegador executa codi Python arbitrari al teu servidor: per a un atacant és una porta oberta de bat a bat. A més, aquest servidor de desenvolupament no està pensat per a trànsit real; en producció es fa servir un servidor WSGI com gunicorn darrere de nginx — ho esmentem aquí per honestedat i ho tocarem de passada a 10-05, però el desplegament en producció queda fora d'aquest curs.

Rutes amb variables: /llibre/<titol>

El catàleg té quatre llibres i no escriuràs quatre vistes. Les rutes admeten parts variables entre angles, que Flask extreu i passa com a argument:

from papyrus.magatzem import carregar_cataleg, cercar_llibre

cataleg = carregar_cataleg("dades/cataleg.json")

@app.route("/llibre/<titol>")
def fitxa_llibre(titol):
    llibre = cercar_llibre(cataleg, titol)
    if llibre is None:
        return "<p>No tenim aquest llibre.</p>", 404
    return (f"<h1>{llibre.titol}</h1>"
            f"<p>Preu: {llibre.preu:.2f} € — "
            f"soci: {llibre.preu_final(soci=True):.2f} €</p>"
            f"<p>Estoc: {llibre.estoc}</p>")

Punts clau:

  • GET /llibre/Hamlet executa fitxa_llibre("Hamlet"): la variable de la ruta es converteix en el paràmetre de la funció, amb el mateix nom.
  • Reutilització pura: carregar_cataleg, cercar_llibre i preu_final són els de M5–M6, intactes. La vista només tradueix entre HTTP i el teu paquet. Hamlet respon 9.95 € — soci: 9.83 €, els números que la teva suite de M9 vigila.
  • Retornar una tupla (cos, codi) fixa el codi d'estat: aquí, el 404 de 10-01 quan el llibre no existeix. (A 10-03 farem aquesta traducció de manera més elegant amb obtenir_llibre i les seves excepcions.)
  • Es pot exigir tipus: /llibre/<int:posicio> només casaria amb enters. Per a títols ens val el conversor per defecte (text).

Plantilles Jinja2: HTML amb forats

Construir HTML amb f-strings escala fatal i és perillós (si un títol contingués <script>, l'injectaries tal qual a la pàgina). La solució del framework són les plantilles: fitxers HTML amb forats que Flask omple amb les teves dades fent servir el motor Jinja2. Van a la carpeta templates/, germana d'app.py.

templates/inici.html:

<h1>Catàleg de Papyrus</h1>
<table>
    <tr><th>Títol</th><th>Preu</th><th>Preu de soci</th><th>Estoc</th></tr>
    {% for llibre in llibres %}
    <tr>
        <td><a href="/llibre/{{ llibre.titol }}">{{ llibre.titol }}</a></td>
        <td>{{ "%.2f"|format(llibre.preu) }} €</td>
        <td>{{ "%.2f"|format(llibre.preu_final(soci=True)) }} €</td>
        <td>{% if llibre.hi_ha_estoc() %}{{ llibre.estoc }}{% else %}Esgotat{% endif %}</td>
    </tr>
    {% endfor %}
</table>

I la vista es redueix a lliurar les dades:

from flask import render_template

@app.route("/")
def inici():
    return render_template("inici.html", llibres=list(cataleg.values()))

La gramàtica de Jinja2 té dos delimitadors i amb ells n'hi ha prou per a gairebé tot:

Sintaxi Què és Exemple
{{ expressió }} Forat: s'avalua i s'imprimeix {{ llibre.titol }}
{% instrucció %} Lògica: bucles i condicionals, no imprimeix {% for %}...{% endfor %}, {% if %}
`{{ valor filtre }}` Filtre: transforma abans d'imprimir

Dins dels forats pots accedir a atributs (llibre.estoc) i cridar mètodes (llibre.hi_ha_estoc()) dels teus objectes: les plantilles veuen les teves dataclasses de M5 directament. Cada argument amb nom de render_template (aquí llibres=...) es converteix en una variable disponible a la plantilla — el context. I el regal de seguretat: Jinja2 escapa l'HTML automàticament als {{ }}; un títol maliciós amb <script> es mostraria com a text inofensiu.

Herència de plantilles: base.html

Totes les pàgines de Papyrus compartiran capçalera, menú i peu. Repetir-los a cada plantilla seria el mateix pecat que repetir codi a M3. Jinja2 ho resol amb herència — sí, el concepte és cosí del de M5: una plantilla base defineix l'estructura i blocs sobreescrivibles.

templates/base.html:

<!DOCTYPE html>
<html lang="ca">
<head>
    <meta charset="utf-8">
    <title>{% block titol %}Papyrus{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='estils.css') }}">
</head>
<body>
    <header><a href="/">Papyrus</a> — llibreria de barri</header>
    <main>{% block contingut %}{% endblock %}</main>
    <footer>Papyrus · Anna i socis · des de M1</footer>
</body>
</html>

I inici.html passa a heretar i omplir només els seus blocs:

{% extends "base.html" %}
{% block titol %}Catàleg — Papyrus{% endblock %}
{% block contingut %}
<h1>Catàleg de Papyrus</h1>
<table> ... la taula d'abans ... </table>
{% endblock %}

{% extends %} ha de ser la primera línia; cada {% block %} de la filla substitueix l'homònim de la base. Afegir ara una pàgina nova costa cinc línies i surt uniformada.

Fitxers estàtics: un CSS mínim

El CSS, les imatges i la resta de fitxers que se serveixen tal qual van a static/. A base.html ja l'hem enllaçat amb url_for('static', filename='estils.css')url_for construeix URLs a partir de noms, millor que escriure-les a mà. Un static/estils.css mínim perquè Papyrus no sembli de 1996:

body { font-family: Georgia, serif; max-width: 52rem; margin: 2rem auto; }
header, footer { color: #666; border-bottom: 1px solid #ddd; padding: .5rem 0; }
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #ddd; padding: .4rem .8rem; text-align: left; }

No és un curs de CSS (allò promès a 10-01 continua en peu): amb això n'hi ha prou.

El cercador: un formulari GET sobre cercar_llibre()

La pàgina d'inici necessita el cercador que la Júlia farà servir des del mòbil. A inici.html, dins del bloc de contingut:

<form action="/cercar" method="get">
    <input type="text" name="titol" placeholder="Títol...">
    <button type="submit">Cerca</button>
</form>

És el formulari GET de 10-01: en cercar "odissea" el navegador demanarà GET /cercar?titol=odissea. Per llegir aquesta query string, Flask ofereix l'objecte request:

from flask import request

@app.route("/cercar")
def cercar():
    terme = request.args.get("titol", "").strip()
    llibre = cercar_llibre(cataleg, terme)
    return render_template("cercar.html", terme=terme, llibre=llibre)
  • request.args és un diccionari (M4) amb els paràmetres de la query string; .get() amb valor per defecte evita el KeyError si algú visita /cercar sense paràmetre — reflex defensiu de M7.
  • De nou, la lògica és la de sempre: cercar_llibre() retorna Llibre | None (M8 hi va posar aquest type hint), i la plantilla decideix què pintar:
{% extends "base.html" %}
{% block contingut %}
{% if llibre %}
    <h1>{{ llibre.titol }}</h1>
    <p>{{ "%.2f"|format(llibre.preu) }} € — en queden {{ llibre.estoc }}</p>
{% else %}
    <p>Cap resultat per a «{{ terme }}». Prova amb un altre títol.</p>
{% endif %}
{% endblock %}

Amb això, la web de Papyrus versió 1 està completa: portada amb el catàleg i cercador, i fitxa per llibre.

Estructura de projecte raonable

Per a una aplicació d'aquesta mida, aquesta disposició és idiomàtica i t'acompanyarà fins a 10-03:

papyrus_web_flask/
├── .venv/                  # entorn virtual (M1) — fora del control de versions
├── app.py                  # l'aplicacio Flask: rutes i vistes
├── papyrus/                # EL PAQUET DE M3-M9, sense canvis
│   ├── models.py           #   Llibre, preu_final, hi_ha_estoc
│   ├── magatzem.py         #   cercar_llibre, vendre, carregar_cataleg...
│   ├── errors.py           #   ErrorPapyrus i familia
│   └── cupons.py
├── dades/
│   ├── cataleg.json
│   └── papyrus.log
├── templates/
│   ├── base.html
│   ├── inici.html
│   └── cercar.html
├── static/
│   └── estils.css
└── tests/                  # la suite de M9 continua vigilant

La regla d'or de la separació: app.py no calcula preus ni toca estoc; només tradueix HTTP ↔ paquet papyrus. Si demà canvies l'IVA, toques models.py, la suite de M9 ho verifica, i la web ni se n'assabenta. Quan app.py creixi, Flask ofereix blueprints per trossejar-lo; en tindràs prou de saber que existeixen.

Errors Comuns i Consells

  • TemplateNotFound: inici.html: la carpeta s'ha de dir exactament templates/ i ser al costat d'app.py (per això Flask(__name__)). Mateix remei per als estàtics amb static/.
  • Edites i el navegador no canvia: o no vas arrencar amb --debug (sense recàrrega automàtica), o és la memòria cau del navegador — recarrega amb Ctrl+F5.
  • Address already in use: has deixat un altre servidor corrent al 5000. Tanca'l, o arrenca en un altre port: flask --app app run --debug --port 5001.
  • Retornar objectes directament: return llibre falla — una vista retorna text/HTML (o JSON, a 10-03), no un Llibre. La plantilla és qui presenta l'objecte.
  • Lògica de negoci a les vistes: si et descobreixes calculant descomptes a app.py, atura't. Això pertany a papyrus/, on està testejat. La vista demana, el paquet resol, la plantilla pinta.
  • Oblidar el 404: si cercar_llibre retorna None i no ho comproves, la plantilla petarà amb un error críptic. Tracta sempre el cas buit — M7 et va entrenar exactament per a això.

Exercicis

Exercici 1: pàgina "Qui som"

Afegeix la ruta /socis amb una plantilla socis.html que hereti de base.html i mostri una llista <ul> amb els socis de Papyrus generada amb {% for %} a partir d'una llista de tuples passada des de la vista: [("Lluis", "LLUIS-001"), ("Marta", "MARTA-002"), ("Pau", "PAU-003")].

Exercici 2: preu de soci sota demanda

Modifica la vista cercar perquè accepti un segon paràmetre opcional de query string, soci (/cercar?titol=faust&soci=si). Si val "si", la plantilla ha de mostrar el preu de soci (preu_final(soci=True)) en lloc del normal. Comprova que Faust mostra 21.00 € sense el paràmetre i 20.75 € amb ell.

Exercici 3: fitxa amb plantilla

La vista fitxa_llibre d'aquesta lliçó encara retorna f-strings. Refactoritza-la perquè faci servir una plantilla llibre.html que hereti de base.html, mostri títol, preu, preu de soci i estoc (amb «Esgotat» si hi_ha_estoc() és fals), i retorni la tupla (render_template("no_trobat.html", titol=titol), 404) quan el llibre no existeixi.

Solucions

Exercici 1:

@app.route("/socis")
def socis():
    llista = [("Lluis", "LLUIS-001"), ("Marta", "MARTA-002"), ("Pau", "PAU-003")]
    return render_template("socis.html", socis=llista)
{% extends "base.html" %}
{% block contingut %}
<h1>Els socis de Papyrus</h1>
<ul>
{% for nom, codi in socis %}
    <li>{{ nom }} — carnet {{ codi }}</li>
{% endfor %}
</ul>
{% endblock %}

El desempaquetatge nom, codi al for de Jinja2 funciona com el de Python (M4).

Exercici 2:

@app.route("/cercar")
def cercar():
    terme = request.args.get("titol", "").strip()
    es_soci = request.args.get("soci") == "si"
    llibre = cercar_llibre(cataleg, terme)
    return render_template("cercar.html", terme=terme,
                           llibre=llibre, es_soci=es_soci)

A la plantilla: {{ "%.2f"|format(llibre.preu_final(soci=es_soci)) }} €. Nota: preu_final(soci=False) retorna el preu amb IVA de M5, així que "sense paràmetre" mostra el preu de venda habitual; amb soci=si, Faust baixa a 20.75 — un dels quatre números canònics de la suite de M9.

Exercici 3:

@app.route("/llibre/<titol>")
def fitxa_llibre(titol):
    llibre = cercar_llibre(cataleg, titol)
    if llibre is None:
        return render_template("no_trobat.html", titol=titol), 404
    return render_template("llibre.html", llibre=llibre)

llibre.html reutilitza els mateixos forats que ja vas escriure a cercar.html; no_trobat.html poden ser tres línies amb «No tenim «{{ titol }}»». L'essencial: el codi 404 viatja al costat de la plantilla dins de la tupla, perquè els clients (i els cercadors) sàpiguen que la pàgina no existeix.

Conclusió

Papyrus ja és una aplicació web: flask run, i el catàleg sencer — amb preus de soci calculats pel preu_final() de M5 i vigilats pels tests de M9 — se serveix a http://127.0.0.1:5000 amb portada, cercador i fitxa per llibre. Has après que @app.route és un decorador de registre dels de 08-02, que el mode debug és or en desenvolupament i verí en producció, que les rutes amb <variables> converteixen URLs en arguments de funció, i que Jinja2 (forats {{ }}, lògica {% %}, herència amb base.html) genera HTML segur a partir dels teus propis objectes. Sobretot, has comprovat el principi rector del mòdul: la capa web només tradueix; la lògica continua al paquet papyrus, intacta i testejada. Però aquesta web parla HTML, l'idioma dels navegadors. L'app mòbil que la Júlia somia per reservar llibres no vol taules HTML: vol dades — JSON net, codis d'estat precisos, rutes predictibles. Això és una API REST, i construir-la amb Flask, connectant LlibreNoTrobatError amb el 404 i asdict() amb jsonify, és exactament la propera lliçó.

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