Cours Python · Module 5: Flask et applications web

Flask-WTF - formulaires et validation

11 min de lecture
Dans cette leçon13

Bon retour ! Darwin est là, prêt à te montrer des formulaires professionnels avec Flask-WTF !

Jusqu'ici tu gérais les formulaires manuellement via request.form. Ça marche, mais il n'y a aucune validation, aucune protection CSRF, et c'est source d'erreurs !

Analogie Safari : La gestion manuelle des formulaires, c'est comme vérifier chaque touriste à la main sans checklist - facile d'oublier quelque chose ! Flask-WTF est comme un formulaire d'inscription professionnel avec validation automatique - tout est vérifié !

Le problème des formulaires manuels

1@app.route('/add-species', methods=['POST'])
2def add_species():
3    name = request.form.get('name')
4    population = request.form.get('population')
5
6    # Aucune validation - que se passe-t-il si le nom est vide ?
7    # Aucune validation de type - que faire si population est du texte ?
8    # Aucune protection CSRF
9    # Il faut tout vérifier à la main
10
11    if not name:
12        return "Erreur : le nom est obligatoire", 400
13
14    try:
15        population = int(population)
16    except ValueError:
17        return "Erreur : la population doit être un nombre", 400
18
19    # Ajouter à la base de données...

Problèmes : Code de validation long Aucune protection CSRF (Cross-Site Request Forgery) Il faut répéter la validation pour chaque formulaire Aucun affichage automatique des erreurs Difficile à maintenir

Solution - Flask-WTF

Flask-WTF est une extension qui intègre WTForms (bibliothèque de formulaires) à Flask.

Que fournit Flask-WTF ? Validation automatique - plus besoin de vérifier à la main Protection CSRF - sécurité clé en main Classes de formulaire - code lisible et DRY Rendu dans les templates - affichage facile Messages d'erreur - affichage automatique

Installer Flask-WTF

1pip install Flask-WTF

Flask-WTF installe automatiquement WTForms (la bibliothèque de base).

Ajouter à requirements.txt

1pip freeze > requirements.txt

Configurer Flask-WTF

Flask-WTF nécessite une clé secrète pour la protection CSRF :

1# app.py
2from flask import Flask
3from flask_wtf import FlaskForm
4
5app = Flask(__name__)
6
7# SECRET_KEY est obligatoire pour la protection CSRF
8app.config['SECRET_KEY'] = 'your-super-secret-key-12345'  # À changer en production !
9
10# Alternativement - utilise os.urandom()
11import os
12app.config['SECRET_KEY'] = os.urandom(24)

IMPORTANT : En production, utilise des variables d'environnement plutôt que des clés codées en dur !

1import os
2app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY') or 'dev-key-123'

Créer une classe de formulaire

Un formulaire est une classe qui hérite de FlaskForm :

1# forms.py
2from flask_wtf import FlaskForm
3from wtforms import StringField, IntegerField, TextAreaField, SelectField, SubmitField
4from wtforms.validators import DataRequired, Length, NumberRange, Optional
5
6class AddSpeciesForm(FlaskForm):
7    name = StringField('Nom de l\'espèce',
8                      validators=[DataRequired(message='Le nom est obligatoire'),
9                                 Length(min=2, max=100, message='Le nom doit faire 2 à 100 caractères')])
10
11    scientific_name = StringField('Nom scientifique',
12                                 validators=[DataRequired(),
13                                            Length(min=2, max=150)])
14
15    population = IntegerField('Population',
16                             validators=[DataRequired(),
17                                        NumberRange(min=0, message='La population ne peut pas être négative')])
18
19    habitat = SelectField('Habitat',
20                         choices=[('savanna', 'Savane'),
21                                 ('forest', 'Forêt'),
22                                 ('desert', 'Désert'),
23                                 ('wetland', 'Zone humide')],
24                         validators=[DataRequired()])
25
26    notes = TextAreaField('Notes',
27                         validators=[Optional(), Length(max=500)])
28
29    submit = SubmitField('Ajouter l\'espèce')

Champs de formulaire :

  • StringField - champ texte (input type="text")
  • IntegerField - entier (input type="number")
  • TextAreaField - texte multiligne (textarea)
  • SelectField - liste déroulante (select)
  • SubmitField - bouton d'envoi (button type="submit")

Autres types de champs :

  • EmailField - email avec validation
  • PasswordField - mot de passe (input type="password")
  • BooleanField - case à cocher
  • DateField - date
  • FileField - téléversement de fichier
  • RadioField - boutons radio
  • DecimalField - nombre à virgule flottante

Validateurs - validation des données

WTForms possède des validateurs intégrés :

1from wtforms.validators import (
2    DataRequired,    # Le champ est obligatoire
3    Optional,        # Le champ est facultatif
4    Length,          # Longueur du texte
5    Email,           # Validation d'email
6    EqualTo,         # Égal à un autre champ (mots de passe)
7    NumberRange,     # Plage de nombres
8    Regexp,          # Regex
9    URL,             # Validation d'URL
10    AnyOf,           # Une des valeurs
11    NoneOf           # Aucune des valeurs
12)

Exemples :

1from wtforms import StringField, IntegerField, PasswordField
2from wtforms.validators import DataRequired, Length, Email, EqualTo, NumberRange
3
4class RegistrationForm(FlaskForm):
5    username = StringField('Nom d\'utilisateur',
6                          validators=[DataRequired(),
7                                     Length(min=4, max=20)])
8
9    email = EmailField('Email',
10                      validators=[DataRequired(), Email()])
11
12    password = PasswordField('Mot de passe',
13                            validators=[DataRequired(),
14                                       Length(min=8)])
15
16    confirm_password = PasswordField('Confirmer le mot de passe',
17                                    validators=[DataRequired(),
18                                               EqualTo('password', message='Les mots de passe doivent correspondre')])
19
20    age = IntegerField('Âge',
21                      validators=[NumberRange(min=13, max=120,
22                                            message='L\'âge doit être entre 13 et 120')])

Utiliser un formulaire dans une route

1# app.py
2from flask import Flask, render_template, redirect, url_for, flash
3from forms import AddSpeciesForm
4
5app = Flask(__name__)
6app.config['SECRET_KEY'] = 'your-secret-key-123'
7
8species_db = {}
9next_id = 1
10
11@app.route('/species/add', methods=['GET', 'POST'])
12def add_species():
13    form = AddSpeciesForm()
14
15    # Requête POST + validation réussie
16    if form.validate_on_submit():
17        global next_id
18
19        # Récupérer les données du formulaire
20        new_species = {
21            'id': next_id,
22            'name': form.name.data,
23            'scientific_name': form.scientific_name.data,
24            'population': form.population.data,
25            'habitat': form.habitat.data,
26            'notes': form.notes.data
27        }
28
29        species_db[next_id] = new_species
30        next_id += 1
31
32        # Message flash - notification de succès
33        flash(f'L\'espèce {new_species["name"]} a été ajoutée !', 'success')
34
35        return redirect(url_for('list_species'))
36
37    # Requête GET ou validation échouée
38    return render_template('add_species.html', form=form)

Comment ça marche ?

  1. Requête GET → rend le formulaire
  2. Requête POST → form.validate_on_submit() vérifie :
    • La méthode est-elle POST ?
    • Le jeton CSRF est-il valide ?
    • Tous les validateurs ont-ils réussi ?
  3. Si validation OK → traiter les données (form.field_name.data)
  4. Si validation ÉCHOUÉE → réafficher le formulaire avec les erreurs

Rendre un formulaire dans un template

1{% extends 'base.html' %}
2
3{% block title %}Ajouter une espèce - Safari Database{% endblock %}
4
5{% block content %}
6    <h2>Ajouter une nouvelle espèce</h2>
7
8    <form method="POST" novalidate>
9        {{ form.hidden_tag() }}  <!-- jeton CSRF -->
10
11        <div style="margin: 15px 0;">
12            {{ form.name.label }}
13            {{ form.name(size=40) }}
14
15            {% if form.name.errors %}
16                <ul style="color: red; margin: 5px 0;">
17                    {% for error in form.name.errors %}
18                        <li>{{ error }}</li>
19                    {% endfor %}
20                </ul>
21            {% endif %}
22        </div>
23
24        <div style="margin: 15px 0;">
25            {{ form.scientific_name.label }}
26            {{ form.scientific_name(size=40) }}
27
28            {% if form.scientific_name.errors %}
29                <ul style="color: red; margin: 5px 0;">
30                    {% for error in form.scientific_name.errors %}
31                        <li>{{ error }}</li>
32                    {% endfor %}
33                </ul>
34            {% endif %}
35        </div>
36
37        <div style="margin: 15px 0;">
38            {{ form.population.label }}
39            {{ form.population() }}
40
41            {% if form.population.errors %}
42                <ul style="color: red; margin: 5px 0;">
43                    {% for error in form.population.errors %}
44                        <li>{{ error }}</li>
45                    {% endfor %}
46                </ul>
47            {% endif %}
48        </div>
49
50        <div style="margin: 15px 0;">
51            {{ form.habitat.label }}
52            {{ form.habitat() }}
53
54            {% if form.habitat.errors %}
55                <ul style="color: red; margin: 5px 0;">
56                    {% for error in form.habitat.errors %}
57                        <li>{{ error }}</li>
58                    {% endfor %}
59                </ul>
60            {% endif %}
61        </div>
62
63        <div style="margin: 15px 0;">
64            {{ form.notes.label }}
65            {{ form.notes(rows=5, cols=40) }}
66
67            {% if form.notes.errors %}
68                <ul style="color: red; margin: 5px 0;">
69                    {% for error in form.notes.errors %}
70                        <li>{{ error }}</li>
71                    {% endfor %}
72                </ul>
73            {% endif %}
74        </div>
75
76        <div style="margin-top: 20px;">
77            {{ form.submit(style='padding: 10px 20px; background: #2c5f2d; color: white; border: none; border-radius: 4px; cursor: pointer;') }}
78        </div>
79    </form>
80
81    <p style="margin-top: 20px;">
82        <a href="{{ url_for('list_species') }}">← Retour à la liste</a>
83    </p>
84{% endblock %}

Éléments clés :

  1. {{ form.hidden_tag() }} - jeton CSRF (obligatoire !)
  2. {{ form.field_name.label }} - libellé du champ
  3. {{ form.field_name() }} - champ du formulaire
  4. {% if form.field_name.errors %} - afficher les erreurs

Messages flash - notifications

Flask dispose d'un système intégré de messages flash pour afficher des notifications :

1from flask import flash
2
3@app.route('/species/add', methods=['POST'])
4def add_species():
5    form = AddSpeciesForm()
6
7    if form.validate_on_submit():
8        # ... ajouter l'espèce ...
9
10        flash('L\'espèce a été ajoutée !', 'success')
11        return redirect(url_for('list_species'))
12
13    # Erreur de validation
14    flash('Merci de corriger les erreurs du formulaire.', 'error')
15    return render_template('add_species.html', form=form)

Catégories de flash :

  • 'success' - succès (vert)
  • 'error' - erreur (rouge)
  • 'warning' - avertissement (orange)
  • 'info' - information (bleu)

Afficher les messages flash dans les templates

Dans base.html ajoute :

1<!DOCTYPE html>
2<html>
3<head>...</head>
4<body>
5    <header>...</header>
6
7    <!-- Messages flash -->
8    {% with messages = get_flashed_messages(with_categories=true) %}
9        {% if messages %}
10            <div style="max-width: 900px; margin: 20px auto;">
11                {% for category, message in messages %}
12                    <div style="padding: 15px; margin: 10px 0; border-radius: 4px;
13                               {% if category == 'success' %}background: #d4edda; color: #155724; border: 1px solid #c3e6cb;
14                               {% elif category == 'error' %}background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;
15                               {% elif category == 'warning' %}background: #fff3cd; color: #856404; border: 1px solid #ffeaa7;
16                               {% else %}background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb;{% endif %}">
17                        {{ message }}
18                    </div>
19                {% endfor %}
20            </div>
21        {% endif %}
22    {% endwith %}
23
24    <main>{% block content %}{% endblock %}</main>
25</body>
26</html>

Macro pour rendre les champs - DRY

Au lieu de répéter le code pour chaque champ, crée une macro :

1{# templates/macros.html #}
2{% macro render_field(field) %}
3    <div style="margin: 15px 0;">
4        {{ field.label }}
5        {{ field(size=40, **kwargs) }}
6
7        {% if field.errors %}
8            <ul style="color: #d62828; margin: 5px 0; padding-left: 20px;">
9                {% for error in field.errors %}
10                    <li>{{ error }}</li>
11                {% endfor %}
12            </ul>
13        {% endif %}
14    </div>
15{% endmacro %}
16
17{# Utilisation dans le formulaire #}
18{% from 'macros.html' import render_field %}
19
20<form method="POST" novalidate>
21    {{ form.hidden_tag() }}
22
23    {{ render_field(form.name) }}
24    {{ render_field(form.scientific_name) }}
25    {{ render_field(form.population) }}
26    {{ render_field(form.habitat) }}
27    {{ render_field(form.notes) }}
28
29    <div style="margin-top: 20px;">
30        {{ form.submit(style='...') }}
31    </div>
32</form>

Formulaire d'édition - pré-remplir avec des données

1# forms.py
2class EditSpeciesForm(FlaskForm):
3    name = StringField('Nom', validators=[DataRequired(), Length(min=2, max=100)])
4    scientific_name = StringField('Nom scientifique', validators=[DataRequired()])
5    population = IntegerField('Population', validators=[DataRequired(), NumberRange(min=0)])
6    habitat = SelectField('Habitat',
7                         choices=[('savanna', 'Savane'), ('forest', 'Forêt'),
8                                 ('desert', 'Désert'), ('wetland', 'Zone humide')])
9    notes = TextAreaField('Notes', validators=[Optional(), Length(max=500)])
10    submit = SubmitField('Enregistrer les modifications')
11
12# app.py
13@app.route('/species/<int:species_id>/edit', methods=['GET', 'POST'])
14def edit_species(species_id):
15    species = species_db.get(species_id)
16
17    if not species:
18        flash('Espèce introuvable.', 'error')
19        return redirect(url_for('list_species'))
20
21    form = EditSpeciesForm()
22
23    if form.validate_on_submit():
24        # Mettre à jour les données
25        species['name'] = form.name.data
26        species['scientific_name'] = form.scientific_name.data
27        species['population'] = form.population.data
28        species['habitat'] = form.habitat.data
29        species['notes'] = form.notes.data
30
31        flash(f'L\'espèce {species["name"]} a été mise à jour !', 'success')
32        return redirect(url_for('show_species', species_id=species_id))
33
34    elif request.method == 'GET':
35        # Pré-remplir le formulaire avec les données actuelles
36        form.name.data = species['name']
37        form.scientific_name.data = species['scientific_name']
38        form.population.data = species['population']
39        form.habitat.data = species['habitat']
40        form.notes.data = species.get('notes', '')
41
42    return render_template('edit_species.html', form=form, species=species)

Logique :

  • GET - pré-remplir le formulaire avec les données de la base
  • POST + valide - enregistrer les modifications
  • POST + invalide - afficher les erreurs

Validateurs personnalisés

Tu peux créer tes propres validateurs :

1from wtforms.validators import ValidationError
2
3class AddSpeciesForm(FlaskForm):
4    name = StringField('Nom', validators=[DataRequired()])
5    scientific_name = StringField('Nom scientifique', validators=[DataRequired()])
6    population = IntegerField('Population', validators=[DataRequired()])
7
8    # Validateur personnalisé - méthode validate_<nom_du_champ>
9    def validate_name(self, field):
10        # Vérifier si l'espèce existe déjà
11        for species in species_db.values():
12            if species['name'].lower() == field.data.lower():
13                raise ValidationError('Une espèce portant ce nom existe déjà !')
14
15    def validate_scientific_name(self, field):
16        # Vérifier le format du nom scientifique (Genre espèce)
17        parts = field.data.split()
18        if len(parts) != 2:
19            raise ValidationError('Le nom scientifique doit suivre le format : Genre espèce')
20
21        # Vérifier que le premier mot commence par une majuscule
22        if not parts[0][0].isupper() or not parts[1][0].islower():
23            raise ValidationError('Format : premier mot en majuscule, second en minuscule')

Fonction de validateur personnalisé :

1def validate_population_range(form, field):
2    if field.data < 10:
3        raise ValidationError('La population doit être d\'au moins 10 individus')
4    if field.data > 1000000:
5        raise ValidationError('La population dépasse une plage réaliste')
6
7class AddSpeciesForm(FlaskForm):
8    population = IntegerField('Population',
9                             validators=[DataRequired(), validate_population_range])

Exemple complet - Safari Database avec formulaires

forms.py

1from flask_wtf import FlaskForm
2from wtforms import StringField, IntegerField, TextAreaField, SelectField, SubmitField
3from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError
4
5class AddSpeciesForm(FlaskForm):
6    name = StringField('Nom de l\'espèce',
7                      validators=[DataRequired(message='Le nom est obligatoire'),
8                                 Length(min=2, max=100)])
9
10    scientific_name = StringField('Nom scientifique',
11                                 validators=[DataRequired(), Length(min=5, max=150)])
12
13    population = IntegerField('Population',
14                             validators=[DataRequired(), NumberRange(min=0)])
15
16    habitat = SelectField('Habitat',
17                         choices=[('savanna', 'Savane'), ('forest', 'Forêt'),
18                                 ('desert', 'Désert'), ('wetland', 'Zone humide')],
19                         validators=[DataRequired()])
20
21    notes = TextAreaField('Notes', validators=[Optional(), Length(max=500)])
22
23    submit = SubmitField('Ajouter l\'espèce')
24
25    def validate_scientific_name(self, field):
26        parts = field.data.split()
27        if len(parts) < 2:
28            raise ValidationError('Le nom scientifique doit suivre le format : Genre espèce')

app.py

1from flask import Flask, render_template, redirect, url_for, flash
2from forms import AddSpeciesForm
3
4app = Flask(__name__)
5app.config['SECRET_KEY'] = 'your-secret-key-here'
6
7species_db = {
8    1: {'id': 1, 'name': 'Lion', 'scientific_name': 'Panthera leo',
9        'population': 120, 'habitat': 'savanna', 'notes': ''},
10}
11next_id = 2
12
13@app.route('/')
14def home():
15    return render_template('home.html', species_count=len(species_db))
16
17@app.route('/species')
18def list_species():
19    return render_template('species_list.html', species=list(species_db.values()))
20
21@app.route('/species/add', methods=['GET', 'POST'])
22def add_species():
23    form = AddSpeciesForm()
24
25    if form.validate_on_submit():
26        global next_id
27
28        new_species = {
29            'id': next_id,
30            'name': form.name.data,
31            'scientific_name': form.scientific_name.data,
32            'population': form.population.data,
33            'habitat': form.habitat.data,
34            'notes': form.notes.data
35        }
36
37        species_db[next_id] = new_species
38        next_id += 1
39
40        flash(f'L\'espèce {new_species["name"]} a été ajoutée !', 'success')
41        return redirect(url_for('show_species', species_id=new_species['id']))
42
43    return render_template('add_species.html', form=form)
44
45@app.route('/species/<int:species_id>')
46def show_species(species_id):
47    species = species_db.get(species_id)
48    return render_template('species_detail.html', species=species)
49
50if __name__ == '__main__':
51    app.run(debug=True)

Analogie Safari finale : Flask-WTF est comme un formulaire d'inscription professionnel de safari avec validation automatique - il vérifie l'âge du touriste (NumberRange), exige une signature (DataRequired), vérifie l'email (Email) et protège contre la falsification de documents (CSRF) ! Tu n'as pas à vérifier chaque champ à la main !

Prochaine leçon : Darwin te montrera Flask-Login - l'authentification des utilisateurs (connexion, inscription, protection des routes) !

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Comment installe-t-on Flask-WTF ?

  2. 2. De quelle classe hérite un formulaire Flask-WTF ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Range la définition d'une classe de formulaire en Flask-WTF :

  • Clique dans l'ordre

    Range la définition d'un StringField avec un validateur DataRequired :

  • Éditeur de code

    Écris une classe SpeciesForm avec les champs name (chaîne, requis), population (int) et submit.

  • Tri horizontal

    Comment vérifie-t-on qu'un formulaire a passé la validation ?

Articles utiles