gwift-reborn/book/django-principles/forms.adoc

14 KiB
Raw Blame History

Validation des données en entrée

327
Figure 1. xkcd.com/327/ - Exploits of a Mom -

Nous pouvons en fait voir les forms comme le point dentrée pour chaque donnée arrivant dans notre application: il sagit en quelque sorte dun ensemble de règles complémentaires à celles déjà présentes au niveau du modèle. Ils agissent comme une glue entre lutilisateur et la structure de données, en :

  1. Validant des données, en plus de celles déjà définies au niveau du modèle

  2. Contrôlant le rendu à appliquer aux champs.

Important

Lintérêt du form est de créer une isolation par rapport à toute donnée provenant de lextérieur.

Le principe de forms nest cependant pas exclusivement limité à des formulaires Web : nous pourrions considérer quil sagit de leur objectif principal, mais il est possible (et conseillé) de les utiliser dès que lon touche à des données en entrée. Nous traiterons ci-dessous de deux cas :

  1. Récupérer des informations à partir dun fichier CSV,

  2. 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 dencoder 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 dun fichier CSV

Lexemple 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 lintroduisant dans une instance de notre modèle.

without form base.drawio

Dans sa version simple, nous allons proposer deux versions dun même code :

  1. La version la plus simple, avec la lecture du fichier csv et jongler avec les indices de colonnes,

  2. 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 :

  1. Il nécessite de filtrer la ligne contenant les en-têtes,

  2. La modification du fichier source pour lajout, la modification ou la suppression dune colonne entraînera une erreur au niveau du code - soit parce quil y aura une colonne en plus (ValueError: too many values to unpack (expected 2)), soit parce que lordre des colonnes aura été modifié (et nous aurons alors des livres intitulés selon leurs auteurs),

    • Si lun 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 den-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 len-tête nexisterait pas, parce que mal formaté) sont placées sous une clé None, tandis quen cas de déplacement dune colonne, le code continuera tranquillement à fonctionner. La classe DictReader soccupe é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 nest jamais quun formulaire dans lequel un acteur (humain, ici, mais nous y reviendrons) a la possibilité dencoder des données et informations pour quun 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 quil 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 :

simple html form
Note

Pour que notre page Web réponde à une méthode POST, il est nécessaire de disposer dun serveur Web pouvant interprêter la requête. Pour ceci, nous allons intégrer notre formulaire ci-dessus dans un flux Django, cest-à-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 dun nouveau projet. Que vous preniez lune ou lautre de ces options naura pas dincidence pour la suite, puisque nous pourrons jeter ce code rapidement.

Version avec form

On la vu ci-dessus, il est possible dinjecter des informations dans une base de données "simplement". Il sagit cependant dune 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 sassurer 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.

form base.drawio

Le flux à suivre est le suivant:

  1. Création dune instance grâce à un dictionnaire

  2. Validation des données et des informations reçues

  3. 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 :

  1. Directement au niveau du modèle, au travers de validateurs sur les champs

  2. Via une méthode de nettoyage sur un champ, qui permet de prendre dautres informations contextuelles en considération

  3. Via une méthode de nettoyage globale, associée à linstance.

Validation dun champ

La première manière de valider le contenu dun 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, cest le deuxième niveau. Le contexte donne accès à déjà plus dinformations 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 nest plus nécessaire de définir dattribut validators=[…​], puisque Django va appliquer un peu dintrospection 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, cest global: nous pouvons valider des données ou des champs globalement vis-à-vis dautres 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 nimporte que form ou formset. Elle prend en paramètre une valeur booléenne supplémentaire qui indique sil sagit dun 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 dune autre classe Django. Pour cela, il suffit de fixer lattribut 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 <dont repeat yourself et évite quune modification ne pourrisse le code: en testant les deux champs présent dans lattribut 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 lattribut Meta. Sinon, ils peuvent lêtre directement au niveau du champ.

Squelette par défaut

as_p
as_table
Vitor

Crispy-forms

Comme on la vu à linstant, les forms, en Django, cest le bien. Cela permet de valider des données reçues en entrée et dafficher (très) facilement des formulaires à compléter par lutilisateur.

Par contre, cest lourd. Dès quon souhaite peaufiner un peu laffichage, contrôler parfaitement ce que lutilisateur doit remplir, modifier les types de contrôleurs, les placer au pixel près, …​ Tout ça demande énormément de temps. Et cest là quintervient 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 et null 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 lutilisateur, quelle quelle soit, doit passer par une instance de form: quil sagisse dun formulaire HTML, dun fichier CSV, dun parser XML, …​ Dès que des informations "de lextérieur" font mine darriver dans le périmètre de votre application, il convient dappliquer immédiatement des principes de sécurité reconnus.