Vai al contenuto

Form, validazione e messaggi

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.method dice con quale metodo è arrivata la richiesta.
  • request.form contiene i campi del form. La chiave ("titolo") è l'attributo name del campo HTML: devono coincidere esattamente.

Usate sempre request.form.get("campo", "") e non request.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.