11. Étude de cas : l'application RdvMedecins
11.1. Le problème
L'application RdvMedecins gère les rendez-vous d'un cabinet de médecins. Chaque médecin a des créneaux horaires (de 8 h 00 à 12 h 00 par tranches de 20 minutes, par exemple) ; un rendez-vous associe un jour, un créneau et un client (un patient). C'est la transposition exacte, en [Django], de l'application [Symfony] du cours précédent (elle-même transposée des versions [Laravel], [Flask], [Spring MVC] et [ASP.NET Core MVC]) : même base de données, mêmes règles de gestion, mêmes pages, mêmes adresses, mêmes textes. Les pages HTML sont fabriquées par le serveur, avec des gabarits de [Django] ; l'accès à la base passe par l'ORM de [Django].
Trois rôles d'utilisateurs se partagent l'application :
Rôle | Qui | Droits |
ADMIN | l'administrateur ([admin]) | consulter les agendas, réserver et annuler pour n'importe quel patient ; gérer (lister, ajouter, modifier, supprimer) les médecins et les clients |
DOCTOR | les médecins | consulter les agendas, réserver et annuler pour n'importe quel patient |
USER | les clients (patients) | consulter les agendas sans voir le nom des autres patients ; réserver pour lui-même ; annuler son rendez-vous ; gérer son compte ; créer son compte |
Les règles de gestion principales :
- pas de rendez-vous dans le passé ;
- un créneau n'est réservé qu'une fois par jour ;
- un patient n'a qu'un rendez-vous à venir à la fois ;
- deux médecins (ou deux clients) ne portent pas les mêmes prénom et nom, et un login n'appartient qu'à un compte ;
- un médecin qui a des rendez-vous ne peut pas être supprimé ; un client est supprimé avec ses rendez-vous ;
- la page de connexion est protégée des robots (captcha, limitation des tentatives), et la création d'un compte client exige d'avoir d'abord résolu le captcha de cette page.
11.2. Utilisation de l'application
11.2.1. La page de connexion

La page propose deux boutons : Se connecter, et Créer un compte client. Tous deux exigent d'avoir recopié les caractères de l'image. Les boutons FR / EN changent la langue :

Une erreur (ici un captcha mal recopié) est signalée en bas de la page, sur un fond rose léger :

11.2.2. L'agenda, vu par l'administrateur et les médecins
Une fois connecté, l'administrateur arrive sur la page de l'agenda. Il choisit un médecin et un jour :


Chaque créneau est libre (bouton Réserver) ou occupé (nom du patient, bouton Annuler). Un clic sur Réserver ouvre une fenêtre qui demande le patient. Cette fenêtre est fabriquée par le serveur, comme le reste de la page : l'adresse contient désormais le paramètre [reserver=5], le numéro du créneau choisi :

Après la réservation, un message confirme le rendez-vous :

Un clic sur Annuler demande une confirmation (ici par le docteur Pélissier, dont la barre de navigation n'a pas les menus d'administration) :

11.2.3. L'espace du patient
Un patient (rôle USER) arrive sur Mon espace : son rendez-vous à venir, qu'il peut annuler, et des liens vers la gestion de son compte et vers l'agenda. Ici, il n'a pas encore de rendez-vous :

Dans l'agenda, il ne voit pas le nom des autres patients, remplacé par « Réservé » : ce nom ne figure même pas dans le code source de la page. Il peut réserver un créneau libre :

La fenêtre de réservation ne lui propose pas de choisir le patient : il réserve pour lui-même.


Son espace montre désormais son rendez-vous à venir, qu'il peut annuler :

Ayant un rendez-vous à venir, il ne peut pas en prendre un autre : aucun bouton Réserver n'est affiché, et, sur l'agenda d'un autre jour ou d'un autre médecin, un message lui explique pourquoi :

La gestion de son compte : modifier ses informations, son mot de passe, ou supprimer son compte (et ses rendez-vous). Le mot de passe actuel est exigé pour toute modification :

Un patient qui tente d'ouvrir une page réservée à l'administrateur (ici [/medecins], tapée à la main) reçoit une page d'erreur :

11.2.4. La gestion des médecins et des clients
L'administrateur a deux menus supplémentaires, Médecins et Clients :


Le formulaire d'ajout d'un médecin, envoyé avec des erreurs :

Un homonyme d'un médecin existant est refusé, même écrit en minuscules et avec un accent (c'est la base de données elle-même qui compare les noms sans tenir compte des majuscules ni des accents, cf. exemple 28) :

La suppression d'un client demande une confirmation :

11.2.5. La création d'un compte client
Depuis la page de connexion, le bouton Créer un compte client n'ouvre le formulaire que si le captcha a été correctement recopié :

Une fois le compte créé, l'utilisateur revient à la page de connexion, son login déjà saisi :

11.2.6. La fin de session
Le jeton de connexion a une durée de vie de deux heures. Au-delà (ou si le jeton a été altéré), la page demandée renvoie à la page de connexion, avec un message :

11.3. Installation et exécution
L'application se trouve dans le dossier [rdvmedecins2-django], à côté du dossier [exemples]. C'est un projet autonome, avec ses propres fichiers [requirements.txt] et [.env], et son propre environnement virtuel. Le fichier [README.md] du dossier reprend tout ce qui suit. L'introduction du document a déjà décrit l'installation ; nous la complétons ici.
11.3.1. Les logiciels nécessaires
Deux logiciels suffisent :
- [Python] 3.12 ou plus récent ([Django] 6.1 exige [Python] 3.12). On vérifie l'installation dans un terminal :
- sous [Linux] et [macOS], la commande s'appelle souvent [python3]. Sous [Linux], la compilation de [mysqlclient] demande en outre quelques paquets du système (cf. annexes) ;
- un serveur [MySQL] 8.4 ou plus récent, par exemple celui de [Laragon] sous [Windows] (cf. annexes). [MariaDB] 10.11 ou plus récent convient aussi. Ce sont les versions minimales de [Django] 6.1 : avec [MySQL] 8.0, [Django] refuse de se connecter ([MySQL 8.4 or later is required]).
L'éditeur conseillé est [Visual Studio Code], avec les extensions [Python] et [Django] (cf. annexes) ; mais tout peut se faire en ligne de commande.
11.3.2. Installer les bibliothèques
Les bibliothèques ne sont pas fournies : on crée, une fois pour toutes, un environnement virtuel dans le dossier de l'application, et on y installe les bibliothèques, dans un terminal ouvert sur le dossier [rdvmedecins2-django] (il faut être connecté à Internet) :
| python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
|
(sous [Linux] et [macOS], la deuxième commande s'écrit [source .venv/bin/activate]). [pip] lit le fichier [requirements.txt] (commenté plus loin), télécharge [Django], [mysqlclient], [PyJWT], [bcrypt], et les bibliothèques dont ils dépendent, et les installe dans le dossier [.venv]. L'environnement doit être activé dans chaque nouveau terminal ([Visual Studio Code] le fait lui-même, une fois l'interpréteur de l'environnement choisi, cf. introduction).
11.3.3. Créer la base de données
La base [dbrdvmedecins2] est créée par le script [database/dbrdvmedecins2.sql]. Avec [Laragon], on ouvre l'outil [HeidiSQL] (bouton Base de données), puis on exécute le script par le menu Fichier / Exécuter un fichier SQL (cf. introduction). En ligne de commande, dans le dossier de l'application :
| mysql -u root -p < database/dbrdvmedecins2.sql
|
L'option [-p] fait demander le mot de passe de [root] ; on l'omet si le compte n'en a pas (c'est le cas avec [Laragon]). C'est la même base que celle des autres versions de l'application : si elle existe déjà, cette étape est facultative. Le script peut être exécuté à tout moment pour revenir aux données de départ.
Toute la configuration d'une installation est dans le fichier [.env] (commenté plus loin). Par défaut, l'application se connecte à [MySQL] sur [127.0.0.1], port 3306, avec l'utilisateur [root] sans mot de passe : les réglages par défaut de [Laragon]. Si votre compte [root] a un mot de passe, écrivez-le dans ce fichier, à la ligne [DB_PASSWORD=]. Une variable d'environnement du système l'emporte sur le fichier (exemple 25) : on peut ainsi donner le mot de passe sans l'écrire dans un fichier du projet.
11.3.5. Lancer l'application
Dans un terminal ouvert sur le dossier [rdvmedecins2-django], l'environnement virtuel activé :
| python manage.py runserver 8081
|
| Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
September 27, 2026 - 19:30:12
Django version 6.1.1, using settings 'config.settings'
Starting WSGI development server at http://127.0.0.1:8081/
Quit the server with CONTROL-C.
|
On ouvre ensuite l'adresse [http://localhost:8081] dans un navigateur, et l'on arrête l'application par [Ctrl-C]. Le port 8081 permet de faire tourner en même temps un exemple du cours (port 8000) ; les autres versions de l'application utilisent elles aussi le port 8081 : il ne faut pas les lancer en même temps.
11.3.6. Les comptes de démonstration
Le mot de passe de chaque compte est son login ; il faut aussi recopier les caractères de l'image, en majuscules ou en minuscules :
Login | Rôle |
[admin] | ADMIN (l'administrateur) |
[pelissier], [tatou], [dupont] | DOCTOR (les médecins) |
[martin], [durand], [bistrou], [german] | USER (les patients) |
Un parcours pour découvrir l'application :
- [admin] : Agenda, Mme Marie PELISSIER, aujourd'hui : un créneau est déjà pris par Melle Brigitte BISTROU. Réserver un créneau pour Mr Jean MARTIN, puis tenter un second rendez-vous pour lui (refusé). Menus Médecins et Clients : ajouter, modifier, supprimer (le docteur PELISSIER, qui a des rendez-vous, ne peut pas être supprimé) ;
- [pelissier] : son agenda du jour s'affiche directement ; l'adresse [/medecins], tapée à la main, donne une page 403 ;
- [martin] : Mon espace, son rendez-vous, l'agenda où les autres patients apparaissent comme « Réservé » (afficher le code source de la page : leur nom n'y figure pas) ; Gérer mon compte ;
- page de connexion : Créer un compte client, puis se connecter avec le nouveau compte ;
- boutons FR / EN : toute l'interface change de langue, dates comprises.
11.3.7. Le mode développement
Le fichier [.env] fourni règle l'application pour le développement ([DJANGO_DEBUG=1]) :
- la console du serveur affiche chaque requête, et chaque ordre SQL envoyé à la base (exemple 28). Par exemple, pour l'affichage d'un agenda (après les deux ordres d'ouverture de la connexion, exemple 28 ; lignes abrégées) :
| (0.000) SELECT `medecins`.`id`, ..., `users`.`role` FROM `medecins` INNER JOIN `users` ON (`medecins`.`user` = `users`.`id`) WHERE `medecins`.`id` = 1 ORDER BY `medecins`.`id` ASC LIMIT 1; args=(1,); alias=default
(0.003) SELECT `rv`.`id`, ..., `clients`.`user` FROM `rv` INNER JOIN `creneaux` ON (`rv`.`id_creneau` = `creneaux`.`id`) INNER JOIN `clients` ON (`rv`.`id_client` = `clients`.`id`) WHERE (`creneaux`.`id_medecin` = 1 AND `rv`.`jour` = '2026-09-28'); args=(1, '2026-09-28'); alias=default
(0.000) SELECT `creneaux`.`id`, ..., `creneaux`.`mfin` FROM `creneaux` WHERE `creneaux`.`id_medecin` = 1 ORDER BY `creneaux`.`hdebut` ASC, `creneaux`.`mdebut` ASC; args=(1,); alias=default
[27/Sep/2026 19:50:21] "GET /agenda?idMedecin=1&jour=2026-09-28 HTTP/1.1" 200 9377
|
On y reconnaît trois requêtes : le médecin et son compte (une jointure), les rendez-vous du jour et leurs clients, les créneaux du médecin. La liste des médecins de la liste déroulante, elle, n'est pas lue : elle vient du cache (plus loin). L'utilisateur connecté n'est pas lu non plus : il est décrit par son jeton (exemple 25) ;
- une exception inattendue affiche la page de débogage de [Django] (exemple 03), avec le message de l'exception et la pile d'appels. En production, l'application affiche à la place sa propre page d'erreur 500 ;
- il n'y a rien à redémarrer après une modification : le serveur de développement redémarre de lui-même quand un fichier [Python] change (exemple 01), et un gabarit modifié est relu à la requête suivante.
Le débogueur de l'extension [Python] de [Visual Studio Code] permet en outre d'exécuter l'application pas à pas : il suffit d'une configuration de lancement de type « Django » (fichier [.vscode/launch.json], que l'extension propose d'écrire), avec les paramètres [runserver 8081 --noreload]. L'extension [django-debug-toolbar] (à installer par [pip]) affiche, à côté de chaque page, le détail de la requête : les ordres SQL et leurs durées, les gabarits utilisés, les réglages, les signaux...
11.3.8. Un serveur de production
Le serveur de développement de [Django] n'est pas fait pour la production. En production, l'application est servie par un serveur WSGI (exemple 01) : [gunicorn] sous [Linux] ([gunicorn config.wsgi:application --workers 4]), [waitress] sous [Windows] ([waitress-serve --port=8081 config.wsgi:application]), généralement derrière un serveur web ([nginx], [Apache]) qui reçoit les connexions HTTPS, sert lui-même les fichiers statiques, et confie le reste au serveur WSGI. Dans le fichier [.env], il faut indiquer le mode production, changer les clés secrètes, déclarer le nom du site, et déclarer que le site est servi en HTTPS :
| DJANGO_DEBUG=0
DJANGO_SECRET_KEY=une-longue-chaîne-aléatoire
DJANGO_ALLOWED_HOSTS=rdvmedecins.exemple.fr
JWT_SECRET=une-autre-longue-chaîne-aléatoire
COOKIE_SECURE=1
|
Une chaîne aléatoire convenable s'obtient avec [Python] lui-même : [python -c "import secrets; print(secrets.token_hex(32))"]. On prépare ensuite l'application par deux commandes :
| python manage.py collectstatic
python manage.py check --deploy
|
La première recopie tous les fichiers statiques (ceux de l'application et ceux de [Django]) dans le dossier [var/static] (réglage [STATIC_ROOT]), que le serveur web servira (exemple 15). La seconde vérifie les réglages de sécurité d'un déploiement, et signale ceux qui manquent (les entêtes HSTS, la redirection vers HTTPS...). Pour essayer le mode production avec le serveur de développement, qui ne sert plus alors les fichiers statiques : [python manage.py runserver 8081 --insecure].
11.3.9. Les problèmes fréquents
Avec [MySQL], la commande [runserver] se connecte à la base dès son démarrage : ses vérifications du projet (system checks, exemple 01) ont besoin de connaître la version du serveur. Une base inaccessible l'empêche donc de démarrer : la console affiche une longue pile d'appels, qui se termine par l'erreur. Par exemple, avec une base qui n'existe pas :
| django.db.utils.OperationalError: (1049, "Unknown database 'dbrdvmedecins2'")
|
Un serveur de production ([gunicorn]...) ne fait pas ces vérifications : l'erreur apparaît alors à la première page qui interroge la base (la page de connexion n'en interroge aucune ; la connexion, si), dans le journal. Les messages les plus fréquents :
Message | Cause |
[ModuleNotFoundError: No module named 'django'] | l'environnement virtuel n'est pas activé, ou [pip install -r requirements.txt] n'a pas été exécuté |
[Django 6.1 requires Python 3.12 or later] (à l'installation) | la version de [Python] est antérieure à 3.12 |
[OperationalError: (2002, "Can't connect to server on '127.0.0.1' ...")] | [MySQL] n'est pas démarré, ou n'écoute pas sur [127.0.0.1:3306] |
[OperationalError: (1045, "Access denied for user 'root'@'localhost' ...")] | le mot de passe de [MySQL] est à indiquer dans [DB_PASSWORD] (cf. « Configurer l'accès à la base ») |
[OperationalError: (1049, "Unknown database 'dbrdvmedecins2'")] | le script [database/dbrdvmedecins2.sql] n'a pas été exécuté |
[NotSupportedError: MySQL 8.4 or later is required] | le serveur [MySQL] est trop ancien : passer à [MySQL] 8.4, ou à [MariaDB] 10.11 |
[ImproperlyConfigured: JWT_SECRET doit comporter au moins 32 caractères (cf. .env).] | la clé secrète des jetons est trop courte dans [.env] |
[Error: That port is already in use.] | le port 8081 est déjà pris (une autre version de l'application tourne-t-elle ?) : l'arrêter, ou choisir un autre port |
11.4. L'architecture de l'application

- le navigateur envoie ses requêtes au serveur ([runserver], ou [nginx] + [gunicorn]), qui les confie à [Django] par l'application WSGI du fichier [config/wsgi.py]. La requête traverse les middlewares (chapitre sur le cycle de vie) : la langue de la requête ([FixerLangue]), la protection anti-CSRF, les messages flash, puis l'authentification : l'utilisateur est établi à partir du jeton JWT de son cookie ([Authentification]) ; après le routage, [ControleAcces] refuse les pages protégées aux visiteurs qui n'y ont pas droit. Les erreurs deviennent des pages HTML ([PageErreur], et les vues d'erreur) ;
- [web] : la couche web. Les vues ([web/vues]), une famille de pages par module, et leurs routes ([web/urls.py]) ; les formulaires et leurs contraintes ([web/formulaires.py]) ; les modèles des gabarits ([web/modeles_vues.py]). Elles passent toutes par un objet unique, [ApplicationModel] ([web/application.py]), qui garde dans le cache les listes de médecins et de clients et relaie les autres demandes à la couche métier ;
- [securite] : les jetons JWT, les middlewares d'authentification et de contrôle d'accès, le captcha, la limitation des tentatives, le laissez-passer de création de compte ;
- [metier] : les règles de gestion et les transactions ; [domaine] : les objets de l'agenda, calculés par la couche métier ;
- [dao] : la couche DAO, un module par table (ou famille de tables) ;
- [modeles] : les modèles de l'ORM (exemple 28), que la couche DAO lit et écrit dans la base ;
- [templates] : les gabarits, qui fabriquent les pages HTML ; [locale] : le dictionnaire anglais ; [static] : la feuille de style, le script de la page et [Bootstrap] ;
- le dossier [config] et le fichier [.env] : la configuration, qui assemble le tout.
Le dossier de l'application (sans [.venv], ni [var]) :
| rdvmedecins2-django/
├── config/
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── dao/
│ ├── README.md
│ ├── __init__.py
│ ├── creneaux.py
│ ├── personnes.py
│ ├── rvs.py
│ └── users.py
├── database/
│ ├── README.md
│ └── dbrdvmedecins2.sql
├── domaine/
│ ├── README.md
│ ├── __init__.py
│ └── agenda.py
├── locale/
│ └── en/
│ └── LC_MESSAGES/
│ ├── django.mo
│ └── django.po
├── metier/
│ ├── README.md
│ ├── __init__.py
│ ├── dates.py
│ ├── descripteur.py
│ ├── donnees.py
│ ├── erreurs.py
│ ├── fournisseur.py
│ ├── metier.py
│ └── service.py
├── modeles/
│ ├── README.md
│ ├── __init__.py
│ ├── apps.py
│ └── models.py
├── securite/
│ ├── README.md
│ ├── __init__.py
│ ├── acces.py
│ ├── auth.py
│ ├── captchas.py
│ ├── dessin_captcha.py
│ ├── erreurs.py
│ ├── limiteur.py
│ ├── middleware.py
│ ├── mots_de_passe.py
│ ├── passe_inscription.py
│ └── utilisateur.py
├── static/
│ ├── css/
│ │ └── styles.css
│ ├── js/
│ │ └── app.js
│ └── lib/
│ └── bootstrap/
├── templates/
│ ├── formulaires/
│ │ └── champ.html
│ ├── fragments/
│ │ ├── captcha.html
│ │ ├── confirmation.html
│ │ ├── erreurs.html
│ │ ├── navbar.html
│ │ ├── reservation.html
│ │ └── succes.html
│ ├── personnes/
│ │ ├── formulaire.html
│ │ └── liste.html
│ ├── README.md
│ ├── agenda.html
│ ├── compte.html
│ ├── erreur.html
│ ├── espace.html
│ ├── inscription.html
│ ├── layout.html
│ ├── login.html
│ └── macros.html
├── web/
│ ├── templatetags/
│ │ ├── __init__.py
│ │ └── rdv.py
│ ├── vues/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── agenda.py
│ │ ├── auth.py
│ │ ├── erreurs.py
│ │ ├── espace.py
│ │ ├── inscription.py
│ │ ├── langue.py
│ │ └── personnes.py
│ ├── README.md
│ ├── __init__.py
│ ├── application.py
│ ├── apps.py
│ ├── contextes.py
│ ├── formulaires.py
│ ├── middleware.py
│ ├── modeles_vues.py
│ ├── outils.py
│ ├── rendu.py
│ └── urls.py
├── .env
├── .gitignore
├── README.md
├── manage.py
└── requirements.txt
|
C'est la structure d'un projet [Django] ordinaire (exemple 01) : un paquet de configuration ([config]), un fichier [manage.py], et des paquets pour le code. Deux seulement sont des applications [Django], déclarées dans [INSTALLED_APPS] : [modeles], parce qu'il contient des modèles de l'ORM (que [Django] ne cherche que dans les applications), et [web], parce qu'il contient une bibliothèque de balises ([templatetags], exemple 16). Les autres ([dao], [metier], [securite], [domaine]) sont de simples paquets [Python], que les autres importent : ils n'ont rien à déclarer à [Django]. [Django] n'impose aucun de ces dossiers, et un projet ordinaire aurait plutôt une application par domaine (les agendas, les comptes...), chacune avec ses modèles, ses vues et ses gabarits ; nous avons préféré, comme dans les autres versions de l'étude de cas, une organisation par couche, celle de l'exemple 28. [Bootstrap] fait cette fois partie de l'application (dossier [static/lib/bootstrap]) : l'étude de cas est autonome.
11.5. La base de données
La base [dbrdvmedecins2] a cinq tables :
- [users] : les utilisateurs autorisés à se connecter ;
- [medecins] et [clients] : les médecins et les patients, chacun lié à son compte utilisateur ;
- [creneaux] : les créneaux horaires de chaque médecin ;
- [rv] : les rendez-vous, un client sur un créneau, un jour donné.
11.5.1. database/dbrdvmedecins2.sql
| -- =========================================================================
-- Base de données [dbrdvmedecins2] - MySQL 8
--
-- Évolution de [dbrdvmedecins] :
-- 1. les tables [medecins] et [clients] reçoivent une colonne [user],
-- clé étrangère vers users(id) : chaque médecin et chaque client est
-- désormais aussi un utilisateur capable de se connecter ;
-- 2. un troisième rôle apparaît : DOCTOR (en plus de ADMIN et USER) ;
-- 3. le script crée une ligne [users] pour chaque médecin (rôle DOCTOR)
-- et pour chaque client (rôle USER) :
-- - login = nom en minuscules
-- - mot de passe = nom en minuscules, haché avec bcrypt (10 tours)
-- - nom affiché = prénom + nom
--
-- 4. contraintes d'unicité, toutes NOMMÉES (l'application reconnaît
-- le nom de la contrainte violée dans le message d'erreur MySQL) :
-- - unq_users_login : un login n'appartient qu'à un seul compte ;
-- - unq_medecins_prenom_nom : deux médecins ne peuvent pas avoir les
-- mêmes prénom et nom ;
-- - unq_clients_prenom_nom : idem pour les clients.
-- Attention : la collation utf8mb4_unicode_ci compare sans tenir compte
-- des majuscules ni des accents - "Hélène DUPONT" et "helene dupont"
-- sont donc considérés comme identiques.
--
-- La table [users] doit maintenant être créée AVANT [medecins] et [clients]
-- (elles la référencent), et supprimée APRÈS elles.
-- =========================================================================
-- le script est écrit en UTF-8
SET NAMES utf8mb4;
-- on repart d'une base vide à chaque exécution du script (pratique en développement)
CREATE DATABASE IF NOT EXISTS dbrdvmedecins2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbrdvmedecins2;
-- ordre de suppression : des tables « filles » vers les tables « mères »
DROP TABLE IF EXISTS rv;
DROP TABLE IF EXISTS creneaux;
DROP TABLE IF EXISTS clients;
DROP TABLE IF EXISTS medecins;
DROP TABLE IF EXISTS users;
-- -------------------------------------------------------------------------
-- table [users] : les utilisateurs autorisés à se connecter à l'application
-- -------------------------------------------------------------------------
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
login VARCHAR(30) NOT NULL, -- identifiant de connexion
password VARCHAR(100) NOT NULL, -- mot de passe HACHÉ (bcrypt) - jamais en clair
nom VARCHAR(40) NOT NULL, -- nom affiché une fois connecté
role VARCHAR(10) NOT NULL, -- 'ADMIN', 'DOCTOR' ou 'USER'
-- un login ne peut appartenir qu'à un seul compte
CONSTRAINT unq_users_login UNIQUE (login)
);
-- -------------------------------------------------------------------------
-- table [medecins] : les médecins du cabinet
-- -------------------------------------------------------------------------
CREATE TABLE medecins (
id INT AUTO_INCREMENT PRIMARY KEY, -- clé primaire (identifiant technique)
version INT NOT NULL DEFAULT 0, -- n° de version de la ligne (verrou optimiste)
titre VARCHAR(5) NOT NULL, -- Melle, Mme, Mr
nom VARCHAR(20) NOT NULL,
prenom VARCHAR(20) NOT NULL,
`user` INT NOT NULL, -- clé étrangère -> users(id)
CONSTRAINT fk_medecins_user FOREIGN KEY (`user`) REFERENCES users(id),
-- un compte par médecin, et un médecin par compte
CONSTRAINT unq_medecins_user UNIQUE (`user`),
-- deux médecins ne peuvent pas porter les mêmes prénom et nom
CONSTRAINT unq_medecins_prenom_nom UNIQUE (prenom, nom)
);
-- -------------------------------------------------------------------------
-- table [clients] : les patients des médecins
-- -------------------------------------------------------------------------
CREATE TABLE clients (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
titre VARCHAR(5) NOT NULL,
nom VARCHAR(20) NOT NULL,
prenom VARCHAR(20) NOT NULL,
`user` INT NOT NULL, -- clé étrangère -> users(id)
CONSTRAINT fk_clients_user FOREIGN KEY (`user`) REFERENCES users(id),
-- un compte par client, et un client par compte
CONSTRAINT unq_clients_user UNIQUE (`user`),
-- deux clients ne peuvent pas porter les mêmes prénom et nom
CONSTRAINT unq_clients_prenom_nom UNIQUE (prenom, nom)
);
-- -------------------------------------------------------------------------
-- table [creneaux] : les créneaux horaires de chaque médecin
-- -------------------------------------------------------------------------
CREATE TABLE creneaux (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
id_medecin INT NOT NULL, -- clé étrangère -> medecins(id)
hdebut INT NOT NULL, -- heure de début du créneau
mdebut INT NOT NULL, -- minute de début du créneau
hfin INT NOT NULL, -- heure de fin du créneau
mfin INT NOT NULL, -- minute de fin du créneau
CONSTRAINT fk_creneaux_medecin FOREIGN KEY (id_medecin) REFERENCES medecins(id)
);
-- -------------------------------------------------------------------------
-- table [rv] : les rendez-vous pris par les clients
-- -------------------------------------------------------------------------
CREATE TABLE rv (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
jour DATE NOT NULL, -- jour du rendez-vous
id_creneau INT NOT NULL, -- clé étrangère -> creneaux(id)
id_client INT NOT NULL, -- clé étrangère -> clients(id)
CONSTRAINT fk_rv_creneau FOREIGN KEY (id_creneau) REFERENCES creneaux(id),
CONSTRAINT fk_rv_client FOREIGN KEY (id_client) REFERENCES clients(id),
-- un même créneau, un même jour, ne peut être réservé qu'une seule fois
CONSTRAINT unq1_rv UNIQUE (jour, id_creneau)
);
-- =========================================================================
-- Données de démonstration
-- =========================================================================
-- les utilisateurs : mots de passe hachés avec bcrypt (10 tours de salage)
-- admin/admin (ADMIN)
-- un compte DOCTOR par médecin, un compte USER par client
-- (le compte de démonstration user/user a disparu : un compte USER est
-- désormais TOUJOURS celui d'un client, qui peut réserver et gérer son compte)
-- (login = mot de passe = nom en minuscules)
INSERT INTO users (login, password, nom, role) VALUES
('admin', '$2b$10$YSNBJXXHX9mp13nPZb6YGu8M1jR6uvPwO1s2X73z0QAqBR7TOQjoq', 'Administrateur', 'ADMIN'),
-- médecins
('pelissier', '$2b$10$DnSDOK5ONrdMxTTr1NKCHOphR3WSp5QAyG9S9ueruw9HwlxI.16ba', 'Marie PELISSIER', 'DOCTOR'),
('tatou', '$2b$10$PeovBO.gmJUKP4PglI/fwu4xtWOYSxoM5VTWjTj7uQ4wcd9P9.htS', 'Paul TATOU', 'DOCTOR'),
('dupont', '$2b$10$az7wvPKC1h0EiAxXwu/PU.vYjzmB04rd5B7QnK0mvxp4niYIHVoc.', 'Sophie DUPONT', 'DOCTOR'),
-- clients
('martin', '$2b$10$GT6S5pp.WdGapFw/UmocXesbC6LEjJfZzgatCUyG9Sf4o1iBNlqZW', 'Jean MARTIN', 'USER'),
('durand', '$2b$10$l2gl/zCder8E7Nh4VxGRX.0.SsO3FDiXt6YwqYq3v9nFzgC/jQCy6', 'Alice DURAND', 'USER'),
('bistrou', '$2b$10$naeRp2YUU6c.QDl5itJMAum3Y5aHdUV6jwIcsbp0D.64jPG4F6E9m', 'Brigitte BISTROU', 'USER'),
('german', '$2b$10$tTWxh3ROWF7NjhZOMnWDMOsZcnSWUiwwfy5C3G0.xhccTXALKyLeW', 'Luc GERMAN', 'USER');
-- les médecins : la colonne [user] est retrouvée à partir du login,
-- plutôt que de supposer les valeurs des clés primaires de [users]
INSERT INTO medecins (titre, nom, prenom, `user`) VALUES
('Mme', 'PELISSIER', 'Marie', (SELECT id FROM users WHERE login = 'pelissier')),
('Mr', 'TATOU', 'Paul', (SELECT id FROM users WHERE login = 'tatou')),
('Melle','DUPONT', 'Sophie', (SELECT id FROM users WHERE login = 'dupont'));
INSERT INTO clients (titre, nom, prenom, `user`) VALUES
('Mr', 'MARTIN', 'Jean', (SELECT id FROM users WHERE login = 'martin')),
('Mme', 'DURAND', 'Alice', (SELECT id FROM users WHERE login = 'durand')),
('Melle','BISTROU', 'Brigitte', (SELECT id FROM users WHERE login = 'bistrou')),
('Mr', 'GERMAN', 'Luc', (SELECT id FROM users WHERE login = 'german'));
-- créneaux du docteur PELISSIER (id_medecin = 1) : de 8h00 à 12h00 par tranches de 20 minutes
INSERT INTO creneaux (id_medecin, hdebut, mdebut, hfin, mfin) VALUES
(1, 8, 0, 8, 20), (1, 8, 20, 8, 40), (1, 8, 40, 9, 0),
(1, 9, 0, 9, 20), (1, 9, 20, 9, 40), (1, 9, 40, 10, 0),
(1, 10, 0, 10, 20), (1, 10, 20, 10, 40), (1, 10, 40, 11, 0),
(1, 11, 0, 11, 20), (1, 11, 20, 11, 40), (1, 11, 40, 12, 0);
-- créneaux du docteur TATOU (id_medecin = 2) : de 14h00 à 18h00 par tranches de 20 minutes
INSERT INTO creneaux (id_medecin, hdebut, mdebut, hfin, mfin) VALUES
(2, 14, 0, 14, 20), (2, 14, 20, 14, 40), (2, 14, 40, 15, 0),
(2, 15, 0, 15, 20), (2, 15, 20, 15, 40), (2, 15, 40, 16, 0),
(2, 16, 0, 16, 20), (2, 16, 20, 16, 40), (2, 16, 40, 17, 0),
(2, 17, 0, 17, 20), (2, 17, 20, 17, 40), (2, 17, 40, 18, 0);
-- un rendez-vous déjà pris, pour voir tout de suite un créneau occupé dans l'agenda
INSERT INTO rv (jour, id_creneau, id_client) VALUES
(CURDATE(), 2, 3);
|
- ligne 33 : la base est créée si elle n'existe pas, avec le jeu de caractères [utf8mb4] et la collation [utf8mb4_unicode_ci], qui compare les chaînes sans tenir compte des majuscules ni des accents (exemple 28) ;
- lignes 37-41 : les tables sont supprimées, puis recréées : le script remet la base dans son état de départ. L'ordre compte : une table ne peut pas être supprimée tant qu'une autre la référence par une clé étrangère ;
- lignes 46-55 : les utilisateurs : un login, un mot de passe haché par [bcrypt] (exemple 25), le nom affiché une fois connecté, et le rôle ([ADMIN], [DOCTOR] ou [USER]). La contrainte [unq_users_login] garantit qu'un login n'appartient qu'à un compte ;
- lignes 60-72 : chaque table a une clé primaire [id], générée par la base, et une colonne [version], le verrou optimiste (exemple 28). La colonne [user] est une clé étrangère vers [users(id)] : la base refuse une valeur qui ne serait pas l'id d'un utilisateur existant. [user] étant un mot du langage SQL, il est entouré d'accents graves. Trois contraintes : [fk_medecins_user] (la clé étrangère), [unq_medecins_user] (un compte par médecin, et un médecin par compte) et [unq_medecins_prenom_nom] (pas d'homonymes). Toutes les contraintes sont nommées : l'application reconnaît une contrainte violée à son nom ;
- lignes 77-89 : la table des clients a exactement les mêmes colonnes ;
- lignes 94-103 : un créneau appartient à un médecin ; ses heures de début et de fin sont rangées en heures et minutes ;
- lignes 108-118 : un rendez-vous : un jour (type [DATE], sans heure), un créneau et un client. La contrainte [unq1_rv] interdit de réserver deux fois un même créneau un même jour : c'est la base qui garantit cette règle, même si deux utilisateurs réservent au même instant ;
- lignes 130-140 : les utilisateurs de démonstration, dont le mot de passe est le login. Les hachages commencent par [$2b$10$] : l'algorithme ([bcrypt], variante [2b]) et le coût (10 : 2^10 tours de calcul). Viennent ensuite le sel (22 caractères) et le hachage. Ces hachages ont été calculés par la version [Flask] de l'application, avec la bibliothèque [bcrypt] de [Python], celle qu'utilise aussi cette version ;
- lignes 144-147 : la colonne [user] de chaque médecin est retrouvée par une sous-requête, à partir du login, plutôt qu'en supposant les valeurs des clés primaires ;
- lignes 170-171 : un rendez-vous déjà pris, aujourd'hui ([CURDATE()]), sur le deuxième créneau du docteur PELISSIER, pour Melle BISTROU : l'agenda montre ainsi tout de suite un créneau occupé.
Contrairement à l'usage de [Django], la base n'est pas créée par les migrations de [Django] (exemple 28) : elle est partagée avec les autres versions de l'application, et le script SQL est commun à toutes. Les modèles sont donc déclarés [managed = False], et la commande [python manage.py makemigrations] n'a rien à faire. La commande [python manage.py inspectdb] permet de comparer la base aux modèles.
11.6. La configuration du projet
11.6.1. requirements.txt
| # les bibliothèques de l'application : pip install -r requirements.txt
Django==6.1.1
mysqlclient==2.3.0
PyJWT==2.15.0
bcrypt==5.0.0
|
Les quatre bibliothèques dont l'application a besoin, avec leur version exacte (cf. introduction) : [Django], le pilote [MySQL] ([mysqlclient], exemple 28), les jetons JWT ([PyJWT], exemple 25), et [bcrypt], pour les mots de passe de la base (voir plus loin). [pip] installe en plus les bibliothèques dont elles dépendent ([asgiref] et [sqlparse], pour [Django]). La commande [pip freeze] écrit la liste exacte de ce qui est installé dans l'environnement, dépendances comprises : c'est le moyen d'obtenir un fichier qui fige toutes les versions. Un projet plus important utiliserait un outil de gestion de projet, comme [uv] ou [Poetry], qui sépare les bibliothèques demandées (fichier [pyproject.toml]) des versions exactes installées (un fichier de verrouillage, comme le [composer.lock] de [PHP]).
11.6.2. .env
| # =========================================================================
# .env : les réglages propres à CETTE installation, lus au démarrage par
# config/settings.py. Chaque ligne NOM=valeur devient une variable
# d'environnement du processus ; une vraie variable d'environnement du système
# l'emporte sur ce fichier.
# =========================================================================
# 1 : développement (pages d'erreur détaillées) ; 0 : production (cf. README.md)
DJANGO_DEBUG=1
# la clé secrète de Django (signatures, jetons anti-CSRF...). En production : une
# autre valeur, longue et aléatoire
DJANGO_SECRET_KEY=Hayf1oPBd4cm9qXHUGusMtUqCFqEyyj1nnwGxKvuJKYI0EDjjn
# les noms d'hôte que l'application accepte de servir (séparés par des virgules)
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
# ---- la base MySQL [dbrdvmedecins2] (script : database/dbrdvmedecins2.sql) : le
# serveur, son port, la base, l'utilisateur et son mot de passe (ceux de Laragon :
# root, sans mot de passe)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=dbrdvmedecins2
DB_USER=root
DB_PASSWORD=
# ---- l'authentification (cf. securite/auth.py)
# clé secrète des jetons JWT : 32 caractères au moins (HMAC-SHA256)
JWT_SECRET=229b83fd9cc502aaebd911d2662753a8393b340edb296724
# durée de validité d'un jeton, en minutes (120 min = 2 h)
JWT_DUREE_MINUTES=120
# 1 en production (site servi en HTTPS) : les cookies ne circulent jamais en clair
COOKIE_SECURE=0
# ---- la protection contre les robots (cf. securite/limiteur.py)
# échecs de connexion autorisés pour un même login depuis une même adresse IP, puis
# blocage pendant cette durée (en minutes) ; une adresse IP est aussi bloquée si elle
# cumule LOGIN_MAX_ECHECS_IP échecs, tous logins confondus (un robot qui essaie des
# milliers de logins différents)
LOGIN_MAX_ECHECS=5
LOGIN_MAX_ECHECS_IP=25
LOGIN_BLOCAGE_MINUTES=15
# comptes clients créés au plus par heure et par adresse IP. En salle de cours, tous
# les postes sortent souvent par la même adresse : augmenter si besoin
INSCRIPTION_MAX_PAR_HEURE=10
|
Le fichier des réglages propres à cette installation (exemple 25) : ils changent d'une machine à l'autre (la base de données, ses identifiants) ou sont secrets (les clés). Dans un vrai projet, ce fichier n'est jamais publié avec le code : chaque installation a le sien, et les secrets de production sont souvent donnés par de vraies variables d'environnement. [Django] ne lit pas lui-même un fichier [.env] : c'est le fichier [config/settings.py] qui le lit (plus loin). La bibliothèque [python-dotenv] le ferait aussi, et [django-environ] y ajouterait la conversion des valeurs.
- ligne 9 : le mode debug (exemple 01) : 1 en développement (pages d'erreur détaillées, ordres SQL dans la console), 0 en production ;
- ligne 12 : la clé secrète de [Django], qui sert aux jetons anti-CSRF (exemple 18) et à toutes les signatures (cookies signés, messages flash, exemples 20 et 23) ;
- ligne 14 : les noms d'hôte acceptés (exemple 01) ;
- lignes 19-23 : la connexion à la base (exemple 28) ;
- lignes 25-43 : les réglages propres à l'application : les jetons JWT (exemple 25), le passage des cookies en mode [Secure] (plus loin), et la protection contre les robots (exemple 27).
11.6.3. config/settings.py
Le fichier des réglages rassemble toute la configuration, écrite en [Python] (exemple 01) :
| # =========================================================================
# config/settings.py : la configuration de l'application RdvMedecins.
#
# Les réglages propres à une installation (mots de passe, clés secrètes, seuils)
# sont lus dans des variables d'environnement, que le fichier .env fournit en
# développement (cf. lire_env). Tout le reste est écrit ici.
# =========================================================================
import os
from pathlib import Path
# le dossier du projet (celui qui contient manage.py)
BASE_DIR = Path(__file__).resolve().parent.parent
# -------------------------------------------------------------------------
# lit le fichier .env : chaque ligne NOM=valeur devient une variable
# d'environnement, SAUF si elle existe déjà (setdefault) : une vraie variable
# d'environnement du système l'emporte sur le fichier. Les lignes vides et les
# commentaires (#) sont ignorés ; des guillemets autour de la valeur sont retirés
# -------------------------------------------------------------------------
def lire_env(fichier: Path) -> None:
if not fichier.exists():
return
for ligne in fichier.read_text(encoding="utf-8").splitlines():
ligne = ligne.strip()
if ligne and not ligne.startswith("#") and "=" in ligne:
nom, valeur = ligne.split("=", 1)
os.environ.setdefault(nom.strip(), valeur.strip().strip('"'))
lire_env(BASE_DIR / ".env")
# une variable d'environnement : un texte, un entier, un booléen (1/0)
def env(nom: str, defaut: str = "") -> str:
return os.environ.get(nom, defaut)
def env_int(nom: str, defaut: int) -> int:
return int(env(nom, str(defaut)))
def env_bool(nom: str, defaut: bool) -> bool:
return env(nom, "1" if defaut else "0") == "1"
# ---- le mode debug et la sécurité
DEBUG = env_bool("DJANGO_DEBUG", False)
SECRET_KEY = env("DJANGO_SECRET_KEY")
ALLOWED_HOSTS = [h.strip() for h in env("DJANGO_ALLOWED_HOSTS", "localhost").split(",")]
# ---- les applications : Django lui-même (fichiers statiques, messages flash,
# formulaires), et nos deux applications : modeles (les tables) et web (les
# balises des gabarits). Les autres dossiers (dao, metier, securite...) sont de
# simples paquets Python : ils n'ont ni modèles, ni gabarits
INSTALLED_APPS = [
"django.contrib.staticfiles",
"django.contrib.messages",
"django.forms",
"modeles",
"web",
]
# ---- les middlewares, dans l'ordre (cf. exemple 24) :
# - FixerLangue : la langue de la requête (cookie rdvmedecins_langue) ;
# - CsrfViewMiddleware : le jeton anti-CSRF de toute requête POST ;
# - MessageMiddleware : les messages flash ;
# - Authentification : l'utilisateur, d'après le jeton JWT de son cookie ;
# - ControleAcces : la règle d'accès de chaque URL (après le routage) ;
# - PageErreur : les erreurs deviennent des pages de l'application
MIDDLEWARE = [
"web.middleware.FixerLangue",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"securite.middleware.Authentification",
"securite.middleware.ControleAcces",
"web.middleware.PageErreur",
]
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
# ---- la base de données : MySQL (ou MariaDB), la base dbrdvmedecins2
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"HOST": env("DB_HOST", "127.0.0.1"),
"PORT": env("DB_PORT", "3306"),
"NAME": env("DB_NAME", "dbrdvmedecins2"),
"USER": env("DB_USER", "root"),
"PASSWORD": env("DB_PASSWORD"),
"OPTIONS": {"charset": "utf8mb4"},
},
}
# ---- le CACHE : des fichiers du dossier var/cache, PARTAGÉS par tous les
# processus du serveur (captchas, compteurs d'échecs, laissez-passer, listes des
# médecins et des clients). Un cache en mémoire (LocMemCache) serait propre à
# chaque processus : un serveur de production en lance plusieurs
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
"LOCATION": BASE_DIR / "var" / "cache",
},
}
# ---- les gabarits (dossier templates) et leurs processeurs de contexte
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.template.context_processors.i18n",
"django.contrib.messages.context_processors.messages",
# l'utilisateur connecté, dans tous les gabarits (cf. web/contextes.py)
"web.contextes.utilisateur",
],
},
},
]
# les champs des formulaires sont dessinés avec les classes de Bootstrap 5 (cf.
# exemple 18 et templates/formulaires/champ.html)
FORM_RENDERER = "web.rendu.RenduBootstrap"
# ---- les langues (cf. exemple 22) : le français par défaut, l'anglais ; les
# dictionnaires sont dans le dossier locale
LANGUAGE_CODE = "fr"
LANGUAGES = [("fr", "Français"), ("en", "English")]
LOCALE_PATHS = [BASE_DIR / "locale"]
# le cookie qui mémorise la langue choisie (cf. web/vues/langue.py)
LANGUAGE_COOKIE_NAME = "rdvmedecins_langue"
# ---- le fuseau horaire : celui du cabinet médical. Il fixe la date du jour (pas
# de rendez-vous dans le passé, cf. metier/dates.py)
TIME_ZONE = "Europe/Paris"
USE_TZ = True
# ---- les fichiers statiques (dossier static) : Bootstrap, la feuille de style, le script
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
# en production, collectstatic y rassemble tous les fichiers statiques (cf. README.md)
STATIC_ROOT = BASE_DIR / "var" / "static"
# ---- les messages flash : dans un cookie signé (cf. exemple 20)
MESSAGE_STORAGE = "django.contrib.messages.storage.cookie.CookieStorage"
# ---- les cookies : jamais lisibles par le JavaScript de la page (HttpOnly, même le
# cookie anti-CSRF, que Django laisse lisible par défaut), jamais envoyés avec une
# requête POST venue d'un autre site (Lax), jamais en clair en production (Secure)
COOKIE_SECURE = env_bool("COOKIE_SECURE", False)
CSRF_COOKIE_NAME = "rdvmedecins_csrf"
CSRF_COOKIE_SECURE = COOKIE_SECURE
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = "Lax"
# un jeton anti-CSRF absent ou faux : notre page d'erreur 419 (cf. web/vues/erreurs.py)
CSRF_FAILURE_VIEW = "web.vues.erreurs.csrf_refuse"
# ---- nos propres réglages : l'authentification (cf. securite/auth.py)...
JWT_SECRET = env("JWT_SECRET")
JWT_DUREE_MINUTES = env_int("JWT_DUREE_MINUTES", 120)
# ... et la protection contre les robots (cf. securite/limiteur.py)
LOGIN_MAX_ECHECS = env_int("LOGIN_MAX_ECHECS", 5)
LOGIN_MAX_ECHECS_IP = env_int("LOGIN_MAX_ECHECS_IP", 25)
LOGIN_BLOCAGE_MINUTES = env_int("LOGIN_BLOCAGE_MINUTES", 15)
INSCRIPTION_MAX_PAR_HEURE = env_int("INSCRIPTION_MAX_PAR_HEURE", 10)
# ---- le journal : en mode debug, les ordres SQL envoyés à la base sont écrits dans
# la console (cf. exemple 28)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {
"django.db.backends": {"handlers": ["console"], "level": "DEBUG"},
},
}
|
- lignes 22-32 : la lecture du fichier [.env]. [read_text] lit tout le fichier, [splitlines] le découpe en lignes. Chaque ligne [NOM=valeur], hors commentaires, devient une variable d'environnement du processus ([split("=", 1)] : couper au premier signe [=] seulement, une valeur pouvant en contenir). [setdefault] n'écrase pas une variable qui existe déjà : une vraie variable d'environnement l'emporte sur le fichier (exemple 25) ;
- lignes 36-45 : trois petites fonctions qui lisent une variable d'environnement, avec une valeur par défaut, et la convertissent (un entier, un booléen écrit 1 ou 0) ;
- lignes 49-51 : le mode debug, la clé secrète, et les noms d'hôte, lus dans l'environnement. En l'absence de [.env], le mode debug est désactivé : c'est le choix prudent. [ALLOWED_HOSTS] est une liste, fabriquée à partir d'une chaîne de noms séparés par des virgules ;
- lignes 57-63 : les applications (voir l'architecture) : trois applications de [Django] (les fichiers statiques, exemple 15 ; les messages flash, exemple 20 ; les gabarits des formulaires, exemple 18) et les deux nôtres ;
- lignes 72-79 : les six middlewares, dans l'ordre où la requête les traverse (exemple 24). L'ordre compte : la langue est fixée d'abord, pour que toutes les pages, même les pages d'erreur, soient dans la bonne langue ; la protection anti-CSRF passe avant l'authentification ; l'authentification (qui établit l'utilisateur) passe avant le contrôle d'accès (qui le juge) ; et [PageErreur], le dernier, est le premier à voir les exceptions qui sortent des vues ([process_exception] est appelée dans l'ordre inverse, exemple 24) ;
- lignes 85-95 : la base de données (exemple 28), dont les paramètres viennent de l'environnement ;
- lignes 101-106 : le cache de l'application (exemple 27). Il est rangé dans des fichiers ([FileBasedCache]), dans le dossier [var/cache], que [Django] crée lui-même : contrairement au cache en mémoire de l'exemple 27, il survit aux redémarrages du serveur (de développement, à chaque modification du code), et il est partagé par tous les processus d'un serveur de production (chapitre sur les portées). L'application y met les listes de médecins et de clients, les captchas, les compteurs de tentatives et les laissez-passer. Avec plusieurs serveurs, on choisirait un cache partagé, comme [Redis] ;
- lignes 109-124 : les gabarits (exemple 13), avec les gabarits des applications ([APP_DIRS], pour ceux de [django.forms], exemple 18), et quatre processeurs de contexte : la requête, la langue (exemple 22), les messages flash (exemple 20), et le nôtre, qui met l'utilisateur connecté à la disposition de tous les gabarits (plus loin) ;
- ligne 128 : le moteur de rendu des formulaires, avec les classes de [Bootstrap] (exemple 18) ;
- lignes 132-136 : les langues et le dictionnaire (exemple 22). Le cookie de la langue a un nom propre à l'application ;
- lignes 140-141 : le fuseau horaire du cabinet. [USE_TZ = True] (la valeur par défaut) fait travailler [Django] en temps universel, et convertir les heures dans ce fuseau pour les afficher ; [timezone.localdate()] rend la date du jour dans ce fuseau. La date du jour sert à une règle de gestion (pas de rendez-vous dans le passé) : à Paris, entre minuit et deux heures du matin l'été, le jour en temps universel est encore la veille : le fuseau du cabinet s'impose ;
- lignes 144-147 : les fichiers statiques (exemple 15) : le dossier [static] de l'application, et le dossier où [collectstatic] les rassemble pour la production ;
- ligne 150 : les messages flash, dans un cookie signé (exemple 20) ;
- lignes 155-161 : les cookies de [Django]. [COOKIE_SECURE], lu dans [.env], sera aussi utilisé pour nos propres cookies (le jeton, la langue...) : en production, sur HTTPS, aucun cookie ne circule en clair. Le cookie du jeton anti-CSRF reçoit un nom propre à l'application ; il est rendu [HttpOnly] (le [JavaScript] de la page n'en a pas besoin : il n'envoie aucune requête lui-même) et [SameSite=Lax]. Un jeton anti-CSRF absent ou faux affichera une page de l'application (plus loin), plutôt que la page 403 de [Django] (exemple 18) ;
- lignes 164-170 : les réglages propres à l'application, lus dans l'environnement : les jetons JWT (exemple 25) et la protection contre les robots (exemple 27) ;
- lignes 174-181 : le journal des ordres SQL, en mode debug (exemple 28).
La commande [python manage.py diffsettings] affiche tous les réglages qui diffèrent des valeurs par défaut de [Django].
11.6.4. config/urls.py
| # config/urls.py : la table de routage principale
from django.urls import include, path
urlpatterns = [
path("", include("web.urls")),
]
# les vues des pages d'erreur que Django fabrique lui-même (cf. web/vues/erreurs.py) :
# en mode debug, Django remplace celles des erreurs 400, 404 et 500 par ses pages
# détaillées
handler400 = "web.vues.erreurs.erreur_400"
handler403 = "web.vues.erreurs.erreur_403"
handler404 = "web.vues.erreurs.erreur_404"
handler500 = "web.vues.erreurs.erreur_500"
|
Toutes les routes de l'application sont dans le fichier [web/urls.py] (la couche web, plus loin), monté à la racine du site (exemple 04). Les quatre vues des pages d'erreur que [Django] fabrique lui-même (exemple 24) sont désignées ici par des chaînes (le chemin complet de la fonction) : [Django] les importera lui-même.
11.7. Le démarrage de l'application
11.7.1. manage.py et config/wsgi.py
Les fichiers [manage.py] et [config/wsgi.py] sont ceux de l'exemple 01 : l'outil en ligne de commande, et l'application WSGI que lancent le serveur de développement et les serveurs de production. Au démarrage, [Django] lit les réglages (le fichier [config/settings.py] lit alors le fichier [.env]), charge les applications de [INSTALLED_APPS] (leurs modèles, leur configuration, exemple 24), crée un objet de chaque middleware (exemple 03), puis attend les requêtes. Tout ce qui est construit au démarrage est partagé par toutes les requêtes, tant que le processus vit : c'est le cas des objets uniques de l'application (la couche métier, la façade [ApplicationModel]), créés à leur première utilisation par leurs fournisseurs (exemple 06).
Deux choses sont vérifiées au démarrage, et une erreur arrête le serveur avant toute requête : la clé des jetons JWT (trop courte, elle est refusée, cf. [securite/auth.py]) et, avec [MySQL], la connexion à la base (cf. « Les problèmes fréquents »).
11.8. Les modèles
Les modèles de l'ORM (exemple 28) : une classe par table, dans le fichier [modeles/models.py] de l'application [modeles].
11.8.1. modeles/apps.py
| # modeles/apps.py : la configuration de l'application modeles
from django.apps import AppConfig
class ModelesConfig(AppConfig):
name = "modeles"
|
La configuration de l'application (exemple 24), réduite à son nom. Elle n'est pas obligatoire : [Django] en fabriquerait une ; nous l'écrivons pour que chaque application ait la sienne.
11.8.2. modeles/models.py
| # =========================================================================
# modeles/models.py : les MODÈLES Django, une classe par table de la base
# dbrdvmedecins2 (cf. exemple 28).
#
# Les tables existent déjà : elles sont créées par le script
# database/dbrdvmedecins2.sql, le même que celui des autres versions de
# l'application. Chaque modèle le dit à Django par managed = False : Django ne
# crée, ne modifie ni ne supprime jamais ces tables (pas de migrations).
#
# Les clés étrangères (ForeignKey, OneToOneField) sont déclarées avec
# on_delete=DO_NOTHING : quand on supprime une ligne, Django ne cherche pas lui-même
# les lignes qui la référencent. C'est la couche métier qui supprime, dans le bon
# ordre, ce qui doit l'être (cf. metier/service.py) ; les contraintes de la base
# refuseraient de toute façon une suppression incomplète.
#
# Toutes les tables ont une colonne [version] : le verrou optimiste, que la couche
# DAO utilise pour ses UPDATE (cf. dao/personnes.py).
# =========================================================================
from django.db import models
# les trois rôles d'un utilisateur (colonne users.role) :
# - ADMIN : l'administrateur - rendez-vous + gestion des médecins et clients ;
# - DOCTOR : un médecin - réserve et annule des rendez-vous ;
# - USER : un client (patient) - réserve pour lui-même, gère son compte.
# TextChoices : une ÉNUMÉRATION de Django, dont chaque valeur est une chaîne (celle
# qui est rangée en base) ; Role("ADMIN") fait le chemin inverse
class Role(models.TextChoices):
ADMIN = "ADMIN"
DOCTOR = "DOCTOR"
USER = "USER"
# un utilisateur autorisé à se connecter (table [users]). Chaque médecin et chaque
# client possède SON compte (colonne [user] des tables [medecins] et [clients]).
# Contrainte d'unicité SQL : unq_users_login (un login n'appartient qu'à un seul
# compte), reconnue par son NOM dans la couche métier (cf. metier/service.py)
class User(models.Model):
id = models.AutoField(primary_key=True)
version = models.IntegerField(default=0)
login = models.CharField(max_length=30)
# le mot de passe HACHÉ (bcrypt), jamais le mot de passe lui-même
password = models.CharField(max_length=100)
# le nom affiché une fois connecté : "Marie PELISSIER"
nom = models.CharField(max_length=40)
# choices : les valeurs possibles de la colonne
role = models.CharField(max_length=10, choices=Role.choices)
class Meta:
db_table = "users"
managed = False
def __str__(self):
return self.login
# -------------------------------------------------------------------------
# ce qu'ont en commun les médecins (table [medecins]) et les clients (table
# [clients]) : un titre, un nom, un prénom - et le compte utilisateur associé
# (colonne [user]).
#
# Les deux tables ont exactement les mêmes colonnes : elles sont décrites UNE fois,
# dans ce modèle ABSTRAIT (abstract = True : on ne crée jamais une "Personne", et
# elle n'a pas de table à elle), dont héritent Medecin et Client ; chacun ne précise
# que le nom de sa table.
#
# Attention : [Client] désigne ici un PATIENT du cabinet médical, pas un client
# HTTP ! C'est le vocabulaire du domaine métier original, conservé tel quel.
# -------------------------------------------------------------------------
class Personne(models.Model):
id = models.AutoField(primary_key=True)
version = models.IntegerField(default=0)
# Mr, Mme, Melle
titre = models.CharField(max_length=5)
nom = models.CharField(max_length=20)
prenom = models.CharField(max_length=20)
# la RELATION vers le compte utilisateur : une personne a UN compte, désigné par
# la clé étrangère [user] (db_column). OneToOneField : un compte par personne,
# une personne par compte. personne.compte rend un objet User, lu en base au
# premier accès - ou dès la requête, si elle le demande (select_related, cf.
# dao/personnes.py). related_name : le nom de la relation inverse,
# user.medecin ou user.client (%(class)s : le nom du modèle, en minuscules)
compte = models.OneToOneField(User, db_column="user", on_delete=models.DO_NOTHING,
related_name="%(class)s")
class Meta:
abstract = True
# "Mme Alice DURAND"
def libelle(self) -> str:
return f"{self.titre} {self.prenom} {self.nom}"
def __str__(self):
return self.libelle()
# un médecin (table [medecins]). Contrainte d'unicité SQL : unq_medecins_prenom_nom
# (deux médecins ne peuvent pas porter les mêmes prénom et nom)
class Medecin(Personne):
class Meta:
db_table = "medecins"
managed = False
# un client, c'est-à-dire un PATIENT (table [clients]). Contrainte d'unicité SQL :
# unq_clients_prenom_nom
class Client(Personne):
class Meta:
db_table = "clients"
managed = False
# un créneau horaire d'un médecin (table [creneaux]) : 8h00 - 8h20, etc. Les
# créneaux sont créés par le script SQL : l'application ne fait que les lire
class Creneau(models.Model):
id = models.AutoField(primary_key=True)
version = models.IntegerField(default=0)
# plusieurs créneaux pour un même médecin : une clé étrangère, [id_medecin]
medecin = models.ForeignKey(Medecin, db_column="id_medecin", on_delete=models.DO_NOTHING,
related_name="creneaux")
hdebut = models.IntegerField()
mdebut = models.IntegerField()
hfin = models.IntegerField()
mfin = models.IntegerField()
class Meta:
db_table = "creneaux"
managed = False
# "8:00 - 8:20" ; :02d : sur deux chiffres, "8:05" plutôt que "8:5"
def horaire(self) -> str:
return f"{self.hdebut}:{self.mdebut:02d} - {self.hfin}:{self.mfin:02d}"
# un rendez-vous (table [rv]) : un client, sur un créneau, un jour donné.
# Contrainte d'unicité SQL : unq1_rv (jour, id_creneau) - un créneau n'est réservé
# qu'une fois par jour ; reconnue par son nom dans la couche métier
class Rv(models.Model):
id = models.AutoField(primary_key=True)
version = models.IntegerField(default=0)
# DateField : la colonne [jour] (type SQL DATE) devient un objet date de Python
jour = models.DateField()
creneau = models.ForeignKey(Creneau, db_column="id_creneau", on_delete=models.DO_NOTHING,
related_name="rvs")
client = models.ForeignKey(Client, db_column="id_client", on_delete=models.DO_NOTHING,
related_name="rvs")
class Meta:
db_table = "rv"
managed = False
|
- lignes 29-32 : les trois rôles, sous la forme d'une énumération de [Django] ([models.TextChoices]), elle-même construite sur les énumérations de [Python] (le module [enum]) : un type dont les seules valeurs possibles sont ses membres, [Role.ADMIN], [Role.DOCTOR] et [Role.USER]. Chaque membre a une valeur, ici la chaîne rangée en base ; un membre de [TextChoices] est d'ailleurs lui-même une chaîne : [Role.ADMIN == "ADMIN"] est vrai. [Role("ADMIN")] rend le membre correspondant (et lève [ValueError] pour une valeur inconnue). [Role.choices] rend la liste des couples (valeur, libellé), que l'on peut donner au paramètre [choices] d'un champ ;
- lignes 39-55 : le modèle de la table [users] : la clé primaire, la version (le verrou optimiste, exemple 28), puis les colonnes. [choices] (ligne 48) documente les valeurs possibles de la colonne ; [Django] s'en sert pour valider un formulaire tiré du modèle et pour l'interface d'administration ; la base, elle, ne vérifie rien. [managed = False] : la table est créée par le script SQL (exemple 28) ;
- lignes 71-95 : les tables [medecins] et [clients] ont les mêmes colonnes. Leur description commune est écrite une fois, dans un modèle abstrait ([abstract = True], ligne 88) : un modèle qui n'a pas de table à lui, et ne sert qu'à être hérité. Les modèles [Medecin] et [Client] en héritent : chacun a les champs de [Personne], dans sa propre table. ([Django] propose aussi de vraies hiérarchies de modèles, où chaque classe fille a une table liée à celle de la classe mère : ce n'est pas le cas ici, où les deux tables sont indépendantes) ;
- lignes 84-85 : la relation vers le compte. [OneToOneField] : une personne a un compte, et un compte appartient au plus à une personne. [db_column] désigne la colonne de la clé étrangère, [user] (sans ce paramètre, [Django] l'appellerait [compte_id], le nom du champ suivi de [_id]). [on_delete] dit ce que fait [Django] quand on supprime le compte : [DO_NOTHING], rien (le commentaire des lignes 10-14 explique ce choix ; [CASCADE] supprimerait aussi la personne, [PROTECT] refuserait la suppression). L'attribut [compte] est un objet [User], et non un numéro : c'est l'intérêt d'un ORM. Quand l'ORM lit une personne, il lit aussi son compte si la requête le demande ([select_related], cf. la couche DAO) ; sinon, il le lira par une requête de plus, au premier accès à [personne.compte]. Le numéro lui-même est toujours disponible, sans requête, sous le nom [compte_id]. [related_name] nomme la relation inverse : [user.medecin] ou [user.client], la personne d'un compte ; [%(class)s] est remplacé par le nom du modèle, en minuscules, ce qui donne un nom différent à chacun des deux modèles qui héritent du champ ;
- lignes 91-95 : le libellé d'une personne, « Mme Alice DURAND », utilisé par toutes les pages ;
- lignes 100-111 : deux modèles qui ne précisent que leur table : tout le reste est hérité. Un [Client] est un patient du cabinet, pas un client HTTP : c'est le vocabulaire du domaine métier original, conservé dans toutes les versions de l'application ;
- lignes 116-133 : un créneau. [ForeignKey] (ligne 120) : plusieurs créneaux pour un même médecin, par la clé étrangère [id_medecin] ; la relation inverse, [medecin.creneaux], rend les créneaux d'un médecin. La méthode [horaire] rend « 8:00 - 8:20 » : dans une f-chaîne, [{self.mdebut:02d}] écrit un entier sur deux chiffres au moins, complété par des zéros ([8:05] plutôt que [8:5]) ;
- lignes 139-151 : un rendez-vous. La colonne [jour] (type SQL [DATE]) devient un objet [date] de [Python] ([DateField]) ; les objets [date] sont immuables ([jour + timedelta(days=1)] rend le lendemain, [jour] est inchangé). Deux relations, vers le créneau et vers le client du rendez-vous.
11.9. Le domaine : l'agenda
11.9.1. domaine/agenda.py
| # =========================================================================
# domaine/agenda.py : l'agenda d'un médecin pour un jour donné. Des objets
# "métier" CALCULÉS par la couche métier (MetierService.get_agenda_medecin_jour),
# qui ne correspondent à aucune table, puis mis en forme pour le gabarit par la
# vue de l'agenda (web/vues/agenda.py).
#
# frozen=True : les attributs sont fixés une fois pour toutes par le constructeur -
# exactement ce qu'il faut pour un objet calculé, que personne ne modifie ensuite.
# =========================================================================
from dataclasses import dataclass
from datetime import date
from modeles.models import Creneau, Medecin, Rv
# un créneau horaire, et le rendez-vous éventuellement pris sur ce créneau un jour
# donné (None si le créneau est libre)
@dataclass(frozen=True)
class CreneauMedecinJour:
creneau: Creneau
rv: Rv | None
# l'agenda complet d'un médecin pour un jour donné : la liste de ses créneaux,
# chacun libre ou occupé
@dataclass(frozen=True)
class AgendaMedecinJour:
medecin: Medecin
jour: date
creneaux_medecin_jour: list[CreneauMedecinJour]
|
Deux objets calculés, qui ne correspondent à aucune table : un créneau et son rendez-vous éventuel un jour donné ; l'agenda d'un médecin pour un jour, la liste de ses créneaux. Ce sont des classes de données immuables (exemple 03), dont les attributs sont typés : [Rv | None] (un rendez-vous, ou [None] pour un créneau libre), [list[CreneauMedecinJour]] (une liste de créneaux). [Python] ne vérifie pas ces types à l'exécution, mais les éditeurs ([Pylance]) et les outils d'analyse ([mypy]) les lisent.
11.10. La couche DAO
La couche DAO (exemple 28) regroupe les requêtes : un module par table (les médecins et les clients partagent le leur), sans règle de gestion. Ses méthodes n'ouvrent pas de transaction : c'est la couche métier qui les appelle, dans les siennes. Elles sont écrites avec les [QuerySet] de l'ORM (exemple 28) ; les valeurs y sont des paramètres de la requête : pas d'injection SQL possible. Les DAO sont des classes, dont la couche métier crée un objet de chacune ; elles n'ont pas d'état : ce sont des regroupements de fonctions.
11.10.1. dao/personnes.py
| # =========================================================================
# dao/personnes.py : la couche DAO commune aux médecins et aux clients.
#
# Les DAO des médecins et des clients seraient identiques : la classe PersonneDao
# les écrit une seule fois. self.modele est le modèle concerné (Medecin ou
# Client) : les requêtes portent sur SA table.
#
# Les requêtes sont écrites avec l'ORM de Django (cf. exemple 28), qui les traduit
# en SQL. Les valeurs (prénom, nom...) deviennent des PARAMÈTRES de l'ordre SQL :
# pas de concaténation de chaînes, donc pas d'injection SQL. Ces méthodes n'ouvrent
# aucune transaction : c'est la couche métier qui les appelle, dans les siennes.
# =========================================================================
from django.db.models import F, QuerySet
from modeles.models import Client, Medecin, Personne
class PersonneDao:
# modele : la CLASSE du modèle, Medecin ou Client (en Python, une classe est
# une valeur comme une autre, que l'on peut ranger dans une variable)
def __init__(self, modele: type[Personne]):
self.modele = modele
# -----------------------------------------------------------------------
# une requête sur la table du modèle, compte utilisateur compris.
# select_related("compte") : la jointure SQL avec la table [users]
# SELECT medecins.*, users.* FROM medecins INNER JOIN users ON ...
# les comptes sont chargés PAR LA MÊME requête. Sans cela, chaque accès à
# personne.compte déclencherait sa propre requête
# -----------------------------------------------------------------------
def _requete(self) -> QuerySet:
return self.modele.objects.select_related("compte")
# liste triée par nom puis prénom
def lister(self) -> list[Personne]:
return list(self._requete().order_by("nom", "prenom"))
# une personne et son compte ; None si elle n'existe pas
def lire(self, id: int) -> Personne | None:
return self._requete().filter(id=id).first()
# la personne dont le compte utilisateur est [id_user] ; compte_id : la
# colonne de la clé étrangère elle-même, sans jointure
def lire_par_user(self, id_user: int) -> Personne | None:
return self._requete().filter(compte_id=id_user).first()
# la personne qui porte ces prénom et nom, s'il y en a une. La comparaison est
# faite par MySQL, donc avec la collation de la base (utf8mb4_unicode_ci : sans
# tenir compte des majuscules ni des accents) - exactement la même règle que
# celle de la contrainte d'unicité SQL
def homonyme(self, prenom: str, nom: str) -> Personne | None:
return self._requete().filter(prenom=prenom, nom=nom).first()
# INSERT : la base génère l'id, que Django range dans l'objet créé
def ajouter(self, titre: str, nom: str, prenom: str, compte) -> Personne:
return self.modele.objects.create(titre=titre, nom=nom, prenom=prenom, compte=compte)
# -----------------------------------------------------------------------
# UPDATE avec VERROU OPTIMISTE (cf. exemple 28) :
# UPDATE medecins SET titre = ?, nom = ?, prenom = ?, version = version + 1
# WHERE id = ? AND version = ?
# rend False si aucune ligne n'a été modifiée : la fiche a changé (ou disparu)
# depuis sa lecture
# -----------------------------------------------------------------------
def modifier(self, id: int, version: int, titre: str, nom: str, prenom: str) -> bool:
return self.modele.objects.filter(id=id, version=version).update(
titre=titre, nom=nom, prenom=prenom, version=F("version") + 1) == 1
# DELETE FROM medecins WHERE id = ?
def supprimer(self, id: int) -> None:
self.modele.objects.filter(id=id).delete()
# le DAO des médecins : tout est hérité de PersonneDao
class MedecinDao(PersonneDao):
def __init__(self):
super().__init__(Medecin)
# le DAO des clients : celui des personnes, plus une méthode de verrouillage
class ClientDao(PersonneDao):
def __init__(self):
super().__init__(Client)
# -----------------------------------------------------------------------
# pose un VERROU sur la ligne d'un client jusqu'à la fin de la transaction en
# cours (SELECT ... FOR UPDATE), et rend son id (None si le client n'existe
# pas). Une seconde transaction qui demande le même verrou ATTEND que la
# première soit terminée. select_for_update : le verrou "pessimiste" de
# Django, qui exige une transaction ouverte.
# Utilisé pour la prise de rendez-vous (cf. MetierService.ajouter_rv) : deux
# réservations simultanées pour le même patient sont ainsi traitées l'une
# APRÈS l'autre, et la seconde voit le rendez-vous créé par la première.
# values_list("id", flat=True) : la seule colonne id, sans objet Client
# -----------------------------------------------------------------------
def verrouiller(self, id: int) -> int | None:
return Client.objects.select_for_update().filter(id=id).values_list("id", flat=True).first()
|
- lignes 19-23 : les DAO des médecins et des clients seraient identiques, au modèle près : la classe [PersonneDao] les écrit une fois, et reçoit le modèle en paramètre de son constructeur. [type[Personne]] est l'annotation d'une classe (et non d'un objet) : [Personne] ou l'une de ses classes filles ;
- lignes 32-33 : la requête de base, commune à toutes les méthodes. [select_related("compte")] demande à l'ORM de lire aussi le compte de chaque personne, par une jointure ([INNER JOIN users], cf. la sortie de la console, plus haut) : une seule requête, au lieu d'une requête de plus par personne. Sans lui, afficher la liste des médecins avec leur login coûterait une requête pour la liste, puis une par médecin : c'est le problème dit « N + 1 requêtes », le piège le plus courant des ORM. Le souligné qui commence le nom de la méthode dit qu'elle est interne à la classe (exemple 06) ;
- lignes 36-46 : les lectures. [compte_id] (ligne 46) désigne la colonne de la clé étrangère elle-même : la condition porte sur la table des personnes, sans jointure ;
- lignes 52-53 : la recherche d'un homonyme. La comparaison est faite par [MySQL], donc avec la collation de la base : sans tenir compte des majuscules ni des accents, exactement comme la contrainte d'unicité (exemple 28) ;
- lignes 56-57 : l'insertion ; [compte] est un objet [User], déjà inséré ;
- lignes 66-68 : la modification, avec son verrou optimiste (exemple 28) : [update] rend le nombre de lignes modifiées, 0 si la version a changé ;
- lignes 71-72 : la suppression, par un [QuerySet] : [delete()] envoie un [DELETE] de toutes les lignes qu'il désigne ;
- lignes 76-78 : le DAO des médecins, qui hérite de tout. [super().__init__(Medecin)] appelle le constructeur de la classe mère avec le modèle ;
- lignes 82-98 : le DAO des clients, avec une méthode de plus. [select_for_update()] ajoute à la requête [FOR UPDATE] : [MySQL] pose un verrou sur les lignes lues, jusqu'à la fin de la transaction en cours. Une seconde transaction qui demande le même verrou attend que la première soit terminée. C'est un verrou « pessimiste » : on bloque, au lieu de détecter après coup comme le verrou optimiste. Il n'est permis que dans une transaction (sinon, l'ORM lève une exception). [values_list("id", flat=True)] ne lit que la colonne [id] : une liste d'entiers, et non d'objets. Le commentaire du code explique à quoi sert ce verrou ; nous y reviendrons avec la couche métier.
11.10.2. dao/creneaux.py
| # dao/creneaux.py : la couche DAO des créneaux horaires
from modeles.models import Creneau
class CreneauDao:
# un créneau et son médecin (une jointure)
def lire(self, id: int) -> Creneau | None:
return Creneau.objects.select_related("medecin").filter(id=id).first()
# les créneaux d'un médecin, dans l'ordre chronologique. medecin_id : la colonne
# de la clé étrangère, comparée à un id
def lister_par_medecin(self, id_medecin: int) -> list[Creneau]:
return list(Creneau.objects.filter(medecin_id=id_medecin).order_by("hdebut", "mdebut"))
# supprime tous les créneaux d'un médecin (suppression d'un médecin) :
# DELETE FROM creneaux WHERE id_medecin = ?
def supprimer_par_medecin(self, id_medecin: int) -> None:
Creneau.objects.filter(medecin_id=id_medecin).delete()
|
Les créneaux d'un médecin, par ordre chronologique ; un créneau et son médecin (une jointure) ; la suppression des créneaux d'un médecin (qui précède la suppression du médecin, clé étrangère oblige).
11.10.3. dao/rvs.py
| # =========================================================================
# dao/rvs.py : la couche DAO des rendez-vous.
#
# La table [rv] ne référence pas directement le médecin : on passe par le créneau.
# L'ORM de Django écrit la jointure lui-même quand un filtre suit une relation :
# creneau__medecin_id (deux soulignés : "le médecin du créneau").
# =========================================================================
from datetime import date
from modeles.models import Client, Creneau, Rv
class RvDao:
# un rendez-vous, son client et son créneau
def lire(self, id: int) -> Rv | None:
return Rv.objects.select_related("client", "creneau").filter(id=id).first()
# les rendez-vous d'un médecin un jour donné, avec leur client
def rv_medecin_jour(self, id_medecin: int, jour: date) -> list[Rv]:
return list(Rv.objects.select_related("client")
.filter(jour=jour, creneau__medecin_id=id_medecin))
# -----------------------------------------------------------------------
# les rendez-vous d'un client à partir du jour [depuis] (inclus), dans l'ordre
# chronologique, avec le créneau ET le médecin de ce créneau (deux jointures :
# select_related("creneau__medecin")). jour__gte : jour >= depuis (gte :
# "greater than or equal")
# -----------------------------------------------------------------------
def rv_a_venir_client(self, id_client: int, depuis: date) -> list[Rv]:
return list(Rv.objects.select_related("creneau__medecin")
.filter(client_id=id_client, jour__gte=depuis)
.order_by("jour", "creneau__hdebut", "creneau__mdebut"))
# nombre de rendez-vous d'un client à partir du jour [depuis] (inclus) :
# count() fait compter la base (SELECT COUNT(*) ...)
def compter_a_venir_client(self, id_client: int, depuis: date) -> int:
return Rv.objects.filter(client_id=id_client, jour__gte=depuis).count()
# nombre de rendez-vous (passés ou futurs) d'un médecin
def compter_par_medecin(self, id_medecin: int) -> int:
return Rv.objects.filter(creneau__medecin_id=id_medecin).count()
# INSERT INTO rv ...
def ajouter(self, jour: date, creneau: Creneau, client: Client) -> Rv:
return Rv.objects.create(jour=jour, creneau=creneau, client=client)
def supprimer(self, id: int) -> None:
Rv.objects.filter(id=id).delete()
# supprime TOUS les rendez-vous (passés et futurs) d'un client
def supprimer_par_client(self, id_client: int) -> None:
Rv.objects.filter(client_id=id_client).delete()
|
- lignes 20-22 : les rendez-vous d'un médecin un jour donné. La table [rv] ne référence pas directement le médecin : [creneau__medecin_id] (deux soulignés) suit la relation vers le créneau, puis compare la colonne [id_medecin] du créneau. L'ORM écrit lui-même la jointure ([INNER JOIN creneaux], cf. la sortie de la console, plus haut). Le client de chaque rendez-vous est lu par la même requête ;
- lignes 30-33 : les rendez-vous à venir d'un client. [select_related("creneau__medecin")] suit deux relations : le créneau, puis son médecin (deux jointures). [jour__gte] : le suffixe [gte] (« greater than or equal », exemple 28) compare par [>=]. Le tri suit lui aussi les relations ;
- lignes 37-42 : [count()] fait compter la base ([SELECT COUNT(*)]), sans lire les lignes ;
- lignes 45-53 : l'insertion et les suppressions.
11.10.4. dao/users.py
| # dao/users.py : la couche DAO des comptes utilisateurs. Créer (ou supprimer) un
# médecin ou un client crée (ou supprime) aussi son compte - cf. metier/service.py
from django.db.models import F
from modeles.models import User
class UserDao:
# None si aucun utilisateur ne porte ce login
def lire_par_login(self, login: str) -> User | None:
return User.objects.filter(login=login).first()
def ajouter(self, login: str, hachage: str, nom: str, role: str) -> User:
return User.objects.create(login=login, password=hachage, nom=nom, role=role)
# UPDATE avec verrou optimiste (cf. dao/personnes.py) ; hachage None : le mot de
# passe est inchangé. **champs : le dictionnaire, "déballé" en paramètres nommés
def modifier(self, id: int, version: int, login: str, nom: str, hachage: str | None) -> bool:
champs = {"login": login, "nom": nom, "version": F("version") + 1}
if hachage is not None:
champs["password"] = hachage
return User.objects.filter(id=id, version=version).update(**champs) == 1
def supprimer(self, id: int) -> None:
User.objects.filter(id=id).delete()
|
Les comptes utilisateurs. La modification (lignes 19-23) a un paramètre facultatif : le hachage du nouveau mot de passe, ou [None] pour le garder. Les colonnes à modifier sont réunies dans un dictionnaire, que [**champs] passe à [update] sous forme de paramètres nommés (le « déballage » d'un dictionnaire, exemple 08).
11.11. La couche métier
La couche métier (exemple 28) : les règles de gestion et les transactions. Elle ne sait rien du web : ni requête, ni réponse, ni gabarit.
11.11.1. metier/dates.py
| # metier/dates.py : la date du jour. Elle sert à des RÈGLES DE GESTION (pas de
# réservation dans le passé, un seul rendez-vous À VENIR par patient) : elle vit
# donc dans la couche métier. La couche web la réutilise.
from datetime import date
from django.utils import timezone
# aujourd'hui, dans le fuseau horaire de l'application (réglage TIME_ZONE)
def aujourdhui() -> date:
return timezone.localdate()
|
La date du jour, dans le fuseau horaire de l'application ([TIME_ZONE], cf. [config/settings.py]) : [timezone.localdate()] convertit l'heure universelle dans ce fuseau, puis rend la date. La date du jour sert à deux règles de gestion (pas de rendez-vous dans le passé, un seul rendez-vous à venir) : elle est donc calculée ici, en un seul endroit.
11.11.2. metier/erreurs.py
| # =========================================================================
# metier/erreurs.py : l'exception levée par la couche métier quand une RÈGLE DE
# GESTION est violée (login déjà pris, médecin qui a encore des rendez-vous,
# créneau déjà réservé...).
#
# Elle porte un CODE (LOGIN_PRIS...), que la couche web peut tester, et le message
# destiné à l'utilisateur. Ce message est traduit en français ou en anglais au
# moment où il est affiché, dans la langue de la page : gettext_lazy rend une
# traduction "paresseuse", qui n'est calculée que lorsqu'on en demande le texte
# (str), et non au chargement du module (cf. exemple 22 et le dossier locale).
#
# Une MetierErreur n'est pas une anomalie : c'est une réponse normale ("ce login
# est déjà utilisé"), affichée à l'utilisateur. Toute autre exception est une vraie
# anomalie, qui aboutit à la page d'erreur 500.
# =========================================================================
from django.utils.translation import gettext_lazy as _
# les codes des erreurs métier
LOGIN_PRIS = "LOGIN_PRIS"
MEDECIN_EXISTE_DEJA = "MEDECIN_EXISTE_DEJA"
CLIENT_EXISTE_DEJA = "CLIENT_EXISTE_DEJA"
MEDECIN_A_DES_RV = "MEDECIN_A_DES_RV"
CRENEAU_DEJA_RESERVE = "CRENEAU_DEJA_RESERVE"
MODIFICATION_CONCURRENTE = "MODIFICATION_CONCURRENTE"
MEDECIN_INTROUVABLE = "MEDECIN_INTROUVABLE"
CLIENT_INTROUVABLE = "CLIENT_INTROUVABLE"
DATE_PASSEE = "DATE_PASSEE"
PATIENT_A_UN_RV = "PATIENT_A_UN_RV"
# le message de chaque code
MESSAGES = {
LOGIN_PRIS: _("Ce login est déjà utilisé par un autre compte."),
MEDECIN_EXISTE_DEJA: _("Un médecin portant ce prénom et ce nom existe déjà."),
CLIENT_EXISTE_DEJA: _("Un client portant ce prénom et ce nom existe déjà."),
MEDECIN_A_DES_RV: _("Ce médecin a des rendez-vous : annulez-les avant de le supprimer."),
CRENEAU_DEJA_RESERVE: _("Ce créneau vient d'être réservé par quelqu'un d'autre."),
MODIFICATION_CONCURRENTE: _("Cette fiche a été modifiée entre-temps par un autre utilisateur. "
"Revenez à la liste et rouvrez-la avant de recommencer."),
MEDECIN_INTROUVABLE: _("Ce médecin n'existe pas (ou plus)."),
CLIENT_INTROUVABLE: _("Ce client n'existe pas (ou plus)."),
DATE_PASSEE: _("On ne peut pas prendre de rendez-vous à une date passée."),
PATIENT_A_UN_RV: _("Ce patient a déjà un rendez-vous à venir : il faut l'annuler "
"avant d'en prendre un autre."),
}
class MetierErreur(Exception):
def __init__(self, code: str):
super().__init__(code)
self.code = code
# le message, dans la langue de la requête en cours ; @property : on l'écrit
# erreur.message, sans parenthèses
@property
def message(self) -> str:
return str(MESSAGES[self.code])
|
- lignes 20-29 : les codes des erreurs métier, des constantes (exemple 09). La couche web peut tester le code d'une erreur, pour l'attacher au bon champ d'un formulaire (plus loin) ;
- lignes 32-45 : le message de chaque code, marqué « à traduire ». [gettext_lazy], importée sous le nom court [_] (l'usage, exemple 22), rend une traduction paresseuse : un objet qui ne cherche sa traduction qu'au moment où l'on en demande le texte. Une traduction ordinaire ([gettext]) serait faite au chargement du module, au démarrage, une fois pour toutes, dans la langue par défaut ; la traduction paresseuse est faite à chaque affichage, dans la langue de la requête en cours. Deux chaînes écrites côte à côte sont recollées par [Python] (ligne 38) ;
- lignes 48-57 : l'exception de la couche métier (exemple 28), qui porte son code. La propriété [message] (exemple 14) rend le message, dans la langue de la requête : [str] force la traduction paresseuse.
Une [MetierErreur] n'est pas une anomalie : c'est une réponse normale (« ce login est déjà utilisé »), affichée à l'utilisateur. Toute autre exception est une vraie anomalie, qui aboutit à la page d'erreur 500.
11.11.3. metier/donnees.py
| # metier/donnees.py : les données saisies dans un formulaire "médecin" ou "client",
# telles que la couche web les transmet à la couche métier
from dataclasses import dataclass
@dataclass(frozen=True)
class DonneesPersonne:
titre: str # Mr, Mme, Melle
nom: str
prenom: str
# création : facultatif (None), vaut par défaut le nom en minuscules
# modification : facultatif, le login est inchangé s'il est absent
login: str | None = None
# création : facultatif, vaut par défaut le login
# modification : facultatif, le mot de passe est inchangé s'il est absent
password: str | None = None
# modification : la version de la fiche au moment où le formulaire a été
# affiché (verrou optimiste)
version: int | None = None
|
Les données d'une personne, telles que la couche web les transmet à la couche métier (exemple 28). Les trois derniers attributs ont une valeur par défaut, [None] : ils sont facultatifs. Le commentaire placé après un attribut (ligne 9) est permis : un commentaire va de [#] jusqu'à la fin de la ligne.
11.11.4. metier/metier.py
| # =========================================================================
# metier/metier.py : le CONTRAT de la couche métier : lectures, agenda,
# rendez-vous, gestion des médecins et des clients.
#
# Une classe abstraite (ABC, cf. exemple 06) décrit les méthodes qu'une classe doit
# avoir, sans dire comment elles sont écrites. MetierService (la couche métier) et
# ApplicationModel (le cache de la couche web) en héritent tous les deux ; Python
# refuse de créer un objet d'une classe à laquelle il manque une méthode abstraite.
#
# X | None : un résultat qui peut être absent (un médecin qui n'existe pas...).
# =========================================================================
from abc import ABC, abstractmethod
from datetime import date
from domaine.agenda import AgendaMedecinJour
from modeles.models import Client, Creneau, Medecin, Rv
from .donnees import DonneesPersonne
class Metier(ABC):
# ------------------------------------------------ lectures
@abstractmethod
def get_all_clients(self) -> list[Client]: ...
@abstractmethod
def get_all_medecins(self) -> list[Medecin]: ...
@abstractmethod
def get_all_creneaux(self, id_medecin: int) -> list[Creneau]: ...
@abstractmethod
def get_rv_medecin_jour(self, id_medecin: int, jour: date) -> list[Rv]: ...
@abstractmethod
def get_client_by_id(self, id: int) -> Client | None: ...
@abstractmethod
def get_medecin_by_id(self, id: int) -> Medecin | None: ...
@abstractmethod
def get_rv_by_id(self, id: int) -> Rv | None: ...
@abstractmethod
def get_creneau_by_id(self, id: int) -> Creneau | None: ...
@abstractmethod
def get_agenda_medecin_jour(self, id_medecin: int, jour: date) -> AgendaMedecinJour | None: ...
# le client (patient) dont le compte utilisateur est [id_user]
@abstractmethod
def get_client_by_user_id(self, id_user: int) -> Client | None: ...
# les rendez-vous d'un client, d'aujourd'hui ou futurs, avec créneau et médecin
@abstractmethod
def get_rv_a_venir_client(self, id_client: int) -> list[Rv]: ...
# ------------------------------------------------ rendez-vous (ADMIN, DOCTOR, USER)
# lève une MetierErreur si :
# - le jour est passé (DATE_PASSEE) ;
# - le patient a déjà un rendez-vous à venir (PATIENT_A_UN_RV) ;
# - le créneau est déjà pris ce jour-là (CRENEAU_DEJA_RESERVE).
@abstractmethod
def ajouter_rv(self, jour: date, creneau: Creneau, client: Client) -> Rv: ...
@abstractmethod
def supprimer_rv(self, rv: Rv) -> None: ...
# ------------------------------------------------ médecins (ADMIN)
# crée le médecin ET son compte utilisateur (rôle DOCTOR)
@abstractmethod
def ajouter_medecin(self, donnees: DonneesPersonne) -> Medecin: ...
# modifie le médecin ET son compte utilisateur
@abstractmethod
def modifier_medecin(self, id: int, donnees: DonneesPersonne) -> Medecin: ...
# supprime le médecin, ses créneaux ET son compte - refusé s'il a des rendez-vous
@abstractmethod
def supprimer_medecin(self, id: int) -> None: ...
# ------------------------------------------------ clients
# (l'ADMIN ; ajouter_client sert aussi à l'inscription d'un patient,
# modifier_client et supprimer_client au patient pour son propre compte)
# crée le client ET son compte utilisateur (rôle USER)
@abstractmethod
def ajouter_client(self, donnees: DonneesPersonne) -> Client: ...
@abstractmethod
def modifier_client(self, id: int, donnees: DonneesPersonne) -> Client: ...
# supprime le client, son compte ET tous ses rendez-vous (passés et futurs)
@abstractmethod
def supprimer_client(self, id: int) -> None: ...
|
Le contrat de la couche métier, une classe abstraite (exemple 06) : toutes les méthodes, avec leurs paramètres et leurs types, et, en commentaire, les règles qu'elles appliquent. Le corps d'une méthode abstraite peut s'écrire sur la même ligne que sa signature ([: ...]). Deux classes héritent de ce contrat : [MetierService], la couche métier elle-même, et [ApplicationModel], la façade de la couche web (plus loin), qui peut ainsi remplacer la couche métier partout où l'on attend un objet [Metier].
11.11.5. metier/descripteur.py
| # metier/descripteur.py : tout ce qui distingue un médecin d'un client pour les
# règles de gestion (cf. metier/service.py). Les médecins et les clients suivent les
# mêmes règles : le code commun est écrit UNE fois, et paramétré par un descripteur.
from collections.abc import Callable
from dataclasses import dataclass
from dao.personnes import PersonneDao
@dataclass(frozen=True)
class Descripteur:
dao: PersonneDao
role: str # DOCTOR ou USER
code_introuvable: str # erreur si la personne n'existe pas
code_existe_deja: str # erreur si les mêmes prénom et nom existent déjà
# facultatif (None) : si présent, la suppression est REFUSÉE tant que la
# personne a des rendez-vous (compter_rv(id) > 0), avec l'erreur code_avec_rv.
# Callable[[int], int] : une fonction qui reçoit un entier et rend un entier
compter_rv: Callable[[int], int] | None
code_avec_rv: str | None
# facultatif : ce qu'il faut supprimer AVANT la personne (les lignes qui la
# référencent par une clé étrangère) ; reçoit son id
avant_suppression: Callable[[int], None] | None
|
Les médecins et les clients suivent les mêmes règles de gestion, à quelques détails près : le rôle de leur compte, les codes de leurs erreurs, ce qui empêche ou accompagne leur suppression. Un descripteur réunit ces détails ; le code commun, dans [MetierService], est écrit une fois, et reçoit le descripteur. Les trois derniers attributs sont des fonctions : [Callable[[int], int]] annonce une fonction qui reçoit un entier et rend un entier (le module [collections.abc] fournit ces types abstraits) ; [| None] : ou rien.
11.11.6. metier/fournisseur.py
| # metier/fournisseur.py : le fournisseur de la couche métier (cf. exemple 06) :
# UN objet MetierService, créé à la première demande, puis partagé par toutes les
# requêtes tant que le serveur tourne. Il n'a pas d'état propre à une requête :
# le partager est sans danger, même entre plusieurs fils d'exécution
from functools import cache
from .service import MetierService
@cache
def metier() -> MetierService:
return MetierService()
|
Le fournisseur de la couche métier (exemple 06) : un seul objet [MetierService], créé à la première demande, puis partagé par toutes les requêtes. C'est sans danger, même avec plusieurs fils d'exécution (exemple 20) : l'objet n'a pas d'état propre à une requête (ses DAO n'en ont pas non plus), et chaque fil a sa propre connexion à la base (l'ORM de [Django] ouvre une connexion par fil d'exécution).
11.11.7. metier/service.py
| # =========================================================================
# metier/service.py : l'implémentation de la couche métier.
#
# La gestion des médecins et des clients obéit à trois règles de gestion :
#
# 1. une personne (médecin ou client) et son compte utilisateur vont TOUJOURS
# ensemble : on les crée, modifie et supprime ensemble, dans une même
# TRANSACTION - soit tout réussit, soit rien n'est écrit ;
# 2. le login est unique dans toute l'application, et deux médecins (ou deux
# clients) ne peuvent pas porter les mêmes prénom et nom ;
# 3. suppression :
# - un MÉDECIN qui a des rendez-vous ne peut pas être supprimé (ses patients
# perdraient leur rendez-vous sans être prévenus) : il faut d'abord les
# annuler. Ses créneaux horaires disparaissent avec lui ;
# - un CLIENT, lui, disparaît avec TOUS ses rendez-vous (passés et futurs) -
# que la suppression vienne de l'ADMIN ou du patient lui-même.
#
# Et deux règles pour la prise de rendez-vous, quel que soit le rôle de celui qui
# réserve (ADMIN, DOCTOR ou le patient lui-même) :
# 4. pas de rendez-vous dans le passé ;
# 5. un patient n'a qu'UN rendez-vous à venir à la fois.
#
# Médecins et clients suivent exactement les mêmes règles : le code commun est
# écrit UNE fois (méthodes _xxx_personne), et paramétré par un descripteur qui dit
# à quel type de personne on a affaire.
#
# Les TRANSACTIONS sont ouvertes par la méthode _transaction (en fin de fichier),
# qui traduit aussi en MetierErreur les erreurs que la base signale.
# =========================================================================
import re
from collections.abc import Callable
from datetime import date
from django.db import IntegrityError, transaction
from dao.creneaux import CreneauDao
from dao.personnes import ClientDao, MedecinDao
from dao.rvs import RvDao
from dao.users import UserDao
from domaine.agenda import AgendaMedecinJour, CreneauMedecinJour
from modeles.models import Client, Creneau, Medecin, Personne, Role, Rv
from securite import mots_de_passe
from . import erreurs
from .dates import aujourdhui
from .descripteur import Descripteur
from .donnees import DonneesPersonne
from .erreurs import MetierErreur
from .metier import Metier
# -------------------------------------------------------------------------
# Erreurs de la base -> MetierErreur
#
# Quand une contrainte UNIQUE refuse une écriture, Django lève une IntegrityError,
# dont le message est celui de MySQL. Il cite le NOM de la contrainte :
# (1062, "Duplicate entry 'Marie-PELISSIER' for key 'medecins.unq_medecins_prenom_nom'")
# (MySQL 8 préfixe le nom par celui de la table, MariaDB non). C'est pour cela que
# le script SQL NOMME toutes ses contraintes. L'expression régulière CONTRAINTE
# extrait ce nom : (?:[^'.]*\.)? saute le préfixe éventuel, ([^']+) capture le nom
# -------------------------------------------------------------------------
ERREURS_CONTRAINTES = {
"unq_users_login": erreurs.LOGIN_PRIS,
"unq_medecins_prenom_nom": erreurs.MEDECIN_EXISTE_DEJA,
"unq_clients_prenom_nom": erreurs.CLIENT_EXISTE_DEJA,
"unq1_rv": erreurs.CRENEAU_DEJA_RESERVE,
}
CONTRAINTE = re.compile(r"for key '(?:[^'.]*\.)?([^']+)'")
# une MetierErreur MODIFICATION_CONCURRENTE, levée DANS une transaction pour qu'elle
# soit annulée : un UPDATE n'a modifié aucune ligne (verrou optimiste)
class _VersionPerimee(Exception):
pass
class MetierService(Metier):
# les DAO, créés une fois pour toutes ; ils n'ont pas d'état : un seul objet de
# chaque sert à toutes les requêtes
def __init__(self):
self.medecins = MedecinDao()
self.clients = ClientDao()
self.creneaux = CreneauDao()
self.rvs = RvDao()
self.users = UserDao()
self.medecin = Descripteur(
dao=self.medecins,
role=Role.DOCTOR,
code_introuvable=erreurs.MEDECIN_INTROUVABLE,
code_existe_deja=erreurs.MEDECIN_EXISTE_DEJA,
# self.rvs.compter_par_medecin : la méthode elle-même, sans l'appeler (une
# "méthode liée" à l'objet self.rvs, que l'on appellera plus tard)
compter_rv=self.rvs.compter_par_medecin,
code_avec_rv=erreurs.MEDECIN_A_DES_RV,
# un médecin sans rendez-vous peut encore avoir des créneaux horaires :
# ils n'ont plus de sens sans lui, on les supprime avec lui (sans quoi la
# clé étrangère creneaux.id_medecin interdirait la suppression)
avant_suppression=self.creneaux.supprimer_par_medecin)
self.client = Descripteur(
dao=self.clients,
role=Role.USER,
code_introuvable=erreurs.CLIENT_INTROUVABLE,
code_existe_deja=erreurs.CLIENT_EXISTE_DEJA,
# pas de compter_rv : la suppression d'un client n'est jamais refusée ;
# ses rendez-vous (passés et futurs) sont supprimés avec lui (sans quoi
# la clé étrangère rv.id_client interdirait la suppression)
compter_rv=None,
code_avec_rv=None,
avant_suppression=self.rvs.supprimer_par_client)
# =======================================================================
# lectures
# =======================================================================
def get_all_clients(self) -> list[Client]:
return self.clients.lister()
def get_all_medecins(self) -> list[Medecin]:
return self.medecins.lister()
def get_all_creneaux(self, id_medecin: int) -> list[Creneau]:
return self.creneaux.lister_par_medecin(id_medecin)
def get_rv_medecin_jour(self, id_medecin: int, jour: date) -> list[Rv]:
return self.rvs.rv_medecin_jour(id_medecin, jour)
def get_client_by_id(self, id: int) -> Client | None:
return self.clients.lire(id)
def get_medecin_by_id(self, id: int) -> Medecin | None:
return self.medecins.lire(id)
def get_rv_by_id(self, id: int) -> Rv | None:
return self.rvs.lire(id)
def get_creneau_by_id(self, id: int) -> Creneau | None:
return self.creneaux.lire(id)
def get_client_by_user_id(self, id_user: int) -> Client | None:
return self.clients.lire_par_user(id_user)
def get_rv_a_venir_client(self, id_client: int) -> list[Rv]:
return self.rvs.rv_a_venir_client(id_client, aujourdhui())
# l'agenda d'un médecin un jour donné : ses créneaux, et pour chacun le
# rendez-vous pris ce jour-là (None s'il est libre)
def get_agenda_medecin_jour(self, id_medecin: int, jour: date) -> AgendaMedecinJour | None:
medecin = self.get_medecin_by_id(id_medecin)
if medecin is None:
return None
# un dictionnaire en COMPRÉHENSION : n° de créneau -> rendez-vous pris sur ce créneau
reservations = {rv.creneau_id: rv for rv in self.get_rv_medecin_jour(id_medecin, jour)}
# get(clé) : la valeur, ou None si la clé est absente
creneaux_medecin_jour = [CreneauMedecinJour(creneau, reservations.get(creneau.id))
for creneau in self.get_all_creneaux(id_medecin)]
return AgendaMedecinJour(medecin, jour, creneaux_medecin_jour)
# =======================================================================
# rendez-vous
# =======================================================================
# -----------------------------------------------------------------------
# prise de rendez-vous, dans une transaction :
# 1. pas de rendez-vous dans le passé ;
# 2. on VERROUILLE la ligne du patient (SELECT ... FOR UPDATE) : si le même
# patient réserve deux créneaux au même instant (deux onglets, double
# clic...), la seconde transaction attend la fin de la première - et voit
# alors le rendez-vous qu'elle vient de créer ;
# 3. un seul rendez-vous à venir par patient ;
# 4. enfin l'insertion. Si le créneau vient d'être pris par un AUTRE patient,
# c'est la contrainte SQL unq1_rv (jour, id_creneau) qui refuse
# l'insertion - traduite en MetierErreur par _transaction.
# Une fonction définie DANS une méthode (travail) voit les variables de
# celle-ci (jour, creneau, client) : c'est une "fermeture" (closure)
# -----------------------------------------------------------------------
def ajouter_rv(self, jour: date, creneau: Creneau, client: Client) -> Rv:
def travail():
if jour < aujourdhui():
raise MetierErreur(erreurs.DATE_PASSEE)
id_patient = self.clients.verrouiller(client.id)
if id_patient is None:
raise MetierErreur(erreurs.CLIENT_INTROUVABLE)
if self.rvs.compter_a_venir_client(id_patient, aujourdhui()) > 0:
raise MetierErreur(erreurs.PATIENT_A_UN_RV)
return self.rvs.ajouter(jour, creneau, client)
return self._transaction(travail)
def supprimer_rv(self, rv: Rv) -> None:
# lambda : une fonction anonyme réduite à une expression
self._transaction(lambda: self.rvs.supprimer(rv.id))
# =======================================================================
# médecins et clients - les méthodes publiques délèguent aux méthodes
# communes, avec le bon descripteur
# =======================================================================
def ajouter_medecin(self, donnees: DonneesPersonne) -> Medecin:
return self._ajouter_personne(self.medecin, donnees)
def modifier_medecin(self, id: int, donnees: DonneesPersonne) -> Medecin:
return self._modifier_personne(self.medecin, id, donnees)
def supprimer_medecin(self, id: int) -> None:
self._supprimer_personne(self.medecin, id)
def ajouter_client(self, donnees: DonneesPersonne) -> Client:
return self._ajouter_personne(self.client, donnees)
def modifier_client(self, id: int, donnees: DonneesPersonne) -> Client:
return self._modifier_personne(self.client, id, donnees)
# le client, son compte ET tous ses rendez-vous (ADMIN, ou le patient lui-même
# depuis "Gérer mon compte" : même méthode, même règle)
def supprimer_client(self, id: int) -> None:
self._supprimer_personne(self.client, id)
# -----------------------------------------------------------------------
# création : le compte utilisateur, PUIS la personne (qui le référence par sa
# clé étrangère [user]), dans UNE transaction
# -----------------------------------------------------------------------
def _ajouter_personne(self, d: Descripteur, donnees: DonneesPersonne) -> Personne:
nom = donnees.nom.strip().upper()
prenom = donnees.prenom.strip()
# les règles de la base dbrdvmedecins2 : login = nom en minuscules, mot de
# passe = login - sauf si l'ADMIN (ou le patient) en a saisi d'autres
login = (donnees.login or nom).lower()
password = donnees.password or login
def travail():
self._verifier_personne_unique(d, prenom, nom, None)
self._verifier_login_libre(login)
compte = self.users.ajouter(login, mots_de_passe.hacher(password), f"{prenom} {nom}", d.role)
return d.dao.ajouter(donnees.titre, nom, prenom, compte)
return self._transaction(travail)
# -----------------------------------------------------------------------
# modification : la personne ET son compte (le nom affiché "prénom NOM" doit
# suivre ; le login et le mot de passe ne changent que s'ils ont été saisis).
# -----------------------------------------------------------------------
def _modifier_personne(self, d: Descripteur, id: int, donnees: DonneesPersonne) -> Personne:
def travail():
personne = d.dao.lire(id)
if personne is None:
raise MetierErreur(d.code_introuvable)
# verrou optimiste : si la version en base n'est plus celle du
# formulaire, quelqu'un a modifié cette fiche entre-temps - on refuse
# plutôt que d'écraser silencieusement ses modifications
if donnees.version is not None and donnees.version != personne.version:
raise MetierErreur(erreurs.MODIFICATION_CONCURRENTE)
nom = donnees.nom.strip().upper()
prenom = donnees.prenom.strip()
self._verifier_personne_unique(d, prenom, nom, id)
# son compte : le nom affiché, et, s'ils ont été saisis, le login et le
# mot de passe
compte = personne.compte
login = donnees.login.lower() if donnees.login else compte.login
if login != compte.login:
self._verifier_login_libre(login)
hachage = mots_de_passe.hacher(donnees.password) if donnees.password else None
# les UPDATE, avec leur verrou optimiste : si la fiche (ou le compte) a
# été modifiée APRÈS la lecture ci-dessus, aucune ligne n'est modifiée
if not d.dao.modifier(id, personne.version, donnees.titre, nom, prenom):
raise _VersionPerimee()
if not self.users.modifier(compte.id, compte.version, login, f"{prenom} {nom}", hachage):
raise _VersionPerimee()
# la personne relue, avec son compte : les valeurs enregistrées
return d.dao.lire(id)
return self._transaction(travail)
# -----------------------------------------------------------------------
# suppression, dans UNE transaction, dans l'ordre imposé par les clés
# étrangères :
# 1. refus éventuel s'il reste des rendez-vous (médecin) ;
# 2. ce qui référence la personne (créneaux d'un médecin, rendez-vous d'un
# client) ;
# 3. la personne (elle référence son compte), puis son compte.
# -----------------------------------------------------------------------
def _supprimer_personne(self, d: Descripteur, id: int) -> None:
def travail():
personne = d.dao.lire(id)
if personne is None:
raise MetierErreur(d.code_introuvable)
if d.compter_rv is not None and d.compter_rv(id) > 0:
raise MetierErreur(d.code_avec_rv)
if d.avant_suppression is not None:
d.avant_suppression(id)
d.dao.supprimer(id)
self.users.supprimer(personne.compte_id)
self._transaction(travail)
# refuse des prénom et nom déjà portés par une AUTRE personne du même type
# (id_courant : la personne en cours de modification, qui a évidemment le droit
# de garder ses propres prénom et nom ; None pour une création)
def _verifier_personne_unique(self, d: Descripteur, prenom: str, nom: str,
id_courant: int | None) -> None:
homonyme = d.dao.homonyme(prenom, nom)
if homonyme is not None and homonyme.id != id_courant:
raise MetierErreur(d.code_existe_deja)
def _verifier_login_libre(self, login: str) -> None:
if self.users.lire_par_login(login) is not None:
raise MetierErreur(erreurs.LOGIN_PRIS)
# -----------------------------------------------------------------------
# exécute [travail] dans une transaction (transaction.atomic, cf. exemple 28) :
# si la fonction se termine normalement, la transaction est validée (COMMIT) ;
# si une exception en sort (MetierErreur comprise), elle est annulée
# (ROLLBACK) : rien n'est écrit.
#
# Les contrôles "login libre" et "prénom + nom uniques" sont faits AVANT
# l'écriture, pour donner un message clair. Mais deux ADMIN pourraient
# enregistrer le même login (ou le même médecin) au même instant : dans ce cas
# rare, c'est une contrainte d'unicité SQL qui refuse la seconde écriture. De
# même, deux modifications simultanées d'une même fiche sont départagées par le
# verrou optimiste. On intercepte donc ces erreurs AUTOUR de la transaction,
# pour les traduire en MetierErreur. Toute autre erreur est une vraie anomalie :
# on la laisse remonter.
# -----------------------------------------------------------------------
def _transaction(self, travail: Callable):
try:
with transaction.atomic():
return travail()
except IntegrityError as e:
# str(e) : le message de MySQL
trouve = CONTRAINTE.search(str(e))
if trouve and trouve.group(1) in ERREURS_CONTRAINTES:
# from e : la nouvelle exception garde la trace de celle qui l'a causée
raise MetierErreur(ERREURS_CONTRAINTES[trouve.group(1)]) from e
raise
except _VersionPerimee as e:
raise MetierErreur(erreurs.MODIFICATION_CONCURRENTE) from e
|
11.11.7.1. Les dépendances et les descripteurs
- lignes 62-69 : la traduction des erreurs de la base en erreurs métier (voir « Les transactions », plus loin) ;
- lignes 74-75 : une exception interne, qui signale un [UPDATE] sans effet (le verrou optimiste) ;
- lignes 81-86 : les DAO, créés une fois pour toutes par le constructeur ;
- lignes 88-112 : les deux descripteurs. Les fonctions sont des méthodes des DAO, passées sans parenthèses (ligne 95) : ce sont des méthodes liées (bound methods), des fonctions qui se souviennent de leur objet, et que l'on appellera plus tard ([d.compter_rv(id)], ligne 286). Un médecin qui a des rendez-vous ne peut pas être supprimé ; ses créneaux sont supprimés avec lui. Un client est supprimé avec ses rendez-vous. Les paramètres sont nommés : on lit tout de suite à quoi sert chaque valeur.
11.11.7.2. Les lectures et l'agenda
- lignes 118-146 : les lectures, déléguées à la couche DAO. La date du jour est calculée ici (ligne 146), et non par le DAO : c'est une règle de gestion (« à venir ») ;
- lignes 150-159 : l'agenda d'un médecin pour un jour. Un dictionnaire en compréhension (exemple 18) associe à chaque numéro de créneau le rendez-vous pris sur lui ce jour-là ([rv.creneau_id] : la clé étrangère, sans requête, cf. les modèles) ; puis, pour chaque créneau du médecin, le rendez-vous correspondant, ou [None]. Deux requêtes seulement, quel que soit le nombre de créneaux : les rendez-vous du jour, et les créneaux (plus la lecture du médecin), comme le montrait la console (« Le mode développement »).
11.11.7.3. La prise de rendez-vous
- lignes 179-189 : la prise de rendez-vous, dans une transaction. La fonction [travail], définie dans la méthode, voit ses paramètres ([jour], [creneau], [client]) : c'est une fermeture (exemple 08). Elle est passée à [_transaction], qui l'exécute dans une transaction ;
- lignes 181-182 : pas de rendez-vous dans le passé ;
- lignes 183-185 : le verrou « pessimiste » sur la ligne du patient ([SELECT ... FOR UPDATE], cf. la couche DAO). Voici pourquoi. La règle « un seul rendez-vous à venir » est vérifiée par un comptage (ligne 186), puis le rendez-vous est inséré (ligne 188). Si le même patient réserve deux créneaux au même instant (deux onglets, un double clic), deux transactions pourraient compter toutes les deux zéro rendez-vous, puis insérer toutes les deux : aucune contrainte de la base ne l'empêcherait, les deux créneaux étant différents. Le verrou sur la ligne du patient fait attendre la seconde transaction jusqu'à la fin de la première : elle compte alors un rendez-vous, et refuse. La base ne peut pas garantir cette règle seule : c'est la couche métier qui la garantit, avec son aide ;
- lignes 186-187 : un seul rendez-vous à venir par patient ;
- ligne 188 : l'insertion. Si le créneau vient d'être pris par un autre patient, c'est la contrainte [unq1_rv] de la base qui refuse l'insertion, et [_transaction] traduit l'erreur (plus loin) ;
- lignes 191-193 : l'annulation d'un rendez-vous. [lambda: ...] est une fonction anonyme, réduite à une expression (ici, un appel) : l'équivalent d'une fonction [def] de trois lignes.
11.11.7.4. La gestion des médecins et des clients
- lignes 200-218 : les méthodes publiques délèguent aux méthodes communes, avec le bon descripteur ;
- lignes 224-237 : la création d'une personne. Le nom est mis en majuscules ([PELISSIER], la convention de la base), les espaces qui entourent la saisie sont retirés. Les règles de la base [dbrdvmedecins2] : le login est, par défaut, le nom en minuscules, et le mot de passe, par défaut, le login ([a or b] : [a] s'il est « vrai », une chaîne non vide, [b] sinon). Dans la transaction : les vérifications (homonyme, login libre), puis le compte (avec le hachage du mot de passe, cf. [securite/mots_de_passe.py]), puis la personne, qui référence le compte par sa clé étrangère : l'ordre est imposé ;
- lignes 243-271 : la modification d'une personne et de son compte. La version du formulaire est comparée à la version lue (ligne 251) : c'est le premier niveau du verrou optimiste, qui donne un message clair. Le second niveau est celui des [UPDATE] (lignes 265-268) : si la fiche a été modifiée entre la lecture (ligne 245) et l'écriture, l'[UPDATE] ne modifie rien, et l'exception [_VersionPerimee] annule toute la transaction (le compte n'est donc pas modifié non plus). Le login et le mot de passe ne changent que s'ils ont été saisis ; le nom affiché du compte suit la personne ;
- lignes 281-292 : la suppression, dans l'ordre qu'imposent les clés étrangères : ce qui référence la personne (ses créneaux, ou ses rendez-vous), la personne, puis son compte. [personne.compte_id] est la clé étrangère, lue sans requête ;
- lignes 297-305 : les deux vérifications d'unicité, faites avant l'écriture, pour donner un message clair. Une personne en cours de modification a le droit de garder ses propres prénom et nom : [id_courant] l'exclut.
11.11.7.5. Les transactions et les erreurs de la base
- lignes 322-334 : chaque opération qui écrit est exécutée dans une transaction ([transaction.atomic], exemple 28) : si [travail] se termine normalement, la transaction est validée ([COMMIT]) ; si une exception en sort (une [MetierErreur] comprise), elle est annulée ([ROLLBACK]) : rien n'est écrit. La couche métier décide ainsi de l'étendue des transactions : une opération métier, une transaction ;
- lignes 326-332 : les vérifications faites avant l'écriture ne suffisent pas toujours : deux administrateurs pourraient enregistrer le même login au même instant. C'est alors une contrainte d'unicité de la base qui refuse la seconde écriture, et l'ORM lève une [IntegrityError], dont le message est celui de [MySQL] : [(1062, "Duplicate entry 'Marie-PELISSIER' for key 'medecins.unq_medecins_prenom_nom'")]. L'expression régulière [CONTRAINTE] (ligne 69) en extrait le nom de la contrainte ([search] cherche l'expression dans la chaîne ; [group(1)] rend ce qu'a capturé le premier groupe entre parenthèses ; [(?:...)] est un groupe qui ne capture pas, et [?] le rend facultatif : [MySQL] 8 préfixe le nom par celui de la table, [MariaDB] non). C'est pour cela que le script SQL nomme toutes ses contraintes. Une contrainte connue devient la [MetierErreur] correspondante ; une autre [IntegrityError] est relancée telle quelle ([raise] sans paramètre) : c'est une vraie anomalie. [raise ... from e] attache l'exception d'origine à la nouvelle (exemple 28) ;
- lignes 333-334 : un verrou optimiste qui a échoué devient l'erreur [MODIFICATION_CONCURRENTE]. L'exception [_VersionPerimee] est levée dans le bloc [atomic], pour annuler la transaction, puis traduite en dehors.
Les exceptions sont interceptées en dehors du bloc [with transaction.atomic()], et non dedans : après une erreur de la base, la transaction ne peut plus servir, et doit d'abord être annulée, ce que fait la sortie du bloc (exemple 28).
11.12. L'authentification et la sécurité
Le dossier [securite] réunit tout ce qui concerne l'authentification (qui est l'utilisateur ?), les droits (a-t-il accès à cette page ?) et la protection contre les robots. C'est la version complète des exemples 25 à 27.
11.12.1. securite/mots_de_passe.py
| # =========================================================================
# securite/mots_de_passe.py : le hachage des mots de passe, par l'algorithme bcrypt
# (bibliothèque bcrypt), 10 tours, comme les hachages du script SQL.
#
# Un HACHAGE est à sens unique : on ne peut pas retrouver le mot de passe à partir
# de lui. Pour vérifier un mot de passe saisi, on le hache avec le "sel" (une
# valeur aléatoire) contenu dans le hachage enregistré, et l'on compare les deux
# résultats : c'est ce que fait bcrypt.checkpw.
#
# Les hachages du script SQL commencent par $2b$ ; ceux des autres versions de
# l'application, qui partagent la même base, par $2y$ (PHP) ou $2a$ (Java) :
# bcrypt vérifie les trois formes.
#
# bcrypt ne tient compte que des 72 premiers OCTETS du mot de passe (et la
# bibliothèque refuse d'en hacher davantage) : les formulaires limitent donc la
# longueur des mots de passe (cf. web/formulaires.py).
# =========================================================================
import bcrypt
# le nombre de tours : le hachage coûte 2^10 calculs élémentaires
TOURS = 10
MAX_OCTETS = 72
def hacher(mot_de_passe: str) -> str:
# encode : la chaîne en octets (UTF-8) ; decode : les octets en chaîne
return bcrypt.hashpw(mot_de_passe.encode(), bcrypt.gensalt(TOURS)).decode()
def verifier(mot_de_passe: str, hachage: str) -> bool:
octets = mot_de_passe.encode()
if len(octets) > MAX_OCTETS:
return False
return bcrypt.checkpw(octets, hachage.encode())
|
Les mots de passe de la base sont hachés par [bcrypt] (le script SQL, plus haut), et non par PBKDF2, l'algorithme par défaut de [Django] (exemple 25). La bibliothèque [bcrypt] les vérifie directement :
- lignes 26-28 : le hachage d'un mot de passe. [gensalt(10)] tire un sel au hasard, avec le coût 10 ; [hashpw] calcule le hachage, qui contient l'algorithme, le coût et le sel (exemple 25). La bibliothèque travaille sur des octets : [encode()] convertit la chaîne en octets ([UTF-8]), [decode()] fait l'inverse ;
- lignes 31-35 : la vérification. [checkpw] relit le coût et le sel dans le hachage enregistré, hache le mot de passe saisi avec eux, et compare les résultats (en un temps constant, exemple 23). [bcrypt] ne tient compte que des 72 premiers octets d'un mot de passe, et la bibliothèque, depuis sa version 5, refuse d'en hacher davantage (elle lève une exception) : un mot de passe plus long est donc refusé d'emblée. Les formulaires limitent d'ailleurs la longueur des mots de passe (couche web, plus loin).
[Django] aurait pu vérifier ces hachages lui-même, avec son algorithme [BCryptPasswordHasher] (réglage [PASSWORD_HASHERS]) : mais il attend des hachages préfixés par [bcrypt$], et la base est partagée avec les autres versions de l'application, qui ne connaissent pas ce préfixe.
11.12.2. securite/utilisateur.py
| # =========================================================================
# securite/utilisateur.py : l'utilisateur connecté, tel que le décrit son jeton JWT
# (cf. exemple 25).
#
# Les claims de nos jetons (cf. securite/auth.py) :
# sub : l'id du compte utilisateur (convention JWT : "subject"), en texte
# login : le login
# nom : le nom affiché ("Marie PELISSIER"), pour la barre de navigation
# role : ADMIN, DOCTOR ou USER
# iat, exp : dates de création et d'expiration du jeton
#
# Cet objet ne vient PAS de la base : il est reconstruit, à chaque requête, à
# partir du jeton, dont la signature garantit le contenu.
# =========================================================================
from dataclasses import dataclass
from modeles.models import Role
@dataclass(frozen=True)
class UtilisateurConnecte:
user_id: int
login: str
nom: str
role: str
# l'utilisateur décrit par les claims d'un jeton déjà vérifié ; None si un claim
# manque ou n'a pas la forme attendue. @staticmethod : une fonction rangée dans
# la classe, qui ne reçoit ni objet ni classe
@staticmethod
def de(claims: dict) -> "UtilisateurConnecte | None":
role = claims.get("role")
sub = str(claims.get("sub", ""))
# Role.values : les valeurs de l'énumération ; isdigit : que des chiffres
if role not in Role.values or not sub.isdigit() or "login" not in claims or "nom" not in claims:
return None
return UtilisateurConnecte(int(sub), str(claims["login"]), str(claims["nom"]), role)
@property
def est_admin(self) -> bool:
return self.role == Role.ADMIN
@property
def est_medecin(self) -> bool:
return self.role == Role.DOCTOR
@property
def est_patient(self) -> bool:
return self.role == Role.USER
|
L'utilisateur connecté, tel que le décrit son jeton (exemple 25). Cet objet ne vient pas de la base : il est reconstruit, à chaque requête, à partir du jeton, dont la signature garantit le contenu.
- lignes 31-38 : une méthode statique ([@staticmethod]) : une fonction rangée dans la classe, qui ne reçoit ni objet ni classe, et s'appelle [UtilisateurConnecte.de(claims)]. Elle fabrique l'utilisateur à partir des affirmations d'un jeton déjà vérifié. Même signé par nous, un jeton est vérifié avec prudence : un rôle inconnu, un identifiant qui n'est pas un nombre, une affirmation absente, et l'utilisateur est refusé ([None]). [Role.values] est la liste des valeurs de l'énumération ; [isdigit] vérifie qu'une chaîne n'est faite que de chiffres. L'annotation du type rendu est écrite entre guillemets : la classe n'est pas encore entièrement définie à cet endroit ;
- lignes 40-50 : trois propriétés, pour les gabarits (exemple 26).
11.12.3. securite/auth.py
| # =========================================================================
# securite/auth.py : la logique d'authentification :
# - valider() : vérifie un couple login / mot de passe ;
# - poser_jeton() : fabrique le jeton JWT d'un utilisateur validé, et le
# dépose dans un cookie de la réponse ;
# - utilisateur() : l'utilisateur décrit par un jeton.
#
# Où le jeton JWT voyage-t-il ? Dans un COOKIE (cf. exemple 25) : un navigateur qui
# affiche des pages HTML ordinaires le présente ainsi tout seul, à chaque requête.
# Les options de ce cookie comptent pour la sécurité :
# - HttpOnly : le JavaScript de la page ne peut pas lire le cookie (un script
# injecté ne pourrait donc pas voler le jeton) ;
# - SameSite=Lax : le navigateur n'envoie PAS le cookie avec un POST venant d'un
# autre site (première protection contre l'attaque dite CSRF ; la seconde est
# le jeton anti-CSRF des formulaires) ;
# - Secure : en production (HTTPS), le cookie ne circule jamais en clair ;
# - durée de vie : le cookie disparaît en même temps que le jeton expire.
# =========================================================================
import time
import jwt
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from dao.users import UserDao
from modeles.models import User
from . import mots_de_passe
from .utilisateur import UtilisateurConnecte
# le nom du cookie qui transporte le jeton
COOKIE = "rdvmedecins_jwt"
# HMAC-SHA256 exige une clé de 256 bits au moins : on le dit clairement, dès le
# chargement du module, plutôt que de laisser la bibliothèque échouer plus loin
if len(settings.JWT_SECRET) < 32:
raise ImproperlyConfigured("JWT_SECRET doit comporter au moins 32 caractères (cf. .env).")
_users = UserDao()
# None si le login n'existe pas ou si le mot de passe est faux
def valider(login: str, password: str) -> User | None:
user = _users.lire_par_login(login)
if user is None or not mots_de_passe.verifier(password, user.password):
return None
return user
# -------------------------------------------------------------------------
# le jeton JWT de [user], déposé dans le cookie qui le transporte. Le jeton est
# SIGNÉ (HMAC-SHA256) avec la clé secrète : le navigateur peut le lire (il n'est
# pas chiffré), mais pas le modifier - toute modification rendrait la signature
# invalide.
# -------------------------------------------------------------------------
def poser_jeton(reponse, user: User):
maintenant = int(time.time())
duree = settings.JWT_DUREE_MINUTES * 60
claims = {
"sub": str(user.id),
"login": user.login,
"nom": user.nom,
"role": user.role,
"iat": maintenant,
"exp": maintenant + duree,
}
jeton = jwt.encode(claims, settings.JWT_SECRET, algorithm="HS256")
reponse.set_cookie(COOKIE, jeton, max_age=duree, secure=settings.COOKIE_SECURE,
httponly=True, samesite="Lax")
return reponse
# efface le cookie du jeton : la réponse porte un cookie de même nom, vide, déjà expiré
def effacer_jeton(reponse):
reponse.delete_cookie(COOKIE, samesite="Lax")
return reponse
# -------------------------------------------------------------------------
# l'utilisateur décrit par un jeton ; None si le jeton est expiré, falsifié ou mal
# formé. jwt.decode vérifie la signature ET la date d'expiration (claim exp).
# -------------------------------------------------------------------------
def utilisateur(jeton: str) -> UtilisateurConnecte | None:
try:
claims = jwt.decode(jeton, settings.JWT_SECRET, algorithms=["HS256"])
except jwt.InvalidTokenError:
return None
return UtilisateurConnecte.de(claims)
|
Le service d'authentification de l'exemple 25, avec quelques différences :
- lignes 37-38 : la clé des jetons est vérifiée au chargement du module, c'est-à-dire au démarrage de l'application : une clé trop courte arrête tout, avec un message clair ([ImproperlyConfigured] est l'exception de [Django] pour une configuration incorrecte), plutôt qu'un simple avertissement de [PyJWT] (exemple 25) ;
- lignes 44-48 : la vérification d'un login et d'un mot de passe, par la couche DAO (le compte, lu dans la base) et par le module des mots de passe. La couche web appelle ce module directement : l'authentification est une affaire de sécurité, pas une règle de gestion ;
- lignes 57-71 : la fabrication du jeton, et son dépôt dans un cookie de la réponse (exemple 25). L'identifiant du compte ([sub]) est écrit en texte : la norme JWT le demande. Le cookie est [Secure] en production ([COOKIE_SECURE], cf. [config/settings.py]) ;
- lignes 75-77 : l'effacement du cookie : un cookie de même nom, vide, déjà expiré (exemple 25) ;
- lignes 84-89 : l'utilisateur décrit par un jeton, ou [None] (exemple 25).
11.12.4. securite/acces.py
| # =========================================================================
# securite/acces.py : LA RÈGLE D'ACCÈS de chaque URL.
#
# Les règles sont examinées dans l'ordre ; la première dont le motif (une
# expression régulière) correspond au chemin de l'URL s'applique :
# - quelques URL PUBLIQUES : l'accueil, la connexion, le choix de la langue, la
# création d'un compte ;
# - des URL réservées à certains rôles (un ensemble de rôles : l'UN d'eux suffit) ;
# - toutes les AUTRES exigent un utilisateur authentifié (dernière règle).
# C'est la règle "tout est interdit sauf ce qui est permis" : une URL ajoutée plus
# tard, et qu'on aurait oublié de protéger, l'est d'office.
#
# Le middleware ControleAcces (cf. securite/middleware.py) applique ces règles : un
# visiteur anonyme refusé est envoyé à la page de connexion ; un utilisateur
# authentifié sans le rôle demandé reçoit une page d'erreur 403.
# =========================================================================
import re
from modeles.models import Role
# deux valeurs particulières à la place d'un ensemble de rôles
PUBLIC = "PUBLIC"
AUTHENTIFIE = "AUTHENTIFIE"
# re.compile : l'expression régulière, analysée une fois pour toutes
REGLES = [
(re.compile(r"^/$"), PUBLIC),
(re.compile(r"^/(login|logout)$"), PUBLIC),
(re.compile(r"^/langue/"), PUBLIC),
(re.compile(r"^/inscription"), PUBLIC),
# un patient annule SON rendez-vous depuis "Mon espace", pas depuis l'agenda
(re.compile(r"^/agenda/annuler$"), {Role.ADMIN, Role.DOCTOR}),
(re.compile(r"^/(medecins|clients)"), {Role.ADMIN}),
(re.compile(r"^/mon-"), {Role.USER}),
(re.compile(r"^/"), AUTHENTIFIE),
]
# la règle du chemin [chemin] : PUBLIC, AUTHENTIFIE, ou un ensemble de rôles
def regle(chemin: str):
for motif, exigence in REGLES:
if motif.match(chemin):
return exigence
return AUTHENTIFIE
|
Les règles d'accès de l'application, réunies dans une seule liste. L'exemple 26 protégeait chaque vue par un décorateur ; ici, la protection est centrale, comme le faisaient les règles [access_control] de [Symfony] :
- lignes 27-37 : chaque règle associe un motif (une expression régulière, compilée une fois pour toutes par [re.compile]) à une exigence : [PUBLIC], [AUTHENTIFIE], ou un ensemble de rôles, dont l'un suffit. [^] marque le début du chemin, [$] sa fin ; [(login|logout)] : l'un ou l'autre. Les URL publiques sont l'accueil, la connexion, le choix de la langue et la création de compte. Suivent les URL réservées à certains rôles : l'annulation depuis l'agenda (un patient, lui, annule depuis Mon espace), la gestion des médecins et des clients, l'espace du patient. La dernière règle, [^/], correspond à toutes les URL restantes, et exige un utilisateur authentifié ;
- lignes 41-45 : la règle d'un chemin : la première qui correspond ([match] : l'expression reconnue au début de la chaîne).
C'est la règle « tout est interdit sauf ce qui est permis » : une URL ajoutée plus tard, et qu'on aurait oublié de protéger, l'est d'office. L'erreur inverse (une URL publique qu'on aurait oublié d'ouvrir) se voit tout de suite ; l'erreur d'une page protégée oubliée, beaucoup moins.
11.12.5. securite/middleware.py
| # =========================================================================
# securite/middleware.py : les deux middlewares de la sécurité (cf. exemples 24 à 26).
#
# 1. Authentification, AVANT le routage : qui est l'utilisateur ? D'après le
# jeton JWT de son cookie. Il ne refuse rien lui-même : un jeton absent ou
# invalide laisse le visiteur ANONYME (request.utilisateur vaut None) ; le
# cookie d'un jeton invalide est effacé de la réponse ;
# 2. ControleAcces, APRÈS le routage (process_view) : l'utilisateur a-t-il le
# droit d'accéder à cette URL ? Les règles sont dans securite/acces.py. Une URL
# qui n'a pas de route n'arrive pas jusqu'ici : elle reçoit une page 404.
# =========================================================================
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect
from django.utils.translation import gettext as _
from . import acces, auth
class Authentification:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
jeton = request.COOKIES.get(auth.COOKIE, "")
request.utilisateur = auth.utilisateur(jeton) if jeton else None
reponse = self.get_response(request)
# un jeton invalide (expiré, falsifié) : on efface le cookie, sur la réponse
# quelle qu'elle soit - sauf si la requête vient d'en poser un nouveau (une
# connexion réussie)
if jeton and request.utilisateur is None and auth.COOKIE not in reponse.cookies:
auth.effacer_jeton(reponse)
return reponse
class ControleAcces:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def process_view(self, request, view_func, view_args, view_kwargs):
# path_info : le chemin de l'URL, sans ses paramètres
exigence = acces.regle(request.path_info)
if exigence == acces.PUBLIC:
return None
utilisateur = request.utilisateur
if utilisateur is None:
# un visiteur anonyme : direction la page de connexion. S'il avait un
# cookie de jeton, c'est que son jeton n'est plus valide (expiré,
# falsifié) : sa session a expiré, on le lui dit
if auth.COOKIE in request.COOKIES:
messages.error(request, _("Votre session a expiré : reconnectez-vous."))
return redirect("login")
if exigence != acces.AUTHENTIFIE and utilisateur.role not in exigence:
# authentifié, mais sans le rôle demandé : erreur 403 (vue handler403, cf. config/urls.py)
raise PermissionDenied("rôle insuffisant")
return None
|
Les deux middlewares de la sécurité :
- lignes 21-34 : l'authentification (exemple 25), avant le routage. Elle ne refuse rien : un jeton absent ou invalide laisse le visiteur anonyme. Elle efface le cookie d'un jeton invalide, sur la réponse, quelle qu'elle soit, sauf si la réponse en pose un nouveau (une connexion réussie) : [reponse.cookies] est le dictionnaire des cookies que la réponse dépose ;
- lignes 37-60 : le contrôle d'accès, après le routage (sa méthode [process_view], exemple 24). Une URL qui n'a pas de route n'arrive donc pas jusqu'ici : elle reçoit directement une page 404, sans que l'on demande au visiteur de se connecter. [request.path_info] est le chemin de l'URL, sans ses paramètres. Un visiteur anonyme refusé est envoyé à la page de connexion ; s'il avait un cookie de jeton, c'est que son jeton n'est plus valide : un message flash lui dit que sa session a expiré (la copie d'écran « La fin de session »). Un utilisateur authentifié qui n'a pas le rôle demandé lève [PermissionDenied] : comme l'exception est levée dans [process_view], et non dans la vue, [Django] la transforme lui-même en réponse, par la vue [handler403] de l'application (exemple 24), en mode debug comme en production.
Le message est traduit par [gettext] (ligne 16), et non par [gettext_lazy] : il est traduit pendant la requête, dans sa langue ; une traduction paresseuse n'est utile que pour un texte défini au chargement d'un module.
11.12.6. securite/erreurs.py
| # securite/erreurs.py : le jeton est valide, mais le compte qu'il désigne n'existe
# plus : il a été supprimé depuis la connexion (par le patient lui-même dans un autre
# onglet, ou par l'ADMIN). On traite ce cas comme une session expirée : le
# middleware PageErreur efface le cookie et renvoie vers la page de connexion.
class SessionInvalide(Exception):
pass
|
Un cas particulier : le jeton est valide, mais le compte qu'il désigne n'existe plus (le patient l'a supprimé dans un autre onglet, ou l'administrateur l'a supprimé). Les vues qui ont besoin du compte lèvent alors cette exception, que le middleware [PageErreur] traite comme une session expirée (plus loin).
11.12.7. securite/captchas.py et dessin_captcha.py
| # =========================================================================
# securite/captchas.py : un CAPTCHA ("test pour distinguer un humain d'un robot")
# fabriqué par le serveur lui-même, sans service extérieur (cf. exemple 27).
#
# Fonctionnement :
# 1. generer() tire un texte au hasard, en fait une image, et range la bonne
# réponse DANS LE CACHE du serveur, sous un identifiant aléatoire ;
# 2. la page affiche l'image et place l'identifiant dans un champ caché du
# formulaire (la réponse, elle, ne quitte jamais le serveur) ;
# 3. verifier(id, saisie) compare la saisie à la réponse attendue, puis OUBLIE
# ce captcha : chaque image ne sert qu'une fois, un robot ne peut pas rejouer
# une réponse déjà trouvée.
#
# Pourquoi le cache, et pas une simple variable du module ? Parce qu'un serveur de
# production lance PLUSIEURS processus (cf. README.md), qui ne partagent pas leur
# mémoire : la requête qui vérifie le captcha n'est pas forcément traitée par le
# processus qui l'a fabriqué. Le cache de l'application (des fichiers, cf.
# config/settings.py) est, lui, partagé par tous.
# =========================================================================
import secrets
from dataclasses import dataclass
from django.core.cache import cache
from . import dessin_captcha
# durée de validité d'une image, en secondes : 10 minutes pour remplir le formulaire
DUREE_VIE = 600
# un captcha : son identifiant, à placer dans le champ caché [captcha_id] du
# formulaire, et son image (SVG), à insérer telle quelle dans la page
@dataclass(frozen=True)
class Captcha:
id: str
svg: str
def generer() -> Captcha:
texte = dessin_captcha.texte_aleatoire(5) # 5 caractères
id = secrets.token_hex(12) # un identifiant impossible à deviner
# le cache oubliera de lui-même la réponse au bout de 10 minutes
cache.set(f"captcha.{id}", texte, DUREE_VIE)
return Captcha(id, dessin_captcha.svg(texte))
# vrai si [saisie] est la bonne réponse au captcha [id] (sans tenir compte des
# majuscules). Le captcha est consommé dans tous les cas.
def verifier(id: str, saisie: str) -> bool:
# l'identifiant vient du navigateur : 24 chiffres hexadécimaux, ou rien
if len(id) != 24 or any(c not in "0123456789abcdef" for c in id) or not saisie:
return False
attendu = cache.get(f"captcha.{id}")
# delete rend True si l'élément existait : si deux requêtes présentent le même
# captcha au même instant, une seule le supprime vraiment - l'autre échoue
if attendu is None or not cache.delete(f"captcha.{id}"):
return False
return attendu.casefold() == saisie.strip().casefold()
|
Le service des captchas de l'exemple 27, avec deux précautions de plus :
- lignes 51-52 : l'identifiant vient du navigateur : on vérifie qu'il est fait de 24 chiffres hexadécimaux (ceux de [token_hex(12)]) avant de s'en servir comme clé du cache. [any] rend [True] si l'un des éléments produits par l'expression génératrice est vrai ;
- lignes 53-56 : [cache.delete] rend [True] si l'élément existait. Si deux requêtes présentent le même captcha au même instant, une seule le supprime vraiment ; l'autre échoue. Une image ne sert qu'une fois.
Le commentaire du fichier (lignes 17-22) explique pourquoi les captchas sont rangés dans le cache (des fichiers, partagés par tous les processus du serveur) plutôt que dans une variable du module. Le fichier [securite/dessin_captcha.py] est celui de l'exemple 27, avec un commentaire d'en-tête plus long : chaque caractère est dessiné par des traits, jamais écrit comme du texte, sinon un robot n'aurait qu'à lire le code source de l'image.
11.12.8. securite/limiteur.py
| # =========================================================================
# securite/limiteur.py : second rempart contre les robots (le premier est le
# captcha) : on COMPTE les tentatives, et au-delà d'un seuil on BLOQUE pendant un
# moment (cf. exemple 27).
#
# - connexion : au bout de LOGIN_MAX_ECHECS échecs (mauvais captcha ou mauvais mot
# de passe) pour un même login depuis une même adresse IP, ce couple est bloqué
# jusqu'à la fin d'une fenêtre de LOGIN_BLOCAGE_MINUTES minutes, comptée depuis
# le premier échec. Une même adresse IP est aussi bloquée si elle cumule
# LOGIN_MAX_ECHECS_IP échecs, tous logins confondus (un robot qui essaie des
# milliers de logins différents) ;
# - création de compte : au plus INSCRIPTION_MAX_PAR_HEURE comptes créés par heure
# depuis une même adresse IP.
#
# Attention en salle de cours : tous les postes sortent souvent sur Internet par la
# MÊME adresse IP. C'est pourquoi le blocage de la connexion porte d'abord sur le
# couple (IP, login) - un étudiant qui se trompe de mot de passe ne bloque pas ses
# voisins - et pourquoi les seuils sont réglables (fichier .env).
#
# Chaque compteur est rangé dans le cache (cf. securite/captchas.py), avec la fin
# de sa fenêtre : {"compte": 3, "fin": 1790531419.2}.
# =========================================================================
import hashlib
import math
import time
from django.conf import settings
from django.core.cache import cache
# ---------------------------------------------------------------- connexion
# nombre de minutes de blocage restantes (0 = la tentative est autorisée)
def minutes_blocage_login(ip: str, login: str) -> int:
return max(_minutes_restantes(_cle_login(ip, login), settings.LOGIN_MAX_ECHECS),
_minutes_restantes(f"login_ip.{ip}", settings.LOGIN_MAX_ECHECS_IP))
# un échec de plus ; le premier ouvre une fenêtre de LOGIN_BLOCAGE_MINUTES minutes,
# à la fin de laquelle le compteur repart de zéro
def echec_login(ip: str, login: str) -> None:
duree = settings.LOGIN_BLOCAGE_MINUTES * 60
_compter(_cle_login(ip, login), duree)
_compter(f"login_ip.{ip}", duree)
# une connexion réussie remet à zéro le compteur de CE login
def reussite_login(ip: str, login: str) -> None:
cache.delete(_cle_login(ip, login))
# ---------------------------------------------------------------- inscription
def minutes_blocage_inscription(ip: str) -> int:
return _minutes_restantes(f"inscription.{ip}", settings.INSCRIPTION_MAX_PAR_HEURE)
def inscription_reussie(ip: str) -> None:
_compter(f"inscription.{ip}", 3600)
# ---------------------------------------------------------------- mécanique commune
# la clé du couple (IP, login). Le login vient du navigateur : il peut contenir
# n'importe quoi (des espaces...). Son empreinte SHA-256, faite de 64 chiffres
# hexadécimaux, donne une clé de cache toujours valide
def _cle_login(ip: str, login: str) -> str:
empreinte = hashlib.sha256(f"{ip}|{login.strip().lower()}".encode()).hexdigest()
return f"login.{empreinte}"
# les minutes qui restent jusqu'à la fin de la fenêtre si le compteur a atteint la
# limite, 0 sinon ; les secondes sont arrondies à la minute SUPÉRIEURE (ceil) :
# 30 s -> "1 minute"
def _minutes_restantes(cle: str, limite: int) -> int:
etat = cache.get(cle)
if etat is None or etat["compte"] < limite:
return 0
return max(1, math.ceil((etat["fin"] - time.time()) / 60))
# une tentative de plus dans la fenêtre en cours (ou dans une nouvelle fenêtre de
# [duree] secondes). Le cache oublie le compteur à la fin de la fenêtre (timeout).
# La lecture puis l'écriture ne sont pas ATOMIQUES : deux tentatives simultanées
# pourraient ne compter que pour une. Pour un compteur d'échecs, c'est sans gravité
def _compter(cle: str, duree: int) -> None:
etat = cache.get(cle) or {"compte": 0, "fin": time.time() + duree}
etat["compte"] += 1
cache.set(cle, etat, timeout=max(1, math.ceil(etat["fin"] - time.time())))
|
La limitation des tentatives de l'exemple 27, plus fine :
- lignes 34-49 : la connexion. Les échecs sont comptés pour chaque couple (adresse IP, login) : cinq échecs (réglage [LOGIN_MAX_ECHECS]) bloquent ce login depuis cette adresse, pendant quinze minutes comptées depuis le premier échec. Dans une salle de cours, où tous les postes sortent souvent sur Internet par la même adresse IP, un étudiant qui se trompe de mot de passe ne bloque pas ses voisins. Mais une même adresse est aussi bloquée si elle cumule 25 échecs, tous logins confondus : c'est le cas d'un robot qui essaie des milliers de logins différents. Une connexion réussie efface les échecs de ce login ;
- lignes 53-58 : la création de compte : au plus dix comptes par heure et par adresse IP ;
- lignes 65-67 : la clé du couple (IP, login). Le login vient du navigateur : il peut contenir n'importe quoi. Son empreinte SHA-256 ([hashlib.sha256], de la bibliothèque standard), faite de 64 chiffres hexadécimaux, donne une clé de cache toujours valide, de longueur fixe ;
- lignes 73-77 : les minutes de blocage restantes, arrondies à la minute supérieure ([math.ceil] : 30 secondes font « 1 minute ») ;
- lignes 84-87 : le compteur, dans une fenêtre fixe (exemple 27). [cache.get(cle) or {...}] : l'état existant, ou un état neuf. Le commentaire du code reconnaît la faiblesse de l'exemple 27 (deux échecs simultanés peuvent ne compter que pour un) ; pour un compteur d'échecs, c'est sans gravité.
11.12.9. securite/passe_inscription.py
| # =========================================================================
# securite/passe_inscription.py : le "laissez-passer" de la création de compte.
#
# Pour ouvrir la page "Créer un compte client", il faut d'abord avoir recopié le
# captcha de la page d'authentification (cf. web/vues/inscription.py, POST
# /inscription/acces). Le serveur délivre alors un laissez-passer :
# - un identifiant aléatoire, rangé dans un cookie HttpOnly ;
# - valable 15 minutes (le temps de remplir le formulaire, même en s'y reprenant
# à plusieurs fois après des erreurs de saisie) ;
# - consommé dès qu'un compte est créé : UN captcha résolu = UN compte au plus.
# Un robot ne peut pas créer des comptes en série.
#
# Sans laissez-passer valide, GET et POST /inscription renvoient vers la page
# d'authentification (cf. le décorateur @passe_inscription_exige) : taper
# directement l'URL /inscription ne permet pas de contourner le captcha.
#
# Comme les captchas, les laissez-passer vivent dans le cache.
# =========================================================================
import secrets
from functools import wraps
from django.conf import settings
from django.contrib import messages
from django.core.cache import cache
from django.shortcuts import redirect
from django.utils.translation import gettext as _
COOKIE = "rdvmedecins_inscription"
# le cookie n'est envoyé par le navigateur qu'aux adresses /inscription...
CHEMIN = "/inscription"
# durée de vie d'un laissez-passer, en minutes
DUREE_VIE = 15
# délivre un nouveau laissez-passer, dans un cookie de la réponse
def emettre(reponse):
id = secrets.token_hex(12)
cache.set(f"passe.{id}", True, DUREE_VIE * 60)
reponse.set_cookie(COOKIE, id, max_age=DUREE_VIE * 60, path=CHEMIN,
secure=settings.COOKIE_SECURE, httponly=True, samesite="Lax")
return reponse
# vrai si [id] est un laissez-passer existant et non expiré
def est_valide(id: str | None) -> bool:
return bool(id) and id.isalnum() and cache.get(f"passe.{id}") is not None
# le laissez-passer a servi : on l'oublie...
def consommer(id: str | None) -> None:
if id and id.isalnum():
cache.delete(f"passe.{id}")
# ... et son cookie est effacé par un cookie de même nom et de même chemin, déjà expiré
def effacer_cookie(reponse):
reponse.delete_cookie(COOKIE, path=CHEMIN, samesite="Lax")
return reponse
# -------------------------------------------------------------------------
# @passe_inscription_exige : un décorateur (cf. exemple 26), placé sur les vues qui
# exigent un laissez-passer (GET et POST /inscription). Sans laissez-passer valide,
# la vue n'est pas appelée : retour à la page d'authentification, avec un message
# -------------------------------------------------------------------------
def passe_inscription_exige(vue):
@wraps(vue)
def enveloppe(request, *args, **kwargs):
if not est_valide(request.COOKIES.get(COOKIE)):
messages.error(request, _("Pour créer un compte, recopiez d'abord correctement les "
"caractères de l'image, puis cliquez sur « Créer un compte client »."))
return redirect("login")
return vue(request, *args, **kwargs)
return enveloppe
|
Le laissez-passer de la création de compte. Pour créer un compte, il faut d'abord avoir recopié le captcha de la page de connexion (bouton « Créer un compte client ») : le serveur délivre alors un laissez-passer, un identifiant aléatoire rangé dans le cache et dans un cookie. Sans laissez-passer, la page de création de compte renvoie à la page de connexion : taper directement l'URL [/inscription] ne permet pas de contourner le captcha ; et un laissez-passer ne sert qu'à créer un compte : un robot ne peut pas créer des comptes en série.
- lignes 29-33 : le cookie n'est envoyé par le navigateur qu'aux adresses qui commencent par [/inscription] (paramètre [path] d'un cookie) : il ne voyage pas avec les autres requêtes ;
- lignes 37-42 : la délivrance, pour quinze minutes : le temps de remplir le formulaire, même en s'y reprenant à plusieurs fois après des erreurs de saisie ;
- lignes 46-59 : la vérification, la consommation, et l'effacement du cookie (qui doit porter le même chemin que le cookie à effacer). [isalnum] vérifie que l'identifiant n'est fait que de lettres et de chiffres ;
- lignes 67-75 : le décorateur [@passe_inscription_exige] (exemple 26), placé sur la vue de la création de compte.
11.13. La couche web : les services communs
Le dossier [web] est l'application [Django] de la couche web (déclarée dans [INSTALLED_APPS], cf. [config/settings.py] ; son fichier [web/apps.py] ne contient que son nom). Il réunit les routes, les vues, les formulaires, et ce que toutes les vues ont en commun : c'est l'objet de cette section.
11.13.1. web/application.py
| # =========================================================================
# web/application.py : le cache / façade de la couche métier (l'équivalent de la
# classe [ApplicationModel] des autres versions de l'application) : les listes de
# médecins et de clients sont lues en base une fois, puis servies depuis le cache.
#
# Pourquoi le CACHE de Django, et pas simplement un attribut de cet objet ? Un
# processus Python garde bien ses objets d'une requête à l'autre (cf. exemple 20) ;
# mais un serveur de production lance PLUSIEURS processus, dont chacun aurait SA
# copie des listes : l'ADMIN qui ajoute un médecin ne mettrait à jour que celle du
# processus qui a traité sa requête. Le cache de l'application (des fichiers, cf.
# config/settings.py) est, lui, partagé par tous les processus.
#
# Ces listes peuvent CHANGER (l'ADMIN ajoute, modifie, supprime des médecins et des
# clients) : chaque écriture passe donc par cette façade, qui OUBLIE la liste
# concernée juste après ; la requête suivante qui en a besoin la relit en base. C'est
# tout l'intérêt d'un point d'entrée UNIQUE vers la couche métier : la règle
# "écriture => mise à jour du cache" n'est écrite qu'ici.
# =========================================================================
from datetime import date
from functools import cache as une_fois
from django.core.cache import cache
from domaine.agenda import AgendaMedecinJour
from metier.donnees import DonneesPersonne
from metier.fournisseur import metier
from metier.metier import Metier
from modeles.models import Client, Creneau, Medecin, Rv
MEDECINS = "rdvmedecins.medecins"
CLIENTS = "rdvmedecins.clients"
# même contrat que la couche métier
class ApplicationModel(Metier):
def __init__(self):
self.metier = metier()
# ------------------------------------------------ lectures depuis le cache
# get_or_set(clé, fonction, timeout) : la valeur du cache si elle existe ; sinon,
# la fonction est appelée, et son résultat rangé dans le cache (timeout=None :
# sans limite de durée) avant d'être rendu. Les objets y sont rangés sérialisés
# (module pickle) : chaque requête qui les lit en obtient des copies
def get_all_medecins(self) -> list[Medecin]:
return cache.get_or_set(MEDECINS, self.metier.get_all_medecins, timeout=None)
def get_all_clients(self) -> list[Client]:
return cache.get_or_set(CLIENTS, self.metier.get_all_clients, timeout=None)
# le médecin dont le compte utilisateur est [id_user] - sert à présélectionner,
# pour un médecin connecté, son propre agenda
def get_medecin_by_user_id(self, id_user: int) -> Medecin | None:
return next((m for m in self.get_all_medecins() if m.compte_id == id_user), None)
# ------------------------------------------------ simples délégations
def get_all_creneaux(self, id_medecin: int) -> list[Creneau]:
return self.metier.get_all_creneaux(id_medecin)
def get_rv_medecin_jour(self, id_medecin: int, jour: date) -> list[Rv]:
return self.metier.get_rv_medecin_jour(id_medecin, jour)
def get_client_by_id(self, id: int) -> Client | None:
return self.metier.get_client_by_id(id)
def get_medecin_by_id(self, id: int) -> Medecin | None:
return self.metier.get_medecin_by_id(id)
def get_rv_by_id(self, id: int) -> Rv | None:
return self.metier.get_rv_by_id(id)
def get_creneau_by_id(self, id: int) -> Creneau | None:
return self.metier.get_creneau_by_id(id)
def get_agenda_medecin_jour(self, id_medecin: int, jour: date) -> AgendaMedecinJour | None:
return self.metier.get_agenda_medecin_jour(id_medecin, jour)
# pas de cache ici : c'est la donnée la plus récente qui compte (un patient qui
# vient de modifier ou de supprimer son compte)
def get_client_by_user_id(self, id_user: int) -> Client | None:
return self.metier.get_client_by_user_id(id_user)
def get_rv_a_venir_client(self, id_client: int) -> list[Rv]:
return self.metier.get_rv_a_venir_client(id_client)
def ajouter_rv(self, jour: date, creneau: Creneau, client: Client) -> Rv:
return self.metier.ajouter_rv(jour, creneau, client)
def supprimer_rv(self, rv: Rv) -> None:
self.metier.supprimer_rv(rv)
# ------------------------------------------------ écritures => cache oublié
# (delete : l'élément est retiré du cache)
def ajouter_medecin(self, donnees: DonneesPersonne) -> Medecin:
medecin = self.metier.ajouter_medecin(donnees)
cache.delete(MEDECINS)
return medecin
def modifier_medecin(self, id: int, donnees: DonneesPersonne) -> Medecin:
medecin = self.metier.modifier_medecin(id, donnees)
cache.delete(MEDECINS)
return medecin
def supprimer_medecin(self, id: int) -> None:
self.metier.supprimer_medecin(id)
cache.delete(MEDECINS)
def ajouter_client(self, donnees: DonneesPersonne) -> Client:
client = self.metier.ajouter_client(donnees)
cache.delete(CLIENTS)
return client
def modifier_client(self, id: int, donnees: DonneesPersonne) -> Client:
client = self.metier.modifier_client(id, donnees)
cache.delete(CLIENTS)
return client
def supprimer_client(self, id: int) -> None:
self.metier.supprimer_client(id)
cache.delete(CLIENTS)
# le fournisseur de la façade : un seul objet, partagé (cf. metier/fournisseur.py)
@une_fois
def application() -> ApplicationModel:
return ApplicationModel()
|
Toutes les versions de l'application ont une classe [ApplicationModel] : un point d'entrée unique de la couche web vers la couche métier, qui garde en mémoire les listes des médecins et des clients. Elles sont demandées par presque toutes les pages (la liste déroulante des médecins de l'agenda, celle des patients de la fenêtre de réservation, les pages d'administration), et changent rarement.
Un processus [Python] garde ses objets d'une requête à l'autre (exemple 20) : la version [Flask] rangeait donc simplement ces listes dans les attributs d'un objet unique. Le commentaire de tête explique pourquoi cette version les range plutôt dans le cache de [Django] (exemple 20) : un serveur de production lance plusieurs processus, et chacun aurait sa copie des listes.
- ligne 36 : la classe hérite du contrat [Metier], comme [MetierService] : les vues appellent les mêmes méthodes, quelle que soit la classe qui les sert ;
- lignes 45-49 : [get_or_set(clé, fonction, timeout)] rend la valeur rangée sous cette clé ; si elle n'existe pas, il appelle la fonction (ici, une méthode liée de la couche métier, passée sans parenthèses, qui lit la liste en base), range son résultat dans le cache, sans limite de durée ([timeout=None]), et le rend. La première requête qui a besoin de la liste la lit en base ; les suivantes la trouvent dans le cache. Le cache range la liste sérialisée par le module [pickle] (exemple 20), et la reconstruit à la lecture, avec les relations déjà chargées : le compte de chaque personne, lu par la jointure [select_related("compte")] de la couche DAO. Les objets reconstruits sont des copies : ils servent à l'affichage, jamais à une écriture (la couche métier relit toujours en base ce qu'elle modifie) ;
- lignes 53-54 : le médecin d'un compte, cherché dans la liste du cache. [next(générateur, None)] rend le premier élément produit par l'expression génératrice, ou [None] s'il n'y en a pas (exemple 06) ;
- lignes 57-90 : les autres méthodes du contrat délèguent simplement à la couche métier. Le client d'un compte ([get_client_by_user_id]) est toujours relu en base, et non cherché dans le cache : c'est la donnée la plus récente qui compte (un patient qui vient de modifier ou de supprimer son compte) ;
- lignes 94-120 : les écritures. Chaque écriture d'un médecin ou d'un client oublie la liste concernée ([delete]) : la requête suivante qui en aura besoin la relira en base. C'est tout l'intérêt d'un point d'entrée unique : la règle « une écriture met à jour le cache » n'est écrite qu'ici. Si l'écriture échoue (une [MetierErreur]), l'exception sort de la méthode avant le [delete] : la liste n'est pas oubliée, elle n'a pas changé. Une application qui écrirait dans la base par un autre chemin (un autre programme, une autre version de l'application sur la même base) laisserait le cache périmé : il suffirait alors de vider le dossier [var/cache] ;
- lignes 124-126 : le fournisseur de la façade, sur le modèle de celui de la couche métier : un seul objet, créé à la première demande. [functools.cache], importé sous le nom [une_fois], mémorise le résultat d'une fonction (exemple 06) : la fonction [application()] ne s'exécute qu'une fois, puis rend toujours le même objet.
11.13.2. web/outils.py
| # =========================================================================
# web/outils.py : petites fonctions utilitaires de la couche web :
# - dates : "aaaa-mm-jj", jours "mercredi 23 septembre 2026" ;
# - saisie : lecture d'un entier positif, d'une date, envoyés par le navigateur ;
# - utilisateur : l'utilisateur connecté, et son patient (modèle Client).
# =========================================================================
import re
from datetime import date, datetime
from django.utils import dateformat
from modeles.models import Client
from securite.erreurs import SessionInvalide
from securite.utilisateur import UtilisateurConnecte
from .application import application
# ----------------------------------------------------------------- dates
# la date, si [texte] est une date qui existe au format aaaa-mm-jj ; None sinon.
# strptime refuse les dates qui n'existent pas ("2026-02-30")
def date_valide(texte: str | None) -> date | None:
if texte is None or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", texte):
return None
try:
return datetime.strptime(texte, "%Y-%m-%d").date()
except ValueError:
return None
# "2026-09-23"
def iso(jour: date) -> str:
return jour.isoformat()
# "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", dans la langue
# active (cf. web/middleware.py). dateformat.format met en forme une date comme le
# filtre date des gabarits : l le jour de la semaine en toutes lettres, j le jour,
# F le mois en toutes lettres, Y l'année ; les noms sont traduits par Django
def formater_jour(jour: date) -> str:
return dateformat.format(jour, "l j F Y")
# ----------------------------------------------------------------- saisie
# "3" -> 3 ; "", "abc", "-2", "+2", None -> None
def entier_positif(valeur: str | None) -> int | None:
if valeur is None or not re.fullmatch(r"\d{1,9}", valeur):
return None
n = int(valeur)
return n if n > 0 else None
# ----------------------------------------------------------------- utilisateur
# l'utilisateur connecté. Les vues protégées ne sont appelées que s'il existe (cf.
# securite/middleware.py) ; par précaution, son absence est traitée comme une
# session expirée
def utilisateur(request) -> UtilisateurConnecte:
if request.utilisateur is None:
raise SessionInvalide()
return request.utilisateur
# -------------------------------------------------------------------------
# Le patient (modèle Client) qui correspond à l'utilisateur connecté.
#
# Le jeton JWT ne contient que l'id du COMPTE (users.id). Pour un rôle USER, on
# retrouve le client correspondant grâce à la colonne clients.user.
#
# S'il n'y en a pas, c'est que le compte a été supprimé depuis la connexion alors
# que son jeton est encore valide : on le traite comme une session expirée.
# -------------------------------------------------------------------------
def patient_connecte(request) -> Client:
client = application().get_client_by_user_id(utilisateur(request).user_id)
if client is None:
raise SessionInvalide()
return client
# l'adresse IP du client
def ip(request) -> str:
return request.META.get("REMOTE_ADDR", "")
|
Quelques fonctions utilitaires, dont les vues ont toutes besoin :
- lignes 23-29 : la date d'un paramètre [aaaa-mm-jj], ou [None]. Deux vérifications : la forme (une expression régulière ; [fullmatch] exige que toute la chaîne corresponde), puis l'existence de la date : [strptime] lève une [ValueError] pour le 30 février ;
- lignes 41-42 : « mercredi 23 septembre 2026 » ou « Wednesday 23 September 2026 », selon la langue de la requête. [dateformat.format] met en forme une date comme le filtre [date] des gabarits (exemple 16), avec les mêmes codes ; les noms des jours et des mois sont traduits par [Django] lui-même, dans la langue active ;
- lignes 47-51 : un paramètre de requête est une chaîne, ou [None]. On n'accepte que des chiffres (neuf au plus : un nombre raisonnable, que la base accepte), et un nombre strictement positif ;
- lignes 58-61 : l'utilisateur connecté, établi par le middleware [Authentification]. Les vues protégées ne sont appelées que pour un utilisateur authentifié (le contrôle d'accès), mais on traite quand même son absence, comme une session expirée ;
- lignes 73-77 : le patient (le [Client]) de l'utilisateur connecté. Le jeton ne contient que l'id du compte ; le client est retrouvé par la colonne [clients.user]. S'il n'existe pas, le compte a été supprimé depuis la connexion : [SessionInvalide] (le middleware [PageErreur], plus loin) ;
- lignes 81-82 : l'adresse IP du client, pour le limiteur de tentatives. [REMOTE_ADDR] est l'adresse de la machine qui a ouvert la connexion. Derrière un serveur mandataire (reverse proxy, cf. « Un serveur de production »), ce serait toujours l'adresse du mandataire : il faudrait alors lire l'entête qu'il ajoute ([X-Forwarded-For]), et seulement celui qu'il ajoute, car le navigateur peut envoyer n'importe quel entête.
11.13.3. web/contextes.py
| # =========================================================================
# web/contextes.py : un processeur de contexte (cf. exemple 15) : les variables dont
# TOUTES les pages ont besoin :
# - utilisateur : l'utilisateur connecté (ou None), pour la barre de navigation ;
# - menu : les entrées du menu de la barre de navigation, qui dépendent du rôle.
#
# Le menu est calculé ICI, en Python, et non dans le gabarit : "Médecins" et
# "Clients" n'apparaissent que pour l'ADMIN. Ce n'est qu'un confort d'affichage : la
# vraie protection, ce sont les règles d'accès (un USER qui tape /medecins à la
# main obtient une page 403).
# =========================================================================
from dataclasses import dataclass
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from securite import auth
# une entrée du menu : son URL, son libellé, et si elle est celle de la page courante
@dataclass(frozen=True)
class EntreeMenu:
url: str
libelle: str
active: bool
def utilisateur(request):
# l'utilisateur établi par le middleware Authentification. Par précaution, s'il
# manque (une page d'erreur due à une erreur survenue avant ce middleware), il est
# lu directement dans le jeton, pour que la barre de navigation soit complète
connecte = getattr(request, "utilisateur", None)
if connecte is None and request.COOKIES.get(auth.COOKIE):
connecte = auth.utilisateur(request.COOKIES[auth.COOKIE])
return {"utilisateur": connecte, "menu": menu(request, connecte)}
# les entrées du menu : (nom de la route, libellé, préfixes des chemins pour
# lesquels l'entrée est active)
def menu(request, connecte):
if connecte is None:
return []
entrees = []
if connecte.est_patient:
# active pour /mon-espace ET /mon-compte
entrees.append(("espace", _("Mon espace"), ["/mon-"]))
entrees.append(("agenda", _("Agenda"), ["/agenda"]))
if connecte.est_admin:
entrees.append(("medecins_liste", _("Médecins"), ["/medecins"]))
entrees.append(("clients_liste", _("Clients"), ["/clients"]))
# any : vrai si l'un des préfixes convient
return [EntreeMenu(reverse(route), libelle, any(request.path.startswith(p) for p in prefixes))
for route, libelle, prefixes in entrees]
|
Un processeur de contexte (exemple 15), déclaré dans [config/settings.py] : les variables dont toutes les pages ont besoin, dans le contexte de tous les gabarits :
- [utilisateur] : l'utilisateur connecté, ou [None], pour la barre de navigation. D'ordinaire, c'est celui du middleware [Authentification] ; [getattr(objet, nom, défaut)] lit un attribut, ou rend la valeur par défaut s'il n'existe pas. Par précaution, s'il manque, l'utilisateur est lu directement dans le jeton (lignes 34-35) ;
- [menu] : les entrées du menu de la barre de navigation, qui dépendent du rôle (lignes 41-54). Chaque entrée est un objet [EntreeMenu] (une classe de données, lignes 22-26) : l'URL (fabriquée par [reverse], exemple 04), le libellé traduit, et l'indication de l'entrée active, celle de la page affichée, que le gabarit met en valeur. L'entrée [Mon espace] est active pour toutes les URL qui commencent par [/mon-] : [/mon-espace] et [/mon-compte].
Le menu est calculé en [Python], et non dans le gabarit (pas de logique dans les gabarits, plus loin). Masquer les entrées Médecins et Clients n'est qu'un confort d'affichage : la vraie protection, ce sont les règles d'accès.
| # =========================================================================
# web/templatetags/rdv.py : les balises de nos gabarits (cf. exemple 16), chargées
# par {% load rdv %}.
#
# {% zone_succes %} : les messages flash de SUCCÈS, en haut de la page ;
# {% zone_erreurs %} : la zone d'erreur, en BAS de chaque page, sur fond rose
# léger. Elle regroupe les trois sources d'erreur possibles :
# - [erreur] : l'erreur de la page affichée (date invalide, captcha mal
# recopié, login déjà pris...), transmise par la vue ;
# - les messages flash d'erreur, déposés par la vue précédente avant une
# redirection (créneau déjà réservé, suppression refusée...) ;
# - les erreurs de saisie d'un formulaire invalide (cf. exemple 19) : leurs
# messages sont affichés SOUS les champs ; ici, s'il n'y a pas d'autre
# message, une simple phrase « Le formulaire contient des erreurs. ».
# =========================================================================
from django import template
from django.contrib import messages as flash
from django.utils.translation import gettext as _
register = template.Library()
# takes_context : la fonction reçoit le contexte du gabarit qui l'appelle ; messages :
# la variable du processeur de contexte des messages flash ; level : le niveau d'un message
@register.inclusion_tag("fragments/succes.html", takes_context=True)
def zone_succes(context):
return {"textes": [m.message for m in context.get("messages", []) if m.level == flash.SUCCESS]}
@register.inclusion_tag("fragments/erreurs.html", takes_context=True)
def zone_erreurs(context):
textes = [m.message for m in context.get("messages", []) if m.level == flash.ERROR]
if context.get("erreur"):
textes.insert(0, context["erreur"])
formulaire = context.get("formulaire")
# un formulaire LIÉ (envoyé) et invalide, sans autre message
if not textes and formulaire is not None and formulaire.is_bound and formulaire.errors:
textes.append(_("Le formulaire contient des erreurs."))
return {"textes": textes}
|
Deux balises d'inclusion (exemple 16), chargées dans les gabarits par [{% load rdv %}]. Chacune rend un petit gabarit (un fragment, plus loin), avec les variables que rend la fonction :
- lignes 26-28 : [{% zone_succes %}] : les messages flash de succès (exemple 20), affichés en haut de la page. [takes_context=True] : la fonction reçoit le contexte du gabarit qui l'appelle, où le processeur de contexte des messages a rangé la variable [messages]. [level] est le niveau d'un message ([flash.SUCCESS], [flash.ERROR]...) ;
- lignes 31-40 : [{% zone_erreurs %}] : la zone d'erreur, en bas de chaque page. Elle regroupe les trois sources d'erreur possibles : l'erreur de la page (la variable [erreur], transmise par la vue, placée en tête de la liste par [insert(0, ...)]), les messages flash d'erreur (déposés par la vue précédente avant une redirection), et les erreurs de saisie d'un formulaire invalide, dont les messages sont affichés sous les champs : la zone d'erreur se contente alors d'une phrase, « Le formulaire contient des erreurs. ». [is_bound] : le formulaire a reçu une saisie (exemple 19).
Les deux balises lisent la même liste, chacune n'en gardant qu'un niveau. Une liste de messages qui a été lue est effacée à la fin de la requête (exemple 20) : chaque message n'est affiché qu'une fois.
11.13.5. web/middleware.py
| # =========================================================================
# web/middleware.py : deux middlewares de la couche web (cf. exemple 24).
# =========================================================================
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import BadRequest, PermissionDenied
from django.http import Http404
from django.shortcuts import redirect
from django.utils import translation
from django.utils.translation import gettext as _
from securite import auth
from securite.erreurs import SessionInvalide
from .vues.erreurs import page_erreur
# -------------------------------------------------------------------------
# la langue de la requête (cf. exemple 22) : celle du cookie [rdvmedecins_langue]
# si c'est une langue gérée, le français sinon. Placé en tête de la liste des
# middlewares, il agit pour TOUTES les requêtes, même celles qui n'aboutissent à
# aucune route (page 404) : leur page d'erreur est dans la bonne langue
# -------------------------------------------------------------------------
class FixerLangue:
# les langues gérées ; la première est la langue par défaut
LANGUES = [code for code, nom in settings.LANGUAGES]
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
langue = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
if langue not in self.LANGUES:
langue = self.LANGUES[0]
translation.activate(langue)
# la langue de la requête, rangée comme le fait LocaleMiddleware (cf. exemple 22)
request.LANGUAGE_CODE = langue
return self.get_response(request)
# -------------------------------------------------------------------------
# comment les erreurs deviennent des pages HTML. Trois cas :
# 1. une SessionInvalide (compte supprimé depuis la connexion) -> retour à la
# page de connexion, comme pour une session expirée ;
# 2. une erreur HTTP levée par une vue : requête invalide (BadRequest, 400), rôle
# insuffisant (PermissionDenied, 403), objet inconnu (Http404) -> la page
# d'erreur, avec le code d'origine ;
# 3. toute autre exception (une vraie anomalie) : Django l'inscrit dans le
# journal, avec sa pile d'appels ; en production (DEBUG faux), l'utilisateur
# voit la page d'erreur 500 (handler500, cf. config/urls.py) - jamais la pile
# d'appels ; en développement, la page de débogage de Django.
# Une méthode HTTP refusée (405) n'est pas une exception : les décorateurs
# require_GET... rendent une réponse 405 vide, que __call__ remplace par notre page.
# -------------------------------------------------------------------------
class PageErreur:
CODES = {BadRequest: 400, PermissionDenied: 403, Http404: 404}
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
reponse = self.get_response(request)
if reponse.status_code == 405 and not reponse.content:
page = page_erreur(request, 405)
# l'entête Allow : les méthodes acceptées
page["Allow"] = reponse["Allow"]
return page
return reponse
def process_exception(self, request, exception):
if isinstance(exception, SessionInvalide):
messages.error(request, _("Votre session a expiré : reconnectez-vous."))
return auth.effacer_jeton(redirect("login"))
for classe, code in self.CODES.items():
if isinstance(exception, classe):
return page_erreur(request, code)
return None
|
Les deux middlewares de la couche web (exemple 24). Rappelons leur place dans la liste [MIDDLEWARE] de [config/settings.py] : [FixerLangue] en tête, [PageErreur] en dernier.
- lignes 25-39 : la langue de la requête, celle du cookie [rdvmedecins_langue] si c'est une langue gérée, le français sinon. C'est le middleware [FixerLangue] de l'exemple 16, et non [LocaleMiddleware] (exemple 22) : [LocaleMiddleware] choisirait, à défaut de cookie, la langue préférée du navigateur ; les autres versions de l'application commencent toujours en français, et celle-ci fait de même. Les langues gérées sont lues dans le réglage [LANGUAGES] (ligne 27, une liste en compréhension, exécutée une fois, au chargement de la classe). [translation.activate] (ligne 36) fixe la langue active (exemple 16) : celle des traductions, des dates, et de la variable [LANGUAGE_CODE] des gabarits (processeur de contexte [i18n], exemple 22). La langue est aussi rangée dans [request.LANGUAGE_CODE] (ligne 38), comme le fait [LocaleMiddleware], pour les vues qui voudraient la lire. Placé en tête, ce middleware agit pour toutes les requêtes, avant tout autre traitement : toutes les pages, pages d'erreur comprises, sont dans la bonne langue ;
- lignes 62-69 : une méthode HTTP refusée (un GET vers [/logout], qui n'accepte que POST) n'est pas une exception : les décorateurs [require_GET], [require_POST]... rendent une réponse 405, sans contenu. [__call__] la remplace par la page d'erreur, en gardant l'entête [Allow] (les méthodes acceptées), obligatoire dans une réponse 405 ;
- lignes 71-78 : les exceptions qui sortent des vues ([process_exception], exemple 24). Une [SessionInvalide] (le compte a disparu) : le message « session expirée », l'effacement du cookie du jeton, et la page de connexion. Une erreur HTTP levée par une vue ([BadRequest], [PermissionDenied], [Http404]) : la page d'erreur, avec son code, en production comme en développement. Toute autre exception est une vraie anomalie : la méthode rend [None], et [Django] la traite lui-même ; il l'inscrit dans le journal, avec sa pile d'appels, puis affiche sa page de débogage en développement, la page d'erreur 500 ([handler500]) en production. L'utilisateur ne voit jamais la pile d'appels d'une application en production.
| $ curl -si http://localhost:8000/logout
HTTP/1.1 405 Method Not Allowed
Content-Type: text/html; charset=utf-8
Allow: POST
...
<!doctype html>
...
|
Le code 405 (Method Not Allowed) est présenté à l'utilisateur comme une page introuvable (le message de la page d'erreur, plus loin). Une URL qui ne correspond à aucune route ne passe pas par [PageErreur] : [Django] appelle directement la vue [handler404] en production, et affiche sa page 404 détaillée en développement (exemple 24).
11.14. Les dictionnaires
Les textes de l'interface sont ceux des autres versions de l'application, qui les rangeaient dans des fichiers JSON, sous des clés ([LOGIN.TITLE] désignait le texte [Connexion]). [Django] procède autrement (exemple 22) : le texte d'origine, en français, est écrit directement dans le code ([_("Connexion")]) et dans les gabarits ([{% translate "Connexion" %}]) ; il sert lui-même de clé dans le dictionnaire anglais, le fichier [locale/en/LC_MESSAGES/django.po] :
| #: web/vues/agenda.py:202 web/vues/espace.py:75
msgid "Rendez-vous annulé."
msgstr "Appointment cancelled."
#: web/vues/auth.py:62 web/vues/espace.py:142
#, python-format
msgid "Trop de tentatives infructueuses. Réessayez dans %(minutes)d minute."
msgid_plural ""
"Trop de tentatives infructueuses. Réessayez dans %(minutes)d minutes."
msgstr[0] "Too many failed attempts. Try again in %(minutes)d minute."
msgstr[1] "Too many failed attempts. Try again in %(minutes)d minutes."
|
Ce fichier d'environ 140 textes n'est pas reproduit ici. Il a été fabriqué par la commande [python manage.py makemessages -l en], qui relève tous les textes marqués du code et des gabarits, puis complété à la main, et compilé en [django.mo] par [python manage.py compilemessages] (exemple 22). Le fichier [django.mo] compilé est livré avec le code : l'application fonctionne sans les outils [gettext]. Il n'y a pas de dictionnaire français : un texte sans traduction est affiché tel quel, c'est-à-dire en français.
Les lignes [#:] indiquent où chaque texte est utilisé ; [#, python-format] signale un texte à paramètres ([%(minutes)d]), dont [makemessages] vérifiera la traduction. [gettext] permet ce que les autres versions ne savaient pas faire : le pluriel (« 1 minute », « 15 minutes »), avec [ngettext] dans le code (plus loin) et [msgid_plural] dans le dictionnaire (exemple 22). Un texte a été ajouté : le message de la page 419, que les autres versions n'ont pas.
Les messages d'erreur des formulaires sont traduits de la même façon (plus loin). Les messages que [Django] fournit lui-même (un nombre trop grand, une adresse électronique invalide...) sont traduits par les dictionnaires de [Django].
Les formulaires « personne » sont des formulaires [Django] (exemples 18 et 19) : une classe décrit les champs et leurs contraintes ; la vue fabrique le formulaire avec la saisie ([PersonneForm(request.POST)]), puis lui demande s'il est valide ([is_valid]). Un formulaire invalide est réaffiché, avec la saisie de l'utilisateur et les messages d'erreur sous les champs (sans les mots de passe : un champ [PasswordInput] n'est jamais réaffiché). Le jeton anti-CSRF est vérifié, pour tous les formulaires, par le middleware [CsrfViewMiddleware].
| # =========================================================================
# web/formulaires.py : les formulaires "personne" et leurs contraintes (cf.
# exemples 18 et 19). Trois formulaires partagent les mêmes champs d'identité
# (titre, prénom, nom) :
#
# ChampsPersonneForm titre, prénom, nom
# ├─ PersonneForm + login / mot de passe FACULTATIFS, version
# │ (l'ADMIN ajoute ou modifie un médecin / client)
# ├─ InscriptionForm + login / mot de passe / confirmation OBLIGATOIRES
# │ (un patient crée son compte)
# └─ MonCompteForm + login, nouveau mot de passe facultatif, mot de
# passe ACTUEL obligatoire, version
# (un patient modifie son compte)
#
# Les classes filles HÉRITENT de ChampsPersonneForm : elles ont ses champs, plus
# les leurs. La saisie validée (cleaned_data) est transformée en DonneesPersonne
# pour la couche métier par la méthode donnees().
#
# Les libellés, les aides et les messages d'erreur sont traduits (gettext_lazy,
# cf. metier/erreurs.py). Les longueurs maximales sont celles des colonnes SQL.
# =========================================================================
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.utils.translation import gettext_lazy as _
from metier.donnees import DonneesPersonne
from securite import mots_de_passe
TITRES = ["Mr", "Mme", "Melle"]
# longueur minimale d'un mot de passe CHOISI par un patient
MOT_DE_PASSE_MIN = 6
# login : lettres minuscules, chiffres, point, tiret, souligné - 30 caractères au plus
FORMAT_LOGIN = RegexValidator(r"^[a-z0-9._-]{1,30}$", message=_(
"30 caractères au plus : lettres minuscules non accentuées, chiffres, point, tiret, souligné."))
# bcrypt ne tient compte que des 72 premiers octets d'un mot de passe (cf.
# securite/mots_de_passe.py) ; une lettre accentuée occupe deux octets en UTF-8
def longueur_bcrypt(valeur: str) -> None:
if len(valeur.encode()) > mots_de_passe.MAX_OCTETS:
raise ValidationError(_("Le mot de passe ne doit pas dépasser 72 caractères."))
# -------------------------------------------------------------------------
# un login est toujours en minuscules : le champ convertit la saisie AVANT sa
# validation (to_python est appelée avant les validateurs)
# -------------------------------------------------------------------------
class LoginField(forms.CharField):
def to_python(self, value):
texte = super().to_python(value)
return texte.lower() if texte else texte
# un champ de mot de passe : jamais réaffiché, et sans retrait des espaces
# (strip=False : un espace fait partie du mot de passe)
def champ_mot_de_passe(label, required, help_text="", min_length=None):
messages = {
"required": _("Le mot de passe est obligatoire."),
# %(limit_value)d : la longueur minimale, placée par Django dans le message
"min_length": _("Le mot de passe doit comporter au moins %(limit_value)d caractères."),
}
return forms.CharField(label=label, required=required, help_text=help_text, strip=False,
min_length=min_length, validators=[longueur_bcrypt],
error_messages=messages,
widget=forms.PasswordInput(attrs={"maxlength": 72,
"autocomplete": "new-password"}))
# -------------------------------------------------------------------------
# la classe mère de nos formulaires (cf. exemple 18) : chaque zone de saisie reçoit
# la classe CSS de Bootstrap qui lui convient ; signaler() encadre en rouge (classe
# is-invalid) les champs qui ont une erreur de saisie, et ceux qu'une erreur MÉTIER
# désigne (un login déjà pris...), le message étant alors en bas de page
# -------------------------------------------------------------------------
class FormulaireBootstrap(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for champ in self.fields.values():
classe = "form-select" if isinstance(champ.widget, forms.Select) else "form-control"
champ.widget.attrs["class"] = classe
def signaler(self, champs=()):
# errors : les erreurs de saisie ; | : l'union de deux ensembles
for nom in set(self.errors) | set(champs):
if nom in self.fields:
self.fields[nom].widget.attrs["class"] += " is-invalid"
return self
class ChampsPersonneForm(FormulaireBootstrap):
titre = forms.ChoiceField(label=_("Titre"), choices=[(t, t) for t in TITRES], error_messages={
"required": _("Choisissez un titre dans la liste."),
"invalid_choice": _("Choisissez un titre dans la liste.")})
prenom = forms.CharField(label=_("Prénom"), max_length=20, error_messages={
"required": _("Le prénom est obligatoire."),
"max_length": _("Le prénom ne doit pas dépasser 20 caractères.")})
nom = forms.CharField(label=_("Nom"), max_length=20, error_messages={
"required": _("Le nom est obligatoire."),
"max_length": _("Le nom ne doit pas dépasser 20 caractères.")})
# la saisie validée -> les données attendues par la couche métier. Un champ
# vide vaut None (or None : une chaîne vide est "fausse")
def donnees(self) -> DonneesPersonne:
d = self.cleaned_data
return DonneesPersonne(d["titre"], d["nom"], d["prenom"], d.get("login") or None,
d.get("password") or None, d.get("version"))
# le formulaire d'un médecin ou d'un client (ADMIN) : login et mot de passe
# FACULTATIFS ; creation=False : le formulaire de modification, avec la version
class PersonneForm(ChampsPersonneForm):
def __init__(self, *args, creation=True, **kwargs):
super().__init__(*args, **kwargs)
# un champ vide est accepté : les validateurs ne s'appliquent qu'à une valeur saisie
self.fields["login"] = LoginField(
label=_("Login"), required=False, max_length=30, validators=[FORMAT_LOGIN],
help_text=_("Facultatif : par défaut, le nom en minuscules.") if creation
else _("Laisser vide pour ne pas le changer."),
widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "off"}))
self.fields["password"] = champ_mot_de_passe(
_("Mot de passe"), False,
_("Facultatif : par défaut, identique au login.") if creation
else _("Laisser vide pour ne pas le changer."))
self.fields["password"].widget.attrs["class"] = "form-control"
# modification : la version de la fiche affichée, dans un champ caché (le
# verrou optimiste)
if not creation:
self.fields["version"] = forms.IntegerField(widget=forms.HiddenInput)
# -------------------------------------------------------------------------
# le mot de passe et sa confirmation : deux champs, comparés par clean (cf.
# exemple 19). Commun à l'inscription (obligatoire) et au compte du patient
# (facultatif : vide = mot de passe inchangé)
# -------------------------------------------------------------------------
class AvecConfirmation(ChampsPersonneForm):
def ajouter_mots_de_passe(self, obligatoire: bool):
self.fields["login"] = LoginField(
label=_("Login"), max_length=30, validators=[FORMAT_LOGIN],
help_text=_("Lettres minuscules non accentuées, chiffres, point, tiret, souligné.")
if obligatoire else "",
error_messages={"required": _("Le login est obligatoire.")},
widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "username"}))
self.fields["password"] = champ_mot_de_passe(
_("Mot de passe") if obligatoire else _("Nouveau mot de passe"), obligatoire,
_("Au moins 6 caractères.") if obligatoire else _("Laisser vide pour ne pas le changer."),
min_length=MOT_DE_PASSE_MIN)
self.fields["confirmation"] = champ_mot_de_passe(_("Confirmation du mot de passe"), obligatoire)
self.fields["confirmation"].error_messages["required"] = _("Confirmez le mot de passe.")
for nom in ("password", "confirmation"):
self.fields[nom].widget.attrs["class"] = "form-control"
# la validation du formulaire entier, après celle de chaque champ
def clean(self):
donnees = super().clean()
password = donnees.get("password")
confirmation = donnees.get("confirmation")
if password and password != confirmation and "confirmation" not in self.errors:
self.add_error("confirmation", _("La confirmation ne correspond pas au mot de passe."))
return donnees
# "Créer un compte client" : login, mot de passe et confirmation OBLIGATOIRES
class InscriptionForm(AvecConfirmation):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ajouter_mots_de_passe(obligatoire=True)
# "Gérer mon compte" (un patient) : login obligatoire, nouveau mot de passe
# facultatif, mot de passe ACTUEL obligatoire, version
class MonCompteForm(AvecConfirmation):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ajouter_mots_de_passe(obligatoire=False)
# le mot de passe ACTUEL, exigé pour toute modification du compte
self.fields["current_password"] = forms.CharField(
label=_("Mot de passe actuel"), strip=False,
help_text=_("Obligatoire pour enregistrer une modification."),
error_messages={"required": _("Saisissez votre mot de passe actuel.")},
widget=forms.PasswordInput(attrs={"class": "form-control", "maxlength": 72,
"autocomplete": "current-password"}))
self.fields["version"] = forms.IntegerField(widget=forms.HiddenInput)
|
Trois formulaires partagent les champs d'identité (titre, prénom, nom) : celui de l'administrateur (un médecin ou un client), la création d'un compte client, et « Gérer mon compte ». Le commentaire de tête dessine leur arbre d'héritage : leur classe mère commune, [ChampsPersonneForm], décrit les champs d'identité ; chaque fille ajoute les siens. Les libellés, les aides et les messages sont traduits de façon paresseuse ([gettext_lazy]) : ils sont définis au chargement du module, et traduits à chaque affichage, dans la langue de la requête (la couche métier, plus haut).
- lignes 36-37 : la forme d'un login, un validateur ([RegexValidator]) qui vérifie une expression régulière. Un validateur est un objet appelable qui lève [ValidationError] pour une valeur incorrecte (exemple 19) ; on le donne à un champ par son paramètre [validators] ;
- lignes 42-44 : un validateur écrit comme une simple fonction. [bcrypt] ne prend en compte que les 72 premiers octets d'un mot de passe (la couche sécurité, plus haut) : la limite porte sur les octets, et non sur les caractères ([encode()] : une lettre accentuée occupe deux octets en [UTF-8]) ;
- lignes 51-54 : un login est toujours en minuscules. Un champ personnalisé, fils de [CharField], redéfinit [to_python], la méthode qui convertit la saisie en valeur Python : elle est appelée avant les validateurs, qui vérifient donc la saisie déjà convertie. [super().to_python(value)] fait d'abord le travail ordinaire de [CharField] (retrait des espaces qui entourent la saisie) ;
- lignes 59-69 : une fonction qui fabrique un champ de mot de passe. [strip=False] : les espaces ne sont pas retirés, ils peuvent faire partie d'un mot de passe. [min_length] : la longueur minimale, que [Django] vérifie lui-même ; son message contient [%(limit_value)d], le paramètre que [Django] y place. Le widget [PasswordInput] n'est jamais réaffiché ; l'attribut HTML [maxlength] empêche de taper plus de 72 caractères ; [autocomplete="new-password"] demande au navigateur de proposer un nouveau mot de passe, plutôt que de remplir le champ avec le mot de passe enregistré ;
- lignes 78-90 : la classe mère de nos formulaires (exemple 18). Son constructeur donne à chaque zone de saisie la classe CSS de [Bootstrap] qui lui convient ([form-select] pour une liste, [form-control] pour les autres). La méthode [signaler] ajoute la classe [is-invalid] (un cadre rouge) aux champs qui ont une erreur de saisie, et à ceux qu'une erreur métier désigne (un login déjà pris) : [set(a) | set(b)] est l'union de deux ensembles. Elle rend le formulaire lui-même : les vues l'appellent au moment de le transmettre au gabarit ;
- lignes 93-102 : le titre, une liste ([ChoiceField], exemple 18) ; le prénom et le nom, 20 caractères au plus, la longueur des colonnes SQL (le champ [maxlength] de la zone de saisie est ajouté par [Django] lui-même). Chaque message d'erreur est remplacé par le nôtre ([error_messages], exemple 19). [invalid_choice] est le message d'une valeur qui ne fait pas partie de la liste (une requête fabriquée à la main). Les espaces qui entourent une saisie sont retirés par [CharField] ;
- lignes 106-109 : la saisie validée ([cleaned_data]), transformée en [DonneesPersonne] pour la couche métier : la couche métier ne connaît pas les formulaires. [d.get(clé)] rend [None] pour un champ que le formulaire n'a pas ; [x or None] transforme une chaîne vide en [None] (inchangé, ou la valeur par défaut) ;
- lignes 114-131 : le formulaire de l'administrateur. Le paramètre [creation], de notre invention, distingue la création de la modification ; il est placé après [*args] : c'est un paramètre nommé obligatoirement ([PersonneForm(request.POST, creation=False)]). Les champs propres à ce formulaire sont ajoutés par le constructeur, dans le dictionnaire [self.fields] : leurs options dépendent de [creation] (les aides diffèrent : « par défaut, le nom en minuscules », « laisser vide pour ne pas le changer »). Le login et le mot de passe sont facultatifs ([required=False]) : un validateur ne s'applique qu'à une valeur saisie, un champ vide est donc accepté. Seule la modification a le champ caché de la version (le verrou optimiste, couche métier) ;
- lignes 139-163 : le mot de passe et sa confirmation, communs à l'inscription et au compte du patient. Les deux champs sont comparés par la méthode [clean] du formulaire (exemple 19), appelée après la validation de chaque champ. [add_error] attache le message au champ de confirmation, sauf si ce champ a déjà une erreur (« Confirmez le mot de passe. ») : un seul message par champ. La longueur minimale n'est exigée que d'un mot de passe choisi par un patient (6 caractères) ;
- lignes 167-170 : la création d'un compte client : login, mot de passe et confirmation obligatoires ;
- lignes 175-186 : « Gérer mon compte » : le nouveau mot de passe est facultatif (vide : inchangé), le mot de passe actuel est obligatoire. Il n'est pas vérifié par le formulaire, mais par la vue : un mot de passe faux doit être compté par le limiteur de tentatives, comme un échec de connexion. [autocomplete="current-password"] : le navigateur peut, ici, proposer le mot de passe enregistré.
11.15.2. web/rendu.py
| # web/rendu.py : le moteur de rendu des formulaires (cf. exemple 18) : un champ
# complet (libellé, zone de saisie, aide, erreurs) est dessiné par le gabarit
# templates/formulaires/champ.html, avec les classes de Bootstrap 5
from django.forms.renderers import TemplatesSetting
class RenduBootstrap(TemplatesSetting):
field_template_name = "formulaires/champ.html"
|
Le moteur de rendu des formulaires de l'exemple 18, déclaré par le réglage [FORM_RENDERER] de [config/settings.py] : chaque champ complet (libellé, zone de saisie, aide, erreurs) est dessiné par le gabarit [templates/formulaires/champ.html] (plus loin), avec les classes de [Bootstrap].
11.16. Les modèles des vues
| # =========================================================================
# web/modeles_vues.py : les "modèles de vue" (view models) : des objets construits
# en une fois par une vue, que le gabarit se contente de lire. Règle suivie par
# toutes les pages : pas de logique dans les gabarits. Les vues préparent des
# données DÉJÀ mises en forme ("Mme Marie PELISSIER", "8:00 - 8:20",
# selectionne = True...) et déjà filtrées selon le rôle de l'utilisateur ; les
# gabarits se contentent de les placer dans le HTML.
# =========================================================================
from dataclasses import dataclass
# une option d'une liste déroulante (médecin, patient)
@dataclass(frozen=True)
class OptionVue:
id: int
libelle: str
selectionne: bool
# une ligne de l'agenda affiché (un créneau)
@dataclass(frozen=True)
class LigneAgenda:
id_creneau: int
horaire: str # "8:00 - 8:20"
libre: bool
# nom affiché pour un créneau occupé ; None = « Réservé »
patient: str | None
moi: bool # le rendez-vous du patient connecté
reservable: bool # afficher le bouton "Réserver" ?
url_reserver: str
# renseigné seulement si le bouton "Annuler" est affiché
id_rv: int | None
# l'agenda affiché : le médecin, le jour, les lignes
@dataclass(frozen=True)
class AgendaVue:
medecin: str # "Mme Marie PELISSIER"
jour: str # "mercredi 23 septembre 2026"
lignes: list[LigneAgenda]
# la fenêtre de réservation d'un créneau
@dataclass(frozen=True)
class ReservationVue:
id_creneau: int
horaire: str
# ADMIN / DOCTOR : les patients parmi lesquels choisir
clients: list[OptionVue]
# USER : le patient, c'est lui-même
patient: str | None
# un rendez-vous à venir, dans l'espace du patient
@dataclass(frozen=True)
class RendezVousVue:
id: int
jour: str # "mercredi 23 septembre 2026"
horaire: str # "8:00 - 8:20"
medecin: str # "Mme Marie PELISSIER"
url_agenda: str # l'agenda du médecin ce jour-là
|
Le fichier [web/modeles_vues.py] contient les modèles de vue : des objets construits par une vue, que le gabarit se contente de lire. La règle, suivie par toutes les pages : pas de logique dans les gabarits. Les vues préparent des données déjà mises en forme (« Mme Marie PELISSIER », « 8:00 - 8:20 ») et déjà filtrées selon le rôle de l'utilisateur ; les gabarits les placent dans le HTML. Ce sont des classes de données figées ([frozen=True], exemple 08) : une fois construit, un modèle de vue ne change plus.
- lignes 14-18 : une option d'une liste déroulante : l'id, le libellé, et l'option est-elle choisie ?
- lignes 22-33 : une ligne de l'agenda : un créneau, et ce que l'utilisateur connecté a le droit d'en voir et d'en faire. [patient] n'est renseigné que si l'utilisateur a le droit de connaître le nom du patient ; [reservable] dit s'il faut afficher le bouton Réserver ; [id_rv], le bouton Annuler ;
- lignes 37-52 : l'agenda affiché, et la fenêtre de réservation : pour l'administrateur et les médecins, la liste des patients parmi lesquels choisir ; pour un patient, son propre nom ;
- lignes 56-62 : un rendez-vous à venir, dans l'espace du patient.
11.17. La couche web : les routes et les vues
11.17.1. Les routes
| # =========================================================================
# web/urls.py : les routes de l'application. Leur protection n'est pas écrite ici,
# mais dans securite/acces.py (une règle par préfixe d'URL).
# =========================================================================
from django.urls import path, re_path
from .vues import agenda, auth, espace, inscription, langue
from .vues.personnes import clients, medecins
urlpatterns = [
# la connexion
path("", auth.accueil, name="accueil"),
path("login", auth.login, name="login"),
path("logout", auth.logout, name="logout"),
# la langue : fr ou en seulement
re_path(r"^langue/(?P<code>fr|en)$", langue.changer, name="langue"),
# l'agenda
path("agenda", agenda.afficher, name="agenda"),
path("agenda/reserver", agenda.reserver, name="agenda_reserver"),
path("agenda/annuler", agenda.annuler, name="agenda_annuler"),
# la création d'un compte client
path("inscription/acces", inscription.acces, name="inscription_acces"),
path("inscription", inscription.inscription, name="inscription"),
# l'espace du patient
path("mon-espace", espace.espace, name="espace"),
path("mon-espace/annuler", espace.annuler_rv, name="espace_annuler"),
path("mon-compte", espace.compte, name="compte"),
path("mon-compte/supprimer", espace.supprimer_compte, name="compte_supprimer"),
# la gestion des médecins et des clients : les routes fabriquées par les deux
# objets GestionPersonnes ; * "déballe" la liste qu'ils rendent
*medecins.urls(),
*clients.urls(),
]
|
Les routes de l'application, montées à la racine du site par [config/urls.py]. Les routes de la gestion des médecins et des clients sont fabriquées par deux objets (plus loin) ; [*liste], dans une liste, y place les éléments d'une autre liste (lignes 32-33). La commande [show_urls] n'existe pas dans [Django] (elle vient d'une extension) ; un court script, lancé par [python manage.py shell], les liste toutes, avec leur nom :
| $ python manage.py shell -v 0 -c "from django.urls import get_resolver; [print(f'{str(p.pattern):32} {p.name}') for p in get_resolver().url_patterns[0].url_patterns]"
accueil
login login
logout logout
^langue/(?P<code>fr|en)$ langue
agenda agenda
agenda/reserver agenda_reserver
agenda/annuler agenda_annuler
inscription/acces inscription_acces
inscription inscription
mon-espace espace
mon-espace/annuler espace_annuler
mon-compte compte
mon-compte/supprimer compte_supprimer
medecins medecins_liste
medecins/ajouter medecins_ajout
medecins/<int:id>/modifier medecins_modification
medecins/<int:id>/supprimer medecins_supprimer
clients clients_liste
clients/ajouter clients_ajout
clients/<int:id>/modifier clients_modification
clients/<int:id>/supprimer clients_supprimer
|
Les URL sont celles des autres versions de l'application : elles n'ont pas de barre oblique finale ([/agenda], et non [/agenda/]), et la modification et la suppression d'un médecin sont des requêtes POST ([/medecins/{id}/modifier], [/medecins/{id}/supprimer]), et non PUT et DELETE comme à l'exemple 28. Le code fabrique toujours les URL par le nom des routes ([redirect("login")], [reverse("agenda")], [{% url 'agenda' %}] dans les gabarits, exemple 04) : changer une URL ne demande de modifier que sa route.
Les méthodes acceptées par chaque route sont déclarées par les décorateurs [require_GET], [require_POST] et [require_http_methods] des vues (exemple 02). Les règles d'accès ne sont pas dans les vues, mais dans le fichier [securite/acces.py], vu plus haut : une règle par préfixe d'URL ([^/(medecins|clients)] pour l'administrateur, [^/mon-] pour les patients...), et une dernière règle qui exige un utilisateur authentifié pour toutes les autres URL. Les URL de l'application ont été choisies pour cela : toutes les pages d'un même rôle commencent de la même façon.
11.17.2. web/vues/erreurs.py
| # =========================================================================
# web/vues/erreurs.py : la page d'erreur (400, 403, 404, 405, 419, 500), et les vues
# que Django appelle pour les erreurs qu'il détecte lui-même (cf. config/urls.py :
# handler400, handler403, handler404, handler500, et le réglage CSRF_FAILURE_VIEW).
# =========================================================================
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
# le message de chaque code
MESSAGES = {
400: _("La requête est invalide."),
403: _("Votre rôle ne vous permet pas d'accéder à cette page."),
404: _("Cette page n'existe pas."),
405: _("Cette page n'existe pas."),
419: _("La page a expiré : rechargez-la, puis recommencez."),
500: _("Une erreur inattendue s'est produite. Réessayez plus tard."),
}
def page_erreur(request, code: int):
return render(request, "erreur.html", {
"titre_page": _("Erreur"), "code": code, "message": MESSAGES.get(code, MESSAGES[500]),
}, status=code)
# les vues des erreurs détectées par Django (exception : l'exception qui a provoqué
# l'erreur ; la vue 500 ne la reçoit pas)
def erreur_400(request, exception):
return page_erreur(request, 400)
def erreur_403(request, exception):
return page_erreur(request, 403)
def erreur_404(request, exception):
return page_erreur(request, 404)
def erreur_500(request):
return page_erreur(request, 500)
# -------------------------------------------------------------------------
# un jeton anti-CSRF absent ou faux : la page a sans doute été affichée depuis
# longtemps, dans un autre onglet, avant un changement du cookie du jeton... Erreur
# 419, le code (non officiel, mais répandu) d'une page expirée. reason : la raison
# du refus, donnée par CsrfViewMiddleware (en anglais)
# -------------------------------------------------------------------------
def csrf_refuse(request, reason=""):
return page_erreur(request, 419)
|
La page d'erreur, et les vues des erreurs que [Django] détecte lui-même :
- lignes 11-18 : le message de chaque code. Le code 405 est présenté comme une page introuvable ;
- lignes 21-24 : la page d'erreur, le gabarit [erreur.html], avec le code HTTP de la réponse ([status=code]). Un code inconnu reçoit le message de l'erreur 500 ([dict.get(clé, défaut)]) ;
- lignes 29-42 : les quatre vues [handler400]... [handler500], désignées par [config/urls.py] (exemple 24). Leur signature est imposée par [Django] : la requête, et l'exception qui a provoqué l'erreur (sauf pour l'erreur 500) ;
- lignes 51-52 : un jeton anti-CSRF absent ou faux. Le middleware [CsrfViewMiddleware] refuse la requête avant d'appeler la vue (exemple 21), et appelle à la place la vue du réglage [CSRF_FAILURE_VIEW] (cf. [config/settings.py]) : la page d'erreur 419, le code (non officiel, mais répandu) d'une page expirée. C'est le cas d'une page restée longtemps affichée dans un onglet, alors que le cookie du jeton a changé depuis. La page propose alors : « La page a expiré : rechargez-la, puis recommencez. ». Son paramètre [reason] est la raison du refus, en anglais, destinée au journal plutôt qu'à l'utilisateur.
11.17.3. web/vues/langue.py
| # =========================================================================
# web/vues/langue.py : GET /langue/fr, GET /langue/en : les deux boutons FR / EN de
# la barre de navigation. Route publique : on doit pouvoir changer de langue avant
# de se connecter.
#
# Le choix est mémorisé dans un cookie : à chaque requête suivante, le middleware
# FixerLangue le relit et fixe la langue de la requête. Puis on revient sur la page
# d'où l'on vient (cf. exemple 22).
# =========================================================================
from urllib.parse import urlsplit
from django.conf import settings
from django.shortcuts import redirect
from django.views.decorators.http import require_GET
# code : fr ou en (cf. web/urls.py : /langue/de donne une page 404)
@require_GET
def changer(request, code):
reponse = redirect(page_d_origine(request))
reponse.set_cookie(settings.LANGUAGE_COOKIE_NAME, code, max_age=365 * 24 * 3600,
secure=settings.COOKIE_SECURE, httponly=True, samesite="Lax")
return reponse
# -------------------------------------------------------------------------
# la page précédente, d'après l'entête HTTP "Referer" envoyé par le navigateur. On
# n'en garde que le chemin et les paramètres (/agenda?...) : rediriger vers une
# adresse complète fournie par la requête permettrait d'envoyer l'utilisateur sur
# un autre site (faille dite "open redirect"). urlsplit découpe une URL en ses
# composants : scheme (http), netloc (la machine), path, query...
# -------------------------------------------------------------------------
def page_d_origine(request) -> str:
referer = urlsplit(request.headers.get("Referer", ""))
chemin = referer.path
# "//site.com/..." serait compris par le navigateur comme une adresse sur un
# AUTRE site : refusé aussi
if not referer.scheme or not chemin.startswith("/") or chemin.startswith("//") \
or chemin.startswith("/langue/"):
return "/"
return f"{chemin}?{referer.query}" if referer.query else chemin
|
Les boutons FR / EN de la barre de navigation : le choix de la langue est mémorisé dans un cookie pour un an (exemple 22), puis le navigateur revient à la page d'où il vient. Le cookie est [HttpOnly] : le [JavaScript] de la page n'a pas à le lire. Il n'est pas signé : le middleware [FixerLangue] vérifie sa valeur, et une valeur inconnue donne le français (chapitre sur les portées).
- ligne 20 : [code] vaut [fr] ou [en] : la route (une expression régulière, [re_path], exemple 02) n'accepte que ces deux valeurs. [/langue/de] ne correspond à aucune route (page 404) ;
- lignes 34-42 : la page précédente est donnée par l'entête HTTP [Referer] que le navigateur envoie avec la requête. C'est une donnée fournie par le client, donc suspecte : rediriger vers une adresse complète venue de la requête permettrait à un pirate d'envoyer l'utilisateur sur son propre site, par un lien vers [/langue/fr] muni d'un faux [Referer] (la faille dite open redirect). On n'en garde donc que le chemin et les paramètres ([/agenda?idMedecin=1&...]), extraits par la fonction [urlsplit] de la bibliothèque standard, qui découpe une URL en ses parties ([scheme], [netloc], [path], [query]...). Un chemin qui commence par [//] est refusé aussi : un navigateur comprend [//site.com/page] comme une adresse sur un autre site. Enfin, on ne revient pas vers [/langue/...] : une boucle. Une barre oblique inversée ([\]) à la fin d'une ligne ([Python]) prolonge l'instruction sur la ligne suivante.
| $ curl -si http://localhost:8000/langue/en -H "Referer: http://localhost:8000/agenda?idMedecin=1&jour=2026-09-30"
HTTP/1.1 302 Found
Location: /agenda?idMedecin=1&jour=2026-09-30
Set-Cookie: rdvmedecins_langue=en; expires=Mon, 27 Sep 2027 17:56:57 GMT; HttpOnly; Max-Age=31536000; Path=/; SameSite=Lax
...
$ curl -si http://localhost:8000/langue/en -H "Referer: https://pirate.com//page"
HTTP/1.1 302 Found
Location: /
...
|
11.17.4. web/vues/auth.py
| # =========================================================================
# web/vues/auth.py : les routes PUBLIQUES de la connexion :
# GET / redirige vers la page d'accueil du rôle (ou vers /login)
# GET /login affiche le formulaire de connexion (avec son captcha)
# POST /login vérifie limite de tentatives, captcha, login / mot de passe
# POST /logout la déconnexion
#
# Protection contre les robots, dans cet ordre :
# 1. trop d'échecs récents pour ce login depuis cette adresse IP ?
# -> refus immédiat, sans même regarder le mot de passe ;
# 2. mauvaise réponse au captcha ? -> refus (compté comme un échec) ;
# 3. seulement alors, vérification du mot de passe.
# On ne vérifie JAMAIS le mot de passe d'une tentative refusée aux étapes 1 ou 2 :
# un robot ne peut donc rien apprendre en insistant.
# =========================================================================
from django.shortcuts import redirect, render
from django.utils.translation import gettext as _, ngettext
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from modeles.models import Role
from securite import auth, captchas, limiteur
from .. import outils
# la page d'accueil de chaque rôle, juste après la connexion : le nom de sa route
def page_accueil(role: str) -> str:
return "espace" if role == Role.USER else "agenda"
# GET / : la route est publique ; l'utilisateur, s'il y en a un, est connu
@require_GET
def accueil(request):
if request.utilisateur is not None:
return redirect(page_accueil(request.utilisateur.role))
return redirect("login")
# GET /login[?login=xxx] : le formulaire ; le paramètre facultatif pré-remplit le
# champ login (utilisé juste après la création d'un compte, cf. vues/inscription.py).
# POST /login : la connexion
@require_http_methods(["GET", "POST"])
def login(request):
if request.method == "POST":
return connexion(request)
# déjà connecté (jeton encore valide) : directement à la page d'accueil
if request.utilisateur is not None:
return redirect(page_accueil(request.utilisateur.role))
# [:30] : 30 caractères au plus
return page(request, request.GET.get("login", "")[:30], None)
def connexion(request):
login = request.POST.get("login", "")
password = request.POST.get("password", "")
ip = outils.ip(request)
# 1. trop d'échecs récents ? ngettext : le singulier ou le pluriel, selon le nombre
minutes = limiteur.minutes_blocage_login(ip, login)
if minutes > 0:
return page(request, login, ngettext(
"Trop de tentatives infructueuses. Réessayez dans %(minutes)d minute.",
"Trop de tentatives infructueuses. Réessayez dans %(minutes)d minutes.",
minutes) % {"minutes": minutes})
# 2. le captcha
if not captchas.verifier(request.POST.get("captcha_id", ""), request.POST.get("captcha", "")):
limiteur.echec_login(ip, login)
return page(request, login, _("Les caractères recopiés ne correspondent pas à l'image. "
"Une nouvelle image vous est proposée."))
# 3. le mot de passe
user = auth.valider(login, password) if login and password else None
if user is None:
limiteur.echec_login(ip, login)
return page(request, login, _("Login ou mot de passe incorrect."))
limiteur.reussite_login(ip, login)
return auth.poser_jeton(redirect(page_accueil(user.role)), user)
# POST /logout : effacer le cookie du jeton, puis aller à la page de connexion.
# POST seulement : un simple lien (GET), placé sur un autre site, ne peut pas
# déconnecter l'utilisateur à son insu
@require_POST
def logout(request):
return auth.effacer_jeton(redirect("login"))
# le formulaire, toujours avec un NOUVEAU captcha (chaque image ne sert qu'une
# fois) ; on réaffiche le login saisi, jamais le mot de passe. Une erreur : le
# code 422, comme pour un formulaire invalide (cf. exemple 19)
def page(request, login: str, erreur: str | None):
return render(request, "login.html", {
"titre_page": _("Connexion"), "erreur": erreur, "login": login,
"captcha": captchas.generer(),
}, status=422 if erreur else 200)
|
La connexion, qui reprend l'exemple 27 :
- lignes 28-29 : la page d'accueil de chaque rôle, le nom de sa route : « Mon espace » pour un patient, l'agenda pour les autres ;
- lignes 33-37 : l'adresse [/] renvoie vers la page d'accueil du rôle, ou vers la page de connexion. La route est publique (securite/acces.py) : [request.utilisateur] est l'utilisateur établi par le middleware [Authentification], s'il y en a un ;
- lignes 43-51 : un utilisateur déjà connecté va directement à sa page d'accueil. Le paramètre [login] de l'URL, facultatif, pré-remplit le champ (après la création d'un compte) ; [[:30]] le coupe à 30 caractères ;
- lignes 54-76 : la connexion, dans l'ordre de l'exemple 27 : le blocage éventuel, le captcha, le mot de passe (le jeton anti-CSRF a déjà été vérifié par le middleware). Un mot de passe n'est jamais vérifié pour une tentative refusée par le limiteur ou le captcha : un robot n'apprend rien en insistant. [ngettext(singulier, pluriel, nombre)] (lignes 61-64) choisit la forme qui convient au nombre, dans la langue de la requête (exemple 22) ; le paramètre [%(minutes)d] est placé après la traduction. En cas d'échec, la page est rendue directement (pas de redirection) : le formulaire réaffiché a toujours un nouveau captcha, et le login saisi. En cas de succès, le compteur du login est remis à zéro, et le navigateur est redirigé vers la page d'accueil du rôle, avec le cookie du jeton ;
- lignes 82-84 : la déconnexion efface le cookie du jeton (exemple 25). Elle n'accepte que POST, avec le jeton anti-CSRF : un simple lien placé sur un autre site ne peut pas déconnecter l'utilisateur à son insu ;
- lignes 90-94 : la page de connexion. Une erreur est rendue avec le code 422 (Unprocessable Content), comme un formulaire invalide (exemple 19).
La page de connexion reçoit la variable [titre_page], le titre de la page, déjà traduit : toutes les vues la fournissent à leur gabarit, pour la balise [<title>] et le titre de la page (plus loin).
11.17.5. web/vues/agenda.py
| # =========================================================================
# web/vues/agenda.py : la page de l'agenda : choix d'un médecin et d'un jour,
# agenda de ce médecin ce jour-là, réservation et annulation de rendez-vous.
#
# GET /agenda?idMedecin=1&jour=2026-09-23 la page
# GET /agenda?idMedecin=1&jour=2026-09-23&reserver=7 la même page, avec la
# fenêtre de réservation
# du créneau 7 ouverte
# POST /agenda/reserver crée un rendez-vous
# POST /agenda/annuler annule un rendez-vous
#
# Toute la page est décrite par son URL : on peut la recharger, la mettre en
# favori, utiliser le bouton "Précédent" du navigateur - sans une ligne de
# JavaScript.
#
# Ce que chacun voit et peut faire :
#
# | rôle | créneau occupé affiché | Réserver | Annuler |
# |---------------|-----------------------------------|--------------------|---------|
# | ADMIN, DOCTOR | nom du patient | pour un patient | oui |
# | | | choisi dans liste | |
# | USER (patient)| « Réservé », ou SON nom si c'est | pour lui-même, s'il| non (*) |
# | | son propre rendez-vous | n'a pas déjà un rdv| |
# | | | à venir | |
#
# (*) le patient annule son rendez-vous depuis sa page "Mon espace".
#
# CONFIDENTIALITÉ : pour un patient, le nom des AUTRES patients n'est pas
# simplement masqué par le gabarit - il n'est jamais transmis au gabarit. Le HTML
# envoyé au navigateur ne le contient donc pas, même caché (un simple "Afficher le
# code source" le révélerait sinon).
#
# Pas de réservation dans le passé : le bouton "Réserver" n'apparaît que pour
# aujourd'hui et les jours suivants (et la couche métier le refuserait de toute
# façon).
#
# Sécurité : toutes les URL /agenda exigent un utilisateur authentifié, et
# /agenda/annuler le rôle ADMIN ou DOCTOR (cf. securite/acces.py).
# =========================================================================
from dataclasses import dataclass
from datetime import date
from urllib.parse import urlencode
from django.contrib import messages
from django.core.exceptions import BadRequest
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.decorators.http import require_GET, require_POST
from domaine.agenda import AgendaMedecinJour
from metier import erreurs
from metier.dates import aujourdhui
from metier.erreurs import MetierErreur
from modeles.models import Client, Personne, Role
from .. import outils
from ..application import application
from ..modeles_vues import AgendaVue, LigneAgenda, OptionVue, ReservationVue
# qui est connecté, et qu'a-t-il le droit de faire sur cette page ?
@dataclass(frozen=True)
class Droits:
gestionnaire: bool # ADMIN ou DOCTOR
patient: Client | None # le client connecté (rôle USER), sinon None
patient_peut_reserver: bool # un USER sans rendez-vous à venir
# -------------------------------------------------------------------------
# GET /agenda
# Les paramètres de l'URL sont lus bruts (request.GET), pour distinguer un
# paramètre ABSENT (None) d'un paramètre VIDE (?idMedecin=).
# -------------------------------------------------------------------------
@require_GET
def afficher(request):
erreur = None
utilisateur = outils.utilisateur(request)
droits = calculer_droits(request)
app = application()
# le jour : celui de l'URL s'il est valide, sinon aujourd'hui
jour = aujourdhui()
jour_brut = request.GET.get("jour")
if jour_brut is not None:
jour_demande = outils.date_valide(jour_brut)
if jour_demande is not None:
jour = jour_demande
else:
erreur = _("La date demandée n'est pas valide.")
jour_reservable = jour >= aujourdhui()
# le médecin : celui de l'URL ; à défaut, pour un médecin connecté, lui-même
id_medecin_brut = request.GET.get("idMedecin")
id_medecin = outils.entier_positif(id_medecin_brut)
if id_medecin_brut is None and utilisateur.role == Role.DOCTOR:
medecin = app.get_medecin_by_user_id(utilisateur.user_id)
id_medecin = medecin.id if medecin else None
# la liste déroulante des médecins
medecins = [option(m, m.id == id_medecin) for m in app.get_all_medecins()]
# l'agenda, si un médecin est choisi (et la date valide)
agenda = None
if id_medecin is not None and erreur is None:
agenda_medecin = app.get_agenda_medecin_jour(id_medecin, jour)
if agenda_medecin is None:
erreur = _("Ce médecin n'existe pas (ou plus).")
else:
agenda = agenda_vue(agenda_medecin, droits, jour_reservable)
# la fenêtre de réservation : seulement si le créneau demandé est réservable par
# cet utilisateur
reservation = None
id_creneau = outils.entier_positif(request.GET.get("reserver"))
if agenda is not None and id_creneau is not None:
ligne = next((l for l in agenda.lignes if l.id_creneau == id_creneau and l.reservable), None)
if ligne is not None:
reservation = ReservationVue(
ligne.id_creneau,
ligne.horaire,
[option(c, False) for c in app.get_all_clients()] if droits.gestionnaire else [],
droits.patient.libelle() if droits.patient else None)
return render(request, "agenda.html", {
"titre_page": _("Agenda"),
"erreur": erreur,
"medecins": medecins,
"id_medecin": id_medecin,
"jour": outils.iso(jour),
"agenda": agenda,
"reservation": reservation,
# l'agenda affiché, sans fenêtre de réservation
"url_retour": url_agenda(id_medecin, jour) if id_medecin is not None else reverse("agenda"),
"info": info_patient(droits, agenda),
})
# -------------------------------------------------------------------------
# POST /agenda/reserver champs : idMedecin, jour, idCreneau, idClient
#
# Pour un patient (USER), le champ idClient est IGNORÉ : il réserve toujours pour
# lui-même. Faire confiance à un champ envoyé par le navigateur permettrait à un
# patient malin de réserver au nom d'un autre.
#
# Schéma "Post / Redirect / Get" : quel que soit le résultat, on ne fabrique pas de
# page ici - on dépose un message flash et on redirige vers l'agenda (cf. exemple 20).
# -------------------------------------------------------------------------
@require_POST
def reserver(request):
id_medecin, jour = agenda_demande(request)
id_creneau = outils.entier_positif(request.POST.get("idCreneau"))
if id_medecin is None or id_creneau is None:
# -> page "requête invalide" (cf. web/middleware.py)
raise BadRequest("agenda ou créneau invalide")
app = application()
utilisateur = outils.utilisateur(request)
if utilisateur.est_patient:
client = outils.patient_connecte(request)
else:
id_client = outils.entier_positif(request.POST.get("idClient"))
if id_client is None:
# aucun patient choisi : on rouvre la fenêtre de réservation
messages.error(request, _("Choisissez un patient."))
return redirect(url_agenda(id_medecin, jour, id_creneau))
client = app.get_client_by_id(id_client)
creneau = app.get_creneau_by_id(id_creneau)
if client is None:
messages.error(request, _("Ce client n'existe pas (ou plus)."))
elif creneau is None or creneau.medecin_id != id_medecin:
messages.error(request, _("Ce créneau n'existe pas (ou plus)."))
else:
try:
# les règles (pas dans le passé, un seul rendez-vous à venir par
# patient, créneau libre) sont vérifiées par la couche métier
app.ajouter_rv(jour, creneau, client)
messages.success(request, _("Rendez-vous enregistré."))
except MetierErreur as e:
# même règle, mais un message à la 2e personne quand c'est le patient
# lui-même qui réserve (« Vous avez déjà... » plutôt que « Ce patient... »).
# Les autres exceptions (vraies anomalies) ne sont pas attrapées : page 500
if e.code == erreurs.PATIENT_A_UN_RV and utilisateur.est_patient:
messages.error(request, _("Vous avez déjà un rendez-vous à venir : "
"annulez-le pour en prendre un autre."))
else:
messages.error(request, e.message)
return redirect(url_agenda(id_medecin, jour))
# -------------------------------------------------------------------------
# POST /agenda/annuler champs : idRv, idMedecin, jour
# (un patient annule SON rendez-vous depuis "Mon espace", pas d'ici)
# -------------------------------------------------------------------------
@require_POST
def annuler(request):
id_medecin, jour = agenda_demande(request)
id_rv = outils.entier_positif(request.POST.get("idRv"))
if id_medecin is None or id_rv is None:
raise BadRequest("agenda ou rendez-vous invalide")
app = application()
rv = app.get_rv_by_id(id_rv)
if rv is None:
# déjà annulé (par quelqu'un d'autre, ou double clic)
messages.error(request, _("Ce rendez-vous n'existe pas (ou plus)."))
else:
app.supprimer_rv(rv)
messages.success(request, _("Rendez-vous annulé."))
return redirect(url_agenda(id_medecin, jour))
# -------------------------------------------------------------------------
# qui est connecté, et qu'a-t-il le droit de faire sur cette page ?
# -------------------------------------------------------------------------
def calculer_droits(request) -> Droits:
if not outils.utilisateur(request).est_patient:
return Droits(gestionnaire=True, patient=None, patient_peut_reserver=False)
patient = outils.patient_connecte(request)
rv_a_venir = application().get_rv_a_venir_client(patient.id)
# not liste : vrai pour une liste vide
return Droits(gestionnaire=False, patient=patient, patient_peut_reserver=not rv_a_venir)
# -------------------------------------------------------------------------
# l'agenda du médecin, mis en forme pour le gabarit - et filtré selon les droits
# -------------------------------------------------------------------------
def agenda_vue(agenda_medecin: AgendaMedecinJour, droits: Droits, jour_reservable: bool) -> AgendaVue:
id_medecin = agenda_medecin.medecin.id
jour = agenda_medecin.jour
lignes = []
for cmj in agenda_medecin.creneaux_medecin_jour:
creneau = cmj.creneau
rv = cmj.rv
libre = rv is None
# le rendez-vous du patient connecté ?
moi = rv is not None and droits.patient is not None and rv.client_id == droits.patient.id
lignes.append(LigneAgenda(
id_creneau=creneau.id,
horaire=creneau.horaire(),
libre=libre,
# LE point de confidentialité : un patient ne reçoit le nom que de SON
# rendez-vous ; pour les autres, None (le gabarit affiche « Réservé »)
patient=rv.client.libelle() if rv is not None and (droits.gestionnaire or moi) else None,
moi=moi,
reservable=libre and jour_reservable and (droits.gestionnaire or droits.patient_peut_reserver),
url_reserver=url_agenda(id_medecin, jour, creneau.id),
id_rv=rv.id if rv is not None and droits.gestionnaire else None))
return AgendaVue(agenda_medecin.medecin.libelle(), outils.formater_jour(jour), lignes)
# ----------------------------------------------------------------- fonctions utilitaires
# les champs cachés idMedecin / jour des formulaires POST ; (None, None) s'ils sont
# invalides
def agenda_demande(request) -> tuple[int | None, date | None]:
id_medecin = outils.entier_positif(request.POST.get("idMedecin"))
jour = outils.date_valide(request.POST.get("jour"))
return (None, None) if id_medecin is None or jour is None else (id_medecin, jour)
# /agenda?idMedecin=1&jour=2026-09-23[&reserver=7] : reverse fabrique le chemin de la
# route, urlencode les paramètres de l'URL, correctement codés (cf. exemple 04)
def url_agenda(id_medecin: int, jour: date, reserver: int | None = None) -> str:
parametres = {"idMedecin": id_medecin, "jour": outils.iso(jour)}
if reserver is not None:
parametres["reserver"] = reserver
return f"{reverse('agenda')}?{urlencode(parametres)}"
def option(p: Personne, selectionne: bool) -> OptionVue:
return OptionVue(p.id, p.libelle(), selectionne)
# -------------------------------------------------------------------------
# le message d'information d'un patient qui a déjà un rendez-vous à venir (pourquoi
# il n'y a pas de bouton "Réserver"), ou None. Inutile si SON rendez-vous figure
# dans l'agenda affiché (en bleu) : c'est le cas juste après qu'il vient de le
# prendre, où le message serait déplacé
# -------------------------------------------------------------------------
def info_patient(droits: Droits, agenda: AgendaVue | None) -> str | None:
if droits.patient is None or droits.patient_peut_reserver:
return None
rv_affiche = agenda is not None and any(l.moi for l in agenda.lignes)
return None if rv_affiche else _("Vous avez déjà un rendez-vous à venir : "
"annulez-le pour en prendre un autre.")
|
La page principale de l'application. Le commentaire de tête résume ce que chacun voit et peut faire.
11.17.5.1. Les URL de la page
Toute la page est décrite par son URL : [/agenda?idMedecin=1&jour=2026-09-30] affiche l'agenda du médecin 1 ce jour-là ; [&reserver=4] ouvre en plus la fenêtre de réservation du créneau 4. On peut recharger la page, la mettre en favori, utiliser le bouton Précédent du navigateur, sans une ligne de [JavaScript]. Le formulaire du haut de la page (le choix du médecin et du jour) est un formulaire GET (exemple 17) : il recharge la page avec de nouveaux paramètres. La fenêtre de réservation est ouverte par un simple lien, et fermée par un lien vers la même page sans le paramètre [reserver].
11.17.5.2. GET /agenda
Lignes 76-131 :
- les paramètres de l'URL sont lus bruts ([request.GET.get("jour")]), sans formulaire : un paramètre absent ([None]) n'est pas une erreur (on prend aujourd'hui, ou aucun médecin) ; une date invalide en est une, affichée en bas de la page (le choix du médecin et du jour reste affiché, mais pas l'agenda) ; un numéro de médecin ou de créneau qui n'est pas un entier positif est simplement ignoré ;
- un médecin connecté qui n'a pas choisi de médecin voit son agenda : son médecin est cherché par son compte ([get_medecin_by_user_id]) ;
- la liste déroulante des médecins : une liste en compréhension transforme chaque médecin en [OptionVue] ;
- l'agenda, mis en forme et filtré selon les droits de l'utilisateur ([agenda_vue], plus bas) ;
- la fenêtre de réservation, seulement si le créneau demandé est réservable par cet utilisateur : un patient qui fabriquerait lui-même l'URL [&reserver=2] d'un créneau occupé n'obtiendrait rien. [next(...)] rend la première ligne de l'agenda qui convient, ou [None]. La liste des patients n'est fournie qu'à l'administrateur et aux médecins ; un patient ne reçoit que son propre nom ;
- le gabarit reçoit ses variables ([render], exemple 13), dont [titre_page] et [erreur], lues par le gabarit de base.
11.17.5.3. POST /agenda/reserver
Lignes 144-182 :
- les champs cachés du formulaire de réservation (le médecin, le jour, le créneau). Invalides, ce ne peut être qu'une requête fabriquée à la main : [BadRequest], la page « requête invalide » (400), par le middleware [PageErreur] ;
- pour un patient, le champ [idClient] est ignoré : il réserve toujours pour lui-même ([outils.patient_connecte]). Faire confiance à un champ envoyé par le navigateur permettrait à un patient malin de réserver au nom d'un autre. Pour l'administrateur et les médecins, un patient doit avoir été choisi ; sinon, la fenêtre de réservation est rouverte, avec un message ;
- le créneau doit appartenir au médecin de la page ([creneau.medecin_id] : la clé étrangère, sans requête) ;
- les règles de gestion (pas dans le passé, un seul rendez-vous à venir, créneau libre) sont vérifiées par la couche métier. Une [MetierErreur] devient un message flash d'erreur. Le même refus, « un rendez-vous à venir », est formulé à la deuxième personne pour le patient lui-même (« Vous avez déjà... »). Toute autre exception n'est pas interceptée : c'est une vraie anomalie (page 500) ;
- dans tous les cas, schéma Post / Redirect / Get (exemple 20) : un message flash, et une redirection vers l'agenda.
11.17.5.4. POST /agenda/annuler
Lignes 189-203 : l'annulation d'un rendez-vous, par l'administrateur ou un médecin (la règle [^/agenda/annuler$] de [securite/acces.py]). Un rendez-vous qui n'existe plus (déjà annulé par quelqu'un d'autre, ou un double clic) n'est pas une anomalie : un simple message.
- lignes 64-68 et 209-215 : ce que l'utilisateur a le droit de faire sur la page, dans une classe de données : est-il un gestionnaire (ADMIN ou DOCTOR) ? un patient (et lequel) ? ce patient peut-il réserver (pas de rendez-vous à venir) ? [not liste] est vrai pour une liste vide ;
- lignes 221-242 : l'agenda du médecin, transformé en lignes pour le gabarit. Le point de confidentialité est la ligne 237 : un patient ne reçoit le nom que de son rendez-vous. Pour les autres, l'attribut [patient] vaut [None], et le gabarit affiche « Réservé ». Le nom n'est pas masqué par le gabarit : il n'est jamais transmis au gabarit, et ne figure donc pas dans le code source de la page, où il suffirait d'aller le lire. Le bouton Réserver est affiché pour un créneau libre, un jour qui n'est pas passé, à un gestionnaire ou à un patient qui peut réserver ; le bouton Annuler, aux gestionnaires seulement. Les paramètres nommés ([id_creneau=creneau.id]) rendent lisible la construction d'un objet qui a huit attributs ;
- lignes 248-251 : les champs cachés des formulaires POST. La fonction rend un tuple, que la vue déstructure en deux variables ([id_medecin, jour = agenda_demande(request)], lignes 146 et 191) ;
- lignes 256-260 : l'URL de l'agenda d'un médecin un jour donné. [reverse("agenda")] rend le chemin de la route ; [urlencode] fabrique les paramètres, correctement codés : [/agenda?idMedecin=1&jour=2026-09-30] ;
- lignes 273-278 : le message qui explique à un patient pourquoi il n'a pas de bouton Réserver (il a déjà un rendez-vous à venir), sauf si ce rendez-vous est dans l'agenda affiché (juste après l'avoir pris, le message serait déplacé). [any(...)] : une ligne au moins vérifie-t-elle la condition ?
11.17.6. web/vues/personnes.py
| # =========================================================================
# web/vues/personnes.py : la gestion des médecins et celle des clients (réservées à
# l'ADMIN) sont IDENTIQUES : même liste, même formulaire, mêmes règles. Plutôt que
# d'écrire deux fois les mêmes vues, on les écrit une fois, comme les MÉTHODES
# d'une classe, GestionPersonnes ; deux OBJETS de cette classe, medecins et
# clients, ne diffèrent que par ce qui les distingue : le nom de la section
# (medecins ou clients), les textes, et les méthodes de l'ApplicationModel à appeler.
#
# Une méthode d'un objet est appelée avec l'objet (self) : la vue objet.liste(request)
# sait ainsi de quelle section il s'agit. La méthode urls() fabrique les routes de
# la section :
#
# GET /medecins liste medecins_liste
# GET /medecins/ajouter formulaire vide medecins_ajout
# POST /medecins/ajouter création medecins_ajout
# GET /medecins/{id}/modifier formulaire pré-rempli medecins_modification
# POST /medecins/{id}/modifier modification medecins_modification
# POST /medecins/{id}/supprimer suppression medecins_supprimer
#
# (et les mêmes URL sous /clients). Les gabarits sont eux aussi communs :
# templates/personnes/liste.html et personnes/formulaire.html.
#
# SÉCURITÉ : la règle ^/(medecins|clients) de securite/acces.py réserve toutes ces
# URL à l'ADMIN - y compris celles qu'on ajouterait plus tard.
# =========================================================================
from collections.abc import Callable
from dataclasses import dataclass
from django.contrib import messages
from django.http import Http404
from django.shortcuts import redirect, render
from django.urls import path, reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from metier import erreurs
from metier.erreurs import MetierErreur
from modeles.models import Personne
from ..application import application
from ..formulaires import PersonneForm
# les champs du formulaire concernés par chaque erreur métier "de saisie" (un
# doublon) : ils sont encadrés en rouge, le message étant en bas de page
CHAMPS_ERREURS_METIER = {
erreurs.LOGIN_PRIS: ["login"],
erreurs.MEDECIN_EXISTE_DEJA: ["prenom", "nom"],
erreurs.CLIENT_EXISTE_DEJA: ["prenom", "nom"],
}
# les textes propres à chaque type de personne ; %(nom)s, %(login)s : les
# paramètres des messages
@dataclass(frozen=True)
class Textes:
titre: str # "Médecins"
ajouter: str # "Ajouter un médecin"
modifier: str # "Modifier un médecin"
vide: str # "Aucun médecin."
confirmer_suppression: str # "Supprimer ce médecin... ?"
ajoute: str # "Le médecin %(nom)s a été ajouté (login : %(login)s)."
modifie: str
supprime: str
class GestionPersonnes:
def __init__(self, section: str, textes: Textes, lister: Callable, trouver: Callable,
creer: Callable, mettre_a_jour: Callable, effacer: Callable):
# "medecins" ou "clients" : le début du chemin et du nom des routes
self.section = section
self.textes = textes
# les méthodes de l'ApplicationModel à appeler (des fonctions, cf. plus bas)
self.lister = lister
self.trouver = trouver
self.creer = creer
self.mettre_a_jour = mettre_a_jour
self.effacer = effacer
# les routes de la section ; self.liste : la méthode liée à CET objet
def urls(self):
s = self.section
return [
path(f"{s}", require_GET(self.liste), name=f"{s}_liste"),
path(f"{s}/ajouter", require_http_methods(["GET", "POST"])(self.ajout), name=f"{s}_ajout"),
path(f"{s}/<int:id>/modifier", require_http_methods(["GET", "POST"])(self.modification),
name=f"{s}_modification"),
path(f"{s}/<int:id>/supprimer", require_POST(self.supprimer), name=f"{s}_supprimer"),
]
# -------------------------------------------------------------------
# GET /medecins (ou /clients)
# -------------------------------------------------------------------
def liste(self, request):
return render(request, "personnes/liste.html", {
"titre_page": self.textes.titre, "textes": self.textes, "section": self.section,
"url_ajout": reverse(f"{self.section}_ajout"), "personnes": self.lister()})
# -------------------------------------------------------------------
# GET /medecins/ajouter : le formulaire vide ; POST : la création
# -------------------------------------------------------------------
def ajout(self, request):
if request.method == "GET":
return self.page(request, PersonneForm(initial={"titre": "Mr"}), None)
formulaire = PersonneForm(request.POST)
if not formulaire.is_valid():
return self.page(request, formulaire, None)
try:
personne = self.creer(formulaire.donnees())
except MetierErreur as e:
return self.erreur_metier(request, e, formulaire, None)
messages.success(request, str(self.textes.ajoute) % parametres(personne))
return redirect(f"{self.section}_liste")
# -------------------------------------------------------------------
# GET /medecins/{id}/modifier : le formulaire pré-rempli ; POST : la modification.
# <int:id> : un paramètre fait de chiffres (cf. exemple 02) :
# /medecins/abc/modifier ne correspond à aucune route (page 404)
# -------------------------------------------------------------------
def modification(self, request, id):
personne = self.trouver(id)
if personne is None:
raise Http404("personne inconnue")
if request.method == "GET":
# le formulaire, pré-rempli avec les valeurs de la personne (le mot de
# passe, lui, n'est jamais affiché)
formulaire = PersonneForm(creation=False, initial={
"titre": personne.titre, "prenom": personne.prenom, "nom": personne.nom,
"login": personne.compte.login, "version": personne.version})
return self.page(request, formulaire, personne)
formulaire = PersonneForm(request.POST, creation=False)
if not formulaire.is_valid():
return self.page(request, formulaire, personne)
try:
personne = self.mettre_a_jour(id, formulaire.donnees())
except MetierErreur as e:
return self.erreur_metier(request, e, formulaire, personne)
messages.success(request, str(self.textes.modifie) % parametres(personne))
return redirect(f"{self.section}_liste")
# -------------------------------------------------------------------
# POST /medecins/{id}/supprimer (la confirmation "Êtes-vous sûr ?" est demandée
# avant l'envoi du formulaire, cf. static/js/app.js)
# -------------------------------------------------------------------
def supprimer(self, request, id):
personne = self.trouver(id)
try:
self.effacer(id)
messages.success(request, str(self.textes.supprime) % parametres(personne))
except MetierErreur as e:
messages.error(request, e.message)
return redirect(f"{self.section}_liste")
# ----------------------------------------------------------------- fonctions internes
# personne None : formulaire de création ; sinon : formulaire de modification.
# Un formulaire refusé (saisie invalide, ou règle de gestion) : le code 422
def page(self, request, formulaire, personne, erreur=None, champs_invalides=()):
refuse = erreur is not None or (formulaire.is_bound and bool(formulaire.errors))
titre = self.textes.ajouter if personne is None else self.textes.modifier
return render(request, "personnes/formulaire.html", {
"titre_page": titre,
"formulaire": formulaire.signaler(champs_invalides),
"erreur": erreur,
"url_liste": reverse(f"{self.section}_liste"),
}, status=422 if refuse else 200)
# une règle de gestion a refusé l'écriture : le formulaire est réaffiché, avec
# la saisie de l'utilisateur (sans le mot de passe), le message de la couche
# métier (en bas de page), et les champs concernés, s'il y en a, encadrés en
# rouge pour que l'utilisateur voie tout de suite quoi corriger
def erreur_metier(self, request, e: MetierErreur, formulaire, personne):
if e.code in (erreurs.MEDECIN_INTROUVABLE, erreurs.CLIENT_INTROUVABLE):
# supprimé entre-temps par un autre ADMIN : retour à la liste
messages.error(request, e.message)
return redirect(f"{self.section}_liste")
return self.page(request, formulaire, personne, e.message, CHAMPS_ERREURS_METIER.get(e.code, []))
# les paramètres %(nom)s et %(login)s des messages "ajouté / modifié / supprimé"
def parametres(personne: Personne | None) -> dict:
if personne is None:
return {"nom": "", "login": ""}
return {"nom": f"{personne.prenom} {personne.nom}", "login": personne.compte.login}
# -------------------------------------------------------------------------
# les deux objets. lambda : une petite fonction anonyme, qui appelle la bonne
# méthode de l'ApplicationModel au moment de la requête. Les textes sont traduits
# de façon "paresseuse" (gettext_lazy) : au moment de leur affichage, dans la
# langue de la requête, et non au chargement du module
# -------------------------------------------------------------------------
medecins = GestionPersonnes(
"medecins",
Textes(_("Médecins"), _("Ajouter un médecin"), _("Modifier un médecin"), _("Aucun médecin."),
_("Supprimer ce médecin, ses créneaux horaires et son compte utilisateur ?"),
_("Le médecin %(nom)s a été ajouté (login : %(login)s)."),
_("Le médecin %(nom)s a été modifié."), _("Le médecin %(nom)s a été supprimé.")),
lister=lambda: application().get_all_medecins(),
trouver=lambda id: application().get_medecin_by_id(id),
creer=lambda donnees: application().ajouter_medecin(donnees),
mettre_a_jour=lambda id, donnees: application().modifier_medecin(id, donnees),
effacer=lambda id: application().supprimer_medecin(id))
clients = GestionPersonnes(
"clients",
Textes(_("Clients"), _("Ajouter un client"), _("Modifier un client"), _("Aucun client."),
_("Supprimer ce client, son compte utilisateur et tous ses rendez-vous ?"),
_("Le client %(nom)s a été ajouté (login : %(login)s)."),
_("Le client %(nom)s a été modifié."), _("Le client %(nom)s a été supprimé.")),
lister=lambda: application().get_all_clients(),
trouver=lambda id: application().get_client_by_id(id),
creer=lambda donnees: application().ajouter_client(donnees),
mettre_a_jour=lambda id, donnees: application().modifier_client(id, donnees),
effacer=lambda id: application().supprimer_client(id))
|
La gestion des médecins et celle des clients sont identiques : même liste, même formulaire, mêmes règles. Les vues sont écrites une fois, comme les méthodes d'une classe, [GestionPersonnes] ; deux objets de cette classe, [medecins] et [clients], ne diffèrent que par ce qui les distingue : le nom de la section, les textes, et les méthodes de l'[ApplicationModel] à appeler. Une vue n'a pas besoin d'être une fonction : c'est n'importe quel objet appelable qui reçoit une requête et rend une réponse. [self.liste], une méthode liée à un objet, en est un : [Django] l'appelle avec la requête, et la méthode reçoit en plus son objet ([self]), donc sa section. Pour ajouter une vue à la gestion des médecins et à celle des clients, il suffit de l'écrire ici.
- lignes 46-50 : les champs concernés par chaque erreur métier de saisie : un login déjà pris concerne le champ login ; un homonyme, les champs prénom et nom. Ces champs seront encadrés en rouge ;
- lignes 55-64 : les textes propres à chaque section, dans une classe de données ;
- lignes 68-78 : le constructeur. Les cinq derniers paramètres sont des fonctions ([Callable]) : lire la liste, trouver, créer, modifier, supprimer une personne ;
- lignes 81-89 : les routes de la section. Les décorateurs sont ici appelés comme des fonctions ([require_GET(self.liste)] : la vue [self.liste], décorée) : c'est exactement ce que fait la syntaxe [@require_GET] placée au-dessus d'une fonction (exemple 02). Le préfixe du chemin et du nom des routes est la section : la vue [liste] devient les routes [medecins_liste] ([/medecins]) et [clients_liste] ([/clients]) ;
- lignes 94-97 : la liste. Le gabarit est commun aux deux sections : il reçoit les textes de la section, et l'URL du bouton d'ajout ;
- lignes 102-113 : l'ajout. Une seule vue pour GET (le formulaire vide, avec le titre [Mr] choisi, par [initial], exemple 18) et POST (la création), comme à l'exemple 19. Une saisie invalide réaffiche le formulaire. Une saisie valide va à la couche métier, qui peut refuser l'écriture (un homonyme, un login déjà pris) ; sinon, message flash de succès et retour à la liste (schéma PRG). Le texte du message est une traduction paresseuse : [str(...)] la traduit, avant que l'opérateur [%] n'y place ses paramètres ;
- lignes 120-139 : la modification, pour une personne qui existe : sinon [Http404], la page 404. [<int:id>] n'accepte que des chiffres pour le paramètre [id] (exemple 02), et le convertit en entier. Le formulaire GET est pré-rempli avec les valeurs de la personne, et sa version ; un formulaire POST reçoit la saisie ;
- lignes 145-152 : la suppression. Le formulaire a demandé une confirmation, dans le navigateur (exemple 21). On lit la personne avant de la supprimer, pour le message de succès (« Le médecin Paul TATOU a été supprimé. ») ;
- lignes 157-165 : le formulaire, de création ([personne] vaut [None]) ou de modification. Un formulaire refusé (saisie invalide, ou règle de gestion) est rendu avec le code 422 (exemple 19). C'est ici que [signaler] encadre en rouge les champs en erreur ;
- lignes 171-176 : la couche métier a refusé l'écriture. Une personne supprimée entre-temps (par un autre administrateur) : retour à la liste. Sinon, le formulaire est réaffiché directement (sans redirection), comme pour une erreur de saisie : il contient déjà la saisie de l'utilisateur. S'y ajoutent le message de la couche métier (affiché en bas de la page, dans la zone d'erreur) et les champs à encadrer en rouge ;
- lignes 180-183 : les paramètres [%(nom)s] et [%(login)s] des messages de succès ;
- lignes 193-215 : les deux objets. Chaque [lambda] appelle la bonne méthode de l'[ApplicationModel] au moment de la requête : la façade n'est pas créée au chargement du module, mais à sa première utilisation.
11.17.7. web/vues/inscription.py
| # =========================================================================
# web/vues/inscription.py : "Créer un compte client" : un patient qui n'a pas
# encore de compte le crée lui-même. Routes PUBLIQUES (on n'a pas encore de compte,
# par définition) :
#
# POST /inscription/acces bouton "Créer un compte client" de la page
# d'authentification : vérifie le captcha de CETTE
# page, puis ouvre le formulaire
# GET /inscription le formulaire
# POST /inscription la création, puis retour à la page de connexion
#
# Le parcours est protégé contre les robots par UN captcha, celui de la page
# d'authentification :
# 1. le bouton "Créer un compte client" envoie le formulaire de connexion vers
# /inscription/acces ; si le captcha est bien recopié, le serveur délivre un
# laissez-passer (cf. securite/passe_inscription.py) ;
# 2. sans laissez-passer valide, /inscription renvoie à la page
# d'authentification (décorateur @passe_inscription_exige) ;
# 3. le laissez-passer est consommé à la création : un captcha résolu permet de
# créer UN compte ;
# 4. en plus, le nombre de comptes créés par heure et par adresse IP est limité
# (cf. securite/limiteur.py).
#
# Le compte créé est exactement celui que créerait l'ADMIN depuis la page "Clients"
# (même méthode de la couche métier : ajouter_client), si ce n'est que le patient
# CHOISIT son login et son mot de passe (obligatoires, avec confirmation).
# =========================================================================
from urllib.parse import urlencode
from django.contrib import messages
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _, ngettext
from django.views.decorators.http import require_http_methods, require_POST
from metier.erreurs import MetierErreur
from securite import captchas, limiteur, passe_inscription
from securite.passe_inscription import passe_inscription_exige
from .. import outils
from ..application import application
from ..formulaires import InscriptionForm
from .personnes import CHAMPS_ERREURS_METIER
# le message "trop de comptes créés", au singulier ou au pluriel
def trop_de_comptes(minutes: int) -> str:
return ngettext(
"Trop de comptes ont été créés depuis votre connexion. Réessayez dans %(minutes)d minute.",
"Trop de comptes ont été créés depuis votre connexion. Réessayez dans %(minutes)d minutes.",
minutes) % {"minutes": minutes}
# -------------------------------------------------------------------------
# POST /inscription/acces champs : captcha_id, captcha (ceux de la page
# d'authentification ; login et mot de passe, envoyés aussi, sont ignorés)
# -------------------------------------------------------------------------
@require_POST
def acces(request):
ip = outils.ip(request)
# trop de comptes créés récemment depuis cette adresse : inutile d'aller plus loin
minutes = limiteur.minutes_blocage_inscription(ip)
if minutes > 0:
messages.error(request, trop_de_comptes(minutes))
return redirect("login")
# captcha absent ou faux : retour à la page d'authentification, avec une
# nouvelle image (un échec compte comme un échec de connexion)
if not captchas.verifier(request.POST.get("captcha_id", ""), request.POST.get("captcha", "")):
limiteur.echec_login(ip, "")
messages.error(request, _("Pour créer un compte, recopiez d'abord correctement les "
"caractères de l'image, puis cliquez sur « Créer un compte client »."))
return redirect("login")
# captcha correct : on délivre le laissez-passer et on ouvre le formulaire
return passe_inscription.emettre(redirect("inscription"))
# -------------------------------------------------------------------------
# GET /inscription : le formulaire ; POST /inscription : la création. Le
# laissez-passer a été vérifié AVANT l'appel de la vue (@passe_inscription_exige).
# Le formulaire valide TOUTE la saisie (champs et confirmation du mot de passe) :
# l'utilisateur corrige toutes ses erreurs en une fois
# -------------------------------------------------------------------------
@require_http_methods(["GET", "POST"])
@passe_inscription_exige
def inscription(request):
if request.method == "GET":
return page(request, InscriptionForm(initial={"titre": "Mr"}))
formulaire = InscriptionForm(request.POST)
if not formulaire.is_valid():
return page(request, formulaire)
ip = outils.ip(request)
# 1. trop de comptes créés récemment depuis cette adresse ?
minutes = limiteur.minutes_blocage_inscription(ip)
if minutes > 0:
return page(request, formulaire, trop_de_comptes(minutes))
# 2. la création (client + compte USER, dans une transaction)
donnees = formulaire.donnees()
try:
application().ajouter_client(donnees)
except MetierErreur as e:
# login ou prénom + nom déjà pris : le laissez-passer reste valable
return page(request, formulaire, e.message, CHAMPS_ERREURS_METIER.get(e.code, []))
# 3. le laissez-passer a servi : il ne permettra pas de créer un 2e compte
passe_inscription.consommer(request.COOKIES.get(passe_inscription.COOKIE))
limiteur.inscription_reussie(ip)
# 4. retour à la page de connexion, login pré-rempli
messages.success(request, _("Votre compte a été créé. Vous pouvez vous connecter avec "
"le login %(login)s.") % {"login": donnees.login})
url = f"{reverse('login')}?{urlencode({'login': donnees.login})}"
return passe_inscription.effacer_cookie(redirect(url))
# le formulaire ; un formulaire refusé : le code 422
def page(request, formulaire, erreur=None, champs_invalides=()):
refuse = erreur is not None or (formulaire.is_bound and bool(formulaire.errors))
return render(request, "inscription.html", {
"titre_page": _("Créer un compte client"),
"formulaire": formulaire.signaler(champs_invalides), "erreur": erreur,
}, status=422 if refuse else 200)
|
La création d'un compte client par le patient lui-même. Le parcours est protégé par un captcha, celui de la page de connexion.
- lignes 59-75 : le bouton Créer un compte client de la page de connexion envoie le formulaire de connexion à cette adresse (plus loin, le gabarit [login.html]). Si trop de comptes ont été créés récemment depuis cette adresse IP, inutile d'aller plus loin. Si le captcha est absent ou faux : retour à la page de connexion, avec une nouvelle image et un message ; l'échec compte comme un échec de connexion (avec un login vide). Si le captcha est bon : un laissez-passer, dans un cookie, et une redirection vers le formulaire ;
- lignes 84-91 : le formulaire. Le décorateur [@passe_inscription_exige] (ligne 85) vérifie le laissez-passer avant l'appel de la vue. L'ordre des décorateurs compte : le plus proche de la fonction s'applique en premier ; [require_http_methods], placé au-dessus, refuse donc une méthode interdite avant toute autre vérification. Le formulaire valide toute la saisie, confirmation du mot de passe comprise : l'utilisateur voit toutes ses erreurs d'un coup ;
- lignes 92-111 : la création. Le client et son compte sont créés par la même méthode de la couche métier que pour l'administrateur ([ajouter_client]) ; seuls le login et le mot de passe sont choisis par le patient. Un login ou un homonyme déjà pris : retour au formulaire, sans consommer le laissez-passer. Un compte créé consomme le laissez-passer (un captcha résolu, un compte), compte pour la limite horaire, et renvoie à la page de connexion, le login pré-rempli (le paramètre [?login=...], fabriqué par [urlencode]), avec un message de succès. Le cookie du laissez-passer est effacé.
11.17.8. web/vues/espace.py
| # =========================================================================
# web/vues/espace.py : les pages PERSONNELLES du patient (rôle USER), celle qu'il
# voit juste après s'être connecté :
#
# GET /mon-espace page d'accueil : [Gérer mon compte], [Voir un
# agenda], et son rendez-vous à venir
# POST /mon-espace/annuler annule SON rendez-vous
# GET /mon-compte formulaire de modification de son compte
# POST /mon-compte modification
# POST /mon-compte/supprimer suppression du compte (et de ses rendez-vous)
#
# Règle d'or de ce module : le patient concerné est TOUJOURS celui du jeton
# (outils.patient_connecte), jamais un identifiant envoyé par le navigateur. Un
# patient ne peut donc ni voir, ni modifier, ni annuler ce qui appartient à un autre
# patient, même en fabriquant lui-même ses requêtes.
#
# Toute modification ou suppression du compte exige le mot de passe ACTUEL :
# quelqu'un qui trouverait une session restée ouverte ne pourrait ni changer le mot
# de passe, ni supprimer le compte. Les erreurs de mot de passe actuel sont
# comptées comme des échecs de connexion (même limite).
#
# Sécurité : toutes ces URL sont réservées au rôle USER (règle ^/mon- de
# securite/acces.py).
# =========================================================================
from django.contrib import messages
from django.shortcuts import redirect, render
from django.utils.translation import gettext as _, ngettext
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from metier.erreurs import MetierErreur
from modeles.models import Client
from securite import auth, limiteur
from .. import outils
from ..application import application
from ..formulaires import MonCompteForm
from ..modeles_vues import RendezVousVue
from .agenda import url_agenda
from .personnes import CHAMPS_ERREURS_METIER
# -------------------------------------------------------------------------
# GET /mon-espace
# -------------------------------------------------------------------------
@require_GET
def espace(request):
patient = outils.patient_connecte(request)
rendez_vous = [RendezVousVue(
rv.id,
outils.formater_jour(rv.jour),
rv.creneau.horaire(),
rv.creneau.medecin.libelle(),
# lien vers l'agenda du médecin ce jour-là : le patient y voit son nom
url_agenda(rv.creneau.medecin_id, rv.jour))
for rv in application().get_rv_a_venir_client(patient.id)]
return render(request, "espace.html", {"titre_page": _("Mon espace"),
"patient": patient.libelle(), "rendez_vous": rendez_vous})
# -------------------------------------------------------------------------
# POST /mon-espace/annuler champ : idRv
# -------------------------------------------------------------------------
@require_POST
def annuler_rv(request):
patient = outils.patient_connecte(request)
id_rv = outils.entier_positif(request.POST.get("idRv"))
rv = application().get_rv_by_id(id_rv) if id_rv is not None else None
# un rendez-vous qui n'existe pas, et celui d'un AUTRE patient, reçoivent la même
# réponse : on ne révèle rien sur les rendez-vous des autres
if rv is None or rv.client_id != patient.id:
messages.error(request, _("Ce rendez-vous n'existe pas (ou plus)."))
else:
application().supprimer_rv(rv)
messages.success(request, _("Rendez-vous annulé."))
return redirect("espace")
# -------------------------------------------------------------------------
# GET /mon-compte : le formulaire, pré-rempli avec les valeurs du patient ;
# POST /mon-compte : la modification
# -------------------------------------------------------------------------
@require_http_methods(["GET", "POST"])
def compte(request):
patient = outils.patient_connecte(request)
if request.method == "GET":
return page(request, MonCompteForm(initial={
"titre": patient.titre, "prenom": patient.prenom, "nom": patient.nom,
"login": patient.compte.login, "version": patient.version}))
formulaire = MonCompteForm(request.POST)
if not formulaire.is_valid():
return page(request, formulaire)
# le mot de passe actuel ; faux : le message va SOUS le champ (une erreur ajoutée
# au champ par add_error), et en bas de page
erreur = verifier_mot_de_passe_actuel(request, patient, formulaire.cleaned_data["current_password"])
if erreur is not None:
formulaire.add_error("current_password", erreur)
return page(request, formulaire, erreur)
try:
client = application().modifier_client(patient.id, formulaire.donnees())
except MetierErreur as e:
return page(request, formulaire, e.message, CHAMPS_ERREURS_METIER.get(e.code, []))
# le login et le nom affiché sont DANS le jeton : on en délivre un nouveau, sinon
# la barre de navigation garderait l'ancien nom
messages.success(request, _("Votre compte a été modifié."))
return auth.poser_jeton(redirect("espace"), client.compte)
# -------------------------------------------------------------------------
# POST /mon-compte/supprimer champ : current_password
# Le compte, le client et TOUS ses rendez-vous disparaissent ensemble, puis le
# patient est déconnecté.
# -------------------------------------------------------------------------
@require_POST
def supprimer_compte(request):
patient = outils.patient_connecte(request)
erreur = verifier_mot_de_passe_actuel(request, patient, request.POST.get("current_password", ""))
if erreur is not None:
messages.error(request, erreur)
return redirect("compte")
application().supprimer_client(patient.id)
messages.success(request, _("Votre compte a été supprimé."))
return auth.effacer_jeton(redirect("login"))
# ----------------------------------------------------------------- fonctions internes
# la page du compte ; un formulaire refusé : le code 422
def page(request, formulaire, erreur=None, champs_invalides=()):
refuse = erreur is not None or (formulaire.is_bound and bool(formulaire.errors))
return render(request, "compte.html", {
"titre_page": _("Gérer mon compte"),
"formulaire": formulaire.signaler(champs_invalides), "erreur": erreur,
}, status=422 if refuse else 200)
# None si le mot de passe actuel est correct ; sinon le message d'erreur
def verifier_mot_de_passe_actuel(request, patient: Client, mot_de_passe: str) -> str | None:
ip = outils.ip(request)
login = patient.compte.login
minutes = limiteur.minutes_blocage_login(ip, login)
if minutes > 0:
return ngettext("Trop de tentatives infructueuses. Réessayez dans %(minutes)d minute.",
"Trop de tentatives infructueuses. Réessayez dans %(minutes)d minutes.",
minutes) % {"minutes": minutes}
if not mot_de_passe or auth.valider(login, mot_de_passe) is None:
limiteur.echec_login(ip, login)
return _("Mot de passe actuel incorrect.")
return None
|
Les pages personnelles du patient. Règle d'or de ce module : le patient concerné est toujours celui du jeton ([outils.patient_connecte]), jamais un identifiant envoyé par le navigateur. Un patient ne peut donc ni voir, ni modifier, ni annuler ce qui appartient à un autre, même en fabriquant lui-même ses requêtes.
- lignes 46-58 : Mon espace : le libellé du patient, et ses rendez-vous à venir, mis en forme ([RendezVousVue]), chacun avec un lien vers l'agenda du médecin ce jour-là. La fonction [url_agenda] est celle du module de l'agenda (ligne 39, [from .agenda import url_agenda]) ;
- lignes 64-76 : l'annulation de son rendez-vous. Un rendez-vous qui n'existe pas, et celui d'un autre patient, reçoivent la même réponse : on ne révèle rien sur les rendez-vous des autres (pas même leur existence) ;
- lignes 83-92 : le formulaire « Gérer mon compte », pré-rempli avec les valeurs du patient, et sa validation ;
- lignes 95-106 : la modification. Toute modification exige le mot de passe actuel : quelqu'un qui trouverait une session restée ouverte ne pourrait ni changer le mot de passe, ni rien d'autre. Un mot de passe actuel faux : le formulaire est réaffiché, avec le message sous le champ et en bas de la page. [add_error(champ, message)] ajoute une erreur à un champ, comme si la validation l'avait trouvée (exemple 19). Après la modification, un nouveau jeton : le login et le nom affiché sont dans le jeton, et la barre de navigation garderait sinon l'ancien nom jusqu'à la prochaine connexion ;
- lignes 114-123 : la suppression du compte, du client et de tous ses rendez-vous, par la même méthode de la couche métier que pour l'administrateur. Ce second formulaire de la page est écrit à la main (un seul champ, le mot de passe actuel). Le patient est ensuite déconnecté (cookie du jeton effacé) ;
- lignes 137-148 : la vérification du mot de passe actuel. Elle est soumise au limiteur de tentatives, comme une connexion : sans cela, quelqu'un qui disposerait d'une session ouverte pourrait essayer des milliers de mots de passe, par le formulaire « Gérer mon compte », et découvrir le bon.
11.18. Les gabarits
Les gabarits du dossier [templates] (réglage [TEMPLATES] de [config/settings.py]) : un gabarit parent, des fragments, les fragments nommés des formulaires, et les pages.
11.18.1. Le gabarit parent et les fragments
11.18.1.1. templates/layout.html
| <!doctype html>
{% comment %}
=========================================================================
templates/layout.html
-------------------------------------------------------------------------
Le gabarit PARENT de toutes les pages (cf. exemple 15) : chaque page
(agenda.html, login.html...) l'ÉTEND ({% extends %}) et remplit son bloc
[contenu].
Django ÉCHAPPE tout ce qu'il affiche avec {{ ... }} (les caractères < > & " '
deviennent < >...) : protection contre l'injection de HTML via un nom
saisi, par exemple. Pour insérer du HTML tel quel, il faut le demander
explicitement (filtre safe, cf. fragments/captcha.html).
Messages : un succès s'affiche en HAUT de la page, une erreur en BAS, sur fond
rose léger (balises zone_succes et zone_erreurs, cf. web/templatetags/rdv.py).
Le gabarit partage les variables de la page : [titre_page] (le titre de la
page, transmis par la vue, déjà traduit). LANGUAGE_CODE : la langue de la
requête (cf. web/middleware.py).
=========================================================================
{% endcomment %}
{% load static i18n rdv %}
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ titre_page }} - RdvMedecins</title>
{# static : l'URL d'un fichier du dossier static #}
<link rel="stylesheet" href="{% static 'lib/bootstrap/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
</head>
<body>
<div class="container py-4">
{% include "fragments/navbar.html" %}
{% zone_succes %}
<main>
{% block contenu %}{% endblock %}
</main>
{% zone_erreurs %}
</div>
{# la fenêtre "Êtes-vous sûr ?", cachée tant qu'on n'en a pas besoin #}
{% include "fragments/confirmation.html" %}
<script src="{% static 'js/app.js' %}"></script>
</body>
</html>
|
Le gabarit parent de toutes les pages (exemple 15) : chaque page l'étend ([{% extends "layout.html" %}]) et remplit son bloc [contenu] (ligne 38).
- ligne 23 : les bibliothèques de balises utilisées : [static] (les fichiers statiques), [i18n] (les traductions, exemple 22), et [rdv], la nôtre (couche web, plus haut). Un gabarit parent ne transmet pas ses bibliothèques aux gabarits qui l'étendent : chaque page charge celles dont elle a besoin ;
- ligne 24 : la langue de la page, [fr] ou [en] : la langue active de la requête, fixée par le middleware [FixerLangue] ;
- ligne 28 : le titre de la page, déjà traduit par la vue, qui le transmet dans la variable [titre_page] ;
- lignes 30-31 : la balise [{% static %}] rend l'URL d'un fichier du dossier [static] (exemple 15) ;
- lignes 35-40 : la barre de navigation, les messages de succès en haut de la page, le contenu de la page, et la zone d'erreur en bas de la page. Le fragment de la barre de navigation est inséré par [{% include %}] (exemple 15) : il voit les variables de la page. Les deux zones de messages sont les balises d'inclusion de [web/templatetags/rdv.py] ;
- lignes 43-44 : la fenêtre « Êtes-vous sûr ? », cachée tant qu'on n'en a pas besoin, et le script qui l'affiche.
Le commentaire de tête (lignes 2-22) est une balise [{% comment %}] : un commentaire des gabarits qui peut s'étendre sur plusieurs lignes ([{# ... #}] tient sur une seule ligne, exemple 14). Il est écrit après [<!doctype html>] : la première ligne de la page envoyée doit être la déclaration du type de document. Le DTL échappe tout ce qu'il affiche avec [{{ ... }}] (exemple 14) : un nom saisi qui contiendrait du HTML ([<script>]) est affiché tel quel, sans être interprété. C'est la protection contre l'injection de HTML (XSS), sans effort, dans toutes les pages.
11.18.1.2. templates/fragments/navbar.html
| {% comment %}
=========================================================================
templates/fragments/navbar.html - la barre de navigation.
Titre, FR/EN, rôle, nom, déconnexion - et le menu, dont les entrées dépendent
du rôle : elles sont calculées par le processeur de contexte web/contextes.py
(variable menu), avec, pour chacune, son URL, son libellé, et si elle est celle
de la page courante (classe "active").
utilisateur : l'utilisateur connecté, ou None (cf. web/contextes.py).
=========================================================================
{% endcomment %}
{% load i18n %}
<nav class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
<span class="navbar-brand mb-0">RdvMedecins</span>
<ul class="navbar-nav me-auto">
{% for entree in menu %}
<li class="nav-item">
<a class="nav-link{% if entree.active %} active{% endif %}" href="{{ entree.url }}">{{ entree.libelle }}</a>
</li>
{% endfor %}
</ul>
<div class="d-flex align-items-center gap-2">
{# FR / EN : de simples liens (GET /langue/fr), la langue active en surbrillance #}
{% get_available_languages as langues %}
<div class="btn-group btn-group-sm" role="group" aria-label="FR / EN">
{% for code, nom in langues %}
<a class="btn {% if code == LANGUAGE_CODE %}btn-light{% else %}btn-outline-light{% endif %}"
href="{% url 'langue' code %}">{{ code|upper }}</a>
{% endfor %}
</div>
{% if utilisateur %}
<span class="badge text-bg-light text-primary">{{ utilisateur.role }}</span>
<span class="text-white">{{ utilisateur.nom }}</span>
{# la déconnexion est un POST : un formulaire réduit à son bouton (et au jeton anti-CSRF) #}
<form method="post" action="{% url 'logout' %}" class="m-0">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-light">{% translate "Se déconnecter" %}</button>
</form>
{% endif %}
</div>
</nav>
|
La barre de navigation. Elle se contente d'afficher ce que le processeur de contexte [web/contextes.py] a préparé : l'utilisateur connecté ([utilisateur]), et les entrées du menu ([menu]), qui dépendent du rôle.
- lignes 17-23 : le menu, chaque entrée avec son URL et son libellé ; la classe [active] de [Bootstrap] met en valeur l'entrée de la page affichée ;
- lignes 27-32 : les boutons FR / EN, de simples liens ([/langue/fr]), la langue active en surbrillance. [{% get_available_languages as langues %}] (bibliothèque [i18n]) range dans la variable [langues] le réglage [LANGUAGES], une liste de couples (code, nom), que la boucle déstructure ([{% for code, nom in langues %}]) ; le filtre [upper] met le code en majuscules. [{% url 'langue' code %}] fabrique l'URL d'une route qui a un paramètre (exemple 15) ;
- lignes 35-43 : pour un utilisateur connecté, son rôle, son nom, et le bouton de déconnexion. La déconnexion est une requête POST : un formulaire réduit à son bouton, avec son jeton anti-CSRF ([{% csrf_token %}], exemple 21).
11.18.1.3. templates/fragments/succes.html et erreurs.html
| {# templates/fragments/succes.html - les messages de SUCCÈS (balise zone_succes, cf. web/templatetags/rdv.py) #}
{% for texte in textes %}
<div class="alert alert-success" role="status">{{ texte }}</div>
{% endfor %}
|
| {% comment %}
templates/fragments/erreurs.html - la zone d'erreur, en BAS de chaque page, sur
fond rose léger (classe .zone-erreur, cf. static/css/styles.css). Les messages
sont rassemblés par la balise zone_erreurs (cf. web/templatetags/rdv.py).
role="alert" : un lecteur d'écran annonce le message dès l'affichage de la page,
même s'il se trouve en bas.
{% endcomment %}
{% if textes %}
<div class="zone-erreur" role="alert">
{% for texte in textes %}
<div>{{ texte }}</div>
{% endfor %}
</div>
{% endif %}
|
Les deux gabarits des balises d'inclusion [zone_succes] et [zone_erreurs] (couche web, plus haut) : ils reçoivent la liste [textes], préparée par la balise, et l'affichent. [role="status"] : un lecteur d'écran annonce le message de succès. [role="alert"] : il annonce la zone d'erreur dès l'affichage de la page, même si elle se trouve en bas. La zone d'erreur reste collée au bas de la fenêtre quand la page est longue (feuille de style, plus loin).
11.18.1.4. templates/fragments/confirmation.html
| {% comment %}
=========================================================================
templates/fragments/confirmation.html - la fenêtre modale "Êtes-vous sûr ?"
(cf. exemple 21).
Elle remplace la boîte window.confirm() du navigateur, qu'on ne peut ni mettre
en forme, ni traduire (ses boutons restent dans la langue du navigateur, pas
dans celle de la page).
Une SEULE fenêtre pour toute la page, cachée (d-none) au départ. Ses libellés
sont traduits ici, par le serveur, comme le reste de la page ; le petit script
static/js/app.js se contente de l'afficher, d'y placer le message propre au
formulaire (attribut data-confirmation), et d'envoyer le formulaire si
l'utilisateur confirme.
=========================================================================
{% endcomment %}
{% load i18n %}
<div id="modale-confirmation" class="d-none">
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true"
aria-labelledby="modale-confirmation-titre" aria-describedby="modale-confirmation-message">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modale-confirmation-titre">{% translate "Confirmation" %}</h5>
<button type="button" class="btn-close" data-action="non" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<p class="mb-0" id="modale-confirmation-message"></p>
</div>
<div class="modal-footer">
{# "Non, revenir" plutôt qu'"Annuler" : la question est parfois « Annuler ce rendez-vous ? » #}
<button type="button" class="btn btn-secondary" data-action="non">{% translate "Non, revenir" %}</button>
<button type="button" class="btn btn-danger" data-action="oui">{% translate "Oui, confirmer" %}</button>
</div>
</div>
</div>
</div>
</div>
|
La fenêtre modale « Êtes-vous sûr ? » de l'exemple 21, qui remplace la boîte [window.confirm()] du navigateur : celle-ci ne se met pas en forme, et ses boutons restent dans la langue du navigateur, pas dans celle de la page. Une seule fenêtre pour toute la page, cachée au départ (classe [d-none] de [Bootstrap]) ; ses libellés sont traduits par le serveur, comme le reste de la page. Le script [static/js/app.js] l'affiche, y place le message du formulaire à confirmer (son attribut [data-confirmation]), et envoie le formulaire si l'utilisateur confirme. Le bouton de refus s'appelle « Non, revenir » plutôt qu'« Annuler » : la question est parfois « Annuler ce rendez-vous ? », et un bouton Annuler y serait ambigu. Les attributs [role="alertdialog"], [aria-modal], [aria-labelledby] et [aria-describedby] décrivent la fenêtre aux lecteurs d'écran : une boîte de dialogue qui attend une réponse, son titre, son message.
11.18.1.5. templates/fragments/reservation.html
| {% comment %}
=========================================================================
templates/fragments/reservation.html - la fenêtre de réservation de l'agenda.
Les VRAIES classes Bootstrap d'une fenêtre modale (.modal, .modal-backdrop...),
rendues visibles "en dur" (show d-block) plutôt que par le JavaScript de
Bootstrap. C'est le SERVEUR qui décide de l'afficher (paramètre &reserver=... de
l'URL), et la fermer revient à suivre un lien vers la même page sans ce
paramètre.
Le fragment lit directement les variables de la page qui l'insère
(reservation, id_medecin, jour, url_retour).
=========================================================================
{% endcomment %}
{% load i18n %}
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="titre-reservation">
<div class="modal-dialog modal-dialog-centered">
<form method="post" action="{% url 'agenda_reserver' %}" class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="titre-reservation">{% translate "Réserver ce créneau" %}</h5>
<a class="btn-close" href="{{ url_retour }}" aria-label="{% translate 'Fermer' %}"></a>
</div>
<div class="modal-body">
<p>{% blocktranslate with horaire=reservation.horaire %}Créneau de {{ horaire }}{% endblocktranslate %}</p>
{# ce que le serveur doit savoir en plus du patient choisi #}
{% csrf_token %}
<input type="hidden" name="idMedecin" value="{{ id_medecin }}">
<input type="hidden" name="jour" value="{{ jour }}">
<input type="hidden" name="idCreneau" value="{{ reservation.id_creneau }}">
{# un patient réserve pour lui-même : pas de liste à choisir (et le serveur ignore de toute façon un éventuel idClient) #}
{% if reservation.patient %}
<p class="mb-0">
<span>{% translate "Patient" %}</span> :
<strong>{{ reservation.patient }}</strong>
</p>
{% else %}
<label class="form-label" for="idClient">{% translate "Patient" %}</label>
<select id="idClient" name="idClient" class="form-select" required autofocus>
<option value="">{% translate "-- choisir un patient --" %}</option>
{% for client in reservation.clients %}
<option value="{{ client.id }}">{{ client.libelle }}</option>
{% endfor %}
</select>
{% endif %}
</div>
<div class="modal-footer">
<a class="btn btn-secondary" href="{{ url_retour }}">{% translate "Annuler" %}</a>
<button type="submit" class="btn btn-primary">{% translate "Confirmer" %}</button>
</div>
</form>
</div>
</div>
|
La fenêtre de réservation de l'agenda. Elle utilise les vraies classes d'une fenêtre modale de [Bootstrap] ([modal], [modal-backdrop]...), mais rendues visibles « en dur » ([show d-block]), plutôt que par le [JavaScript] de [Bootstrap] : c'est le serveur qui décide de l'afficher, quand l'URL contient [&reserver=...]. La fermer (la croix, ou le bouton Annuler) revient à suivre un lien vers la même page sans ce paramètre ([url_retour]).
- ligne 26 : [{% blocktranslate with horaire=reservation.horaire %}] : un texte traduit qui contient une variable (exemple 22). Dans un [blocktranslate], seules des variables simples sont permises ([{{ horaire }}], et non [{{ reservation.horaire }}]) : la clause [with] en crée une ;
- lignes 29-32 : le jeton anti-CSRF, et ce que le serveur doit savoir : le médecin, le jour, le créneau ;
- lignes 35-48 : un patient réserve pour lui-même : son nom, sans liste à choisir (et le serveur ignore de toute façon un éventuel champ [idClient]). L'administrateur et les médecins choisissent le patient dans une liste ; [required] et [autofocus] : le navigateur exige un choix, et place le curseur dans la liste.
11.18.1.6. templates/fragments/captcha.html
| {% comment %}
=========================================================================
templates/fragments/captcha.html - le captcha de la page d'authentification
(cf. securite/captchas.py). Il protège à la fois la connexion et l'accès à la
création de compte (bouton "Créer un compte client"). Il lit la variable
[captcha] de la page.
captcha.svg|safe : l'image SVG est insérée TELLE QUELLE dans la page, sans
échappement. C'est sans danger ici parce que ce SVG est fabriqué par le serveur
lui-même et ne contient rien qui vienne de l'utilisateur - on ne ferait JAMAIS
cela avec une saisie.
Le champ caché captcha_id dit au serveur QUEL captcha vérifier ; la bonne
réponse, elle, n'est jamais envoyée au navigateur. "Autre image" recharge
simplement la page ({% url 'login' %} : l'URL de la page, sans ses paramètres).
=========================================================================
{% endcomment %}
{% load i18n %}
<div class="mb-3">
<label class="form-label" for="captcha">{% translate "Recopiez les caractères de l'image" %}</label>
<div class="d-flex align-items-center gap-3 mb-2">
<div class="captcha-image" role="img" aria-label="{% translate 'Image de vérification anti-robots' %}">{{ captcha.svg|safe }}</div>
<a class="small" href="{% url 'login' %}">{% translate "Autre image" %}</a>
</div>
<input type="hidden" name="captcha_id" value="{{ captcha.id }}">
<input id="captcha" name="captcha" type="text" maxlength="10" autocomplete="off" autocapitalize="off"
spellcheck="false" required class="form-control">
<div class="form-text">{% translate "Majuscules ou minuscules, peu importe." %}</div>
</div>
|
Le captcha (exemple 27). [captcha.svg|safe] insère l'image SVG telle quelle, sans échappement : sinon, le navigateur afficherait son code XML. C'est sans danger ici parce que ce SVG est fabriqué par le serveur, et ne contient rien qui vienne de l'utilisateur : on ne ferait jamais cela avec une saisie. Le champ caché [captcha_id] dit au serveur quel captcha vérifier ; la réponse, elle, n'est jamais envoyée au navigateur. Le lien « Autre image » recharge la page de connexion, et donc le captcha. Les attributs de la zone de saisie ([autocomplete="off"], [autocapitalize="off"], [spellcheck="false"]) empêchent le navigateur de proposer une saisie précédente, et le clavier d'un téléphone de mettre une majuscule ou de corriger l'orthographe.
| {% comment %}
templates/formulaires/champ.html - un champ complet d'un formulaire : libellé,
zone de saisie, puis son message d'erreur ou son aide (cf. exemple 18 et le
réglage FORM_RENDERER). La zone de saisie est encadrée en rouge (classe
is-invalid) par la méthode signaler du formulaire (cf. web/formulaires.py).
{% endcomment %}
<div class="mb-3">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}<div class="invalid-feedback d-block" id="{{ field.auto_id }}_error">{% for erreur in field.errors %}{{ erreur }}{% if not forloop.last %}<br>{% endif %}{% endfor %}</div>{% endif %}
{% if field.help_text and not field.errors %}<div class="form-text" id="{{ field.auto_id }}_helptext">{{ field.help_text }}</div>{% endif %}
</div>
|
Le gabarit d'un champ complet, celui du moteur de rendu [RenduBootstrap] (couche web, plus haut), comme à l'exemple 18 : le libellé, la zone de saisie ([{{ field }}], avec les classes CSS données par le formulaire), puis le message d'erreur ou, à défaut, l'aide. Les classes [invalid-feedback] (le message en rouge) et [form-text] (l'aide en gris) sont celles de [Bootstrap] ; [d-block] affiche le message même si la zone de saisie n'est pas juste avant lui. Un champ peut avoir plusieurs erreurs : elles sont séparées par un saut de ligne ([forloop.last], exemple 14).
Les identifiants [..._error] et [..._helptext] ne sont pas choisis au hasard : ce sont ceux que [Django] désigne dans les attributs [aria-describedby] de la zone de saisie, avec [aria-invalid] pour un champ en erreur. Un lecteur d'écran lit ainsi le message d'erreur ou l'aide avec le champ :
| <div class="mb-3">
<label class="form-label" for="id_prenom">Prénom</label>
<input type="text" name="prenom" maxlength="20" class="form-control is-invalid" required aria-invalid="true" aria-describedby="id_prenom_error" id="id_prenom">
<div class="invalid-feedback d-block" id="id_prenom_error">Le prénom est obligatoire.</div>
</div>
|
Le cadre rouge ([is-invalid]) a été ajouté par la méthode [signaler] du formulaire ; [maxlength] et [required] par [Django], d'après les options du champ. L'attribut [required] n'empêche pas l'envoi d'un formulaire vide : les formulaires de l'application portent l'attribut [novalidate] (plus loin), c'est le serveur qui valide.
11.18.2.2. templates/macros.html
| {% comment %}
templates/macros.html - les fragments nommés des formulaires (cf. exemple 15).
identite : les champs d'identité (titre, prénom, nom), communs à trois
formulaires : médecin/client de l'ADMIN, "Créer un compte client" et "Gérer mon
compte". Il lit la variable [formulaire] de la page qui l'insère :
{% include "macros.html#identite" %}.
{% endcomment %}
{% partialdef identite %}
{{ formulaire.titre.as_field_group }}
<div class="row g-3">
<div class="col-sm-6">{{ formulaire.prenom.as_field_group }}</div>
<div class="col-sm-6">{{ formulaire.nom.as_field_group }}</div>
</div>
{% endpartialdef %}
|
Un fragment nommé ([{% partialdef %}], exemple 15), utilisé par les trois pages qui affichent un formulaire « personne » : les champs d'identité, le titre, puis le prénom et le nom côte à côte (la grille de [Bootstrap] : [row], [col-sm-6]). Chaque champ est dessiné par [as_field_group] (exemple 18), c'est-à-dire par le gabarit [champ.html]. Une page l'insère par [{% include "macros.html#identite" %}] : le fragment lit la variable [formulaire] de la page.
11.18.3. Les pages
11.18.3.1. templates/login.html
| {% extends "layout.html" %}
{% comment %}
=========================================================================
templates/login.html - le formulaire de connexion (cf. web/vues/auth.py).
Un <form> HTML ordinaire, écrit à la main : le navigateur envoie lui-même login,
mot de passe et réponse au captcha en POST /login, sans JavaScript. En cas
d'échec, le serveur renvoie CETTE page, avec le login déjà saisi, un NOUVEAU
captcha et l'erreur, affichée en bas de page par le gabarit parent.
=========================================================================
{% endcomment %}
{% load i18n %}
{% block contenu %}
<div class="row justify-content-center">
<div class="col-sm-8 col-md-6 col-lg-5">
<div class="card p-4">
<h5 class="card-title mb-3">{% translate "Connexion" %}</h5>
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<div class="mb-3">
<label class="form-label" for="login">{% translate "Login" %}</label>
{# autofocus : un attribut booléen, écrit si la condition est vraie. Le curseur va au premier champ à remplir #}
<input id="login" name="login" type="text" class="form-control" autocomplete="username"
value="{{ login }}" required{% if not login %} autofocus{% endif %}>
</div>
<div class="mb-3">
<label class="form-label" for="password">{% translate "Mot de passe" %}</label>
<input id="password" name="password" type="password" class="form-control"
autocomplete="current-password" required{% if login %} autofocus{% endif %}>
</div>
{% include "fragments/captcha.html" %}
<button type="submit" class="btn btn-primary w-100">{% translate "Se connecter" %}</button>
{% comment %}
pas encore de compte : un patient peut en créer un lui-même. Ce n'est PAS
un simple lien : c'est un second bouton d'envoi de CE formulaire, qui part
vers une autre adresse (formaction). Le serveur y vérifie le captcha
ci-dessus avant d'ouvrir la page de création de compte (cf.
web/vues/inscription.py). formnovalidate : le navigateur n'exige pas
login et mot de passe (inutiles pour créer un compte). Entrée dans un
champ déclenche toujours le PREMIER bouton du formulaire, "Se connecter".
{% endcomment %}
<button type="submit" class="btn btn-outline-primary w-100 mt-3"
formaction="{% url 'inscription_acces' %}" formnovalidate>{% translate "Créer un compte client" %}</button>
<div class="form-text text-center">{% translate "Pour créer un compte, recopiez d'abord les caractères de l'image." %}</div>
</form>
<div class="text-muted small mt-3">
<span>{% translate "Comptes de démonstration (mot de passe = login) :" %}</span>
<ul class="mb-0 mt-1">
<li><code>admin</code> - <span>{% translate "rôle ADMIN : rendez-vous + gestion des médecins et des clients" %}</span></li>
<li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - <span>{% translate "rôle DOCTOR : prise et annulation de rendez-vous" %}</span></li>
<li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - <span>{% translate "rôle USER (patients) : réservation pour eux-mêmes, gestion de leur compte" %}</span></li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
|
Le formulaire de connexion, un [<form>] écrit à la main, envoyé en POST à [/login]. La balise [{% extends %}] est la première du gabarit (ligne 1), comme l'exige le DTL ; le commentaire vient ensuite.
- lignes 24-30 : le login saisi est réaffiché après un échec ([login]). L'attribut [autofocus] place le curseur dans le premier champ à remplir : le login s'il est vide, le mot de passe sinon (après un échec, ou après la création d'un compte). Un attribut booléen s'écrit ou ne s'écrit pas : [{% if not login %} autofocus{% endif %}] ;
- ligne 33 : le captcha ;
- lignes 46-47 : le bouton Créer un compte client n'est pas un lien : c'est un second bouton d'envoi du même formulaire, qui l'envoie à une autre adresse (attribut [formaction] du bouton). Le serveur y vérifie le captcha avant d'ouvrir la page de création de compte ([inscription.acces]). [formnovalidate] : le navigateur n'exige pas le login et le mot de passe (attributs [required]), inutiles pour créer un compte. La touche Entrée, dans un champ, déclenche toujours le premier bouton d'envoi du formulaire : Se connecter ;
- lignes 51-58 : les comptes de démonstration, pour les essais.
11.18.3.2. templates/agenda.html
| {% extends "layout.html" %}
{% comment %}
=========================================================================
templates/agenda.html - la page principale (cf. web/vues/agenda.py).
Trois blocs :
1. le choix du médecin et du jour ;
2. l'agenda ;
3. la fenêtre de réservation (fragment reservation).
Une éventuelle erreur (date invalide, médecin inconnu) est affichée en bas de
la page par le gabarit parent (balise zone_erreurs).
=========================================================================
{% endcomment %}
{% load i18n %}
{% block contenu %}
{# 1. choix du médecin et du jour : un formulaire GET, qui recharge simplement cette page avec ?idMedecin=...&jour=... #}
<form method="get" action="{% url 'agenda' %}" class="card p-3 mb-3">
<div class="row g-2 align-items-end">
<div class="col-sm-5">
<label class="form-label" for="idMedecin">{% translate "Médecin" %}</label>
<select id="idMedecin" name="idMedecin" class="form-select" required>
<option value="">{% translate "-- choisir un médecin --" %}</option>
{% for medecin in medecins %}
<option value="{{ medecin.id }}"{% if medecin.selectionne %} selected{% endif %}>{{ medecin.libelle }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-4">
<label class="form-label" for="jour">{% translate "Jour" %}</label>
<input id="jour" name="jour" type="date" class="form-control" value="{{ jour }}" required>
</div>
<div class="col-sm-3">
<button type="submit" class="btn btn-primary w-100">{% translate "Voir l'agenda" %}</button>
</div>
</div>
</form>
{# patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton "Réserver" #}
{% if info %}
<div class="alert alert-info">
<span>{{ info }}</span>
<a href="{% url 'espace' %}" class="alert-link">{% translate "Mon espace" %}</a>
</div>
{% endif %}
{# 2. l'agenda #}
{% if agenda %}
<div class="card p-3">
<h5>{% translate "Agenda du" %} {{ agenda.jour }} - {{ agenda.medecin }}</h5>
{% if agenda.lignes %}
<table class="table table-hover align-middle">
<thead>
<tr>
<th>{% translate "Créneau" %}</th>
<th>{% translate "Statut" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ligne in agenda.lignes %}
<tr{% if ligne.reservable %} class="creneau-libre"{% endif %}>
<td>{{ ligne.horaire }}</td>
<td>
{% comment %}
[patient] n'est renseigné que si l'utilisateur a le droit de voir ce
nom (ADMIN, DOCTOR, ou le patient pour SON rendez-vous) - sinon,
simplement « Réservé »
{% endcomment %}
{% if ligne.libre %}
<span class="badge text-bg-success">{% translate "Libre" %}</span>
{% elif ligne.patient %}
<span class="badge {% if ligne.moi %}text-bg-primary{% else %}text-bg-secondary{% endif %}">{{ ligne.patient }}</span>
{% else %}
<span class="badge text-bg-secondary">{% translate "Réservé" %}</span>
{% endif %}
</td>
<td>
{% comment %}
les boutons affichés ont été décidés par la vue (reservable, id_rv)
selon le rôle de l'utilisateur. "Réserver" est un simple lien : il
rouvre cette page avec la fenêtre de réservation de ce créneau
{% endcomment %}
{% if ligne.reservable %}
<a class="btn btn-sm btn-outline-success" href="{{ ligne.url_reserver }}">{% translate "Réserver" %}</a>
{% endif %}
{# "Annuler" MODIFIE des données : c'est donc un POST #}
{% if ligne.id_rv %}
<form method="post" action="{% url 'agenda_annuler' %}" class="m-0"
data-confirmation="{% translate 'Annuler ce rendez-vous ?' %}">
{% csrf_token %}
<input type="hidden" name="idRv" value="{{ ligne.id_rv }}">
<input type="hidden" name="idMedecin" value="{{ id_medecin }}">
<input type="hidden" name="jour" value="{{ jour }}">
<button type="submit" class="btn btn-sm btn-outline-danger">{% translate "Annuler" %}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted mb-0">{% translate "Ce médecin n'a aucun créneau horaire." %}</p>
{% endif %}
</div>
{% else %}
<p class="text-muted">{% translate "Choisissez un médecin et un jour, puis cliquez sur « Voir l'agenda »." %}</p>
{% endif %}
{# 3. la fenêtre de réservation, seulement si l'URL contient &reserver=... #}
{% if reservation %}
{% include "fragments/reservation.html" %}
{% endif %}
{% endblock %}
|
La page principale. Trois blocs : le choix du médecin et du jour, l'agenda, la fenêtre de réservation.
- lignes 17-36 : le choix du médecin et du jour : un formulaire GET (exemple 17), qui recharge la page avec [?idMedecin=...&jour=...] dans l'URL. Le jour est un champ [type="date"] : le navigateur affiche un calendrier, et envoie la date au format [aaaa-mm-jj], quelle que soit la façon dont il l'affiche ([30/09/2026] en français) ; la valeur initiale ([value]) est écrite dans ce même format ;
- lignes 39-44 : pour un patient qui a déjà un rendez-vous à venir, pourquoi il n'y a pas de bouton Réserver, avec un lien vers Mon espace ;
- lignes 70-76 : l'état du créneau : Libre (en vert), le nom du patient (en bleu pour le rendez-vous du patient connecté lui-même, en gris sinon), ou « Réservé ». Le gabarit ne décide de rien : [ligne.patient] n'est renseigné que si l'utilisateur a le droit de voir ce nom ;
- lignes 84-86 : Réserver est un simple lien : il rouvre la page, avec la fenêtre de réservation de ce créneau ([ligne.url_reserver]). Il ne modifie rien : un GET suffit ;
- lignes 88-97 : Annuler modifie des données : c'est un formulaire POST (exemple 21), avec son jeton anti-CSRF, le rendez-vous à annuler, et de quoi revenir à la même page (le médecin, le jour). L'attribut [data-confirmation] fait demander une confirmation par le script de la page ; son texte est traduit par [{% translate %}], écrit entre guillemets simples à l'intérieur de l'attribut, lui-même entre guillemets doubles ;
- lignes 112-114 : la fenêtre de réservation, si l'URL la demande (et si le créneau est réservable).
11.18.3.3. templates/personnes/liste.html
| {% extends "layout.html" %}
{% comment %}
=========================================================================
templates/personnes/liste.html - la liste des médecins OU des clients (ADMIN).
Un seul gabarit pour les deux : les textes propres à chacun arrivent dans
[textes], et [section] ("medecins" ou "clients") donne le début des noms des
routes (cf. web/vues/personnes.py).
=========================================================================
{% endcomment %}
{% load i18n %}
{% block contenu %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">{{ textes.titre }}</h4>
<a class="btn btn-primary" href="{{ url_ajout }}">{{ textes.ajouter }}</a>
</div>
<div class="card p-3">
{% if personnes %}
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>{% translate "Titre" %}</th>
<th>{% translate "Prénom" %}</th>
<th>{% translate "Nom" %}</th>
<th>{% translate "Login" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{# personne.compte.login : le compte, chargé par la même requête (select_related) #}
{% for personne in personnes %}
<tr>
<td>{{ personne.titre }}</td>
<td>{{ personne.prenom }}</td>
<td>{{ personne.nom }}</td>
<td><code>{{ personne.compte.login }}</code></td>
<td class="text-end">
<div class="d-inline-flex gap-2">
{# section|add:"_modification" : "medecins_modification", le nom de la route #}
<a class="btn btn-sm btn-outline-primary"
href="{% url section|add:'_modification' personne.id %}">{% translate "Modifier" %}</a>
{# data-confirmation : une fenêtre de confirmation s'ouvrira avant l'envoi du formulaire (cf. static/js/app.js) #}
<form method="post" action="{% url section|add:'_supprimer' personne.id %}" class="m-0"
data-confirmation="{{ textes.confirmer_suppression }}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger">{% translate "Supprimer" %}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted mb-0">{{ textes.vide }}</p>
{% endif %}
</div>
{% endblock %}
|
La liste des médecins ou des clients : les textes propres à chacun arrivent dans la variable [textes], et les noms des routes sont fabriqués à partir du nom de la section : [section|add:'_modification'] donne [medecins_modification] ou [clients_modification] (le filtre [add] additionne deux nombres, ou, comme ici, concatène deux chaînes). [{% url %}] accepte ainsi, comme nom de route, une expression calculée, suivie des paramètres de la route ([personne.id]). [personne.compte.login] : le login du compte, chargé avec la liste (couche DAO), et rangé avec elle dans le cache. Le bouton Supprimer est un formulaire POST, avec confirmation.
| {% extends "layout.html" %}
{% comment %}
=========================================================================
templates/personnes/formulaire.html - création OU modification d'un médecin ou
d'un client (ADMIN), avec le formulaire PersonneForm.
Quand la saisie est refusée (champ vide, login déjà pris...), la vue réaffiche
CETTE page, et le gabarit montre :
- les valeurs déjà saisies (sauf le mot de passe) ;
- les champs fautifs encadrés en rouge (classe Bootstrap "is-invalid") ;
- sous un champ refusé par la validation, son message ;
- le message d'erreur de la page, EN BAS de la page (balise zone_erreurs).
Le formulaire est envoyé à l'URL de la page elle-même (pas d'attribut action).
=========================================================================
{% endcomment %}
{% load i18n %}
{% block contenu %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card p-4">
<h5 class="card-title mb-3">{{ titre_page }}</h5>
<form method="post" novalidate>
{% csrf_token %}
{# titre, prénom, nom : un fragment commun à trois formulaires #}
{% include "macros.html#identite" %}
<h6 class="mt-4 text-muted">{% translate "Compte utilisateur" %}</h6>
{{ formulaire.login.as_field_group }}
{# jamais pré-rempli : le mot de passe ne repart jamais vers le navigateur #}
{{ formulaire.password.as_field_group }}
{# en modification seulement : la version de la fiche, dans un champ caché (le verrou optimiste) #}
{% if formulaire.version %}{{ formulaire.version }}{% endif %}
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">{% translate "Enregistrer" %}</button>
<a class="btn btn-secondary" href="{{ url_liste }}">{% translate "Retour à la liste" %}</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
|
La création ou la modification d'un médecin ou d'un client, avec le formulaire [PersonneForm].
- ligne 24 : un formulaire POST sans attribut [action] : il est envoyé à l'URL de la page elle-même ([/medecins/ajouter], ou [/medecins/3/modifier]). [novalidate] désactive les contrôles du navigateur ([required], [maxlength]...) à l'envoi du formulaire : c'est le serveur qui valide, et c'est lui qui fait foi (exemple 19) ;
- ligne 27 : titre, prénom et nom, par le fragment [identite] ;
- lignes 30-32 : le login, et le mot de passe, jamais pré-rempli. Leurs aides diffèrent en création et en modification (le constructeur du formulaire) ;
- ligne 34 : en modification seulement, le champ caché de la version (le verrou optimiste). [{{ formulaire.version }}] écrit la zone de saisie seule, sans libellé : [<input type="hidden" name="version" value="1" id="id_version">]. En création, le formulaire n'a pas de champ [version] : la variable est vide, et le test est faux.
Les erreurs du formulaire lui-même, qui ne concernent aucun champ ([non_field_errors], exemple 19), n'ont pas à être affichées : le formulaire n'en produit pas, et un jeton anti-CSRF faux est refusé avant la vue (la page 419).
11.18.3.5. templates/inscription.html
| {% extends "layout.html" %}
{% comment %}
=========================================================================
templates/inscription.html - "Créer un compte client" (page publique, cf.
web/vues/inscription.py), avec le formulaire InscriptionForm. Mêmes champs
d'identité que le formulaire de l'ADMIN (fragment identite), mais le login et le
mot de passe sont obligatoires, avec confirmation. Pas de captcha ICI : on
n'arrive sur cette page qu'après avoir recopié celui de la page
d'authentification (bouton "Créer un compte client").
=========================================================================
{% endcomment %}
{% load i18n %}
{% block contenu %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card p-4">
<h5 class="card-title mb-1">{% translate "Créer un compte client" %}</h5>
<p class="text-muted small mb-3">{% translate "Ce compte vous permettra de prendre vous-même vos rendez-vous." %}</p>
<form method="post" action="{% url 'inscription' %}" novalidate>
{% csrf_token %}
{% include "macros.html#identite" %}
<h6 class="mt-4 text-muted">{% translate "Compte utilisateur" %}</h6>
{{ formulaire.login.as_field_group }}
{# le mot de passe et sa confirmation, côte à côte #}
<div class="row g-3">
<div class="col-sm-6">{{ formulaire.password.as_field_group }}</div>
<div class="col-sm-6">{{ formulaire.confirmation.as_field_group }}</div>
</div>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">{% translate "Créer mon compte" %}</button>
<a class="btn btn-secondary" href="{% url 'login' %}">{% translate "Retour à la connexion" %}</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
|
La création d'un compte client : les champs d'identité, puis le login, le mot de passe et sa confirmation, obligatoires, côte à côte (lignes 28-31). L'aide du mot de passe annonce la longueur minimale : « Au moins 6 caractères. ». Pas de captcha ici : on n'arrive sur cette page qu'après avoir recopié celui de la page de connexion.
11.18.3.6. templates/espace.html
| {% extends "layout.html" %}
{% comment %}
=========================================================================
templates/espace.html - "Mon espace" : la page d'accueil du patient (rôle USER),
affichée juste après sa connexion.
- deux options : [Gérer mon compte] et [Voir un agenda] ;
- son rendez-vous à venir, s'il en a un, avec le moyen de l'annuler.
=========================================================================
{% endcomment %}
{% load i18n %}
{% block contenu %}
<h4 class="mb-3">{% blocktranslate %}Bonjour {{ patient }}{% endblocktranslate %}</h4>
<div class="row g-3 mb-4">
<div class="col-sm-6">
<a class="card p-4 h-100 text-decoration-none carte-option" href="{% url 'compte' %}">
<h5 class="mb-1">{% translate "Gérer mon compte" %}</h5>
<span class="text-muted">{% translate "Modifier mes informations, mon mot de passe, ou supprimer mon compte." %}</span>
</a>
</div>
<div class="col-sm-6">
<a class="card p-4 h-100 text-decoration-none carte-option" href="{% url 'agenda' %}">
<h5 class="mb-1">{% translate "Voir un agenda" %}</h5>
<span class="text-muted">{% translate "Consulter l'agenda d'un médecin et réserver un créneau." %}</span>
</a>
</div>
</div>
<div class="card p-3">
<h5>{% translate "Mon rendez-vous" %}</h5>
{% comment %}
normalement un seul (règle "un rendez-vous à venir à la fois"), mais la page
sait en afficher plusieurs. {% for %} ... {% empty %} : une boucle, et ce
qu'on affiche si la liste est vide ; forloop.last : dernier élément ?
{% endcomment %}
{% for rv in rendez_vous %}
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2{% if not forloop.last %} border-bottom{% endif %}">
<div>
<div class="fw-semibold">{{ rv.jour }}, {{ rv.horaire }}</div>
<div class="text-muted">
<span>{{ rv.medecin }}</span> -
<a href="{{ rv.url_agenda }}">{% translate "voir dans l'agenda" %}</a>
</div>
</div>
<form method="post" action="{% url 'espace_annuler' %}" class="m-0"
data-confirmation="{% translate 'Annuler ce rendez-vous ?' %}">
{% csrf_token %}
<input type="hidden" name="idRv" value="{{ rv.id }}">
<button type="submit" class="btn btn-outline-danger">{% translate "Annuler ce rendez-vous" %}</button>
</form>
</div>
{% empty %}
<p class="text-muted mb-0">{% translate "Vous n'avez aucun rendez-vous à venir." %}</p>
{% endfor %}
</div>
{% endblock %}
|
« Mon espace », la page d'accueil du patient : deux grandes cartes (Gérer mon compte, Voir un agenda), et son rendez-vous à venir.
- ligne 13 : [{% blocktranslate %}Bonjour {{ patient }}{% endblocktranslate %}] : un texte traduit qui contient une variable ([Hello %(patient)s] dans le dictionnaire anglais) ;
- lignes 37-55 : [{% for %}] ... [{% empty %}] (exemple 14) est une boucle, suivie de ce qu'il faut afficher si la liste est vide. Normalement, un patient a au plus un rendez-vous à venir ; mais la page sait en afficher plusieurs. [forloop.last] (exemple 14) : le dernier élément n'a pas de trait de séparation ;
- lignes 46-51 : chaque rendez-vous a un formulaire d'annulation (POST, avec confirmation), qui n'envoie que son id : le serveur vérifiera qu'il appartient bien au patient connecté.
11.18.3.7. templates/compte.html
| {% extends "layout.html" %}
{% comment %}
=========================================================================
templates/compte.html - "Gérer mon compte" (rôle USER). Deux formulaires
indépendants sur la même page :
1. la modification (identité, login, nouveau mot de passe facultatif), avec
le formulaire MonCompteForm ;
2. la suppression du compte - qui annule aussi ses rendez-vous : un
formulaire écrit à la main.
L'un comme l'autre exigent le mot de passe ACTUEL (champ current_password).
=========================================================================
{% endcomment %}
{% load i18n %}
{% block contenu %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
{# 1. modification ; novalidate : c'est le SERVEUR qui valide (cf. exemple 19) #}
<div class="card p-4 mb-4">
<h5 class="card-title mb-3">{% translate "Gérer mon compte" %}</h5>
<form method="post" action="{% url 'compte' %}" novalidate>
{% csrf_token %}
{% include "macros.html#identite" %}
<h6 class="mt-4 text-muted">{% translate "Compte utilisateur" %}</h6>
{{ formulaire.login.as_field_group }}
<div class="row g-3">
<div class="col-sm-6">{{ formulaire.password.as_field_group }}</div>
<div class="col-sm-6">{{ formulaire.confirmation.as_field_group }}</div>
</div>
{{ formulaire.current_password.as_field_group }}
{# la version de la fiche : un champ caché (le verrou optimiste) #}
{{ formulaire.version }}
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">{% translate "Enregistrer" %}</button>
<a class="btn btn-secondary" href="{% url 'espace' %}">{% translate "Retour à mon espace" %}</a>
</div>
</form>
</div>
{% comment %}
2. suppression. Le champ du mot de passe a le même NOM que celui du premier
formulaire (current_password), mais un autre id : deux éléments d'une même
page ne peuvent pas avoir le même id
{% endcomment %}
<div class="card p-4 border-danger">
<h5 class="card-title text-danger mb-1">{% translate "Supprimer mon compte" %}</h5>
<p class="text-muted small">{% translate "Votre compte sera définitivement supprimé, ainsi que tous vos rendez-vous." %}</p>
<form method="post" action="{% url 'compte_supprimer' %}" class="d-flex flex-wrap gap-2 align-items-end"
data-confirmation="{% translate 'Supprimer définitivement votre compte ? Votre rendez-vous éventuel sera annulé.' %}">
{% csrf_token %}
<div class="flex-grow-1">
<label class="form-label" for="mot_de_passe_suppression">{% translate "Mot de passe actuel" %}</label>
<input id="mot_de_passe_suppression" name="current_password" type="password" maxlength="72"
autocomplete="current-password" required class="form-control">
</div>
<button type="submit" class="btn btn-danger">{% translate "Supprimer mon compte" %}</button>
</form>
</div>
</div>
</div>
{% endblock %}
|
« Gérer mon compte » : deux formulaires indépendants sur une même page.
- lignes 20-42 : la modification, avec le formulaire [MonCompteForm] : les champs d'identité, le login, le nouveau mot de passe et sa confirmation (facultatifs), le mot de passe actuel, obligatoire, et le champ caché de la version ;
- lignes 49-62 : la suppression du compte, un formulaire écrit à la main, avec son propre champ de mot de passe actuel, et une confirmation. Le champ a le même nom que celui du premier formulaire ([current_password]), mais un autre id : deux éléments d'une page ne peuvent pas avoir le même id, que l'attribut [for] d'un libellé désigne. Les deux formulaires sont indépendants : le navigateur n'envoie que les champs du formulaire dont on a cliqué le bouton.
11.18.3.8. templates/erreur.html
| {% extends "layout.html" %}
{% comment %}
templates/erreur.html - la page d'erreur (400, 403, 404, 405, 419, 500...),
fabriquée par web/vues/erreurs.py.
{% endcomment %}
{% load i18n %}
{% block contenu %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card p-4 text-center">
<h1 class="display-4 text-danger">{{ code }}</h1>
<p class="lead">{{ message }}</p>
<div><a class="btn btn-primary" href="{% url 'accueil' %}">{% translate "Retour à l'accueil" %}</a></div>
</div>
</div>
</div>
{% endblock %}
|
La page d'erreur, fabriquée par [web/vues/erreurs.py] : le code HTTP, le message, et un lien vers l'accueil ([/], qui redirige vers la page d'accueil du rôle, ou vers la page de connexion). Elle étend le gabarit parent comme les autres pages : la barre de navigation, avec le menu de l'utilisateur, y figure aussi.
11.19. Les fichiers statiques
Le dossier [static] contient les fichiers que le serveur envoie tels quels (exemple 15) : [Bootstrap] ([lib/bootstrap], la feuille de style seule, et sa licence), une feuille de style et un script. En développement, le serveur de [Django] les sert lui-même (application [django.contrib.staticfiles]) ; en production, ils sont recopiés dans [var/static] par la commande [collectstatic], et servis par le serveur web (« Un serveur de production »).
11.19.1. static/css/styles.css
| /* =========================================================================
static/css/styles.css - quelques réglages en plus de Bootstrap
(les mêmes que dans les autres versions de cette application).
========================================================================= */
body {
background-color: #f5f7fa;
}
.creneau-libre:hover {
background-color: #e9f7ef;
}
/* -------------------------------------------------------------------------
.zone-erreur : les messages d'erreur, en bas de page, sur fond rose léger
(cf. templates/fragments/erreurs.html).
position: sticky + bottom : si la page est plus haute que la fenêtre (un
agenda de 12 créneaux, par exemple), la zone reste collée au bas de la
fenêtre au lieu de disparaître sous la ligne de flottaison - sans jamais
recouvrir le contenu quand la page est courte.
z-index : au-dessus du fond grisé de la fenêtre de réservation (.modal-backdrop).
------------------------------------------------------------------------- */
.zone-erreur {
position: sticky;
bottom: 1rem;
z-index: 1060;
margin-top: 1.5rem;
padding: 0.75rem 1rem;
background-color: #fdecef; /* rose léger */
border: 1px solid #f5c2c7;
border-radius: 0.375rem;
color: #842029;
}
/* les deux grandes options de la page "Mon espace" du patient */
.carte-option {
transition: border-color 0.15s, box-shadow 0.15s;
}
.carte-option:hover {
border-color: #0d6efd;
box-shadow: 0 0.25rem 0.75rem rgba(13, 110, 253, 0.15);
}
/* l'image du captcha (SVG de 150 x 50 pixels) */
.captcha-image svg {
display: block;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
}
|
Quelques réglages en plus de [Bootstrap], les mêmes que dans les autres versions de l'application.
- lignes 10-12 : une ligne réservable de l'agenda se colore en vert au passage de la souris ;
- lignes 23-33 : la zone d'erreur, en bas de la page, sur fond rose léger. [position: sticky] avec [bottom: 1rem] : si la page est plus haute que la fenêtre (un agenda de 12 créneaux), la zone reste collée au bas de la fenêtre, au lieu de disparaître sous la ligne de flottaison ; si la page est courte, elle reste à sa place, sans rien recouvrir. [z-index] : elle passe au-dessus du fond grisé de la fenêtre de réservation ;
- lignes 36-43 : les deux grandes cartes de « Mon espace », qui s'éclairent au passage de la souris ;
- lignes 46-50 : l'image du captcha, avec une bordure.
11.19.2. static/js/app.js
| // =========================================================================
// static/js/app.js - le SEUL JavaScript de l'application.
// -------------------------------------------------------------------------
// Toute l'application fonctionne sans JavaScript. Ce script ajoute un
// confort : avant d'envoyer un formulaire qui porte l'attribut
// data-confirmation="...", une fenêtre modale demande "Êtes-vous sûr ?"
// (la fenêtre elle-même est dans la page : templates/fragments/confirmation.html).
// Si le JavaScript est désactivé, le formulaire part simplement sans
// confirmation - rien ne casse (principe d'"amélioration progressive").
//
// Pourquoi pas window.confirm() ? Sa boîte ne se met pas en forme, et ses
// boutons restent dans la langue du navigateur, pas dans celle de la page.
// =========================================================================
(() => {
const modale = document.getElementById('modale-confirmation');
if (!modale) {
return;
}
const message = document.getElementById('modale-confirmation-message');
const boutonNon = modale.querySelector('.modal-footer [data-action="non"]');
let formulaireEnAttente = null; // le formulaire à envoyer si l'on confirme
let focusPrecedent = null; // l'élément à qui rendre le focus à la fermeture
function ouvrir(formulaire) {
formulaireEnAttente = formulaire;
focusPrecedent = document.activeElement;
// textContent (et non innerHTML) : le message est affiché comme du TEXTE,
// jamais interprété comme du HTML
message.textContent = formulaire.dataset.confirmation;
modale.classList.remove('d-none');
// le focus va sur "Non" : un appui distrait sur Entrée ne supprime rien
boutonNon.focus();
}
function fermer() {
modale.classList.add('d-none');
formulaireEnAttente = null;
if (focusPrecedent) {
focusPrecedent.focus();
}
}
// 1. on intercepte l'envoi des formulaires à confirmer
document.querySelectorAll('form[data-confirmation]').forEach((formulaire) => {
formulaire.addEventListener('submit', (evenement) => {
evenement.preventDefault();
ouvrir(formulaire);
});
});
// 2. les boutons de la fenêtre (Oui / Non / croix de fermeture)
modale.addEventListener('click', (evenement) => {
const action = evenement.target.closest('[data-action]')?.dataset.action;
if (action === 'oui') {
const formulaire = formulaireEnAttente;
fermer();
// formulaire.submit() envoie le formulaire SANS redéclencher
// l'événement 'submit' : pas de nouvelle demande de confirmation
formulaire.submit();
} else if (action === 'non') {
fermer();
} else if (evenement.target.classList.contains('modal')) {
fermer(); // clic sur le fond grisé, en dehors de la fenêtre
}
});
// 3. clavier : Échap ferme la fenêtre ; Tab reste DANS la fenêtre
// (sinon le focus partirait sur la page grisée, derrière)
modale.addEventListener('keydown', (evenement) => {
if (evenement.key === 'Escape') {
fermer();
} else if (evenement.key === 'Tab') {
const boutons = [...modale.querySelectorAll('button')];
const premier = boutons[0];
const dernier = boutons[boutons.length - 1];
if (evenement.shiftKey && document.activeElement === premier) {
evenement.preventDefault();
dernier.focus();
} else if (!evenement.shiftKey && document.activeElement === dernier) {
evenement.preventDefault();
premier.focus();
}
}
});
})();
|
Le seul [JavaScript] de l'application, celui de l'exemple 21, un peu complété. Toute l'application fonctionne sans [JavaScript] : ce script ajoute un confort, une confirmation avant l'envoi d'un formulaire qui porte l'attribut [data-confirmation]. Si le [JavaScript] est désactivé, le formulaire part sans confirmation : rien ne casse (principe d'amélioration progressive).
- lignes 15-19 : le script est une fonction appelée aussitôt définie ([(() => { ... })()]) : ses variables ne sont pas visibles du reste de la page. Sans fenêtre de confirmation dans la page, il ne fait rien ;
- lignes 26-35 : l'ouverture de la fenêtre : le formulaire à envoyer si l'on confirme est mémorisé, et le message du formulaire est placé dans la fenêtre. [textContent], et non [innerHTML] : le message est affiché comme du texte, jamais interprété comme du HTML. Le focus va sur le bouton « Non » : un appui distrait sur Entrée ne supprime rien ;
- lignes 37-43 : la fermeture ; le focus revient à l'élément qui l'avait avant ;
- lignes 46-51 : l'envoi de chaque formulaire à confirmer est intercepté ([preventDefault] l'empêche), et la fenêtre s'ouvre. L'événement [submit] n'a lieu qu'après les contrôles du navigateur : le mot de passe actuel, obligatoire ([required]) dans le formulaire de suppression du compte, est exigé avant la question ;
- lignes 54-67 : les boutons de la fenêtre. Un seul gestionnaire pour toute la fenêtre : [closest('[data-action]')] trouve le bouton cliqué, ou l'élément qui le contient ; [?.] rend [undefined] sans erreur si aucun ne convient. « Oui » envoie le formulaire par [formulaire.submit()], qui ne déclenche pas l'événement [submit] : pas de nouvelle demande de confirmation. Un clic sur le fond grisé, en dehors de la fenêtre, la ferme ;
- lignes 71-86 : le clavier. Échap ferme la fenêtre ; la touche Tab fait passer le focus d'un bouton à l'autre sans sortir de la fenêtre (sinon, il partirait sur la page grisée, derrière elle) : c'est ce qu'attendent les utilisateurs qui naviguent au clavier, et les lecteurs d'écran.