Skip to content

2. Vues, routage, réponses

Ce chapitre étudie le chemin d'une requête, du navigateur jusqu'à la vue qui la traite, et la réponse que renvoie cette vue. Les gabarits n'apparaîtront qu'au chapitre 3 : ici, les vues renvoient directement du texte ou du JSON.

2.1. Exemple 01 : la structure d'une application Django

Le premier exemple est la plus petite application [Django] possible. Un projet créé par [django-admin startproject] contient six fichiers, et une application créée par [python manage.py startapp] sept de plus (cf. introduction) ; nous n'en gardons ici que ceux dont l'application ne peut pas se passer :

1
2
3
4
5
6
7
01-structure/
├── config/
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py
  • [manage.py] : l'outil en ligne de commande du projet. C'est par lui que l'on lance le serveur de développement, et bien d'autres commandes ;
  • [config] : un paquet [Python], c'est-à-dire un dossier qui contient des modules [Python] (des fichiers [.py]) et un fichier [__init__.py]. On importe un module d'un paquet en écrivant [config.settings] (le fichier [config/settings.py]) ;
  • [config/settings.py] : les réglages de l'application ;
  • [config/urls.py] : les routes de l'application, et, dans ce premier exemple, sa seule vue ;
  • [config/wsgi.py] : le point d'entrée de l'application pour un serveur web ;
  • [config/__init__.py] : le fichier qui fait du dossier [config] un paquet. Il est souvent vide ; le nôtre ne contient qu'un commentaire.

2.1.1. Le point d'entrée : config/wsgi.py

[Python] a une norme pour les applications web : WSGI (Web Server Gateway Interface, prononcé « whisky »). Elle définit comment un serveur web confie une requête à une application [Python], et comment il en reçoit la réponse. Tous les serveurs web [Python] ([gunicorn], [waitress], [uWSGI], le serveur de développement de [Django]...) savent appeler une application WSGI, et tous les frameworks web [Python] ([Django], [Flask]...) en fabriquent une. Le fichier [config/wsgi.py] fabrique celle de notre projet :

# config/wsgi.py : le point d'entrée de l'application pour un serveur web (norme WSGI)

import os

from django.core.wsgi import get_wsgi_application

# le module de configuration, comme dans manage.py
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")

# l'APPLICATION : l'objet que le serveur appelle pour chaque requête reçue
application = get_wsgi_application()
  • ligne 1 : un commentaire, de [#] jusqu'à la fin de la ligne. Tous les fichiers des exemples commencent par un commentaire qui rappelle leur nom et leur rôle ;
  • ligne 3 : l'instruction [import] importe un module : ici [os], un module de la bibliothèque standard de [Python], qui donne accès au système d'exploitation, et en particulier aux variables d'environnement du processus ([os.environ]) ;
  • ligne 5 : [from ... import ...] importe un nom d'un module : la fonction [get_wsgi_application] du module [django.core.wsgi]. Le nom complet d'un module dit où il se trouve : [django.core.wsgi] est le fichier [wsgi.py] du dossier [core] du paquet [django], installé dans l'environnement virtuel (cf. introduction). Après cet import, le fichier peut écrire simplement [get_wsgi_application] ;
  • ligne 8 : [Django] trouve ses réglages grâce à la variable d'environnement [DJANGO_SETTINGS_MODULE], qui contient le nom du module de configuration : [config.settings]. [os.environ] se comporte comme un dictionnaire ; sa méthode [setdefault] lui donne une valeur, sauf si elle en a déjà une : une vraie variable d'environnement, fixée avant de lancer le serveur, l'emporte ;
  • ligne 11 : [get_wsgi_application()] prépare [Django] : il lit les réglages, prépare le journal (logging), charge les applications du projet (exemple 02), puis rend l'application WSGI, rangée dans la variable [application]. C'est cette variable que le serveur web cherche dans le fichier. En [Python], une variable n'est pas déclarée : elle naît à sa première affectation, et peut recevoir des valeurs de tout type.

L'application WSGI est un objet que le serveur appelle, pour chaque requête reçue, avec la description de la requête ; l'objet rend la réponse. Toute l'application est donc une fonction qui transforme une requête en réponse. Entre les deux, [Django] fabrique un objet requête ([HttpRequest]), cherche la vue chargée de la requête (le routage), l'appelle, et récupère l'objet réponse ([HttpResponse]) qu'elle produit, qu'il transmet au serveur.

Ce fichier n'est exécuté qu'une fois, au démarrage du serveur. C'est la grande différence avec [PHP], que le lecteur du cours [Symfony] a vue à l'œuvre : en [PHP], l'application est reconstruite à chaque requête, puis oubliée ; en [Python], elle est construite une fois, au démarrage, puis reste en mémoire et traite toutes les requêtes suivantes. La configuration, la table des routes, les modules importés, les objets rangés dans des variables de module, tout cela est construit une fois, et partagé par toutes les requêtes. C'est rapide, mais cela demande des précautions : une donnée modifiée par une requête est vue par les suivantes, et, si le serveur traite plusieurs requêtes à la fois (le serveur de développement le fait, avec un fil d'exécution, un thread, par requête), deux requêtes peuvent modifier la même donnée en même temps. Nous y reviendrons à l'exemple 06, et surtout au chapitre sur les portées des données.

[Python] a une seconde norme, plus récente : ASGI (Asynchronous Server Gateway Interface), pour les applications asynchrones (les WebSockets, par exemple). [Django] sait fabriquer les deux ; le fichier [asgi.py] que crée [django-admin startproject] fabrique l'application ASGI. Nous nous en tiendrons à WSGI.

2.1.2. La configuration : config/settings.py

# config/settings.py : la CONFIGURATION de l'application, réduite à l'essentiel

from pathlib import Path

# le dossier du projet : celui qui contient manage.py
BASE_DIR = Path(__file__).resolve().parent.parent

# le mode debug : une erreur affiche une page détaillée, le serveur de développement
# sert les fichiers statiques... Jamais en production !
DEBUG = True

# la clé secrète de l'application (signatures, jetons anti-CSRF...), propre à chaque projet
SECRET_KEY = "django-insecure-o%dj0-lrbi!9py48(5i0j0v8bpz%3@n6c!1daacp&r0tk+u16&"

# les noms d'hôte que l'application accepte de servir ; en mode debug, une liste vide
# vaut ["localhost", "127.0.0.1", "[::1]"]
ALLOWED_HOSTS = []

# la langue et le fuseau horaire de l'application
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"

# le module qui contient les ROUTES de l'application : le fichier config/urls.py
ROOT_URLCONF = "config.urls"

# l'application WSGI, que le serveur appelle pour chaque requête (config/wsgi.py)
WSGI_APPLICATION = "config.wsgi.application"

Les réglages de [Django] sont des variables du module [config.settings], écrites en majuscules. C'est un fichier [Python] ordinaire : on peut y calculer des valeurs, lire des variables d'environnement, importer d'autres modules. [Django] n'exige que quelques réglages ; tous les autres ont une valeur par défaut, définie dans le module [django.conf.global_settings] de [Django] (plus de 150 réglages). La commande [python manage.py diffsettings] affiche les réglages qui diffèrent de leur valeur par défaut :

  • ligne 3 : [pathlib] est le module de la bibliothèque standard qui manipule les chemins de fichiers. Un objet [Path] représente un chemin ; il s'écrit de la même façon sous [Windows] et ailleurs ;
  • ligne 6 : le dossier du projet. [__file__] est le chemin du fichier en cours ([config/settings.py]) ; [resolve()] le rend absolu ; [.parent] désigne le dossier qui le contient ([config]), et [.parent.parent] le dossier au-dessus : le dossier [01-structure]. Ce réglage, [BASE_DIR], n'est pas un réglage de [Django] : c'est une commodité, que les autres réglages utiliseront pour désigner des dossiers du projet ([BASE_DIR / "templates"], exemple 13 : l'opérateur [/] assemble deux morceaux de chemin) ;
  • ligne 10 : le mode debug. S'il vaut [True], une exception non traitée affiche une page d'erreur détaillée (exemple 03), et le serveur de développement sert lui-même les fichiers statiques (exemple 15). Ce mode est fait pour le développement, et seulement pour lui : la page d'erreur révèle le code source et la configuration de l'application ;
  • ligne 13 : la clé secrète de l'application : une chaîne aléatoire, que [Django] utilise pour signer certaines données (les cookies signés, les jetons de protection anti-CSRF, les liens de réinitialisation de mot de passe...). Ce réglage est obligatoire : sans lui, [Django] refuse de démarrer. Chaque exemple a la sienne. Le préfixe [django-insecure-] est celui que met [django-admin startproject] : il rappelle que cette clé, écrite dans le code source, ne doit pas servir en production, où l'on fournit la clé par une variable d'environnement (étude de cas) ;
  • ligne 17 : les noms d'hôte que l'application accepte de servir (l'entête [Host] de la requête). C'est une protection contre les requêtes qui mentent sur le site visé. Une liste vide vaut, en mode debug, [localhost], [127.0.0.1] et [::1] ; hors du mode debug, elle doit être remplie : sinon [python manage.py runserver] refuse de démarrer. En [Python], [[a, b]] est une liste ;
  • lignes 20-21 : la langue de l'application ([fr] : les dates, les nombres, les messages de [Django] sont en français, cf. exemple 06) et son fuseau horaire. Les chaînes s'écrivent entre guillemets ou entre apostrophes, au choix : les exemples utilisent les guillemets ;
  • ligne 24 : le module qui contient les routes de l'application : [config.urls], c'est-à-dire le fichier [config/urls.py] ;
  • ligne 27 : l'application WSGI, désignée par son chemin complet : la variable [application] du module [config.wsgi]. C'est elle que lance le serveur de développement.

2.1.3. Les routes et la vue : config/urls.py

# config/urls.py : les ROUTES de l'application, et, pour ce premier exemple, sa seule vue

from django.http import HttpResponse
from django.urls import path


# une VUE : la fonction que Django appelle pour la requête GET /. Elle reçoit la
# requête, et rend la réponse
def accueil(request):
    # la réponse envoyée au navigateur
    return HttpResponse("Bonjour de la part de Django !")


# la table de routage : le chemin "" (la racine du site) est associé à la vue accueil
urlpatterns = [
    path("", accueil),
]
  • lignes 3-4 : l'import de la classe [HttpResponse], la réponse HTTP, et de la fonction [path], qui déclare une route ;
  • lignes 9-11 : une vue : une fonction qui reçoit la requête, et rend la réponse. [def] définit une fonction ; son corps est le bloc indenté qui suit les deux-points. En [Python], l'indentation n'est pas une question de présentation : c'est elle qui délimite les blocs (là où [PHP] ou [Java] utilisent des accolades). Le paramètre [request] reçoit la requête, un objet [HttpRequest] que [Django] passe toujours en premier à une vue (exemple 05) ; c'est une convention de [Django] de le nommer [request] ;
  • ligne 11 : la vue rend un objet [HttpResponse], dont le premier paramètre est le corps de la réponse. En [Python], on crée un objet en appelant sa classe comme une fonction, sans mot-clé [new]. C'est la règle de [Django] : une vue rend toujours un objet [HttpResponse] (exemple 03) ;
  • lignes 15-17 : la table de routage. [Django] cherche dans le module [ROOT_URLCONF] une variable nommée [urlpatterns] : une liste de routes. La route [path("", accueil)] associe le chemin vide (la racine du site, [/]) à la vue [accueil]. On remarque que le chemin d'une route s'écrit sans la barre oblique de tête : [Django] la retire du chemin demandé avant de le comparer aux routes. On remarque aussi que l'on passe à [path] la fonction elle-même ([accueil], sans parenthèses), et non son résultat : en [Python], une fonction est un objet comme un autre, que l'on peut ranger dans une variable ou passer à une autre fonction. [Django] l'appellera plus tard, pour chaque requête [/]. La virgule après le dernier élément de la liste est permise, et d'usage quand les éléments sont écrits un par ligne.

Dans un vrai projet, les vues ne sont jamais écrites dans le fichier des routes : elles vont dans les fichiers [views.py] des applications (exemple 02). Nous l'avons fait ici pour réduire l'exemple à l'essentiel.

2.1.4. L'outil manage.py

Le fichier [manage.py] est un script [Python], qui prépare [Django], comme [config/wsgi.py], mais pour exécuter une commande plutôt que traiter des requêtes :

#!/usr/bin/env python
# manage.py : l'outil en ligne de commande de Django (python manage.py runserver...)

import os
import sys


def main():
    # le module qui contient la configuration de l'application : config/settings.py
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
    # Django exécute la commande demandée sur la ligne de commande (runserver, check...)
    from django.core.management import execute_from_command_line
    execute_from_command_line(sys.argv)


if __name__ == "__main__":
    main()
  • ligne 1 : sous [Linux] et [macOS], cette ligne permet d'exécuter le fichier directement ([./manage.py]) ; sous [Windows], on écrit toujours [python manage.py] ;
  • ligne 10 : le module de configuration, comme dans [config/wsgi.py] ;
  • lignes 12-13 : un import peut aussi se faire à l'intérieur d'une fonction. [execute_from_command_line] exécute la commande demandée : [sys.argv] est la liste des paramètres de la ligne de commande ([["manage.py", "runserver"]]) ;
  • lignes 16-17 : [__name__] vaut ["__main__"] quand le fichier est exécuté directement ([python manage.py]), et le nom du module quand il est importé. Cette condition, que l'on trouve dans beaucoup de scripts [Python], appelle la fonction [main] seulement dans le premier cas.

[python manage.py help] affiche la liste des commandes disponibles : vingt-cinq dans cet exemple, davantage quand on ajoute des applications de [Django] (les fichiers statiques, les sessions, l'authentification...). Nous en utiliserons plusieurs : [runserver], [check] (la vérification du projet), [shell] (une console [Python] où [Django] est prêt), [makemessages] et [compilemessages] (les traductions, exemple 22), [collectstatic] (les fichiers statiques, étude de cas ; c'est l'application [django.contrib.staticfiles] qui l'ajoute). [django-admin] (cf. annexes) est le même outil, qui ne sait pas, lui, où est la configuration du projet.

2.1.5. Exécution

Dans un terminal ouvert sur le dossier [01-structure], où l'on a activé l'environnement virtuel des exemples (cf. introduction), on tape [python manage.py runserver]. [Django] affiche :

Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
September 27, 2026 - 19:14:50
Django version 6.1.1, using settings 'config.settings'
Starting WSGI development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

WARNING: This is a development server. Do not use it in a production setting. Use a production WSGI or ASGI server instead.
For more information on production servers see: https://docs.djangoproject.com/en/6.1/howto/deployment/

La commande [runserver] lance le serveur de développement intégré à [Django]. Il vérifie d'abord le projet (system checks) : des réglages manquants ou incohérents, des routes mal écrites, produisent ici des erreurs ou des avertissements, avant toute requête. Il écoute ensuite sur le port 8000 de la machine elle-même ([127.0.0.1], [localhost]) : il n'accepte que les requêtes venues de cette machine, ce qui convient au développement ([python manage.py runserver 0.0.0.0:8000] l'ouvrirait aux autres machines du réseau). On l'arrête par [Ctrl-C]. Ce serveur est fait pour le développement, comme le rappelle son avertissement : en production, ce sera un serveur WSGI comme [gunicorn] ou [waitress] (cf. étude de cas).

La première ligne indique que le serveur surveille les fichiers du projet ([StatReloader] : il compare régulièrement leur date de modification). Puisque l'application reste en mémoire, une modification d'un fichier [Python] ne serait pas vue par l'application en cours d'exécution : le serveur de développement redémarre donc de lui-même dès qu'un fichier change. Il n'est pas nécessaire de le relancer à la main, sauf après l'ajout d'un nouveau fichier qui n'est encore importé par personne.

Demandons l'URL [http://localhost:8000/] avec un navigateur :

Image

puis avec [curl]. L'option [-i] affiche aussi les entêtes HTTP de la réponse :

curl -i http://localhost:8000/
1
2
3
4
5
6
7
HTTP/1.1 200 OK
Date: Sun, 27 Sep 2026 17:14:50 GMT
Server: WSGIServer/0.2 CPython/3.13.13
Content-Type: text/html; charset=utf-8
Connection: close

Bonjour de la part de Django !

La première ligne de la réponse est la version du protocole, le code de statut, 200, et son libellé, [OK]. Viennent ensuite les entêtes : la date, le serveur (le serveur WSGI de la bibliothèque standard de [Python], sur lequel est bâti le serveur de développement), le type du contenu, et [Connection: close] (le serveur de développement ferme la connexion après chaque réponse). Une ligne vide sépare les entêtes du corps de la réponse. Dans la suite du document, nous ne reproduirons plus les entêtes [Date], [Server] et [Connection], qui ne varient pas.

L'entête [Content-Type: text/html; charset=utf-8] indique que la réponse est du HTML, dont les caractères sont codés en UTF-8. C'est le choix de [Django] pour une réponse dont la vue n'a pas précisé le type : le navigateur interprète donc la chaîne comme du HTML (ici, sans balise, elle s'affiche telle quelle). L'exemple 03 montrera comment annoncer un autre type. La réponse est minimale : pas d'entête [Content-Length], pas de cookie, aucun entête de sécurité. Ce sont les middlewares de [Django] (exemple 03) qui les ajoutent, et notre application n'en a aucun.

À chaque requête, la console du serveur affiche une ligne de journal : la date, la ligne de la requête (méthode, chemin, protocole), le code de la réponse et la taille de son corps, en octets :

[27/Sep/2026 19:14:50] "GET / HTTP/1.1" 200 30

Modifions le texte de la ligne 11 de [config/urls.py] et enregistrons le fichier : la console affiche [.../01-structure/config/urls.py changed, reloading.], puis les lignes de démarrage. La requête suivante reçoit le nouveau texte.

2.2. Exemple 02 : le routage

Le routage associe une URL à une vue. Écrire toutes les vues dans le fichier des routes deviendrait vite illisible : on les range dans des applications. Une application [Django] est un paquet [Python] qui regroupe ce qui concerne un même domaine : ses vues, ses routes, et, plus tard, ses gabarits, ses formulaires, ses modèles. Un projet est un assemblage d'applications. L'exemple 02 en a deux :

02-routage/
├── accueil/
│   ├── __init__.py
│   └── views.py
├── config/
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── premier/
│   ├── __init__.py
│   ├── urls.py
│   └── views.py
└── manage.py

Les fichiers [manage.py], [config/__init__.py] et [config/wsgi.py] sont ceux de l'exemple 01 : nous ne les citerons plus. Le fichier [config/settings.py] commence toujours par les mêmes réglages (seuls changent le nom de l'exemple et la clé secrète) ; nous ne commenterons que ceux qu'ajoute chaque exemple. Ici, la liste des applications :

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

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-tux4f(1*-79v%n@x(-h*y81q+iwyjg@yo0%ksc_ow1vjpf(0va"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

# les APPLICATIONS du projet : chacune est un paquet Python (un dossier), qui
# regroupe les vues, les routes, et plus tard les modèles et les gabarits d'un même
# domaine
INSTALLED_APPS = [
    "accueil",
    "premier",
]
  • lignes 17-20 : le réglage [INSTALLED_APPS] énumère les applications du projet, désignées par le nom de leur paquet. [Django] les charge au démarrage, dans cet ordre. Pour notre exemple, la déclaration n'est pas indispensable : les vues de [accueil] et de [premier] sont trouvées par les imports des fichiers de routes. Elle le deviendra dès que les applications apporteront des gabarits (exemple 13), des balises de gabarits (exemple 16), des traductions (exemple 22) ou des modèles de l'ORM (exemple 28), que [Django] ne cherche que dans les applications déclarées. [Django] fournit lui-même plusieurs applications ([django.contrib.sessions], [django.contrib.messages], [django.contrib.staticfiles], [django.contrib.admin]...), que l'on déclare de la même façon.

2.2.1. La vue de l'application accueil

# accueil/views.py : la page d'accueil

from django.http import HttpResponse
from django.views.decorators.http import require_GET


# GET / : une page HTML qui contient des liens vers les vues de l'application premier.
# Le décorateur @require_GET refuse toute autre méthode HTTP que GET (même HEAD)
@require_GET
def index(request):
    return HttpResponse("""
<h2>Exemple 02 - le routage</h2>
<ul>
  <li><a href="/premier/">/premier/</a></li>
  <li><a href="/premier/action01">/premier/action01</a></li>
  <li><a href="/premier/produits/17">/premier/produits/17</a></li>
  <li><a href="/premier/produits/17/avis/3">/premier/produits/17/avis/3</a></li>
  <li><a href="/premier/fichiers/images/logo.png">/premier/fichiers/images/logo.png</a></li>
  <li><a href="/premier/produits/abc">/premier/produits/abc</a></li>
  <li><a href="/inconnue">/inconnue</a></li>
</ul>
""")
  • ligne 4 : l'import d'un décorateur de [Django] ;
  • lignes 9-10 : un décorateur est une fonction qui en enveloppe une autre, pour modifier son comportement. On l'écrit [@nom] au-dessus de la définition de la fonction : [@require_GET] au-dessus de [def index] équivaut à écrire, après la définition, [index = require_GET(index)]. La fonction [index] est remplacée par une fonction qui vérifie d'abord la méthode HTTP de la requête : si ce n'est pas GET, elle répond elle-même par une erreur 405, sans appeler [index]. Sans décorateur, une vue accepte toutes les méthodes. Le module [django.views.decorators.http] contient aussi [require_POST], [require_safe] (GET et HEAD) et [require_http_methods(["GET", "POST"])]. On remarque que [require_GET] refuse jusqu'à la méthode HEAD (la même requête que GET, sans le corps de la réponse) : il faut [require_safe] pour l'accepter. Par convention, un nom de fonction ou de variable [Python] s'écrit en minuscules, les mots séparés par des soulignés ([require_GET] fait exception), un nom de classe avec une majuscule à chaque mot ([HttpResponse]) ;
  • lignes 11-22 : une chaîne sur plusieurs lignes, délimitée par trois guillemets (["""]). La vue rend du HTML, envoyé avec le type [text/html] (cf. exemple 01).

La vue [index] renvoie ainsi une page HTML qui contient des liens vers les vues de l'autre application :

Image

2.2.2. Les vues de l'application premier

# premier/views.py : les vues des URL /premier...

from django.http import HttpResponse
from django.views import View
from django.views.decorators.http import require_GET


# GET /premier/
@require_GET
def index(request):
    return HttpResponse("Vue [index] de l'application [premier]")


# GET et POST /premier/action01 : la même URL, deux méthodes HTTP, deux méthodes de
# la classe. Une méthode HTTP que la classe ne traite pas (PUT...) est refusée
class Action01(View):
    def get(self, request):
        return HttpResponse("Vue [Action01], méthode [get] : requête GET")

    def post(self, request):
        return HttpResponse("Vue [Action01], méthode [post] : requête POST")


# GET /premier/produits/17 : le paramètre de route id est passé au paramètre id de la
# vue, déjà converti en entier
@require_GET
def produit(request, id):
    return HttpResponse(f"Produit n° {id}")


# GET /premier/produits/17/avis/3 : les paramètres de route sont passés par leur NOM
@require_GET
def avis(request, numero, id):
    return HttpResponse(f"Avis n° {numero} du produit n° {id}")


# GET /premier/fichiers/images/logo.png
@require_GET
def fichier(request, chemin):
    return HttpResponse(f"Fichier demandé : {chemin}")
  • lignes 9-11 : une vue sous forme de fonction, comme à l'exemple 01 ;
  • lignes 16-21 : une vue peut aussi être une classe, qui hérite de la classe [View] de [Django]. En [Python], l'héritage s'écrit en mettant la classe mère entre parenthèses, après le nom de la classe. La classe a une méthode par méthode HTTP traitée, au nom de la méthode HTTP en minuscules : [get] pour GET, [post] pour POST. [Django] appelle celle qui correspond à la requête ; une méthode HTTP qu'aucune méthode de la classe ne traite produit une erreur 405. Une méthode [Python] reçoit toujours en premier paramètre l'objet sur lequel elle est appelée, nommé [self] par convention ; la requête vient ensuite. C'est la façon la plus claire de traiter plusieurs méthodes HTTP sur une même URL (une fonction devrait tester [request.method] elle-même, cf. exemple 11). [Django] propose aussi des vues génériques, des classes toutes prêtes pour les besoins courants ([TemplateView], [ListView], [FormView]...), que ce document n'utilise pas : elles cachent ce que nous voulons justement montrer ;
  • lignes 26-28 : une vue qui reçoit un paramètre de route, [id], après la requête. La valeur est déjà convertie en entier (cf. plus loin). La chaîne [f"Produit n° {id}"] est une f-chaîne (f-string) : les expressions écrites entre accolades sont remplacées par leur valeur ;
  • lignes 32-34 : deux paramètres de route. [Django] les passe à la vue par leur nom (des paramètres nommés) : l'ordre des paramètres de la vue n'a pas d'importance. Ici, [numero] est écrit avant [id] ;
  • lignes 38-40 : un paramètre de route qui contient des barres obliques.

2.2.3. Les routes : config/urls.py et premier/urls.py

Il reste à associer les URL aux vues. La table de routage principale, celle du réglage [ROOT_URLCONF] :

# config/urls.py : la table de routage PRINCIPALE du projet

from django.urls import include, path

from accueil import views as accueil_views

urlpatterns = [
    # GET / : la vue index de l'application accueil ; name : le NOM de la route
    path("", accueil_views.index, name="accueil"),
    # toutes les URL qui commencent par premier/ sont confiées à la table de routage
    # de l'application premier (fichier premier/urls.py), sans ce préfixe
    path("premier/", include("premier.urls")),
]
  • ligne 5 : l'import du module [views] de l'application [accueil], sous un autre nom : [as accueil_views]. Deux applications peuvent avoir chacune un module [views] ; le renommage évite de les confondre ;
  • ligne 9 : la route de la racine du site. Le troisième paramètre, [name], nomme la route : ce nom permettra de fabriquer son URL (exemple 04), sans l'écrire en dur. [Python] accepte les paramètres par position ou par nom ([name="accueil"]) : les paramètres nommés rendent l'appel lisible ;
  • ligne 12 : [include] délègue une partie des URL à une autre table de routage : toutes les URL qui commencent par [premier/] sont confiées aux routes du module [premier.urls], auxquelles [Django] ne passe que la fin du chemin. Chaque application garde ainsi ses routes dans son propre fichier [urls.py], et le projet décide seulement à quel préfixe il les monte. Le préfixe se termine par une barre oblique : c'est l'usage de [Django], où les routes des applications ne commencent jamais par une barre oblique.
# premier/urls.py : les routes de l'application premier. Les chemins sont écrits
# SANS le préfixe premier/, ajouté par l'include de config/urls.py

from django.urls import path

from . import views

# l'ESPACE DE NOMS des routes de ce fichier : la route "produit" s'appelle, pour le
# reste du projet, "premier:produit"
app_name = "premier"

urlpatterns = [
    # GET /premier/ : le chemin "" désigne le préfixe lui-même
    path("", views.index, name="index"),
    # GET et POST /premier/action01 : une vue écrite sous forme de CLASSE, dont chaque
    # méthode traite une méthode HTTP
    path("action01", views.Action01.as_view(), name="action01"),
    # GET /premier/produits/17 : <int:id> est un paramètre de route ; le convertisseur
    # int n'accepte que des chiffres, et passe à la vue un entier
    path("produits/<int:id>", views.produit, name="produit"),
    # GET /premier/produits/17/avis/3 : deux paramètres de route
    path("produits/<int:id>/avis/<int:numero>", views.avis, name="avis"),
    # GET /premier/fichiers/images/logo.png : le convertisseur path accepte n'importe
    # quelle fin de chemin, barres obliques comprises
    path("fichiers/<path:chemin>", views.fichier, name="fichier"),
]
  • ligne 10 : [app_name] donne un espace de noms (namespace) aux routes du fichier : pour le reste du projet, la route nommée [produit] s'appelle [premier:produit]. Deux applications peuvent ainsi avoir chacune une route [index] sans conflit ;
  • ligne 14 : le chemin [""] désigne le préfixe lui-même : l'URL [/premier/] ;
  • ligne 17 : la vue écrite sous forme de classe. [as_view()] est une méthode de la classe [View] qui fabrique la fonction que [Django] appellera pour chaque requête : cette fonction crée un objet [Action01], puis appelle sa méthode [get] ou [post], selon la méthode HTTP de la requête. Un nouvel objet est créé pour chaque requête ;
  • ligne 20 : [<int:id>] est un paramètre de route : ce segment de l'URL peut prendre plusieurs valeurs. [id] est le nom du paramètre, qui sera passé à la vue ; [int] est un convertisseur, qui n'accepte que des chiffres et passe à la vue un entier : pour l'URL [/premier/produits/17], le paramètre [id] de la vue vaut l'entier 17. Si le segment n'est pas fait de chiffres, la route ne correspond pas. C'est la liaison de données (data binding), que le chapitre suivant étudiera en détail ;
  • ligne 22 : deux paramètres de route ;
  • ligne 25 : le convertisseur [path] accepte n'importe quelle fin de chemin, barres obliques comprises. [Django] a cinq convertisseurs : [str] (le convertisseur par défaut : [<nom>] équivaut à [<str:nom>] ; tout texte non vide sans barre oblique), [int] (des chiffres), [slug] (des lettres, des chiffres, des tirets et des soulignés, comme dans [mon-article-01]), [uuid] (un identifiant UUID) et [path]. L'exemple 12 montrera comment écrire un convertisseur « maison », et l'exemple 22 la fonction [re_path], qui décrit une route par une expression régulière.

Pour chaque requête, [Django] parcourt les routes dans l'ordre, et s'arrête à la première qui correspond au chemin demandé : l'ordre des routes compte quand deux routes peuvent correspondre au même chemin.

2.2.4. Exécution

Voici les réponses à quelques requêtes :

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

Vue [index] de l'application [premier]
curl -i http://localhost:8000/premier/produits/17/avis/3
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

Avis n° 3 du produit n° 17
curl -i http://localhost:8000/premier/fichiers/images/logo.png
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

Fichier demandé : images/logo.png

Pour envoyer une requête POST, on utilise l'option [-X POST] de [curl] :

curl -i -X POST http://localhost:8000/premier/action01
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

Vue [Action01], méthode [post] : requête POST

La requête POST est acceptée telle quelle. Ce ne sera plus le cas quand l'application aura le middleware de protection anti-CSRF de [Django] (exemple 18), qui refuse toute requête POST qui ne prouve pas venir d'un formulaire de l'application. Une méthode HTTP que la vue ne traite pas produit une erreur 405 ([Method Not Allowed]). L'entête [Allow] indique les méthodes acceptées :

curl -i -X PUT http://localhost:8000/premier/action01
1
2
3
HTTP/1.1 405 Method Not Allowed
Content-Type: text/html; charset=utf-8
Allow: GET, POST, HEAD, OPTIONS

La réponse n'a pas de corps. La classe [View] accepte, en plus des méthodes de la classe, la méthode HEAD (traitée par la méthode [get]) et la méthode OPTIONS (qui rend la liste des méthodes acceptées). La console du serveur signale le refus :

Method Not Allowed (PUT): /premier/action01
[27/Sep/2026 19:17:38] "PUT /premier/action01 HTTP/1.1" 405 0

Le décorateur [@require_GET] produit la même erreur :

curl -i -X POST http://localhost:8000/premier/produits/17
1
2
3
HTTP/1.1 405 Method Not Allowed
Content-Type: text/html; charset=utf-8
Allow: GET

Une URL qu'aucune route ne traite produit une erreur 404. C'est aussi le cas d'un identifiant qui n'est pas un nombre : le convertisseur [int] empêche la route de correspondre à [/premier/produits/abc].

curl -i http://localhost:8000/premier/produits/abc
1
2
3
4
5
6
7
8
9
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html lang="en">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8">
  <title>Page not found at /premier/produits/abc</title>
...

Dans un navigateur, en mode debug :

Image

La page d'erreur de [Django] explique qu'aucune route ne correspond au chemin demandé, et liste, dans l'ordre, toutes les routes de l'application, avec leur nom : c'est un bon moyen de vérifier ses routes ([Django] n'a pas de commande qui les liste). On remarque que les routes de l'application [premier] ont bien reçu le préfixe [premier/], et que le chemin a été comparé sans sa barre oblique de tête. Le chapitre 6 montrera comment remplacer cette page par une page propre à l'application. Remarquons encore que [Django] distingue [/premier/] de [/premier] : la seconde URL n'a pas de route, et produit une erreur 404. Un middleware de [Django], [CommonMiddleware] (étude de cas), corrige ce cas : quand un chemin sans barre oblique finale n'a pas de route, mais que le même chemin avec la barre oblique en a une, il redirige le navigateur (exemple 04) vers ce second chemin (c'est le réglage [APPEND_SLASH], actif par défaut).

curl -i http://localhost:8000/premier
1
2
3
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
...

2.3. Exemple 03 : les réponses d'une vue

Ce que renvoie une vue détermine la réponse HTTP. L'exemple 03 passe en revue les principaux cas :

03-reponses/
├── config/
│   ├── middleware.py
│   ├── settings.py
│   ├── urls.py
│   └── ...
├── reponses/
│   ├── __init__.py
│   ├── modeles.py
│   ├── urls.py
│   └── views.py
└── manage.py

Les routes de l'application [reponses], montées au préfixe [reponses/] par le fichier [config/urls.py], associent chaque URL [/reponses/xxx] à une vue :

# reponses/urls.py : une route par sorte de réponse

from django.urls import path

from . import views

app_name = "reponses"

urlpatterns = [
    path("texte", views.texte, name="texte"),
    path("html", views.html, name="html"),
    path("json", views.en_json, name="json"),
    path("dictionnaire", views.dictionnaire, name="dictionnaire"),
    path("liste", views.liste, name="liste"),
    path("calcul", views.calcul, name="calcul"),
    path("cree", views.cree, name="cree"),
    path("vide", views.vide, name="vide"),
    path("introuvable", views.introuvable, name="introuvable"),
    path("abandon", views.abandon, name="abandon"),
    path("erreur", views.erreur, name="erreur"),
    path("oubli", views.oubli, name="oubli"),
]
# reponses/views.py : les différentes réponses d'une vue

from dataclasses import asdict

from django.http import Http404, HttpResponse, JsonResponse
from django.views.decorators.http import require_GET

from .modeles import Personne


# une chaîne, annoncée comme du texte brut : le navigateur l'affiche telle quelle
@require_GET
def texte(request):
    return HttpResponse("<h1>Vue [texte]</h1>", content_type="text/plain; charset=utf-8")


# sans type annoncé, Django envoie la réponse avec le type text/html
@require_GET
def html(request):
    return HttpResponse("<h1>Vue [html]</h1>")


# un objet : JsonResponse ne sait sérialiser que les types de base de Python
# (dictionnaires, listes, chaînes, nombres...) ; asdict transforme l'objet en
# dictionnaire. La vue ne s'appelle pas json, pour ne pas masquer le module json
@require_GET
def en_json(request):
    return JsonResponse(asdict(Personne("Martin", "Jean", 42)))


# un dictionnaire : il devient un objet JSON
@require_GET
def dictionnaire(request):
    return JsonResponse({"nom": "Martin", "prenom": "Jean", "age": 42})


# une liste : elle devient un tableau JSON. safe=False : JsonResponse accepte autre
# chose qu'un dictionnaire
@require_GET
def liste(request):
    return JsonResponse(["Mme", "Melle", "Mr"], safe=False)


# une liste calculée : triée, puis transformée élément par élément
@require_GET
def calcul(request):
    nombres = [3, 1, 2]
    nombres.sort()
    return JsonResponse([n * 10 for n in nombres], safe=False)


# le code 201 (Created) au lieu de 200 (OK)
@require_GET
def cree(request):
    return JsonResponse({"message": "ressource créée"}, status=201)


# aucune réponse : le code 204 (No Content) signifie "rien à afficher"
@require_GET
def vide(request):
    return HttpResponse(status=204)


# une erreur "prévue" : une réponse JSON avec le code 404
@require_GET
def introuvable(request):
    return JsonResponse({"message": "Cette ressource n'existe pas"}, status=404)


# la même, par une exception, que Django transforme en réponse 404
@require_GET
def abandon(request):
    raise Http404("Cette ressource n'existe pas non plus")


# une erreur "imprévue" : une exception non traitée produit une réponse 500
@require_GET
def erreur(request):
    raise RuntimeError("Une erreur imprévue")


# l'oubli : une vue DOIT rendre un objet HttpResponse. Celle-ci fabrique bien la
# réponse, mais oublie de la rendre : sans return, une fonction Python rend None
@require_GET
def oubli(request):
    HttpResponse("Une réponse perdue")

Commentons ce code :

  • ligne 5 : les classes de réponse et d'exception du module [django.http] ;
  • ligne 8 : l'import d'une classe d'un module de la même application. Le point de [.modeles] désigne le paquet du fichier en cours ([reponses]) : c'est un import relatif ;
  • lignes 12-14 : [HttpResponse] accepte des paramètres nommés : [content_type] annonce ici du texte brut ([text/plain]) : le navigateur n'interprétera pas les balises. [status] (le code de statut, 200 par défaut) et [headers] (un dictionnaire d'entêtes) sont les autres ;
  • lignes 18-20 : la même chaîne, sans type annoncé : [Django] l'envoie avec le type [text/html], et le navigateur l'interprète ;
  • lignes 26-28 : [JsonResponse] est une classe fille de [HttpResponse] : elle convertit son premier paramètre en JSON, et annonce le type [application/json]. La conversion est faite par le module [json] de la bibliothèque standard, qui ne connaît que les types de base de [Python] : dictionnaires, listes, chaînes, nombres, booléens et [None]. Un objet doit donc d'abord être transformé en dictionnaire : c'est ce que fait la fonction [asdict] (ligne 3) pour une classe de données, comme [Personne], présentée plus bas. La vue s'appelle [en_json] : si elle s'appelait [json], elle masquerait, dans ce fichier, le module [json] s'il était importé ;
  • lignes 32-34 : un dictionnaire [Python], écrit entre accolades, devient un objet JSON ;
  • lignes 39-41 : une liste devient un tableau JSON. Par défaut, [JsonResponse] n'accepte qu'un dictionnaire : c'est une ancienne précaution contre une faille des navigateurs d'autrefois, qui permettait à un site tiers de lire un tableau JSON. Le paramètre [safe=False] lève cette restriction ;
  • lignes 45-49 : une liste calculée. La méthode [sort] trie la liste (elle la modifie elle-même). [[n * 10 for n in nombres]] est une liste en compréhension : la liste des valeurs [n * 10], pour chaque élément [n] de la liste [nombres] ;
  • lignes 53-55 : le paramètre [status] : 201 ([Created]) remplace 200 ;
  • lignes 59-61 : le code 204 ([No Content]) signifie « rien à afficher » : la réponse n'a pas de corps ;
  • lignes 65-67 : une erreur « prévue » : une réponse 404 ([Not Found]), avec un corps JSON choisi par la vue. Le texte est entre guillemets parce qu'il contient une apostrophe ;
  • lignes 71-73 : la même erreur, produite par une exception. [Http404] est une exception de [Django] ; l'instruction [raise] la lève, ce qui interrompt la vue. [Django] la transforme en réponse d'erreur 404. C'est la façon la plus courante d'arrêter une vue en erreur, même au fond d'une fonction appelée par la vue. Le module [django.shortcuts] offre deux raccourcis qui la lèvent : [get_object_or_404] et [get_list_or_404] (pour les modèles de l'ORM, chapitre 7). [django.core.exceptions] définit les exceptions qui produisent les autres codes d'erreur : [PermissionDenied] (403), [BadRequest] (400), [SuspiciousOperation] (400) ;
  • lignes 77-79 : une erreur « imprévue » : une exception que personne ne traite produit une réponse 500 ([Internal Server Error]). [RuntimeError] est une classe d'exception de la bibliothèque standard de [Python], disponible sans import ;
  • lignes 84-86 : l'erreur à ne pas commettre. Une vue [Django] doit rendre un objet [HttpResponse] ; celle-ci fabrique bien la réponse, mais oublie de la rendre. Une fonction [Python] sans [return] rend la valeur [None], que [Django] refuse (nous verrons la réponse plus bas). C'est une différence avec d'autres frameworks, comme [Flask], qui transforment eux-mêmes un dictionnaire ou une chaîne en réponse : [Django] veut que la vue dise explicitement quelle réponse elle rend.

La classe [Personne] :

# reponses/modeles.py : une personne

from dataclasses import dataclass


# @dataclass écrit lui-même le constructeur de la classe (__init__), d'après ses
# attributs typés ; frozen=True : les attributs ne peuvent plus changer après la
# construction
@dataclass(frozen=True)
class Personne:
    nom: str
    prenom: str
    age: int
  • ligne 3 : le module [dataclasses] de la bibliothèque standard ;
  • lignes 9-13 : une classe de données. Les lignes [nom: str] déclarent des attributs, avec leur type (des annotations de type, que [Python] n'impose pas à l'exécution, mais que l'éditeur de code vérifie). Le décorateur [@dataclass] écrit lui-même, d'après ces attributs, le constructeur de la classe (la méthode [__init__], appelée par [Personne("Martin", "Jean", 42)]), sa comparaison ([==]) et sa représentation en texte ([Personne(nom='Martin', prenom='Jean', age=42)]). Avec [frozen=True], les attributs ne peuvent plus être modifiés après la construction : un objet [Personne] est immuable. Rien n'oblige à ranger cette classe dans un module [modeles.py] : c'est un choix de ce document, qui réserve le nom [models.py] aux modèles de l'ORM de [Django] (chapitre 7).

Reste le fichier [config/middleware.py] :

# config/middleware.py : un JSON lisible par un humain

import json

from django.http import JsonResponse


# un MIDDLEWARE : une classe dont Django crée UN objet au démarrage de l'application,
# en lui passant get_response, la suite du traitement (les autres middlewares, puis
# la vue). L'objet est ensuite appelé pour chaque requête
class JsonLisible:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # avant la vue : rien à faire ici. La vue produit la réponse...
        response = self.get_response(request)
        # ... que le middleware peut modifier avant son envoi
        if isinstance(response, JsonResponse):
            # "créée" au lieu de "cr\u00e9\u00e9e", et une présentation indentée,
            # sur plusieurs lignes
            donnees = json.loads(response.content)
            response.content = json.dumps(donnees, ensure_ascii=False, indent=4)
        return response

Par défaut, une réponse [JsonResponse] contient un JSON compact, sur une seule ligne, dont les caractères non ASCII sont écrits sous forme d'échappements : [créée] devient [créée], ce qui est du JSON valide, mais peu lisible. Pour les exemples de ce cours, nous voulons un JSON lisible. Plutôt que de le demander dans chaque vue (le paramètre [json_dumps_params] de [JsonResponse] le permettrait), nous modifions toutes les réponses JSON de l'application, grâce à un mécanisme central de [Django] : les middlewares.

Un middleware est un maillon de la chaîne qui mène de la requête à la vue, puis de la vue à la réponse. [Django] enchaîne les middlewares du réglage [MIDDLEWARE] comme des poupées russes : le premier reçoit la requête, et la passe au suivant, qui la passe au suivant, et ainsi de suite jusqu'à la vue ; la réponse de la vue remonte ensuite la chaîne dans l'ordre inverse. Chaque middleware peut examiner, ou modifier, la requête à l'aller et la réponse au retour, et même répondre lui-même, sans appeler la suite. C'est ainsi que [Django] lui-même est construit : la session, la protection anti-CSRF, les messages, la langue sont des middlewares.

  • lignes 11-13 : un middleware est une classe. [Django] en crée un seul objet, au démarrage de l'application, en passant à son constructeur ([__init__]) la fonction [get_response] : la suite de la chaîne (les middlewares suivants, puis la vue). Le constructeur la range dans un attribut de l'objet, [self.get_response] ;
  • lignes 15-24 : la méthode [__call__] rend l'objet appelable comme une fonction : [Django] l'appelle pour chaque requête, avec la requête. Ce qui précède l'appel de [self.get_response(request)] s'exécute avant la vue, ce qui le suit, après : ici, le middleware ne fait rien avant la vue ;
  • lignes 19-23 : si la réponse est une réponse JSON (la fonction [isinstance] teste la classe d'un objet), le middleware relit son corps ([response.content], des octets), le décode ([json.loads]), puis le réencode ([json.dumps]) avec deux options : [ensure_ascii=False] (les caractères non ASCII écrits tels quels) et [indent=4] (une présentation indentée, sur plusieurs lignes). L'affectation de [response.content] remplace le corps de la réponse ;
  • ligne 24 : le middleware rend la réponse, qui continue sa remontée.

Il reste à déclarer le middleware, dans le fichier [config/settings.py] :

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

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-l^yufd#xgw7y7_t=t@o2)h96m-c!z*#%rzk_l7&p09nu!fmwdc"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "reponses",
]

# les MIDDLEWARES : des fonctions que Django appelle pour chaque requête, avant la vue,
# et pour chaque réponse, après elle. Celui-ci rend les réponses JSON lisibles
MIDDLEWARE = [
    "config.middleware.JsonLisible",
]
  • lignes 20-22 : le réglage [MIDDLEWARE] énumère les middlewares de l'application, dans l'ordre où la requête les traverse, désignés par le chemin complet de leur classe. Nous le verrons se remplir au fil des exemples. Les exemples 05 à 12 réutilisent ce middleware.

Dans un navigateur, la différence entre [text/plain] et [text/html] est visible :

Image

Image

Voici les réponses des autres vues :

curl -i http://localhost:8000/reponses/json
1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Content-Type: application/json

{
    "nom": "Martin",
    "prenom": "Jean",
    "age": 42
}

L'objet a été converti en objet JSON, un champ par attribut. La vue [/reponses/dictionnaire] produit exactement la même réponse.

curl -i http://localhost:8000/reponses/liste
curl -i http://localhost:8000/reponses/calcul
HTTP/1.1 200 OK
Content-Type: application/json

[
    "Mme",
    "Melle",
    "Mr"
]
HTTP/1.1 200 OK
Content-Type: application/json

[
    10,
    20,
    30
]
curl -i http://localhost:8000/reponses/cree
1
2
3
4
5
6
HTTP/1.1 201 Created
Content-Type: application/json

{
    "message": "ressource créée"
}
curl -i http://localhost:8000/reponses/vide
HTTP/1.1 204 No Content
Content-Type: text/html; charset=utf-8
curl -i http://localhost:8000/reponses/introuvable
1
2
3
4
5
6
HTTP/1.1 404 Not Found
Content-Type: application/json

{
    "message": "Cette ressource n'existe pas"
}
curl -i http://localhost:8000/reponses/abandon
1
2
3
4
5
6
7
8
9
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html lang="en">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8">
  <title>Page not found at /reponses/abandon</title>
...

La réponse de l'exception est la page d'erreur de [Django], celle de l'exemple 02, avec notre message à la place de la liste des routes. Même quand le client demande une réponse JSON, par l'entête [Accept: application/json] (option [-H] de [curl] : [curl -i -H "Accept: application/json" http://localhost:8000/reponses/abandon]), la réponse reste la page HTML : [Django] n'adapte pas ses réponses d'erreur au type demandé. Une application qui veut des erreurs en JSON les fabrique elle-même, par un middleware (exemple 08). La console signale l'erreur :

Not Found: /reponses/abandon
[27/Sep/2026 19:18:08] "GET /reponses/abandon HTTP/1.1" 404 4941

Reste l'exception imprévue. En mode debug, le navigateur affiche la page d'erreur de [Django] : le type et le message de l'exception, la vue en cours, le fichier et la ligne où elle a été levée, puis la pile d'appels (traceback), la liste des fonctions en cours d'exécution au moment de l'erreur, de la plus ancienne à la plus récente :

Image

Plus bas dans la page, la dernière fonction de la pile est notre vue, [erreur], à la ligne 79 du fichier [reponses/views.py] ; au-dessus, les fonctions de [Django] qui l'ont appelée. Pour chaque fonction, le lien Local vars affiche la valeur de ses variables locales ; la suite de la page affiche la requête (ses paramètres, ses cookies, ses entêtes) et tous les réglages de l'application (ceux dont le nom évoque un secret, comme [SECRET_KEY], sont masqués par des étoiles). C'est pour tout cela que le mode debug ne doit jamais être activé sur un serveur accessible à d'autres. L'erreur est aussi écrite dans le journal de l'application, c'est-à-dire, ici, dans la console du serveur, avec la pile d'appels :

1
2
3
4
5
6
7
Internal Server Error: /reponses/erreur
Traceback (most recent call last):
  ...
  File "/home/claude/django-sept-2026/exemples/03-reponses/reponses/views.py", line 79, in erreur
    raise RuntimeError("Une erreur imprévue")
RuntimeError: Une erreur imprévue
[27/Sep/2026 19:18:59] "GET /reponses/erreur HTTP/1.1" 500 102036

Sans le mode debug ([DEBUG = False] dans le fichier [config/settings.py], avec [ALLOWED_HOSTS = ["localhost"]], puisque la liste ne peut plus être vide), la même requête produit une courte page d'erreur, qui ne révèle rien du code :

HTTP/1.1 500 Internal Server Error
Content-Type: text/html; charset=utf-8


<!doctype html>
<html lang="en">
<head>
  <title>Server Error (500)</title>
</head>
<body>
  <h1>Server Error (500)</h1><p></p>
</body>
</html>

et l'erreur 404 de [/reponses/abandon] devient elle aussi une courte page ([Not Found] : « The requested resource was not found on this server. »), sans notre message. Hors du mode debug, [Django] n'écrit plus non plus la pile d'appels dans la console : il l'envoie par courrier aux administrateurs du site, s'ils sont déclarés (réglage [ADMINS]). L'étude de cas montrera comment configurer le journal. Le chapitre 6 montrera comment afficher une page d'erreur propre à l'application.

Enfin, la vue qui oublie de rendre sa réponse :

curl -i http://localhost:8000/reponses/oubli
1
2
3
4
5
6
7
8
9
HTTP/1.1 500 Internal Server Error
Content-Type: text/html

<!DOCTYPE html>
<html lang="en">
<head>
...
  <title>ValueError
          at /reponses/oubli</title>

La console donne le message de l'exception :

ValueError: The view reponses.views.oubli didn't return an HttpResponse object. It returned None instead.

Le message est clair : « la vue n'a pas rendu d'objet HttpResponse ; elle a rendu None ». C'est une erreur fréquente chez les débutants : dans une vue qui a plusieurs branches ([if] / [else]), l'une d'elles oublie son [return].

2.4. Exemple 04 : les redirections

Une redirection est une réponse qui demande au navigateur d'aller chercher une autre URL : un code 3xx, et l'entête [Location], qui contient la nouvelle adresse. Le navigateur envoie aussitôt une seconde requête, vers cette adresse. Les routes de l'exemple :

1
2
3
4
5
6
7
8
# config/urls.py : la table de routage principale

from django.urls import include, path

urlpatterns = [
    # un include sans préfixe : les routes de l'application sont à la racine du site
    path("", include("redirections.urls")),
]
# redirections/urls.py : les routes de l'exemple 04

from django.urls import path

from . import views

urlpatterns = [
    path("ancienne", views.ancienne, name="ancienne"),
    path("nouvelle", views.nouvelle, name="nouvelle"),
    path("aller/<str:destination>", views.aller, name="aller"),
    path("vers-route/<str:destination>", views.vers_route, name="vers_route"),
    # name : le NOM de la route, qui permet de fabriquer son URL
    path("page/<str:nom>", views.page, name="page"),
    path("documentation", views.documentation, name="documentation"),
]

Le fichier [config/urls.py] monte les routes de l'application [redirections] à la racine du site (préfixe vide). Le fichier [redirections/urls.py] n'a pas de ligne [app_name] : ses routes n'ont pas d'espace de noms, et s'appellent simplement [page], [nouvelle]... Les vues :

# redirections/views.py : les redirections

from django.http import HttpResponse
from django.shortcuts import redirect
from django.views.decorators.http import require_GET


# redirection permanente (301) : la page a définitivement changé d'adresse
@require_GET
def ancienne(request):
    return redirect("/nouvelle", permanent=True)


@require_GET
def nouvelle(request):
    return HttpResponse("Vous êtes sur la page [nouvelle]")


# redirection temporaire (302, le code par défaut), vers une adresse calculée en
# recollant des morceaux de texte
@require_GET
def aller(request, destination):
    return redirect("/page/" + destination)


# redirect reçoit ici le NOM d'une route, et ses paramètres : il fabrique lui-même
# l'adresse, par la fonction reverse, qui code correctement le paramètre nom
@require_GET
def vers_route(request, destination):
    return redirect("page", nom=destination)


@require_GET
def page(request, nom):
    return HttpResponse(f"Vous êtes sur la page [{nom}]")


# redirection vers un autre site
@require_GET
def documentation(request):
    return redirect("https://docs.djangoproject.com/fr/6.1/")
  • ligne 4 : le module [django.shortcuts] réunit des raccourcis, des fonctions qui rendent les services les plus courants en une ligne : [redirect], [render] (le chapitre sur les gabarits), [get_object_or_404]... ;
  • lignes 9-11 : [redirect] fabrique une réponse de redirection (un objet [HttpResponsePermanentRedirect] ou [HttpResponseRedirect], deux classes filles de [HttpResponse]) vers l'adresse donnée. Avec [permanent=True], le code est 301 ([Moved Permanently]) : la page a définitivement changé d'adresse, et le navigateur (et les moteurs de recherche) peuvent retenir la nouvelle ;
  • lignes 21-23 : sans [permanent=True], [redirect] produit une redirection temporaire, de code 302 ([Found]). L'adresse est calculée en recollant des morceaux de texte (l'opérateur [+] concatène deux chaînes), ce qui pose un problème, que nous allons voir ;
  • lignes 28-30 : [redirect] accepte aussi le nom d'une route (['page']), suivi de ses paramètres ([nom=destination]) : il fabrique alors lui-même l'adresse, grâce à la fonction [reverse] de [Django], qui fait le chemin inverse du routage (d'un nom de route et de ses paramètres vers une URL), et code correctement les paramètres pour une URL. Si l'on change un jour le chemin de la route [page], les redirections vers elle suivront d'elles-mêmes. Pour une route d'une application qui a un espace de noms, le nom s'écrit [premier:produit] (exemple 02) ;
  • lignes 40-41 : une redirection vers un autre site : l'adresse est prise telle quelle. [redirect] essaie d'abord de voir dans la chaîne un nom de route ; à défaut, une chaîne qui contient une barre oblique ou un point est prise comme une adresse (sinon, c'est une erreur).

Les réponses, avec [curl] (qui, sans option, ne suit pas les redirections) :

curl -i http://localhost:8000/ancienne
1
2
3
HTTP/1.1 301 Moved Permanently
Content-Type: text/html; charset=utf-8
Location: /nouvelle

L'entête [Location] contient l'adresse telle que la vue l'a donnée, relative au site : le navigateur la complète avec le protocole, la machine et le port de la page en cours. La réponse n'a pas de corps. L'option [-L] de [curl] suit la redirection, comme le ferait un navigateur :

curl -L http://localhost:8000/ancienne
Vous êtes sur la page [nouvelle]

Comparons maintenant les deux redirections calculées, avec une destination qui contient un point d'interrogation, codé [%3F] dans l'URL (un [?] non codé commencerait les paramètres de l'URL) :

curl -i http://localhost:8000/aller/a%3Fb
curl -i http://localhost:8000/vers-route/a%3Fb
1
2
3
4
5
6
7
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /page/a?b

HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /page/a%3Fb

[Django] a décodé le paramètre de route avant de le passer à la vue : [destination] vaut [a?b]. La première vue le recolle tel quel dans l'adresse : [/page/a?b], que le navigateur comprend comme le chemin [/page/a] suivi du paramètre [b]. La seconde passe par la route nommée, dont le paramètre est codé à nouveau : [/page/a%3Fb]. En suivant les redirections :

curl -L http://localhost:8000/aller/a%3Fb
curl -L http://localhost:8000/vers-route/a%3Fb
Vous êtes sur la page [a]
Vous êtes sur la page [a?b]

Seule la seconde arrive sur la bonne page. D'où la règle : une adresse qui contient une valeur variable se fabrique à partir du nom de sa route ([redirect("page", nom=...)], [reverse("page", kwargs={"nom": ...})], et, dans les gabarits, la balise [{% url %}]), jamais en recollant des chaînes. Dans le navigateur, on ne voit que la page finale ; la barre d'adresse affiche la nouvelle URL :

Image

Deux autres codes de redirection existent : 303 ([See Other]) et 307 ([Temporary Redirect]), que l'on obtient en fabriquant soi-même la réponse : [HttpResponseRedirect(url, status=303)]. Après une requête POST, les codes 302 et 303 font envoyer par le navigateur une requête GET vers la nouvelle adresse, alors que 307 (et 308, la version permanente) lui font répéter la requête POST. L'exemple 20 utilisera la redirection après un POST.

2.5. Exemple 05 : les objets requête et réponse

Jusqu'ici, les vues ne se servaient que des paramètres de route, et rendaient une réponse construite d'une ligne. Une vue peut aussi consulter la requête en cours, et construire sa réponse pièce par pièce. L'application s'appelle [requetes] (et non [http], comme dans le cours [Symfony] : [http] est déjà le nom d'un paquet de la bibliothèque standard de [Python], que le nôtre masquerait) ; ses routes sont montées au préfixe [http/] :

# requetes/views.py : les objets requête et réponse

import time

from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
from django.views.decorators.http import require_GET


# la requête : méthode, chemin, paramètres, entêtes... Django passe TOUJOURS à la vue,
# en premier paramètre, l'objet HttpRequest de la requête en cours
@require_GET
def requete(request):
    return JsonResponse({
        "methode": request.method,
        "chemin": request.path,
        "url": request.build_absolute_uri(),
        "parametres": request.META["QUERY_STRING"],
        "navigateur": request.headers.get("User-Agent"),
        "ip": request.META["REMOTE_ADDR"],
    })


# la réponse : on lui ajoute un entête
@require_GET
def entete(request):
    reponse = HttpResponse("Réponse avec un entête ajouté")
    reponse["X-Exemple"] = "05-requete-reponse"
    return reponse


# la réponse construite en une fois : corps, code, entêtes
@require_GET
def reponse(request):
    return HttpResponse("Réponse construite par [HttpResponse(...)]", status=200,
                        headers={"Content-Type": "text/plain; charset=utf-8"})


# une réponse envoyée PAR MORCEAUX : le code et les entêtes partent avec le premier
# morceau, et ne peuvent plus changer ensuite
@require_GET
def flux(request):
    # une fonction GÉNÉRATRICE : chaque yield produit un morceau de la réponse
    def morceaux():
        yield "Début de la réponse... "
        time.sleep(1)
        # trop tard pour annoncer une erreur : le code 200 est déjà parti
        raise RuntimeError("erreur au milieu de la réponse")

    return StreamingHttpResponse(morceaux(), content_type="text/plain; charset=utf-8")
  • lignes 11-20 : le paramètre [request] de la vue, que nous n'avions pas encore utilisé, est l'objet [HttpRequest] de la requête en cours. Ses attributs et ses méthodes donnent tout ce qu'elle contient : sa méthode HTTP ([method]), son chemin ([path]), son URL complète ([build_absolute_uri()], qui ajoute le protocole, la machine et le port), ses entêtes ([headers], un dictionnaire dont les clés ne tiennent pas compte des majuscules : [headers.get("user-agent")] donnerait le même résultat). L'attribut [META] est un dictionnaire qui contient toutes les variables que le serveur WSGI a transmises : les entêtes (sous des noms comme [HTTP_USER_AGENT]), les paramètres bruts (la partie de l'URL qui suit le [?], [QUERY_STRING]), l'adresse IP du client ([REMOTE_ADDR])... La méthode [get] d'un dictionnaire rend la valeur d'une clé, ou [None] si la clé n'existe pas, là où [d["clé"]] lèverait une exception [KeyError]. Les paramètres, un par un, seront l'objet du chapitre suivant ;
  • lignes 24-28 : la réponse est d'abord créée, puis complétée : un objet [HttpResponse] se manipule comme un dictionnaire d'entêtes. [reponse["X-Exemple"] = ...] ajoute l'entête [X-Exemple] (par convention, les entêtes propres à une application commencent souvent par [X-]) ; [reponse.headers] donne aussi accès à ces entêtes ;
  • lignes 32-35 : la réponse construite en une fois : le corps, le code de statut et les entêtes. Un appel de fonction peut s'écrire sur plusieurs lignes : à l'intérieur de parenthèses, [Python] ignore les passages à la ligne ;
  • lignes 40-49 : une réponse envoyée par morceaux (streaming). Un objet [StreamingHttpResponse] reçoit, au lieu d'une chaîne, un itérateur : un objet qui produit des morceaux, les uns après les autres. Ici, c'est le résultat d'une fonction génératrice : une fonction qui contient l'instruction [yield]. Appeler [morceaux()] n'exécute pas la fonction : cela rend un générateur, qui exécutera la fonction pas à pas, d'un [yield] au suivant, chaque fois qu'on lui demandera un morceau. La fonction est définie à l'intérieur de la vue : c'est permis en [Python]. Le serveur envoie le code et les entêtes, puis chaque morceau dès qu'il est produit. La réponse ne connaît pas sa longueur à l'avance. C'est utile pour une réponse très longue, produite petit à petit (un gros fichier CSV, par exemple). Mais une fois le premier morceau parti, le code de statut ne peut plus changer : ici, la fonction lève une exception après le premier morceau. C'est l'erreur à ne pas commettre : une réponse commencée ne peut plus devenir une réponse d'erreur.

Le middleware [JsonLisible] est celui de l'exemple 03. Les réponses :

curl -i "http://localhost:8000/http/requete?nom=martin&age=42"
HTTP/1.1 200 OK
Content-Type: application/json

{
    "methode": "GET",
    "chemin": "/http/requete",
    "url": "http://localhost:8000/http/requete?nom=martin&age=42",
    "parametres": "nom=martin&age=42",
    "navigateur": "curl/8.5.0",
    "ip": "127.0.0.1"
}

Les guillemets autour de l'URL empêchent le terminal d'interpréter le caractère [&]. Dans un navigateur, l'entête [User-Agent] est celui du navigateur.

curl -i http://localhost:8000/http/entete
1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
X-Exemple: 05-requete-reponse

Réponse avec un entête ajouté
curl -i http://localhost:8000/http/reponse
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Réponse construite par [HttpResponse(...)]
curl -i http://localhost:8000/http/flux
1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Début de la réponse...

Au bout d'une seconde, [curl] s'arrête : le serveur a fermé la connexion. Le client a reçu un code 200 et un corps simplement tronqué, sans aucun moyen de savoir qu'une erreur s'est produite. [Django] n'a rien pu faire : quand l'exception est levée, sa vue a fini son travail depuis longtemps (elle a rendu la réponse et son générateur), et c'est le serveur, en demandant le morceau suivant, qui reçoit l'exception. Seule la console du serveur l'indique, avec une pile d'appels qui part du serveur WSGI de [Python], et non de [Django] :

1
2
3
4
5
6
7
Traceback (most recent call last):
  File "/usr/lib/python3.13/wsgiref/handlers.py", line 138, in run
    self.finish_response()
  ...
  File "/home/claude/django-sept-2026/exemples/05-requete-reponse/requetes/views.py", line 47, in morceaux
    raise RuntimeError("erreur au milieu de la réponse")
RuntimeError: erreur au milieu de la réponse

2.6. Exemple 06 : services partagés

Une vue ne devrait pas contenir elle-même la logique de l'application : elle la délègue à des services, des objets qui savent faire une chose (lire des produits, calculer un panier...). Plusieurs vues utilisent souvent le même service. [Symfony], comme [Spring] ou [ASP.NET Core], possède pour cela un conteneur de services, qui crée les services et les fournit automatiquement à ceux qui en ont besoin : c'est l'injection de dépendances. [Django] n'a rien de tel. En [Python], un module n'est exécuté qu'une fois, à son premier import, et tous ceux qui l'importent ensuite reçoivent le même module : un objet rangé dans une variable de module est donc déjà partagé par toute l'application. Les modules font office de conteneur. Il reste à organiser les choses pour que les vues dépendent d'un contrat, et non d'une implémentation. L'exemple 06 organise son code par domaine (les produits, les clients), c'est-à-dire, en [Django], par application :

06-services/
├── clients/
│   ├── __init__.py
│   ├── urls.py
│   └── views.py
├── config/
│   └── ...
├── produits/
│   ├── __init__.py
│   ├── fournisseur.py
│   ├── modeles.py
│   ├── services.py
│   ├── urls.py
│   └── views.py
└── manage.py

2.6.1. Le produit : produits/modeles.py

# produits/modeles.py : un produit

from dataclasses import dataclass


# frozen=True : l'objet est immuable (ses attributs sont fixés par le constructeur)
@dataclass(frozen=True)
class Produit:
    id: int
    nom: str
    # le prix en CENTIMES : un nombre entier, pour des calculs exacts
    prix_centimes: int
  • ligne 7 : une classe de données immuable (cf. exemple 03). Un objet immuable peut être partagé sans risque entre plusieurs parties du code, et entre plusieurs requêtes ;
  • lignes 9-12 : les attributs, avec leur type. Le prix est un entier, le prix en centimes (150 pour 1,50 €). Un nombre à virgule, de type [float], est représenté en binaire, et ne sait pas représenter exactement 0,1 ou 3,2 (en binaire, ces nombres ont une infinité de chiffres) : une somme de prix en [float] peut donner 5.500000000000001. Une somme d'entiers, elle, est toujours exacte. [Python] offre aussi le type [Decimal] (module [decimal]), qui calcule exactement en base 10 : c'est lui que l'ORM de [Django] utilise pour les colonnes décimales d'une base de données.

2.6.2. Le contrat du service et son implémentation : produits/services.py

# produits/services.py : le CONTRAT du service des produits, et une implémentation

from abc import ABC, abstractmethod

from .modeles import Produit


# le CONTRAT : ce que sait faire le service, sans dire comment. ABC (Abstract Base
# Class) : une classe abstraite, dont on ne peut pas créer d'objet
class ProduitsService(ABC):
    @abstractmethod
    def find_all(self) -> list[Produit]:
        ...

    # Produit | None : un produit, ou None s'il n'existe pas
    @abstractmethod
    def find_by_id(self, id: int) -> Produit | None:
        ...


# une IMPLÉMENTATION du contrat, avec des données en mémoire
class ProduitsServiceMemoire(ProduitsService):
    def __init__(self):
        # pour voir, dans la console, combien de fois le service est créé
        print("création du service ProduitsServiceMemoire")
        self._produits = [
            Produit(1, "stylo", 150),
            Produit(2, "cahier", 320),
            Produit(3, "gomme", 80),
        ]

    def find_all(self) -> list[Produit]:
        # une COPIE de la liste : l'appelant ne peut pas modifier celle du service
        return list(self._produits)

    def find_by_id(self, id: int) -> Produit | None:
        # next : le premier élément produit par le générateur, ou None s'il n'y en a pas
        return next((p for p in self._produits if p.id == id), None)
  • lignes 3, 10 : le contrat du service : ce qu'il sait faire, sans dire comment. [Java] ou [C#] l'écriraient par une interface ; [Python] l'écrit par une classe abstraite, qui hérite de la classe [ABC] (Abstract Base Class) du module [abc]. On ne peut pas créer d'objet d'une classe abstraite ;
  • lignes 11-18 : les méthodes du contrat, marquées [@abstractmethod] : une classe fille doit toutes les écrire, sinon on ne pourra pas créer d'objet de cette classe fille non plus. Leur corps, [...] (l'objet [Ellipsis]), ne fait rien : il est là parce qu'une fonction [Python] doit avoir un corps. Les annotations de type décrivent le contrat : [find_all] rend une liste de produits ([list[Produit]]) ; [find_by_id] reçoit un entier et rend un produit, ou [None] s'il n'existe pas ([Produit | None]) ;
  • ligne 22 : l'implémentation hérite du contrat ;
  • lignes 23-30 : le constructeur. [self._produits] est un attribut de l'objet ; le souligné de tête est une convention de [Python] pour dire « privé » : rien n'empêche un autre module de s'en servir, mais il sait qu'il ne devrait pas. La ligne 25 affiche un message dans la console, pour que nous voyions quand le service est créé ;
  • lignes 32-34 : la méthode rend une copie de la liste des produits ([list(...)] fabrique une nouvelle liste) : l'appelant peut la modifier sans toucher aux données du service (les objets [Produit], eux, ne sont pas copiés, mais ils sont immuables) ;
  • lignes 36-38 : [(p for p in self._produits if p.id == id)] est une expression génératrice, cousine de la liste en compréhension de l'exemple 03 : elle produit, un par un, les produits dont l'identifiant est [id]. La fonction [next] rend le premier élément produit, ou la valeur de son second paramètre ([None]) s'il n'y en a aucun.

2.6.3. Le fournisseur du service : produits/fournisseur.py

Les vues ne créent pas le service elles-mêmes : elles le demandent à une fonction, le fournisseur, qui décide quelle implémentation créer, et veille à n'en créer qu'une :

# produits/fournisseur.py : le FOURNISSEUR du service des produits

from functools import cache

from django.conf import settings
from django.utils.module_loading import import_string

from .services import ProduitsService


# @cache : la fonction n'est exécutée qu'une fois ; les appels suivants rendent le
# résultat du premier. Le service est donc créé une seule fois, et PARTAGÉ par toutes
# les vues et toutes les requêtes, tant que le serveur tourne
@cache
def produits_service() -> ProduitsService:
    # la classe choisie par la configuration (réglage PRODUITS_SERVICE)
    classe = import_string(settings.PRODUITS_SERVICE)
    return classe()
  • ligne 5 : [django.conf.settings] donne accès aux réglages de l'application, ceux du fichier [config/settings.py] complétés par les valeurs par défaut : [settings.DEBUG], [settings.LANGUAGE_CODE]... C'est ainsi que tout module de l'application lit sa configuration ;
  • ligne 6 : [import_string] importe un objet désigné par son chemin complet, sous forme de chaîne : c'est ainsi que [Django] trouve les middlewares du réglage [MIDDLEWARE] ;
  • lignes 14-18 : la fonction fournisseur. Elle lit le nom de la classe choisie dans le réglage [PRODUITS_SERVICE], la charge, crée un objet de cette classe ([classe()]) et le rend. Le décorateur [@cache], du module [functools] de la bibliothèque standard, retient le résultat du premier appel : les appels suivants rendent le même objet, sans exécuter la fonction. Le service n'est donc créé qu'une fois, au premier appel, et partagé ensuite par toutes les vues et toutes les requêtes, tant que le serveur tourne. C'est ce qu'on appelle ailleurs un singleton. Il est aussi paresseux : tant qu'aucune vue ne l'a demandé, il n'est pas créé. L'annotation [-> ProduitsService] indique le type rendu : le contrat, et non l'implémentation.

Il reste à choisir l'implémentation, dans le fichier [config/settings.py] :

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

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-%ooh-uyk3pt43f_st+x1hr@b&84t#^-&nia4-xk7v3mpy%1w(j"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "produits",
    "clients",
]

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

# un réglage PROPRE à l'application : le CHOIX de l'implémentation du service des
# produits, désignée par le chemin complet de sa classe (cf. produits/fournisseur.py)
PRODUITS_SERVICE = "produits.services.ProduitsServiceMemoire"
  • ligne 25 : un réglage propre à l'application. [Django] accepte, dans le fichier des réglages, toute variable écrite en majuscules, et la rend disponible par [django.conf.settings]. C'est ici, et ici seulement, que l'on choisit l'implémentation du service. Pour passer à une implémentation qui lit une base de données, il suffirait de changer cette ligne : les vues, qui ne connaissent que le contrat, ne changeraient pas. C'est l'intérêt de l'injection de dépendances, obtenu ici sans conteneur : chaque partie de l'application dépend de contrats, et un seul endroit décide des implémentations. Le paquet [produits] s'appelle aussi [produits] dans le réglage [INSTALLED_APPS] (ligne 15) : [Django] l'importe au démarrage.

2.6.4. Les vues des produits et des clients

# produits/views.py : les vues de /produits

from dataclasses import asdict

from django.http import JsonResponse
from django.views.decorators.http import require_GET

from .fournisseur import produits_service


@require_GET
def liste(request):
    # la vue demande le service à son fournisseur : elle ne connaît que le contrat
    # (ProduitsService), pas l'implémentation choisie
    service = produits_service()
    return JsonResponse([asdict(p) for p in service.find_all()], safe=False)


@require_GET
def un_produit(request, id):
    produit = produits_service().find_by_id(id)
    if produit is None:
        return JsonResponse({"message": f"Produit n° {id} introuvable"}, status=404)
    return JsonResponse(asdict(produit))
  • ligne 8 : la vue importe le fournisseur, et non la classe [ProduitsServiceMemoire] : elle ignore que les produits sont en mémoire ;
  • lignes 12-16 : la vue délègue le travail au service. La liste de produits est transformée en liste de dictionnaires, qui devient un tableau JSON (exemple 03) ;
  • lignes 20-24 : un produit inconnu produit une réponse 404, avec un message en JSON. Le convertisseur [<int:id>] de la route (fichier [produits/urls.py]) garantit que [id] est un entier.
# clients/views.py : une seconde application, qui utilise le MÊME service

from django.http import JsonResponse
from django.utils.formats import number_format
from django.views.decorators.http import require_GET

from produits.fournisseur import produits_service


@require_GET
def panier(request):
    produits = produits_service().find_all()
    # la somme des prix, en centimes : des entiers, donc un calcul exact
    total = sum(p.prix_centimes for p in produits)
    return JsonResponse({"client": "Jean Martin", "articles": len(produits),
                         "total_centimes": total,
                         # number_format : le nombre écrit selon la langue (fr : 5,50)
                         "total": number_format(total / 100, 2) + " €"})
  • lignes 7, 12 : cette seconde application utilise le même service que celle des produits, en l'important du paquet [produits]. Le fournisseur lui rend le même objet ;
  • ligne 14 : [sum] fait la somme des éléments produits par l'expression génératrice ;
  • lignes 15-18 : [len] rend le nombre d'éléments d'une liste. Le total est rendu en centimes, et mis en forme en euros : [number_format], du module [django.utils.formats], écrit un nombre avec deux décimales, selon les conventions de la langue de l'application : la virgule comme séparateur décimal, pour le français. La division [total / 100] produit un [float], mais seulement pour l'affichage : les calculs, eux, ont été faits en entiers. L'exemple 16 montrera une bien meilleure façon de mettre en forme des montants.

Un mot, enfin, sur la durée de vie de ce service partagé. Nous l'avons vu à l'exemple 01 : en [Python], l'application reste en mémoire d'une requête à l'autre. Le service est donc unique pour toute l'application : la requête suivante recevra le même objet que la précédente, et les données qu'une requête y aurait modifiées seraient vues par toutes les suivantes. Pour un service qui ne fait que lire des données fixes, comme celui-ci, c'est parfait : il n'est construit qu'une fois. Pour un service dont les données changent (la liste des tâches de l'exemple 20), c'est commode (les données survivent aux requêtes, sans fichier ni base de données), mais il faut prendre deux précautions : deux requêtes simultanées peuvent modifier les données en même temps, il faudra donc les protéger par un verrou ; et les données disparaissent quand le serveur s'arrête (ou redémarre, après une modification du code). C'est l'inverse exact de [PHP], où chaque requête a ses propres services, recréés à chaque fois.

2.6.5. Exécution

La console du serveur affiche le message du constructeur à la première requête qui demande le service, et seulement à elle :

curl -i http://localhost:8000/produits/
HTTP/1.1 200 OK
Content-Type: application/json

[
    {
        "id": 1,
        "nom": "stylo",
        "prix_centimes": 150
    },
    {
        "id": 2,
        "nom": "cahier",
        "prix_centimes": 320
    },
    {
        "id": 3,
        "nom": "gomme",
        "prix_centimes": 80
    }
]
curl -i http://localhost:8000/produits/9
1
2
3
4
5
6
HTTP/1.1 404 Not Found
Content-Type: application/json

{
    "message": "Produit n° 9 introuvable"
}
curl -i http://localhost:8000/clients/panier
1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: application/json

{
    "client": "Jean Martin",
    "articles": 3,
    "total_centimes": 550,
    "total": "5,50 €"
}

La console du serveur :

1
2
3
4
5
création du service ProduitsServiceMemoire
[27/Sep/2026 19:19:51] "GET /produits/ HTTP/1.1" 200 251
Not Found: /produits/9
[27/Sep/2026 19:19:51] "GET /produits/9 HTTP/1.1" 404 46
[27/Sep/2026 19:19:51] "GET /clients/panier HTTP/1.1" 200 102

Le service n'a été créé qu'une fois, pour les trois requêtes et les deux applications. Si l'on modifie un fichier du projet, le serveur redémarre, et le message réapparaît à la requête suivante.