Created on 10 Aug 2020 ;    Modified on 10 Aug 2020 ;    Translationenglish

La gestione dinamica della validazione di un campo in WTForms

Premessa

Nella sezione Covid di questo blog [1] abbiamo aggiunto la possibilità di selezionare l'intervallo temporale dei dati che l'utente vuole osservare.

Per fare ciò abbiamo ampliato la form SelectForm utilizzata per recepire nel browser WEB le scelte dell'utente. Queste verranno trasmesse all'applicazione nel WEB server. Il quale, a sua volta, le elaborerà eseguendo gli opportuni algoritmi che costruiranno le risposte attese. Risposte che il server trasmetterà infine al browser WEB dell'utente.

Parlando di date inserite dall'utente, dobbiamo controllare che i valori ricevuti dal server siano ragionevoli [2].

Quindi, tra le altre cose, vogliamo controllare che le date inserite come inizio e fine dell'intervallo temporale in osservazione rientrino tra quelle per le quali abbiamo i dati [3].

Come riportato in un altro articolo, questa sezione del blog è sviluppata utilizzando il framework Flask.

I documentatori di Flask suggeriscono di utilizzare WTForms per la gestione delle form verso l'utente, cosa che noi facciamo scrupolosamente.

WTForms è una libreria Python che semplifica molto lo sviluppo e la manutenzione di form per le applicazioni WEB. In sintesi: ci permette di progettare una form utilizzando una classe i cui attributi rappresentano i campi che l'utente ha a disposizione. Sono disponibili campi di diverse tipologie, che coprono la maggior parte delle esigenze di progettazione di form.

Ad esempio prendiamo la seguente frazione (due campi) di SelectForm dal modulo forms.py:

class SelectForm(FlaskForm):
    ... <cut> ...

    first   = DateField('from', validators=[InputRequired()], format='%Y-%m-%d')
    last    = DateField('to',   validators=[InputRequired()], format='%Y-%m-%d')

    ... <cut> ...

I campi first e last conterranno le date (notiamo l'uso della classe DateField) di inizio e fine intervallo che l'utente vuole osservare. Nella form le relative label saranno from e to. Con il parametro validators abbiamo agganciato il controllo InputRequired; questo impone che il campo sia compilato, non può essere vuoto. Infine, con il parametro format, abbiamo indicato il formato atteso per la data: anno-mese-giorno.

Tra le altre cose, uno dei principali motivi per utilizzare una libreria come questa, consiste proprio nel fatto che usualmente troviamo già implementato quanto necessario per gestire i controlli sui contenuti dei campi inviati dall'utente all'applicazione WEB. Aspetto particolarmente critico, in quanto investe:

  • considerazioni di sicurezza [4];
  • e aspetti di usabilità dell'applicazione, ovvero la famigerata User Interface.

Il problema

In effetti, se consultiamo la documentazione di WTForms riguardo i validators, ovvero i controllori del contenuto dei campi, troviamo una pagina che ci illustra quali controlli sono già disponibili, e come scrivere controlli customizzati per i nostri scopi.

Sin qui tutto bene. Solo che negli esempi della documentazione i validators sono agganciati al campo nella definizione della form: ricordiamo InputRequired dell'esempio precedente?

Nel nostro caso l'uso di questo metodo non è efficace perché il controllo (customizzato) deve essere in grado di utilizzare valori di riscontro che cambiano giorno per giorno. E purtroppo non siamo in grado di individuare questi valori prima della istanziazione della form [5].

Quindi dobbiamo agganciare il nostro controllo ai campi data dopo che la form è stata istanziata. Ipotizzando di avere a disposizione la classe TimeRange che implementa il validatore che ci serve, nel modulo views.py possiamo fare come segue:

def select():
    ... <cut> ...

    form = forms.SelectForm()
    ... <cut> ...

    if request.method=='POST':
        time_range1 = forms.TimeRange(FIRST, LAST)  #+- initializing TimeRange validator for POST
        form.first.validators.append(time_range1)   #+- binding dynamically timerange validator to field first
        form.last.validators.append(time_range1)    #+- as above to field last

    if form.validate_on_submit():
        ... <cut> ...
        return redirect(... <cut> ...)

    ... <cut> ...
    return render_template(... <cut> ...)

Ovvero: istanziamo la form, vi carichiamo eventuali dati dinamici, e, se ci serve [6], istanziamo il controllo TimeRange e lo appendiamo alla lista dei validatori nei campi first e last.

Se la form supera i controlli della validate_on_submit facciamo le nostre elaborazioni e con la redirect andiamo alla logica applicativa che implementa i comandi richiesti.

Se la form non supera i controlli la inviamo nuovamente al WEB browser con la render_template.

Con questo potremmo pensare di aver fatto. Ma non è così. Purtroppo, con questa versione di WTForms se agganciamo dinamicamente dei validatori, questi rimangono disponibili anche dopo la distruzione della form; e riutilizzati in chiamate successive, nonostante ne siano state create nuove istanze. Questo comportamento può esporre a malfunzionamenti intermittenti, di cui è difficile individuare la causa [7].

Quindi dobbiamo occuparci di un opportuno clean-up. Possiamo ampliare il codice precedente come segue:

def select():
    ... <cut> ...

    form = forms.SelectForm()
    ... <cut> ...

    if request.method=='POST':
        time_range1 = forms.TimeRange(FIRST, LAST)  #+- initializing TimeRange validator for POST
        form.first.validators.append(time_range1)   #+- binding dynamically timerange validator to field first
        form.last.validators.append(time_range1)    #+- as above to field last

    if form.validate_on_submit():
        ... <cut> ...

        form.first.validators.remove(time_range1)  # we need to remove timerange validators to destroy them
        form.last.validators.remove(time_range1)   #    otherwise they will be reused in succedings calls

        return redirect(... <cut> ...)

    try:                                           # again: removing timerange validators. see previous comment
        if time_range1 in form.first.validators:
            form.first.validators.remove(time_range1)
        if time_range1 in form.last.validators:
            form.last.validators.remove(time_range1)
    except:
        pass

    ... <cut> ...
    return render_template(... <cut> ...)

Il validatore TimeRange

Giusto per completare il discorso, qui di seguito riportiamo l'essenziale per implementare il validatore del TimeRange:

class TimeRange(object):
    ... <cut> ...
    def __init__(self, first=None, last=None, message=None):
        if not first:
            first = date.today()
        if not last:
            last = date.today()
        if not message:
            message = 'Field must be between {} and {}'.format(first.strftime('%Y-%m-%d'), last.strftime('%Y-%m-%d'))
        self.first = first
        self.last  = last
        self.message = message

    ... <cut> ...

    def __call__(self, form, field):
        fname = 'TimeRange().__call__'
        f = field.data and field.data<self.first
        l = field.data and field.data>self.last
        if f or l:
            raise ValidationError(self.message)

Conclusioni

Implementare un validatore custom dinamico da utilizzare con WTForms è un compito abbordabile, ma è necessario fare attenzione alla gestione delle istanze. Attività che usualmente in Python viene trascurata, grazie all'ottimo funzionamento del garbage collector che di solito solleva il programmatore da questa incombenza.

Enjoy. ldfa


[1]Il progetto completo di questa sezione è consultabile a questo indirizzo GitHub.
[2]

Vi sono due (tre) diversi modi di fare questi controlli. Possiamo

  • controllare nel browser WEB, prima di inviare i dati al server; è il modo più reattivo verso l'utente, ma deve essere implementato con javascipt presenti nella form, che la appesantiscono un po' (mi riferisco alla dimensione della pagina HTML trasmessa);
  • controllare nel WEB server quando gli arrivano le scelte effettuate dall'utente nel WEB browser; metodo meno reattivo, ma le pagine HTML sono più piccole, e siamo in sicurezza anche nel caso in cui eventuali malintenzionati modifichino i dati trasmessi dal WEB browser al server.

Il terzo modo è un ibrido dei precedenti: AJAX; consiste in javascript che inviano al server una singola selezione per volta; e questo risponde al volo e selettivamente, ovvero non invia tutta la pagina, ma solo l'aggiornamento del campo in manipolazione.

Questo modo comunque non esime dall'effettuare i controlli sul WEB server quando viene inviata l'intera pagina a conclusione delle scelte effettuate dall'utente. Esiste sempre la possibilità che "qualcuno" modifichi i dati trasmessi dal WEB browser al server.

[3]Quale sia l'intervallo temporale dei dati è riportato nel quarto paragrafo della pagina di presentazione della sezione.
[4]Sicurezza perché questi campi possono essere malevolmente utilizzati per sferrare attacchi all'applicazione. Da cui l'importanza del controllo del loro contenuto.
[5]

Quanto meno: lo scrivente non è riuscito ad individuare questi valori nel modulo forms.py perché dal suo interno non riesce ad importare views.py. Si veda il progetto per i relativi dettagli.

Or mi sovviene che avrei potuto spostare la definizione della form dal modulo forms.py al modulo views.py, che ospita la logica applicativa. Vi sembra una soluzione elegante? A me non piace: ci porta verso un codice monolitico.

[6]Dovremo controllare le scelte dell'utente, quindi carichiamo il validatore dopo che le scelte sono state effettuate: ovvero quando le riceviamo con un POST.
[7]Parliamo per esperienza diretta. Il malfunzionamento #2 di questo progetto ci ha impegnato parecchio. Abbiamo pensato di averlo risolto almeno tre volte, per vederlo rispuntare fuori il giorno successivo all'ultima fix. Per capire cosa stava succedendo ci siamo dovuti loggare gli indirizzi di allocazione delle istanze per osservare il loro uso da parte dell'applicazione.