In questa lezione impari a
- gestire GET e POST nella stessa route
- leggere i dati di un form in modo sicuro con request.form.get
- validare i dati lato server, non solo nel browser
- dare un riscontro all'utente con i messaggi flash
- evitare il doppio invio con il pattern Post/Redirect/Get
Finora la bacheca mostra annunci scritti nel codice. Oggi gli utenti li pubblicano da soli, con un form. Un'applicazione vera deve aspettarsi dati mancanti, sbagliati o maliziosi: impariamo a difenderci.
Il kit della lezione
Provate subito: pubblicate un annuncio corretto, poi provate a inviare il form con un titolo di una lettera. E infine, dopo aver pubblicato, premete F5: l'annuncio non viene pubblicato due volte. Oggi capiamo perché.
Il giro completo
1. GET /nuovo → il server manda il form vuoto
2. l'utente compila e preme "Pubblica"
3. POST /nuovo (titolo, categoria, testo nel corpo della richiesta)
│
├── dati sbagliati → il server rimanda il form, con gli errori e i dati già scritti
│
└── dati corretti → salva l'annuncio
mette un messaggio flash "Annuncio pubblicato!"
risponde 302: "vai a /annuncio/4"
4. GET /annuncio/4 → la pagina dell'annuncio, con il messaggio verde
Una route, due metodi
@app.route("/nuovo", methods=["GET", "POST"])
def nuovo():
errori = []
if request.method == "POST":
titolo = request.form.get("titolo", "").strip()
testo = request.form.get("testo", "").strip()
categoria = request.form.get("categoria", "")
# ... controlli e salvataggio ...
return render_template("nuovo.html", errori=errori)
methods=["GET", "POST"]: senza, la route accetta solo GET e il form riceverebbe 405 Method Not Allowed.request.methoddice con quale metodo è arrivata la richiesta.request.formcontiene i campi del form. La chiave ("titolo") è l'attributonamedel campo HTML: devono coincidere esattamente.
Usate sempre
request.form.get("campo", "")e nonrequest.form["campo"]. Se il campo manca, il secondo fa rispondere al server 400 Bad Request; il primo restituisce una stringa vuota che potete controllare voi. Il.strip()toglie gli spazi all'inizio e alla fine: così un titolo fatto di soli spazi diventa vuoto.
Validare: mai fidarsi del browser
Nel form ci sono required, minlength, maxlength e un menu a tendina: il browser impedisce già di inviare dati sbagliati… all'utente onesto. Chiunque può mandare una richiesta POST senza passare dal form (con Thunder Client, con Python, modificando la pagina con F12). I controlli veri si fanno nel server:
if len(titolo) < 3:
errori.append("Il titolo deve avere almeno 3 caratteri")
if len(titolo) > 80:
errori.append("Il titolo può avere al massimo 80 caratteri")
if not testo:
errori.append("Il testo non può essere vuoto")
if categoria not in CATEGORIE:
errori.append("Categoria non valida")
Il controllo sulla categoria sembra inutile, visto che c'è un menu a tendina. Ma il menu si aggira in due secondi. Ogni valore che arriva dall'esterno si controlla, sempre.
| Controllo nel browser (HTML) | Controllo nel server (Python) |
|---|---|
| comodo per l'utente: errore immediato | obbligatorio per la sicurezza |
| si aggira facilmente | non si può aggirare |
| facoltativo | sempre |
Il form che ricorda
Se ci sono errori, il form viene rimostrato con i dati già scritti, così l'utente non deve ricominciare da capo:
{% for e in errori %}
<p class="flash flash-errore">{{ e }}</p>
{% endfor %}
<form class="modulo" method="post">
<label>Titolo
<input name="titolo" value="{{ request.form.titolo }}" required minlength="3" maxlength="80">
</label>
<label>Categoria
<select name="categoria">
{% for nome in categorie %}
<option value="{{ nome }}" {% if request.form.categoria == nome %}selected{% endif %}>{{ nome | capitalize }}</option>
{% endfor %}
</select>
</label>
<label>Testo
<textarea name="testo" required>{{ request.form.testo }}</textarea>
</label>
<button>Pubblica</button>
</form>
request è disponibile in tutti i template: alla prima apertura (GET) request.form.titolo è vuoto, dopo un invio con errori contiene quello che l'utente aveva scritto. Il form non ha action: senza, viene inviato allo stesso indirizzo della pagina, cioè /nuovo.
I messaggi flash
Dopo un'azione, l'utente vuole sapere se è andata bene. I messaggi flash sopravvivono a un solo redirect: si creano in una route e si mostrano nella pagina successiva, poi spariscono.
flash("Annuncio pubblicato!", "successo") # il secondo argomento è la categoria
return redirect(url_for("annuncio", id=nuovo_id))
In base.html, una volta per tutte, così funzionano in ogni pagina:
{% for categoria_msg, messaggio in get_flashed_messages(with_categories=true) %}
<p class="flash flash-{{ categoria_msg }}">{{ messaggio }}</p>
{% endfor %}
La categoria diventa una classe CSS: flash-successo è verde, flash-errore è rosso.
La chiave segreta
I flash vengono conservati nella sessione, che Flask salva in un cookie firmato: per firmarlo serve una chiave segreta.
app.config["SECRET_KEY"] = "solo-per-sviluppo-cambiami"
Senza questa riga, il primo flash() fa crollare tutto con un errore. Una chiave scritta nel codice va bene solo in laboratorio: nella lezione 7 la nasconderemo.
Il pattern Post/Redirect/Get
Perché dopo il salvataggio facciamo redirect invece di mostrare direttamente una pagina?
SENZA redirect CON redirect (PRG)
POST /nuovo → 200 pagina "fatto" POST /nuovo → 302 vai a /annuncio/4
F5 = il browser RIMANDA il POST GET /annuncio/4 → 200
→ annuncio pubblicato due volte! F5 = ripete solo il GET: innocuo
Post/Redirect/Get: dopo un POST riuscito si risponde sempre con un redirect. È una regola d'oro di ogni applicazione web.
Esiste una libreria, Flask-WTF, che automatizza validazione e protezione CSRF dei form. Qui scriviamo la validazione a mano per capire cosa succede davvero; del CSRF parliamo nella lezione 6.
Missioni
⭐ Missione 1 – Chi lo scrive?
Aggiungete al form il campo "Firma" (il nome di chi pubblica, obbligatorio, almeno 2 caratteri), salvatelo nell'annuncio e mostratelo nella sua pagina.
⭐ Missione 2 – Provate ad aggirarlo
Aprite il form, premete F12, e nell'HTML cambiate il valore di un'option della categoria in pippo. Inviate: cosa risponde il server? Poi togliete il controllo sulla categoria da app.py e riprovate. Rimettete il controllo e spiegate in due righe perché serve.
⭐⭐ Missione 3 – La data dell'evento
Aggiungete un campo facoltativo <input type="date" name="scadenza">. Se è compilato, deve essere oggi o nel futuro (altrimenti errore). Se manca, l'annuncio usa la data di oggi.
Aiutino
Il campo arriva come stringa "AAAA-MM-GG". date.fromisoformat(scadenza) la converte; se la stringa non è valida solleva ValueError, quindi mettetela in un try / except ValueError.
⭐⭐⭐ Missione 4 – Eliminare
Aggiungete nella pagina dell'annuncio un pulsante "Elimina": un form POST verso /annuncio/<id>/elimina, che toglie l'annuncio dalla lista, mette un flash "Annuncio eliminato" e torna alla home.
Aiutino
Mai un link <a> per eliminare: i link fanno GET, e GET non deve modificare i dati (un motore di ricerca che segue i link cancellerebbe tutto!). Per una conferma: onsubmit="return confirm('Sicuro?')" sul form.
⭐⭐⭐ Missione 5 – Modificare
Aggiungete /annuncio/<id>/modifica: mostra lo stesso form già compilato con i dati dell'annuncio e, al salvataggio, aggiorna l'annuncio invece di crearne uno nuovo.
Aiutino
Passate al template l'annuncio da modificare e nei campi scrivete value="{{ request.form.titolo or annuncio.titolo }}". Il form deve avere action="{{ url_for('modifica', id=annuncio.id) }}" perché l'indirizzo è diverso da /nuovo. Si può anche usare un template nuovo, modifica.html, copiando nuovo.html.