4. Les gabarits et les formulaires
Jusqu'ici, les vues fabriquaient elles-mêmes leur réponse : du texte, du JSON, quelques balises HTML écrites dans une chaîne de caractères. Dans une application MVC, fabriquer la page est le rôle de la partie V, que [Django] appelle un gabarit (template) : un fichier qui décrit la page HTML, avec des emplacements pour les données que lui transmet la vue, le contexte du gabarit (le modèle M de MVC, cf. introduction).
[Django] a son propre moteur de gabarits, et son propre langage de gabarits, le DTL (Django Template Language). Il sait aussi utiliser d'autres moteurs, comme [Jinja2], celui de [Flask] (réglage [TEMPLATES]) ; ce document s'en tient au DTL, que toutes les applications de [Django] utilisent. Un gabarit est un fichier texte, ici du HTML (on lui donne l'extension [.html]), rangé dans un dossier [templates], dans lequel trois sortes de balises sont interprétées sur le serveur :
- [{{ variable }}] : la valeur d'une variable du contexte, insérée dans la page ;
- [{% balise %}] : une balise (tag), qui commande le moteur : condition ([{% if %}]), boucle ([{% for %}]), héritage de gabarit ([{% extends %}], [{% block %}]), inclusion ([{% include %}]), fabrication d'une URL ([{% url %}])... ;
- [{# commentaire #}] : un commentaire, qui n'apparaît pas dans la page produite. Il tient sur une seule ligne ; un commentaire sur plusieurs lignes s'écrit entre les balises [{% comment %}] et [{% endcomment %}].
Tout le reste est recopié tel quel. Les expressions ne sont pas écrites en [Python], mais dans le langage du DTL, volontairement limité : [modele.titre] désigne la clé [titre] d'un dictionnaire, ou l'attribut [titre] d'un objet, ou l'élément [titre] d'une liste ([Django] essaie dans cet ordre), et, si c'est une méthode, [Django] l'appelle, sans paramètre ; les filtres transforment une valeur ([nom|upper] met un nom en majuscules) ; les balises agissent. C'est tout : le DTL n'a ni opérateurs arithmétiques, ni appels de fonction avec paramètres, ni affectation. C'est voulu : les créateurs de [Django] voulaient que les gabarits puissent être écrits par des graphistes, sans risque de voir la logique de l'application se glisser dans les pages. Un calcul se fait donc en [Python], dans la vue ou dans une méthode d'un objet du contexte, et le gabarit ne fait qu'afficher. Les langages de [Twig] ([Symfony]) et de [Jinja2] ([Flask]), qui s'en sont inspirés, sont plus permissifs : ils calculent ([total / nombre]) et appellent des fonctions avec des paramètres. Enfin, un gabarit n'est pas relu à chaque requête : à sa première utilisation, [Django] l'analyse et le transforme en une liste de nœuds, un objet [Python] qu'il garde en mémoire et réutilise ensuite (en mode debug, il le relit s'il a été modifié). Nous verrons à quoi ressemble cette liste.
4.1. Exemple 13 : un premier gabarit
4.1.1. La configuration : config/settings.py
Le moteur de gabarits se configure par le réglage [TEMPLATES] :
- lignes 19-24 : [TEMPLATES] est une liste de moteurs de gabarits, chacun décrit par un dictionnaire. Ici, un seul : [BACKEND] désigne le moteur de [Django] (le DTL) ; [DIRS] est la liste des dossiers où il cherche les gabarits : le dossier [templates] du projet. Le réglage peut aussi dire au moteur de chercher les gabarits dans le dossier [templates] de chaque application du réglage [INSTALLED_APPS] ([APP_DIRS: True], exemple 18) : c'est ainsi qu'une application réutilisable apporte ses propres gabarits.
Les gabarits sont rangés dans un sous-dossier [pages], du nom de l'application : c'est l'usage, qui évite que deux applications aient chacune un gabarit [index.html] et que l'un masque l'autre.
4.1.2. Le contexte du gabarit : pages/modeles.py
Le contexte d'un gabarit est un dictionnaire [Python]. Ici, il n'aura qu'une entrée, [modele], un objet immuable de la classe [PageModele] (cf. exemple 03), qui réunit les trois informations que le gabarit affichera. Rien n'oblige à décrire le contexte d'un gabarit par une classe : on pourrait passer trois variables séparées au gabarit. Mais une classe documente ce que le gabarit attend, et permet à l'éditeur de vérifier le code de la vue. C'est la démarche que suivra l'étude de cas, où chaque page importante a sa classe de modèle.
4.1.3. Les vues : pages/views.py
- lignes 16-20 : la vue construit le modèle. [timezone.localtime()], du module [django.utils.timezone], rend l'heure courante dans le fuseau horaire de l'application (le réglage [TIME_ZONE], [Europe/Paris]) ; [strftime] la met en forme : [%H] les heures, [%M] les minutes, [%S] les secondes ;
- ligne 21 : le raccourci [render] (du module [django.shortcuts], exemple 04) fait fabriquer la page par le moteur de gabarits. Il reçoit la requête, le nom du gabarit (le chemin du fichier, relatif à l'un des dossiers de gabarits) et le contexte : un dictionnaire, dont chaque clé devient une variable du gabarit, ici une seule, [modele]. [render] rend un objet [HttpResponse], dont le corps est la page fabriquée (de type [text/html]) ;
- lignes 27-29 : un autre gabarit, avec une autre variable ([nom]). Le paramètre [status] de [render] donne le code de la réponse : ici 404, pour une page d'erreur « maison » ;
- lignes 30-34 : le même gabarit que la vue [index], avec un autre modèle. C'est l'intérêt de la séparation entre vue et gabarit : un gabarit peut servir à plusieurs vues.
Les routes ([pages/urls.py]) associent [/] à la vue [index], et [/bonjour/<str:nom>] à la vue [bonjour].
4.1.4. Le gabarit : templates/pages/index.html
- ligne 5 : un commentaire du DTL ;
- ligne 6 : [{{ modele.titre }}] insère la valeur de l'attribut [titre] de la variable [modele]. Le point est l'opérateur d'accès du DTL, là où [Python] écrirait [modele.titre] ou [modele["titre"]] selon le cas.
Tout ce qui est inséré par [{{ }}] est échappé : les caractères [<], [>], [&], ["] et ['] sont remplacés par leurs entités HTML. C'est le rôle que jouait la fonction [format_html] dans l'exemple 09, mais cette fois il est automatique : un gabarit ne peut donc pas, par inadvertance, insérer dans la page du HTML fourni par un utilisateur.
4.1.5. Le gabarit : templates/pages/inconnu.html
Un gabarit n'est pas obligé de recevoir un objet : la variable [nom] est une chaîne.
4.1.6. Exécution


L'heure affichée est celle du fuseau horaire de l'application. La réponse brute montre le travail du moteur : les balises [{{ }}] ont été remplacées par leurs valeurs, le commentaire a disparu (en laissant derrière lui une ligne vide, avec son indentation).
La page d'erreur « maison », puis un nom contenant du HTML et une apostrophe ([<b>martin'], encodé dans l'URL) :
La balise [<b>] a été échappée, sans que la vue ni le gabarit aient rien eu à faire (l'apostrophe aussi : ['] ; celle du message l'est tout autant, ce qui ne change rien à son affichage). Voici enfin le gabarit tel que [Django] le garde en mémoire. La commande [python manage.py shell] ouvre une console [Python] où [Django] est prêt ; l'option [-c] lui fait exécuter quelques instructions :
Le gabarit est devenu une liste de nœuds : des morceaux de texte ([TextNode], dont l'affichage est tronqué), recopiés tels quels, et des variables ([VariableNode]), dont la valeur est cherchée dans le contexte, puis échappée. Le commentaire n'a laissé aucun nœud. Fabriquer la page, c'est parcourir cette liste et recoller les morceaux. Un gabarit n'est donc qu'une façon plus sûre et plus lisible d'écrire une fonction [Python] qui fabrique du HTML.
4.2. Exemple 14 : le langage des gabarits
Cet exemple réunit dans un seul gabarit les principales constructions du DTL.
4.2.1. Le modèle et la vue
Le modèle du gabarit est décrit par trois classes de données immuables :
- ligne 24 : [str | None] : une chaîne, ou [None] (exemple 06) ;
- lignes 28-29 : des listes d'objets [Produit] ;
- lignes 34-40 : puisque le DTL ne sait pas calculer, les calculs dont le gabarit a besoin (le total des prix, le prix moyen) sont des méthodes du modèle. Le décorateur [@property] fait d'une méthode une propriété : on la lit comme un attribut, sans parenthèses ([modele.total]), et c'est ainsi que le DTL la verra. Les prix sont ici de type [float], pour simplifier (l'exemple 06 a montré pourquoi on leur préfère des centimes entiers).
Le modèle contient des valeurs de types variés : chaînes, [None], booléen, liste d'objets, liste vide, objet contenant un autre objet.
La vue construit le modèle avec des paramètres nommés (cf. exemple 02) : chaque valeur est précédée du nom de l'attribut auquel elle est destinée, ce qui rend l'appel lisible malgré le nombre de paramètres.
4.2.2. Le gabarit : templates/bases/index.html
- lignes 9-13 : un commentaire sur plusieurs lignes ;
- ligne 14 : une valeur [None] s'affiche... [None] : c'est la représentation de [None] en texte. Le filtre [default] remplace une valeur « vide » ([None], chaîne vide, 0, liste vide...) par une valeur par défaut. Un filtre s'applique par une barre verticale, et reçoit un paramètre éventuel après un deux-points : [valeur|filtre:"paramètre"]. Une variable qui n'existe pas du tout dans le contexte ([modele.inconnu]) s'afficherait, elle, comme une chaîne vide, sans erreur ;
- lignes 16-17 : [{{ }}] échappe le HTML (2a) ; le filtre [safe] insère au contraire la valeur telle quelle, sans échappement (2b). [safe] est à réserver à du HTML produit par l'application elle-même, jamais à une saisie de l'utilisateur. La balise [{% autoescape off %}] désactive l'échappement pour tout un morceau de gabarit ;
- lignes 19-23 : une condition : [{% if %}], [{% elif %}], [{% else %}], [{% endif %}]. Chaque bloc est fermé par une balise [{% end... %}]. Une condition peut utiliser les opérateurs [==], [!=], [<], [>], [<=], [>=], [in], [not], [and], [or] ;
- lignes 27-33 : une boucle [{% for ... in ... %}] ... [{% endfor %}]. À l'intérieur, la variable spéciale [forloop] décrit l'état de la boucle : [forloop.counter0] (le numéro de l'itération, à partir de 0), [forloop.counter] (à partir de 1), [forloop.first], [forloop.last] (vrais à la première et à la dernière itération), [forloop.revcounter] (le nombre d'itérations restantes). Les variables extérieures à la boucle ([modele.nom], [modele.devise]) restent accessibles ;
- ligne 29 : le filtre [yesno] choisit un texte selon qu'une valeur est vraie ou fausse : ses deux textes (un troisième, facultatif, servirait pour [None]) sont séparés par une virgule. C'est l'équivalent, en DTL, d'une expression conditionnelle ;
- ligne 37 : une boucle [for] peut avoir un bloc [{% empty %}], exécuté si la liste est vide. C'est plus simple qu'un [{% if %}] qui testerait la longueur de la liste ;
- lignes 43-46 : la balise [{% with %}] crée une variable, valable jusqu'à [{% endwith %}] : ici le nombre de produits, calculé par le filtre [length] (le nombre d'éléments d'une liste, ou de caractères d'une chaîne) ;
- lignes 44-45 : les propriétés calculées du modèle. Le filtre [floatformat] arrondit un nombre : [floatformat:2], à deux décimales. Il écrit le nombre selon les conventions de la langue de l'application ([LANGUAGE_CODE]) : la virgule décimale en français. Tous les nombres insérés dans une page le sont d'ailleurs : [Django] met en forme les nombres et les dates selon la langue active ([12.5] devient [12,5]) ;
- ligne 48 : le point traverse les objets ([modele.adresse.ville]), et le filtre [upper] met une chaîne en majuscules (en traitant correctement les caractères accentués) ;
- ligne 50 : la balise [{% verbatim %}] recopie son contenu sans l'interpréter : c'est utile quand une page contient des accolades destinées à un framework [JavaScript].
Le DTL a une soixantaine de filtres ([lower], [capfirst], [title], [truncatechars], [join], [slice], [first], [last], [date], [time], [linebreaks], [urlize], [pluralize], [add], [divisibleby], [escape], [json_script]...) et une trentaine de balises ([if], [for], [with], [url], [now], [cycle], [firstof], [include], [extends], [block], [load], [csrf_token], [spaceless]...) ; les applications de [Django] en ajoutent ([static], [translate]...), et l'application peut écrire les siens (exemple 16).
4.2.3. Exécution

La page produite :
Les commentaires et les balises ont disparu, mais ils laissent derrière eux leurs espaces et leurs sauts de ligne : [Django] ne retire aucun blanc. C'est sans conséquence : le navigateur ignore ces blancs. (La balise [{% spaceless %}] retirerait les blancs entre les balises HTML d'un morceau de gabarit.) Les nombres ont été écrits à la française ([12,5], [186,5]), sans que le gabarit l'ait demandé.
4.3. Exemple 15 : gabarit parent, fragments, fichiers statiques
Les pages d'un site partagent le plus souvent une même structure : un entête, une barre de navigation, un pied de page, les mêmes feuilles de style. Plutôt que de répéter cette structure dans chaque gabarit, on l'écrit une fois, dans un gabarit parent (on dit aussi un layout), que les pages étendent. Cet exemple utilise aussi [Bootstrap], le framework CSS le plus répandu, pour la présentation.
4.3.1. Les fichiers statiques
Une page HTML fait référence à d'autres fichiers : feuilles de style, scripts [JavaScript], images. Ces fichiers, que le serveur envoie tels quels, sont dits statiques. Avec [Django], ils sont rangés dans des dossiers [static] (celui du projet, et ceux des applications), et c'est l'application [django.contrib.staticfiles] qui les gère. La configuration :
- lignes 14-19 : l'application [django.contrib.staticfiles], l'une des applications livrées avec [Django] ;
- lignes 21-34 : le moteur de gabarits, avec deux processeurs de contexte, présentés plus loin ;
- ligne 37 : [STATIC_URL], le préfixe des URL des fichiers statiques : le fichier [css/styles.css] sera disponible à l'URL [/static/css/styles.css] ;
- lignes 40-43 : [STATICFILES_DIRS], la liste des dossiers de fichiers statiques du projet (en plus des dossiers [static] des applications). Le premier est celui de l'exemple. Le second est le dossier [exemples/commun/static], qui contient [Bootstrap] : plutôt que d'en mettre une copie dans chaque exemple, les exemples 15 à 28 le partagent (cf. introduction). [Django] cherche un fichier dans tous ces dossiers, dans l'ordre, et rend le premier trouvé.
En mode debug, le serveur de développement sert lui-même les fichiers statiques, grâce à l'application [staticfiles] : une URL qui commence par [/static/] n'est pas confiée aux routes de l'application, mais au gestionnaire des fichiers statiques. En production ([DEBUG = False]), il ne le fait plus : ce n'est pas le rôle d'une application [Python]. La commande [python manage.py collectstatic] recopie alors tous les fichiers statiques du projet et des applications dans un seul dossier (le réglage [STATIC_ROOT]), que le serveur web ([nginx], [Apache]...) sert directement, ou que sert une bibliothèque comme [WhiteNoise] (cf. étude de cas).
Quelques règles CSS en plus de [Bootstrap] : un fond gris clair, le style du pied de page, et celui d'une zone d'erreurs qui servira aux exemples suivants.
4.3.2. Le gabarit parent : templates/layout.html
- ligne 3 : [{% load static %}] charge une bibliothèque de balises : celle de l'application [staticfiles], qui fournit la balise [{% static %}]. Les balises et les filtres qui ne font pas partie du cœur du DTL sont rangés dans des bibliothèques, qu'un gabarit doit charger avant de s'en servir ;
- ligne 7 : la balise [viewport] demande aux navigateurs des téléphones d'afficher la page à la largeur de l'écran, ce qu'attend [Bootstrap] ;
- ligne 9 : la balise [{% block %}] marque un emplacement nommé, que les pages qui étendent le gabarit peuvent remplir, en définissant un bloc de même nom. Le contenu écrit entre [{% block titre %}] et [{% endblock %}] est le contenu par défaut de l'emplacement, utilisé par une page qui ne définit pas ce bloc. [nom_site] est une variable globale, présente dans tous les gabarits (voir [config/contextes.py], plus loin) ;
- lignes 11-12 : [{% static 'chemin' %}] fabrique l'URL d'un fichier statique : [STATIC_URL] suivi du chemin. Écrire les URL à la main ([/static/css/styles.css]) fonctionnerait aussi, mais [{% static %}] s'adapte si l'on change [STATIC_URL] (pour servir les fichiers depuis un autre site, par exemple), ou si l'on ajoute au nom des fichiers une empreinte de leur contenu, pour les caches (étude de cas) ;
- ligne 17 : [{% include %}] insère le contenu d'un autre gabarit, ici la barre de navigation. Le gabarit inclus voit les variables du gabarit qui l'inclut ;
- ligne 20 : l'emplacement que chaque page remplit avec son propre contenu ;
- ligne 23 : la variable [request] est la requête en cours ([request.path] : le chemin de l'URL demandée). Elle est présente dans tous les gabarits grâce au processeur de contexte [request] (voir plus loin). La balise [{% now "Y" %}] écrit la date courante selon un format : [Y], l'année sur quatre chiffres.
La classe [container] de [Bootstrap] centre le contenu et limite sa largeur ; [py-3] ajoute une marge verticale intérieure (padding) : [Bootstrap] fournit des centaines de classes de ce genre, qu'on combine dans l'attribut [class].
4.3.3. Les variables globales : config/contextes.py
Un processeur de contexte est une fonction qui reçoit la requête, et rend un dictionnaire de variables à ajouter au contexte de tous les gabarits. [Django] appelle les processeurs du réglage [context_processors] (lignes 28-31 du fichier [config/settings.py]) chaque fois qu'un gabarit est fabriqué avec la requête (c'est le cas avec [render(request, ...)]). Le processeur [django.template.context_processors.request], livré avec [Django], ajoute la variable [request] ; le nôtre ajoute [nom_site]. D'autres applications de [Django] fournissent les leurs : les messages (exemple 20), la langue (exemple 22)... Un processeur est appelé pour chaque gabarit fabriqué : il doit être rapide.
4.3.4. La barre de navigation : templates/fragments/navbar.html
Un fragment : un morceau de page, qui n'a pas de sens seul. Les classes [navbar], [navbar-brand], [nav-link] sont celles de la barre de navigation de [Bootstrap]. Les liens sont fabriqués par la balise [{% url %}] (ligne 6), à partir des noms des routes (exemple 04) : [{% url 'equipe' %}] donne [/equipe]. Les paramètres de la route suivent le nom : [{% url 'contacts_fiche' contact.id %}] (exemple 21).
4.3.5. Des fragments nommés : templates/macros.html
- lignes 4-11 : la balise [{% partialdef %}], apparue avec [Django] 6.0, définit un fragment nommé (partial) : un morceau de gabarit, ici une carte ([card]) de [Bootstrap]. Un fichier peut en réunir plusieurs. Défini ainsi, le fragment n'est pas affiché là où il est écrit : on l'affiche ailleurs, par [{% include "macros.html#carte" %}] (le nom du fichier, un dièse, le nom du fragment), ou, dans le même fichier, par [{% partial carte %}]. Le fragment affiche deux variables, [nom] et [fonction], qui lui seront fournies à l'inclusion.
Le lecteur du cours [Symfony] reconnaîtra l'usage que ce cours faisait des macros de [Twig]. Il y a une différence : une macro de [Twig] ne voyait que ses paramètres ; un fragment inclus voit toutes les variables du gabarit qui l'inclut, en plus de celles qu'on lui donne. Le mot-clé [only] ([{% include "..." with nom="..." only %}]) le réduit à ces dernières. Pour un composant plus riche, qui aurait besoin de code [Python], on écrit une balise d'inclusion (exemple 16).
4.3.6. Les pages
- ligne 2 : la page étend le gabarit parent : [Django] fabrique la page du parent, en remplaçant ses blocs par ceux que la page définit. [{% extends %}] doit être la première balise du gabarit (seul un commentaire [{# #}] peut la précéder). Dans un gabarit qui en étend un autre, tout le contenu doit être dans des blocs : le texte écrit en dehors n'apparaît pas dans la page produite ;
- lignes 4-10 : le bloc [contenu], qui remplit l'emplacement de même nom. La page ne définit pas le bloc [titre] : l'emplacement garde son contenu par défaut, « Accueil » ;
- ligne 9 : l'inclusion du fragment [carte], avec deux variables, données après le mot-clé [with]. Le HTML du fragment n'est pas échappé une seconde fois : [Django] sait qu'il est déjà du HTML sûr (les variables, elles, ont été échappées dans le fragment).
- ligne 3 : le bloc [titre], sur une ligne ;
- lignes 7-14 : une carte par membre de l'équipe, dans une grille de [Bootstrap] ([row], [col-md-4] : trois colonnes sur un écran moyen ou large, une seule sur un écran étroit) ;
- ligne 11 : les variables données au fragment sont des expressions quelconques.
Un bloc du parent peut aussi être complété, plutôt que remplacé : [{{ block.super }}], écrit dans le bloc de la page, insère le contenu que le bloc avait dans le parent. Un gabarit peut étendre un gabarit qui en étend lui-même un autre : l'héritage a autant de niveaux qu'il en faut. La vue ne présente pas de nouveauté :
La troisième page, enfin, n'utilise pas le gabarit parent :
4.3.7. Exécution



L'écran de la copie d'écran étant étroit, les cartes de l'équipe sont les unes sous les autres. La page de l'équipe, telle que la reçoit le navigateur :
Le navigateur a reçu une page complète : l'assemblage du gabarit parent, du fragment de la barre de navigation, de la page et des cartes a été fait sur le serveur. Les URL des feuilles de style ont été calculées par [{% static %}] : ce sont des URL relatives au site. On peut vérifier que les feuilles de style sont bien servies (l'option [-I] de [curl] n'affiche que les entêtes de la réponse, en envoyant une requête HEAD) :
Le premier fichier vient du dossier [commun/static], le second du dossier [static] de l'exemple : pour le navigateur, rien ne les distingue. Le type du contenu a été déduit de l'extension du fichier ([text/css]) : un navigateur refuse une feuille de style annoncée comme autre chose. Une tentative de sortir des dossiers de fichiers statiques, enfin :
(les options [-s -o /dev/null -w "%{http_code}\n"] n'affichent que le code de la réponse ; sous [Windows], on écrit [-o NUL]). L'URL demande le fichier [config/settings.py] de l'exemple, en remontant de deux dossiers (les [/] sont codés [%2F], pour que ni [curl] ni le navigateur ne simplifient eux-mêmes le chemin). [Django] a refusé : la console affiche l'exception [SuspiciousFileOperation], avec le message « The joined path (...) is located outside of the base path component ». Le code 500 est celui d'une exception non traitée ; l'essentiel est que le fichier n'a pas été envoyé.
4.4. Exemple 16 : étendre le langage des gabarits
Un gabarit a souvent besoin de mettre en forme des valeurs : un montant avec sa devise, une date en toutes lettres, un texte en majuscules. Ce code n'a pas sa place dans la vue (qui ne s'occupe pas de présentation), et le DTL ne permet pas de l'écrire dans le gabarit (cf. introduction du chapitre). On l'écrit en [Python], dans une bibliothèque de balises et de filtres : un module qui ajoute au langage des gabarits ses propres filtres et balises. C'est aussi en [Python] que l'on écrit les composants qui fabriquent des morceaux de HTML à partir de données, les balises d'inclusion.
4.4.1. La bibliothèque : pages/templatetags/formats.py
Une bibliothèque de balises est un module rangé dans le paquet [templatetags] d'une application (qui doit être déclarée dans [INSTALLED_APPS]) ; le nom du module ([formats]) est celui qu'un gabarit donnera à [{% load formats %}] :
- lignes 4-5 : deux fonctions de la bibliothèque [Babel] (cf. introduction), qui connaît les conventions d'écriture des nombres, des montants et des dates de tous les pays (elle utilise les données du projet CLDR d'Unicode) ;
- ligne 7 : [get_language] rend la langue active de la requête en cours ([fr], [en]) ; nous allons voir qui la fixe ;
- ligne 10 : l'objet [register], une [template.Library], enregistre les filtres et les balises du module. Le nom de la variable est imposé : c'est là que [Django] les cherche ;
- lignes 17-19 : un filtre est une fonction, enregistrée par le décorateur [@register.filter] sous son propre nom, [monnaie]. Son premier paramètre est la valeur filtrée ; le second, facultatif, est le paramètre du filtre dans le gabarit ([prix|monnaie:"USD"]) : ici la devise, désignée par son code international ([EUR], [USD]). Le filtre met en forme un montant, reçu en centimes (cf. exemple 06). La mise en forme dépend de la langue : en français, [1 149,90 €] (espace pour séparer les milliers, virgule décimale, symbole après le montant) ; en anglais, [€1,149.90] ;
- lignes 23-25 : la mise en forme d'une date : le format [full] écrit la date en toutes lettres, avec le jour de la semaine ([dimanche 27 septembre 2026]) ;
- lignes 31-36 : une balise d'inclusion, enregistrée par [@register.inclusion_tag] : une fonction qui calcule un contexte, et un gabarit (ici [etiquettes/lien.html]) qui l'affiche. Dans un gabarit, [{% lien 'produits' '/produits' 'Produits' %}] appelle la fonction avec ces trois paramètres, puis insère le gabarit, fabriqué avec le dictionnaire qu'elle a rendu. Avec [takes_context=True], la fonction reçoit en premier paramètre le contexte du gabarit qui l'appelle : elle y trouve la requête, et en déduit si le lien désigne la page courante ([startswith] teste le début d'une chaîne) ;
- lignes 40-42 : une seconde balise d'inclusion, sans le contexte : un badge.
Une bibliothèque peut aussi fournir des balises « simples » ([@register.simple_tag]), qui rendent une valeur, et des balises complètes, qui analysent elles-mêmes leur contenu (comme [{% if %}]) : c'est plus rare. Les gabarits des deux balises d'inclusion :
Le lien de la page courante reçoit la classe [active] de [Bootstrap], qui le met en évidence. [{% url route %}] fabrique l'URL d'une route dont le nom est dans une variable. Le badge est vert ([text-bg-success]) ou rouge ([text-bg-danger]) selon le stock. Le paquet [templatetags] a aussi son fichier [__init__.py], sans lequel ce ne serait pas un paquet.
4.4.2. La langue de chaque requête : config/middleware.py
Chaque requête a une langue active, qui vaut, par défaut, celle du réglage [LANGUAGE_CODE]. Ici, le paramètre [?lang=en] de l'URL choisit l'anglais, le français est la langue par défaut. Un middleware (exemple 03) la fixe avant chaque vue, par la fonction [translation.activate] : c'est la langue que rendra [get_language], celle dans laquelle [Django] met en forme les nombres et les dates, et traduit ses propres messages (les messages d'erreur des formulaires, par exemple). La langue active est propre au fil d'exécution (thread) qui traite la requête : deux requêtes simultanées peuvent avoir des langues différentes. Elle n'est pas remise à zéro d'une requête à l'autre : c'est pourquoi le middleware la fixe à chaque requête, même pour le français. Le chapitre sur l'internationalisation remplacera ce middleware par celui de [Django], [LocaleMiddleware], et se servira de cette même langue pour traduire les textes.
4.4.3. La configuration : config/settings.py
- lignes 15-18 : les langues de l'application ;
- lignes 20-25 : l'application [pages] doit être déclarée : c'est dans les applications de [INSTALLED_APPS] que [Django] cherche les paquets [templatetags] ;
- lignes 27-29 : le middleware de la langue.
4.4.4. La vue : pages/views.py
Les vues transmettent aux gabarits des valeurs brutes : une date ([date.today()], la date du jour), des montants en centimes. La mise en forme est l'affaire des gabarits. Le total du stock, en revanche, est un calcul : il est fait en [Python] (ligne 24), puisque le DTL ne sait pas calculer. La classe [Produit] réunit un nom, un prix (en centimes, cf. exemple 06) et un stock.
4.4.5. Les gabarits
- ligne 1 : le fragment charge lui-même la bibliothèque dont il a besoin : les [{% load %}] ne se transmettent ni aux gabarits inclus, ni aux gabarits qui étendent un parent. Chaque gabarit charge ses bibliothèques ;
- ligne 6 : la même chose qu'avec la balise [lien], écrite directement dans le gabarit, avec une condition. [request.resolver_match] décrit le résultat du routage de la requête en cours : [url_name] est le nom de la route. La balise évite de répéter la condition pour chaque lien ;
- ligne 8 : l'appel de la balise, avec ses trois paramètres, séparés par des espaces ;
- ligne 10 : [{% url %}] ne fabrique que le chemin : les paramètres de l'URL s'ajoutent à la main ([?lang=en]).
- ligne 2 : la page charge deux bibliothèques : [i18n], l'internationalisation de [Django], et la nôtre ;
- ligne 8 : notre filtre [date_longue] ;
- lignes 10-11 : la balise [{% get_current_language as langue %}], de la bibliothèque [i18n], range la langue active dans une variable du gabarit.
- ligne 13 : un filtre de [Django] ;
- lignes 14-15 : notre filtre [monnaie], avec sa devise par défaut, puis avec un paramètre ;
- ligne 17 : l'appel de la balise [badge] ;
- ligne 22 : le total du stock, calculé par la vue.
Le gabarit parent [layout.html] est une version simplifiée de celui de l'exemple 15 ; son attribut [lang] reçoit la langue active ([<html lang="{{ langue }}">], après [{% get_current_language as langue %}]).
4.4.6. Exécution


La même page, avec le paramètre [?lang=en]. Seule la mise en forme des nombres change : les textes, eux, sont écrits en dur dans les gabarits. Le chapitre sur l'internationalisation traduira aussi les textes.

Un extrait du HTML produit :
4.5. Exemple 17 : un formulaire GET
Un formulaire HTML envoie ses champs au serveur, de l'une de deux façons, selon son attribut [method] :
- [method="get"] : les champs sont ajoutés à l'URL, comme paramètres ([/recherche?mot_cle=le&genre=roman]). C'est la méthode d'un formulaire qui consulte : une recherche, un filtre. L'URL des résultats peut être mise en favori, partagée, rechargée sans risque ;
- [method="post"] : les champs voyagent dans le corps de la requête (cf. exemple 11). C'est la méthode d'un formulaire qui modifie des données : une inscription, un ajout, une suppression.
Ce premier formulaire est une recherche de livres. Il est écrit à la main, en HTML, dans le gabarit : les exemples suivants confieront les formulaires aux formulaires de [Django].
4.5.1. Le modèle : livres/modeles.py
- lignes 14-19 : le modèle du gabarit contient la saisie de l'utilisateur, pour que le formulaire la réaffiche, la liste des genres proposés, et les résultats.
4.5.2. La vue : livres/views.py
- lignes 9-16 : deux constantes du module : la liste des genres et celle des livres. Dans une vraie application, ils viendraient d'une base de données ;
- lignes 20-21 : une seule vue, que deux routes désignent ([config/urls.py] : [""] et ["recherche"], nommées [livres_accueil] et [livres_recherche]). À la première visite ([/]), il n'y a pas de paramètres, et tous les livres sont affichés ;
- lignes 22-23 : les champs du formulaire sont lus comme des paramètres de l'URL (exemple 07), avec la chaîne vide pour valeur par défaut ;
- lignes 24-28 : le filtrage, par une liste en compréhension avec une condition ([if]) : un livre est retenu si le mot-clé est vide ou figure dans son titre ou son auteur (en minuscules des deux côtés : [lower()] traite correctement les caractères accentués), et si le genre est vide ou est le sien. Une condition peut s'écrire sur plusieurs lignes, entre parenthèses ;
- lignes 29-31 : le modèle du gabarit.
4.5.3. Le gabarit : templates/livres/recherche.html
- ligne 6 : la balise [<form>]. L'attribut [action] est l'URL à laquelle le formulaire est envoyé ; [method="get"] place les champs dans l'URL. Les classes de [Bootstrap] disposent les champs sur une ligne ;
- ligne 8 : un libellé. Son attribut [for] désigne l'identifiant ([id]) du champ qu'il décrit : un clic sur le libellé place le curseur dans le champ, et les lecteurs d'écran lisent le libellé avec le champ ;
- ligne 10 : une zone de saisie. Son attribut [name] est le nom du paramètre envoyé au serveur : c'est le lien entre le formulaire et la vue ([request.GET.get("mot_cle", "")]). L'attribut [value] est la valeur affichée : la saisie précédente, réaffichée (et échappée par [{{ }}], ce qui est indispensable dans un attribut : une saisie contenant un guillemet ["] fermerait sinon l'attribut) ;
- lignes 14-20 : une liste déroulante. Chaque [<option>] a une valeur ([value]), envoyée au serveur, et un texte, affiché. La première option, de valeur vide, signifie « tous les genres » ;
- ligne 18 : l'option choisie à la requête précédente reçoit l'attribut [selected]. C'est un attribut booléen : sa présence suffit, il n'a pas de valeur. La condition l'écrit si elle est vraie, et n'écrit rien sinon ;
- ligne 23 : le bouton d'envoi ;
- ligne 26 : le nombre de résultats.
4.5.4. Exécution

Saisissons [le] et choisissons le genre [roman] :

L'URL de la page des résultats contient les champs du formulaire : [/recherche?mot_cle=le&genre=roman]. La réponse, à partir du formulaire :
« Madame Bovary » n'est pas retenu : « le » ne figure ni dans son titre ni dans le nom de son auteur.
4.6. Exemple 18 : les champs d'un formulaire
Cet exemple présente tous les types de champs d'un formulaire HTML, envoyé cette fois par POST. Il montre comment réafficher chaque type de champ avec sa valeur courante, comment le navigateur envoie les valeurs de chacun, et comment le serveur vérifie qu'une valeur reçue fait bien partie des choix proposés. Écrire tout cela à la main, comme à l'exemple 17, serait long et répétitif. Les formulaires de [Django], que nous avons utilisés au chapitre précédent pour valider des paramètres, savent aussi s'afficher : chaque champ a un widget, un objet qui fabrique sa zone de saisie en HTML, avec la valeur courante, et le formulaire réaffiche la saisie et les erreurs. C'est la version complète de ce que le cours [Symfony] confiait à son composant Form, et le cours [Flask] à [WTForms].
4.6.1. Les listes : formulaire/listes.py
Les listes de choix du formulaire (titres, pays, loisirs, langues) sont des constantes d'un module : dans une vraie application, elles viendraient d'une base de données. Chaque liste est faite de couples (valeur, texte) : la valeur est celle qui est envoyée au serveur, le texte celui qui est affiché (exemple 08). Un couple s'écrit entre parenthèses : c'est un tuple, une suite de valeurs non modifiable.
4.6.2. Le formulaire : formulaire/forms.py
- lignes 10-23 : une classe mère pour nos formulaires, qui donne à chaque zone de saisie la classe CSS de [Bootstrap] qui lui convient. Elle redéfinit le constructeur ([__init__]), qui reçoit les mêmes paramètres que celui de [forms.Form] ([*args], [**kwargs], exemple 08), et commence par l'appeler ([super().__init__(...)]). L'attribut [fields] du formulaire est alors un dictionnaire de ses champs ; chaque champ a son widget ([champ.widget]), dont le dictionnaire [attrs] contient les attributs HTML de la zone de saisie. [isinstance] accepte un tuple de classes : le widget est-il de l'une de ces classes ? Les boutons radio et les cases à cocher prennent la classe [form-check-input], les listes [form-select], les autres [form-control] ;
- lignes 28-51 : les champs, dans l'ordre de leur affichage. Il faut distinguer deux objets : le champ ([forms.XxxField]) convertit et valide la valeur, comme au chapitre précédent ; son widget ([forms.XxxInput], [forms.Select]...) dessine la zone de saisie. Chaque champ a un widget par défaut, que le paramètre [widget] remplace. Le libellé d'un champ est, par défaut, son nom, mis en forme ([mot_de_passe] deviendrait « Mot de passe », [nom] « Nom ») ; le paramètre [label] le choisit ;
- lignes 31-32 : un [ChoiceField] est une liste de choix. Avec le widget [RadioSelect], il devient un groupe de boutons radio (un choix unique). Le paramètre [error_messages] (exemple 09) donne le message de l'erreur « champ obligatoire » ;
- ligne 34 : un [CharField] (widget par défaut : [TextInput], un [<input type="text">]), facultatif. Un champ obligatoire ([required=True], le défaut) produit l'attribut HTML [required], que le navigateur vérifie (exemple 19) ;
- ligne 36 : un mot de passe (widget [PasswordInput], un [<input type="password">]), qui n'est jamais renvoyé dans la page : son champ est toujours vide à l'affichage ;
- lignes 38-39 : une date ([DateField]), dont la valeur sera un objet [date] de [Python]. Le widget [DateInput] reçoit l'attribut [type="date"] : un champ [<input type="date">], pour lequel le navigateur affiche un calendrier. Un tel champ échange sa valeur avec le serveur au format [aaaa-mm-jj], quel que soit le format dans lequel le navigateur l'affiche ([DateField] accepte ce format) ;
- lignes 41-42 : un [MultipleChoiceField] (exemple 10), dessiné par le widget [CheckboxSelectMultiple] : des cases à cocher ;
- ligne 44 : un [ChoiceField] avec son widget par défaut, [Select] : une liste déroulante ;
- lignes 46-48 : un [MultipleChoiceField] avec son widget par défaut, [SelectMultiple] : une liste à sélection multiple, affichée sur quatre lignes (l'attribut HTML [size], donné par [attrs]) ;
- ligne 49 : une zone de texte ([Textarea]), sur deux lignes ;
- ligne 51 : un champ caché ([HiddenInput]).
[Django] fournit aussi [EmailField] (exemple 19), [IntegerField], [DecimalField], [URLField], [BooleanField] (une case à cocher unique), [FileField], [ImageField], [SlugField], [UUIDField], [JSONField]... et d'autres widgets : [EmailInput], [NumberInput], [CheckboxInput], [ClearableFileInput], [SelectDateWidget] (trois listes, jour, mois, année)...
4.6.3. Le dessin des champs : config/rendu.py et les gabarits des formulaires
Un formulaire [Django] se dessine lui-même, à l'aide de gabarits : un gabarit pour le formulaire entier, un pour chaque champ complet (libellé, zone de saisie, aide, erreurs), et un pour chaque sorte de widget. [Django] fournit tous ces gabarits, dans l'application [django.forms], avec une présentation neutre. Pour obtenir la présentation de [Bootstrap], nous en remplaçons quelques-uns. Il faut d'abord que [Django] cherche les gabarits des formulaires comme les autres, dans les dossiers du réglage [TEMPLATES] : c'est le rôle d'un moteur de rendu (form renderer) :
- ligne 9 : [TemplatesSetting] est le moteur de rendu qui cherche ses gabarits avec le moteur de gabarits du projet : un gabarit du dossier [templates] du projet peut donc remplacer un gabarit de [Django] de même nom. Notre classe en hérite ;
- ligne 11 : l'attribut [field_template_name] désigne le gabarit d'un champ complet : le nôtre, [formulaires/champ.html].
La configuration :
- ligne 17 : l'application [django.forms], qui apporte les gabarits des formulaires de [Django], dans son dossier [templates] ;
- lignes 21-24 : le middleware de protection anti-CSRF (voir plus loin) ;
- ligne 32 : [APP_DIRS: True] : [Django] cherche aussi les gabarits dans le dossier [templates] de chaque application déclarée, dont [django.forms]. Les dossiers de [DIRS] passent d'abord : nos gabarits l'emportent sur ceux de [Django] ;
- ligne 38 : le réglage [FORM_RENDERER] désigne notre moteur de rendu.
Le gabarit d'un champ complet :
Le gabarit reçoit le champ à dessiner, [field] (un objet [BoundField] : le champ, lié au formulaire, et donc à sa valeur et à ses erreurs) :
- lignes 2-6 : un groupe de boutons radio ou de cases à cocher ([field.use_fieldset] est vrai pour ces widgets) est entouré d'un [<fieldset>], dont la légende ([<legend>]) est le libellé du groupe : c'est ce qu'attendent les lecteurs d'écran ;
- lignes 8-10 : un autre champ a un [<label>], relié à la zone de saisie par son identifiant ([id_for_label] : [id_nom], par défaut [id_] suivi du nom du champ). [{{ field }}] dessine la zone de saisie, par le gabarit de son widget ;
- ligne 12 : le texte d'aide du champ (paramètre [help_text] d'un champ), s'il en a un ;
- ligne 13 : les erreurs du champ ([field.errors]), avec les classes de [Bootstrap] ([invalid-feedback] : un texte rouge, [d-block] : toujours visible). Son identifiant, [id_nom_error], est celui que [Django] indique, dans l'attribut [aria-describedby] de la zone de saisie, pour que les lecteurs d'écran lisent le message avec le champ ;
- ligne 14 : la fermeture de la balise ouverte par la première partie.
Enfin, deux gabarits de widgets de [Django] sont remplacés, pour que les boutons radio et les cases à cocher soient présentés à la façon de [Bootstrap], côte à côte :
Ils portent exactement le nom et le chemin des gabarits de [Django] qu'ils remplacent ([django/forms/widgets/...], que l'on trouve dans le dossier [django/forms/templates] de la bibliothèque) :
- [multiple_input.html] dessine le groupe : pour chaque option ([widget.optgroups] : les options, regroupées ; nous n'avons pas de sous-groupes), une [<div class="form-check form-check-inline">] qui contient l'option, dessinée par son propre gabarit ([option.template_name]) ;
- [input_option.html] dessine une option : la zone de saisie ([<input type="radio">] ou [<input type="checkbox">], par le gabarit de [Django] [input.html]), suivie de son libellé.
Tout cela n'est à écrire qu'une fois : les exemples 19, 21 et 28, et l'étude de cas, reprennent ces fichiers. Des bibliothèques toutes prêtes, comme [django-crispy-forms] et son paquet [crispy-bootstrap5], ou [django-bootstrap5], le font aussi ; nous avons préféré montrer le mécanisme.
4.6.4. La vue : formulaire/views.py
- ligne 9 : les valeurs initiales du formulaire : un dictionnaire, une valeur par champ (une liste pour un champ à valeurs multiples) ;
- ligne 14 : une seule vue, pour les deux méthodes : GET affiche le formulaire, POST le traite ;
- lignes 16-19 : en GET, un formulaire non lié (sans données), avec ses valeurs initiales (paramètre [initial]) : il n'est pas validé, et n'affiche aucune erreur ;
- lignes 21-22 : en POST, un formulaire lié aux champs envoyés ([request.POST]), puis validé ([is_valid], chapitre précédent) : chaque champ convertit sa valeur (la date, les listes...), vérifie qu'un choix fait partie de ceux qui étaient proposés, et applique ses contraintes ;
- lignes 24-28 : si le formulaire est valide, ses données converties ([cleaned_data]), dans un dictionnaire en compréhension (la syntaxe [{clé: valeur for ...}]) : une liste est écrite sous forme de texte, ses éléments séparés par des virgules ([", ".join(...)]) ;
- ligne 31 : pour l'exemple, ce que le navigateur a réellement envoyé. La méthode [lists()] d'un [QueryDict] (exemple 10) parcourt les couples (nom, liste de toutes les valeurs). La chaîne [\n] est un saut de ligne ;
- lignes 32-37 : le formulaire lié est passé au gabarit, qui l'affichera, avec sa saisie et ses erreurs.
4.6.5. Le gabarit : templates/formulaire/formulaire.html
- ligne 5 : la balise [<form>], écrite à la main : le formulaire [Django] ne l'écrit pas ;
- ligne 7 : la balise [{% csrf_token %}] écrit un champ caché qui contient le jeton anti-CSRF (voir plus loin) ;
- ligne 9 : [{{ formulaire.titre }}] désigne le champ [titre] du formulaire (un [BoundField]) ; sa méthode [as_field_group] le dessine complet, par le gabarit [formulaires/champ.html] : libellé, zone de saisie (avec sa valeur courante), erreurs ;
- lignes 10-20 : les autres champs, disposés par les classes de grille de [Bootstrap] ;
- ligne 22 : le champ caché, dont on ne dessine que la zone de saisie ;
- ligne 26 : la variable [recu] n'existe qu'après un envoi : une variable absente est « fausse » ;
- lignes 32-34 : les erreurs. [formulaire.errors] est un dictionnaire, que l'on parcourt par [items] (sans parenthèses, en DTL) : pour chaque champ, la liste de ses erreurs ;
- lignes 39-41 : une boucle sur un dictionnaire, qui parcourt à la fois les clés et les valeurs.
On aurait pu écrire tout le formulaire d'une ligne : [{{ formulaire }}], qui dessine tous les champs, dans l'ordre, par le gabarit du formulaire (réglage [form_template_name] du moteur de rendu). Les champs, un par un, permettent de les disposer à sa guise.
4.6.6. La protection anti-CSRF
Une attaque CSRF (Cross-Site Request Forgery, falsification de requête inter-sites) consiste, pour un site malveillant, à faire envoyer par le navigateur de sa victime un formulaire vers notre application. Le navigateur y joint les cookies de notre application, et donc la session de l'utilisateur connecté : la requête semble légitime. La parade : chaque formulaire de notre application contient un jeton, aléatoire, que le site malveillant ne peut pas connaître.
[Django] l'applique à toute l'application, par un middleware, [CsrfViewMiddleware] (ligne 23 du fichier [config/settings.py]). À la fabrication d'une page, la balise [{% csrf_token %}] écrit le champ caché [csrfmiddlewaretoken], et le middleware dépose, avec la réponse, un cookie [csrftoken], qui contient un secret. Pour toute requête qui modifie (POST, PUT, PATCH, DELETE), avant d'appeler la vue, le middleware vérifie que le jeton envoyé (dans le champ [csrfmiddlewaretoken], ou dans l'entête [X-CSRFToken], pour une requête envoyée par [JavaScript]) correspond au secret du cookie. Un site malveillant ne peut ni lire le cookie de notre site, ni lire nos pages : il ne peut pas fabriquer un jeton valide. Si la vérification échoue, la vue n'est pas appelée : [Django] répond lui-même par une erreur 403. C'est une différence importante avec [Symfony], dont le composant Form vérifiait le jeton de chaque formulaire, et laissait l'action décider de la suite : avec [Django], toutes les requêtes POST sont protégées, et une vue ne peut pas oublier de l'être. Le décorateur [@csrf_exempt] dispense une vue de cette vérification (pour une API appelée par d'autres serveurs, par exemple).
4.6.7. Exécution

Les valeurs initiales sont visibles : le bouton radio « Mme », le pays « France », la langue « français ». Le HTML produit (extraits) :
[Django] a tout écrit : les identifiants ([id_titre_1]), les libellés reliés à leurs champs, les noms des champs (les cases à cocher portent toutes le nom [loisirs] : le navigateur enverra un paramètre répété, exemple 10), les attributs [checked] et [selected] des valeurs courantes, l'attribut [required] des champs obligatoires, les classes de [Bootstrap]. Le premier champ caché porte le jeton anti-CSRF ; il est différent à chaque page (il est « masqué » par un nombre aléatoire, pour qu'un espion du réseau ne puisse pas deviner le secret en comparant plusieurs pages). Remplissons le formulaire et envoyons-le :

La page montre ce qu'a reçu le serveur : un paramètre par champ, avec plusieurs valeurs pour les cases à cocher et la liste multiple ; le mot de passe a été envoyé, mais n'est pas réaffiché dans son champ. Le navigateur a affiché la date selon ses propres réglages, mais l'a envoyée au format [aaaa-mm-jj] ; le gabarit l'a écrite à la française, [14 mai 1990], comme toute date qu'il affiche. Le champ caché [jeton] a été renvoyé tel quel : c'est le moyen de faire faire un aller-retour à une information que l'utilisateur ne voit pas (l'étude de cas s'en sert pour le numéro de version d'un enregistrement). Mais un champ caché n'est pas un champ protégé : l'utilisateur peut le modifier avec les outils de développement de son navigateur, et le serveur doit le vérifier comme tout autre champ. Le jeton anti-CSRF fait lui aussi partie des champs reçus ; il ne fait pas partie des données du formulaire.
Envoyons maintenant, avec [curl], un formulaire sans jeton, et avec un pays qui n'était pas proposé :
La requête n'est pas allée plus loin que le middleware : la vue n'a pas été appelée, et la console du serveur l'indique :
Dans un navigateur, la page d'erreur de [Django] explique la raison du refus (en mode debug) :

Pour que le jeton soit accepté, [curl] doit se comporter comme un navigateur : demander d'abord le formulaire, en conservant le cookie [csrftoken] (option [-c], qui enregistre les cookies reçus dans un fichier), puis renvoyer le cookie (option [-b]) et le jeton lu dans la page :
(le jeton est celui de la page reçue par la première commande). Cette fois, la vue a été appelée, et le formulaire est invalide, pour deux raisons : aucun titre n'a été envoyé, et le pays [xx] ne fait pas partie des choix. Ce dernier contrôle est fait par le champ [ChoiceField] lui-même, sans que nous l'ayons demandé : un utilisateur malveillant peut envoyer n'importe quoi, pas seulement ce que proposait la page. La vue, ici, renvoie le formulaire invalide avec le code 200 ; l'exemple suivant choisira un code plus juste.
4.7. Exemple 19 : la validation d'un formulaire
Un formulaire d'inscription, dont chaque champ a ses contraintes. En cas d'erreur, le formulaire est réaffiché avec la saisie de l'utilisateur et un message sous chaque champ erroné. Nous y ajoutons une règle qui porte sur deux champs : le mot de passe et sa confirmation doivent être identiques.
Les fichiers [config/settings.py], [config/rendu.py] et [templates/formulaires/champ.html] sont ceux de l'exemple 18.
4.7.1. Le formulaire : inscription/forms.py
- lignes 7-11 : la classe mère de l'exemple 18, simplifiée : tous les champs sont des zones de saisie ;
- lignes 17-19 : le nom est obligatoire, et ne dépasse pas 20 caractères. Chaque erreur reçoit ici son message, en français, par son code (exemple 09). Le paramètre [max_length] a deux effets : le champ vérifie la longueur (sur le serveur), et son widget écrit l'attribut HTML [maxlength], qui limite la saisie dans le navigateur ([min_length] de même, avec l'attribut [minlength]) ;
- lignes 21-23 : un [EmailField] vérifie qu'une adresse est valide, et exige un nom de domaine complet ([exemple.fr], et non [exemple]). Son widget, [EmailInput], produit un [<input type="email">], que le navigateur sait vérifier ;
- lignes 25-29 : un [IntegerField] produit un [<input type="number">], avec les attributs [min] et [max] déduits de [min_value] et [max_value], et convertit la saisie en entier. Une saisie qui n'est pas un nombre entier produit l'erreur [invalid] ;
- lignes 30-35 : le mot de passe (au moins 6 caractères) et sa confirmation, deux [CharField] avec le widget [PasswordInput]. Un paramètre peut s'écrire sur plusieurs lignes ;
- lignes 39-47 : la méthode [clean] valide le formulaire entier, après la validation de chaque champ (et de ses méthodes [clean_<champ>], exemple 12). [super().clean()] rend [cleaned_data], qui ne contient que les champs valides : [donnees.get("mot_de_passe")] vaut [None] si le mot de passe a déjà été refusé (trop court). La comparaison n'est faite que si les deux champs sont valides ; s'ils diffèrent, [add_error] attache une erreur au champ [confirmation] ([add_error(None, "...")] l'attacherait au formulaire lui-même : une erreur « générale », que rend [non_field_errors()]). C'est le moyen de vérifier une règle qui porte sur plusieurs champs.
4.7.2. La vue : inscription/views.py
- ligne 13 : le paramètre [?html5=true] active la validation par le navigateur (voir plus loin) ;
- lignes 15-20 : un formulaire lié aux champs envoyés. S'il est valide, la page de bienvenue reçoit ses données (l'exemple suivant montrera qu'il vaut mieux, après un POST réussi, rediriger) ;
- lignes 23-24 : sinon, le formulaire est réaffiché, avec la saisie et les erreurs, et le code 422 (Unprocessable Content : « la requête est bien formée, mais son contenu ne convient pas ») ;
- lignes 26-27 : au premier affichage, un formulaire vide, non lié.
Le formulaire invalide est donc réaffiché par la vue POST elle-même, qui dispose de la saisie et des erreurs : c'est la démarche habituelle de [Django], comme de [Symfony]. D'autres frameworks, comme [Laravel], répondent plutôt par une redirection vers la page du formulaire, en conservant saisie et erreurs dans la session pour la requête suivante.
4.7.3. Les gabarits
- lignes 5-9 : un lien pour activer ou désactiver la validation par le navigateur ;
- ligne 11 : l'attribut [novalidate] désactive la validation par le navigateur : le formulaire est envoyé tel quel, et seul le serveur le valide. C'est ce que nous voulons observer d'abord. Le formulaire n'a pas d'attribut [action] : il est envoyé à l'URL de la page en cours ;
- lignes 14-16 : une boucle sur le formulaire lui-même : elle parcourt ses champs, dans l'ordre de leur déclaration, chacun dessiné complet ;
- lignes 21-26 : le bloc [bas_de_page], défini par le gabarit parent (voir plus loin). [formulaire.errors] est vide avant l'envoi, ou si tout est valide. Si le formulaire a des erreurs, un message général, dans une zone dont le style est défini par [static/css/styles.css] (exemple 15). L'attribut [role="alert"] le signale aux lecteurs d'écran.
- ligne 17 : un second bloc, [bas_de_page], vide par défaut : un gabarit peut avoir autant de blocs qu'il en faut.
[inscription] est le dictionnaire des données validées : le nom a été débarrassé de ses espaces (voir plus loin), l'âge est un entier.
4.7.4. Exécution
Envoyons un formulaire plein d'erreurs : un nom fait d'espaces, une adresse sans domaine complet, un âge de 12 ans, un mot de passe trop court, et une confirmation différente :

La saisie a été conservée (sauf les mots de passe), et chaque erreur est signalée sous son champ. Le nom fait d'espaces est signalé comme absent : un [CharField] retire les espaces qui entourent chaque saisie (paramètre [strip], exemple 08), et réaffiche la valeur nettoyée. La confirmation, elle, n'a pas d'erreur, alors qu'elle diffère du mot de passe : le mot de passe ayant été refusé, la méthode [clean] ne les a pas comparés. L'URL est celle du formulaire, [/inscription] : la page a été affichée par la vue, en réponse au POST. Avec [curl], en fournissant le jeton anti-CSRF comme à l'exemple 18, on peut aussi envoyer ce qu'un navigateur n'enverrait pas, un âge non numérique :
On voit la saisie réaffichée, les attributs HTML5 ([required], [min], [max]), le message de l'erreur [invalid], et les attributs [aria-invalid] et [aria-describedby], que [Django] ajoute de lui-même à un champ en erreur, et qui relient le champ à son message pour les lecteurs d'écran.
4.7.5. La validation par le navigateur
Supprimons l'attribut [novalidate], en suivant le lien « l'activer » (paramètre [?html5=true]). Le navigateur vérifie alors lui-même les attributs [required], [type="email"], [min], [max], [maxlength]... avant d'envoyer le formulaire, et affiche ses propres messages, dans une bulle placée sous le premier champ erroné (« Veuillez renseigner ce champ », « La valeur doit être supérieure ou égale à 18 »...) : le formulaire n'est pas envoyé tant qu'il reste une erreur. [Django] a écrit ces attributs de lui-même, d'après les champs : [required] pour tout champ obligatoire, [maxlength] et [minlength] d'après [max_length] et [min_length], [min] et [max] d'après [min_value] et [max_value], [type="email"] et [type="number"] d'après les widgets. Ils sont donc toujours cohérents avec les contraintes du serveur. Seule la règle de la méthode [clean] (la confirmation) échappe au navigateur.
Cette validation est confortable pour l'utilisateur : immédiate, sans aller-retour avec le serveur. Mais elle ne remplace pas celle du serveur : un utilisateur peut la désactiver, et un programme comme [curl] l'ignore complètement. La règle est absolue : le serveur valide toujours, la validation par le navigateur n'est qu'un confort en plus.
Une saisie correcte, enfin. Le nom, saisi avec des espaces autour, a été nettoyé par le formulaire :

4.8. Exemple 20 : le schéma Post / Redirect / Get
Quand une vue POST répond par une page, cette page est le résultat d'un POST. Si l'utilisateur la recharge (touche [F5]), le navigateur renvoie le POST, après un avertissement que peu de gens lisent : la commande est passée deux fois, le message publié deux fois. Le schéma Post / Redirect / Get (PRG) évite ce problème : une vue POST qui a réussi ne rend pas de page ; elle répond par une redirection (exemple 04) vers une page GET, que le navigateur demande aussitôt. Recharger cette page ne fait que refaire le GET.
Il reste un problème : la page GET voudrait afficher un message (« la tâche a été ajoutée »), mais c'est la vue POST qui le connaît, et elle ne peut pas transmettre de variable à une requête qui n'a pas encore eu lieu. La solution : le message flash, déposé par la vue POST, conservé jusqu'à ce qu'il soit lu, affiché, puis oublié. [Django] les fournit par son application [django.contrib.messages].
4.8.1. La configuration : config/settings.py
- ligne 17 : l'application des messages ;
- lignes 21-26 : son middleware, [MessageMiddleware], qui lit les messages en attente au début de chaque requête, et range, à la fin, ceux qui n'ont pas été lus (et les nouveaux) ;
- ligne 30 : le réglage [MESSAGE_STORAGE] choisit où les messages sont conservés entre deux requêtes : ici, dans un cookie (voir plus loin). Par défaut, [Django] essaie le cookie, puis, si le message est trop gros, la session (chapitre sur les portées) ; notre exemple n'a pas de session ;
- lignes 37-40 : le processeur de contexte des messages : il met à la disposition de tous les gabarits la variable [messages].
4.8.2. Le service : taches/service.py
La liste des tâches est partagée par tous les utilisateurs, et doit survivre d'une requête à la suivante. Nous l'avons vu aux exemples 01 et 06 : en [Python], le serveur reste en mémoire d'une requête à l'autre. Une simple variable du module suffit donc. Le lecteur du cours [Symfony] se souvient qu'il fallait, en [PHP], ranger la liste dans un cache ou un fichier.
- ligne 8 : la liste, variable du module. Elle vit aussi longtemps que le processus du serveur : elle est perdue quand le serveur s'arrête, ou redémarre (après une modification du code, en développement). Et si le serveur de production lance plusieurs processus (c'est l'usage : un par processeur, par exemple), chacun aura sa propre liste. Une vraie application rangerait donc la liste dans une base de données (chapitre 7) ;
- ligne 12 : le serveur de développement traite plusieurs requêtes en même temps, chacune dans son fil d'exécution (thread). Deux ajouts simultanés pourraient se mélanger. Un verrou ([threading.Lock]) garantit qu'un seul fil à la fois exécute le code qu'il protège ;
- lignes 15-18 : la fonction rend une copie de la liste, faite sous la protection du verrou ;
- lignes 21-24 : l'instruction [with] prend le verrou, exécute le bloc, puis rend le verrou, dans tous les cas, même si une exception interrompt le bloc. Un objet utilisable par [with] s'appelle un gestionnaire de contexte (context manager) ; les fichiers en sont un autre exemple ([with open(...) as f:] ferme le fichier à la fin du bloc).
Le service est un module, et non une classe : ses fonctions sont appelées par [service.liste()], [service.ajouter(...)]. C'est la façon la plus simple, en [Python], d'avoir un objet unique partagé par toute l'application (exemple 06).
4.8.3. Les vues : taches/views.py
- lignes 16-22 : la saisie est validée à la main, pour ne pas encombrer l'exemple ([strip] retire les espaces qui l'entourent). Une saisie invalide est réaffichée, sans redirection : il n'y a rien à protéger d'un double envoi, puisque rien n'a été modifié ;
- ligne 25 : [messages.success] dépose un message flash de niveau « succès » (il existe aussi [debug], [info], [warning] et [error] : le niveau permet de présenter différemment les messages) ;
- ligne 27 : la redirection vers la page GET, nommée [taches] (exemple 04) ;
- lignes 31-34 : le contre-exemple : une page en réponse au POST. L'opérateur [or] rend son opérande de gauche s'il est « vrai » (ici, une chaîne non vide), celui de droite sinon.
4.8.4. Les gabarits
- lignes 14-16 : [messages] contient les messages flash en attente. Les parcourir les marque comme lus : le middleware les retirera à la fin de la requête ; un message flash n'est affiché qu'une fois. La classe [alert alert-success] de [Bootstrap] affiche le message dans un cadre vert. Chaque message a aussi un niveau ([message.level]) et des étiquettes ([message.tags] : [success]...), pour choisir sa présentation.
- ligne 11 : le jeton anti-CSRF : le middleware [CsrfViewMiddleware] est actif (ligne 22 du fichier [config/settings.py]) ;
- ligne 12 : la variable [libelle] n'existe qu'après une saisie invalide : une variable absente s'affiche comme une chaîne vide ;
- ligne 15 : l'attribut [formaction] d'un bouton envoie le formulaire à une autre adresse que celle de son attribut [action] : un même formulaire peut ainsi déclencher deux vues différentes ;
- lignes 17-22 : le message d'erreur, et l'avertissement du contre-exemple.
4.8.5. Où sont conservés les messages flash ?
Entre la requête POST et la requête GET qui suit, le message doit être conservé quelque part. Nous avons choisi un cookie. Observons-le avec [curl], en conservant les cookies dans un fichier (options [-c] et [-b], exemple 18), après avoir demandé la page pour obtenir le jeton anti-CSRF :
La réponse au POST est une redirection (code 302, entête [Location]), accompagnée du cookie [messages]. Ce cookie est [HttpOnly] (le [JavaScript] de la page ne peut pas le lire) et [SameSite=Lax] (le navigateur ne l'envoie pas avec une requête POST venue d'un autre site). Sa valeur a trois parties, séparées par des deux-points : les données, une date (codée en base 62), et une signature. Les données sont la liste des messages, en JSON, compressée puis codée en base 64 (le point initial signale la compression). Décodées, elles donnent :
On y lit le niveau du message (25 : succès) et son texte. Le contenu du cookie n'est donc pas secret : n'importe qui peut le décoder. Mais il est signé : la signature est calculée à partir des données et de la clé secrète de l'application ([SECRET_KEY], exemple 01). Si le navigateur renvoie un cookie modifié, la signature ne correspond plus, et [Django] l'ignore : un utilisateur ne peut pas fabriquer de faux messages. Suivons la redirection, en renvoyant le cookie :
Le message est affiché ; le middleware, voyant qu'il a été lu, supprime le cookie (un cookie vide, qui a expiré en 1970). Une nouvelle requête GET n'afficherait plus le message. L'entête [Vary: Cookie] prévient les caches que la réponse dépend des cookies de la requête. La liste des tâches, elle, est dans la mémoire du serveur.
4.8.6. Exécution
Ajout d'une tâche : le navigateur a suivi la redirection ; l'URL est celle de la page GET, que l'on peut recharger sans risque.

Une saisie invalide, réaffichée avec son message d'erreur :

Le contre-exemple : la page est la réponse au POST, et son URL est [/taches/sans-prg]. Si l'on appuie sur [F5], le navigateur demande s'il faut renvoyer le formulaire ; si l'on accepte, la tâche est ajoutée une seconde fois.

4.9. Exemple 21 : liens, boutons et confirmation
Un principe du protocole HTTP : une requête GET ne modifie rien. Les navigateurs, les moteurs de recherche, les outils qui préchargent les pages suivent librement les liens, sans demander l'avis de l'utilisateur. Un lien « supprimer » ([<a href="/contacts/2/supprimer">]) serait donc dangereux : un robot qui parcourt le site supprimerait tout. Dans une application web :
- consulter se fait par un lien ([<a href>]), qui émet une requête GET ;
- modifier (enregistrer, dupliquer, supprimer) se fait par un bouton placé dans un formulaire, protégé par un jeton anti-CSRF.
Cet exemple gère une liste de contacts, et ajoute une fenêtre de confirmation avant chaque suppression.
4.9.1. La simulation de méthode : config/middleware.py
Un formulaire HTML ne sait envoyer que des requêtes GET et POST. Or le protocole HTTP prévoit d'autres méthodes : DELETE pour supprimer, PUT et PATCH pour modifier. Beaucoup de frameworks contournent cette limite : un formulaire POST qui contient un champ caché [_method], de valeur [DELETE] (ou [PUT], [PATCH]), est traité comme une requête de cette méthode. C'est la simulation de méthode (method spoofing). [Symfony] l'offrait par une option ; [Django] ne l'offre pas. Écrivons-la, sous la forme d'un middleware :
- ligne 6 : un attribut de la classe : l'ensemble ([set], écrit entre accolades) des méthodes acceptées ;
- lignes 11-12 : [__call__] passe la requête à la suite, sans rien faire ;
- lignes 17-22 : la méthode [process_view] : [Django] l'appelle après le routage, juste avant d'appeler la vue, avec la vue choisie et ses paramètres (le chapitre sur le cycle de vie la présentera en détail). Si la requête est un POST dont le champ [_method] désigne l'une des méthodes acceptées, le middleware remplace la méthode de la requête. Rendre [None] laisse la requête poursuivre son chemin.
Pourquoi [process_view], et non [__call__] ? À cause de la protection anti-CSRF. Le middleware [CsrfViewMiddleware] fait sa vérification dans sa propre méthode [process_view], qui est appelée avant la nôtre, puisqu'il le précède dans le réglage [MIDDLEWARE]. Le jeton est donc vérifié sur la requête POST, avant que nous ne la transformions en DELETE. C'est ce que rappelle le commentaire du fichier [config/settings.py] :
- lignes 23-27 : trois middlewares : la protection anti-CSRF, les messages (exemple 20), la simulation de méthode, dans cet ordre ;
- ligne 44 : le moteur de rendu des formulaires de l'exemple 18.
4.9.2. Le service : contacts/service.py
Les contacts sont rangés en mémoire, comme les tâches de l'exemple 20. Chaque contact est un objet immuable [Contact] (un identifiant, un nom, un numéro de téléphone, fichier [contacts/modeles.py]). Quelques points de [Python] :
- ligne 12 : le dernier identifiant attribué ;
- lignes 27-36 : un contact est immuable : on en fabrique un nouveau, qui remplace l'ancien dans la liste. [enumerate] parcourt une liste par couples (position, élément) ;
- lignes 39-47 : une fonction qui affecte une variable du module doit le déclarer par [global] (ligne 42) : sans ce mot, l'affectation [_dernier_id += 1] créerait une variable locale à la fonction (et échouerait ici, puisqu'elle lit sa valeur avant de l'affecter). Lire une variable du module, ou modifier la liste qu'elle désigne ([_contacts.append(...)]), ne demande rien de tel ;
- ligne 55 : [pop(i)] retire l'élément de position [i] de la liste, et le rend ;
- les fonctions rendent [None] pour un contact inexistant : c'est à la vue de décider de la réponse.
4.9.3. Le formulaire de la fiche : contacts/forms.py
Les deux champs, obligatoires, avec la classe [form-control] de [Bootstrap] donnée directement à leur widget. Les boutons ne font pas partie d'un formulaire [Django] : ils sont écrits dans le gabarit.
4.9.4. Les vues : contacts/views.py
- lignes 13-16 : une fonction utilitaire : un contact inexistant arrête la vue par une réponse 404 ;
- lignes 20-22 : la vue GET de la liste, qui ne modifie rien ;
- lignes 27-48 : la fiche d'un contact. En GET, elle affiche le formulaire, rempli avec le contact ; en POST, elle le traite ; en DELETE, elle supprime le contact. La même URL [/contacts/2] sert ainsi à consulter, à enregistrer et à supprimer. Le décorateur [require_http_methods] est vérifié au moment d'appeler la vue, donc après [process_view] : il voit bien la méthode DELETE ;
- lignes 35-37 : un formulaire invalide est réaffiché ;
- lignes 42-47 : un formulaire peut avoir plusieurs boutons d'envoi. Quand le formulaire est envoyé, le navigateur transmet, en plus des champs, le nom (attribut [name]) du bouton cliqué, et seulement de lui. [in] teste la présence d'une clé dans un dictionnaire : ["dupliquer" in request.POST] est vrai si c'est le bouton [dupliquer] qui a envoyé le formulaire. Selon le bouton, la vue duplique ou enregistre le contact ;
- ligne 48 : dans les deux cas, le schéma PRG (exemple 20), avec un message flash ;
- lignes 52-55 : la suppression. Elle n'a pas à vérifier le jeton anti-CSRF : [CsrfViewMiddleware] l'a fait avant elle. Un jeton absent ou faux aurait arrêté la requête par une erreur 403.
4.9.5. Les gabarits
- ligne 13 : un lien, présenté comme un bouton par les classes de [Bootstrap] ([btn]...) : l'apparence n'a rien à voir avec la méthode HTTP. [{% url 'contacts_fiche' contact.id %}] place l'id dans le chemin : [/contacts/2] ;
- lignes 15-21 : un formulaire pour chaque bouton « Supprimer », sans autre champ que le jeton et la méthode. La classe [d-inline] l'affiche sur la même ligne que le lien. L'attribut [data-confirmation] porte le message de confirmation : les attributs [data-...] sont libres, destinés au [JavaScript] de la page ;
- ligne 17 : le jeton anti-CSRF ;
- ligne 19 : le champ caché [_method], qui fera de ce POST une requête DELETE.
- lignes 11-12 : les deux boutons d'envoi du formulaire, chacun avec son nom ;
- ligne 13 : le retour à la liste : un lien.
4.9.6. La fenêtre de confirmation
Le navigateur sait afficher une boîte de confirmation (fonction [JavaScript] [confirm]), mais elle est peu esthétique, et son apparence dépend du navigateur. On utilise ici une fenêtre modale de [Bootstrap], affichée par quelques lignes de [JavaScript]. La fenêtre est un fragment, inclus dans le gabarit parent, et donc présent, caché, dans toutes les pages :
- ligne 2 : la classe [d-none] de [Bootstrap] cache l'élément ;
- lignes 3-4 : le fond grisé, puis la fenêtre, dessinés par les classes de [Bootstrap] ;
- ligne 11 : l'emplacement du message ;
- lignes 14-15 : les deux boutons, de type [button] (et non [submit] : ils n'envoient aucun formulaire), distingués par leur attribut [data-action].
- lignes 12-14 : les messages flash (exemple 20) ;
- ligne 20 : la fenêtre, incluse dans toutes les pages ;
- ligne 22 : le script, placé en fin de page : quand il s'exécute, les éléments qu'il cherche existent déjà. C'est un fichier statique, rangé dans le dossier [static/js].
Le script [static/js/app.js] :
- ligne 3 : le code est enfermé dans une fonction anonyme, exécutée aussitôt : ses variables ne se mélangent pas à celles des autres scripts de la page ;
- lignes 4-6 : la fenêtre, l'emplacement du message, et le formulaire en attente de confirmation ;
- lignes 9-16 : pour chaque formulaire qui porte un attribut [data-confirmation], on intercepte l'événement [submit], déclenché par son envoi. [preventDefault()] suspend l'envoi ; le formulaire est mis en attente, son message ([dataset.confirmation], la valeur de l'attribut [data-confirmation]) est placé dans la fenêtre, qui apparaît ;
- lignes 19-27 : un seul gestionnaire pour les clics dans la fenêtre : [evenement.target] est l'élément cliqué ; son attribut [data-action] dit de quel bouton il s'agit. « Oui » envoie le formulaire en attente, par la méthode [submit()], qui, contrairement au clic sur un bouton, ne déclenche pas l'événement [submit] (sinon, la fenêtre réapparaîtrait) ; « Non » cache la fenêtre.
Sans [JavaScript], le formulaire est simplement envoyé sans confirmation : la page reste utilisable. C'est l'amélioration progressive : le [JavaScript] ajoute un confort, mais l'application ne dépend pas de lui.
4.9.7. Exécution
La liste des contacts :

Un clic sur « Supprimer » affiche la fenêtre de confirmation :

Après confirmation, la requête DELETE (simulée), la redirection, et le message flash :

La fiche d'un contact, avec ses deux boutons d'envoi :

Une requête sans jeton anti-CSRF est refusée, et un contact inexistant donne une erreur 404 :
Les pages d'erreur sont encore celles, détaillées, du mode debug de [Django]. Le chapitre sur le cycle de vie d'une requête montrera comment les remplacer par des pages « maison ».