14 KiB
Validation des données en entrée
Nous pouvons en fait voir les forms
comme le point d’entrée pour chaque donnée arrivant dans notre application: il s’agit en quelque sorte d’un ensemble de règles complémentaires à celles déjà présentes au niveau du modèle.
Ils agissent comme une glue entre l’utilisateur et la structure de données, en :
-
Validant des données, en plus de celles déjà définies au niveau du modèle
-
Contrôlant le rendu à appliquer aux champs.
Important
|
L’intérêt du form est de créer une isolation par rapport à toute donnée provenant de l’extérieur. |
Le principe de forms
n’est cependant pas exclusivement limité à des formulaires Web : nous pourrions considérer qu’il s’agit de leur objectif principal, mais il est possible (et conseillé) de les utiliser dès que l’on touche à des données en entrée.
Nous traiterons ci-dessous de deux cas :
-
Récupérer des informations à partir d’un fichier CSV,
-
Récupérer des données unitaires encodées par un utilisateur sur une page Web.
Bien que technologiquement différents, ces deux méthodes fonctionnent de la même manière, que nous demandions à un utilisateur d’encoder des informations dans un formulaire HTML ou lorsque nous recevons un fichier contenant des informations : un acteur dispose de données en entrée, et nous souhaitons les faire transiter vers un espace de stockage dont nous maîtrisons la structure.
Lecture d’un fichier CSV
L’exemple le plus simple est un fichier .csv
: la lecture de ce
fichier pourrait se faire de manière très simple, en récupérant les valeurs de chaque colonne et en l’introduisant dans une instance de notre modèle.
Dans sa version simple, nous allons proposer deux versions d’un même code :
-
La version la plus simple, avec la lecture du fichier csv et jongler avec les indices de colonnes,
-
Une version plus sophistiquée et plus lisible, à base de DictReader.
Le fichier que nous utiliserons pour cette version est structuré de la manière suivante et est nommé library.csv
:
book,author
L'assassin Royal,Robin Hobb
Harry Potter & the Philosopher Stone,J.K. Rowling
The Great Gatsby,F. Scott Fitzgerald
H2G2,Douglas Adams
Version simple
from library.models import Author, Book
with open("library.csv", "r") as file:
while(line := file.readline()):
author_name, book_title = line.split(",")
author_instance, _ = Author.objects.get_or_create(
name=author_name
)
book_instance, _ = Book.objects.get_or_create(
title=book_title,
author=author_instance
)
Le code ci-dessus fonctionne, mais présente quelques problèmes :
-
Il nécessite de filtrer la ligne contenant les en-têtes,
-
La modification du fichier source pour l’ajout, la modification ou la suppression d’une colonne entraînera une erreur au niveau du code - soit parce qu’il y aura une colonne en plus (
ValueError: too many values to unpack (expected 2)
), soit parce que l’ordre des colonnes aura été modifié (et nous aurons alors des livres intitulés selon leurs auteurs),-
Si l’un des champs contient notre délimiteur (ici,
,
) et même si les valeurs sont correctement encapsulées, la séparation sur notre délimiteur ne fonctionnera pas correctement et fera planter notre application.
-
Version avec DictReader
La classe DictReader
permet de gérer correctement les trois remarques ci-dessus :
import csv
with open("library.csv", "r") as file:
reader = csv.DictReader(file)
for row in reader:
...
Ceci nous donnera une liste de dictionnaires, dont chaque clé équivaudra à une association d’en-tête et de valeur :
{'book': "L'assassin Royal", 'author': 'Robin Hobb'}
{
'book': 'Harry Potter & the Philosopher Stone',
'author': 'J.K. Rowling',
None: ['1997']
}
{
'book': 'The Great Gatsby',
'author': 'F. Scott Fitzgerald',
None: ['1925']
}
{'book': 'H2G2', 'author': 'Douglas Adams'}
Nous pouvons remarquer que les valeurs inconnues (dont l’en-tête n’existerait pas, parce que mal formaté) sont placées sous une clé None
, tandis qu’en cas de déplacement d’une colonne, le code continuera tranquillement à fonctionner.
La classe DictReader
s’occupe également d’échapper correctement les différentes valeurs à la lecture - sous réserve que le format soit évidemment respecté.
Page Web
Comme expliqué ci-dessus, une page Web n’est jamais qu’un formulaire dans lequel un acteur (humain, ici, mais nous y reviendrons) a la possibilité d’encoder des données et informations pour qu’un traitement informatique y soit appliqué.
En HTML, chaque champ est identifié par une propriété name
, et nous en récupérons la valeur lors du POST, après qu’il ait soumis ses données en (généralement) appuyant sur le bouton "ENVOYER" :
<form method="POST">
<div>
<label for="author">Auteur :</label>
<input type="text" name="author" />
</div>
<div>
<label for="book">Titre du livre:</label>
<input type="text" name="book" />
</div>
<button type="submit">ENVOYER</button>
</form>
Ceci nous donnera la représentation (extrêmement simpliste) ci-dessous :
Note
|
Pour que notre page Web réponde à une méthode POST, il est nécessaire de disposer d’un serveur Web pouvant interprêter la requête. Pour ceci, nous allons intégrer notre formulaire ci-dessus dans un flux Django, c’est-à-dire avec sa propre vue, un URL et un template. Pour ceci, reprenez soit le projet que nous avions créé lors de la partie 2 [Déploiement], soit partez d’un nouveau projet. Que vous preniez l’une ou l’autre de ces options n’aura pas d’incidence pour la suite, puisque nous pourrons jeter ce code rapidement. |
Version avec form
On l’a vu ci-dessus, il est possible d’injecter des informations dans une base de données "simplement". Il s’agit cependant d’une mauvaise idée : les données fournies par un utilisateur doivent *toujours être validées avant d’être introduites dans la base de données, et les règles de validation définies au niveau du modèle pourraient ne pas être suffisantes que pour s’assurer de la bienveillance des informations reçues. La solution consiste à introduire une couche supplémentaire de validation, entre les données et la base de données.
Le flux à suivre est le suivant:
-
Création d’une instance grâce à un dictionnaire
-
Validation des données et des informations reçues
-
Traitement, si la validation a réussi.
Fichier CSV
Page Web
Principes de validation
La validation des champs intervient sur toute donnée entrée via le modèle. Cette validation dispose de trois niveaux principaux :
-
Directement au niveau du modèle, au travers de validateurs sur les champs
-
Via une méthode de nettoyage sur un champ, qui permet de prendre d’autres informations contextuelles en considération
-
Via une méthode de nettoyage globale, associée à l’instance.
Validation d’un champ
La première manière de valider le contenu d’un champ est aussi la plus simple. En prenant un modèle type:
# library/models.py
from django.db import models
def validate_title(value):
if 't' not in value:
raise ValidationError('Title does not start with T')
class Book(models.Model):
title = models.CharField(max_length=255, validators=[validate_title])
Clean_<field_name>
Ca, c’est le deuxième niveau. Le contexte donne accès à déjà plus d’informations et permet de valider les informations en interogeant (par exemple) la base de données.
class Bidule(models.Model):
def clean_title(self):
raise ValidationError('Title does not start with T')
Il n’est plus nécessaire de définir d’attribut validators=[…]
, puisque Django va appliquer un peu d’introspection pour récupérer toutes les méthodes qui commencent par clean_
et pour les faire correspondre au nom du champ à valider (ici, title
).
Clean
Ici, c’est global: nous pouvons valider des données ou des champs globalement vis-à-vis d’autres champs déjà remplis.
class Bidule(models.Model):
def clean(self):
raise ValidationError('Title does not start with T')
Gestion du changement
Dans le package django.contrib.admin.utils
, on trouve une petite pépite du nom de construct_change_message
.
Cette fonction permet de construire un message de changement à partir de n’importe que form
ou formset
.
Elle prend en paramètre une valeur booléenne supplémentaire qui indique s’il s’agit d’un ajout ou pas.
Le résultat retourne une structure qui indique les champs qui ont été modifiés, les champs qui ont été ajoutés ou supprimés:
def construct_change_message(form, formsets, add):
[snip]
change_message = []
if add:
change_message.append({"added": {}})
elif form.changed_data:
change_message.append({"changed": {"fields": changed_field_labels}})
if formsets:
with translation_override(None):
for formset in formsets:
for added_object in formset.new_objects:
change_message.append(
{
"added": {
"name": str(added_object._meta.verbose_name),
"object": str(added_object),
}
}
)
for changed_object, changed_fields in formset.changed_objects:
change_message.append(
{
"changed": {
"name": str(changed_object._meta.verbose_name),
"object": str(changed_object),
"fields": _get_changed_field_labels_from_form(
formset.forms[0], changed_fields
),
}
}
)
for deleted_object in formset.deleted_objects:
change_message.append(
{
"deleted": {
"name": str(deleted_object._meta.verbose_name),
"object": str(deleted_object),
}
}
)
return change_message
Nous obtenons ainsi une structure représentée par un dictionnaire, qui décrit les informations ajoutées, modifiées ou supprimées.
Dépendance avec le modèle
Un form peut hériter d’une autre classe Django.
Pour cela, il suffit de fixer l’attribut model
au niveau de la class Meta
dans la définition.
from django import forms
from wish.models import Wishlist
class WishlistCreateForm(forms.ModelForm):
class Meta:
model = Wishlist
fields = ('name', 'description')
Notre form dépendra automatiquement des champs déjà déclarés dans la classe Wishlist
.
Cela suit le principe de DRY <don’t repeat yourself et évite qu’une modification ne pourrisse le code: en testant les deux champs présent dans l’attribut fields, nous pourrons nous assurer de faire évoluer le formulaire en fonction du modèle sur lequel il se base.
Rendu et affichage
Le formulaire permet également de contrôler le rendu qui sera appliqué
lors de la génération de la page. Si les champs dépendent du modèle sur
lequel se base le formulaire, ces widgets doivent être initialisés dans
l’attribut Meta
. Sinon, ils peuvent l’être directement au niveau du
champ.
Squelette par défaut
as_p
as_table
Vitor
Crispy-forms
Comme on l’a vu à l’instant, les forms, en Django, c’est le bien. Cela permet de valider des données reçues en entrée et d’afficher (très) facilement des formulaires à compléter par l’utilisateur.
Par contre, c’est lourd. Dès qu’on souhaite peaufiner un peu l’affichage, contrôler parfaitement ce que l’utilisateur doit remplir, modifier les types de contrôleurs, les placer au pixel près, … Tout ça demande énormément de temps. Et c’est là qu’intervient Django-Crispy-Forms. Cette librairie intègre plusieurs frameworks CSS (Bootstrap, Foundation et uni-form) et permet de contrôler entièrement le layout et la présentation.
(c/c depuis le lien ci-dessous)
Pour chaque champ, crispy-forms va :
-
Utiliser le
verbose_name
comme label. -
Vérifier les paramètres
blank
etnull
pour savoir si le champ est obligatoire. -
Utiliser le type de champ pour définir le type de la balise
<input>
. -
Récupérer les valeurs du paramètre
choices
(si présent) pour la balise<select>
.
Conclusions
Toute donnée entrée par l’utilisateur, quelle qu’elle soit, doit passer par une instance de form
: qu’il s’agisse d’un formulaire HTML,
d’un fichier CSV, d’un parser XML, …
Dès que des informations "de l’extérieur" font mine d’arriver dans le périmètre de votre application, il convient d’appliquer immédiatement des principes de sécurité reconnus.