Skip to content

9. Architecture en couches et accès aux données

Les exemples précédents gardaient leurs données en mémoire. Une vraie application les garde dans une base de données, et s'organise en couches (cf. schéma de l'introduction) :

  • la couche web (vues, formulaires, gabarits) : lire la requête, appeler la couche métier, choisir le gabarit ;
  • la couche métier : les règles de gestion ;
  • la couche DAO (Data Access Object) : les requêtes à la base de données.

Chaque couche ne connaît que la couche du dessous, et le moins possible de ses détails : la couche web ignore les requêtes, la couche métier ignore le HTML. Cette séparation rend chaque couche plus simple à comprendre, à tester et à faire évoluer. L'exemple 28 l'applique à la gestion d'une liste de personnes : c'est, en plus petit, la gestion des médecins et des clients de l'étude de cas.

En [Python], l'accès à une base de données repose sur plusieurs étages :

  • la norme DB-API 2.0 (PEP 249) de [Python] définit une interface commune à tous les SGBD : ouvrir une connexion, obtenir un curseur, exécuter un ordre SQL, lire les lignes. Chaque SGBD a son pilote : pour [MySQL] (et [MariaDB]), [Django] recommande [mysqlclient], une bibliothèque écrite en C, installée par le fichier [requirements.txt] des exemples ;
  • l'ORM (Object-Relational Mapper) de [Django] fait correspondre des classes, les modèles, aux tables de la base, et transforme les lignes lues en objets. Il gère les connexions et les transactions, fabrique lui-même les ordres SQL, adaptés à chaque SGBD, et traduit les erreurs de chaque pilote en exceptions communes (une violation de contrainte d'unicité devient une [IntegrityError], quel que soit le SGBD). Il suit le modèle dit Active Record : chaque objet sait s'enregistrer ([personne.save()]) et se supprimer ([personne.delete()]), et chaque classe a un gestionnaire ([Personne.objects]) d'où partent les requêtes. C'est le modèle d'[Eloquent], l'ORM de [Laravel] ; [Doctrine], l'ORM de [Symfony], suivait au contraire le modèle Data Mapper, où un objet à part lit et enregistre les entités.

L'ORM de [Django] fait partie de [Django], et c'est l'une de ses pièces maîtresses : l'interface d'administration, l'application d'authentification, les sessions en base, les vues génériques reposent sur lui. C'est l'équivalent de [JPA] et [Hibernate] en [Java], d'[Entity Framework Core] en [C#], ou de [SQLAlchemy] (utilisé avec [Flask]) en [Python]. Remarquons une conséquence du fonctionnement de [Django] : par défaut, chaque requête ouvre sa connexion à la base (à sa première requête SQL), et la ferme en se terminant (sur le signal [request_finished], exemple 24). Le réglage [CONN_MAX_AGE] permet de garder les connexions ouvertes d'une requête à l'autre (des connexions persistantes), et [Django] 5.1 a ajouté, pour [PostgreSQL], un véritable pool de connexions. Ouvrir une connexion à [MySQL] sur la même machine ne prend d'ailleurs que quelques millisecondes.

L'exemple nécessite un serveur [MySQL] 8.4 ou plus récent (ou [MariaDB] 10.11 ou plus récent, les versions minimales de [Django] 6.1) en fonctionnement : l'annexe explique comment en installer un avec [Laragon].

9.1. Exemple 28 : une gestion de personnes en trois couches

28-couches/
├── config/
│   ├── middleware.py
│   ├── rendu.py
│   ├── settings.py
│   ├── urls.py
│   └── ...
├── database/
│   └── dbpersonnes.sql
├── personnes/
│   ├── __init__.py
│   ├── dao.py
│   ├── forms.py
│   ├── metier.py
│   ├── models.py
│   ├── urls.py
│   └── views.py
├── static/
│   ├── css/
│   │   └── styles.css
│   └── js/
│       └── app.js
├── templates/
│   ├── formulaires/
│   │   └── champ.html
│   ├── fragments/
│   │   └── confirmation.html
│   ├── personnes/
│   │   ├── formulaire.html
│   │   └── liste.html
│   └── layout.html
└── manage.py

9.1.1. La base de données

La base [dbpersonnes] est créée par un script SQL. Avec [Laragon], on l'exécute par exemple dans l'outil [HeidiSQL] (menu Fichier / Exécuter un fichier SQL), ou en ligne de commande, depuis le dossier de l'exemple :

mysql -u root < database/dbpersonnes.sql

Le fichier [database/dbpersonnes.sql] :

-- base de l'exemple 28 : une seule table, [personnes]
-- le script est écrit en UTF-8
SET NAMES utf8mb4;
DROP DATABASE IF EXISTS dbpersonnes;
CREATE DATABASE dbpersonnes CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbpersonnes;

CREATE TABLE personnes (
  id INT NOT NULL AUTO_INCREMENT,
  -- la version de la ligne, augmentée à chaque modification (cf. couche DAO)
  version INT NOT NULL DEFAULT 0,
  prenom VARCHAR(30) NOT NULL,
  nom VARCHAR(30) NOT NULL,
  age INT NOT NULL,
  PRIMARY KEY (id),
  -- deux personnes ne peuvent pas avoir les mêmes prénom et nom
  CONSTRAINT unq_personnes_prenom_nom UNIQUE (prenom, nom)
) ENGINE = InnoDB;

INSERT INTO personnes (prenom, nom, age) VALUES
  ('Marie', 'Pélissier', 42),
  ('Jacques', 'Bromard', 35),
  ('Philippe', 'Jandot', 58);
  • ligne 3 : le script est écrit en [UTF-8] : on l'indique au serveur, pour que les accents des données ([Pélissier]) arrivent intacts ;
  • ligne 5 : le jeu de caractères [utf8mb4] accepte tous les caractères (accents, emojis...). La collation [utf8mb4_unicode_ci] compare les chaînes sans tenir compte des majuscules ni des accents : pour [MySQL], [pelissier] et [Pélissier] sont égaux ;
  • ligne 11 : un numéro de version, augmenté à chaque modification de la ligne (cf. verrou optimiste, plus bas) ;
  • ligne 17 : une contrainte d'unicité nommée : la base refuse deux personnes de mêmes prénom et nom.

[Django] sait aussi créer les tables lui-même, à partir des modèles, par des migrations : des fichiers [Python] qui décrivent, étape par étape, la création et les modifications de chaque table. La commande [python manage.py makemigrations] les écrit, en comparant les modèles à l'état décrit par les migrations précédentes ; [python manage.py migrate] les applique à la base. C'est la façon ordinaire de travailler avec [Django]. Nous gardons un script SQL, commun à toutes les versions de l'étude de cas, et qui montre exactement ce que contient la base. À l'inverse, la commande [python manage.py inspectdb] écrit les modèles d'une base existante : un bon point de départ, à relire.

9.1.2. La configuration : config/settings.py

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

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure--7xp)7k(l6nl(owyoe0r99a@hufm3z2t@a(@w+va%5uw^%fbbm"
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",
    "personnes",
]

MIDDLEWARE = [
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    # PUT et DELETE par le champ caché _method (cf. exemple 21)
    "config.middleware.MethodeSurchargee",
]

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

# la BASE DE DONNÉES : le SGBD MySQL, sur cette machine (127.0.0.1), port 3306, la
# base dbpersonnes, l'utilisateur root sans mot de passe (les réglages de Laragon)
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "NAME": "dbpersonnes",
        "USER": "root",
        "PASSWORD": "",
        "HOST": "127.0.0.1",
        "PORT": "3306",
        "OPTIONS": {"charset": "utf8mb4"},
    },
}

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",
]

# le JOURNAL (logging) : les ordres SQL envoyés à la base sont écrits dans la console
# (le journal django.db.backends ne les émet qu'en mode debug)
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "loggers": {
        "django.db.backends": {"handlers": ["console"], "level": "DEBUG"},
    },
}
  • lignes 21-26 : les middlewares de l'exemple 21 : la protection anti-CSRF, les messages flash, et la simulation des méthodes PUT et DELETE (le fichier [config/middleware.py] est celui de l'exemple 21) ;
  • lignes 32-42 : le réglage [DATABASES] décrit les bases de données de l'application : ici une seule, la base [default]. [ENGINE] désigne le moteur de [Django] pour [MySQL] (qui utilise le pilote [mysqlclient]) ; viennent ensuite le nom de la base, l'utilisateur et son mot de passe (vide, avec les réglages de [Laragon] : si votre serveur a un mot de passe, écrivez-le ici), la machine et le port. [OPTIONS] passe au pilote le jeu de caractères du dialogue avec le serveur, celui de la base ;
  • lignes 44-55 : les gabarits, avec [APP_DIRS] pour les gabarits des formulaires (exemple 18), et le processeur de contexte des messages (exemple 20) ;
  • lignes 67-74 : le réglage [LOGGING] configure le journal de [Python] (le module [logging]), que [Django] utilise pour ses messages. Il suit le format du module [logging.config] : des destinations ([handlers] : ici la console, par un [StreamHandler], qui écrit sur la sortie d'erreur), et des journaux ([loggers]), désignés par leur nom. Le journal [django.db.backends] reçoit, en mode debug seulement, chaque ordre SQL envoyé à la base, avec sa durée : nous l'envoyons à la console, au niveau [DEBUG] (le plus détaillé). [disable_existing_loggers: False] garde les journaux que [Django] configure lui-même.

Il n'y a rien d'autre à configurer : les modules des trois couches s'importent l'un l'autre (la couche web importe la couche métier, qui importe la couche DAO), et l'ORM ouvre lui-même la connexion à la base, quand il en a besoin.

9.1.3. Le modèle Personne : personnes/models.py

# personnes/models.py : une ligne de la table [personnes], un MODÈLE Django

from django.db import models


# un modèle Django : une classe qui hérite de models.Model ; chaque attribut de classe
# qui est un CHAMP (models.XxxField) correspond à une colonne de la table
class Personne(models.Model):
    # la clé primaire, générée par la base (AUTO_INCREMENT)
    id = models.AutoField(primary_key=True)
    # la VERSION de la ligne, augmentée à chaque modification (cf. la couche DAO)
    version = models.IntegerField(default=0)
    # les colonnes VARCHAR(30) et INT
    prenom = models.CharField(max_length=30)
    nom = models.CharField(max_length=30)
    age = models.IntegerField()

    # Meta : les informations sur le modèle lui-même
    class Meta:
        # le nom de la table (par défaut : personnes_personne, application_modèle)
        db_table = "personnes"
        # la table existe déjà (script database/dbpersonnes.sql) : Django ne la crée pas
        managed = False
        # l'ordre des lignes quand la requête n'en précise pas
        ordering = ["nom", "prenom"]

    # le texte qui représente l'objet (dans la console, les messages...)
    def __str__(self):
        return f"{self.prenom} {self.nom}"

Le fichier s'appelle [models.py] : c'est là que [Django] cherche les modèles d'une application déclarée dans [INSTALLED_APPS].

  • ligne 8 : un modèle est une classe qui hérite de [models.Model] ;
  • ligne 10 : la clé primaire, générée par la base ([AutoField] : l'[AUTO_INCREMENT] de [MySQL]). [Django] l'ajouterait de lui-même si elle n'était pas déclarée (un champ [id], du type choisi par le réglage [DEFAULT_AUTO_FIELD]) ; nous l'écrivons pour la clarté. Elle vaut [None] tant que l'objet n'a pas été inséré ;
  • ligne 12 : la version de la ligne. L'ORM de [Django] n'a pas de verrou optimiste intégré : c'est la couche DAO qui l'écrit (voir plus loin) ;
  • lignes 14-16 : les colonnes. Chaque attribut de classe qui est un champ du modèle ([models.XxxField]) correspond à la colonne de même nom ; le type de la colonne est déduit du type du champ ([CharField] : [VARCHAR], de longueur [max_length] ; [IntegerField] : [INT]). Comme dans un formulaire (exemple 08), les champs sont déclarés comme des attributs de la classe, mais ce ne sont pas les mêmes champs : ceux de [django.db.models] décrivent des colonnes, ceux de [django.forms] des champs de formulaire ;
  • lignes 19-25 : la classe intérieure [Meta] donne les informations sur le modèle lui-même. [db_table] : le nom de la table ([Django] prendrait sinon le nom de l'application suivi de celui du modèle, [personnes_personne]). [managed = False] : la table existe déjà, et [Django] ne la gère pas : [makemigrations] ne produira pour elle aucune migration de création ou de modification. [ordering] : l'ordre des lignes quand une requête n'en précise pas ;
  • lignes 28-29 : la méthode [__str__] (exemple 12) donne le texte qui représente l'objet : [str(p)], ou [{p}] dans une f-chaîne, rend [Marie Pélissier].

Un modèle est une classe ordinaire, à laquelle on peut ajouter des méthodes et des propriétés. L'ORM, lui, remplit les attributs à partir des lignes lues, et fabrique les ordres SQL à partir des attributs.

9.1.4. La couche DAO : personnes/dao.py

# personnes/dao.py : la couche DAO, des requêtes, aucune règle de gestion

from django.db.models import F

from .models import Personne


# SELECT ... FROM personnes ORDER BY nom, prenom. objects : le GESTIONNAIRE du modèle,
# d'où partent toutes les requêtes ; all() rend un QuerySet, une requête qui n'est
# exécutée que lorsqu'on en lit les résultats (ici, par list)
def lister() -> list[Personne]:
    return list(Personne.objects.all())


# SELECT ... FROM personnes WHERE id = ? ; None si la personne n'existe pas
def lire(id: int) -> Personne | None:
    return Personne.objects.filter(id=id).first()


# une personne de mêmes prénom et nom, autre que [sauf_id]. filter : les conditions
# WHERE ; exclude : les conditions WHERE NOT
def homonyme(prenom: str, nom: str, sauf_id: int = 0) -> Personne | None:
    return Personne.objects.filter(prenom=prenom, nom=nom).exclude(id=sauf_id).first()


# INSERT INTO personnes ... : create construit l'objet et l'enregistre
def inserer(prenom: str, nom: str, age: int) -> Personne:
    return Personne.objects.create(prenom=prenom, nom=nom, age=age)


# UPDATE personnes SET ..., version = version + 1 WHERE id = ? AND version = ? : la
# ligne n'est modifiée que si sa version n'a pas changé depuis sa lecture (VERROU
# OPTIMISTE). F("version") désigne la colonne elle-même, dans l'ordre SQL. update
# rend le nombre de lignes modifiées : 0 si la version a changé
def modifier(id: int, version: int, prenom: str, nom: str, age: int) -> bool:
    lignes = Personne.objects.filter(id=id, version=version).update(
        prenom=prenom, nom=nom, age=age, version=F("version") + 1)
    return lignes == 1


# DELETE FROM personnes WHERE id = ?
def supprimer(personne: Personne) -> None:
    personne.delete()

La couche DAO ne contient que des requêtes, sans aucune règle de gestion. Chaque fonction commence par [Personne.objects], le gestionnaire du modèle, d'où partent toutes les requêtes.

  • lignes 11-12 : [all()] rend un [QuerySet] : un objet qui décrit une requête, sans l'exécuter. La requête n'est envoyée à la base que lorsqu'on a besoin de ses résultats (ici, quand [list] les parcourt) : on dit qu'un [QuerySet] est paresseux. On peut ainsi le compléter avant son exécution ([filter], [exclude], [order_by]...), chaque méthode rendant un nouveau [QuerySet]. L'ordre est celui de [Meta.ordering] ;
  • lignes 16-17 : [filter(id=id)] ajoute la condition [WHERE id = ...] ; [first()] exécute la requête, limitée à une ligne ([LIMIT 1]), et rend le premier objet, ou [None]. ([Personne.objects.get(id=id)] rendrait l'objet, mais lèverait l'exception [Personne.DoesNotExist] s'il n'existait pas, et le raccourci [get_object_or_404], exemple 03, une erreur 404) ;
  • lignes 22-23 : plusieurs conditions dans [filter] sont réunies par [AND] ; [exclude] ajoute une condition [NOT]. Les paramètres nommés désignent les champs ([prenom=...]) ; des suffixes précisent la comparaison : [age__gte=18] ([>=]), [nom__startswith="P"], [nom__iexact=...] (sans tenir compte des majuscules)... Les valeurs sont transmises à la base à part, et jamais recopiées dans le texte de l'ordre SQL, ce qui rend impossible l'injection SQL ;
  • lignes 27-28 : [create] construit l'objet et l'enregistre ([INSERT]) ; après l'insertion, l'ORM range dans l'objet l'id généré par la base. C'est l'équivalent de [p = Personne(prenom=..., ...)] suivi de [p.save()] ;
  • lignes 35-38 : la modification, avec son verrou optimiste. [update] modifie en une fois, par un seul ordre [UPDATE], toutes les lignes que désigne le [QuerySet], et rend le nombre de lignes modifiées. Le filtre porte deux conditions : l'id, et la version lue. Si une autre requête a modifié la ligne entre-temps, sa version a changé, l'[UPDATE] ne modifie aucune ligne, et la fonction rend [False]. C'est le principe du verrou optimiste : on ne bloque rien, mais on détecte les modifications concurrentes. L'expression [F("version") + 1] désigne la colonne elle-même, dans l'ordre SQL ([version = version + 1]) : le calcul est fait par la base, et non en [Python] à partir d'une valeur lue plus tôt ;
  • lignes 42-43 : la suppression : [delete()] envoie le [DELETE] de la ligne de l'objet.

L'ORM de [Django] n'a pas, comme [Doctrine], de gestionnaire qui compare chaque objet lu à son état d'origine pour n'enregistrer que les colonnes modifiées : [save()] réécrit toutes les colonnes de l'objet (ou celles de son paramètre [update_fields]). C'est plus simple, et plus prévisible ; [update] permet d'écrire exactement l'ordre voulu.

9.1.5. La couche métier : personnes/metier.py

# personnes/metier.py : la couche MÉTIER, les règles de gestion, et elles seules.
# Chaque opération qui écrit est une TRANSACTION : validée (COMMIT) si la fonction se
# termine normalement, annulée (ROLLBACK) si une exception en sort

from dataclasses import dataclass

from django.db import IntegrityError, transaction

from . import dao
from .models import Personne

HOMONYME = "Une personne porte déjà ces prénom et nom."
MODIFIEE = "Cette fiche a été modifiée entre-temps : rechargez-la."
INCONNUE = "Cette personne n'existe pas (ou plus)."


# une règle de gestion refuse l'opération ; le message est destiné à l'utilisateur
class MetierErreur(Exception):
    pass


# les données d'une personne, telles que la couche web les transmet
@dataclass(frozen=True)
class DonneesPersonne:
    prenom: str
    nom: str
    age: int


def lister() -> list[Personne]:
    return dao.lister()


def lire(id: int) -> Personne:
    personne = dao.lire(id)
    if personne is None:
        raise MetierErreur(INCONNUE)
    return personne


# with transaction.atomic() : le bloc est exécuté dans une transaction. La contrainte
# d'unicité de la base (deux ajouts simultanés) lève IntegrityError, qui devient une
# erreur métier
def ajouter(donnees: DonneesPersonne) -> Personne:
    try:
        with transaction.atomic():
            # règle 1 : pas deux personnes de mêmes prénom et nom
            if dao.homonyme(donnees.prenom, donnees.nom):
                raise MetierErreur(HOMONYME)
            return dao.inserer(donnees.prenom, donnees.nom, donnees.age)
    except IntegrityError as e:
        raise MetierErreur(HOMONYME) from e


def modifier(id: int, version: int, donnees: DonneesPersonne) -> Personne:
    try:
        with transaction.atomic():
            personne = lire(id)
            # règle 2 : on ne modifie pas une fiche modifiée entre-temps par un autre
            if personne.version != version:
                raise MetierErreur(MODIFIEE)
            if dao.homonyme(donnees.prenom, donnees.nom, id):
                raise MetierErreur(HOMONYME)
            # l'UPDATE ... WHERE version = ... ne modifie rien si une autre requête a
            # modifié la ligne depuis notre lecture
            if not dao.modifier(id, version, donnees.prenom, donnees.nom, donnees.age):
                raise MetierErreur(MODIFIEE)
            return lire(id)
    except IntegrityError as e:
        raise MetierErreur(HOMONYME) from e


def supprimer(id: int) -> Personne:
    with transaction.atomic():
        personne = lire(id)
        dao.supprimer(personne)
        return personne
  • lignes 12-14 : les messages des règles de gestion ;
  • lignes 18-19 : une classe d'exception propre à la couche métier : une règle de gestion refuse l'opération, et le message de l'exception est destiné à l'utilisateur. Elle hérite d'[Exception], sans rien y ajouter : seul son nom compte ;
  • lignes 23-27 : les données d'une personne, telles que la couche web les transmet à la couche métier : un objet simple, sans lien avec [Django] ;
  • lignes 34-38 : une personne inexistante est une erreur métier ;
  • lignes 44-52 : la règle n° 1 : pas deux personnes de mêmes prénom et nom. La règle est vérifiée avant d'écrire, et une [MetierErreur] est levée si elle n'est pas respectée. Mais cette vérification ne suffit pas : deux requêtes simultanées pourraient vérifier toutes les deux qu'il n'y a pas d'homonyme, puis insérer toutes les deux. La contrainte d'unicité de la base refuse alors le second [INSERT], et l'ORM lève une [IntegrityError], transformée en la même [MetierErreur]. La base est le dernier rempart. [raise ... from e] attache l'exception d'origine à la nouvelle : le journal montrera les deux ;
  • lignes 55-70 : la règle n° 2 : on ne modifie pas une fiche qui a été modifiée entre-temps par quelqu'un d'autre. Le formulaire de modification transporte la version de la fiche lue (dans un champ caché) ; si elle diffère de la version actuelle, la fiche a changé depuis son affichage. Il reste le cas où la modification concurrente a lieu entre la lecture de la ligne 58 et l'enregistrement de la ligne 66 : c'est le verrou optimiste de la couche DAO qui le détecte. La fonction relit ensuite la personne, pour la rendre avec sa nouvelle version ;
  • lignes 73-77 : la suppression rend la personne supprimée, pour le message de confirmation.

Chaque opération qui écrit est exécutée dans une transaction : [transaction.atomic()], utilisé par [with] (un gestionnaire de contexte, exemple 20), ouvre une transaction ([BEGIN]) au début du bloc ; si le bloc se termine normalement (même par un [return]), la transaction est validée ([COMMIT]) ; si une exception sort du bloc, elle est annulée ([ROLLBACK]) : aucune des modifications faites dans le bloc n'est conservée, et l'exception poursuit son chemin. [transaction.atomic] s'emploie aussi comme décorateur, pour une fonction entière ([@transaction.atomic]). La couche métier décide ainsi de l'étendue des transactions : une opération métier, une transaction. Hors d'un bloc [atomic], l'ORM travaille en mode « autocommit » : chaque ordre SQL est validé aussitôt. Une [IntegrityError] est interceptée hors du bloc [with] : à l'intérieur, la transaction ne serait plus utilisable après l'erreur. Les autres exceptions (une panne de la base, une erreur de programmation) ne sont pas des erreurs métier : elles poursuivent leur chemin.

9.1.6. La couche web

La saisie est décrite par un formulaire (exemple 19) :

# personnes/forms.py : le formulaire d'une personne (cf. exemples 18 et 19)

from django import forms

from .metier import DonneesPersonne


class PersonneForm(forms.Form):
    prenom = forms.CharField(label="Prénom", max_length=30, error_messages={
        "required": "Le prénom est obligatoire.", "max_length": "30 caractères au plus."})
    nom = forms.CharField(label="Nom", max_length=30, error_messages={
        "required": "Le nom est obligatoire.", "max_length": "30 caractères au plus."})
    age = forms.IntegerField(label="Âge", min_value=0, max_value=150, error_messages={
        "required": "L'âge est obligatoire.",
        "invalid": "L'âge doit être un nombre entier.",
        "min_value": "L'âge doit être compris entre 0 et 150.",
        "max_value": "L'âge doit être compris entre 0 et 150."})

    # modification=True : le formulaire de modification, qui a un champ de plus
    def __init__(self, *args, modification=False, **kwargs):
        super().__init__(*args, **kwargs)
        for champ in self.fields.values():
            champ.widget.attrs["class"] = "form-control"
        # la version : un champ caché, dans le formulaire de modification seulement
        if modification:
            self.fields["version"] = forms.IntegerField(widget=forms.HiddenInput)

    # les données validées, pour la couche métier ; CharField a déjà retiré les
    # espaces du début et de la fin de la saisie (option strip, vraie par défaut)
    def donnees(self) -> DonneesPersonne:
        d = self.cleaned_data
        return DonneesPersonne(d["prenom"], d["nom"], d["age"])
  • lignes 9-17 : les champs et leurs contraintes, avec des messages en français ;
  • lignes 20-26 : un paramètre de plus pour le constructeur, [modification], donné par son nom ([PersonneForm(request.POST, modification=True)]) : un paramètre écrit après [*args] ne peut être passé que par son nom. Le formulaire de modification reçoit un champ de plus, la version, dans un champ caché : [self.fields] est un dictionnaire, auquel on peut ajouter un champ ;
  • lignes 30-32 : les données validées, sous la forme attendue par la couche métier.

[Django] sait aussi fabriquer un formulaire à partir d'un modèle (la classe [forms.ModelForm]) : ses champs et leurs contraintes sont déduits des colonnes, et sa méthode [save()] enregistre l'objet. C'est très répandu. Mais le formulaire modifierait alors le modèle avant que la couche métier ait vérifié ses règles. Un formulaire distinct garde les couches séparées : la couche web ne transmet à la couche métier que des données validées.

# personnes/views.py : la couche WEB ; elle ne connaît que la couche métier

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

from . import metier
from .forms import PersonneForm
from .metier import MetierErreur


@require_GET
def accueil(request):
    return redirect("personnes_liste")


@require_GET
def liste(request):
    return render(request, "personnes/liste.html", {"personnes": metier.lister()})


# GET : le formulaire vide ; POST : le formulaire envoyé
@require_http_methods(["GET", "POST"])
def ajout(request):
    titre = "Ajouter une personne"
    action = reverse("personnes_ajout")
    if request.method == "GET":
        return page_formulaire(request, titre, PersonneForm(), action)
    formulaire = PersonneForm(request.POST)
    if not formulaire.is_valid():
        return page_formulaire(request, titre, formulaire, action, statut=422)
    try:
        p = metier.ajouter(formulaire.donnees())
    except MetierErreur as e:
        # une règle métier refuse : le formulaire est réaffiché, avec la saisie, et
        # le message, en bas de page
        return page_formulaire(request, titre, formulaire, action, erreur=str(e))
    messages.success(request, f"{p} a été ajouté(e).")
    return redirect("personnes_liste")


# GET : le formulaire de modification, pré-rempli
@require_GET
def modification(request, id):
    try:
        p = metier.lire(id)
    except MetierErreur as e:
        messages.error(request, str(e))
        return redirect("personnes_liste")
    formulaire = PersonneForm(modification=True, initial={
        "prenom": p.prenom, "nom": p.nom, "age": p.age, "version": p.version})
    return page_formulaire(request, "Modifier une personne", formulaire,
                           reverse("personnes_personne", args=[id]), methode="PUT")


# PUT : le formulaire de modification envoyé ; DELETE : la suppression
@require_http_methods(["PUT", "DELETE"])
def personne(request, id):
    if request.method == "DELETE":
        try:
            p = metier.supprimer(id)
            messages.success(request, f"{p} a été supprimé(e).")
        except MetierErreur as e:
            # pas de formulaire à réafficher : l'erreur voyage elle aussi en message flash
            messages.error(request, str(e))
        return redirect("personnes_liste")
    titre = "Modifier une personne"
    action = reverse("personnes_personne", args=[id])
    formulaire = PersonneForm(request.POST, modification=True)
    if not formulaire.is_valid():
        return page_formulaire(request, titre, formulaire, action, methode="PUT", statut=422)
    try:
        p = metier.modifier(id, formulaire.cleaned_data["version"], formulaire.donnees())
    except MetierErreur as e:
        return page_formulaire(request, titre, formulaire, action, methode="PUT", erreur=str(e))
    messages.success(request, f"{p} a été modifié(e).")
    return redirect("personnes_liste")


# la page du formulaire ; une erreur métier : le code 422, comme pour un formulaire
# invalide (cf. exemple 19)
def page_formulaire(request, titre, formulaire, action, methode=None, erreur=None, statut=200):
    return render(request, "personnes/formulaire.html", {
        "titre": titre, "formulaire": formulaire, "action": action, "methode": methode,
        "erreur": erreur}, status=422 if erreur else statut)
  • ligne 8 : la couche métier, seule couche connue de la couche web ;
  • lignes 25-40 : l'ajout, en trois temps : la validation du formulaire (les erreurs de saisie, exemple 19) ; l'appel de la couche métier (les règles de gestion) ; puis le schéma PRG, avec un message flash de succès (exemple 20). Une [MetierErreur] réaffiche le formulaire, avec la saisie, et le message de l'exception ([str(e)]), que le gabarit affichera en bas de page. Les erreurs de saisie et les erreurs métier sont ainsi traitées de la même façon ;
  • lignes 44-54 : le formulaire de modification, pré-rempli avec la personne lue (paramètre [initial], exemple 18). Une personne inexistante renvoie à la liste, avec un message flash d'erreur ([messages.error]). Le formulaire est envoyé par la méthode PUT (le champ caché [_method], exemple 21), à l'URL [/personnes/<id>] ([reverse], avec les paramètres de la route dans [args]) ;
  • lignes 58-78 : la vue de [/personnes/<id>], pour les méthodes PUT (la modification, sur le même modèle que l'ajout) et DELETE (la suppression, protégée par le jeton anti-CSRF, exemple 21). Pour la suppression, il n'y a pas de formulaire à réafficher : l'erreur éventuelle voyage elle aussi dans un message flash ;
  • lignes 83-86 : la page du formulaire. Une erreur métier est renvoyée avec le code 422, comme un formulaire invalide.

La couche web ne contient ni SQL ni règle de gestion : elle traduit la requête en appel de la couche métier, et le résultat en page. Les routes ([personnes/urls.py]) : [/personnes] (la liste), [/personnes/ajout], [/personnes/<id>/modification], et [/personnes/<id>] (PUT et DELETE).

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>Personnes</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: 44rem">
    {# les messages flash ; tags : leur niveau (success, error) #}
    {% for message in messages %}
      <div class="alert {% if message.tags == 'error' %}alert-danger{% else %}alert-success{% endif %}" role="status">{{ message }}</div>
    {% endfor %}
    <main>
      {% block contenu %}{% endblock %}
    </main>
    {% block bas_de_page %}{% endblock %}
  </div>
  {% include "fragments/confirmation.html" %}
  <script src="{% static 'js/app.js' %}"></script>
</body>
</html>
  • lignes 14-16 : les messages flash, en haut de page (exemple 20). Leur étiquette ([message.tags] : [success], [error]) choisit leur couleur ;
  • ligne 20 : le bloc du bas de page, où les pages placent leurs erreurs (exemple 19) ;
  • lignes 22-23 : la fenêtre de confirmation et son script (exemple 21).
{% extends "layout.html" %}

{% block contenu %}
  <div class="d-flex justify-content-between align-items-center mb-2">
    <h1 class="h3 mb-0">Personnes</h1>
    <a class="btn btn-primary btn-sm" href="{% url 'personnes_ajout' %}">Ajouter</a>
  </div>
  <table class="table align-middle">
    <thead><tr><th>Prénom</th><th>Nom</th><th>Âge</th><th></th></tr></thead>
    <tbody>
      {% for p in personnes %}
        <tr>
          <td>{{ p.prenom }}</td>
          <td>{{ p.nom }}</td>
          <td>{{ p.age }}</td>
          <td class="text-end">
            <a class="btn btn-sm btn-outline-primary" href="{% url 'personnes_modification' p.id %}">Modifier</a>
            <form method="post" class="d-inline" action="{% url 'personnes_personne' p.id %}"
                  data-confirmation="Supprimer {{ p.prenom }} {{ p.nom }} ?">
              {% csrf_token %}
              <input type="hidden" name="_method" value="DELETE">
              <button class="btn btn-sm btn-outline-danger" type="submit">Supprimer</button>
            </form>
          </td>
        </tr>
      {% empty %}
        <tr><td colspan="4">Aucune personne.</td></tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}

La liste reprend les liens et les boutons de l'exemple 21, avec sa fenêtre de confirmation (les fichiers [fragments/confirmation.html] et [static/js/app.js] sont ceux de l'exemple 21). Les attributs [p.prenom], [p.nom]... sont ceux des objets [Personne] lus par l'ORM. Le bloc [{% empty %}] (exemple 14) traite la liste vide.

{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">{{ titre }}</h1>
  <form method="post" action="{{ action }}" novalidate style="max-width: 24rem">
    {% csrf_token %}
    {# la modification est envoyée par la méthode PUT (cf. exemple 21) #}
    {% if methode %}<input type="hidden" name="_method" value="{{ methode }}">{% endif %}
    {{ formulaire.prenom.as_field_group }}
    {{ formulaire.nom.as_field_group }}
    {{ formulaire.age.as_field_group }}
    {# la version : un champ caché, dans le formulaire de modification seulement #}
    {% if formulaire.version %}{{ formulaire.version }}{% endif %}
    <div class="d-flex gap-2">
      <button class="btn btn-primary" type="submit">Enregistrer</button>
      <a class="btn btn-link" href="{% url 'personnes_liste' %}">Annuler</a>
    </div>
  </form>
{% endblock %}

{% block bas_de_page %}
  {% if erreur %}
    <div class="zone-erreur" role="alert">{{ erreur }}</div>
  {% endif %}
{% endblock %}

Un gabarit commun à l'ajout et à la modification :

  • ligne 8 : le champ caché [_method], pour la modification seulement ;
  • ligne 13 : le champ caché de la version, s'il existe dans le formulaire ;
  • lignes 21-25 : l'erreur métier, en bas de page.

9.1.7. Exécution

La liste des personnes :

Image

Un formulaire d'ajout mal rempli : les erreurs de saisie sont détectées par la couche web.

Image

Un homonyme : la saisie est correcte, mais la couche métier refuse l'ajout. La comparaison étant faite par la base, elle ne tient compte ni des majuscules ni des accents : [marie pelissier] est un homonyme de [Marie Pélissier].

Image

Un ajout réussi :

Image

Pour observer la règle n° 2, ouvrons la même fiche dans deux onglets. Dans le premier, modifions l'âge et enregistrons. Dans le second, qui affiche encore l'ancienne version, modifions aussi la fiche :

Image

La suppression, avec sa confirmation :

Image

Image

9.1.8. Les ordres SQL

Suivons dans la console les ordres SQL d'une modification (le serveur a été redémarré, et la base recréée). L'affichage du formulaire de modification de la personne n° 1 :

(0.000)
                SELECT VERSION(),
                       @@sql_mode,
                       @@default_storage_engine,
                       @@sql_auto_is_null,
                       @@lower_case_table_names,
                       CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL
            ; args=None; alias=default
(0.000) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; args=None; alias=default
(0.000) SELECT `personnes`.`id`, `personnes`.`version`, `personnes`.`prenom`, `personnes`.`nom`, `personnes`.`age` FROM `personnes` WHERE `personnes`.`id` = 1 ORDER BY `personnes`.`nom` ASC, `personnes`.`prenom` ASC LIMIT 1; args=(1,); alias=default
[27/Sep/2026 19:45:19] "GET /personnes/1/modification HTTP/1.1" 200 2450

Chaque ligne donne la durée de l'ordre (en secondes), l'ordre SQL, ses paramètres ([args]) et le nom de la base ([alias]). Les deux premiers ordres sont ceux de l'ouverture de la connexion : [Django] interroge le serveur (sa version, ses réglages), puis fixe le niveau d'isolation des transactions ([READ COMMITTED] : une transaction voit les modifications validées par les autres). Vient ensuite la lecture par clé primaire, avec son [ORDER BY] (celui de [Meta.ordering]) et son [LIMIT 1] (celui de [first()]). Dans le journal, les paramètres sont recopiés dans l'ordre pour qu'on puisse le lire ; à la base, ils sont envoyés à part ([args]). Il n'y a pas de transaction : une lecture isolée n'en a pas besoin. L'enregistrement de la fiche, avec un nouvel âge :

1
2
3
4
5
6
7
(0.000) BEGIN; args=None; alias=default
(0.001) SELECT `personnes`.`id`, `personnes`.`version`, `personnes`.`prenom`, `personnes`.`nom`, `personnes`.`age` FROM `personnes` WHERE `personnes`.`id` = 1 ORDER BY `personnes`.`nom` ASC, `personnes`.`prenom` ASC LIMIT 1; args=(1,); alias=default
(0.001) SELECT `personnes`.`id`, `personnes`.`version`, `personnes`.`prenom`, `personnes`.`nom`, `personnes`.`age` FROM `personnes` WHERE (`personnes`.`nom` = 'Pélissier' AND `personnes`.`prenom` = 'Marie' AND NOT (`personnes`.`id` = 1)) ORDER BY `personnes`.`nom` ASC, `personnes`.`prenom` ASC LIMIT 1; args=('Pélissier', 'Marie', 1); alias=default
(0.000) UPDATE `personnes` SET `prenom` = 'Marie', `nom` = 'Pélissier', `age` = 43, `version` = (`personnes`.`version` + 1) WHERE (`personnes`.`id` = 1 AND `personnes`.`version` = 0); args=('Marie', 'Pélissier', 43, 1, 1, 0); alias=default
(0.000) SELECT `personnes`.`id`, `personnes`.`version`, `personnes`.`prenom`, `personnes`.`nom`, `personnes`.`age` FROM `personnes` WHERE `personnes`.`id` = 1 ORDER BY `personnes`.`nom` ASC, `personnes`.`prenom` ASC LIMIT 1; args=(1,); alias=default
(0.001) COMMIT; args=None; alias=default
[27/Sep/2026 19:45:19] "POST /personnes/1 HTTP/1.1" 302 0

(la connexion a été ouverte, comme à la requête précédente). On y reconnaît, dans une seule transaction ([BEGIN] ... [COMMIT]) : la lecture de la fiche, la recherche d'un homonyme, l'[UPDATE], puis la relecture de la fiche. L'[UPDATE] augmente la version ([version + 1]), et porte la condition du verrou optimiste ([AND version = 0]). Le serveur de développement note la requête comme un POST : c'est ainsi qu'elle est arrivée, avant que le middleware n'en fasse un PUT. Envoyons maintenant le même formulaire une seconde fois, toujours avec l'ancienne version :

1
2
3
4
5
(0.000) BEGIN; args=None; alias=default
(0.000) SELECT `personnes`.`id`, `personnes`.`version`, `personnes`.`prenom`, `personnes`.`nom`, `personnes`.`age` FROM `personnes` WHERE `personnes`.`id` = 1 ORDER BY `personnes`.`nom` ASC, `personnes`.`prenom` ASC LIMIT 1; args=(1,); alias=default
(0.000) ROLLBACK; args=None; alias=default
Unprocessable Content: /personnes/1
[27/Sep/2026 19:45:19] "POST /personnes/1 HTTP/1.1" 422 2661

La version lue n'est pas celle du formulaire : la couche métier a levé une [MetierErreur], qui a annulé la transaction ([ROLLBACK]), et la page affiche « Cette fiche a été modifiée entre-temps : rechargez-la. ». De même, l'ajout d'un homonyme s'arrête après la recherche de l'homonyme, sans [INSERT].