Skip to content

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

13-premiere-vue/
├── config/
│   ├── settings.py
│   ├── urls.py
│   └── ...
├── pages/
│   ├── __init__.py
│   ├── modeles.py
│   ├── urls.py
│   └── views.py
├── templates/
│   └── pages/
│       ├── inconnu.html
│       └── index.html
└── manage.py

4.1.1. La configuration : config/settings.py

Le moteur de gabarits se configure par le réglage [TEMPLATES] :

# config/settings.py : la configuration de l'exemple 13

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-_ph!-_ox*o_&n8o2i=ykjwic(j9!mwf!cc2(()%nzdrs9ssai_"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "pages",
]

# les MOTEURS DE GABARITS : ici un seul, celui de Django (le DTL, Django Template
# Language), qui cherche les gabarits dans le dossier templates du projet
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "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

# pages/modeles.py : le modèle de la vue, les données que la vue Python transmet au gabarit

from dataclasses import dataclass


@dataclass(frozen=True)
class PageModele:
    titre: str
    message: str
    heure: str

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

# pages/views.py : des vues qui rendent une page fabriquée par un GABARIT

from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.http import require_GET

from .modeles import PageModele


# render : le moteur de gabarits fabrique la page à partir du gabarit
# templates/pages/index.html et du CONTEXTE, un dictionnaire des variables mises à
# la disposition du gabarit (ici une seule, modele) ; la page produite est le corps
# de la réponse
@require_GET
def index(request):
    modele = PageModele(
        "Mon premier gabarit",
        "Cette page a été fabriquée par Django, sur le serveur.",
        # l'heure, dans le fuseau horaire de l'application (réglage TIME_ZONE)
        timezone.localtime().strftime("%H:%M:%S"))
    return render(request, "pages/index.html", {"modele": modele})


# le même gabarit avec un autre modèle ; ou un autre gabarit, avec le code 404
@require_GET
def bonjour(request, nom):
    if nom == "inconnu":
        # status : le code de la réponse
        return render(request, "pages/inconnu.html", {"nom": nom}, status=404)
    modele = PageModele(
        f"Bonjour {nom}",
        "Cette fois, le titre de la page vient de l'URL.",
        timezone.localtime().strftime("%H:%M:%S"))
    return render(request, "pages/index.html", {"modele": modele})
  • 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

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  {# {{ ... }} : la valeur d'une variable, insérée dans la page #}
  <title>{{ modele.titre }}</title>
</head>
<body>
  <h1>{{ modele.titre }}</h1>
  <p>{{ modele.message }}</p>
  <p>Page fabriquée à {{ modele.heure }}.</p>
</body>
</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

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Inconnu</title>
</head>
<body>
  <h1>Personne ne s'appelle [{{ nom }}] ici.</h1>
</body>
</html>

Un gabarit n'est pas obligé de recevoir un objet : la variable [nom] est une chaîne.

4.1.6. Exécution

Image

Image

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).

curl -i http://localhost:8000/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">

  <title>Mon premier gabarit</title>
</head>
<body>
  <h1>Mon premier gabarit</h1>
  <p>Cette page a été fabriquée par Django, sur le serveur.</p>
  <p>Page fabriquée à 19:28:44.</p>
</body>
</html>

La page d'erreur « maison », puis un nom contenant du HTML et une apostrophe ([<b>martin'], encodé dans l'URL) :

curl -i http://localhost:8000/bonjour/inconnu
curl "http://localhost:8000/bonjour/%3Cb%3Emartin'"
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Inconnu</title>
</head>
<body>
  <h1>Personne ne s'appelle [inconnu] ici.</h1>
</body>
</html>
<!doctype html>
...
  <title>Bonjour &lt;b&gt;martin&#x27;</title>
</head>
<body>
  <h1>Bonjour &lt;b&gt;martin&#x27;</h1>
  <p>Cette fois, le titre de la page vient de l&#x27;URL.</p>
...

La balise [<b>] a été échappée, sans que la vue ni le gabarit aient rien eu à faire (l'apostrophe aussi : [&#x27;] ; 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 :

python manage.py shell -c "from django.template.loader import get_template; print(*get_template('pages/index.html').template.nodelist, sep='\n')"
<TextNode: '<!doctype html>\n<html lan'>
<TextNode: '\n  <title>'>
<Variable Node: modele.titre>
<TextNode: '</title>\n</head>\n<body>\n '>
<Variable Node: modele.titre>
<TextNode: '</h1>\n  <p>'>
<Variable Node: modele.message>
<TextNode: '</p>\n  <p>Page fabriquée '>
<Variable Node: modele.heure>
<TextNode: '.</p>\n</body>\n</html>\n'>

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 :

# bases/modeles.py : le modèle du gabarit de l'exemple 14

from dataclasses import dataclass


@dataclass(frozen=True)
class Adresse:
    rue: str
    ville: str


@dataclass(frozen=True)
class Produit:
    nom: str
    prix: float
    en_stock: bool


@dataclass(frozen=True)
class BasesModele:
    titre: str
    nom: str
    # str | None : une chaîne, ou None
    surnom: str | None
    html: str
    connecte: bool
    devise: str
    produits: list[Produit]
    panier: list[Produit]
    adresse: Adresse

    # @property : une méthode que l'on lit comme un attribut (modele.total). Le
    # langage des gabarits ne sait pas calculer : les calculs se font en Python
    @property
    def total(self) -> float:
        return sum(p.prix for p in self.produits)

    @property
    def prix_moyen(self) -> float:
        return self.total / len(self.produits)
  • 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.

# bases/views.py : un gabarit qui utilise les principales constructions du langage de Django

from django.shortcuts import render
from django.views.decorators.http import require_GET

from .modeles import Adresse, BasesModele, Produit


@require_GET
def index(request):
    # des arguments NOMMÉS : chaque valeur est associée à un attribut par son nom
    modele = BasesModele(
        titre="Les bases des gabarits",
        nom="Martin",
        surnom=None,
        html="<b>ceci est du HTML</b>",
        connecte=True,
        devise="€",
        produits=[
            Produit("Clavier", 25, True),
            Produit("Souris", 12.5, False),
            Produit("Écran", 149, True),
        ],
        panier=[],
        adresse=Adresse("12 rue des Lilas", "Angers"))
    return render(request, "bases/index.html", {"modele": modele})

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

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>{{ modele.titre }}</title>
</head>
<body>
  <h1>{{ modele.titre }}</h1>
  {% comment %}
    1. afficher une valeur du modèle ; None s'affiche... None ; le filtre default
    remplace une valeur vide (None, "", 0, liste vide...). Un commentaire {# #} tient
    sur une seule ligne ; celui-ci, sur plusieurs, s'écrit entre comment et endcomment
  {% endcomment %}
  <p>1. Nom : {{ modele.nom }} - surnom : [{{ modele.surnom }}] [{{ modele.surnom|default:"aucun" }}]</p>
  {# 2. {{ }} échappe le HTML ; le filtre safe l'insère tel quel #}
  <p>2a. {{ modele.html }}</p>
  <p>2b. {{ modele.html|safe }}</p>
  {# 3. une condition #}
  {% if modele.connecte %}
    <p>3. Vous êtes connecté.</p>
  {% else %}
    <p>3. Vous n'êtes pas connecté.</p>
  {% endif %}
  {# 4. une boucle : la variable forloop décrit l'état de la boucle #}
  <p>4. Nos produits :</p>
  <ul>
    {% for produit in modele.produits %}
      <li>n° {{ forloop.counter0 }} : {{ produit.nom }}, {{ produit.prix }} {{ modele.devise }}
        {{ produit.en_stock|yesno:"(en stock),(épuisé)" }}
        {% if forloop.first %} - le premier{% endif %}
        {% if forloop.last %} - le dernier, vu par {{ modele.nom }}{% endif %}
      </li>
    {% endfor %}
  </ul>
  {# 5. la liste vide : le bloc empty d'une boucle for #}
  <p>5. Votre panier :
    {% for p in modele.panier %}<span>{{ p.nom }}</span>{% empty %} il est vide.{% endfor %}
  </p>
  {% comment %}
    6. with : une variable du gabarit ; le filtre length compte les produits ;
    floatformat arrondit, et écrit le nombre selon la langue (réglage LANGUAGE_CODE)
  {% endcomment %}
  {% with nombre=modele.produits|length %}
    <p>6. {{ nombre }} produits, pour un total de {{ modele.total }} {{ modele.devise }} ;
      prix moyen : {{ modele.prix_moyen|floatformat:2 }} {{ modele.devise }}</p>
  {% endwith %}
  {# 7. un objet dans un objet, et un filtre qui met en majuscules #}
  <p>7. Adresse : {{ modele.adresse.rue }}, {{ modele.adresse.ville|upper }}</p>
  {# 8. verbatim : un texte que Django recopie sans l'interpréter #}
  <p>8. Des accolades affichées telles quelles : {% verbatim %}{{ modele.nom }}{% endverbatim %}</p>
</body>
</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

Image

La page produite :

curl http://localhost:8000/
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Les bases des gabarits</title>
</head>
<body>
  <h1>Les bases des gabarits</h1>

  <p>1. Nom : Martin - surnom : [None] [aucun]</p>

  <p>2a. &lt;b&gt;ceci est du HTML&lt;/b&gt;</p>
  <p>2b. <b>ceci est du HTML</b></p>


    <p>3. Vous êtes connecté.</p>


  <p>4. Nos produits :</p>
  <ul>

      <li>n° 0 : Clavier, 25 €
        (en stock)
         - le premier

      </li>

      <li>n° 1 : Souris, 12,5 €
        (épuisé)
...
  <p>5. Votre panier :
     il est vide.
  </p>


    <p>6. 3 produits, pour un total de 186,5 € ;
      prix moyen : 62,17 €</p>


  <p>7. Adresse : 12 rue des Lilas, ANGERS</p>

  <p>8. Des accolades affichées telles quelles : {{ modele.nom }}</p>
</body>
</html>

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.

15-gabarit/
├── config/
│   ├── contextes.py
│   ├── settings.py
│   └── ...
├── pages/
│   ├── __init__.py
│   ├── modeles.py
│   └── views.py
├── static/
│   └── css/
│       └── styles.css
├── templates/
│   ├── fragments/
│   │   └── navbar.html
│   ├── pages/
│   │   ├── accueil.html
│   │   ├── brute.html
│   │   └── equipe.html
│   ├── layout.html
│   └── macros.html
└── manage.py

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 :

# config/settings.py : la configuration de l'exemple 15

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-u0g1t-=6%v^*y#i*czmc@ge#-yk6m!jj13lte=0yw_pa6y+2m4"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    # l'application de Django qui gère les fichiers STATIQUES (feuilles de style,
    # images, scripts) : la balise {% static %}, et leur service par runserver
    "django.contrib.staticfiles",
    "pages",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "OPTIONS": {
            # les PROCESSEURS DE CONTEXTE : des fonctions qui ajoutent des variables au
            # contexte de TOUS les gabarits ; request : la requête en cours
            "context_processors": [
                "django.template.context_processors.request",
                "config.contextes.site",
            ],
        },
    },
]

# l'URL des fichiers statiques : {% static 'css/styles.css' %} -> /static/css/styles.css
STATIC_URL = "static/"
# les dossiers où Django cherche les fichiers statiques : celui de l'exemple, et
# celui de Bootstrap, partagé par les exemples 15 à 28
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "commun" / "static",
]
  • 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églages en plus de Bootstrap */
body {
  background-color: #f5f7fa;
}

/* le pied de page du gabarit */
.pied {
  margin-top: 2rem;
  font-size: 0.85rem;
  color: #6c757d;
}

/* les messages d'erreur, en bas de page, sur fond rose léger */
.zone-erreur {
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef;
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

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

<!doctype html>
{# le gabarit PARENT des pages : les pages l'ÉTENDENT, et remplissent ses blocs #}
{% load static %}
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# block : un emplacement que la page peut remplir ; ici avec un contenu par défaut #}
  <title>{% block titre %}Accueil{% endblock %} - {{ nom_site }}</title>
  {# static : l'URL d'un fichier statique #}
  <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
  <link rel="stylesheet" href="{% static 'css/styles.css' %}">
</head>
<body>
  <div class="container py-3">
    {# insertion du gabarit fragments/navbar.html #}
    {% include "fragments/navbar.html" %}
    <main>
      {# le contenu de la page demandée par la vue est inséré ici #}
      {% block contenu %}{% endblock %}
    </main>
    {# request : la requête en cours ; nom_site : une variable globale (cf. config/contextes.py) #}
    <footer class="pied">Page [{{ request.path }}] - © {% now "Y" %} {{ nom_site }}</footer>
  </div>
</body>
</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

1
2
3
4
5
6
7
# config/contextes.py : un processeur de contexte

# Django l'appelle pour chaque gabarit rendu avec la requête (render(request, ...)) ;
# le dictionnaire qu'il rend s'ajoute au contexte : nom_site est une variable GLOBALE,
# présente dans tous les gabarits
def site(request):
    return {"nom_site": "Mon site"}

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, inséré par include #}
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">{{ nom_site }}</span>
  <div class="navbar-nav">
    {# url : l'URL d'une route, désignée par son nom #}
    <a class="nav-link" href="{% url 'accueil' %}">Accueil</a>
    <a class="nav-link" href="{% url 'equipe' %}">Équipe</a>
    <a class="nav-link" href="{% url 'brute' %}">Page sans gabarit parent</a>
  </div>
</nav>

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

{# des FRAGMENTS NOMMÉS (partialdef) : des morceaux de page réutilisables, rangés dans un même fichier #}

{# une carte Bootstrap, avec un nom et une fonction #}
{% partialdef carte %}
  <div class="card">
    <div class="card-body">
      <h5 class="card-title">{{ nom }}</h5>
      <p class="card-text">{{ fonction }}</p>
    </div>
  </div>
{% endpartialdef %}
  • 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

{# cette page ÉTEND le gabarit parent : elle ne fournit que le contenu de ses blocs #}
{% extends "layout.html" %}

{% block contenu %}
  <h1>Bienvenue</h1>
  <p>Cette page ne contient que son propre contenu : l'entête, la barre de
    navigation et le pied de page viennent du gabarit parent <code>layout.html</code>.</p>
  {# le fragment [carte] du fichier macros.html, avec deux variables écrites sur place #}
  {% include "macros.html#carte" with nom="Un fragment" fonction="inclus avec des variables écrites sur place" %}
{% endblock %}
  • 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).
{% extends "layout.html" %}

{% block titre %}Notre équipe{% endblock %}

{% block contenu %}
  <h1>Notre équipe</h1>
  <div class="row g-3">
    {% for membre in membres %}
      <div class="col-md-4">
        {# les variables du fragment sont des expressions quelconques #}
        {% include "macros.html#carte" with nom=membre.nom fonction=membre.fonction %}
      </div>
    {% endfor %}
  </div>
{% endblock %}
  • 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é :

# pages/views.py : trois pages

from django.shortcuts import render
from django.views.decorators.http import require_GET

from .modeles import Membre


@require_GET
def accueil(request):
    return render(request, "pages/accueil.html")


@require_GET
def equipe(request):
    membres = [
        Membre("Marie Pélissier", "Directrice"),
        Membre("Jacques Bromard", "Développeur"),
        Membre("Philippe Jandot", "Graphiste"),
    ]
    return render(request, "pages/equipe.html", {"membres": membres})


@require_GET
def brute(request):
    return render(request, "pages/brute.html")

La troisième page, enfin, n'utilise pas le gabarit parent :

<!doctype html>
{# ce gabarit n'étend pas le gabarit parent : c'est une page complète #}
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Page brute</title>
</head>
<body>
  <p>Cette page n'utilise pas le gabarit parent : ni Bootstrap, ni barre de navigation.</p>
  <p><a href="{% url 'accueil' %}">Retour à l'accueil</a></p>
</body>
</html>

4.3.7. Exécution

Image

Image

Image

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 :

curl http://localhost:8000/equipe
<!doctype html>


<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>Notre équipe - Mon site</title>

  <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
  <div class="container py-3">


<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Mon site</span>
  <div class="navbar-nav">

    <a class="nav-link" href="/">Accueil</a>
    <a class="nav-link" href="/equipe">Équipe</a>
    <a class="nav-link" href="/brute">Page sans gabarit parent</a>
  </div>
</nav>

    <main>


  <h1>Notre équipe</h1>
  <div class="row g-3">

      <div class="col-md-4">


  <div class="card">
    <div class="card-body">
      <h5 class="card-title">Marie Pélissier</h5>
      <p class="card-text">Directrice</p>
    </div>
  </div>

      </div>
...
  </div>

    </main>

    <footer class="pied">Page [/equipe] - © 2026 Mon site</footer>
  </div>
</body>
</html>

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) :

curl -I http://localhost:8000/static/bootstrap/css/bootstrap.min.css
curl -I http://localhost:8000/static/css/styles.css
HTTP/1.1 200 OK
Content-Type: text/css
Content-Length: 232111
Content-Disposition: inline; filename="bootstrap.min.css"
Last-Modified: Sun, 27 Sep 2026 16:41:12 GMT

HTTP/1.1 200 OK
Content-Type: text/css
Content-Length: 422
Content-Disposition: inline; filename="styles.css"
Last-Modified: Sun, 27 Sep 2026 16:42:11 GMT

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 :

curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:8000/static/..%2F..%2Fconfig%2Fsettings.py"
500

(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.

16-extensions/
├── config/
│   ├── middleware.py
│   ├── settings.py
│   └── ...
├── pages/
│   ├── templatetags/
│   │   ├── __init__.py
│   │   └── formats.py
│   ├── __init__.py
│   ├── modeles.py
│   └── views.py
├── static/
│   └── css/
│       └── styles.css
├── templates/
│   ├── etiquettes/
│   │   ├── badge.html
│   │   └── lien.html
│   ├── fragments/
│   │   └── navbar.html
│   ├── pages/
│   │   ├── accueil.html
│   │   └── produits.html
│   └── layout.html
└── manage.py

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 %}] :

# pages/templatetags/formats.py : une BIBLIOTHÈQUE d'extensions du langage des
# gabarits, chargée par {% load formats %} : deux filtres et deux balises

from babel.dates import format_date
from babel.numbers import format_currency
from django import template
from django.utils.translation import get_language

# l'objet qui enregistre les filtres et les balises de la bibliothèque
register = template.Library()


# un FILTRE : {{ produit.prix_centimes|monnaie }} ou {{ ...|monnaie:"USD" }}. Un
# montant en centimes, dans une devise, au format de la langue active :
# 114990 -> "1 149,90 €" en français, "€1,149.90" en anglais. La bibliothèque
# Babel connaît les conventions de chaque pays
@register.filter
def monnaie(centimes, devise="EUR"):
    return format_currency(centimes / 100, devise, locale=get_language())


# une date en toutes lettres, dans la langue active : "dimanche 27 septembre 2026"
@register.filter
def date_longue(jour):
    return format_date(jour, format="full", locale=get_language())


# une BALISE D'INCLUSION : {% lien 'produits' '/produits' 'Produits' %} affiche le
# gabarit etiquettes/lien.html, avec le contexte que rend la fonction.
# takes_context : la fonction reçoit aussi le contexte du gabarit qui l'appelle
@register.inclusion_tag("etiquettes/lien.html", takes_context=True)
def lien(context, route, chemin, texte):
    # un lien de la barre de navigation, qui reçoit la classe [active] si la page
    # courante est sous le chemin [chemin]
    actif = context["request"].path.startswith(chemin)
    return {"route": route, "texte": texte, "actif": actif}


# un badge Bootstrap : vert si le produit est en stock, rouge sinon
@register.inclusion_tag("etiquettes/badge.html")
def badge(stock):
    return {"stock": stock}
  • 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 :

<a class="nav-link{% if actif %} active{% endif %}" href="{% url route %}">{{ texte }}</a>
{% if stock > 0 %}<span class="badge text-bg-success">{{ stock }} en stock</span>{% else %}<span class="badge text-bg-danger">épuisé</span>{% endif %}

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

# config/middleware.py : la langue de chaque requête

from django.utils import translation


class FixerLangue:
    def __init__(self, get_response):
        self.get_response = get_response

    # le paramètre ?lang=en de l'URL, le français sinon
    def __call__(self, request):
        langue = "en" if request.GET.get("lang") == "en" else "fr"
        # activate : la langue ACTIVE, que Django utilise pour mettre en forme les
        # nombres et les dates, et pour traduire ses messages
        translation.activate(langue)
        return self.get_response(request)

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

# config/settings.py : la configuration de l'exemple 16

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-yw&p(@e00b!)6xtfnoy-goyep6e*8jhvk_o0aj8a&pizr94xrq"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

# les langues de l'application
LANGUAGES = [
    ("fr", "Français"),
    ("en", "English"),
]

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    # l'application pages contient un dossier templatetags : ses extensions du langage
    # des gabarits sont disponibles par {% load formats %}
    "pages",
]

MIDDLEWARE = [
    "config.middleware.FixerLangue",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
            ],
        },
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "commun" / "static",
]
  • 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

# pages/views.py : deux pages

from datetime import date

from django.shortcuts import render
from django.views.decorators.http import require_GET

from .modeles import Produit


@require_GET
def accueil(request):
    return render(request, "pages/accueil.html", {"aujourdhui": date.today()})


@require_GET
def produits(request):
    liste = [
        Produit("clavier", 2500, 12),
        Produit("souris", 1250, 0),
        Produit("écran", 114990, 3),
    ]
    # le calcul est fait ici, en Python : le gabarit n'a qu'à l'afficher
    total = sum(p.prix_centimes * p.stock for p in liste)
    return render(request, "pages/produits.html", {"produits": liste, "total_stock": total})

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

{% load formats %}
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Extensions</span>
  <div class="navbar-nav">
    {# 1. une condition écrite dans le gabarit ; resolver_match : la route de la requête en cours #}
    <a class="nav-link{% if request.resolver_match.url_name == 'accueil' %} active{% endif %}" href="{% url 'accueil' %}">Accueil</a>
    {# 2. notre balise [lien] #}
    {% lien 'produits' '/produits' 'Produits' %}
    {# 3. url fabrique le chemin d'une route ; les paramètres de l'URL s'ajoutent à la main #}
    <a class="nav-link" href="{% url 'produits' %}?lang=en">Products (en)</a>
  </div>
</nav>
  • 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]).
{% extends "layout.html" %}
{% load i18n formats %}
{% block titre %}Accueil{% endblock %}

{% block contenu %}
  <h1>Accueil</h1>
  {# notre filtre date_longue #}
  <p>Nous sommes le {{ aujourdhui|date_longue }}.</p>
  {# la langue active, fixée par le middleware FixerLangue #}
  {% get_current_language as langue %}
  <p>Langue de formatage : {{ langue }}
    (<a href="{% url 'accueil' %}?lang=en">en anglais</a>, <a href="{% url 'accueil' %}">en français</a>)</p>
{% endblock %}
  • 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.
{% extends "layout.html" %}
{% load formats %}
{% block titre %}Produits{% endblock %}

{% block contenu %}
  <h1>Produits</h1>
  <table class="table table-sm w-auto">
    <thead><tr><th>Produit</th><th>Prix</th><th>Prix en dollars</th><th>Stock</th></tr></thead>
    <tbody>
      {% for produit in produits %}
        <tr>
          {# un filtre de Django (upper), puis notre filtre monnaie, sans puis avec paramètre #}
          <td>{{ produit.nom|upper }}</td>
          <td>{{ produit.prix_centimes|monnaie }}</td>
          <td>{{ produit.prix_centimes|monnaie:"USD" }}</td>
          {# notre balise [badge] fabrique le HTML du badge #}
          <td>{% badge produit.stock %}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
  <p>Total du stock : {{ total_stock|monnaie }}</p>
{% endblock %}
  • 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

Image

Image

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.

Image

Un extrait du HTML produit :

curl "http://localhost:8000/produits?lang=en"
...
    <a class="nav-link" href="/">Accueil</a>
    <a class="nav-link active" href="/produits">Produits</a>
    <a class="nav-link" href="/produits?lang=en">Products (en)</a>
...
        <tr>
          <td>ÉCRAN</td>
          <td>€1,149.90</td>
          <td>$1,149.90</td>
          <td><span class="badge text-bg-success">3 en stock</span>
</td>
        </tr>
...

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

# livres/modeles.py : un livre, et le modèle du gabarit de la recherche

from dataclasses import dataclass


@dataclass(frozen=True)
class Livre:
    titre: str
    auteur: str
    genre: str


# le modèle du gabarit : la saisie (pour la réafficher), les genres, les résultats
@dataclass(frozen=True)
class RechercheModele:
    mot_cle: str
    genre: str
    genres: list[str]
    resultats: list[Livre]
  • 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

# livres/views.py : un formulaire de recherche, envoyé par GET

from django.shortcuts import render
from django.views.decorators.http import require_GET

from .modeles import Livre, RechercheModele

# des CONSTANTES du module : les données, écrites dans le code pour l'exemple
GENRES = ["roman", "poésie", "théâtre"]
LIVRES = [
    Livre("Le Rouge et le Noir", "Stendhal", "roman"),
    Livre("Les Fleurs du mal", "Baudelaire", "poésie"),
    Livre("Madame Bovary", "Flaubert", "roman"),
    Livre("Alcools", "Apollinaire", "poésie"),
    Livre("Le Cid", "Corneille", "théâtre"),
]


# GET / et GET /recherche?mot_cle=...&genre=... : le formulaire ET ses résultats
@require_GET
def recherche(request):
    mot_cle = request.GET.get("mot_cle", "")
    genre = request.GET.get("genre", "")
    mot = mot_cle.lower()
    # une liste en COMPRÉHENSION : les livres qui satisfont la condition
    resultats = [livre for livre in LIVRES
                 if (mot == "" or mot in livre.titre.lower() or mot in livre.auteur.lower())
                 and (genre == "" or livre.genre == genre)]
    return render(request, "livres/recherche.html", {
        "modele": RechercheModele(mot_cle, genre, GENRES, resultats),
    })
  • 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

{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Recherche de livres</h1>
  {# method="get" : les champs sont envoyés dans l'URL (?mot_cle=...&genre=...) #}
  <form method="get" action="{% url 'livres_recherche' %}" class="row g-2 align-items-end mb-3">
    <div class="col-auto">
      <label class="form-label" for="mot_cle">Titre ou auteur</label>
      {# value : le champ réaffiche ce qui a été saisi (échappé par {{ }}) #}
      <input class="form-control" id="mot_cle" name="mot_cle" value="{{ modele.mot_cle }}">
    </div>
    <div class="col-auto">
      <label class="form-label" for="genre">Genre</label>
      <select class="form-select" id="genre" name="genre">
        <option value="">(tous)</option>
        {% for genre in modele.genres %}
          {# l'attribut booléen selected, écrit si la condition est vraie, omis sinon #}
          <option value="{{ genre }}"{% if genre == modele.genre %} selected{% endif %}>{{ genre }}</option>
        {% endfor %}
      </select>
    </div>
    <div class="col-auto">
      <button class="btn btn-primary" type="submit">Rechercher</button>
    </div>
  </form>
  <p>{{ modele.resultats|length }} livre(s) trouvé(s).</p>
  <ul>
    {% for livre in modele.resultats %}
      <li><i>{{ livre.titre }}</i>, {{ livre.auteur }} ({{ livre.genre }})</li>
    {% endfor %}
  </ul>
{% endblock %}
  • 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

Image

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

Image

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 :

curl "http://localhost:8000/recherche?mot_cle=le&genre=roman"
...
      <input class="form-control" id="mot_cle" name="mot_cle" value="le">
...
      <select class="form-select" id="genre" name="genre">
        <option value="">(tous)</option>


          <option value="roman" selected>roman</option>


          <option value="poésie">poésie</option>


          <option value="théâtre">théâtre</option>

      </select>
...
  <p>1 livre(s) trouvé(s).</p>
  <ul>

      <li><i>Le Rouge et le Noir</i>, Stendhal (roman)</li>

  </ul>
...

« 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].

18-champs/
├── config/
│   ├── rendu.py
│   ├── settings.py
│   └── ...
├── formulaire/
│   ├── __init__.py
│   ├── forms.py
│   ├── listes.py
│   └── views.py
├── static/
│   └── ...
├── templates/
│   ├── django/
│   │   └── forms/
│   │       └── widgets/
│   │           ├── input_option.html
│   │           └── multiple_input.html
│   ├── formulaire/
│   │   └── formulaire.html
│   ├── formulaires/
│   │   └── champ.html
│   └── layout.html
└── manage.py

4.6.1. Les listes : formulaire/listes.py

1
2
3
4
5
6
7
8
9
# formulaire/listes.py : les listes proposées par le formulaire. Dans une vraie
# application, elles viendraient d'une base de données

# chaque liste : des couples (valeur envoyée, texte affiché)
TITRES = [("Mr", "Mr"), ("Mme", "Mme"), ("Melle", "Melle")]
PAYS = [("fr", "France"), ("be", "Belgique"), ("ch", "Suisse"), ("ca", "Canada")]
LOISIRS = [("lecture", "lecture"), ("musique", "musique"), ("sport", "sport"), ("cinéma", "cinéma")]
LANGUES = [("français", "français"), ("anglais", "anglais"), ("espagnol", "espagnol"),
           ("allemand", "allemand")]

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

# formulaire/forms.py : la DESCRIPTION du formulaire : ses champs, leur type, leur widget

from django import forms

from . import listes


# une classe mère pour nos formulaires : elle donne à chaque zone de saisie la
# classe CSS de Bootstrap qui lui convient
class FormulaireBootstrap(forms.Form):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # fields : les champs du formulaire, dans un dictionnaire {nom: champ}
        for champ in self.fields.values():
            widget = champ.widget
            if isinstance(widget, (forms.RadioSelect, forms.CheckboxSelectMultiple)):
                classe = "form-check-input"
            elif isinstance(widget, forms.Select):
                classe = "form-select"
            else:
                classe = "form-control"
            # attrs : les attributs HTML de la zone de saisie
            widget.attrs["class"] = classe


# les champs du formulaire, dans l'ordre de leur affichage. Le CHAMP (forms.XxxField)
# convertit et valide la valeur ; le WIDGET (forms.XxxInput...) dessine la zone de saisie
class Formulaire(FormulaireBootstrap):
    # boutons radio : une liste de choix, dessinée par le widget RadioSelect ;
    # error_messages : le message de l'erreur "champ obligatoire"
    titre = forms.ChoiceField(choices=listes.TITRES, widget=forms.RadioSelect,
                              error_messages={"required": "Le titre est obligatoire."})
    # une zone de saisie ; required=False : le champ n'est pas obligatoire
    nom = forms.CharField(required=False)
    # un mot de passe : jamais réaffiché dans la page
    mot_de_passe = forms.CharField(label="Mot de passe", required=False, widget=forms.PasswordInput)
    # une date : un champ <input type="date"> ; la valeur est une date Python
    naissance = forms.DateField(label="Date de naissance", required=False,
                                widget=forms.DateInput(attrs={"type": "date"}))
    # cases à cocher : une liste de choix MULTIPLES
    loisirs = forms.MultipleChoiceField(choices=listes.LOISIRS, required=False,
                                        widget=forms.CheckboxSelectMultiple)
    # liste déroulante : le widget par défaut d'un ChoiceField
    pays = forms.ChoiceField(choices=listes.PAYS)
    # liste à sélection multiple, affichée sur quatre lignes
    langues = forms.MultipleChoiceField(label="Langues parlées", choices=listes.LANGUES,
                                        required=False,
                                        widget=forms.SelectMultiple(attrs={"size": 4}))
    commentaire = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 2}))
    # un champ caché
    jeton = forms.CharField(widget=forms.HiddenInput)
  • 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) :

# config/rendu.py : le moteur de rendu des formulaires

from django.forms.renderers import TemplatesSetting


# TemplatesSetting : un moteur de rendu qui cherche ses gabarits comme les autres
# gabarits du projet (réglage TEMPLATES) : un gabarit du dossier templates du projet
# peut donc remplacer celui de Django
class RenduBootstrap(TemplatesSetting):
    # le gabarit d'un champ complet : libellé, zone de saisie, aide, erreurs
    field_template_name = "formulaires/champ.html"
  • 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 :

# config/settings.py : la configuration de l'exemple 18

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-b13ls-fk@*cj%i9(=5e1aa_eg(j0&*2cs)oc=j+qo&(!v06l^y"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    # les gabarits des formulaires de Django (un gabarit par sorte de champ)
    "django.forms",
    "formulaire",
]

MIDDLEWARE = [
    # la protection anti-CSRF de TOUTES les requêtes POST (cf. plus loin)
    "django.middleware.csrf.CsrfViewMiddleware",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        # APP_DIRS : Django cherche aussi les gabarits dans le dossier templates de
        # chaque application, dont django.forms
        "APP_DIRS": True,
    },
]

# le MOTEUR DE RENDU des formulaires : le nôtre, qui dessine les champs avec les
# classes de Bootstrap 5 (cf. config/rendu.py)
FORM_RENDERER = "config.rendu.RenduBootstrap"

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "commun" / "static",
]
  • 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 :

{# un champ complet, avec les classes de Bootstrap ; field : le champ à dessiner #}
{% if field.use_fieldset %}
  {# un groupe de boutons radio ou de cases à cocher : un fieldset et sa légende #}
  <fieldset class="mb-3">
    <legend class="form-label fs-6 mb-1">{{ field.label }}</legend>
    {{ field }}
{% else %}
  <div class="mb-3">
    <label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
    {{ field }}
{% endif %}
    {% if field.help_text %}<div class="form-text" id="{{ field.auto_id }}_helptext">{{ field.help_text }}</div>{% endif %}
    {% if field.errors %}<div class="invalid-feedback d-block" id="{{ field.auto_id }}_error">{% for erreur in field.errors %}{{ erreur }}{% if not forloop.last %}<br>{% endif %}{% endfor %}</div>{% endif %}
{% if field.use_fieldset %}</fieldset>{% else %}</div>{% endif %}

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 :

1
2
3
4
{# remplace le gabarit de Django : un groupe de boutons radio ou de cases à cocher, côte à côte #}
<div id="{{ widget.attrs.id }}">{% for group, options, index in widget.optgroups %}{% for option in options %}
  <div class="form-check form-check-inline">{% include option.template_name with widget=option %}</div>{% endfor %}{% endfor %}
</div>
1
2
3
{# remplace le gabarit de Django : UN bouton radio ou UNE case à cocher, suivi de son libellé #}
{% include "django/forms/widgets/input.html" %}
<label class="form-check-label" for="{{ widget.attrs.id }}">{{ widget.label }}</label>

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

# formulaire/views.py : un formulaire qui utilise toutes les sortes de champs

from django.shortcuts import render
from django.views.decorators.http import require_http_methods

from .forms import Formulaire

# les valeurs initiales (une par champ, une liste pour un champ multiple)
INITIALES = {"titre": "Mme", "pays": "fr", "langues": ["français"], "jeton": "A7X2"}


# GET : le formulaire, avec des valeurs initiales ; POST : les champs envoyés sont
# lus, validés, puis réaffichés par le même formulaire
@require_http_methods(["GET", "POST"])
def formulaire(request):
    if request.method == "GET":
        # initial : les valeurs affichées tant que rien n'a été envoyé
        return render(request, "formulaire/formulaire.html",
                      {"formulaire": Formulaire(initial=INITIALES)})
    # un formulaire LIÉ aux données envoyées : il les convertit et les valide
    formulaire = Formulaire(request.POST)
    valide = formulaire.is_valid()
    donnees = None
    if valide:
        # cleaned_data : les valeurs converties par chaque champ ; une liste (champ
        # multiple) est écrite sous forme de texte, ses valeurs séparées par des virgules
        donnees = {cle: ", ".join(valeur) if isinstance(valeur, list) else valeur
                   for cle, valeur in formulaire.cleaned_data.items()}
    # ce que le navigateur a réellement envoyé, champ par champ ; lists() : les
    # couples (nom, liste de toutes les valeurs du champ)
    recu = "\n".join(f"{nom} = [{', '.join(valeurs)}]" for nom, valeurs in request.POST.lists())
    return render(request, "formulaire/formulaire.html", {
        "formulaire": formulaire,
        "recu": recu,
        "valide": valide,
        "donnees": donnees,
    })
  • 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

{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Un formulaire complet</h1>
  <form method="post" action="{% url 'formulaire' %}" style="max-width: 40rem">
    {# le jeton anti-CSRF : un champ caché, que le middleware CsrfViewMiddleware vérifie #}
    {% csrf_token %}
    {# as_field_group : un champ complet (libellé, zone de saisie, erreurs) #}
    {{ formulaire.titre.as_field_group }}
    <div class="row g-2">
      <div class="col">{{ formulaire.nom.as_field_group }}</div>
      <div class="col">{{ formulaire.mot_de_passe.as_field_group }}</div>
      <div class="col">{{ formulaire.naissance.as_field_group }}</div>
    </div>
    {{ formulaire.loisirs.as_field_group }}
    <div class="row g-2">
      <div class="col">{{ formulaire.pays.as_field_group }}</div>
      <div class="col">{{ formulaire.langues.as_field_group }}</div>
    </div>
    {{ formulaire.commentaire.as_field_group }}
    {# un champ caché : la zone de saisie seule, sans libellé #}
    {{ formulaire.jeton }}
    <button class="btn btn-primary" type="submit">Envoyer</button>
  </form>
  {# recu : présent dans le contexte si le formulaire a été envoyé #}
  {% if recu %}
    <h2 class="h5 mt-3">Ce que le serveur a reçu</h2>
    <pre class="bg-light p-2 border">{{ recu }}</pre>
    <p>Formulaire valide : {{ valide|yesno:"oui,non" }}</p>
    <ul>
      {# errors : un dictionnaire {champ: liste des erreurs} ; items : ses couples #}
      {% for champ, erreurs in formulaire.errors.items %}
        {% for erreur in erreurs %}<li>{{ champ }} : {{ erreur }}</li>{% endfor %}
      {% endfor %}
    </ul>
    {% if valide %}
      <p>Les données du formulaire :</p>
      <ul>
        {% for cle, valeur in donnees.items %}
          <li>{{ cle }} : {{ valeur }}</li>
        {% endfor %}
      </ul>
    {% endif %}
  {% endif %}
{% endblock %}
  • 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

Image

Les valeurs initiales sont visibles : le bouton radio « Mme », le pays « France », la langue « français ». Le HTML produit (extraits) :

<form method="post" action="/formulaire" style="max-width: 40rem">
    <input type="hidden" name="csrfmiddlewaretoken" value="nz41EEOJgdJJDxCYFtTWLl9AEEBdxQmnR6Rs8F8f0Xvr10ayE7RJproG7YEWvhWi">
    <fieldset class="mb-3">
    <legend class="form-label fs-6 mb-1">Titre</legend>
    <div id="id_titre">
  <div class="form-check form-check-inline">
<input type="radio" name="titre" value="Mr" class="form-check-input" required id="id_titre_0">
<label class="form-check-label" for="id_titre_0">Mr</label>
</div>
  <div class="form-check form-check-inline">
<input type="radio" name="titre" value="Mme" class="form-check-input" required id="id_titre_1" checked>
<label class="form-check-label" for="id_titre_1">Mme</label>
</div>
...
<input type="checkbox" name="loisirs" value="lecture" class="form-check-input" id="id_loisirs_0">
...
    <select name="langues" size="4" class="form-select" id="id_langues" multiple>
  <option value="français" selected>français</option>
  <option value="anglais">anglais</option>
...
    <input type="hidden" name="jeton" value="A7X2" class="form-control" id="id_jeton">
    <button class="btn btn-primary" type="submit">Envoyer</button>
  </form>

[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 :

Image

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é :

curl -i -d "nom=Martin&pays=xx" http://localhost:8000/formulaire
1
2
3
4
5
6
7
8
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html lang="en">
...
  <title>403 Forbidden</title>
...

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 :

Forbidden (CSRF cookie not set.): /formulaire
[27/Sep/2026 19:30:21] "POST /formulaire HTTP/1.1" 403 2567

Dans un navigateur, la page d'erreur de [Django] explique la raison du refus (en mode debug) :

Image

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 :

curl -c cookies.txt http://localhost:8000/formulaire
curl -b cookies.txt -d "csrfmiddlewaretoken=VHhabRgR...GKF&nom=Martin&pays=xx&jeton=A7X2" http://localhost:8000/formulaire
...
    <pre class="bg-light p-2 border">csrfmiddlewaretoken = [VHhabRgRYFWR4Y5STU7Cto4curX1DysB8tDcThyhCVZZbHeGwk3l3pjeMmAGHVKF]
nom = [Martin]
pays = [xx]
jeton = [A7X2]</pre>
    <p>Formulaire valide : non</p>
    <ul>


        <li>titre : Le titre est obligatoire.</li>

        <li>pays : Sélectionnez un choix valide. xx n’en fait pas partie.</li>

    </ul>
...

(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.

19-validation-formulaire/
├── config/
│   ├── rendu.py
│   ├── settings.py
│   └── ...
├── inscription/
│   ├── __init__.py
│   ├── forms.py
│   └── views.py
├── static/
│   └── ...
├── templates/
│   ├── formulaires/
│   │   └── champ.html
│   ├── inscription/
│   │   ├── bienvenue.html
│   │   └── inscription.html
│   └── layout.html
└── manage.py

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

# inscription/forms.py : le formulaire d'inscription, ses champs et leurs contraintes

from django import forms


# la classe mère de l'exemple 18 : les classes CSS de Bootstrap
class FormulaireBootstrap(forms.Form):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for champ in self.fields.values():
            champ.widget.attrs["class"] = "form-control"


class InscriptionForm(FormulaireBootstrap):
    # max_length : une contrainte vérifiée par le serveur, que Django écrit aussi dans
    # la page (attribut maxlength), pour le navigateur
    nom = forms.CharField(max_length=20, error_messages={
        "required": "Le nom est obligatoire.",
        "max_length": "Le nom ne doit pas dépasser 20 caractères."})
    # EmailField : une adresse valide, avec un nom de domaine complet (exemple.fr, et non exemple)
    email = forms.EmailField(label="Adresse électronique", error_messages={
        "required": "L'adresse électronique est obligatoire.",
        "invalid": "Adresse électronique invalide."})
    # IntegerField : un nombre entier ; une saisie qui n'en est pas un produit l'erreur invalid
    age = forms.IntegerField(label="Âge", min_value=18, max_value=120, error_messages={
        "required": "L'âge est obligatoire.",
        "invalid": "Un nombre entier est attendu.",
        "min_value": "L'âge doit être compris entre 18 et 120 ans.",
        "max_value": "L'âge doit être compris entre 18 et 120 ans."})
    mot_de_passe = forms.CharField(label="Mot de passe", min_length=6, widget=forms.PasswordInput,
                                   error_messages={
                                       "required": "Le mot de passe est obligatoire.",
                                       "min_length": "Le mot de passe doit comporter au moins 6 caractères."})
    confirmation = forms.CharField(label="Confirmation", widget=forms.PasswordInput,
                                   error_messages={"required": "La confirmation est obligatoire."})

    # clean : la validation du formulaire ENTIER, après celle de chaque champ. C'est
    # ici que l'on compare deux champs
    def clean(self):
        donnees = super().clean()
        mot_de_passe = donnees.get("mot_de_passe")
        confirmation = donnees.get("confirmation")
        # les deux champs ont passé leur propre validation, mais diffèrent
        if mot_de_passe and confirmation and mot_de_passe != confirmation:
            # add_error : une erreur, attachée au champ confirmation
            self.add_error("confirmation", "La confirmation ne correspond pas au mot de passe.")
        return donnees
  • 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

# inscription/views.py : un formulaire validé par le serveur

from django.shortcuts import render
from django.views.decorators.http import require_http_methods

from .forms import InscriptionForm


# GET /inscription : le formulaire ; ?html5=true : on laisse le navigateur le valider
# avant l'envoi. POST /inscription : le formulaire envoyé
@require_http_methods(["GET", "POST"])
def inscription(request):
    html5 = request.GET.get("html5") == "true"
    if request.method == "POST":
        formulaire = InscriptionForm(request.POST)
        # is_valid : chaque champ a pu être converti et respecte ses contraintes, et
        # la méthode clean n'a pas signalé d'erreur
        if formulaire.is_valid():
            return render(request, "inscription/bienvenue.html",
                          {"inscription": formulaire.cleaned_data})
        # un formulaire invalide, réaffiché avec la saisie et les erreurs, avec le
        # code 422 (Unprocessable Content)
        return render(request, "inscription/inscription.html",
                      {"formulaire": formulaire, "html5": html5}, status=422)
    # le premier affichage : un formulaire vide
    return render(request, "inscription/inscription.html",
                  {"formulaire": InscriptionForm(), "html5": html5})
  • 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

{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Inscription</h1>
  {% if html5 %}
    <p>Validation par le navigateur <b>activée</b> - <a href="{% url 'inscription' %}">la désactiver</a></p>
  {% else %}
    <p>Validation par le navigateur <b>désactivée</b> - <a href="{% url 'inscription' %}?html5=true">l'activer</a></p>
  {% endif %}
  {# novalidate : le navigateur ignore les attributs required, min... et envoie le formulaire tel quel #}
  <form method="post" {% if not html5 %}novalidate{% endif %}>
    {% csrf_token %}
    {# une boucle sur le formulaire : ses champs, chacun avec son libellé et ses erreurs #}
    {% for champ in formulaire %}
      {{ champ.as_field_group }}
    {% endfor %}
    <button class="btn btn-primary" type="submit">S'inscrire</button>
  </form>
{% endblock %}

{% block bas_de_page %}
  {# errors : les erreurs du formulaire ; vide avant l'envoi, ou si tout est valide #}
  {% if formulaire.errors %}
    <div class="zone-erreur" role="alert">Le formulaire contient des erreurs.</div>
  {% endif %}
{% endblock %}
  • 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.
<!doctype html>
{% load static %}
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Inscription</title>
  <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
  <link rel="stylesheet" href="{% static 'css/styles.css' %}">
</head>
<body>
  <div class="container py-3" style="max-width: 34rem">
    <main>
      {% block contenu %}{% endblock %}
    </main>
    {# un second bloc, en bas de page #}
    {% block bas_de_page %}{% endblock %}
  </div>
</body>
</html>
  • ligne 17 : un second bloc, [bas_de_page], vide par défaut : un gabarit peut avoir autant de blocs qu'il en faut.
1
2
3
4
5
6
7
8
{% extends "layout.html" %}

{% block contenu %}
  {# inscription : les données validées du formulaire (cleaned_data) #}
  <h1 class="h3">Bienvenue {{ inscription.nom }}</h1>
  <p>Votre inscription est enregistrée : adresse <b>{{ inscription.email }}</b>,
    âge <b>{{ inscription.age }}</b> ans.</p>
{% endblock %}

[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 :

Image

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 :

curl -i -b cookies.txt -d "csrfmiddlewaretoken=...&nom=Martin&email=jean.martin@exemple.fr&age=abc&mot_de_passe=secret123&confirmation=secret123" http://localhost:8000/inscription
1
2
3
4
5
6
HTTP/1.1 422 Unprocessable Content
...
    <label class="form-label" for="id_age">Âge</label>
    <input type="number" name="age" value="abc" min="18" max="120" class="form-control" required aria-invalid="true" aria-describedby="id_age_error" id="id_age">
    <div class="invalid-feedback d-block" id="id_age_error">Un nombre entier est attendu.</div>
...

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 :

Image

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

# config/settings.py : la configuration de l'exemple 20

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-a%lgt@s)1dxed4be^y25kc))1tt@7_%yytqt!xgh_v4=485(+k"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    # les MESSAGES FLASH : des messages déposés par une requête, affichés par la suivante
    "django.contrib.messages",
    "taches",
]

MIDDLEWARE = [
    "django.middleware.csrf.CsrfViewMiddleware",
    # le middleware des messages flash : il les lit en début de requête, et les
    # range en fin de requête
    "django.contrib.messages.middleware.MessageMiddleware",
]

# où sont conservés les messages flash entre deux requêtes : dans un COOKIE (cf. le
# cours, "Où sont conservés les messages flash ?")
MESSAGE_STORAGE = "django.contrib.messages.storage.cookie.CookieStorage"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "OPTIONS": {
            "context_processors": [
                # la variable messages : les messages flash à afficher
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "commun" / "static",
]
  • 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

# taches/service.py : la liste des tâches, partagée par tous les utilisateurs

import threading

# le processus du serveur reste en mémoire d'une requête à l'autre : une variable du
# MODULE vit aussi longtemps que lui. La liste est donc partagée par toutes les
# requêtes de tous les utilisateurs (et perdue quand le serveur s'arrête)
_taches = ["Lire le chapitre 3"]

# le serveur traite plusieurs requêtes EN MÊME TEMPS, dans plusieurs fils d'exécution
# (threads) : un verrou garantit qu'un seul à la fois modifie la liste
_verrou = threading.Lock()


def liste() -> list[str]:
    # une copie : l'appelant ne peut pas modifier la liste du service
    with _verrou:
        return list(_taches)


def ajouter(libelle: str) -> None:
    # with : le verrou est pris ici, et rendu à la sortie du bloc, même après une exception
    with _verrou:
        _taches.append(libelle)

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

# taches/views.py : le schéma Post / Redirect / Get

from django.contrib import messages
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods, require_POST

from . import service


# GET /taches : la liste et le formulaire d'ajout ; POST /taches : l'ajout, selon le
# schéma Post / Redirect / Get
@require_http_methods(["GET", "POST"])
def taches(request):
    if request.method == "GET":
        return render(request, "taches/taches.html", {"taches": service.liste()})
    libelle = request.POST.get("libelle", "").strip()
    if libelle == "" or len(libelle) > 40:
        # saisie invalide : on réaffiche la page, sans redirection
        return render(request, "taches/taches.html", {
            "taches": service.liste(),
            "libelle": libelle,
            "erreur": "Le libellé doit comporter de 1 à 40 caractères."})
    service.ajouter(libelle)
    # un message "flash" : il survivra à la redirection, pour la requête SUIVANTE
    messages.success(request, f"La tâche [{libelle}] a été ajoutée.")
    # pas de page : une redirection vers la page GET
    return redirect("taches")


# POST /taches/sans-prg : le contre-exemple (une page en réponse au POST)
@require_POST
def ajouter_sans_prg(request):
    service.ajouter(request.POST.get("libelle", "").strip() or "(sans titre)")
    return render(request, "taches/taches.html", {"taches": service.liste(), "sans_prg": True})
  • 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

<!doctype html>
{% load static %}
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tâches</title>
  <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
  <link rel="stylesheet" href="{% static 'css/styles.css' %}">
</head>
<body>
  <div class="container py-3" style="max-width: 40rem">
    {# les messages flash : lus une fois, ils disparaissent #}
    {% for message in messages %}
      <div class="alert alert-success" role="status">{{ message }}</div>
    {% endfor %}
    <main>
      {% block contenu %}{% endblock %}
    </main>
  </div>
</body>
</html>
  • 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.
{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Mes tâches</h1>
  <ol>
    {% for tache in taches %}
      <li>{{ tache }}</li>
    {% endfor %}
  </ol>
  <form method="post" action="{% url 'taches' %}" class="d-flex gap-2">
    {% csrf_token %}
    <input class="form-control" name="libelle" value="{{ libelle }}" placeholder="Nouvelle tâche">
    <button class="btn btn-primary text-nowrap" type="submit">Ajouter</button>
    {# formaction : ce bouton envoie le MÊME formulaire à une autre adresse #}
    <button class="btn btn-outline-secondary text-nowrap" type="submit" formaction="{% url 'taches_sans_prg' %}">Ajouter sans PRG</button>
  </form>
  {% if erreur %}
    <div class="zone-erreur" role="alert">{{ erreur }}</div>
  {% endif %}
  {% if sans_prg %}
    <p class="text-danger mt-2">Page affichée en réponse à un POST : appuyez sur F5 et la tâche sera ajoutée une seconde fois.</p>
  {% endif %}
{% endblock %}
  • 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 :

curl -i -b cookies.txt -c cookies.txt -d "csrfmiddlewaretoken=...&libelle=Faire%20les%20exercices" http://localhost:8000/taches
1
2
3
4
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /taches
Set-Cookie: messages=.eJyLjlaKj88qzs-Lz00tLk5MT1XSMdAxMtVR8klUKIkpNTBINUrOSFWIdkvMLEpVyEktVkitSC1KzkxOLY5VSFQAq7CEKLRUSMzKL4WyU_WUdJSUYmMB-SAezw:1xAsin:eyFszHLnZB1gbIkSumQ9Au2Fcc-M4jZeI-3gHV-o6uI; HttpOnly; Path=/; SameSite=Lax

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 :

[["__json_message",0,25,"La tâche [Faire les exercices] a été ajoutée.",""]]

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 :

curl -i -b cookies.txt -c cookies.txt http://localhost:8000/taches
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Vary: Cookie
Set-Cookie: messages=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; SameSite=Lax
...
      <div class="alert alert-success" role="status">La tâche [Faire les exercices] a été ajoutée.</div>
...
      <li>Lire le chapitre 3</li>

      <li>Faire les exercices</li>
...

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.

Image

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

Image

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.

Image

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.

21-liens-boutons/
├── config/
│   ├── middleware.py
│   ├── rendu.py
│   ├── settings.py
│   └── ...
├── contacts/
│   ├── __init__.py
│   ├── forms.py
│   ├── modeles.py
│   ├── service.py
│   └── views.py
├── static/
│   ├── css/
│   │   └── styles.css
│   └── js/
│       └── app.js
├── templates/
│   ├── contacts/
│   │   ├── fiche.html
│   │   └── liste.html
│   ├── formulaires/
│   │   └── champ.html
│   ├── fragments/
│   │   └── confirmation.html
│   └── layout.html
└── manage.py

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 :

# config/middleware.py : la méthode HTTP d'un formulaire, remplacée par son champ _method

# un formulaire HTML ne sait envoyer que GET et POST. Un formulaire POST qui contient un
# champ caché _method (DELETE, PUT, PATCH) est traité comme une requête de cette méthode
class MethodeSurchargee:
    METHODES = {"DELETE", "PUT", "PATCH"}

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        return self.get_response(request)

    # process_view : Django l'appelle APRÈS le routage, juste avant la vue (et après le
    # process_view des middlewares qui précèdent celui-ci, dont celui qui vérifie le
    # jeton anti-CSRF d'un POST). Rendre None : la requête poursuit son chemin
    def process_view(self, request, view_func, view_args, view_kwargs):
        if request.method == "POST":
            methode = request.POST.get("_method", "").upper()
            if methode in self.METHODES:
                request.method = methode
        return None
  • 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] :

# config/settings.py : la configuration de l'exemple 21

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-0c(8)1yo+s15vc6372!f@+0f@jpe2n+3)g34z*%s=h@ikzos9a"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    "django.contrib.messages",
    "django.forms",
    "contacts",
]

# l'ORDRE compte : la méthode HTTP n'est remplacée (MethodeSurchargee) qu'APRÈS la
# vérification du jeton anti-CSRF (cf. config/middleware.py)
MIDDLEWARE = [
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "config.middleware.MethodeSurchargee",
]

MESSAGE_STORAGE = "django.contrib.messages.storage.cookie.CookieStorage"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

FORM_RENDERER = "config.rendu.RenduBootstrap"

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "commun" / "static",
]
  • 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

# contacts/service.py : les contacts, partagés par tous, en mémoire (cf. exemple 20)

import threading

from .modeles import Contact

_contacts = [
    Contact(1, "Marie Durand", "01 23 45 67 89"),
    Contact(2, "Paul Martin", "06 12 34 56 78"),
    Contact(3, "Philippe Jandot", "02 98 76 54 32"),
]
_dernier_id = 3
_verrou = threading.Lock()


def liste() -> list[Contact]:
    with _verrou:
        return list(_contacts)


# le contact, ou None s'il n'existe pas
def lire(id: int) -> Contact | None:
    with _verrou:
        return next((c for c in _contacts if c.id == id), None)


def modifier(id: int, nom: str, telephone: str) -> Contact | None:
    with _verrou:
        # enumerate : les couples (position, élément) de la liste
        for i, contact in enumerate(_contacts):
            if contact.id == id:
                # un contact est immuable (frozen) : on en fabrique un nouveau, qui
                # remplace l'ancien dans la liste
                _contacts[i] = Contact(id, nom, telephone)
                return _contacts[i]
        return None


def ajouter(nom: str, telephone: str) -> Contact:
    # global : la fonction modifie la variable _dernier_id du module (sans ce mot,
    # l'affectation créerait une variable locale)
    global _dernier_id
    with _verrou:
        _dernier_id += 1
        contact = Contact(_dernier_id, nom, telephone)
        _contacts.append(contact)
        return contact


def supprimer(id: int) -> Contact | None:
    with _verrou:
        for i, contact in enumerate(_contacts):
            if contact.id == id:
                # pop retire l'élément i de la liste, et le rend
                return _contacts.pop(i)
        return None

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

1
2
3
4
5
6
7
8
# contacts/forms.py : le formulaire de la fiche d'un contact

from django import forms


class ContactForm(forms.Form):
    nom = forms.CharField(widget=forms.TextInput(attrs={"class": "form-control"}))
    telephone = forms.CharField(label="Téléphone", widget=forms.TextInput(attrs={"class": "form-control"}))

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

# contacts/views.py : liens (GET) et boutons (POST, DELETE)

from django.contrib import messages
from django.http import Http404
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET, require_http_methods

from . import service
from .forms import ContactForm


# le contact, ou une réponse 404 s'il n'existe pas
def contact_ou_404(contact):
    if contact is None:
        raise Http404("contact inconnu")
    return contact


# un LIEN (<a href>) : une requête GET, qui ne modifie rien
@require_GET
def liste(request):
    return render(request, "contacts/liste.html", {"contacts": service.liste()})


# GET /contacts/2 : la fiche ; POST /contacts/2 : l'un de ses deux boutons ;
# DELETE /contacts/2 : la suppression (un POST et son champ _method)
@require_http_methods(["GET", "POST", "DELETE"])
def contact(request, id):
    if request.method == "DELETE":
        return supprimer(request, id)
    contact = contact_ou_404(service.lire(id))
    if request.method == "GET":
        formulaire = ContactForm(initial={"nom": contact.nom, "telephone": contact.telephone})
        return render(request, "contacts/fiche.html", {"contact": contact, "formulaire": formulaire})
    formulaire = ContactForm(request.POST)
    if not formulaire.is_valid():
        return render(request, "contacts/fiche.html", {"contact": contact, "formulaire": formulaire})
    nom = formulaire.cleaned_data["nom"]
    telephone = formulaire.cleaned_data["telephone"]
    # le bouton cliqué est le seul à envoyer son nom : "dupliquer" in request.POST
    # est vrai si c'est lui qui a envoyé le formulaire
    if "dupliquer" in request.POST:
        copie = service.ajouter(f"{nom} (copie)", telephone)
        messages.success(request, f"Le contact [{copie.nom}] a été créé.")
    else:
        service.modifier(id, nom, telephone)
        messages.success(request, f"Le contact [{nom}] a été modifié.")
    return redirect("contacts")


# la suppression ; le jeton anti-CSRF a déjà été vérifié par CsrfViewMiddleware
def supprimer(request, id):
    contact = contact_ou_404(service.supprimer(id))
    messages.success(request, f"Le contact [{contact.nom}] a été supprimé.")
    return redirect("contacts")
  • 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

{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Contacts</h1>
  <table class="table align-middle">
    <tbody>
      {% for contact in contacts %}
        <tr>
          <td>{{ contact.nom }}</td>
          <td>{{ contact.telephone }}</td>
          <td class="text-end">
            {# consulter : un simple lien ; url place l'id dans le chemin : /contacts/2 #}
            <a class="btn btn-sm btn-outline-primary" href="{% url 'contacts_fiche' contact.id %}">Voir</a>
            {# supprimer : un formulaire, dont l'envoi sera confirmé #}
            <form method="post" class="d-inline" action="{% url 'contacts_fiche' contact.id %}"
                  data-confirmation="Supprimer le contact {{ contact.nom }} ?">
              {% csrf_token %}
              {# la méthode DELETE, qu'un formulaire HTML ne sait pas envoyer #}
              <input type="hidden" name="_method" value="DELETE">
              <button class="btn btn-sm btn-outline-danger" type="submit">Supprimer</button>
            </form>
          </td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}
  • 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.
{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Contact n° {{ contact.id }}</h1>
  <form method="post">
    {% csrf_token %}
    {{ formulaire.nom.as_field_group }}
    {{ formulaire.telephone.as_field_group }}
    {# deux boutons d'envoi pour un même formulaire ; name : le nom du bouton, envoyé s'il est cliqué #}
    <div class="d-flex gap-2">
      <button class="btn btn-primary" type="submit" name="enregistrer">Enregistrer</button>
      <button class="btn btn-outline-primary" type="submit" name="dupliquer">Dupliquer</button>
      <a class="btn btn-link" href="{% url 'contacts' %}">Retour à la liste</a>
    </div>
  </form>
{% endblock %}
  • 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 :

{# une seule fenêtre pour toute la page, cachée (d-none) au départ #}
<div id="modale-confirmation" class="d-none">
  <div class="modal-backdrop fade show"></div>
  <div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title">Confirmation</h5>
        </div>
        <div class="modal-body">
          <p class="mb-0" id="modale-confirmation-message"></p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-action="non">Non, revenir</button>
          <button type="button" class="btn btn-danger" data-action="oui">Oui, confirmer</button>
        </div>
      </div>
    </div>
  </div>
</div>
  • 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].
<!doctype html>
{% load static %}
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Contacts</title>
  <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
  <link rel="stylesheet" href="{% static 'css/styles.css' %}">
</head>
<body>
  <div class="container py-3" style="max-width: 40rem">
    {% for message in messages %}
      <div class="alert alert-success" role="status">{{ message }}</div>
    {% endfor %}
    <main>
      {% block contenu %}{% endblock %}
    </main>
  </div>
  {# la fenêtre de confirmation, présente dans toutes les pages #}
  {% include "fragments/confirmation.html" %}
  {# le script, en fin de page : les éléments qu'il cherche existent déjà #}
  <script src="{% static 'js/app.js' %}"></script>
</body>
</html>
  • 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] :

// confirmation des formulaires portant l'attribut data-confirmation="..."
// Sans JavaScript, ces formulaires partent simplement sans confirmation.
(() => {
  const modale = document.getElementById('modale-confirmation');
  const message = document.getElementById('modale-confirmation-message');
  let enAttente = null; // le formulaire à envoyer si l'on confirme

  // 1. on intercepte l'envoi des formulaires à confirmer
  document.querySelectorAll('form[data-confirmation]').forEach((formulaire) => {
    formulaire.addEventListener('submit', (evenement) => {
      evenement.preventDefault(); // l'envoi est suspendu
      enAttente = formulaire;
      message.textContent = formulaire.dataset.confirmation;
      modale.classList.remove('d-none'); // la fenêtre apparaît
    });
  });

  // 2. les deux boutons de la fenêtre
  modale.addEventListener('click', (evenement) => {
    const action = evenement.target.dataset.action;
    if (action === 'oui') {
      enAttente.submit(); // envoi, SANS redéclencher l'événement 'submit'
    } else if (action === 'non') {
      modale.classList.add('d-none');
      enAttente = null;
    }
  });
})();
  • 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 :

Image

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

Image

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

Image

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

Image

Une requête sans jeton anti-CSRF est refusée, et un contact inexistant donne une erreur 404 :

curl -i -X POST -d "_method=DELETE" http://localhost:8000/contacts/3
curl -i http://localhost:8000/contacts/9
1
2
3
4
5
6
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8
...
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
...

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 ».