Skip to content

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 [Flask], de l'application [Spring MVC] du cours précédent (elle-même transposée de la version [ASP.NET Core MVC]) : même base de données, mêmes règles de gestion, mêmes pages, mêmes adresses, mêmes dictionnaires de traduction. Les pages HTML sont fabriquées par le serveur, avec des vues [Jinja2] ; l'accès à la base passe par [SQLAlchemy].

Trois rôles d'utilisateurs se partagent l'application :

Rôle
Qui
Ce qu'il peut faire
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

Image

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 :

Image

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

Image

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 :

Image

Image

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=4], le numéro du créneau choisi :

Image

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

Image

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

Image

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 :

Image

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 :

Image

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

Image

Image

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

Image

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 :

Image

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 :

Image

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 :

Image

11.2.4. La gestion des médecins et des clients

L'administrateur a deux menus supplémentaires, Médecins et Clients :

Image

Image

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

Image

Un homonyme d'un médecin existant est refusé, même écrit en minuscules (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) :

Image

La suppression d'un client demande une confirmation :

Image

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

Image

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

Image

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 :

Image

11.3. Installation et exécution

L'application se trouve dans le dossier [rdvmedecins2-flask], à côté du dossier [exemples]. C'est un projet autonome, avec ses propres fichiers [requirements.txt] et 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 (l'application utilise la syntaxe des classes génériques apparue avec la version 3.12). On vérifie l'installation dans un terminal :
python --version
Python 3.13.7
  • un serveur [MySQL] 8, par exemple celui de [Laragon] sous [Windows] (cf. annexes). [MariaDB] convient aussi : l'application a été testée avec lui.

Les bibliothèques sont téléchargées par [pip] depuis le dépôt [PyPI] (*Python Package Index*) : il faut donc être connecté à Internet lors de leur installation. L'éditeur conseillé est [Visual Studio Code], avec l'extension [Python] de Microsoft (cf. annexes) ; mais tout peut se faire en ligne de commande.

11.3.2. 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. 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 versions [ASP.NET Core] et [Spring MVC] : 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.

11.3.3. Configurer l'accès à la base

Toute la configuration est dans le fichier [config.py] (commenté plus loin). Par défaut, l'application se connecte à [MySQL] sur [localhost], 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, le plus simple est de l'écrire dans ce fichier, à la ligne [DB_PASSWORD = ""]. Pour ne pas écrire de mot de passe dans un fichier du projet, on peut aussi le donner par une variable d'environnement, qui l'emporte sur le fichier. Son nom est celui de la variable du fichier, précédé de [FLASK_] (exemple 25) :

1
2
3
export FLASK_DB_PASSWORD=monMotDePasse           # Linux, macOS
set FLASK_DB_PASSWORD=monMotDePasse              # Windows (cmd)
$env:FLASK_DB_PASSWORD="monMotDePasse"           # Windows (PowerShell)

La variable ne vaut que pour le terminal où elle a été définie : on y lance ensuite l'application.

11.3.4. Lancer l'application

Dans un terminal ouvert sur le dossier [rdvmedecins2-flask], l'environnement virtuel créé et activé, et les bibliothèques installées (cf. introduction) :

python app.py

L'introduction a montré la sortie de la console. On ouvre ensuite l'adresse [http://localhost:8081] dans un navigateur, et l'on arrête l'application par [Ctrl-C].

11.3.5. 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 :

Logins
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é » ; 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.6. Le mode développement

Le fichier [app.py] lance l'application avec le profil [dev] (le fichier [config_dev.py], plus loin) : la console affiche alors chaque ordre SQL envoyé à la base par [SQLAlchemy]. Le mode debug du serveur de développement ajoute deux facilités :

  • le rechargement automatique : l'application redémarre toute seule dès qu'un fichier [.py] est modifié. Une vue [templates/*.html] modifiée est, elle, relue au prochain affichage de la page ;
  • la page de débogage d'une exception (exemple 03), ici désactivée par le réglage [PROPAGATE_EXCEPTIONS = False] (plus loin) : l'application affiche sa propre page d'erreur, même en développement, et la pile d'appels de l'exception est écrite dans la console.

Le débogueur de [Visual Studio Code] permet en outre d'exécuter l'application pas à pas : on pose un point d'arrêt d'un clic dans la marge d'une ligne de code, puis on lance le fichier [app.py] par le menu Exécuter / Démarrer le débogage (touche [F5]), en choisissant « Python Debugger » puis « Python File ». Le rechargement automatique est alors à éviter (il lance l'application dans un second processus, que le débogueur ne suit pas) : on le désactive en lançant l'application par la commande [flask --app rdvmedecins run --port 8081] (sans [--debug]).

11.3.7. Un serveur de production

Le serveur de développement de [Werkzeug] n'est pas fait pour la production : il le dit lui-même au démarrage. En production, on confie l'application à un serveur WSGI (chapitre sur le cycle de vie). L'application en déclare un dans son fichier [requirements.txt] : [Waitress], un serveur écrit en [Python], qui fonctionne aussi bien sous [Windows] que sous [Linux]. Il est déjà installé dans l'environnement virtuel :

waitress-serve --port=8081 --call rdvmedecins:create_app
INFO:waitress:Serving on http://0.0.0.0:8081

L'option [--call] indique une fabrique à appeler ([create_app], dans le paquet [rdvmedecins]) pour obtenir l'application. Lancée ainsi, l'application n'a pas le profil [dev] (la console n'affiche pas les ordres SQL), ni le mode debug. [Waitress] traite plusieurs requêtes en parallèle, sur plusieurs threads (quatre par défaut, option [--threads]). Sous [Linux], on utilise aussi beaucoup [Gunicorn], qui ne fonctionne pas sous [Windows]. Dans les deux cas, le serveur WSGI est généralement placé derrière un serveur web ([nginx], [Apache]), qui sert en HTTPS et transmet les requêtes à l'application.

En production, il faut aussi changer les clés secrètes, et déclarer que le site est servi en HTTPS, par des variables d'environnement :

1
2
3
export FLASK_SECRET_KEY="<une longue chaîne aléatoire>"
export FLASK_JWT_SECRET="<une autre longue chaîne aléatoire>"
export FLASK_COOKIE_SECURE=true

Une chaîne aléatoire convenable s'obtient avec [Python] lui-même : [python -c "import secrets; print(secrets.token_urlsafe(48))"].

11.3.8. Les problèmes fréquents

Au démarrage, l'application lit les listes des médecins et des clients : si la base est inaccessible, l'application s'arrête aussitôt, avec l'erreur dans la console. Cette erreur est précédée d'une longue pile d'appels ; le message utile est à la fin, avant la ligne [(Background on this error at: ...)]. Par exemple, avec une base qui n'existe pas :

1
2
3
...
sqlalchemy.exc.OperationalError: (pymysql.err.OperationalError) (1049, "Unknown database 'dbinconnue'")
(Background on this error at: https://sqlalche.me/e/21/e3q8)

Les messages les plus fréquents :

Message
Cause
[Can't connect to MySQL server on 'localhost' ([Errno 111] Connection refused)] ([[WinError 10061]] sous [Windows])
[MySQL] n'est pas démarré, ou n'écoute pas sur [localhost:3306]
[Access denied for user 'root'@'localhost']
le mot de passe de [MySQL] est à indiquer (cf. « Configurer l'accès à la base »)
[Unknown database 'dbrdvmedecins2']
le script [database/dbrdvmedecins2.sql] n'a pas été exécuté
[JWT_SECRET doit comporter au moins 32 caractères (cf. config.py).]
la clé secrète des jetons est trop courte dans [config.py] (ou dans la variable [FLASK_JWT_SECRET])
[ModuleNotFoundError: No module named 'flask']
l'environnement virtuel n'est pas activé, ou les bibliothèques n'y ont pas été installées
[Address already in use] ou [Port 8081 is in use by another program]
le port 8081 est déjà pris (la version [Spring MVC] tourne-t-elle ?) : l'arrêter, ou changer le port dans [app.py]
['cryptography' package is required for sha256_password or caching_sha2_password auth methods]
le compte [MySQL] utilise l'authentification de [MySQL] 8 : la bibliothèque [cryptography], installée par [PyMySQL[rsa]] (cf. [requirements.txt]), manque
[SyntaxError] au démarrage, sur une ligne [class ...[T: ...]]
la version de [Python] est antérieure à 3.12

11.4. L'architecture de l'application

Image

  • le navigateur envoie ses requêtes au serveur (le serveur de développement de [Werkzeug], ou [Waitress]), qui les confie à l'application [Flask]. Chacune passe d'abord par les fonctions [before_request] (chapitre sur le cycle de vie) : la langue de la requête, puis l'utilisateur, établi à partir du jeton JWT de son cookie, et la règle d'accès, qui refuse les pages protégées aux visiteurs qui n'y ont pas droit ([auth/securite.py]) ;
  • [controllers] : les contrôleurs de la couche web, un blueprint par partie de l'application. Ils passent tous par un objet unique, [ApplicationModel] (dossier [web]), qui garde en mémoire les listes de médecins et de clients et relaie les autres demandes à la couche métier ;
  • [auth] : la connexion, les jetons JWT, le captcha, la limitation des tentatives, le laissez-passer de création de compte ;
  • [web] : ce que la couche web a en commun : le cache et la façade [ApplicationModel], la traduction, les messages flash, la validation, les variables communes à toutes les vues, les pages d'erreur ; [forms] : les formulaires [WTForms] ; [models] : les modèles des vues ;
  • [metier] : les règles de gestion et les transactions ;
  • [repositories] : la couche DAO, une classe par table ;
  • [entities] : les entités, que [SQLAlchemy] lit et écrit dans la base ; [domain] : les objets de l'agenda, calculés par la couche métier ;
  • [templates] : les vues [Jinja2], qui fabriquent les pages HTML ; [i18n] : les dictionnaires, utilisés par le traducteur ; [static] : la feuille de style, le script de la page et [Bootstrap] ;
  • [rdvmedecins/__init__.py] : la fabrique [create_app], qui assemble le tout ; [config.py] : la configuration.

Le dossier de l'application :

rdvmedecins2-flask/
├── app.py
├── config.py
├── config_dev.py
├── database/
│   ├── dbrdvmedecins2.sql
│   └── README.md
├── rdvmedecins/
│   ├── __init__.py
│   ├── auth/
│   │   ├── auth_service.py
│   │   ├── captcha.py
│   │   ├── dessin_captcha.py
│   │   ├── limiteur.py
│   │   ├── mots_de_passe.py
│   │   ├── passe_inscription.py
│   │   ├── README.md
│   │   ├── securite.py
│   │   └── utilisateur.py
│   ├── controllers/
│   │   ├── agenda.py
│   │   ├── auth.py
│   │   ├── clients.py
│   │   ├── espace.py
│   │   ├── inscription.py
│   │   ├── langue.py
│   │   ├── medecins.py
│   │   ├── personnes.py
│   │   └── README.md
│   ├── domain/
│   │   ├── agenda.py
│   │   └── README.md
│   ├── entities/
│   │   ├── base.py
│   │   ├── creneau.py
│   │   ├── personne.py
│   │   ├── README.md
│   │   ├── role.py
│   │   ├── rv.py
│   │   └── user.py
│   ├── forms/
│   │   ├── formulaires.py
│   │   ├── README.md
│   │   └── regles.py
│   ├── i18n/
│   │   ├── en.json
│   │   └── fr.json
│   ├── metier/
│   │   ├── dates.py
│   │   ├── donnees.py
│   │   ├── exception.py
│   │   ├── metier.py
│   │   ├── README.md
│   │   └── service.py
│   ├── models/
│   │   ├── modeles_vues.py
│   │   └── README.md
│   ├── repositories/
│   │   ├── client_repository.py
│   │   ├── creneau_repository.py
│   │   ├── medecin_repository.py
│   │   ├── personne_repository.py
│   │   ├── README.md
│   │   ├── rv_repository.py
│   │   └── user_repository.py
│   ├── static/
│   │   ├── css/
│   │   │   └── styles.css
│   │   ├── js/
│   │   │   └── app.js
│   │   └── lib/
│   │       └── bootstrap/
│   ├── templates/
│   │   ├── agenda.html
│   │   ├── compte.html
│   │   ├── erreur.html
│   │   ├── espace.html
│   │   ├── fragments/
│   │   │   ├── captcha.html
│   │   │   ├── champs_personne.html
│   │   │   ├── confirmation.html
│   │   │   ├── erreurs.html
│   │   │   ├── flash.html
│   │   │   ├── navbar.html
│   │   │   └── reservation.html
│   │   ├── inscription.html
│   │   ├── layout.html
│   │   ├── login.html
│   │   ├── personnes/
│   │   │   ├── formulaire.html
│   │   │   └── liste.html
│   │   └── README.md
│   └── web/
│       ├── application_model.py
│       ├── erreurs.py
│       ├── flash.py
│       ├── modele_commun.py
│       ├── outils.py
│       ├── README.md
│       ├── services.py
│       ├── traducteur.py
│       └── validation.py
├── README.md
└── requirements.txt

Chaque dossier de paquet contient aussi un fichier [__init__.py], le plus souvent vide, qui fait du dossier un paquet [Python] : c'est ce qui permet d'écrire [from rdvmedecins.entities import Medecin]. [Bootstrap] fait cette fois partie de l'application (dossier [static/lib/bootstrap]) : l'étude de cas est autonome.

Remarquons le choix fait pour le paquet [rdvmedecins] : l'application n'est plus un simple module [app.py], mais un paquet, dont le fichier [__init__.py] contient la fabrique [create_app]. C'est la structure recommandée par la documentation de [Flask] pour une application de cette taille : le fichier [app.py], à la racine, ne sert qu'à lancer le serveur de développement.

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-54 : 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-71 : 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 réservé de 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-88 : la table des clients a exactement les mêmes colonnes ;
  • lignes 94-102 : un créneau appartient à un médecin ; ses heures de début et de fin sont rangées en heures et minutes ;
  • lignes 108-117 : 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). Viennent ensuite le sel et le hachage ;
  • 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é.

11.6. La configuration du projet

11.6.1. requirements.txt

# les bibliothèques de l'application : pip install -r requirements.txt
Flask==3.1.3
Flask-WTF==1.3.0
SQLAlchemy==2.1.1
PyMySQL[rsa]==1.2.3
PyJWT==2.15.0
bcrypt==5.0.0
Babel==2.18.0
# un serveur WSGI pour la production (cf. README.md)
waitress==3.0.2

Les bibliothèques de l'application, avec leur version exacte ([==]). Fixer les versions garantit que l'application fonctionnera de la même façon sur toutes les machines, et dans un an. [PyMySQL[rsa]] installe le pilote [PyMySQL] avec ses dépendances optionnelles [rsa], c'est-à-dire la bibliothèque [cryptography], nécessaire pour les méthodes d'authentification de [MySQL] 8. [pip install -r requirements.txt] installe ces bibliothèques et celles dont elles dépendent ([Werkzeug], [Jinja2], [WTForms]...). La commande [pip freeze] affiche la liste complète de ce qui est installé dans l'environnement virtuel.

11.6.2. config.py

# =========================================================================
# config.py - la configuration de l'application.
#
# Flask ne retient que les variables en MAJUSCULES (cf. rdvmedecins/__init__.py,
# app.config.from_pyfile). Chaque valeur peut être remplacée, sans modifier ce
# fichier, par une variable d'environnement préfixée par FLASK_ :
# DB_PASSWORD s'écrit alors FLASK_DB_PASSWORD (app.config.from_prefixed_env).
# Les valeurs de ces variables sont lues comme du JSON quand c'est possible :
# FLASK_LOGIN_MAX_ECHECS=3 donne le nombre 3, FLASK_COOKIE_SECURE=true le booléen True.
# =========================================================================

# ---- la base MySQL [dbrdvmedecins2] (script : database/dbrdvmedecins2.sql) :
# le serveur, son port, l'utilisateur et son mot de passe (ceux de Laragon :
# root, sans mot de passe), la base
DB_HOST = "localhost"
DB_PORT = 3306
DB_USER = "root"
DB_PASSWORD = ""
DB_NAME = "dbrdvmedecins2"

# True : les ordres SQL envoyés à la base sont affichés dans la console
SQL_ECHO = False

# ---- la clé secrète de Flask : elle SIGNE le cookie de session (messages
# flash, jeton anti-CSRF). À changer impérativement en production.
SECRET_KEY = "change-moi-aussi-en-une-longue-chaine-aleatoire"

# ---- l'authentification (cf. rdvmedecins/auth/)
# clé secrète des jetons JWT : 32 caractères au moins (HMAC-SHA256).
# À changer impérativement en production (longue chaîne aléatoire).
JWT_SECRET = "change-moi-en-une-longue-chaine-aleatoire"
# durée de validité d'un jeton, en secondes (7200 s = 2 h) ; le cookie qui
# transporte le jeton a la même durée de vie
JWT_EXPIRES_IN_SECONDS = 7200
# True en production (site servi en HTTPS) : les cookies de l'application ne
# sont alors jamais envoyés sur une connexion non chiffrée
COOKIE_SECURE = False

# ---- la protection contre les robots (cf. rdvmedecins/auth/limiteur.py)
# connexion : échecs autorisés pour un même login depuis une même adresse IP...
LOGIN_MAX_ECHECS = 5
# ... avant un blocage de cette durée (l'adresse IP entière est aussi bloquée
# si elle cumule 5 fois plus d'échecs, tous logins confondus)
LOGIN_BLOCAGE_MINUTES = 15
# création de compte client : comptes 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

La configuration de l'application, lue par [app.config.from_pyfile] (exemple 25) :

  • lignes 15-19 : l'accès à la base : le serveur, son port, l'utilisateur et son mot de passe, la base. Ces valeurs servent à construire l'URL de connexion de [SQLAlchemy] (plus loin) ;
  • ligne 22 : l'affichage des ordres SQL, désactivé par défaut ;
  • ligne 26 : la clé secrète de [Flask], qui signe le cookie de session (exemple 20) ;
  • lignes 31-34 : la clé qui signe les jetons JWT, et leur durée de vie : deux heures ;
  • ligne 37 : à [True] en production, les cookies de l'application portent l'attribut [Secure] : le navigateur ne les envoie que sur une connexion HTTPS ;
  • lignes 41-48 : les réglages de la protection contre les robots : le nombre d'échecs de connexion autorisés et la durée du blocage qui suit ; le nombre de comptes clients qu'une même adresse IP peut créer par heure.

Chaque valeur peut être remplacée par une variable d'environnement préfixée par [FLASK_]. Les deux clés secrètes du fichier ne sont que des valeurs de développement : en production, elles doivent être remplacées (cf. « Un serveur de production »).

11.6.3. config_dev.py

1
2
3
4
5
6
# =========================================================================
# config_dev.py - complète config.py en développement (cf. app.py, qui lance
# l'application avec le profil "dev") : on affiche dans la console les
# ordres SQL envoyés par SQLAlchemy.
# =========================================================================
SQL_ECHO = True

Le profil de développement : un second fichier, lu après [config.py], dont les valeurs remplacent les siennes. Il n'y en a qu'une : l'affichage des ordres SQL. On pourrait de même écrire un [config_test.py] pour les tests, avec une autre base.

11.7. Le démarrage de l'application

11.7.1. app.py

# =========================================================================
# app.py - lance l'application EN DÉVELOPPEMENT :   python app.py
#
# Le serveur de développement de Flask (Werkzeug), sur le port 8081, avec :
#   - le profil "dev" (config_dev.py : les ordres SQL sont affichés) ;
#   - debug=True : l'application redémarre toute seule dès qu'un fichier .py
#     est modifié, et une vue modifiée est relue au rechargement de la page.
# En production, on utilise un vrai serveur WSGI (cf. README.md : waitress).
# =========================================================================
from rdvmedecins import create_app

if __name__ == "__main__":
    create_app(profil="dev").run(port=8081, debug=True)

Le fichier qui lance l'application en développement : la fabrique [create_app] est appelée avec le profil [dev], puis l'application est confiée au serveur de développement, sur le port 8081, en mode debug. En production, ce fichier ne sert pas : c'est le serveur WSGI qui appelle [create_app] (cf. « Un serveur de production »).

11.7.2. rdvmedecins/__init__.py

La fabrique de l'application. C'est le fichier qui assemble tout le reste : il est la « carte » de l'application.

# =========================================================================
# rdvmedecins/__init__.py
# -------------------------------------------------------------------------
# La FABRIQUE de l'application (cf. exemple 02) : create_app construit et
# configure l'application Flask. C'est ici que tout est assemblé :
#   1. la configuration (config.py, config_dev.py, variables FLASK_...) ;
#   2. l'accès à la base : le moteur et la fabrique de sessions de SQLAlchemy ;
#   3. les objets partagés : DAO -> couche métier -> cache (ApplicationModel),
#      authentification, captcha, limiteur, laissez-passer, traducteur -
#      c'est l'injection de dépendances, écrite "à la main" ;
#   4. ce qui s'exécute à chaque requête : langue, authentification ;
#   5. les blueprints (les contrôleurs), les gestionnaires d'erreurs, la
#      protection anti-CSRF.
#
# Ce fichier fait de [rdvmedecins] un PAQUET Python : "from rdvmedecins
# import create_app" exécute ce fichier.
# =========================================================================
from pathlib import Path

from flask import Flask, g, request
from flask_wtf.csrf import CSRFProtect
from sqlalchemy import URL, create_engine
from sqlalchemy.orm import sessionmaker

from rdvmedecins.auth import securite
from rdvmedecins.auth.auth_service import AuthService
from rdvmedecins.auth.captcha import CaptchaService
from rdvmedecins.auth.limiteur import LimiteurTentatives
from rdvmedecins.auth.passe_inscription import PasseInscription
from rdvmedecins.controllers.agenda import agenda
from rdvmedecins.controllers.auth import auth
from rdvmedecins.controllers.clients import clients
from rdvmedecins.controllers.espace import espace
from rdvmedecins.controllers.inscription import inscription
from rdvmedecins.controllers.langue import langue
from rdvmedecins.controllers.medecins import medecins
from rdvmedecins.metier.service import MetierService
from rdvmedecins.repositories.client_repository import ClientRepository
from rdvmedecins.repositories.creneau_repository import CreneauRepository
from rdvmedecins.repositories.medecin_repository import MedecinRepository
from rdvmedecins.repositories.rv_repository import RvRepository
from rdvmedecins.repositories.user_repository import UserRepository
from rdvmedecins.web import erreurs, modele_commun, traducteur
from rdvmedecins.web.application_model import ApplicationModel
from rdvmedecins.web.traducteur import Traducteur

# le dossier du projet (celui qui contient config.py), parent de ce paquet
RACINE = Path(__file__).resolve().parent.parent


def create_app(profil: str | None = None) -> Flask:
    app = Flask(__name__)
    app.jinja_env.trim_blocks = True
    app.jinja_env.lstrip_blocks = True

    # ---- 1. la configuration : config.py, puis config_<profil>.py s'il y a
    # un profil, puis les variables d'environnement FLASK_...
    app.config.from_pyfile(RACINE / "config.py")
    if profil:
        app.config.from_pyfile(RACINE / f"config_{profil}.py")
    app.config.from_prefixed_env()
    app.config.update(
        # le cookie de session de Flask (messages flash, jeton anti-CSRF)
        SESSION_COOKIE_NAME="rdvmedecins_session",
        SESSION_COOKIE_SAMESITE="Lax",
        SESSION_COOKIE_SECURE=app.config["COOKIE_SECURE"],
        # le jeton anti-CSRF reste valable tant que la session du navigateur dure
        WTF_CSRF_TIME_LIMIT=None,
        # une exception non traitée est confiée à nos gestionnaires d'erreurs
        # (page 500), même en mode debug
        PROPAGATE_EXCEPTIONS=False,
    )

    # ---- 2. l'accès à la base. L'URL de connexion de SQLAlchemy
    # (mysql+pymysql://root:@localhost:3306/dbrdvmedecins2?charset=utf8mb4),
    # fabriquée par URL.create : un mot de passe qui contient des caractères
    # spéciaux (@, :, /...) y est correctement codé. str(...) : une variable
    # FLASK_DB_PASSWORD=123456 serait lue comme un NOMBRE (cf. config.py)
    url = URL.create("mysql+pymysql", username=str(app.config["DB_USER"]),
                     password=str(app.config["DB_PASSWORD"]), host=app.config["DB_HOST"],
                     port=int(app.config["DB_PORT"]), database=app.config["DB_NAME"],
                     query={"charset": "utf8mb4"})
    # Le moteur gère un POOL de connexions ; pool_pre_ping : une connexion est
    # testée avant d'être réutilisée ; pool_recycle : elle est renouvelée au
    # bout d'une heure (MySQL ferme de lui-même les connexions inactives
    # depuis trop longtemps)
    moteur = create_engine(url, echo=app.config["SQL_ECHO"], pool_pre_ping=True,
                           pool_recycle=3600)
    # expire_on_commit=False : les objets lus restent utilisables après la fin
    # de la transaction (par les vues, par le cache)
    sessions = sessionmaker(moteur, expire_on_commit=False)

    # ---- 3. les objets partagés, créés une fois pour toutes
    metier = MetierService(sessions, MedecinRepository(), ClientRepository(),
                           CreneauRepository(), RvRepository(), UserRepository())
    # le cache lit les médecins et les clients : une base inaccessible arrête
    # l'application ICI, au démarrage
    app.extensions["rdvmedecins.application"] = ApplicationModel(metier)
    auth_service = AuthService(sessions, UserRepository(), app.config["JWT_SECRET"],
                               app.config["JWT_EXPIRES_IN_SECONDS"], app.config["COOKIE_SECURE"])
    app.extensions["rdvmedecins.auth"] = auth_service
    app.extensions["rdvmedecins.captchas"] = CaptchaService()
    app.extensions["rdvmedecins.limiteur"] = LimiteurTentatives(
        app.config["LOGIN_MAX_ECHECS"], app.config["LOGIN_BLOCAGE_MINUTES"],
        app.config["INSCRIPTION_MAX_PAR_HEURE"])
    app.extensions["rdvmedecins.passes"] = PasseInscription()
    tradu = Traducteur(Path(app.root_path) / "i18n")

    # ---- 4. à chaque requête, dans l'ordre d'enregistrement :
    # la langue (cookie rdvmedecins_langue, le français sinon)...
    @app.before_request
    def fixer_langue():
        g.langue = traducteur.langue(request.cookies.get(traducteur.COOKIE_LANGUE))

    # ... puis l'utilisateur, et la règle d'accès (cf. auth/securite.py)
    securite.enregistrer(app, auth_service)
    # les variables communes à toutes les vues
    modele_commun.enregistrer(app, tradu)

    # ---- 5. les contrôleurs, les pages d'erreur, la protection anti-CSRF
    for blueprint in (auth, langue, agenda, medecins, clients, inscription, espace):
        app.register_blueprint(blueprint)
    erreurs.enregistrer(app)
    # toute requête POST doit renvoyer le jeton anti-CSRF de la page
    # (champ caché csrf_token) : sinon, réponse 400
    CSRFProtect(app)
    return app
  • ligne 48 : le dossier du projet, qui contient [config.py] : le dossier parent ([parent.parent]) de celui de ce fichier ;
  • lignes 52-54 : l'application ; [__name__] vaut ici [rdvmedecins], le nom du paquet : [Flask] cherche donc les dossiers [templates] et [static] dans le dossier du paquet ;
  • lignes 58-61 : la configuration, en trois temps : le fichier [config.py], le fichier du profil s'il y en a un, les variables d'environnement. Chaque source remplace les valeurs des précédentes ;
  • lignes 62-71 : quelques réglages de [Flask] et de [Flask-WTF], écrits ici plutôt que dans [config.py] car ils ne sont pas destinés à être changés. [SESSION_COOKIE_NAME] renomme le cookie de session (par défaut [session]), pour qu'il ne se mélange pas à celui d'une autre application servie par [localhost], comme les exemples ; [SESSION_COOKIE_SAMESITE] et [SESSION_COOKIE_SECURE] lui donnent les attributs [SameSite=Lax] et, en production, [Secure] (exemple 25). [WTF_CSRF_TIME_LIMIT=None] : par défaut, un jeton anti-CSRF n'est valable qu'une heure, et un formulaire laissé ouvert plus longtemps serait refusé ; ici, il reste valable tant que dure la session du navigateur. [PROPAGATE_EXCEPTIONS=False] : en mode debug, [Flask] laisse remonter certaines exceptions jusqu'à la page de débogage ; ce réglage garantit qu'elles passent par nos gestionnaires d'erreurs ;
  • lignes 79-82 : l'URL de connexion de [SQLAlchemy] (exemple 28), fabriquée par [URL.create] à partir de ses composants. Écrite à la main, une URL devrait encoder les caractères spéciaux d'un mot de passe ([@], [:], [/]...), qui ont un sens dans une URL ; [URL.create] le fait elle-même. Les [str(...)] et [int(...)] protègent contre les conversions des variables d'environnement : [FLASK_DB_PASSWORD=123456] serait lu comme un nombre ;
  • lignes 87-88 : le moteur et son pool de connexions. [pool_recycle=3600] renouvelle une connexion au bout d'une heure : [MySQL] ferme de lui-même les connexions restées inactives trop longtemps (huit heures par défaut) ;
  • ligne 91 : la fabrique de sessions (exemple 28) ;
  • lignes 94-106 : les objets partagés, créés une fois pour toutes, et rangés dans [app.extensions] (exemple 06). C'est l'injection de dépendances, écrite à la main : chaque objet reçoit dans son constructeur les objets dont il a besoin. La couche métier reçoit la fabrique de sessions et les cinq classes de la couche DAO ; le cache [ApplicationModel] reçoit la couche métier ; le service d'authentification reçoit la fabrique de sessions, la classe DAO des utilisateurs et la configuration des jetons. Les clés de [app.extensions] sont préfixées par [rdvmedecins.], pour ne pas entrer en collision avec celles des extensions ([Flask-WTF] y range aussi ses objets). Le constructeur d'[ApplicationModel] lit la liste des médecins et des clients : si la base est inaccessible, l'application s'arrête ici, au démarrage, et non à la première requête ;
  • ligne 107 : le service de traduction (exemple 22) ;
  • lignes 111-113 : la première fonction [before_request] : la langue de la requête, lue dans le cookie [rdvmedecins_langue] ;
  • ligne 116 : la seconde : l'utilisateur de la requête, et la règle d'accès (cf. [auth/securite.py]). L'ordre compte : la page de connexion vers laquelle un visiteur non autorisé est redirigé a besoin de connaître la langue ;
  • ligne 118 : les variables communes à toutes les vues (le processeur de contexte de l'exemple 15, cf. [web/modele_commun.py]) ;
  • lignes 121-122 : les sept blueprints de l'application ;
  • ligne 123 : les gestionnaires d'erreurs (cf. [web/erreurs.py]) ;
  • ligne 126 : la protection anti-CSRF de toutes les requêtes POST (exemple 21).

Ce fichier est l'équivalent de la classe de démarrage et des classes de configuration d'une application [Spring Boot], et du fichier [Program.cs] d'une application [ASP.NET Core]. La différence principale : [Flask] ne crée ni n'injecte rien de lui-même. Tout ce qui existe dans l'application a été créé ici, explicitement, et l'on peut suivre chaque objet de sa création à son utilisation.

11.8. Les entités

Le dossier [entities] contient une classe par table : les entités que [SQLAlchemy] lit et écrit dans la base (exemple 28). Leurs attributs portent les noms des colonnes, sauf exception signalée.

11.8.1. entities/base.py

# =========================================================================
# entities/base.py
# -------------------------------------------------------------------------
# La classe mère de toutes les entités, et les deux colonnes communes à
# toutes les tables : l'identifiant [id] et le numéro de version [version].
#
# SQLAlchemy "déclaratif" : chaque classe fille de Base décrit une table
# (__tablename__), chaque attribut Mapped[...] une colonne. SQLAlchemy lit
# ces déclarations au démarrage pour savoir quelle classe va dans quelle
# table, quel attribut dans quelle colonne.
# =========================================================================
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column


class Base(DeclarativeBase):
    pass


# un MIXIN : une classe sans table, dont les colonnes sont ajoutées à celles
# des classes qui en héritent (Medecin, Client, Creneau, Rv, User)
class AvecIdEtVersion:

    # la clé primaire, générée par la base (colonne AUTO_INCREMENT) :
    # SQLAlchemy relit sa valeur après chaque INSERT. Une entité pas encore
    # enregistrée a un id None.
    id: Mapped[int] = mapped_column(primary_key=True)

    # le verrou optimiste. Chaque UPDATE envoyé par SQLAlchemy porte la
    # condition "WHERE id = ... AND version = <valeur lue>", et écrit la
    # version augmentée de 1. Si quelqu'un a modifié la ligne entre-temps,
    # aucune ligne n'est mise à jour : SQLAlchemy lève une StaleDataError
    # (cf. metier/service.py).
    version: Mapped[int] = mapped_column()

    # @declared_attr : un attribut calculé pour CHAQUE classe fille ;
    # version_id_col : la colonne qui sert de verrou optimiste
    @declared_attr.directive
    def __mapper_args__(cls):
        return {"version_id_col": cls.version}
  • lignes 15-16 : la classe mère de toutes les entités (exemple 28) ;
  • lignes 21-39 : les deux colonnes communes à toutes les tables, [id] et [version], écrites une seule fois. [AvecIdEtVersion] est un mixin : une classe qui n'a pas de table, et qu'on ne crée jamais seule, mais dont les classes filles héritent les colonnes. Une entité hérite ainsi de deux classes : [class User(AvecIdEtVersion, Base)] (l'héritage multiple de [Python]) ;
  • lignes 37-39 : le verrou optimiste (exemple 28). [__mapper_args__] doit désigner la colonne [version] de chaque classe fille, et non celle du mixin : le décorateur [@declared_attr.directive] fait de cette méthode un attribut calculé pour chaque classe qui hérite du mixin. [SQLAlchemy] l'appelle au moment où il analyse la classe fille, avec la classe fille en paramètre ([cls]).

11.8.2. entities/role.py

# =========================================================================
# entities/role.py
# -------------------------------------------------------------------------
# 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.
#
# Une énumération (Enum) : aucune autre valeur n'est possible. StrEnum : ses
# valeurs sont aussi des chaînes ("ADMIN"), rangées telles quelles en base,
# comme le fait le script SQL (cf. user.py).
# =========================================================================
from enum import StrEnum


class Role(StrEnum):
    ADMIN = "ADMIN"
    DOCTOR = "DOCTOR"
    USER = "USER"

Une énumération : un type dont les valeurs possibles sont fixées une fois pour toutes ([Role.ADMIN], [Role.DOCTOR], [Role.USER]). [StrEnum] (module standard [enum], [Python] 3.11) : chaque valeur est aussi une chaîne, égale à ["ADMIN"], etc. On peut donc la comparer à une chaîne, l'écrire dans un jeton JWT ou dans une page sans conversion.

11.8.3. entities/user.py

# =========================================================================
# entities/user.py
# -------------------------------------------------------------------------
# Un utilisateur autorisé à se connecter à l'application (table [users]).
# Chaque médecin et chaque client possède SON compte utilisateur (colonne
# [user] des tables [medecins] et [clients], cf. personne.py).
#
# Contrainte d'unicité SQL : unq_users_login (un login n'appartient qu'à un
# seul compte). Elle est créée par le script database/dbrdvmedecins2.sql ;
# la couche métier la reconnaît par son NOM (cf. metier/service.py).
# =========================================================================
from sqlalchemy import Enum, String
from sqlalchemy.orm import Mapped, mapped_column

from rdvmedecins.entities.base import AvecIdEtVersion, Base
from rdvmedecins.entities.role import Role


class User(AvecIdEtVersion, Base):
    __tablename__ = "users"

    login: Mapped[str] = mapped_column(String(30))
    # le mot de passe HACHÉ (bcrypt) - jamais en clair
    password: Mapped[str] = mapped_column(String(100))
    # le nom affiché une fois connecté - pour un médecin ou un client :
    # "prénom NOM" (ex : "Marie PELISSIER")
    nom: Mapped[str] = mapped_column(String(40))
    # ADMIN, DOCTOR ou USER, rangé en base sous forme de texte.
    # native_enum=False : une simple colonne VARCHAR (et non le type ENUM de MySQL)
    role: Mapped[Role] = mapped_column(Enum(Role, native_enum=False, length=10))
  • ligne 19 : l'entité hérite de l'id et de la version du mixin, et de [Base] ;
  • ligne 24 : le mot de passe haché ;
  • ligne 30 : un attribut de type [Role]. Le type [Enum] de [SQLAlchemy] convertit l'énumération en texte à l'écriture (["ADMIN"]), et le texte en énumération à la lecture. [native_enum=False] : la colonne est un simple [VARCHAR], comme dans le script SQL, et non le type [ENUM] propre à [MySQL].

11.8.4. entities/personne.py

# =========================================================================
# entities/personne.py
# -------------------------------------------------------------------------
# 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 le mixin Personne, dont héritent Medecin et Client. Une
# relation déclarée dans un mixin doit l'être par une méthode @declared_attr :
# SQLAlchemy l'appelle pour chaque classe fille, qui reçoit ainsi SA relation.
#
# 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 (table [clients]).
#
# Contraintes d'unicité SQL : unq_medecins_prenom_nom, unq_clients_prenom_nom
# (deux médecins, ou deux clients, ne peuvent pas porter les mêmes prénom et
# nom) - créées par le script SQL, reconnues par leur nom dans la couche métier.
# =========================================================================
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship

from rdvmedecins.entities.base import AvecIdEtVersion, Base
from rdvmedecins.entities.user import User


class Personne(AvecIdEtVersion):

    titre: Mapped[str] = mapped_column(String(5))  # Melle, Mme, Mr
    nom: Mapped[str] = mapped_column(String(20))
    prenom: Mapped[str] = mapped_column(String(20))

    # la clé étrangère vers users(id), lue comme un simple nombre : on connaît
    # ainsi l'id du compte sans charger le User. "user" est le nom de la
    # colonne (un mot-clé de SQL, que SQLAlchemy entoure lui-même d'accents
    # graves `user` si le SGBD l'exige) ; id_user, celui de l'attribut Python
    @declared_attr
    def id_user(cls) -> Mapped[int]:
        return mapped_column("user", ForeignKey("users.id"))

    # -----------------------------------------------------------------------
    # La relation vers le compte utilisateur : un médecin (un client) a UN
    # compte. relationship : l'attribut [user] est un objet User, et non un
    # nombre. SQLAlchemy ne le lit en base que si on le demande : dans la
    # requête (options joinedload, cf. repositories/), ou au premier accès à
    # l'attribut, tant que la session est ouverte.
    # -----------------------------------------------------------------------
    @declared_attr
    def user(cls) -> Mapped[User]:
        return relationship()


class Medecin(Personne, Base):
    __tablename__ = "medecins"


class Client(Personne, Base):
    __tablename__ = "clients"
  • ligne 27 : les tables [medecins] et [clients] ayant exactement les mêmes colonnes, elles sont décrites une fois, dans le mixin [Personne], qui hérite lui-même du mixin [AvecIdEtVersion] ;
  • lignes 29-31 : les colonnes ordinaires, déclarées comme dans l'exemple 28 ;
  • lignes 9-39 : la clé étrangère vers le compte utilisateur. Dans un mixin, une colonne qui porte une clé étrangère doit être déclarée par une méthode [@declared_attr] : chaque classe fille reçoit ainsi sa propre colonne. [ForeignKey("users.id")] indique la colonne référencée ; le premier paramètre de [mapped_column] est le nom de la colonne dans la table ([user]), différent de celui de l'attribut [Python] ([id_user]) ;
  • lignes 37-50 : la relation vers le compte : l'attribut [user] est un objet [User], et non un nombre. [SQLAlchemy] déduit la jointure de la clé étrangère. Une relation n'est pas une colonne : c'est un moyen d'obtenir l'objet lié. Par défaut, [SQLAlchemy] ne le lit dans la base qu'au premier accès à l'attribut (chargement paresseux, *lazy loading*), ce qui suppose que la session soit encore ouverte ;
  • lignes 53-58 : deux entités, qui ne diffèrent que par leur table.

Attention au vocabulaire : un [Client] est ici un patient du cabinet médical, et non un client HTTP. C'est le vocabulaire de l'application d'origine, conservé tel quel.

11.8.5. entities/creneau.py

# =========================================================================
# entities/creneau.py
# -------------------------------------------------------------------------
# Un créneau horaire d'un médecin (table [creneaux]) : 8h00 - 8h20, etc.
# =========================================================================
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

from rdvmedecins.entities.base import AvecIdEtVersion, Base
from rdvmedecins.entities.personne import Medecin


class Creneau(AvecIdEtVersion, Base):
    __tablename__ = "creneaux"

    hdebut: Mapped[int]  # heure de début du créneau
    mdebut: Mapped[int]  # minute de début du créneau
    hfin: Mapped[int]  # heure de fin du créneau
    mfin: Mapped[int]  # minute de fin du créneau

    # plusieurs créneaux pour un même médecin : la clé étrangère [id_medecin]
    # (un nombre), et la relation [medecin] (l'objet Medecin)
    id_medecin: Mapped[int] = mapped_column(ForeignKey("medecins.id"))
    medecin: Mapped[Medecin] = relationship()

Un créneau horaire et son médecin : la clé étrangère [id_medecin] (un nombre) et la relation [medecin] (l'objet). Sans mixin, la relation se déclare directement, comme un attribut.

11.8.6. entities/rv.py

# =========================================================================
# entities/rv.py
# -------------------------------------------------------------------------
# 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. Créée par le script SQL, reconnue par son
# nom dans la couche métier.
# =========================================================================
from datetime import date

from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

from rdvmedecins.entities.base import AvecIdEtVersion, Base
from rdvmedecins.entities.creneau import Creneau
from rdvmedecins.entities.personne import Client


class Rv(AvecIdEtVersion, Base):
    __tablename__ = "rv"

    # date : une date SANS heure, qui correspond exactement au type SQL DATE
    # de la colonne [jour]
    jour: Mapped[date]

    id_client: Mapped[int] = mapped_column(ForeignKey("clients.id"))
    client: Mapped[Client] = relationship()

    id_creneau: Mapped[int] = mapped_column(ForeignKey("creneaux.id"))
    creneau: Mapped[Creneau] = relationship()
  • ligne 25 : le type [date] de [Python], une date sans heure, correspond exactement au type SQL [DATE] ;
  • lignes 27-31 : les deux clés étrangères et leurs relations.

11.8.7. entities/__init__.py

# =========================================================================
# entities/ - les ENTITÉS : une classe par table de la base dbrdvmedecins2.
# Ce fichier regroupe les importations : "from rdvmedecins.entities import
# Medecin" suffit ailleurs dans l'application.
# =========================================================================
from rdvmedecins.entities.base import Base
from rdvmedecins.entities.creneau import Creneau
from rdvmedecins.entities.personne import Client, Medecin, Personne
from rdvmedecins.entities.role import Role
from rdvmedecins.entities.rv import Rv
from rdvmedecins.entities.user import User

__all__ = ["Base", "Client", "Creneau", "Medecin", "Personne", "Role", "Rv", "User"]

Le fichier [__init__.py] d'un paquet est exécuté quand on importe le paquet. Celui-ci importe les entités de leurs modules : ailleurs, [from rdvmedecins.entities import Medecin] suffit, sans avoir à savoir dans quel module la classe est définie. La liste [__all__] déclare les noms que le paquet met à disposition.

11.9. SQLAlchemy : sessions et chargement des relations

L'exemple 28 a présenté l'essentiel de [SQLAlchemy]. L'étude de cas en utilise deux aspects de plus, liés l'un à l'autre.

La durée de vie des objets lus. Chaque méthode de la couche métier ouvre une session et une transaction, et les ferme en sortant (exemple 28). Les entités qu'elle rend sont alors détachées : elles ne sont plus liées à aucune session. Grâce au réglage [expire_on_commit=False], leurs attributs ordinaires restent lisibles. Mais une relation qui n'a pas été chargée ne peut plus l'être : il faudrait interroger la base, et il n'y a plus de session. L'accès à l'attribut lève alors une exception [DetachedInstanceError]. C'est le problème que [Spring Boot] résout, avec [Hibernate], par le réglage [open-in-view] (une session ouverte pendant toute la requête), que la version [Spring MVC] de l'étude de cas désactivait pour la même raison que nous : une vue ne doit pas déclencher de requêtes SQL. La règle est donc : chaque méthode de la couche DAO charge explicitement les relations dont l'appelant aura besoin.

Les options de chargement. [SQLAlchemy] offre plusieurs façons de charger une relation en même temps que l'entité :

  • [joinedload(Medecin.user)] : ajoute à la requête une jointure ([LEFT OUTER JOIN users ...]), dont les colonnes servent à construire les objets [User]. Une seule requête ramène les médecins et leurs comptes ;
  • [contains_eager(Rv.creneau)] : quand la requête contient déjà une jointure ([select(Rv).join(Rv.creneau)], écrite pour filtrer sur une colonne de la table jointe), cette option dit à [SQLAlchemy] d'utiliser les colonnes de cette jointure pour remplir la relation, plutôt que d'en ajouter une seconde ;
  • les options s'enchaînent pour charger une relation d'une relation : [contains_eager(Rv.creneau).joinedload(Creneau.medecin)] charge le créneau de chaque rendez-vous, puis le médecin de chaque créneau.

Ce sont les équivalents des [JOIN FETCH] de [JPA] et des [Include] d'[Entity Framework Core].

11.10. Le domaine : l'agenda

11.10.1. domain/agenda.py

# =========================================================================
# domain/agenda.py
# -------------------------------------------------------------------------
# Des objets "métier" calculés, qui ne correspondent à aucune table :
#   - CreneauMedecinJour : un créneau horaire, et le rendez-vous éventuellement
#     pris sur ce créneau un jour donné (None si le créneau est libre) ;
#   - AgendaMedecinJour : l'agenda complet d'un médecin pour un jour donné,
#     la liste de ses créneaux, chacun libre ou occupé. Calculé par
#     MetierService.get_agenda_medecin_jour, transformé en tableau HTML par
#     controllers/agenda.py.
#
# frozen=True : des objets immuables, dont les champs 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 rdvmedecins.entities import Creneau, Medecin, Rv


@dataclass(frozen=True)
class CreneauMedecinJour:
    creneau: Creneau
    rv: Rv | None


@dataclass(frozen=True)
class AgendaMedecinJour:
    medecin: Medecin
    jour: date
    creneaux_medecin_jour: list[CreneauMedecinJour]

Deux classes qui ne correspondent à aucune table : l'agenda d'un médecin pour un jour, calculé par la couche métier. [CreneauMedecinJour] associe un créneau au rendez-vous pris sur ce créneau ce jour-là, ou à [None] s'il est libre ; [AgendaMedecinJour] réunit le médecin, le jour, et la liste de ses créneaux. Ce sont des [dataclass] immuables ([frozen=True]) : un objet calculé, que personne ne modifie ensuite.

11.11. La couche DAO

Le dossier [repositories] (le nom anglais courant des classes DAO, « dépôts ») contient une classe par entité. Comme dans l'exemple 28, chaque méthode reçoit la session ouverte par la couche métier, et ne contient aucune règle de gestion.

11.11.1. repositories/personne_repository.py

# =========================================================================
# repositories/personne_repository.py
# -------------------------------------------------------------------------
# La couche DAO commune aux médecins et aux clients.
#
# Chaque méthode reçoit la SESSION de SQLAlchemy ouverte par la couche
# métier (cf. metier/service.py) : toutes les requêtes d'une même opération
# utilisent ainsi la même connexion et la même TRANSACTION.
#
# Les requêtes sont écrites avec les objets de SQLAlchemy (select, where,
# order_by...), qui portent sur les CLASSES et leurs attributs ; SQLAlchemy
# les traduit en SQL. Les valeurs (prenom, nom...) deviennent des PARAMÈTRES
# de la requête : pas de concaténation de chaînes, donc pas d'injection SQL.
#
# Les DAO des médecins et des clients seraient identiques : cette classe
# GÉNÉRIQUE les écrit une seule fois. [T: Personne] : un paramètre de type,
# qui vaut Medecin ou Client ; self.entite est la classe correspondante.
# =========================================================================
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, joinedload

from rdvmedecins.entities import Personne


class PersonneRepository[T: Personne]:

    def __init__(self, entite: type[T]):
        self.entite = entite

    # liste triée par nom puis prénom, compte utilisateur compris.
    # joinedload(user) : ramène AUSSI le compte, par une jointure
    # (SELECT ... FROM medecins LEFT OUTER JOIN users ...). Sans elle, le
    # compte ne serait lu qu'au premier accès - impossible une fois la session
    # fermée : ces listes sont gardées en mémoire par le cache
    # (web/application_model.py), bien après la fin de la requête.
    def lister(self, session: Session) -> list[T]:
        e = self.entite
        requete = select(e).options(joinedload(e.user)).order_by(e.nom, e.prenom)
        return list(session.scalars(requete))

    # une personne et son compte ; None si elle n'existe pas
    def lire(self, session: Session, id: int) -> T | None:
        return session.get(self.entite, id, options=[joinedload(self.entite.user)])

    # la personne dont le compte utilisateur est [id_user]
    def lire_par_user(self, session: Session, id_user: int) -> T | None:
        e = self.entite
        requete = select(e).options(joinedload(e.user)).where(e.id_user == id_user)
        return session.scalars(requete).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, session: Session, prenom: str, nom: str) -> T | None:
        e = self.entite
        return session.scalars(select(e).where(e.prenom == prenom, e.nom == nom).limit(1)).first()

    def ajouter(self, session: Session, personne: T) -> T:
        session.add(personne)
        return personne

    # un DELETE envoyé directement à la base, sans lire la personne d'abord
    def supprimer(self, session: Session, id: int):
        session.execute(delete(self.entite).where(self.entite.id == id))
  • ligne 25 : une classe générique, écrite avec la syntaxe de [Python] 3.12 (PEP 695). [T] est un paramètre de type : la classe sera utilisée comme [PersonneRepository[Medecin]] ou [PersonneRepository[Client]]. [T: Personne] limite [T] aux classes qui héritent de [Personne]. Les types ne sont pas vérifiés par [Python] à l'exécution (exemple 06) : ils documentent le code, et permettent à l'éditeur de savoir que [lister()] rend une liste de [Medecin] pour le DAO des médecins. Avant [Python] 3.12, on aurait écrit [T = TypeVar("T", bound=Personne)] puis [class PersonneRepository(Generic[T])] ;
  • lignes 27-28 : le paramètre de type n'existe pas à l'exécution : la classe réelle ([Medecin] ou [Client]) est donc passée au constructeur, et gardée dans [self.entite]. [type[T]] est le type d'une classe (et non d'un objet) ;
  • lignes 36-39 : la liste triée, avec le compte de chaque personne, chargé par une jointure ([joinedload]). C'est indispensable ici : ces listes sont gardées en mémoire par le cache [ApplicationModel], bien après la fin de la session ;
  • lignes 42-43 : la lecture par clé primaire accepte, elle aussi, des options de chargement ;
  • lignes 46-49 : la personne associée à un compte : c'est ainsi que l'on retrouve le client qui s'est connecté ;
  • lignes 55-57 : la recherche d'un homonyme. La comparaison est faite par [MySQL], avec la collation de la base : c'est exactement la règle de la contrainte d'unicité ;
  • lignes 59-61 : l'ajout, sans [flush] : c'est la couche métier qui décide du moment où les ordres SQL partent ;
  • lignes 64-65 : un [DELETE] envoyé directement à la base.

11.11.2. repositories/medecin_repository.py

# =========================================================================
# repositories/medecin_repository.py
# -------------------------------------------------------------------------
# Le DAO des médecins : tout est hérité de PersonneRepository.
# =========================================================================
from rdvmedecins.entities import Medecin
from rdvmedecins.repositories.personne_repository import PersonneRepository


class MedecinRepository(PersonneRepository[Medecin]):

    def __init__(self):
        super().__init__(Medecin)

Le DAO des médecins hérite de [PersonneRepository[Medecin]], et passe la classe [Medecin] au constructeur de sa classe mère : tout le reste est hérité.

11.11.3. repositories/client_repository.py

# =========================================================================
# repositories/client_repository.py
# -------------------------------------------------------------------------
# Le DAO des clients : celui des personnes, plus une méthode de verrouillage.
# =========================================================================
from sqlalchemy import select
from sqlalchemy.orm import Session

from rdvmedecins.entities import Client
from rdvmedecins.repositories.personne_repository import PersonneRepository


class ClientRepository(PersonneRepository[Client]):

    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 renvoie 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.
    # 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.
    # with_for_update : ajoute "FOR UPDATE" à la requête.
    # -----------------------------------------------------------------------
    def verrouiller(self, session: Session, id: int) -> int | None:
        return session.scalars(select(Client.id).where(Client.id == id).with_for_update()).first()
  • lignes 28-29 : une méthode de plus, qui pose un verrou sur la ligne d'un client : [with_for_update()] ajoute [FOR UPDATE] à la requête ([SELECT clients.id FROM clients WHERE clients.id = ? FOR UPDATE]). Jusqu'à la fin de la transaction, une autre transaction qui demande le même verrou attend. La couche métier s'en sert pour la prise de rendez-vous : deux réservations simultanées pour un 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 (règle : un seul rendez-vous à venir par patient). Ce verrou pessimiste complète le verrou optimiste : l'optimiste détecte une modification concurrente d'une ligne, le pessimiste empêche deux transactions de prendre en même temps une décision fondée sur les mêmes données.

11.11.4. repositories/creneau_repository.py

# =========================================================================
# repositories/creneau_repository.py
# -------------------------------------------------------------------------
# Couche DAO de l'entité [Creneau].
# =========================================================================
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, joinedload

from rdvmedecins.entities import Creneau


class CreneauRepository:

    # un créneau et son médecin
    def lire(self, session: Session, id: int) -> Creneau | None:
        return session.get(Creneau, id, options=[joinedload(Creneau.medecin)])

    # les créneaux d'un médecin, dans l'ordre chronologique
    def lister_par_medecin(self, session: Session, id_medecin: int) -> list[Creneau]:
        requete = (select(Creneau).where(Creneau.id_medecin == id_medecin)
                   .order_by(Creneau.hdebut, Creneau.mdebut))
        return list(session.scalars(requete))

    # 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, session: Session, id_medecin: int):
        session.execute(delete(Creneau).where(Creneau.id_medecin == id_medecin))

Un créneau (avec son médecin), les créneaux d'un médecin dans l'ordre chronologique, et la suppression des créneaux d'un médecin (quand on supprime le médecin).

11.11.5. repositories/rv_repository.py

# =========================================================================
# repositories/rv_repository.py
# -------------------------------------------------------------------------
# Couche DAO de l'entité [Rv] (un rendez-vous).
#
# join(Rv.creneau) : une jointure SQL, écrite à partir de la RELATION entre
# les deux classes (SQLAlchemy connaît la clé étrangère). contains_eager :
# les colonnes de la table jointe servent AUSSI à remplir la relation
# (rv.creneau), sans nouvelle requête.
# =========================================================================
from datetime import date

from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session, contains_eager, joinedload

from rdvmedecins.entities import Creneau, Rv


class RvRepository:

    # un rendez-vous, son client et son créneau
    def lire(self, session: Session, id: int) -> Rv | None:
        return session.get(Rv, id, options=[joinedload(Rv.client), joinedload(Rv.creneau)])

    # les rendez-vous d'un médecin un jour donné, client et créneau compris.
    # La table [rv] ne référence pas directement le médecin : on passe par le créneau.
    def rv_medecin_jour(self, session: Session, id_medecin: int, jour: date) -> list[Rv]:
        requete = (select(Rv).join(Rv.creneau)
                   .options(contains_eager(Rv.creneau), joinedload(Rv.client))
                   .where(Creneau.id_medecin == id_medecin, Rv.jour == jour))
        return list(session.scalars(requete))

    # 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
    def rv_a_venir_client(self, session: Session, id_client: int, depuis: date) -> list[Rv]:
        requete = (select(Rv).join(Rv.creneau)
                   .options(contains_eager(Rv.creneau).joinedload(Creneau.medecin))
                   .where(Rv.id_client == id_client, Rv.jour >= depuis)
                   .order_by(Rv.jour, Creneau.hdebut, Creneau.mdebut))
        return list(session.scalars(requete))

    # nombre de rendez-vous d'un client à partir du jour [depuis] (inclus)
    def compter_a_venir_client(self, session: Session, id_client: int, depuis: date) -> int:
        requete = select(func.count(Rv.id)).where(Rv.id_client == id_client, Rv.jour >= depuis)
        return session.scalar(requete)

    # nombre de rendez-vous (passés ou futurs) d'un médecin
    def compter_par_medecin(self, session: Session, id_medecin: int) -> int:
        requete = select(func.count(Rv.id)).join(Rv.creneau).where(Creneau.id_medecin == id_medecin)
        return session.scalar(requete)

    def ajouter(self, session: Session, rv: Rv) -> Rv:
        session.add(rv)
        return rv

    # supprime un rendez-vous : DELETE FROM rv WHERE id = ?
    def supprimer(self, session: Session, id: int):
        session.execute(delete(Rv).where(Rv.id == id))

    # supprime TOUS les rendez-vous (passés et futurs) d'un client
    def supprimer_par_client(self, session: Session, id_client: int):
        session.execute(delete(Rv).where(Rv.id_client == id_client))
  • lignes 27-31 : les rendez-vous d'un médecin un jour donné. La table [rv] ne référence pas directement le médecin : on passe par le créneau. [join(Rv.creneau)] écrit la jointure à partir de la relation ; le filtre porte sur une colonne de la table jointe ([Creneau.id_medecin]). [contains_eager] réutilise la jointure pour remplir [rv.creneau], [joinedload] charge le client : l'agenda est construit en une seule requête SQL ;
  • lignes 35-39 : les rendez-vous à venir d'un client, avec leur créneau et le médecin du créneau ;
  • lignes 43-45 : [func.count] produit la fonction SQL [COUNT] ; [session.scalar] (au singulier) rend la première colonne de la première ligne : le nombre ;
  • lignes 48-50 : le nombre de rendez-vous d'un médecin, pour savoir s'il peut être supprimé ;
  • lignes 61-62 : la suppression des rendez-vous d'un client (quand on supprime le client).

11.11.6. repositories/user_repository.py

# =========================================================================
# repositories/user_repository.py
# -------------------------------------------------------------------------
# Couche DAO de l'entité [User]. Créer (ou supprimer) un médecin ou un
# client crée (ou supprime) aussi son compte - cf. metier/service.py.
# =========================================================================
from sqlalchemy import delete, select
from sqlalchemy.orm import Session

from rdvmedecins.entities import User


class UserRepository:

    # None si aucun utilisateur ne porte ce login
    def lire_par_login(self, session: Session, login: str) -> User | None:
        return session.scalars(select(User).where(User.login == login)).first()

    def ajouter(self, session: Session, user: User) -> User:
        session.add(user)
        return user

    def supprimer(self, session: Session, id: int):
        session.execute(delete(User).where(User.id == id))

La lecture d'un utilisateur par son login (pour la connexion), l'ajout et la suppression d'un compte.

11.12. La couche métier

Le dossier [metier] contient les règles de gestion, et elles seules. Il ne connaît ni [Flask], ni HTML, ni requête HTTP : on pourrait l'utiliser tel quel dans une application en ligne de commande, ou dans une API.

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


# la date du jour, dans le fuseau horaire du serveur
def aujourdhui() -> date:
    return date.today()

La date du jour sert à deux règles de gestion (pas de rendez-vous dans le passé, un seul rendez-vous à venir par patient) : elle a sa place dans la couche métier. L'isoler dans une fonction a un autre avantage : un test automatisé pourrait la remplacer par une date fixe.

11.12.2. metier/exception.py

# =========================================================================
# metier/exception.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 ne transporte pas un texte, mais une CLÉ de
# traduction (ex : "ERRORS.LOGIN_TAKEN") : c'est la couche web qui la
# traduira en français ou en anglais au moment d'afficher la page
# (cf. i18n/fr.json, i18n/en.json).
#
# Une MetierException n'est pas une anomalie : c'est une réponse normale
# ("ce login est déjà pris"), affichée à l'utilisateur. Toute autre
# exception est une vraie anomalie, qui aboutit à la page d'erreur 500.
# =========================================================================


class MetierException(Exception):

    def __init__(self, cle: str, parametres: dict[str, str] | None = None):
        super().__init__(cle)
        # clé de traduction du message
        self.cle = cle
        # paramètres éventuels du message ({minutes}...)
        self.parametres = parametres or {}

L'exception de la couche métier ne transporte pas un texte, comme dans l'exemple 28, mais une clé de traduction ([ERRORS.LOGIN_TAKEN]), et d'éventuels paramètres : la couche métier ne sait pas dans quelle langue l'utilisateur lit la page. C'est la couche web qui traduira la clé. [super().__init__(cle)] transmet la clé au constructeur de la classe [Exception] : c'est elle qui s'affiche si l'exception est écrite dans le journal.

Une [MetierException] n'est pas une anomalie : c'est une réponse normale (« ce login est déjà pris »), que l'on affiche à l'utilisateur. Toute autre exception est une vraie anomalie, qui aboutit à la page d'erreur 500.

11.12.3. metier/donnees.py

# =========================================================================
# metier/donnees.py
# -------------------------------------------------------------------------
# Les données saisies dans le 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, cf. entities/base.py)
    version: int | None = None

Les données d'un formulaire « médecin » ou « client », transmises par la couche web (exemple 28). Le login, le mot de passe et la version sont facultatifs : les commentaires disent ce que signifie leur absence, à la création et à la modification.

11.12.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.
#
# Un Protocol décrit les méthodes qu'un objet doit avoir, sans dire comment
# elles sont écrites (l'équivalent d'une interface). MetierService (la
# couche métier) et ApplicationModel (le cache de la couche web) le
# respectent tous les deux. Python ne vérifie rien à l'exécution : ce sont
# les outils d'analyse (celui de VS Code, par exemple) qui signalent un écart.
#
# T | None : un résultat qui peut être absent (un médecin qui n'existe pas...).
# =========================================================================
from datetime import date
from typing import Protocol

from rdvmedecins.domain.agenda import AgendaMedecinJour
from rdvmedecins.entities import Client, Creneau, Medecin, Rv
from rdvmedecins.metier.donnees import DonneesPersonne


class Metier(Protocol):

    # ------------------------------------------------ lectures
    def get_all_clients(self) -> list[Client]: ...

    def get_all_medecins(self) -> list[Medecin]: ...

    def get_all_creneaux(self, id_medecin: int) -> list[Creneau]: ...

    def get_rv_medecin_jour(self, id_medecin: int, jour: date) -> list[Rv]: ...

    def get_client_by_id(self, id: int) -> Client | None: ...

    def get_medecin_by_id(self, id: int) -> Medecin | None: ...

    def get_rv_by_id(self, id: int) -> Rv | None: ...

    def get_creneau_by_id(self, id: int) -> Creneau | None: ...

    def get_agenda_medecin_jour(self, id_medecin: int, jour: date) -> AgendaMedecinJour | None: ...

    # le client (patient) dont le compte utilisateur est [id_user]
    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
    def get_rv_a_venir_client(self, id_client: int) -> list[Rv]: ...

    # ------------------------------------------------ rendez-vous (ADMIN, DOCTOR, USER)
    # lève une MetierException si :
    #  - le jour est passé ("ERRORS.PAST_DATE") ;
    #  - le patient a déjà un rendez-vous à venir ("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT") ;
    #  - le créneau est déjà pris ce jour-là ("ERRORS.SLOT_ALREADY_BOOKED").
    def ajouter_rv(self, jour: date, creneau: Creneau, client: Client) -> Rv: ...

    def supprimer_rv(self, rv: Rv) -> None: ...

    # ------------------------------------------------ médecins (ADMIN)
    # crée le médecin ET son compte utilisateur (rôle DOCTOR)
    def ajouter_medecin(self, donnees: DonneesPersonne) -> Medecin: ...

    # modifie le médecin ET son compte utilisateur
    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
    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)
    def ajouter_client(self, donnees: DonneesPersonne) -> Client: ...

    def modifier_client(self, id: int, donnees: DonneesPersonne) -> Client: ...

    # supprime le client, son compte ET tous ses rendez-vous (passés et futurs)
    def supprimer_client(self, id: int) -> None: ...
  • ligne 23 : le contrat de la couche métier : la liste de ses méthodes, avec leurs paramètres et leur résultat. Un [Protocol] (module standard [typing]) est l'équivalent [Python] d'une interface : il décrit les méthodes qu'un objet doit avoir, sans dire comment elles sont écrites. Une classe le respecte dès qu'elle a ces méthodes, sans avoir à le déclarer (on parle de typage structurel, ou de *duck typing* : « si ça marche comme un canard... »). Deux classes le respectent : [MetierService], la couche métier, et [ApplicationModel], le cache de la couche web (plus loin). [Python] ne vérifie rien à l'exécution : ce sont les outils d'analyse, comme celui de [Visual Studio Code], qui signalent un écart ;
  • ligne 26 : le corps d'une méthode d'un protocole est réduit à [...] (l'objet [Ellipsis] de [Python], qui ne fait rien) ;
  • lignes 55-78 : les commentaires documentent les règles de gestion et les clés d'erreur de chaque méthode.

11.12.5. metier/service.py

L'implémentation de la couche métier. C'est le fichier le plus important de l'application.

# =========================================================================
# metier/service.py
# -------------------------------------------------------------------------
# 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 privées _xxx_personne), et paramétré par un
# petit "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 MetierException les erreurs que la base
# signale au moment où les ordres SQL lui sont envoyés.
# =========================================================================
import re
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date

from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm.exc import StaleDataError

from rdvmedecins.auth.mots_de_passe import hacher
from rdvmedecins.domain.agenda import AgendaMedecinJour, CreneauMedecinJour
from rdvmedecins.entities import Client, Creneau, Medecin, Personne, Role, Rv, User
from rdvmedecins.metier import dates
from rdvmedecins.metier.donnees import DonneesPersonne
from rdvmedecins.metier.exception import MetierException
from rdvmedecins.repositories.client_repository import ClientRepository
from rdvmedecins.repositories.creneau_repository import CreneauRepository
from rdvmedecins.repositories.medecin_repository import MedecinRepository
from rdvmedecins.repositories.personne_repository import PersonneRepository
from rdvmedecins.repositories.rv_repository import RvRepository
from rdvmedecins.repositories.user_repository import UserRepository


# tout ce qui distingue un médecin d'un client pour les règles de gestion
@dataclass(frozen=True)
class Descripteur:
    dao: PersonneRepository
    fabrique: type[Personne]  # Medecin ou Client
    role: Role  # DOCTOR ou USER
    cle_introuvable: str  # message si la personne n'existe pas
    cle_existe_deja: str  # message 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 > 0), avec le message cle_avec_rv
    compter_rv: Callable[[Session, int], int] | None
    cle_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)
    avant_suppression: Callable[[Session, int], None] | None


# -----------------------------------------------------------------------
# Erreurs de la base -> MetierException
#
# Quand une contrainte UNIQUE refuse une écriture, SQLAlchemy lève une
# IntegrityError, dont l'attribut [orig] est l'erreur d'origine, celle du
# pilote PyMySQL. Son message cite le NOM de la contrainte :
#   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.
# -----------------------------------------------------------------------
MESSAGES_CONTRAINTES = {
    "unq_users_login": "ERRORS.LOGIN_TAKEN",
    "unq_medecins_prenom_nom": "ERRORS.DOCTOR_ALREADY_EXISTS",
    "unq_clients_prenom_nom": "ERRORS.PATIENT_ALREADY_EXISTS",
    "unq1_rv": "ERRORS.SLOT_ALREADY_BOOKED",
}

CONTRAINTE = re.compile(r"for key '(?:[^'.]*\.)?([^']+)'")


class MetierService:

    # toutes les dépendances sont fournies par la fabrique de l'application
    # (cf. rdvmedecins/__init__.py) : c'est l'injection de dépendances "à la
    # main". sessions : la fabrique des sessions de SQLAlchemy.
    def __init__(self, sessions: sessionmaker, medecins: MedecinRepository,
                 clients: ClientRepository, creneaux: CreneauRepository, rvs: RvRepository,
                 users: UserRepository):
        self._sessions = sessions
        self._medecins = medecins
        self._clients = clients
        self._creneaux = creneaux
        self._rvs = rvs
        self._users = users

        self._medecin = Descripteur(
            dao=medecins, fabrique=Medecin, role=Role.DOCTOR,
            cle_introuvable="ERRORS.DOCTOR_NOT_FOUND",
            cle_existe_deja="ERRORS.DOCTOR_ALREADY_EXISTS",
            compter_rv=rvs.compter_par_medecin,
            cle_avec_rv="ERRORS.DOCTOR_HAS_APPOINTMENTS",
            # 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=creneaux.supprimer_par_medecin)

        self._client = Descripteur(
            dao=clients, fabrique=Client, role=Role.USER,
            cle_introuvable="ERRORS.PATIENT_NOT_FOUND",
            cle_existe_deja="ERRORS.PATIENT_ALREADY_EXISTS",
            # 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, cle_avec_rv=None,
            avant_suppression=rvs.supprimer_par_client)

    # =======================================================================
    # lectures : une session, le temps de la lecture ; les objets lus restent
    # utilisables ensuite (expire_on_commit=False, cf. rdvmedecins/__init__.py)
    # =======================================================================

    def _lecture[R](self, travail: Callable[[Session], R]) -> R:
        with self._sessions() as session:
            return travail(session)

    def get_all_clients(self) -> list[Client]:
        return self._lecture(self._clients.lister)

    def get_all_medecins(self) -> list[Medecin]:
        return self._lecture(self._medecins.lister)

    def get_all_creneaux(self, id_medecin: int) -> list[Creneau]:
        return self._lecture(lambda s: self._creneaux.lister_par_medecin(s, id_medecin))

    def get_rv_medecin_jour(self, id_medecin: int, jour: date) -> list[Rv]:
        return self._lecture(lambda s: self._rvs.rv_medecin_jour(s, id_medecin, jour))

    def get_client_by_id(self, id: int) -> Client | None:
        return self._lecture(lambda s: self._clients.lire(s, id))

    def get_medecin_by_id(self, id: int) -> Medecin | None:
        return self._lecture(lambda s: self._medecins.lire(s, id))

    def get_rv_by_id(self, id: int) -> Rv | None:
        return self._lecture(lambda s: self._rvs.lire(s, id))

    def get_creneau_by_id(self, id: int) -> Creneau | None:
        return self._lecture(lambda s: self._creneaux.lire(s, id))

    def get_client_by_user_id(self, id_user: int) -> Client | None:
        return self._lecture(lambda s: self._clients.lire_par_user(s, id_user))

    def get_rv_a_venir_client(self, id_client: int) -> list[Rv]:
        return self._lecture(lambda s: self._rvs.rv_a_venir_client(s, id_client, dates.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
        creneaux_horaires = self.get_all_creneaux(id_medecin)
        # n° de créneau -> rendez-vous pris sur ce créneau (un dictionnaire "en compréhension")
        reservations = {rv.id_creneau: rv for rv in self.get_rv_medecin_jour(id_medecin, jour)}
        creneaux_medecin_jour = [CreneauMedecinJour(creneau, reservations.get(creneau.id))
                                 for creneau in creneaux_horaires]
        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 MetierException par _transaction.
    # -----------------------------------------------------------------------
    def ajouter_rv(self, jour: date, creneau: Creneau, client: Client) -> Rv:
        def travail(session: Session) -> Rv:
            aujourdhui = dates.aujourdhui()
            if jour < aujourdhui:
                raise MetierException("ERRORS.PAST_DATE")
            id_patient = self._clients.verrouiller(session, client.id)
            if id_patient is None:
                raise MetierException("ERRORS.PATIENT_NOT_FOUND")
            if self._rvs.compter_a_venir_client(session, id_patient, aujourdhui) > 0:
                raise MetierException("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT")
            # il suffit de connaître les id du créneau et du patient pour écrire
            # les clés étrangères : inutile de relire ces objets dans la session
            return self._rvs.ajouter(session, Rv(jour=jour, id_creneau=creneau.id,
                                                 id_client=id_patient))

        return self._transaction(travail)

    def supprimer_rv(self, rv: Rv) -> None:
        self._transaction(lambda session: self._rvs.supprimer(session, rv.id))

    # =======================================================================
    # médecins et clients - les méthodes publiques délèguent aux méthodes
    # privées 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 ET la personne, dans UNE transaction.
    # -----------------------------------------------------------------------
    def _ajouter_personne(self, d: Descripteur, donnees: DonneesPersonne):
        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 en a saisi d'autres
        login = (donnees.login or nom).lower()
        password = donnees.password or login

        def travail(session: Session):
            self._verifier_personne_unique(session, d, prenom, nom, None)
            self._verifier_login_libre(session, login)
            user = User(login=login, password=hacher(password), nom=f"{prenom} {nom}", role=d.role)
            personne = d.fabrique(titre=donnees.titre, nom=nom, prenom=prenom, user=user)
            # la personne référence son compte : SQLAlchemy insère d'abord le
            # compte (pour connaître son id), puis la personne
            d.dao.ajouter(session, personne)
            # flush : les INSERT partent tout de suite (et non au COMMIT) ; une
            # contrainte violée lève donc son erreur ICI
            session.flush()
            return personne

        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):
        def travail(session: Session):
            personne = d.dao.lire(session, id)
            if personne is None:
                raise MetierException(d.cle_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.
            # (Si la modification concurrente a lieu APRÈS cette lecture, c'est
            # la clause "WHERE version = ..." de l'UPDATE qui la détecte.)
            if donnees.version is not None and donnees.version != personne.version:
                raise MetierException("ERRORS.CONCURRENT_UPDATE")

            nom = donnees.nom.strip().upper()
            prenom = donnees.prenom.strip()
            self._verifier_personne_unique(session, d, prenom, nom, id)

            # on se contente de modifier les objets : ils sont "suivis" par la
            # session, qui enverra elle-même les UPDATE nécessaires
            personne.titre = donnees.titre
            personne.nom = nom
            personne.prenom = prenom

            user = personne.user
            user.nom = f"{prenom} {nom}"
            if donnees.login and donnees.login.lower() != user.login:
                login = donnees.login.lower()
                self._verifier_login_libre(session, login)
                user.login = login
            if donnees.password:
                user.password = hacher(donnees.password)
            session.flush()
            return personne

        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) ;
    #   4. son compte.
    # -----------------------------------------------------------------------
    def _supprimer_personne(self, d: Descripteur, id: int) -> None:
        def travail(session: Session):
            personne = d.dao.lire(session, id)
            if personne is None:
                raise MetierException(d.cle_introuvable)
            if d.compter_rv is not None and d.compter_rv(session, id) > 0:
                raise MetierException(d.cle_avec_rv)
            if d.avant_suppression is not None:
                d.avant_suppression(session, id)
            d.dao.supprimer(session, id)
            self._users.supprimer(session, personne.id_user)

        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)
    @staticmethod
    def _verifier_personne_unique(session: Session, d: Descripteur, prenom: str, nom: str,
                                  id_courant: int | None):
        homonyme = d.dao.homonyme(session, prenom, nom)
        if homonyme is not None and homonyme.id != id_courant:
            raise MetierException(d.cle_existe_deja)

    def _verifier_login_libre(self, session: Session, login: str):
        if self._users.lire_par_login(session, login) is not None:
            raise MetierException("ERRORS.LOGIN_TAKEN")

    # -----------------------------------------------------------------------
    # exécute [travail] dans une transaction :
    #   sessions.begin() -> une session et sa transaction (START TRANSACTION) ;
    #   à la sortie du bloc with : COMMIT si tout s'est bien passé ; si une
    #   exception en sort (MetierException comprise), 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. On intercepte donc les erreurs de la base AUTOUR de la
    # transaction, pour les traduire en la même MetierException :
    #   - StaleDataError : l'UPDATE "WHERE id = ... AND version = ..." n'a
    #     modifié aucune ligne (verrou optimiste) ;
    #   - IntegrityError : une contrainte de la base a refusé l'écriture.
    # Toute autre erreur est une vraie anomalie : on la laisse remonter.
    # -----------------------------------------------------------------------
    def _transaction[R](self, travail: Callable[[Session], R]) -> R:
        try:
            with self._sessions.begin() as session:
                return travail(session)
        except StaleDataError:
            raise MetierException("ERRORS.CONCURRENT_UPDATE")
        except IntegrityError as e:
            correspondance = CONTRAINTE.search(str(e.orig))
            if correspondance and correspondance.group(1) in MESSAGES_CONTRAINTES:
                raise MetierException(MESSAGES_CONTRAINTES[correspondance.group(1)])
            raise

11.12.5.1. Les descripteurs

Les médecins et les clients obéissent exactement aux mêmes règles de gestion, à quelques détails près : un DAO différent, un rôle différent pour leur compte, des messages d'erreur différents, et une règle de suppression différente. Plutôt que d'écrire deux fois chaque méthode, le code commun est écrit une fois (les méthodes [_ajouter_personne], [_modifier_personne], [_supprimer_personne]), et paramétré par un descripteur qui dit à quel type de personne on a affaire :

  • lignes 59-71 : le descripteur est une [dataclass] immuable. Trois de ses champs sont des fonctions : [Callable[[Session, int], int]] est le type d'une fonction qui reçoit une session et un entier, et rend un entier. [compter_rv] compte les rendez-vous d'une personne (pour refuser la suppression d'un médecin qui en a) ; [avant_suppression] supprime ce qui référence la personne (les créneaux d'un médecin, les rendez-vous d'un client) ;
  • lignes 109-118 : le descripteur des médecins. [rvs.compter_par_medecin] est une méthode liée à l'objet [rvs] (exemple 22) : une fonction qui recevra la session et l'id ;
  • lignes 120-128 : celui des clients : pas de refus de suppression ([compter_rv=None]), mais la suppression préalable de tous leurs rendez-vous.

11.12.5.2. Les lectures et l'agenda

  • lignes 135-137 : les lectures sont confiées à une petite méthode générique : [R] est le type du résultat, celui de la fonction [travail]. La session est ouverte par [self._sessions()] (sans [begin]), le temps d'exécuter la fonction, puis fermée par le bloc [with]. [SQLAlchemy] commence tout de même une transaction à la première requête ([BEGIN (implicit)] dans la console) ; comme rien n'a été écrit, la fermeture de la session l'annule ([ROLLBACK]) : ce sont les lignes que la console affiche au démarrage de l'application ;
  • lignes 139-167 : chaque lecture confie à [_lecture] la méthode DAO à appeler. Quand la méthode DAO a d'autres paramètres que la session, une fonction anonyme ([lambda s: ...]) les lui fournit ;
  • lignes 171-180 : l'agenda d'un médecin pour un jour. Les rendez-vous du jour sont rangés dans un dictionnaire en compréhension ([{clé: valeur for ...}]) qui associe à chaque numéro de créneau son rendez-vous ; chaque créneau est ensuite associé à son rendez-vous, ou à [None] ([reservations.get(creneau.id)]). Trois lectures, dans trois sessions : une incohérence entre elles (un rendez-vous pris entre deux lectures) serait sans conséquence, l'agenda n'étant qu'un affichage.

11.12.5.3. La prise de rendez-vous

  • lignes 198-213 : la méthode définit une fonction interne, [travail], qui contient les opérations à faire dans la transaction, et la confie à la méthode [_transaction] (plus loin). Les règles, dans l'ordre : - pas de rendez-vous dans le passé ([jour < aujourdhui] : les dates [Python] se comparent avec les opérateurs habituels) ; - la ligne du patient est verrouillée ([verrouiller], cf. [ClientRepository]) : si le même patient réserve deux créneaux au même instant (deux onglets, un double clic), la seconde transaction attend la fin de la première, et voit alors le rendez-vous qu'elle a créé ; - un seul rendez-vous à venir par patient ; - enfin l'insertion. Le rendez-vous est créé avec les id du créneau et du patient : il suffit de connaître les valeurs des clés étrangères, inutile de relire les objets. Les paramètres nommés du constructeur ([Rv(jour=..., id_creneau=...)]) sont fournis par [SQLAlchemy] à toute entité qui n'a pas son propre constructeur.
  • Il reste un cas : le créneau vient d'être pris par un autre patient, entre l'affichage de l'agenda et la réservation. C'est la contrainte [unq1_rv] de la base qui refuse alors l'insertion, au moment du [COMMIT]. La méthode [_transaction] traduit cette erreur en [MetierException] ;
  • lignes 215-216 : l'annulation d'un rendez-vous. Les vérifications de droits (un patient n'annule que ses propres rendez-vous) sont faites par la couche web, qui connaît l'utilisateur.

11.12.5.4. La gestion des médecins et des clients

  • lignes 223-241 : les méthodes publiques délèguent aux méthodes communes, avec le bon descripteur ;
  • lignes 246-267 : la création. Le nom est mis en majuscules, le prénom débarrassé de ses espaces ; le login vaut par défaut le nom en minuscules, le mot de passe le login (la règle des comptes de démonstration du script SQL). Dans la transaction : les vérifications (pas d'homonyme, login libre), puis la création du compte et de la personne. La personne reçoit son compte par sa relation ([user=user]) : [SQLAlchemy] sait qu'il doit insérer d'abord le compte, pour connaître son id, puis la personne. [session.flush()] envoie les [INSERT] tout de suite : une contrainte violée lève son erreur dans la transaction, et la personne rendue a son id ;
  • lignes 274-309 : la modification. La version du formulaire est comparée à celle de la base (verrou optimiste, exemple 28) ; puis on se contente de modifier les objets lus : ils sont suivis par la session, qui enverra elle-même les [UPDATE] nécessaires. Le nom affiché du compte suit le prénom et le nom ; le login et le mot de passe ne changent que s'ils ont été saisis ;
  • lignes 320-332 : la suppression, dans l'ordre imposé par les clés étrangères : refus éventuel (un médecin qui a des rendez-vous), suppression de ce qui référence la personne, de la personne elle-même, puis de son compte. Dans l'ordre inverse, la base refuserait chaque [DELETE]. Le tout dans une transaction : si l'une des étapes échoue, aucune n'a lieu ;
  • lignes 337-342 : un homonyme est refusé, sauf s'il s'agit de la personne en cours de modification, qui a le droit de garder ses propres prénom et nom ;
  • lignes 344-346 : un login ne peut appartenir qu'à un compte.

11.12.5.5. Les transactions et les contraintes d'unicité

  • lignes 365-375 : toutes les écritures passent par cette méthode. [sessions.begin()] ouvre une session et sa transaction ; à la sortie du bloc [with], la transaction est validée ([COMMIT]) si tout s'est bien passé, annulée ([ROLLBACK]) si une exception en sort, [MetierException] comprise ;
  • lignes 369-370 : le verrou optimiste a détecté une modification concurrente (exemple 28) ;
  • lignes 371-374 : une contrainte de la base a refusé l'écriture. Les vérifications faites avant l'écriture (login libre, pas d'homonyme) donnent un message clair dans l'immense majorité des cas ; mais deux administrateurs pourraient enregistrer le même login au même instant, ou deux patients réserver le même créneau. La contrainte d'unicité refuse alors la seconde écriture, et [SQLAlchemy] lève une [IntegrityError]. Son message d'origine ([e.orig], l'erreur de [PyMySQL]) cite le nom de la contrainte (ici avec [MariaDB], pour le deuxième créneau réservé deux fois le même jour) :
(1062, "Duplicate entry '2026-09-26-2' for key 'unq1_rv'")
  • l'expression régulière [CONTRAINTE] (ligne 91) extrait ce nom. [MySQL] 8 le préfixe par le nom de la table (['rv.unq1_rv']), [MariaDB] non : la partie [(?:[^'.]*\.)?] accepte ce préfixe facultatif ([(?:...)] est un groupe non capturant, [?] le rend facultatif). Le dictionnaire [MESSAGES_CONTRAINTES] (lignes 84-88) associe à chaque contrainte connue la clé de son message. C'est pour cela que le script SQL nomme toutes ses contraintes ;
  • ligne 375 : toute autre erreur est une vraie anomalie : [raise], sans paramètre, relance l'exception en cours, qui aboutira à la page d'erreur 500.

Remarquons la structure de toutes les méthodes d'écriture : une fonction interne [travail], qui contient les opérations, confiée à [_transaction], qui l'exécute dans une transaction et traduit les erreurs. C'est l'équivalent de l'annotation [@Transactional] de [Spring], écrit en quelques lignes : une fonction est un objet comme un autre, qu'on peut passer en paramètre.

11.13. L'authentification

Le dossier [auth] reprend les mécanismes des exemples 25 à 27, avec trois différences : la règle d'accès est renversée (tout est interdit, sauf ce qui est déclaré public), les utilisateurs sont lus dans la base, et la création d'un compte client est protégée par un « laissez-passer ».

11.13.1. auth/mots_de_passe.py

# =========================================================================
# auth/mots_de_passe.py
# -------------------------------------------------------------------------
# Le hachage des mots de passe : bcrypt, 10 tours, version $2b$ - comme les
# hachages du script SQL.
#
# bcrypt ne tient compte que des 72 premiers OCTETS d'un mot de passe (un
# caractère accentué en occupe deux, en UTF-8) ; la bibliothèque bcrypt
# refuse même un mot de passe plus long. On ne lui transmet donc que ces
# 72 octets, à la création comme à la vérification.
# =========================================================================
import bcrypt


def _octets(mot_de_passe: str) -> bytes:
    return mot_de_passe.encode("utf-8")[:72]


# le hachage à enregistrer en base : "$2b$10$" + le sel + le résultat
def hacher(mot_de_passe: str) -> str:
    return bcrypt.hashpw(_octets(mot_de_passe), bcrypt.gensalt(rounds=10)).decode("ascii")


# checkpw hache le mot de passe saisi avec le "sel" contenu dans le hachage
# enregistré, puis compare les deux résultats
def verifier(mot_de_passe: str, hachage: str) -> bool:
    return bcrypt.checkpw(_octets(mot_de_passe), hachage.encode("ascii"))
  • lignes 15-16 : [bcrypt] ne tient compte que des 72 premiers octets d'un mot de passe, et la version 5 de la bibliothèque refuse un mot de passe plus long (exemple 25). On ne lui transmet donc que ces 72 octets, à la création comme à la vérification : un mot de passe plus long est accepté, mais ses caractères au-delà du 72e octet sont ignorés. Un caractère accentué occupe deux octets en [UTF-8] ;
  • lignes 20-21 : le hachage est rangé en base sous forme de texte : [decode("ascii")] convertit les octets rendus par [bcrypt] en chaîne ;
  • lignes 26-27 : la vérification (exemple 25). Ce module est utilisé par l'authentification et par la couche métier (création d'un compte, changement de mot de passe).

11.13.2. auth/utilisateur.py

# =========================================================================
# auth/utilisateur.py
# -------------------------------------------------------------------------
# Ce que le jeton JWT contient, et comment le relire.
#
# Les claims de nos jetons (cf. AuthService.creer_jeton) :
#   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
# =========================================================================
from dataclasses import dataclass

from rdvmedecins.entities import Role


# l'utilisateur connecté, sous une forme typée
@dataclass(frozen=True)
class UtilisateurConnecte:
    user_id: int
    login: str
    nom: str
    role: Role

    # 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
    def de(claims: dict) -> "UtilisateurConnecte | None":
        try:
            return UtilisateurConnecte(int(claims["sub"]), claims["login"], claims["nom"],
                                       Role(claims["role"]))
        except (KeyError, ValueError, TypeError):
            return None

    @property
    def est_admin(self) -> bool:
        return self.role == Role.ADMIN

    @property
    def est_patient(self) -> bool:
        return self.role == Role.USER
  • lignes 20-24 : l'utilisateur connecté, sous une forme typée : l'exemple 25 rangeait dans [g.utilisateur] le dictionnaire des affirmations du jeton ; l'application range cet objet, dont les attributs sont connus de l'éditeur ([g.utilisateur.role]), et dont le rôle est une valeur de l'énumération [Role] ;
  • lignes 29-34 : la construction à partir des affirmations d'un jeton déjà vérifié. Le type du résultat est écrit entre guillemets (["UtilisateurConnecte | None"]) : au moment où [Python] lit la définition de la méthode, la classe [UtilisateurConnecte] n'est pas encore entièrement définie, et son nom ne peut pas être utilisé directement. Un jeton dont une affirmation manque ([KeyError]) ou a une valeur inattendue ([ValueError] : un rôle inconnu, un [sub] qui n'est pas un nombre) donne [None] : l'utilisateur n'est pas authentifié ;
  • lignes 36-42 : deux propriétés : le décorateur [@property] transforme une méthode en attribut calculé, qui s'utilise sans parenthèses : [g.utilisateur.est_admin].

Le jeton contient l'id du compte ([sub], sous forme de texte, comme l'exige la norme JWT), le login, le nom affiché et le rôle : tout ce dont la barre de navigation et les règles d'accès ont besoin, sans lire la base à chaque requête.

11.13.3. auth/auth_service.py

# =========================================================================
# auth/auth_service.py
# -------------------------------------------------------------------------
# La logique d'authentification :
#   - valider_utilisateur() : vérifie un couple login / mot de passe ;
#   - deposer_jeton()       : fabrique le jeton JWT d'un utilisateur validé
#                             et le range dans un cookie de la réponse ;
#   - effacer_jeton()       : supprime ce cookie ;
#   - utilisateur()         : l'utilisateur décrit par le cookie de la requête.
#
# Où le jeton JWT voyage-t-il ?
# Avec un client JavaScript, c'était le code du client qui gardait le jeton
# et l'ajoutait lui-même à chaque requête (entête "Authorization: Bearer
# ..."). Un navigateur qui affiche des pages HTML ordinaires ne sait pas
# faire cela : pour qu'il présente le jeton tout seul à chaque requête, il
# faut le ranger dans un COOKIE. 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, cf. rdvmedecins/__init__.py) ;
#   - Secure : en production (HTTPS), le cookie ne circule jamais en clair ;
#   - Max-Age : le cookie disparaît en même temps que le jeton expire.
# =========================================================================
from datetime import datetime, timedelta, timezone

import jwt
from flask import Request, Response

from rdvmedecins.auth.mots_de_passe import verifier
from rdvmedecins.auth.utilisateur import UtilisateurConnecte
from rdvmedecins.entities import User
from rdvmedecins.repositories.user_repository import UserRepository

# le nom du cookie qui transporte le jeton
COOKIE_JWT = "rdvmedecins_jwt"


class AuthService:

    def __init__(self, sessions, users: UserRepository, secret: str, duree_secondes: int,
                 cookie_secure: bool):
        # HMAC-SHA256 exige une clé de 256 bits au moins : on le dit
        # clairement au démarrage plutôt qu'à la première connexion
        if len(secret.encode("utf-8")) < 32:
            raise RuntimeError("JWT_SECRET doit comporter au moins 32 caractères (cf. config.py).")
        self._sessions = sessions
        self._users = users
        self._secret = secret
        self._duree = timedelta(seconds=duree_secondes)
        self._cookie_secure = cookie_secure

    # None si le login n'existe pas ou si le mot de passe est faux
    def valider_utilisateur(self, login: str, password: str) -> User | None:
        with self._sessions() as session:
            user = self._users.lire_par_login(session, login)
        if user is None or not verifier(password, user.password):
            return None
        return user

    # -----------------------------------------------------------------------
    # fabrique le jeton JWT de [user] et le dépose dans un cookie.
    # 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 deposer_jeton(self, reponse: Response, user: User):
        maintenant = datetime.now(timezone.utc)
        claims = {
            "sub": str(user.id),
            "login": user.login,
            "nom": user.nom,
            "role": user.role.value,
            "iat": maintenant,
            "exp": maintenant + self._duree,
        }
        jeton = jwt.encode(claims, self._secret, algorithm="HS256")
        reponse.set_cookie(COOKIE_JWT, jeton, max_age=self._duree, path="/", httponly=True,
                           samesite="Lax", secure=self._cookie_secure)

    # efface le cookie : on le redépose, vide, avec une durée de vie nulle
    @staticmethod
    def effacer_jeton(reponse: Response):
        reponse.delete_cookie(COOKIE_JWT, path="/")

    # le jeton présent dans la requête, qu'il soit valide ou non
    @staticmethod
    def jeton_de(requete: Request) -> str | None:
        return requete.cookies.get(COOKIE_JWT)

    # -----------------------------------------------------------------------
    # l'utilisateur décrit par le cookie de la requête ; None s'il n'y a pas
    # de jeton, ou si le jeton est expiré, falsifié ou mal formé.
    # jwt.decode vérifie la signature ET la date d'expiration (claim exp).
    # -----------------------------------------------------------------------
    def utilisateur(self, requete: Request) -> UtilisateurConnecte | None:
        jeton = self.jeton_de(requete)
        if not jeton:
            return None
        try:
            claims = jwt.decode(jeton, self._secret, algorithms=["HS256"])
        except jwt.InvalidTokenError:
            return None
        return UtilisateurConnecte.de(claims)
  • lignes 46-47 : une clé de signature trop courte est signalée au démarrage de l'application, plutôt qu'à la première connexion : [create_app] crée le service, et l'exception arrête l'application avec ce message ;
  • lignes 55-60 : la vérification d'un login et d'un mot de passe. Le service lit l'utilisateur par le DAO, dans une session ouverte le temps de cette lecture ; la vérification du mot de passe ([bcrypt], lente) se fait après la fermeture de la session, pour ne pas garder une connexion à la base inutilement ;
  • lignes 68-80 : le jeton (exemple 25) et son cookie, [rdvmedecins_jwt]. [user.role.value] : la valeur de l'énumération, la chaîne ["USER"]. Le cookie reçoit l'attribut [Secure] en production ([COOKIE_SECURE]) ;
  • lignes 84-85 : l'effacement du cookie. C'est une méthode statique : elle n'a pas besoin de l'objet, et s'appelle sur la classe ([AuthService.effacer_jeton(reponse)]) ;
  • lignes 97-105 : l'utilisateur décrit par le cookie de la requête, ou [None].

11.13.4. auth/securite.py

Le cœur de la sécurité : l'authentification de chaque requête, et la règle d'accès.

# =========================================================================
# auth/securite.py
# -------------------------------------------------------------------------
# Qui peut accéder à quoi, et comment l'utilisateur est reconnu à chaque
# requête (cf. exemples 25 et 26).
#
# Pour chaque requête, la fonction authentifier (un before_request de
# l'application) :
#   1. lit le jeton du cookie, vérifie sa signature et sa date d'expiration,
#      et range l'utilisateur dans g.utilisateur (None s'il n'y en a pas) ;
#   2. applique la règle d'accès : TOUTES les actions exigent un utilisateur
#      authentifié, sauf celles qui sont marquées @publique (connexion,
#      création de compte, choix de la langue) et les fichiers statiques.
#      C'est la règle "tout est interdit sauf ce qui est permis" : une action
#      ajoutée plus tard, et qu'on aurait oublié de protéger, l'est d'office.
# Ensuite seulement, Flask appelle l'action, dont le décorateur
# @roles_requis(...) vérifie le rôle.
#
# Deux cas d'échec, deux réponses adaptées à une application HTML :
#   - pas d'utilisateur authentifié (pas de jeton, jeton expiré ou falsifié)
#     -> redirection vers la page de connexion ;
#   - rôle insuffisant -> réponse 403, dont web/erreurs.py fait une page.
# =========================================================================
from functools import wraps

from flask import abort, current_app, g, redirect, request, url_for

from rdvmedecins.auth.auth_service import AuthService
from rdvmedecins.entities import Role
from rdvmedecins.web.flash import flash_erreur


# 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 :
# web/erreurs.py efface le cookie et renvoie vers la page de connexion.
class SessionInvalide(Exception):
    pass


# un décorateur qui MARQUE une action comme publique (cf. exemple 24)
def publique(action):
    action.publique = True
    return action


def enregistrer(app, auth_service: AuthService):

    @app.before_request
    def authentifier():
        g.utilisateur = auth_service.utilisateur(request)
        # aucune route ne correspond (la page 404 suivra), ou un fichier statique
        if request.endpoint is None or request.endpoint == "static":
            return None
        action = current_app.view_functions[request.endpoint]
        if getattr(action, "publique", False) or g.utilisateur is not None:
            return None
        # une réponse rendue par un before_request arrête le traitement :
        # l'action n'est pas appelée
        return vers_login()


# -----------------------------------------------------------------------
# pas d'utilisateur authentifié -> page de connexion. Si un cookie était
# présent, c'est que son jeton n'est plus valide : la session a expiré.
# On efface alors le cookie, et on prévient l'utilisateur par un message flash.
# -----------------------------------------------------------------------
def vers_login():
    reponse = redirect(url_for("auth.afficher_login"))
    if AuthService.jeton_de(request):
        AuthService.effacer_jeton(reponse)
        flash_erreur("ERRORS.SESSION_EXPIRED")
    return reponse


# -----------------------------------------------------------------------
# le rôle de l'utilisateur doit être l'un de [roles] ; sinon, réponse 403.
# exiger_roles s'appelle depuis un before_request de blueprint (toutes les
# actions du blueprint, cf. controllers/personnes.py) ; roles_requis est
# le décorateur d'une action (cf. exemple 26).
# -----------------------------------------------------------------------
def exiger_roles(*roles: Role):
    if g.utilisateur is None:
        raise SessionInvalide()
    if g.utilisateur.role not in roles:
        abort(403)


def roles_requis(*roles: Role):
    def decorateur(action):
        @wraps(action)
        def action_protegee(*args, **kwargs):
            exiger_roles(*roles)
            return action(*args, **kwargs)

        return action_protegee

    return decorateur
  • lignes 37-38 : une exception pour 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 actions qui ont besoin du compte la lèvent ; le gestionnaire d'erreurs la traite comme une session expirée (cf. [web/erreurs.py]) ;
  • lignes 42-44 : le décorateur qui marque une action comme publique (exemple 24) ;
  • lignes 50-60 : la fonction [before_request] de l'application. D'abord, l'utilisateur est établi, pour toutes les requêtes, y compris les requêtes publiques : la page de connexion doit savoir si l'utilisateur est déjà connecté. Puis la règle d'accès : une requête qui ne correspond à aucune route ([request.endpoint is None] : la page 404 suivra) ou qui demande un fichier statique passe ; une action marquée publique passe ; un utilisateur authentifié passe. Sinon, la fonction rend une réponse (la redirection vers la page de connexion), ce qui arrête le traitement : l'action n'est pas appelée.

C'est la règle « tout est interdit sauf ce qui est permis » : une action ajoutée plus tard, et que l'on aurait oublié de protéger, l'est d'office. Oublier [@publique] sur une action publique se voit tout de suite (la page redirige vers la connexion) ; oublier de protéger une action privée ne se voit pas : la règle par défaut doit être la plus sûre. C'est aussi la règle de la version [Spring MVC] ([anyRequest().authenticated()]) ;

  • lignes 68-73 : la redirection vers la page de connexion. Si la requête portait un cookie de jeton, c'est que ce jeton n'est plus valide (expiré, falsifié) : on efface le cookie, et un message flash explique à l'utilisateur que sa session a expiré (la copie d'écran de la fin de session, plus haut) ;
  • lignes 82-86 : l'autorisation. Le paramètre [*roles] reçoit un nombre quelconque de rôles, dans un tuple : [exiger_roles(Role.ADMIN, Role.DOCTOR)]. Un rôle absent de la liste donne une erreur 403. Le test [g.utilisateur is None] ne devrait jamais être vrai (la règle d'accès a déjà écarté les visiteurs anonymes), sauf si l'on appelait [exiger_roles] depuis une action publique : c'est une sécurité supplémentaire ;
  • lignes 89-98 : le décorateur à paramètres de l'exemple 26, qui appelle [exiger_roles]. [exiger_roles] s'utilise aussi seule, dans le [before_request] d'un blueprint, pour protéger toutes ses actions d'un coup (cf. [controllers/personnes.py]).

11.13.5. auth/dessin_captcha.py

Le dessin des images est celui de l'exemple 27, repris tel quel. Son commentaire d'entête rappelle un point important : chaque caractère est dessiné par des traits (des éléments [<path>]), et jamais écrit comme du texte (un élément [<text>]) : sinon, un robot n'aurait qu'à lire le code source de l'image.

11.13.6. auth/captcha.py

# =========================================================================
# auth/captcha.py
# -------------------------------------------------------------------------
# Un CAPTCHA ("test pour distinguer un humain d'un robot") fabriqué par le
# serveur lui-même, sans service extérieur : quelques caractères déformés,
# brouillés par des traits, dans une image SVG (cf. dessin_captcha.py). Un
# humain les lit sans peine ; un robot qui se contente d'envoyer des
# formulaires en boucle ne sait pas le faire.
#
# Fonctionnement :
#   1. generer() tire un texte au hasard, en fait une image, et range la
#      bonne réponse EN MÉMOIRE 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.
#
# Un seul objet CaptchaService pour tout le serveur, utilisé par plusieurs
# requêtes EN PARALLÈLE (le serveur les traite sur plusieurs threads) : un
# verrou (Lock) garantit qu'un seul thread à la fois lit ou modifie le
# dictionnaire des captchas en attente.
#
# Limite assumée : la mémoire d'un seul processus. Avec plusieurs processus
# ou plusieurs serveurs, il faudrait un stockage partagé.
# =========================================================================
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock

from rdvmedecins.auth import dessin_captcha

# durée de validité d'une image : on a 10 minutes pour remplir le formulaire
DUREE_VIE = timedelta(minutes=10)

# garde-fou mémoire : au-delà, on oublie tous les captchas en attente
MAX_CAPTCHAS = 10_000


# un captcha : id, à placer dans le champ caché "captchaId" du formulaire ;
# svg, l'image, à insérer telle quelle dans la page
@dataclass(frozen=True)
class Captcha:
    id: str
    svg: str


class CaptchaService:

    def __init__(self):
        # id -> (réponse attendue, date d'expiration)
        self._en_attente: dict[str, tuple[str, datetime]] = {}
        self._verrou = Lock()

    def generer(self) -> Captcha:
        texte = dessin_captcha.texte_aleatoire(5)  # 5 caractères
        id = secrets.token_urlsafe(16)  # un identifiant aléatoire, impossible à deviner
        with self._verrou:
            self._purger()
            self._en_attente[id] = (texte, datetime.now() + 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 : si deux
    # requêtes présentent le même captcha au même instant, une seule le
    # retire (pop, sous le verrou) - l'autre échoue.
    def verifier(self, id: str | None, saisie: str | None) -> bool:
        with self._verrou:
            attendu = self._en_attente.pop(id, None) if id else None
        if attendu is None or saisie is None:
            return False
        texte, expire = attendu
        return expire > datetime.now() and texte.lower() == saisie.strip().lower()

    # (appelée sous le verrou)
    def _purger(self):
        maintenant = datetime.now()
        for id in [id for id, (_, expire) in self._en_attente.items() if expire <= maintenant]:
            del self._en_attente[id]
        if len(self._en_attente) >= MAX_CAPTCHAS:
            self._en_attente.clear()

Le service de l'exemple 27, avec un complément : la méthode [_purger] (lignes 77-82), appelée à chaque création d'un captcha, oublie les captchas expirés. Sans elle, les captchas affichés mais jamais utilisés (une page de connexion ouverte puis abandonnée) s'accumuleraient en mémoire. [MAX_CAPTCHAS] est un garde-fou : un robot qui demanderait des millions de pages de connexion ne pourrait pas épuiser la mémoire du serveur. Le nombre [10_000] s'écrit avec un souligné, qui sépare les milliers pour la lisibilité ([Python] l'ignore). La liste des identifiants à supprimer est construite avant la boucle de suppression : on ne peut pas supprimer des clés d'un dictionnaire pendant qu'on le parcourt.

11.13.7. auth/limiteur.py

# =========================================================================
# auth/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.
#
#   - connexion : après 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é LOGIN_BLOCAGE_MINUTES minutes. Une même adresse IP est aussi
#     bloquée si elle cumule 5 fois plus d'é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
# dans config.py.
#
# Un seul objet pour tout le serveur, appelé par des requêtes parallèles :
# toutes les méthodes publiques prennent le verrou - un seul thread à la
# fois lit ET modifie les compteurs.
# =========================================================================
import math
from datetime import datetime, timedelta
from threading import Lock

UNE_HEURE = timedelta(hours=1)


class Compteur:
    def __init__(self, debut: datetime):
        self.nombre = 0  # tentatives comptées dans la fenêtre en cours
        self.debut = debut  # début de la fenêtre de comptage
        self.bloque_jusqua = datetime.min  # datetime.min si pas bloqué


class LimiteurTentatives:

    def __init__(self, login_max_echecs: int, login_blocage_minutes: int,
                 inscription_max_par_heure: int):
        self._compteurs: dict[str, Compteur] = {}
        self._verrou = Lock()
        self._max_echecs_login = login_max_echecs
        self._duree_blocage_login = timedelta(minutes=login_blocage_minutes)
        self._max_inscriptions = inscription_max_par_heure

    # ---------------------------------------------------------------- connexion

    # nombre de minutes de blocage restantes (0 = la tentative est autorisée)
    def minutes_blocage_login(self, ip: str, login: str) -> int:
        with self._verrou:
            return max(self._minutes_restantes(self._cle_login(ip, login)),
                       self._minutes_restantes(self._cle_ip(ip)))

    def echec_login(self, ip: str, login: str):
        with self._verrou:
            self._compter(self._cle_login(ip, login), self._max_echecs_login,
                          self._duree_blocage_login, self._duree_blocage_login)
            self._compter(self._cle_ip(ip), self._max_echecs_login * 5,
                          self._duree_blocage_login, self._duree_blocage_login)

    # une connexion réussie remet à zéro le compteur de CE login
    def reussite_login(self, ip: str, login: str):
        with self._verrou:
            self._compteurs.pop(self._cle_login(ip, login), None)

    # ---------------------------------------------------------------- inscription

    def minutes_blocage_inscription(self, ip: str) -> int:
        with self._verrou:
            return self._minutes_restantes(f"inscription|{ip}")

    # fenêtre d'une heure ; une fois le quota atteint, blocage jusqu'à la fin de l'heure
    def inscription_reussie(self, ip: str):
        with self._verrou:
            self._compter(f"inscription|{ip}", self._max_inscriptions, UNE_HEURE, UNE_HEURE)

    # ---------------------------------------------------------------- mécanique commune
    # (les méthodes privées sont toujours appelées sous le verrou)

    def _minutes_restantes(self, cle: str) -> int:
        compteur = self._compteurs.get(cle)
        if compteur is None:
            return 0
        reste = (compteur.bloque_jusqua - datetime.now()).total_seconds()
        # arrondi à la minute SUPÉRIEURE : 30 s de blocage restantes -> "1 minute"
        return max(0, math.ceil(reste / 60))

    # compte une tentative ; si le seuil est atteint dans la fenêtre, bloque
    def _compter(self, cle: str, maximum: int, fenetre: timedelta, blocage: timedelta):
        maintenant = datetime.now()
        self._purger(maintenant)
        compteur = self._compteurs.get(cle)
        if compteur is None or maintenant - compteur.debut > fenetre:
            compteur = Compteur(maintenant)
            self._compteurs[cle] = compteur
        compteur.nombre += 1
        if compteur.nombre >= maximum:
            compteur.bloque_jusqua = maintenant + blocage
            compteur.nombre = 0  # après le blocage, on repart de zéro
            compteur.debut = compteur.bloque_jusqua

    # on oublie les compteurs qui ne servent plus (ni bloqués, ni récents)
    def _purger(self, maintenant: datetime):
        if len(self._compteurs) < 1000:
            return
        for cle in [cle for cle, c in self._compteurs.items()
                    if c.bloque_jusqua < maintenant and maintenant - c.debut > UNE_HEURE]:
            del self._compteurs[cle]

    @staticmethod
    def _cle_login(ip: str, login: str) -> str:
        return f"login|{ip}|{login.strip().lower()}"

    @staticmethod
    def _cle_ip(ip: str) -> str:
        return f"ip|{ip}"

Une version plus complète du limiteur de l'exemple 27 :

  • le blocage de la connexion porte d'abord sur le couple (adresse IP, login) : en salle de cours, tous les postes sortent souvent sur Internet par la même adresse IP, et un étudiant qui se trompe de mot de passe ne doit pas bloquer ses voisins. Mais une adresse est aussi bloquée si elle cumule cinq fois plus d'échecs, tous logins confondus : c'est le cas d'un robot qui essaie des milliers de logins différents (lignes 57-61) ;
  • la création de comptes est limitée à un nombre de comptes par heure et par adresse IP (lignes 71-78) ;
  • les compteurs sont rangés dans un seul dictionnaire, sous des clés préfixées par leur usage ([login|...], [ip|...], [inscription|...]) ;
  • lignes 92-103 : un compteur compte les tentatives dans une fenêtre de temps ; une tentative arrivée après la fin de la fenêtre en ouvre une nouvelle. Au seuil, la clé est bloquée, et le compteur repart de zéro ;
  • lignes 106-111 : au-delà de mille compteurs, ceux qui ne servent plus (ni bloqués, ni récents) sont oubliés.

Comme le captcha, le limiteur garde ses données dans la mémoire du processus : c'est acceptable pour une application servie par un seul processus (le serveur de développement, [Waitress]), mais pas pour plusieurs processus ou plusieurs serveurs, qui ne partageraient pas leurs compteurs (il faudrait alors un stockage partagé, comme [Redis]).

11.13.8. auth/passe_inscription.py

# =========================================================================
# auth/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.
# controllers/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 : taper directement l'URL /inscription ne permet
# pas de contourner le captcha.
#
# Comme le captcha, les laissez-passer vivent dans la mémoire du processus.
# =========================================================================
import secrets
from datetime import datetime, timedelta
from threading import Lock

from flask import Response

COOKIE = "rdvmedecins_inscription"

DUREE_VIE = timedelta(minutes=15)
MAX_PASSES = 10_000


class PasseInscription:

    def __init__(self):
        # id -> date d'expiration
        self._passes: dict[str, datetime] = {}
        self._verrou = Lock()

    # délivre un nouveau laissez-passer ; renvoie son identifiant
    def emettre(self) -> str:
        id = secrets.token_urlsafe(16)
        with self._verrou:
            self._purger()
            self._passes[id] = datetime.now() + DUREE_VIE
        return id

    # vrai si [id] est un laissez-passer existant et non expiré
    def est_valide(self, id: str | None) -> bool:
        with self._verrou:
            expire = self._passes.get(id) if id else None
        return expire is not None and expire > datetime.now()

    # le laissez-passer a servi : on l'oublie
    def consommer(self, id: str | None):
        with self._verrou:
            self._passes.pop(id, None)

    # (appelée sous le verrou)
    def _purger(self):
        maintenant = datetime.now()
        for id in [id for id, expire in self._passes.items() if expire <= maintenant]:
            del self._passes[id]
        if len(self._passes) >= MAX_PASSES:
            self._passes.clear()


# le cookie n'est envoyé par le navigateur qu'aux adresses /inscription...
def deposer_cookie(reponse: Response, id: str, secure: bool):
    reponse.set_cookie(COOKIE, id, max_age=DUREE_VIE, path="/inscription", httponly=True,
                       samesite="Lax", secure=secure)


# ... et il est effacé en le redéposant vide, avec une durée de vie nulle
def effacer_cookie(reponse: Response):
    reponse.delete_cookie(COOKIE, path="/inscription")

La création d'un compte client est une page publique : un robot pourrait créer des comptes en série. Elle est donc protégée par le captcha de la page de connexion : le bouton « Créer un compte client » envoie ce captcha au serveur, qui, s'il est correct, délivre un laissez-passer :

  • lignes 42-47 : un identifiant aléatoire, valable 15 minutes, gardé en mémoire comme les captchas ;
  • lignes 50-53 : la page d'inscription vérifie le laissez-passer à chaque requête, GET comme POST ;
  • lignes 56-58 : un compte créé consomme le laissez-passer : un captcha résolu, un compte au plus ;
  • lignes 70-72 : le laissez-passer voyage dans un cookie. Son attribut [path="/inscription"] limite les requêtes auxquelles le navigateur le joint : seulement les adresses qui commencent par [/inscription] ;
  • lignes 76-77 : pour effacer un cookie, il faut indiquer le même chemin que lors de son dépôt.

11.13.9. controllers/auth.py

Le contrôleur de la connexion, qui utilise tous ces services.

# =========================================================================
# controllers/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 flask import Blueprint, g, redirect, render_template, request, url_for

from rdvmedecins.auth.auth_service import AuthService
from rdvmedecins.auth.securite import publique
from rdvmedecins.entities import Role
from rdvmedecins.models.modeles_vues import LoginModele, Message
from rdvmedecins.web.services import auth_service, captchas, limiteur

auth = Blueprint("auth", __name__)


# la page d'accueil de chaque rôle, juste après la connexion
def page_accueil(role: Role) -> str:
    return url_for("espace.espace_patient") if role == Role.USER else url_for("agenda.afficher")


# l'adresse IP du visiteur. Derrière un proxy inverse (nginx...), ce serait
# celle du proxy : il faudrait alors envelopper l'application dans le
# middleware ProxyFix de Werkzeug, qui lit l'entête X-Forwarded-For.
def adresse_ip() -> str:
    return request.remote_addr or ""


# -----------------------------------------------------------------------
# GET /
# La route est publique : l'utilisateur a été établi, s'il y en a un, par
# auth/securite.py (g.utilisateur), mais rien n'est exigé.
# -----------------------------------------------------------------------
@auth.get("/")
@publique
def accueil():
    return redirect(page_accueil(g.utilisateur.role) if g.utilisateur else url_for("auth.afficher_login"))


# -----------------------------------------------------------------------
# GET /login[?login=xxx] : le paramètre facultatif pré-remplit le champ
# login (utilisé juste après la création d'un compte, cf. controllers/inscription.py)
# -----------------------------------------------------------------------
@auth.get("/login")
@publique
def afficher_login():
    # déjà connecté (jeton encore valide) : directement à la page d'accueil
    if g.utilisateur:
        return redirect(page_accueil(g.utilisateur.role))
    return formulaire(request.args.get("login", "")[:30], None)


# -----------------------------------------------------------------------
# POST /login
# request.form.get : un champ absent donne None (et non une erreur 400) -
# c'est le code qui décide quoi en faire.
# -----------------------------------------------------------------------
@auth.post("/login")
@publique
def se_connecter():
    login = (request.form.get("login") or "").strip()
    password = request.form.get("password") or ""
    ip = adresse_ip()

    # 1. trop d'échecs récents ?
    minutes = limiteur().minutes_blocage_login(ip, login)
    if minutes > 0:
        return formulaire(login, Message("ERRORS.TOO_MANY_ATTEMPTS", {"minutes": str(minutes)}))

    # 2. le captcha
    if not captchas().verifier(request.form.get("captchaId"), request.form.get("captcha")):
        limiteur().echec_login(ip, login)
        return formulaire(login, Message("ERRORS.WRONG_CAPTCHA"))

    # 3. le mot de passe
    user = auth_service().valider_utilisateur(login, password) if login and password else None
    if user is None:
        limiteur().echec_login(ip, login)
        return formulaire(login, Message("LOGIN.ERROR"))

    limiteur().reussite_login(ip, login)
    reponse = redirect(page_accueil(user.role))
    auth_service().deposer_jeton(reponse, user)
    return reponse


# -----------------------------------------------------------------------
# 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.
# -----------------------------------------------------------------------
@auth.post("/logout")
@publique
def se_deconnecter():
    reponse = redirect(url_for("auth.afficher_login"))
    AuthService.effacer_jeton(reponse)
    return reponse


# 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
def formulaire(login: str, erreur: Message | None):
    modele = LoginModele("LOGIN.TITLE", erreur, login, captchas().generer())
    return render_template("login.html", modele=modele)
  • ligne 24 : les services partagés sont obtenus par de petites fonctions du module [web/services.py] (plus loin), qui les cherchent dans [app.extensions] ;
  • lignes 30-31 : chaque rôle a sa page d'accueil : son espace pour un patient, l'agenda pour les autres ;
  • lignes 37-38 : l'adresse IP du visiteur. Derrière un serveur mandataire (*reverse proxy* : [nginx], [Apache]), ce serait l'adresse du mandataire, la même pour tous les visiteurs : il faudrait alors envelopper l'application dans le middleware [ProxyFix] de [Werkzeug] (chapitre sur le cycle de vie), qui lit l'adresse d'origine dans l'entête [X-Forwarded-For] ajouté par le mandataire ;
  • lignes 46-49 : la racine du site, publique : elle redirige vers la page d'accueil de l'utilisateur, ou vers la connexion ;
  • lignes 56-62 : la page de connexion. Un utilisateur déjà connecté (son jeton est encore valide) est envoyé directement à sa page d'accueil. Le paramètre facultatif [login] pré-remplit le champ (après la création d'un compte) ; [[:30]] limite sa longueur à celle d'un login ;
  • lignes 70-96 : la connexion, en trois temps, dans cet ordre : le blocage éventuel, le captcha, puis seulement le mot de passe. Une tentative refusée aux deux premières étapes ne vérifie jamais le mot de passe : un robot ne peut rien apprendre en insistant. [request.form.get(...) or ""] : un champ absent donne une chaîne vide. Le message d'erreur est un objet [Message] : une clé de traduction et ses paramètres (plus loin) ;
  • lignes 104-109 : la déconnexion, en POST (exemple 25). Elle est publique : un utilisateur dont la session a expiré doit pouvoir cliquer sur « Se déconnecter » sans être d'abord redirigé ;
  • lignes 114-116 : la page de connexion, toujours avec un nouveau captcha. Son modèle est un objet [LoginModele] (cf. [models/modeles_vues.py]).

Remarquons que le formulaire de connexion n'utilise pas [WTForms] : ses trois champs n'ont pas de règles de validation (un login vide est simplement un échec de connexion), et le formulaire est protégé contre les attaques CSRF par [CSRFProtect], comme tous les formulaires de l'application.

11.14. La couche web : les services communs

Le dossier [web] contient ce que les contrôleurs ont en commun. Chaque module y répond à une question que tous les contrôleurs se posent : comment obtenir un service, traduire un texte, afficher un message après une redirection, réafficher un formulaire refusé, transformer une erreur en page.

11.14.1. web/services.py

# =========================================================================
# web/services.py
# -------------------------------------------------------------------------
# Les objets partagés de l'application (cf. exemple 06) : ils sont créés UNE
# fois, par la fabrique de l'application (rdvmedecins/__init__.py), et
# rangés dans le dictionnaire app.extensions. Ces petites fonctions les y
# retrouvent, pour l'application qui traite la requête en cours
# (current_app) ; elles donnent aussi leur TYPE aux outils d'analyse.
# =========================================================================
from flask import current_app

from rdvmedecins.auth.auth_service import AuthService
from rdvmedecins.auth.captcha import CaptchaService
from rdvmedecins.auth.limiteur import LimiteurTentatives
from rdvmedecins.auth.passe_inscription import PasseInscription
from rdvmedecins.web.application_model import ApplicationModel


def application() -> ApplicationModel:
    return current_app.extensions["rdvmedecins.application"]


def auth_service() -> AuthService:
    return current_app.extensions["rdvmedecins.auth"]


def captchas() -> CaptchaService:
    return current_app.extensions["rdvmedecins.captchas"]


def limiteur() -> LimiteurTentatives:
    return current_app.extensions["rdvmedecins.limiteur"]


def passes() -> PasseInscription:
    return current_app.extensions["rdvmedecins.passes"]

Les objets partagés, créés par la fabrique et rangés dans [app.extensions], sont obtenus par ces petites fonctions (exemple 06). Elles évitent de répéter la clé du dictionnaire dans chaque contrôleur, et donnent leur type aux outils d'analyse : dans un contrôleur, l'éditeur sait que [application()] est un [ApplicationModel], et propose ses méthodes.

11.14.2. web/traducteur.py

# =========================================================================
# web/traducteur.py
# -------------------------------------------------------------------------
# Traduction français / anglais de l'interface (cf. exemple 22).
#
# Les dictionnaires sont les MÊMES fichiers que ceux des versions NestJS,
# ASP.NET Core et Spring MVC de l'application (i18n/fr.json, i18n/en.json),
# lus une seule fois au démarrage.
#
# Dans les vues, la fonction t (cf. web/modele_commun.py) :
#   {{ t("LOGIN.TITLE") }}                            sans paramètre ;
#   {{ t("ESPACE.HELLO", nom=modele.patient) }}       paramètres nommés ;
#   {{ t(message.cle, message.parametres) }}          un dictionnaire de paramètres.
#
# LA LANGUE COURANTE : g.langue, fixée à chaque requête d'après un cookie
# (cf. rdvmedecins/__init__.py, fonction fixer_langue).
# =========================================================================
import json
import re
from pathlib import Path

from flask import g

LANGUES = ("fr", "en")
LANGUE_PAR_DEFAUT = "fr"

# le cookie qui mémorise la langue choisie (cf. controllers/langue.py)
COOKIE_LANGUE = "rdvmedecins_langue"

# {nom} : un paramètre dans un texte
PARAMETRE = re.compile(r"\{(\w+)\}")


# la langue gérée qui correspond à [code] : "en" -> "en" ; "de" ou None -> "fr"
def langue(code: str | None) -> str:
    return code if code in LANGUES else LANGUE_PAR_DEFAUT


class Traducteur:

    def __init__(self, dossier: Path):
        # langue -> (clé complète "LOGIN.TITLE" -> texte)
        self._dictionnaires: dict[str, dict[str, str]] = {}
        for code in LANGUES:
            with open(dossier / f"{code}.json", encoding="utf-8") as fichier:
                self._dictionnaires[code] = self._aplatir(json.load(fichier))

    # -----------------------------------------------------------------------
    # traduire("MEDECINS.ADDED", {"nom": "Marie PELISSIER"})
    #   -> cherche "MEDECINS.ADDED" dans le dictionnaire de la langue, et
    #      remplace {nom} par sa valeur. Une clé absente de la langue demandée
    #      est cherchée en français ; absente partout, on affiche la clé
    #      elle-même (on repère ainsi immédiatement un oubli).
    # Les paramètres : un dictionnaire, et/ou des paramètres nommés.
    # -----------------------------------------------------------------------
    def traduire(self, cle: str | None, parametres: dict | None = None, **autres) -> str:
        if not cle:
            return ""
        texte = self._dictionnaires[langue(g.get("langue"))].get(cle)
        if texte is None:
            texte = self._dictionnaires[LANGUE_PAR_DEFAUT].get(cle, cle)
        valeurs = {**(parametres or {}), **autres}
        if not valeurs:
            return texte
        return PARAMETRE.sub(lambda m: str(valeurs.get(m.group(1), m.group(0))), texte)

    # { "LOGIN": { "TITLE": "Connexion" } } -> { "LOGIN.TITLE": "Connexion" }
    @classmethod
    def _aplatir(cls, noeud: dict, prefixe: str = "") -> dict[str, str]:
        resultat = {}
        for cle, valeur in noeud.items():
            cle_complete = f"{prefixe}.{cle}" if prefixe else cle
            if isinstance(valeur, dict):
                resultat.update(cls._aplatir(valeur, cle_complete))
            else:
                resultat[cle_complete] = valeur
        return resultat

Le traducteur de l'exemple 22, avec trois compléments :

  • lignes 56-65 : les paramètres d'un texte peuvent être fournis par un dictionnaire (le deuxième paramètre, [parametres]), par des paramètres nommés ([**autres]), ou les deux. Les messages de l'application (la classe [Message], plus loin) transportent leurs paramètres dans un dictionnaire ; les vues, elles, préfèrent les paramètres nommés. [{**a, **b}] fabrique un dictionnaire qui réunit ceux de [a] et de [b] ;
  • ligne 61 : une clé absente de la langue demandée est cherchée en français, avant d'être affichée telle quelle ;
  • ligne 59 : [g.get("langue")] rend [None] si [g] n'a pas d'attribut [langue], au lieu de lever une exception : le traducteur fonctionne même pour une erreur survenue avant la fonction [before_request] qui fixe la langue.

Le nom du cookie de la langue est préfixé par le nom de l'application ([rdvmedecins_langue]), comme celui du jeton et du laissez-passer : les cookies sont liés à un nom de domaine ([localhost]), et non à un port. Les exemples, servis eux aussi par [localhost], pourraient sinon lire les cookies de l'application.

11.14.3. web/modele_commun.py

# =========================================================================
# web/modele_commun.py
# -------------------------------------------------------------------------
# Ce dont TOUTES les vues ont besoin, quelle que soit la page (cf. exemple
# 15) : un "processeur de contexte", exécuté avant la fabrication de chaque
# vue, ajoute ses variables au modèle de toutes les vues :
#   - utilisateur : l'utilisateur connecté (ou None), pour la barre de navigation ;
#   - langue      : "fr" ou "en" ;
#   - chemin      : l'URL demandée (menu actif de la barre de navigation,
#                   lien "Autre image" du captcha) ;
#   - t           : la fonction de traduction (cf. web/traducteur.py).
# Les messages flash, eux, sont lus par le gabarit (get_flashed_messages).
# =========================================================================
from flask import g, request

from rdvmedecins.web.traducteur import Traducteur


def enregistrer(app, traducteur: Traducteur):

    @app.context_processor
    def modele_commun():
        return {
            "utilisateur": g.get("utilisateur"),
            "langue": g.get("langue", "fr"),
            "chemin": request.path,
            "t": traducteur.traduire,
        }

Le processeur de contexte de l'exemple 15 : quatre variables disponibles dans toutes les vues, sans que les actions aient à les fournir : l'utilisateur connecté (pour la barre de navigation), la langue (pour l'attribut [lang] de la page et les boutons FR / EN), le chemin de l'URL demandée (pour le menu actif et le lien « Autre image » du captcha) et la fonction de traduction [t]. [g.get("utilisateur")] rend [None] si l'attribut n'existe pas (une page d'erreur fabriquée avant l'authentification, par exemple).

11.14.4. web/flash.py

# =========================================================================
# web/flash.py
# -------------------------------------------------------------------------
# Les messages "flash" : un message à afficher UNE fois, sur la page
# SUIVANTE (cf. exemple 20). C'est le complément naturel du schéma
# "Post / Redirect / Get" utilisé par tous les formulaires de l'application :
# un POST réussi ne fabrique pas de page, il redirige (302) vers une page
# GET ; le message, lui, doit survivre à la redirection.
#
# Flask les range dans la SESSION, c'est-à-dire dans un cookie SIGNÉ par la
# clé secrète (SECRET_KEY) : le serveur reste SANS ÉTAT, il ne garde rien en
# mémoire entre deux requêtes. Le navigateur peut lire ce cookie (il n'est
# pas chiffré), mais pas le modifier sans que la signature le trahisse.
#
# Un message ne contient pas un texte, mais une CLÉ de traduction et ses
# paramètres : il est traduit au moment de l'affichage, dans la langue de
# la page (cf. templates/layout.html). Sa catégorie : "success" (affiché en
# haut de la page) ou "danger" (une erreur, affichée en bas).
# =========================================================================
from flask import flash


def flash_succes(cle: str, **parametres):
    flash({"cle": cle, "parametres": parametres}, "success")


def flash_erreur(cle: str, **parametres):
    flash({"cle": cle, "parametres": parametres}, "danger")

Les messages flash de l'exemple 20. Un message n'est pas un texte, mais un dictionnaire : la clé de traduction et ses paramètres. Il sera traduit au moment de l'affichage, par le gabarit, dans la langue de la page. [Flask] range les messages dans la session, qui est un cookie : il les convertit pour cela en JSON, ce qui accepte un dictionnaire de chaînes. La catégorie du message ([success] ou [danger], les noms des couleurs de [Bootstrap]) décide de son emplacement : en haut de la page pour un succès, en bas pour une erreur.

11.14.5. web/erreurs.py

# =========================================================================
# web/erreurs.py
# -------------------------------------------------------------------------
# Comment les erreurs deviennent des pages HTML. Trois gestionnaires se
# partagent le travail (cf. exemple 24) :
#
#   1. une SessionInvalide (compte supprimé depuis la connexion) -> retour à
#      la page de connexion, comme pour une session expirée ;
#   2. une erreur HTTP : abort(400), abort(403) (rôle insuffisant), page
#      inconnue (404), méthode non prise en charge (405), jeton anti-CSRF
#      absent ou faux (400)... -> la page d'erreur, avec le code d'origine ;
#   3. toute autre exception (une vraie anomalie) -> inscrite dans le journal
#      du serveur, avec sa pile d'appels ; l'utilisateur voit la page
#      d'erreur 500 - jamais la pile d'appels.
# =========================================================================
from flask import current_app, redirect, render_template, url_for
from werkzeug.exceptions import HTTPException

from rdvmedecins.auth.auth_service import AuthService
from rdvmedecins.auth.securite import SessionInvalide
from rdvmedecins.models.modeles_vues import ErreurModele
from rdvmedecins.web.flash import flash_erreur

MESSAGES = {
    400: "ERRORS.BAD_REQUEST",
    403: "ERRORS.FORBIDDEN",
    404: "ERRORS.NOT_FOUND",
}


def page_erreur(code: int):
    modele = ErreurModele("ERROR_PAGE.TITLE", None, code, MESSAGES.get(code, "ERRORS.SERVER_ERROR"))
    # la page garde le code d'erreur (404 reste 404...)
    return render_template("erreur.html", modele=modele), code


def enregistrer(app):

    @app.errorhandler(SessionInvalide)
    def session_invalide(e):
        reponse = redirect(url_for("auth.afficher_login"))
        AuthService.effacer_jeton(reponse)
        flash_erreur("ERRORS.SESSION_EXPIRED")
        return reponse

    @app.errorhandler(HTTPException)
    def erreur_http(e: HTTPException):
        return page_erreur(e.code or 500)

    # Exception : la classe mère de toutes les exceptions ; ce gestionnaire
    # ne reçoit que celles qu'aucun gestionnaire plus précis n'a traitées
    @app.errorhandler(Exception)
    def anomalie(e: Exception):
        current_app.logger.exception("erreur inattendue")
        return page_erreur(500)

Les gestionnaires d'erreurs de l'exemple 24 :

  • lignes 31-34 : la page d'erreur, avec le code d'erreur d'origine (une page 404 reste une réponse 404) et un message traduit, choisi selon le code ;
  • lignes 39-44 : un compte supprimé depuis la connexion : retour à la page de connexion, comme pour une session expirée ;
  • lignes 46-48 : toutes les erreurs HTTP : [abort(403)], une page inconnue (404), une méthode refusée (405), un jeton anti-CSRF absent ou faux (400, levée par [CSRFProtect])... ;
  • lignes 52-55 : toute autre exception, c'est-à-dire une vraie anomalie (une erreur de programmation, une base devenue inaccessible). [current_app.logger] est le journal de l'application (un objet du module standard [logging]) ; sa méthode [exception] y écrit le message, suivi de la pile d'appels de l'exception en cours. L'utilisateur, lui, ne voit que la page d'erreur 500 : jamais la pile d'appels, qui révélerait des détails internes de l'application. [Flask] choisit toujours le gestionnaire le plus précis : une erreur HTTP est traitée par le deuxième gestionnaire, et non par celui-ci.

11.14.6. web/application_model.py

# =========================================================================
# web/application_model.py
# -------------------------------------------------------------------------
# Le cache / façade de la couche métier (l'équivalent de la classe
# [ApplicationModel] des versions précédentes de l'application) : les
# listes de médecins et de clients sont lues une fois au démarrage, puis
# servies depuis la mémoire.
#
# Ces listes peuvent CHANGER (l'ADMIN ajoute, modifie, supprime des
# médecins et des clients) : chaque écriture passe donc par cette façade,
# qui recharge la liste concernée juste après. C'est tout l'intérêt d'un
# point d'entrée UNIQUE vers la couche métier : la règle "écriture =>
# rechargement du cache" n'est écrite qu'ici.
#
# Un seul objet pour toute l'application, utilisé par des requêtes
# traitées EN PARALLÈLE, sur plusieurs threads. On ne modifie donc jamais
# une liste en place : on la REMPLACE en entier par une nouvelle. En Python,
# l'affectation d'un attribut est atomique : un thread qui lit la liste voit
# l'ancienne ou la nouvelle, jamais une liste à moitié construite.
# =========================================================================
from datetime import date

from rdvmedecins.domain.agenda import AgendaMedecinJour
from rdvmedecins.entities import Client, Creneau, Medecin, Rv
from rdvmedecins.metier.donnees import DonneesPersonne
from rdvmedecins.metier.metier import Metier


# même contrat que la couche métier (Metier)
class ApplicationModel:

    def __init__(self, metier: Metier):
        self._metier = metier
        # chargement initial des deux listes, au démarrage de l'application.
        # Une erreur de lecture (base inaccessible...) n'est pas attrapée :
        # elle arrête le serveur, plutôt que de le laisser démarrer "à vide"
        self._medecins: list[Medecin] = []
        self._clients: list[Client] = []
        self._recharger_medecins()
        self._recharger_clients()

    def _recharger_medecins(self):
        self._medecins = list(self._metier.get_all_medecins())

    def _recharger_clients(self):
        self._clients = list(self._metier.get_all_clients())

    # ------------------------------------------------ lectures depuis le cache

    def get_all_medecins(self) -> list[Medecin]:
        return self._medecins

    def get_all_clients(self) -> list[Client]:
        return self._clients

    # 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._medecins if m.id_user == 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 => rechargement

    def ajouter_medecin(self, donnees: DonneesPersonne) -> Medecin:
        medecin = self._metier.ajouter_medecin(donnees)
        self._recharger_medecins()
        return medecin

    def modifier_medecin(self, id: int, donnees: DonneesPersonne) -> Medecin:
        medecin = self._metier.modifier_medecin(id, donnees)
        self._recharger_medecins()
        return medecin

    def supprimer_medecin(self, id: int) -> None:
        self._metier.supprimer_medecin(id)
        self._recharger_medecins()

    def ajouter_client(self, donnees: DonneesPersonne) -> Client:
        client = self._metier.ajouter_client(donnees)
        self._recharger_clients()
        return client

    def modifier_client(self, id: int, donnees: DonneesPersonne) -> Client:
        client = self._metier.modifier_client(id, donnees)
        self._recharger_clients()
        return client

    def supprimer_client(self, id: int) -> None:
        self._metier.supprimer_client(id)
        self._recharger_clients()

Le cache de la couche web, et sa façade vers la couche métier :

  • lignes 32-40 : les listes des médecins et des clients sont lues une fois, au démarrage. Elles servent à chaque affichage de l'agenda (la liste déroulante des médecins, celle des patients) : les garder en mémoire évite deux requêtes SQL par page. Une erreur de lecture n'est pas interceptée : elle arrête l'application au démarrage (cf. « Les problèmes fréquents ») ;
  • lignes 50-59 : les lectures servies depuis le cache. [get_medecin_by_user_id] retrouve le médecin connecté, pour présélectionner son propre agenda ;
  • lignes 63-96 : les autres lectures, et les rendez-vous, sont simplement relayées à la couche métier. [get_client_by_user_id] n'utilise pas le cache : un patient qui vient de modifier ou de supprimer son compte doit voir la donnée la plus récente ;
  • lignes 100-126 : chaque écriture sur les médecins ou les clients recharge la liste concernée. C'est l'intérêt d'un point d'entrée unique vers la couche métier : la règle « écriture, puis rechargement du cache » n'est écrite qu'ici.

Cet objet unique est utilisé par des requêtes traitées en parallèle, sur plusieurs threads : on ne modifie donc jamais une liste en place (par [append] ou [remove]), on la remplace en entier par une nouvelle. En [Python], l'affectation d'un attribut est atomique : un thread qui lit la liste voit l'ancienne ou la nouvelle, jamais une liste à moitié construite. Il n'y a donc pas besoin de verrou.

[ApplicationModel] respecte le contrat [Metier] (il a les mêmes méthodes que [MetierService]) : pour les contrôleurs, il est la couche métier. On pourrait retirer le cache sans toucher aux contrôleurs, en rangeant directement [MetierService] dans [app.extensions].

11.14.7. web/validation.py

# =========================================================================
# web/validation.py
# -------------------------------------------------------------------------
# Ce qu'il faut pour RÉAFFICHER un formulaire refusé : les erreurs de
# saisie champ par champ, et les champs à encadrer en rouge.
# =========================================================================
from flask_wtf import FlaskForm

from rdvmedecins.models.modeles_vues import EtatFormulaire, Message


# -----------------------------------------------------------------------
# le formulaire validé (cf. forms/formulaires.py) -> le dictionnaire
# champ -> clé du message, en ne gardant que le premier message de chaque
# champ : { "nom": "ERRORS.LASTNAME_REQUIRED", ... }
#
# Nos messages sont des clés de traduction ("ERRORS...."). Un message que
# WTForms a écrit lui-même (en anglais) est remplacé par la clé
# ERRORS.INVALID_FORM.
# -----------------------------------------------------------------------
def erreurs_de(form: FlaskForm) -> dict[str, str]:
    erreurs = {}
    for champ, messages in form.errors.items():
        message = str(messages[0])
        erreurs[champ] = message if message.startswith("ERRORS.") else "ERRORS.INVALID_FORM"
    return erreurs


# -----------------------------------------------------------------------
# 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 = {
    "ERRORS.LOGIN_TAKEN": ["login"],
    "ERRORS.DOCTOR_ALREADY_EXISTS": ["prenom", "nom"],
    "ERRORS.PATIENT_ALREADY_EXISTS": ["prenom", "nom"],
}


# l'état d'un formulaire à (ré)afficher : un message sous chaque champ
# refusé par la validation (erreurs), et les champs à encadrer en rouge,
# ceux-là plus d'éventuels autres (autres_champs)
def etat(erreurs: dict[str, str], autres_champs: list[str] | None = None) -> EtatFormulaire:
    return EtatFormulaire(dict(erreurs), set(erreurs) | set(autres_champs or []))


# LE message d'erreur de la page, affiché en bas de page : [erreur] s'il
# y en a une, sinon "Le formulaire contient des erreurs." s'il y a des
# erreurs de champ, sinon rien
def message_page(erreurs: dict[str, str], erreur: Message | None) -> Message | None:
    if erreur is not None:
        return erreur
    return Message("ERRORS.INVALID_FORM") if erreurs else None

Ce qu'il faut pour réafficher un formulaire refusé :

  • lignes 21-26 : les erreurs d'un formulaire [WTForms] (exemple 19), sous la forme d'un dictionnaire « champ → clé du message », en ne gardant que le premier message de chaque champ. Les messages de nos validateurs sont des clés de traduction ([ERRORS.LASTNAME_REQUIRED], cf. [forms/formulaires.py]) ; un message que [WTForms] aurait écrit lui-même, en anglais, est remplacé par une clé générique ;
  • lignes 33-36 : certaines erreurs de la couche métier concernent des champs précis : un login déjà pris, un homonyme. Leur message s'affiche en bas de page, mais les champs concernés sont encadrés en rouge, pour que l'utilisateur voie tout de suite quoi corriger (la copie d'écran de l'homonyme, plus haut) ;
  • lignes 43-44 : l'état d'un formulaire à réafficher : les messages sous les champs, et l'ensemble des champs à encadrer en rouge. [set(erreurs)] est l'ensemble des clés du dictionnaire ; l'opérateur [|] fait l'union de deux ensembles ;
  • lignes 50-53 : le message général du bas de page.

11.14.8. web/outils.py

# =========================================================================
# web/outils.py
# -------------------------------------------------------------------------
# Petites fonctions utilitaires de la couche web :
#   - libellés : "Mme Alice DURAND" ;
#   - dates : "aaaa-mm-jj", heures "8:05", jours "mercredi 23 septembre 2026" ;
#   - saisie : lecture d'un entier positif envoyé par le navigateur ;
#   - utilisateur : l'utilisateur connecté, et son patient (entité Client).
# =========================================================================
import re
from datetime import date, datetime

from babel.dates import format_date
from flask import g

from rdvmedecins.auth.securite import SessionInvalide
from rdvmedecins.auth.utilisateur import UtilisateurConnecte
from rdvmedecins.entities import Client, Creneau, Personne

# ----------------------------------------------------------------- libellés


# "Mme Alice DURAND"
def libelle(p: Personne) -> str:
    return f"{p.titre} {p.prenom} {p.nom}"


# ----------------------------------------------------------------- dates

# le format des dates dans les URL et dans le champ <input type="date">
FORMAT_ISO = re.compile(r"\d{4}-\d{2}-\d{2}")


# la date, si [texte] est une date qui existe au format aaaa-mm-jj ; None sinon.
# strptime vérifie que la date existe : "2026-02-30" est refusée
def date_valide(texte: str | None) -> date | None:
    if texte is None or not FORMAT_ISO.fullmatch(texte):
        return None
    try:
        return datetime.strptime(texte, "%Y-%m-%d").date()
    except ValueError:
        return None


def iso(jour: date) -> str:
    return jour.isoformat()


# "8:05" plutôt que "8:5"
def formater_heure(h: int, m: int) -> str:
    return f"{h}:{m:02d}"


def horaire(c: Creneau) -> str:
    return f"{formater_heure(c.hdebut, c.mdebut)} - {formater_heure(c.hfin, c.mfin)}"


# "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", selon la
# langue de la requête (g.langue). Le format de Babel : EEEE le jour de la
# semaine en toutes lettres, d le jour, MMMM le mois en toutes lettres, y l'année
def formater_jour(jour: date) -> str:
    return format_date(jour, "EEEE d MMMM y", locale=g.langue)


# ----------------------------------------------------------------- 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é (cf. auth/securite.py). Les actions protégées ne
# sont appelées que s'il existe ; par précaution, son absence est traitée
# comme une session expirée.
def utilisateur() -> UtilisateurConnecte:
    if g.utilisateur is None:
        raise SessionInvalide()
    return g.utilisateur


# -----------------------------------------------------------------------
# Le patient (entité 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 (cf. SessionInvalide).
# -----------------------------------------------------------------------
def patient_connecte(application) -> Client:
    patient = application.get_client_by_user_id(utilisateur().user_id)
    if patient is None:
        raise SessionInvalide()
    return patient

Des fonctions utilitaires, utilisées par plusieurs contrôleurs :

  • ligne 24 : le libellé d'une personne, « Mme Alice DURAND » ;
  • lignes 31-42 : la lecture d'une date au format [aaaa-mm-jj], celui des URL de l'agenda ([jour=2026-09-26]) et du champ [<input type="date">]. L'expression régulière vérifie la forme ([fullmatch] : la chaîne entière doit correspondre), [strptime] l'existence de la date. Une date invalide donne [None] : c'est l'action qui décide ;
  • lignes 50-51 : le format [02d] écrit un entier sur deux chiffres, complété par des zéros : [8:05] ;
  • lignes 61-62 : une date en toutes lettres, dans la langue de la requête, avec [Babel] (exemple 16). Le format est ici écrit explicitement : [EEEE], le jour de la semaine en toutes lettres ; [d], le jour ; [MMMM], le mois en toutes lettres ; [y], l'année ;
  • lignes 69-73 : la lecture d'un identifiant envoyé par le navigateur (un paramètre de l'URL, un champ caché). [\d{1,9}] : de un à neuf chiffres, ce qui exclut les signes, les espaces, et les nombres trop grands pour une colonne [INT]. Tout ce qui vient du navigateur peut avoir été modifié : il faut le vérifier avant de s'en servir ;
  • lignes 82-85 : l'utilisateur connecté ; son absence (qui ne devrait pas arriver dans une action protégée) est traitée comme une session expirée ;
  • lignes 98-102 : le patient (l'entité [Client]) qui correspond à l'utilisateur connecté. Le jeton ne contient que l'id du compte : le patient est retrouvé par la colonne [clients.user]. S'il n'existe pas, c'est que le compte a été supprimé depuis la connexion.

11.15. Les formulaires et les modèles des vues

11.15.1. forms/regles.py

# =========================================================================
# forms/regles.py
# -------------------------------------------------------------------------
# Les règles de saisie communes aux formulaires "personne", et les FILTRES
# de WTForms (cf. exemple 19) : des fonctions qui transforment la valeur
# reçue AVANT la validation.
# =========================================================================
TITRES = ("Mr", "Mme", "Melle")

# login : lettres minuscules, chiffres, point, tiret, souligné - 30 caractères au plus
FORMAT_LOGIN = r"^[a-z0-9._-]{1,30}$"

# longueur minimale d'un mot de passe CHOISI par un patient
MOT_DE_PASSE_MIN = 6


# "  dupont " -> "dupont" ; "   " -> None
def nettoyer(valeur):
    if not isinstance(valeur, str):
        return valeur
    return valeur.strip() or None


# "  Dupont " -> "dupont" : un login est toujours en minuscules
def login(valeur):
    valeur = nettoyer(valeur)
    return valeur.lower() if isinstance(valeur, str) else valeur


# un champ vide -> None ; pour un mot de passe, on ne retire PAS les
# espaces (ils peuvent en faire partie)
def vide_en_none(valeur):
    return valeur if valeur else None


# le champ caché [version] : un entier, ou None s'il est absent ou vide.
# Un texte qui n'est pas un nombre : une ValueError, que WTForms transforme
# en erreur du champ, avec ce message (une clé de traduction)
def version(valeur):
    if valeur in (None, ""):
        return None
    try:
        return int(valeur)
    except (TypeError, ValueError):
        raise ValueError("ERRORS.INVALID_FORM")

Les règles de saisie communes aux formulaires, et les filtres [WTForms] (exemple 19) :

  • lignes 8-14 : les titres permis, le format d'un login (lettres minuscules, chiffres, point, tiret, souligné, 30 caractères au plus), la longueur minimale d'un mot de passe choisi par un patient ;
  • lignes 18-21 : les espaces qui entourent une saisie sont retirés ; une saisie vide devient [None]. [a or b] vaut [b] si [a] est « faux » (ici, une chaîne vide) ;
  • lignes 25-27 : un login est toujours en minuscules ;
  • lignes 32-33 : pour un mot de passe, on ne retire pas les espaces, qui peuvent en faire partie ;
  • lignes 39-45 : le champ caché de la version (le verrou optimiste) : un entier, ou [None]. Un filtre qui lève une [ValueError] produit une erreur du champ, dont le message est celui de l'exception : un navigateur qui enverrait autre chose qu'un nombre obtiendrait l'erreur « formulaire invalide ».

11.15.2. forms/formulaires.py

# =========================================================================
# forms/formulaires.py
# -------------------------------------------------------------------------
# Les champs des formulaires "personne" et leurs contraintes (cf. exemples
# 19 et 28). Trois formulaires partagent les mêmes champs d'identité
# (titre, prénom, nom) :
#
#   ChampsPersonne                titre, prénom, nom
#    ├─ FormulairePersonne        + login / mot de passe FACULTATIFS, version
#    │                              (l'ADMIN ajoute ou modifie un médecin / client)
#    ├─ FormulaireInscription     + login / mot de passe / confirmation OBLIGATOIRES
#    │                              (un patient crée son compte)
#    └─ FormulaireMonCompte       + login, nouveau mot de passe facultatif,
#                                   mot de passe ACTUEL obligatoire, version
#                                   (un patient modifie son compte)
#
# Une classe WTForms qui hérite d'une autre hérite aussi de ses champs.
#
# LIAISON : FormulairePersonne() lit lui-même les champs de même nom de la
# requête POST en cours (request.form), et leur applique ses filtres
# (espaces superflus retirés, login en minuscules...).
# VALIDATION : form.validate() vérifie ensuite les contraintes de chaque
# champ, dans l'ordre ; la première qui échoue range son message dans
# form.<champ>.errors. Ces messages sont des CLÉS de traduction (cf. i18n/*.json).
#
# Les formulaires ne servent ici qu'à lire et valider la saisie : les vues
# écrivent elles-mêmes leurs balises <input>, à partir des modèles de vue
# (cf. models/modeles_vues.py).
# =========================================================================
from flask_wtf import FlaskForm
from wtforms import PasswordField, StringField
from wtforms.validators import AnyOf, DataRequired, InputRequired, Length, Optional, Regexp

from rdvmedecins.forms import regles


# ------------------------------------------------------------------ base commune
class ChampsPersonne(FlaskForm):

    titre = StringField(validators=[AnyOf(regles.TITRES, message="ERRORS.INVALID_TITLE")])

    # longueurs maximales = celles des colonnes SQL (VARCHAR(20)).
    # DataRequired arrête la validation du champ s'il est vide : le message
    # de Length ne s'ajoute donc jamais à celui-là
    prenom = StringField(filters=[regles.nettoyer], validators=[
        DataRequired("ERRORS.FIRSTNAME_REQUIRED"),
        Length(max=20, message="ERRORS.FIRSTNAME_TOO_LONG")])

    nom = StringField(filters=[regles.nettoyer], validators=[
        DataRequired("ERRORS.LASTNAME_REQUIRED"),
        Length(max=20, message="ERRORS.LASTNAME_TOO_LONG")])


# ------------------------------------------------------------------ ADMIN
class FormulairePersonne(ChampsPersonne):

    # facultatif (cf. metier/donnees.py) : Optional arrête la validation d'un
    # champ vide, sans erreur
    login = StringField(filters=[regles.login], validators=[
        Optional(), Regexp(regles.FORMAT_LOGIN, message="ERRORS.INVALID_LOGIN")])

    # facultatif ; 72 caractères au plus (au-delà, bcrypt ignore la suite)
    password = PasswordField(filters=[regles.vide_en_none], validators=[
        Optional(strip_whitespace=False), Length(max=72, message="ERRORS.PASSWORD_TOO_LONG")])

    # champ caché du formulaire de modification (verrou optimiste)
    version = StringField(filters=[regles.version])


# ------------------------------------------------------------------ inscription d'un patient
class FormulaireInscription(ChampsPersonne):

    # InputRequired : la saisie ne doit pas être vide
    login = StringField(filters=[regles.login], validators=[
        InputRequired("ERRORS.LOGIN_REQUIRED"),
        Regexp(regles.FORMAT_LOGIN, message="ERRORS.INVALID_LOGIN")])

    # deux contraintes Length, chacune avec son message
    password = PasswordField(filters=[regles.vide_en_none], validators=[
        InputRequired("ERRORS.PASSWORD_REQUIRED"),
        Length(min=regles.MOT_DE_PASSE_MIN, message="ERRORS.PASSWORD_TOO_SHORT"),
        Length(max=72, message="ERRORS.PASSWORD_TOO_LONG")])

    confirmation = PasswordField(filters=[regles.vide_en_none], validators=[
        InputRequired("ERRORS.CONFIRMATION_REQUIRED")])


# ------------------------------------------------------------------ "Gérer mon compte"
class FormulaireMonCompte(ChampsPersonne):

    login = StringField(filters=[regles.login], validators=[
        InputRequired("ERRORS.LOGIN_REQUIRED"),
        Regexp(regles.FORMAT_LOGIN, message="ERRORS.INVALID_LOGIN")])

    # facultatif : vide = mot de passe inchangé
    password = PasswordField(filters=[regles.vide_en_none], validators=[
        Optional(strip_whitespace=False),
        Length(min=regles.MOT_DE_PASSE_MIN, message="ERRORS.PASSWORD_TOO_SHORT"),
        Length(max=72, message="ERRORS.PASSWORD_TOO_LONG")])

    confirmation = PasswordField(filters=[regles.vide_en_none])

    # le mot de passe ACTUEL, exigé pour toute modification du compte
    mot_de_passe_actuel = PasswordField(filters=[regles.vide_en_none], validators=[
        InputRequired("ERRORS.CURRENT_PASSWORD_REQUIRED")])

    version = StringField(filters=[regles.version])
  • lignes 38-51 : les champs communs aux trois formulaires : titre, prénom, nom. Les messages des validateurs sont des clés de traduction : la vue les traduira dans la langue de la page. Le validateur [AnyOf] accepte une valeur parmi une liste. Les longueurs maximales sont celles des colonnes SQL : une saisie plus longue serait refusée par la base ;
  • lignes 55-67 : le formulaire de l'administrateur : un login et un mot de passe facultatifs (le validateur [Optional] arrête la validation d'un champ vide, sans erreur ; [strip_whitespace=False] : un mot de passe fait d'espaces n'est pas considéré comme vide), et la version ;
  • lignes 71-85 : le formulaire d'inscription d'un patient : login, mot de passe et confirmation obligatoires. Deux validateurs [Length], chacun avec son message : trop court, trop long ;
  • lignes 89-107 : « Gérer mon compte » : un nouveau mot de passe facultatif, et le mot de passe actuel, obligatoire pour toute modification.

Une classe [WTForms] qui hérite d'une autre hérite de ses champs : les trois formulaires partagent ceux de [ChampsPersonne]. La comparaison du mot de passe et de sa confirmation n'est pas confiée au validateur [EqualTo] (exemple 19), mais faite par l'action, qui choisit ainsi le message et le champ concerné.

Remarquons que ces formulaires ne servent qu'à lire et valider la saisie : les vues n'appellent pas les champs [WTForms] pour les dessiner (exemple 18), elles écrivent elles-mêmes leurs balises [<input>], à partir des modèles de vue. Ce choix garde des vues identiques pour les trois formulaires, et laisse à l'action le contrôle complet de ce qui est réaffiché (jamais un mot de passe, par exemple). C'est aussi celui des versions précédentes de l'application.

11.15.3. models/modeles_vues.py

# =========================================================================
# models/modeles_vues.py
# -------------------------------------------------------------------------
# Les "modèles de vue" (view models) : ce que chaque action transmet à sa
# vue, sous le nom [modele]. Ce sont des dataclasses : des objets construits
# en une fois par l'action, que la vue se contente de lire
# ({{ modele.agenda.lignes }}...).
#
# Règle suivie par toutes les pages : pas de logique dans les vues. Les
# actions 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 vues se contentent de les placer dans le HTML.
#
# Chaque modèle de page a deux champs, lus par le gabarit (templates/layout.html) :
# titre_page, la clé du titre de la page, et erreur, LE message d'erreur de
# la page, affiché en bas de page (None : pas d'erreur).
# =========================================================================
from dataclasses import dataclass, field

from rdvmedecins.auth.captcha import Captcha
from rdvmedecins.forms import regles


# un message à traduire : sa clé, et ses paramètres
@dataclass(frozen=True)
class Message:
    cle: str
    parametres: dict[str, str] = field(default_factory=dict)


# une option d'une liste déroulante (médecin, patient)
@dataclass(frozen=True)
class OptionVue:
    id: int
    libelle: str
    selectionne: bool


# ------------------------------------------------------------------ connexion
@dataclass
class LoginModele:
    titre_page: str
    erreur: Message | None
    login: str
    captcha: Captcha


# ------------------------------------------------------------------ agenda
@dataclass(frozen=True)
class LigneAgendaVue:
    id_creneau: int
    horaire: str  # "8:00 - 8:20"
    libre: bool
    patient: str | None  # nom affiché pour un créneau occupé ; None = « Réservé »
    moi: bool  # le rendez-vous du patient connecté
    reservable: bool  # afficher le bouton "Réserver" ?
    url_reserver: str
    id_rv: int | None  # renseigné seulement si le bouton "Annuler" est affiché


@dataclass(frozen=True)
class AgendaVue:
    medecin: str
    jour: str
    lignes: list[LigneAgendaVue]


@dataclass(frozen=True)
class ReservationVue:
    id_creneau: int
    horaire: str
    clients: list[OptionVue]  # ADMIN / DOCTOR : les patients parmi lesquels choisir
    patient: str | None  # USER : le patient, c'est lui-même


@dataclass
class AgendaModele:
    titre_page: str
    erreur: Message | None
    medecins: list[OptionVue]
    id_medecin: int | None
    jour: str  # "aaaa-mm-jj"
    agenda: AgendaVue | None
    reservation: ReservationVue | None
    url_retour: str  # l'agenda affiché, sans fenêtre de réservation
    # patient qui a déjà un rendez-vous à venir : le message qui explique
    # pourquoi il n'y a pas de bouton "Réserver"
    info: str | None


# ------------------------------------------------------------------ formulaires "personne"

# les valeurs affichées dans les champs du formulaire (jamais le mot de passe)
@dataclass(frozen=True)
class ValeursPersonne:
    titre: str = "Mr"
    prenom: str = ""
    nom: str = ""
    login: str = ""
    version: int | None = None


# l'état d'un formulaire refusé : le message affiché SOUS chaque champ
# (champ -> clé du message), et les champs à encadrer en rouge (classe
# Bootstrap is-invalid) : ceux qui ont un message, plus d'éventuels autres
# (doublon prénom + nom, login pris...)
@dataclass(frozen=True)
class EtatFormulaire:
    erreurs: dict[str, str] = field(default_factory=dict)
    champs_invalides: set[str] = field(default_factory=set)

    def erreur_de(self, champ: str) -> str | None:
        return self.erreurs.get(champ)

    def classe_invalide(self, champ: str) -> str:
        return "is-invalid" if champ in self.champs_invalides else ""


# une option de la liste des titres
@dataclass(frozen=True)
class OptionTitre:
    valeur: str
    selectionne: bool


# ce qu'utilise le fragment commun champs_personne.html (titre, prénom, nom) :
# une classe de base, dont héritent les trois modèles de formulaire
@dataclass
class FormulaireVue:
    titre_page: str
    erreur: Message | None
    valeurs: ValeursPersonne
    etat: EtatFormulaire

    # une propriété : se lit comme un attribut (modele.titres), mais est calculée
    @property
    def titres(self) -> list[OptionTitre]:
        return [OptionTitre(t, t == self.valeurs.titre) for t in regles.TITRES]

    @property
    def mot_de_passe_min(self) -> int:
        return regles.MOT_DE_PASSE_MIN


@dataclass
class FormulairePersonneModele(FormulaireVue):
    creation: bool  # True : ajout ; False : modification
    action: str  # URL du POST
    url_liste: str


@dataclass
class InscriptionModele(FormulaireVue):
    pass


@dataclass
class MonCompteModele(FormulaireVue):
    pass


# ------------------------------------------------------------------ listes médecins / clients

# les clés de traduction propres à chaque type de personne
@dataclass(frozen=True)
class ClesPersonnes:
    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 et son compte ?"
    ajoute: str  # "Le médecin {nom} a été ajouté (login : {login})."
    modifie: str
    supprime: str


@dataclass(frozen=True)
class PersonneVue:
    titre: str
    prenom: str
    nom: str
    login: str
    url_modifier: str
    url_supprimer: str


@dataclass
class ListePersonnesModele:
    titre_page: str
    erreur: Message | None
    cles: ClesPersonnes
    url_ajouter: str
    personnes: list[PersonneVue]


# ------------------------------------------------------------------ espace du patient
@dataclass(frozen=True)
class RendezVousVue:
    id: int
    jour: str
    horaire: str
    medecin: str
    url_agenda: str


@dataclass
class EspaceModele:
    titre_page: str
    erreur: Message | None
    patient: str
    rendez_vous: list[RendezVousVue]


# ------------------------------------------------------------------ page d'erreur
@dataclass
class ErreurModele:
    titre_page: str
    erreur: Message | None
    code: int
    message: str

Les modèles de vue (*view models*) : ce que chaque action transmet à sa vue, sous le nom [modele]. La règle suivie par toutes les pages : pas de logique dans les vues. Les actions 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 vues se contentent de les placer dans le HTML.

  • lignes 26-28 : un message à traduire : une clé et ses paramètres. [field(default_factory=dict)] : la valeur par défaut est un nouveau dictionnaire vide pour chaque objet. On ne peut pas écrire [= {}] : ce dictionnaire serait créé une seule fois, et partagé par tous les objets ;
  • lignes 33-36 : une option d'une liste déroulante ;
  • lignes 41-45 : le modèle de la page de connexion. Chaque modèle de page a deux champs lus par le gabarit : [titre_page], la clé du titre de la page, et [erreur], le message d'erreur affiché en bas de page ;
  • lignes 50-58 : une ligne de l'agenda, déjà préparée pour l'utilisateur connecté : [patient] vaut [None] pour un créneau occupé par un autre patient, quand l'utilisateur est un patient (la vue affichera « Réservé ») ; [reservable] dit s'il faut afficher le bouton Réserver ; [id_rv] n'est renseigné que si le bouton Annuler doit être affiché. Toutes ces décisions sont prises par l'action ;
  • lignes 69-73 : la fenêtre de réservation : la liste des patients pour un administrateur ou un médecin, le nom du patient lui-même pour un patient ;
  • lignes 77-88 : le modèle de la page de l'agenda ;
  • lignes 108-116 : l'état d'un formulaire refusé, avec deux méthodes appelées par les vues : le message d'un champ, et la classe CSS qui l'encadre en rouge ;
  • lignes 129-142 : la classe de base des trois modèles de formulaire, utilisée par le fragment commun [champs_personne.html]. Ses deux propriétés calculent la liste des titres (avec le titre sélectionné) et la longueur minimale d'un mot de passe ;
  • lignes 146-158 : les trois modèles de formulaire héritent de [FormulaireVue]. Une [dataclass] qui hérite d'une autre ajoute ses champs à ceux de sa classe mère ;
  • lignes 166-174 : les clés de traduction propres aux médecins ou aux clients : la même vue affiche les deux listes ;
  • lignes 198-211 : l'espace du patient ;
  • lignes 216-220 : la page d'erreur.

11.16. La couche web : les contrôleurs

Le dossier [controllers] contient un blueprint par partie de l'application (le contrôleur de la connexion, [controllers/auth.py], a été présenté avec l'authentification) :

Blueprint
Adresses
[langue]
[GET /langue/<code>]
[agenda]
[GET /agenda], [POST /agenda/reserver], [POST /agenda/annuler]
[medecins], [clients]
[/medecins/...], [/clients/...] : liste, ajout, modification, suppression
[inscription]
[POST /inscription/acces], [GET] et [POST /inscription]
[espace]
[/mon-espace], [/mon-espace/annuler], [/mon-compte], [/mon-compte/supprimer]

Toutes les actions suivent le même schéma : lire et vérifier la requête, appeler la couche métier (par l'[ApplicationModel]), construire le modèle de la vue ; ou, pour une action POST, déposer un message flash et rediriger (schéma PRG, exemple 20).

11.16.1. controllers/langue.py

# =========================================================================
# controllers/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, la
# fonction fixer_langue (cf. rdvmedecins/__init__.py) le relit et fixe la
# langue de la requête (g.langue). Puis on revient sur la page d'où l'on vient.
# =========================================================================
from datetime import timedelta
from urllib.parse import urlsplit

from flask import Blueprint, abort, current_app, redirect, request

from rdvmedecins.auth.securite import publique
from rdvmedecins.web.traducteur import COOKIE_LANGUE, LANGUES

langue = Blueprint("langue", __name__)


@langue.get("/langue/<code>")
@publique
def changer(code: str):
    if code not in LANGUES:
        abort(404)  # -> page d'erreur 404 (cf. web/erreurs.py)
    reponse = redirect(page_d_origine())
    reponse.set_cookie(COOKIE_LANGUE, code, max_age=timedelta(days=365), path="/",
                       httponly=True, samesite="Lax", secure=current_app.config["COOKIE_SECURE"])
    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 (/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").
# -----------------------------------------------------------------------
def page_d_origine() -> str:
    referer = request.headers.get("Referer")
    if not referer:
        return "/"
    url = urlsplit(referer)
    chemin = url.path
    # "//site.com/..." serait compris par le navigateur comme une adresse
    # sur un AUTRE site : refusé aussi
    if not url.scheme or not chemin.startswith("/") or chemin.startswith("//") \
            or chemin.startswith("/langue/"):
        return "/"
    return f"{chemin}?{url.query}" if url.query else chemin

Le choix de la langue de l'exemple 22, avec deux précautions de plus dans [page_d_origine] (lignes 40-51) : un [Referer] sans schéma ([http], [https]) n'est pas une adresse complète, et un [Referer] qui serait lui-même une adresse [/langue/...] renverrait vers cette action, en boucle. Dans les deux cas, on revient à la racine du site. La durée de vie du cookie est donnée par un [timedelta] : [set_cookie] l'accepte aussi bien qu'un nombre de secondes.

11.16.2. controllers/agenda.py

La page principale de l'application, pour tous les rôles.

# =========================================================================
# controllers/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 la vue - il n'est jamais placé dans le modèle
# transmis à la vue. 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é
# (cf. auth/securite.py : tout ce qui n'est pas @publique). @roles_requis
# sur une action exige en plus l'un des rôles cités. Sans jeton valide :
# retour à /login ; rôle insuffisant : page 403.
# =========================================================================
from dataclasses import dataclass
from datetime import date

from flask import Blueprint, abort, redirect, render_template, request, url_for

from rdvmedecins.auth.securite import roles_requis
from rdvmedecins.domain.agenda import AgendaMedecinJour
from rdvmedecins.entities import Client, Personne, Role
from rdvmedecins.metier import dates
from rdvmedecins.metier.exception import MetierException
from rdvmedecins.models.modeles_vues import (AgendaModele, AgendaVue, LigneAgendaVue, Message,
                                             OptionVue, ReservationVue)
from rdvmedecins.web import outils
from rdvmedecins.web.flash import flash_erreur, flash_succes
from rdvmedecins.web.services import application

agenda = Blueprint("agenda", __name__, url_prefix="/agenda")


# ce que le contrôleur sait de l'utilisateur, pour décider de l'affichage
@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  # USER sans rendez-vous à venir


# -----------------------------------------------------------------------
# GET /agenda
# Les paramètres de l'URL sont lus bruts (request.args.get), pour
# distinguer un paramètre ABSENT (None) d'un paramètre VIDE (?idMedecin=).
# -----------------------------------------------------------------------
@agenda.get("")
def afficher():
    erreur = None
    utilisateur = outils.utilisateur()
    droits = calculer_droits()

    # le jour : celui de l'URL s'il est valide, sinon aujourd'hui
    jour = dates.aujourdhui()
    jour_brut = request.args.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 = "ERRORS.INVALID_DATE"
    jour_reservable = jour >= dates.aujourdhui()

    # le médecin : celui de l'URL ; à défaut, pour un médecin connecté, lui-même
    id_medecin_brut = request.args.get("idMedecin")
    id_medecin = outils.entier_positif(id_medecin_brut)
    if id_medecin_brut is None and utilisateur.role == Role.DOCTOR:
        medecin = application().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 application().get_all_medecins()]

    # l'agenda, si un médecin est choisi (et la date valide)
    vue_agenda = None
    if id_medecin is not None and erreur is None:
        agenda_medecin = application().get_agenda_medecin_jour(id_medecin, jour)
        if agenda_medecin is None:
            erreur = "ERRORS.DOCTOR_NOT_FOUND"
        else:
            vue_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.args.get("reserver"))
    if vue_agenda is not None and id_creneau is not None:
        ligne = next((l for l in vue_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 application().get_all_clients()]
                if droits.gestionnaire else [],
                outils.libelle(droits.patient) if droits.patient else None)

    modele = AgendaModele(
        titre_page="AGENDA.PAGE_TITLE",
        erreur=Message(erreur) if erreur else None,
        medecins=medecins,
        id_medecin=id_medecin,
        jour=outils.iso(jour),
        agenda=vue_agenda,
        reservation=reservation,
        url_retour=url_agenda(id_medecin, jour) if id_medecin is not None else url_for("agenda.afficher"),
        info=info_patient(droits, vue_agenda))
    return render_template("agenda.html", modele=modele)


# -----------------------------------------------------------------------
# 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).
# -----------------------------------------------------------------------
@agenda.post("/reserver")
@roles_requis(Role.ADMIN, Role.DOCTOR, Role.USER)
def reserver():
    id_medecin, jour = lire_agenda_demande()
    id_creneau = outils.entier_positif(request.form.get("idCreneau"))
    if id_medecin is None or id_creneau is None:
        abort(400)  # -> page "requête invalide" (cf. web/erreurs.py)
    utilisateur = outils.utilisateur()

    if utilisateur.est_patient:
        client = outils.patient_connecte(application())
    else:
        id_client = outils.entier_positif(request.form.get("idClient"))
        if id_client is None:
            # aucun patient choisi : on rouvre la fenêtre de réservation
            flash_erreur("ERRORS.CHOOSE_PATIENT")
            return redirect(url_agenda(id_medecin, jour, id_creneau))
        client = application().get_client_by_id(id_client)

    creneau = application().get_creneau_by_id(id_creneau)
    if client is None:
        flash_erreur("ERRORS.PATIENT_NOT_FOUND")
    elif creneau is None or creneau.id_medecin != id_medecin:
        flash_erreur("ERRORS.SLOT_NOT_FOUND")
    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
            application().ajouter_rv(jour, creneau, client)
            flash_succes("AGENDA.BOOKED")
        except MetierException 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.cle == "ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT" and utilisateur.est_patient:
                flash_erreur("AGENDA.ALREADY_BOOKED_INFO")
            else:
                flash_erreur(e.cle, **e.parametres)
    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)
# -----------------------------------------------------------------------
@agenda.post("/annuler")
@roles_requis(Role.ADMIN, Role.DOCTOR)
def annuler():
    id_medecin, jour = lire_agenda_demande()
    id_rv = outils.entier_positif(request.form.get("idRv"))
    if id_medecin is None or id_rv is None:
        abort(400)
    rv = application().get_rv_by_id(id_rv)
    if rv is None:
        # déjà annulé (par quelqu'un d'autre, ou double clic)
        flash_erreur("ERRORS.APPOINTMENT_NOT_FOUND")
    else:
        application().supprimer_rv(rv)
        flash_succes("AGENDA.CANCELLED")
    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() -> Droits:
    if not outils.utilisateur().est_patient:
        return Droits(True, None, False)
    patient = outils.patient_connecte(application())
    rv_a_venir = application().get_rv_a_venir_client(patient.id)
    return Droits(False, patient, not rv_a_venir)


# -----------------------------------------------------------------------
# l'agenda du médecin, mis en forme pour la vue - 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, rv = cmj.creneau, 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.id_client == droits.patient.id
        lignes.append(LigneAgendaVue(
            id_creneau=creneau.id,
            horaire=outils.horaire(creneau),
            libre=libre,
            # LE point de confidentialité : un patient ne reçoit le nom que
            # de SON rendez-vous ; pour les autres, None (la vue affiche « Réservé »)
            patient=outils.libelle(rv.client) 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(outils.libelle(agenda_medecin.medecin), outils.formater_jour(jour), lignes)


# ----------------------------------------------------------------- fonctions utilitaires

# les champs cachés idMedecin / jour des formulaires POST ; (None, None) s'ils sont invalides
def lire_agenda_demande() -> tuple[int | None, date | None]:
    id_medecin = outils.entier_positif(request.form.get("idMedecin"))
    jour = outils.date_valide(request.form.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] : url_for ajoute à la fin de
# l'URL les paramètres qui ne sont pas des variables du chemin
def url_agenda(id_medecin: int, jour: date, reserver: int | None = None) -> str:
    if reserver is None:
        return url_for("agenda.afficher", idMedecin=id_medecin, jour=outils.iso(jour))
    return url_for("agenda.afficher", idMedecin=id_medecin, jour=outils.iso(jour), reserver=reserver)


def option(p: Personne, selectionne: bool) -> OptionVue:
    return OptionVue(p.id, outils.libelle(p), 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, vue: AgendaVue | None) -> str | None:
    if droits.patient is None or droits.patient_peut_reserver:
        return None
    rv_affiche = vue is not None and any(ligne.moi for ligne in vue.lignes)
    return None if rv_affiche else "AGENDA.ALREADY_BOOKED_INFO"

11.16.2.1. Les URL de la page

Toute la page est décrite par son URL : [/agenda?idMedecin=1&jour=2026-09-26] affiche l'agenda du médecin n° 1 ce jour-là ; [&reserver=4] ouvre en plus la fenêtre de réservation du créneau n° 4. La fenêtre de réservation n'est donc pas ouverte par du [JavaScript] : c'est le serveur qui fabrique une page contenant la fenêtre. On peut ainsi recharger la page, la mettre en favori, utiliser le bouton « Précédent » du navigateur, et l'application fonctionne sans [JavaScript] (le seul script de l'application sert aux fenêtres de confirmation, et s'en passe aussi, exemple 21).

11.16.2.2. GET /agenda

  • lignes 65-68 : ce que l'action sait de l'utilisateur, pour décider de l'affichage : un « gestionnaire » (ADMIN ou DOCTOR), ou un patient, qui peut ou non réserver ;
  • lignes 83-91 : le jour : celui de l'URL s'il est valide, aujourd'hui sinon. Les paramètres sont lus bruts ([request.args.get]), pour distinguer un paramètre absent ([None] : on prend la valeur par défaut, sans erreur) d'un paramètre invalide (une erreur est signalée). Seuls aujourd'hui et les jours suivants sont réservables ;
  • lignes 94-98 : le médecin : celui de l'URL ; à défaut, pour un médecin connecté, lui-même : un médecin voit directement son agenda du jour en se connectant ;
  • ligne 101 : la liste déroulante des médecins, lue dans le cache, avec le médecin choisi sélectionné ;
  • lignes 104-110 : l'agenda, si un médecin est choisi et la date valide. Un médecin inexistant (un id modifié à la main dans l'URL) donne un message d'erreur, et non une page d'erreur ;
  • lignes 114-125 : la fenêtre de réservation, seulement si le créneau demandé existe et est réservable par cet utilisateur : un patient qui ajouterait [&reserver=2] à l'URL, pour un créneau déjà pris, n'obtiendrait rien. [next(...)] cherche la ligne de l'agenda correspondante (exemple 21). La fenêtre propose la liste des patients à un gestionnaire ; pour un patient, elle affiche son propre nom ;
  • lignes 127-137 : le modèle de la page. [url_retour] est l'adresse de l'agenda sans la fenêtre de réservation : le bouton Annuler de la fenêtre y mène.

11.16.2.3. POST /agenda/reserver

  • ligne 152 : tous les rôles peuvent réserver. Le décorateur documente la règle, et la protège si un quatrième rôle apparaissait un jour ;
  • lignes 154-157 : les champs cachés du formulaire. Des champs invalides ne peuvent venir que d'une requête fabriquée à la main : erreur 400 ;
  • lignes 160-168 : le patient du rendez-vous. Pour un patient, c'est toujours lui-même : le champ [idClient] est ignoré. Faire confiance à un champ envoyé par le navigateur permettrait à un patient de réserver au nom d'un autre. Pour un gestionnaire qui n'a choisi aucun patient, la fenêtre de réservation est rouverte, avec un message ;
  • lignes 170-189 : les vérifications (le patient existe, le créneau existe et appartient au médecin de l'agenda), puis la réservation. Les règles de gestion sont vérifiées par la couche métier ; une [MetierException] devient un message flash d'erreur. Le message « un seul rendez-vous à venir » est adapté quand c'est le patient lui-même qui réserve (« Vous avez déjà... » plutôt que « Ce patient a déjà... ») ;
  • ligne 190 : dans tous les cas, retour à l'agenda (schéma PRG).

11.16.2.4. POST /agenda/annuler

  • lignes 197-211 : l'annulation d'un rendez-vous, réservée aux gestionnaires (un patient annule son rendez-vous depuis son espace). Un rendez-vous déjà annulé (par quelqu'un d'autre, ou par un double clic) donne un message, et non une erreur.

11.16.2.5. Les droits, l'agenda mis en forme et les fonctions utilitaires

  • lignes 217-222 : les droits de l'utilisateur. Pour un patient, il faut connaître ses rendez-vous à venir : une liste vide est « fausse », d'où [not rv_a_venir] ;
  • lignes 228-248 : l'agenda du domaine, mis en forme pour la vue, et filtré selon les droits. C'est ici que se joue la confidentialité des patients (ligne 243) : pour un patient, le nom des autres patients n'est pas simplement caché par la vue, il n'est jamais placé dans le modèle. Le HTML envoyé au navigateur ne le contient donc pas, même caché (un simple « Afficher le code source » le révélerait sinon). Le bouton Réserver n'apparaît que pour un créneau libre, un jour réservable, et un utilisateur qui a le droit de réserver ; le bouton Annuler, que pour un gestionnaire ;
  • lignes 254-257 : les champs cachés [idMedecin] et [jour] des formulaires POST, vérifiés ;
  • lignes 262-265 : l'URL de l'agenda. [url_for] ajoute à l'URL, comme paramètres, les paramètres nommés qui ne sont pas des variables du chemin (exemple 16) ;
  • lignes 278-282 : le message d'information d'un patient qui a déjà un rendez-vous à venir : il explique l'absence de boutons Réserver. Il n'est pas affiché si son propre rendez-vous figure dans l'agenda (en bleu), ce qui est le cas juste après la réservation. [any(...)] rend [True] si l'un des éléments est vrai.

11.16.3. controllers/personnes.py

La gestion des médecins et celle des clients, réservées à l'administrateur, sont identiques : même liste, même formulaire, mêmes règles. Plutôt que d'écrire deux fois les mêmes actions, on les écrit une fois, dans une fabrique de blueprints.

# =========================================================================
# controllers/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 actions, on les écrit une fois dans une
# FABRIQUE DE BLUEPRINTS : la fonction creer_blueprint construit un
# blueprint complet, à partir d'une "configuration" qui dit QUOI appeler et
# QUELS libellés afficher. controllers/medecins.py et controllers/clients.py
# l'appellent chacun avec la sienne. On obtient ainsi, pour chacun :
#
#   GET  /medecins                   liste
#   GET  /medecins/ajouter           formulaire vide
#   POST /medecins/ajouter           création
#   GET  /medecins/<id>/modifier     formulaire pré-rempli
#   POST /medecins/<id>/modifier     modification
#   POST /medecins/<id>/supprimer    suppression
#
# (et les mêmes URL sous /clients). Les vues sont elles aussi communes :
# templates/personnes/liste.html et templates/personnes/formulaire.html.
#
# SÉCURITÉ : un before_request DU BLUEPRINT exige le rôle ADMIN. Il
# s'exécute avant CHAQUE action du blueprint - y compris celles qu'on lui
# ajouterait plus tard : la règle est écrite une fois, pour toutes.
# =========================================================================
from collections.abc import Callable
from dataclasses import dataclass

from flask import Blueprint, abort, redirect, render_template, url_for

from rdvmedecins.auth.securite import exiger_roles
from rdvmedecins.entities import Personne, Role
from rdvmedecins.forms.formulaires import FormulairePersonne
from rdvmedecins.metier.donnees import DonneesPersonne
from rdvmedecins.metier.exception import MetierException
from rdvmedecins.models.modeles_vues import (ClesPersonnes, EtatFormulaire, FormulairePersonneModele,
                                             ListePersonnesModele, Message, PersonneVue,
                                             ValeursPersonne)
from rdvmedecins.web import validation
from rdvmedecins.web.flash import flash_erreur, flash_succes


# -----------------------------------------------------------------------
# ce que chaque type de personne doit fournir. Les cinq derniers champs
# sont des FONCTIONS : medecins.py et clients.py y rangent des lambdas qui
# appellent les méthodes de l'ApplicationModel (get_all_medecins...).
# -----------------------------------------------------------------------
@dataclass(frozen=True)
class ConfigPersonnes:
    nom: str  # "medecins" ou "clients" : le nom du blueprint, et le début des URL
    cles: ClesPersonnes
    lister: Callable[[], list[Personne]]  # () -> liste
    trouver: Callable[[int], Personne | None]  # id -> personne
    ajouter: Callable[[DonneesPersonne], Personne]  # données -> personne créée
    modifier: Callable[[int, DonneesPersonne], Personne]  # (id, données) -> personne modifiée
    supprimer: Callable[[int], None]  # id -> rien


def creer_blueprint(config: ConfigPersonnes) -> Blueprint:
    bp = Blueprint(config.nom, __name__, url_prefix=f"/{config.nom}")

    # l'URL d'une action de CE blueprint : url("liste") -> "/medecins"
    def url(action: str, **parametres) -> str:
        return url_for(f"{config.nom}.{action}", **parametres)

    # toutes les actions du blueprint sont réservées à l'ADMIN
    @bp.before_request
    def reserve_admin():
        exiger_roles(Role.ADMIN)

    # -------------------------------------------------------------------
    # GET /medecins (ou /clients)
    # -------------------------------------------------------------------
    @bp.get("")
    def liste():
        personnes = [PersonneVue(p.titre, p.prenom, p.nom, login(p),
                                 url("formulaire_modification", id=p.id),
                                 url("supprimer", id=p.id))
                     for p in config.lister()]
        modele = ListePersonnesModele(config.cles.titre, None, config.cles, url("formulaire_ajout"),
                                      personnes)
        return render_template("personnes/liste.html", modele=modele)

    # -------------------------------------------------------------------
    # GET /medecins/ajouter
    # -------------------------------------------------------------------
    @bp.get("/ajouter")
    def formulaire_ajout():
        return afficher_formulaire(None, ValeursPersonne(), {}, None, None)

    # -------------------------------------------------------------------
    # POST /medecins/ajouter
    # FormulairePersonne() lit la saisie ; validate() la vérifie.
    # -------------------------------------------------------------------
    @bp.post("/ajouter")
    def ajouter():
        form = FormulairePersonne()
        if not form.validate():
            return afficher_formulaire(None, valeurs_saisies(form), validation.erreurs_de(form),
                                       None, None)
        try:
            personne = config.ajouter(donnees(form))
        except MetierException as e:
            return afficher_erreur_metier(None, valeurs_saisies(form), e)
        flash_succes(config.cles.ajoute, **parametres(personne))
        return redirect(url("liste"))

    # -------------------------------------------------------------------
    # GET /medecins/<id>/modifier
    # <int:id> : l'id doit être fait de chiffres - /medecins/abc/modifier ne
    # correspond à aucune route (page 404)
    # -------------------------------------------------------------------
    @bp.get("/<int:id>/modifier")
    def formulaire_modification(id: int):
        personne = config.trouver(id)
        if personne is None:
            abort(404)
        valeurs = ValeursPersonne(personne.titre, personne.prenom, personne.nom, login(personne),
                                  personne.version)
        return afficher_formulaire(id, valeurs, {}, None, None)

    # -------------------------------------------------------------------
    # POST /medecins/<id>/modifier
    # -------------------------------------------------------------------
    @bp.post("/<int:id>/modifier")
    def modifier(id: int):
        form = FormulairePersonne()
        if not form.validate():
            return afficher_formulaire(id, valeurs_saisies(form), validation.erreurs_de(form),
                                       None, None)
        try:
            personne = config.modifier(id, donnees(form))
        except MetierException as e:
            return afficher_erreur_metier(id, valeurs_saisies(form), e)
        flash_succes(config.cles.modifie, **parametres(personne))
        return redirect(url("liste"))

    # -------------------------------------------------------------------
    # POST /medecins/<id>/supprimer
    # (la confirmation "Êtes-vous sûr ?" est demandée avant l'envoi du
    # formulaire, cf. static/js/app.js)
    # -------------------------------------------------------------------
    @bp.post("/<int:id>/supprimer")
    def supprimer(id: int):
        personne = config.trouver(id)
        try:
            config.supprimer(id)
            flash_succes(config.cles.supprime, **parametres(personne))
        except MetierException as e:
            flash_erreur(e.cle, **e.parametres)
        return redirect(url("liste"))

    # ----------------------------------------------------------------- fonctions internes

    # id None : formulaire de création ; sinon : formulaire de modification.
    # erreurs : champ -> clé du message ; erreur : la clé du message de bas
    # de page ; autres_champs : des champs à encadrer en rouge en plus
    def afficher_formulaire(id: int | None, valeurs: ValeursPersonne, erreurs: dict[str, str],
                            erreur: str | None, autres_champs: list[str] | None):
        creation = id is None
        etat: EtatFormulaire = validation.etat(erreurs, autres_champs)
        modele = FormulairePersonneModele(
            titre_page=config.cles.ajouter if creation else config.cles.modifier,
            erreur=validation.message_page(erreurs, Message(erreur) if erreur else None),
            valeurs=valeurs,
            etat=etat,
            creation=creation,
            action=url("ajouter") if creation else url("modifier", id=id),
            url_liste=url("liste"))
        return render_template("personnes/formulaire.html", modele=modele)

    # une règle de gestion a refusé l'écriture : on réaffiche le formulaire,
    # avec la saisie de l'utilisateur et le message de la couche métier
    def afficher_erreur_metier(id: int | None, valeurs: ValeursPersonne, e: MetierException):
        if e.cle in ("ERRORS.DOCTOR_NOT_FOUND", "ERRORS.PATIENT_NOT_FOUND"):
            # supprimé entre-temps par un autre ADMIN : retour à la liste
            flash_erreur(e.cle)
            return redirect(url("liste"))
        # le message va en bas de page ; les champs concernés, s'il y en a, sont
        # encadrés en rouge pour que l'utilisateur voie tout de suite quoi corriger
        return afficher_formulaire(id, valeurs, {}, e.cle, validation.CHAMPS_ERREURS_METIER.get(e.cle))

    return bp


# ce que l'utilisateur a saisi (mot de passe exclu : on ne le renvoie
# jamais au navigateur). form.<champ>.data : la valeur, après les filtres
def valeurs_saisies(form: FormulairePersonne) -> ValeursPersonne:
    return ValeursPersonne(form.titre.data or "", form.prenom.data or "", form.nom.data or "",
                           form.login.data or "", form.version.data)


# le formulaire validé -> les données attendues par la couche métier
def donnees(form: FormulairePersonne) -> DonneesPersonne:
    return DonneesPersonne(form.titre.data, form.nom.data, form.prenom.data, form.login.data,
                           form.password.data, form.version.data)


# {nom} et {login} des messages "ajouté / modifié / supprimé"
def parametres(personne: Personne | None) -> dict[str, str]:
    if personne is None:
        return {"nom": "", "login": ""}
    return {"nom": f"{personne.prenom} {personne.nom}", "login": login(personne)}


def login(personne: Personne) -> str:
    return personne.user.login if personne.user else ""
  • lignes 49-56 : ce que chaque type de personne doit fournir : le nom du blueprint (qui est aussi le début des URL), les clés de traduction, et cinq fonctions : lister, trouver, ajouter, modifier, supprimer. C'est le même procédé que les descripteurs de la couche métier ;
  • lignes 59-60 : la fabrique crée un blueprint, dont le nom et le préfixe d'URL viennent de la configuration. Les actions sont des fonctions internes de [creer_blueprint] : elles ont accès à la variable [config] de la fabrique. Chaque appel de [creer_blueprint] crée un nouveau blueprint, avec ses propres actions ;
  • lignes 63-64 : l'URL d'une action de ce blueprint : [url("liste")] rend [/medecins] pour le blueprint des médecins, [/clients] pour celui des clients ;
  • lignes 67-69 : un [before_request] de blueprint : il s'exécute avant chaque action du blueprint (et seulement de celui-ci), après ceux de l'application. Toutes les actions sont ainsi réservées à l'administrateur, y compris celles qu'on ajouterait plus tard : la règle est écrite une fois, pour toutes ;
  • lignes 74-82 : la liste. [@bp.get("")] : la route est le préfixe du blueprint lui-même ([/medecins]) ;
  • lignes 95-106 : l'ajout : validation du formulaire (les erreurs de saisie, réaffichées sous les champs), appel de la couche métier (les erreurs métier, réaffichées en bas de page), puis message flash et redirection vers la liste. Les messages flash reçoivent des paramètres : le nom et le login de la personne ;
  • lignes 113-120 : le formulaire de modification, pré-rempli, avec la version de la fiche (verrou optimiste). Une personne inexistante donne une page 404 ;
  • lignes 125-136 : la modification, sur le modèle de l'ajout ;
  • lignes 143-151 : la suppression, confirmée au préalable par la fenêtre de confirmation (exemple 21). Le refus de la couche métier (un médecin qui a des rendez-vous) devient un message flash d'erreur ;
  • lignes 158-170 : le formulaire d'ajout ([id] vaut [None]) ou de modification, avec son état (valeurs, erreurs, champs en rouge) ;
  • lignes 174-181 : une erreur métier : une personne supprimée entre-temps par un autre administrateur renvoie à la liste ; les autres réaffichent le formulaire, les champs concernés encadrés en rouge ;
  • lignes 188-190 : les valeurs à réafficher : ce que l'utilisateur a saisi, jamais le mot de passe ;
  • lignes 194-196 : le formulaire validé, transformé en données pour la couche métier ;
  • lignes 200-203 : les paramètres des messages « ajouté / modifié / supprimé ».

11.16.4. controllers/medecins.py et controllers/clients.py

# =========================================================================
# controllers/medecins.py
# -------------------------------------------------------------------------
# Gestion des médecins (rôle ADMIN uniquement). Toutes les actions sont
# fabriquées par controllers/personnes.py : il ne reste ici qu'à dire QUOI
# appeler et QUELS libellés afficher.
#
# Les fonctions de la configuration sont des LAMBDAS (des fonctions sans
# nom, écrites sur place) : application() n'est appelée qu'au moment où
# l'action s'exécute, pendant une requête - l'ApplicationModel n'existe pas
# encore quand ce module est importé.
# =========================================================================
from rdvmedecins.controllers.personnes import ConfigPersonnes, creer_blueprint
from rdvmedecins.models.modeles_vues import ClesPersonnes
from rdvmedecins.web.services import application

medecins = creer_blueprint(ConfigPersonnes(
    nom="medecins",
    cles=ClesPersonnes(
        titre="MEDECINS.TITLE",
        ajouter="MEDECINS.ADD",
        modifier="MEDECINS.EDIT",
        vide="MEDECINS.EMPTY",
        confirmer_suppression="MEDECINS.DELETE_CONFIRM",
        ajoute="MEDECINS.ADDED",
        modifie="MEDECINS.UPDATED",
        supprime="MEDECINS.DELETED"),
    lister=lambda: application().get_all_medecins(),
    trouver=lambda id: application().get_medecin_by_id(id),
    ajouter=lambda donnees: application().ajouter_medecin(donnees),
    modifier=lambda id, donnees: application().modifier_medecin(id, donnees),
    supprimer=lambda id: application().supprimer_medecin(id)))

Le blueprint des médecins, fabriqué par [creer_blueprint] avec sa configuration. Les fonctions de la configuration sont des fonctions anonymes ([lambda]), qui appellent l'[ApplicationModel]. Pourquoi ne pas écrire directement [lister=application().get_all_medecins] ? Parce que ce module est exécuté au moment de son importation, au démarrage, avant la création de l'application : [application()], qui a besoin de [current_app], échouerait. La fonction anonyme retarde l'appel au moment où l'action s'exécute, pendant une requête.

# =========================================================================
# controllers/clients.py
# -------------------------------------------------------------------------
# Gestion des clients (les PATIENTS du cabinet), rôle ADMIN uniquement.
# Même principe que controllers/medecins.py (voir ses commentaires).
# =========================================================================
from rdvmedecins.controllers.personnes import ConfigPersonnes, creer_blueprint
from rdvmedecins.models.modeles_vues import ClesPersonnes
from rdvmedecins.web.services import application

clients = creer_blueprint(ConfigPersonnes(
    nom="clients",
    cles=ClesPersonnes(
        titre="CLIENTS.TITLE",
        ajouter="CLIENTS.ADD",
        modifier="CLIENTS.EDIT",
        vide="CLIENTS.EMPTY",
        confirmer_suppression="CLIENTS.DELETE_CONFIRM",
        ajoute="CLIENTS.ADDED",
        modifie="CLIENTS.UPDATED",
        supprime="CLIENTS.DELETED"),
    lister=lambda: application().get_all_clients(),
    trouver=lambda id: application().get_client_by_id(id),
    ajouter=lambda donnees: application().ajouter_client(donnees),
    modifier=lambda id, donnees: application().modifier_client(id, donnees),
    supprimer=lambda id: application().supprimer_client(id)))

Le blueprint des clients, sur le même modèle.

11.16.5. controllers/inscription.py

# =========================================================================
# controllers/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. auth/passe_inscription.py) ;
#   2. sans laissez-passer valide, /inscription renvoie à la page
#      d'authentification - taper l'URL directement ne sert à rien ;
#   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. auth/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 flask import Blueprint, current_app, redirect, render_template, request, url_for

from rdvmedecins.auth import passe_inscription
from rdvmedecins.auth.securite import publique
from rdvmedecins.controllers.auth import adresse_ip
from rdvmedecins.forms.formulaires import FormulaireInscription
from rdvmedecins.metier.donnees import DonneesPersonne
from rdvmedecins.metier.exception import MetierException
from rdvmedecins.models.modeles_vues import InscriptionModele, Message, ValeursPersonne
from rdvmedecins.web import validation
from rdvmedecins.web.flash import flash_erreur, flash_succes
from rdvmedecins.web.services import application, captchas, limiteur, passes

inscription = Blueprint("inscription", __name__, url_prefix="/inscription")


# -----------------------------------------------------------------------
# POST /inscription/acces   champs : captchaId, captcha (ceux de la page
# d'authentification ; login et mot de passe, envoyés aussi, sont ignorés)
# -----------------------------------------------------------------------
@inscription.post("/acces")
@publique
def acces():
    ip = adresse_ip()

    # trop de comptes créés récemment depuis cette adresse : inutile d'aller plus loin
    minutes = limiteur().minutes_blocage_inscription(ip)
    if minutes > 0:
        flash_erreur("ERRORS.TOO_MANY_ACCOUNTS", minutes=str(minutes))
        return redirect(url_for("auth.afficher_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.form.get("captchaId"), request.form.get("captcha")):
        limiteur().echec_login(ip, "")
        flash_erreur("ERRORS.CAPTCHA_BEFORE_ACCOUNT")
        return redirect(url_for("auth.afficher_login"))

    # captcha correct : on délivre le laissez-passer et on ouvre le formulaire
    reponse = redirect(url_for("inscription.formulaire"))
    passe_inscription.deposer_cookie(reponse, passes().emettre(), current_app.config["COOKIE_SECURE"])
    return reponse


# -----------------------------------------------------------------------
# GET /inscription
# -----------------------------------------------------------------------
@inscription.get("")
@publique
def formulaire():
    if not passes().est_valide(request.cookies.get(passe_inscription.COOKIE)):
        return retour_login()
    return afficher(ValeursPersonne(), {}, None, None)


# -----------------------------------------------------------------------
# POST /inscription
# -----------------------------------------------------------------------
@inscription.post("")
@publique
def creer():
    passe = request.cookies.get(passe_inscription.COOKIE)
    if not passes().est_valide(passe):
        return retour_login()
    ip = adresse_ip()
    form = FormulaireInscription()
    form.validate()
    valeurs = ValeursPersonne(form.titre.data or "Mr", form.prenom.data or "",
                              form.nom.data or "", form.login.data or "", None)

    # 1. trop de comptes créés récemment depuis cette adresse ?
    minutes = limiteur().minutes_blocage_inscription(ip)
    if minutes > 0:
        return afficher(valeurs, {}, Message("ERRORS.TOO_MANY_ACCOUNTS", {"minutes": str(minutes)}),
                        None)

    # 2. toutes les erreurs de saisie d'un coup (champs + confirmation) :
    #    l'utilisateur les corrige toutes en une fois
    erreurs = validation.erreurs_de(form)
    if "password" not in erreurs and "confirmation" not in erreurs \
            and form.password.data != form.confirmation.data:
        erreurs["confirmation"] = "ERRORS.PASSWORDS_DIFFER"
    if erreurs:
        return afficher(valeurs, erreurs, None, None)

    # 3. la création (client + compte USER, dans une transaction)
    try:
        application().ajouter_client(DonneesPersonne(form.titre.data, form.nom.data,
                                                     form.prenom.data, form.login.data,
                                                     form.password.data))
    except MetierException as e:
        # login ou prénom + nom déjà pris : le laissez-passer reste valable
        return afficher(valeurs, {}, Message(e.cle), validation.CHAMPS_ERREURS_METIER.get(e.cle))

    # 4. le laissez-passer a servi : il ne permettra pas de créer un 2e compte
    passes().consommer(passe)
    limiteur().inscription_reussie(ip)

    # 5. retour à la page de connexion, login pré-rempli
    flash_succes("INSCRIPTION.DONE", login=form.login.data)
    reponse = redirect(url_for("auth.afficher_login", login=form.login.data))
    passe_inscription.effacer_cookie(reponse)
    return reponse


# ----------------------------------------------------------------- fonctions internes

# pas de laissez-passer valide : retour à la page d'authentification
def retour_login():
    flash_erreur("ERRORS.CAPTCHA_BEFORE_ACCOUNT")
    return redirect(url_for("auth.afficher_login"))


# le formulaire ; jamais de mot de passe renvoyé au navigateur
def afficher(valeurs: ValeursPersonne, erreurs: dict[str, str], erreur: Message | None,
             autres_champs: list[str] | None):
    modele = InscriptionModele("INSCRIPTION.TITLE", validation.message_page(erreurs, erreur),
                               valeurs, validation.etat(erreurs, autres_champs))
    return render_template("inscription.html", modele=modele)
  • lignes 51-72 : le bouton « Créer un compte client » de la page de connexion envoie le formulaire de connexion à cette action (grâce à l'attribut [formaction], exemple 20). Elle vérifie la limite de créations de comptes, puis le captcha de la page de connexion ; s'il est correct, elle délivre un laissez-passer ([auth/passe_inscription.py]) et redirige vers le formulaire d'inscription. Un échec est compté comme un échec de connexion (avec un login vide) ;
  • lignes 78-83 : le formulaire, seulement avec un laissez-passer valide : taper directement l'adresse [/inscription] renvoie à la page de connexion ;
  • lignes 89-133 : la création : - le laissez-passer, encore ; - [form.validate()] est appelé, mais son résultat ignoré : on veut toutes les erreurs de saisie d'un coup, y compris la différence entre le mot de passe et sa confirmation, vérifiée ensuite par l'action (si les deux champs sont par ailleurs valides). L'utilisateur les corrige toutes en une fois ; - la création du client et de son compte, par la même méthode de la couche métier que pour l'administrateur ; - le laissez-passer est consommé, la création comptée par le limiteur ; - retour à la page de connexion, avec un message et le login pré-rempli (paramètre [login] de l'URL). Le cookie du laissez-passer est effacé.

11.16.6. controllers/espace.py

Les pages personnelles du patient.

# =========================================================================
# controllers/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 contrôleur : 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).
# =========================================================================
from flask import Blueprint, redirect, render_template, request, url_for

from rdvmedecins.auth.auth_service import AuthService
from rdvmedecins.auth.securite import exiger_roles
from rdvmedecins.controllers.auth import adresse_ip
from rdvmedecins.entities import Client, Role
from rdvmedecins.forms.formulaires import FormulaireMonCompte
from rdvmedecins.metier.donnees import DonneesPersonne
from rdvmedecins.metier.exception import MetierException
from rdvmedecins.models.modeles_vues import (EspaceModele, Message, MonCompteModele, RendezVousVue,
                                             ValeursPersonne)
from rdvmedecins.web import outils, validation
from rdvmedecins.web.flash import flash_erreur, flash_succes
from rdvmedecins.web.services import application, auth_service, limiteur

espace = Blueprint("espace", __name__)


# toutes les actions de ce blueprint sont réservées au rôle USER
@espace.before_request
def reserve_patient():
    exiger_roles(Role.USER)


# -----------------------------------------------------------------------
# GET /mon-espace
# -----------------------------------------------------------------------
@espace.get("/mon-espace")
def espace_patient():
    patient = outils.patient_connecte(application())
    rendez_vous = [RendezVousVue(
        rv.id,
        outils.formater_jour(rv.jour),
        outils.horaire(rv.creneau),
        outils.libelle(rv.creneau.medecin),
        # lien vers l'agenda du médecin ce jour-là : le patient y voit son nom
        url_for("agenda.afficher", idMedecin=rv.creneau.id_medecin, jour=outils.iso(rv.jour)))
        for rv in application().get_rv_a_venir_client(patient.id)]
    modele = EspaceModele("ESPACE.TITLE", None, outils.libelle(patient), rendez_vous)
    return render_template("espace.html", modele=modele)


# -----------------------------------------------------------------------
# POST /mon-espace/annuler   champ : idRv
# -----------------------------------------------------------------------
@espace.post("/mon-espace/annuler")
def annuler_rv():
    patient = outils.patient_connecte(application())
    id_rv = outils.entier_positif(request.form.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.id_client != patient.id:
        flash_erreur("ERRORS.APPOINTMENT_NOT_FOUND")
    else:
        application().supprimer_rv(rv)
        flash_succes("AGENDA.CANCELLED")
    return redirect(url_for("espace.espace_patient"))


# -----------------------------------------------------------------------
# GET /mon-compte
# -----------------------------------------------------------------------
@espace.get("/mon-compte")
def formulaire_compte():
    patient = outils.patient_connecte(application())
    valeurs = ValeursPersonne(patient.titre, patient.prenom, patient.nom, patient.user.login,
                              patient.version)
    return afficher(valeurs, {}, None, None)


# -----------------------------------------------------------------------
# POST /mon-compte
# -----------------------------------------------------------------------
@espace.post("/mon-compte")
def modifier_compte():
    patient = outils.patient_connecte(application())
    form = FormulaireMonCompte()
    form.validate()
    valeurs = ValeursPersonne(form.titre.data or patient.titre, form.prenom.data or "",
                              form.nom.data or "", form.login.data or "", form.version.data)

    erreurs = validation.erreurs_de(form)
    if "password" not in erreurs and form.password.data is not None \
            and form.password.data != form.confirmation.data:
        erreurs["confirmation"] = "ERRORS.PASSWORDS_DIFFER"
    if erreurs:
        return afficher(valeurs, erreurs, None, None)

    # le mot de passe actuel
    erreur_mot_de_passe = verifier_mot_de_passe_actuel(patient, form.mot_de_passe_actuel.data)
    if erreur_mot_de_passe is not None:
        # mot de passe faux : le message va aussi SOUS le champ
        erreurs_champ = ({"mot_de_passe_actuel": erreur_mot_de_passe.cle}
                         if erreur_mot_de_passe.cle == "ERRORS.WRONG_CURRENT_PASSWORD" else {})
        return afficher(valeurs, erreurs_champ, erreur_mot_de_passe, None)

    try:
        client = application().modifier_client(patient.id, DonneesPersonne(
            form.titre.data, form.nom.data, form.prenom.data, form.login.data,
            form.password.data,  # None = inchangé
            form.version.data))
    except MetierException as e:
        return afficher(valeurs, {}, Message(e.cle), validation.CHAMPS_ERREURS_METIER.get(e.cle))
    # 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
    flash_succes("MON_COMPTE.UPDATED")
    reponse = redirect(url_for("espace.espace_patient"))
    auth_service().deposer_jeton(reponse, client.user)
    return reponse


# -----------------------------------------------------------------------
# POST /mon-compte/supprimer   champ : mot_de_passe_suppression
# Le compte, le client et TOUS ses rendez-vous disparaissent ensemble,
# puis le patient est déconnecté.
# -----------------------------------------------------------------------
@espace.post("/mon-compte/supprimer")
def supprimer_compte():
    patient = outils.patient_connecte(application())
    erreur_mot_de_passe = verifier_mot_de_passe_actuel(
        patient, request.form.get("mot_de_passe_suppression"))
    if erreur_mot_de_passe is not None:
        flash_erreur(erreur_mot_de_passe.cle, **erreur_mot_de_passe.parametres)
        return redirect(url_for("espace.formulaire_compte"))
    application().supprimer_client(patient.id)
    flash_succes("MON_COMPTE.DELETED")
    reponse = redirect(url_for("auth.afficher_login"))
    AuthService.effacer_jeton(reponse)
    return reponse


# ----------------------------------------------------------------- fonctions internes

# None si le mot de passe actuel est correct ; sinon le message d'erreur
def verifier_mot_de_passe_actuel(patient: Client, mot_de_passe: str | None) -> Message | None:
    ip = adresse_ip()
    login = patient.user.login
    minutes = limiteur().minutes_blocage_login(ip, login)
    if minutes > 0:
        return Message("ERRORS.TOO_MANY_ATTEMPTS", {"minutes": str(minutes)})
    ok = bool(mot_de_passe) and auth_service().valider_utilisateur(login, mot_de_passe) is not None
    if not ok:
        limiteur().echec_login(ip, login)
        return Message("ERRORS.WRONG_CURRENT_PASSWORD")
    return None


def afficher(valeurs: ValeursPersonne, erreurs: dict[str, str], erreur: Message | None,
             autres_champs: list[str] | None):
    modele = MonCompteModele("MON_COMPTE.TITLE", validation.message_page(erreurs, erreur), valeurs,
                             validation.etat(erreurs, autres_champs))
    return render_template("compte.html", modele=modele)
  • lignes 43-45 : toutes les actions du blueprint sont réservées au rôle USER ;
  • lignes 51-63 : l'espace du patient : ses rendez-vous à venir, mis en forme, chacun avec un lien vers l'agenda du médecin ce jour-là. Le patient est toujours celui du jeton ([outils.patient_connecte]), jamais un identifiant envoyé par le navigateur : un patient ne peut ni voir, ni modifier, ni annuler ce qui appartient à un autre ;
  • lignes 69-81 : 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 (sinon, un patient pourrait découvrir quels numéros de rendez-vous existent) ;
  • lignes 87-92 : le formulaire de son compte, pré-rempli ;
  • lignes 98-133 : la modification : validation des champs et de la confirmation, vérification du mot de passe actuel, puis appel de la couche métier. Le login et le nom affiché sont dans le jeton : l'action en dépose un nouveau, sinon la barre de navigation garderait l'ancien nom jusqu'à la prochaine connexion ;
  • lignes 141-153 : la suppression du compte, elle aussi protégée par le mot de passe actuel. Le compte, le client et tous ses rendez-vous disparaissent ensemble ; le patient est déconnecté ;
  • lignes 159-169 : le mot de passe actuel protège le compte contre quelqu'un qui trouverait une session restée ouverte. Ses erreurs sont comptées comme des échecs de connexion, avec la même limite : on ne peut pas s'en servir pour deviner le mot de passe.

11.17. Les vues

Le dossier [templates] contient les vues [Jinja2] de l'application : un gabarit, sept fragments, et huit pages. Toutes suivent la même règle : pas de logique dans les vues. Les actions leur transmettent des modèles déjà préparés ([models/modeles_vues.py]) ; les vues se contentent de placer les données dans le HTML, et de traduire les textes par la fonction [t]. Elles ne contiennent aucun texte écrit en dur : tout passe par les dictionnaires.

11.17.1. Le gabarit et les fragments

11.17.1.1. templates/layout.html

<!doctype html>
{# =========================================================================
   templates/layout.html
   -------------------------------------------------------------------------
   Le gabarit COMMUN à toutes les pages (cf. exemple 15) : chaque page
   (agenda.html, login.html...) l'ÉTEND ({% extends %}) et remplit son bloc
   [contenu].

   Jinja2 ÉCHAPPE tout ce qu'il affiche avec {{ ... }} (les caractères
   < > & " ' deviennent &lt; &gt;...) : 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 (fragment erreurs.html).

   Le gabarit partage les variables de la page : [modele] (le modèle de vue
   transmis par l'action, cf. models/modeles_vues.py), où il lit la clé du
   titre de la page ; [langue], [utilisateur], [chemin], [t], ajoutés à
   toutes les vues par web/modele_commun.py.
   ========================================================================= #}
<html lang="{{ langue }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# t(cle) : la traduction d'une clé ; ici, la clé est une VARIABLE (modele.titre_page) #}
  <title>{{ t(modele.titre_page) }} - {{ t("APP.TITLE") }}</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='lib/bootstrap/css/bootstrap.min.css') }}">
  <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body>
  {# les messages flash déposés par la requête précédente : lus UNE fois
     (get_flashed_messages les oublie aussitôt), puis répartis : les succès
     en haut, les erreurs en bas #}
  {% set flashes = get_flashed_messages(with_categories=true) %}
  <div class="container py-4">
    {% include "fragments/navbar.html" %}
    {% include "fragments/flash.html" %}
    <main>
      {% block contenu %}{% endblock %}
    </main>
    {% include "fragments/erreurs.html" %}
  </div>
  {# la fenêtre "Êtes-vous sûr ?", cachée tant qu'on n'en a pas besoin #}
  {% include "fragments/confirmation.html" %}
  <script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>
  • ligne 22 : la langue de la page, fournie par le processeur de contexte ;
  • ligne 27 : le titre de la page. La clé du titre est une variable ([modele.titre_page]) : chaque modèle de page en fournit une (le procédé de l'exemple 22, avec le modèle de la vue au lieu d'une variable [{% set %}]) ;
  • ligne 28 : [Bootstrap] fait partie de l'application, dans son dossier [static] ;
  • ligne 35 : les messages flash sont lus une fois, par le gabarit : [get_flashed_messages] les oublie aussitôt, et un second appel rendrait une liste vide. Avec [with_categories=true], la fonction rend des couples (catégorie, message). La variable [flashes] est ensuite lue par les deux fragments qui les affichent : les succès en haut, les erreurs en bas. Une variable définie par [{% set %}] est visible des fragments inclus par [{% include %}] ;
  • lignes 37-42 : la barre de navigation, les messages de succès, le contenu de la page, la zone d'erreur ;
  • lignes 45-46 : la fenêtre de confirmation, et le script qui la gère (exemple 21).

11.17.1.2. templates/fragments/navbar.html

{# =========================================================================
   templates/fragments/navbar.html - la barre de navigation.

   Titre, FR/EN, rôle, nom, déconnexion - et un menu dont les entrées
   dépendent du rôle : "Médecins" et "Clients" n'apparaissent que pour
   l'ADMIN. Ce n'est qu'un confort d'affichage : la vraie protection, ce sont
   les contrôles de rôle des actions (un USER qui tape /medecins à la main
   obtient une page 403). Un patient (USER) a en plus son menu "Mon espace".

   Le menu de la page courante est mis en évidence (classe "active") :
   [chemin] est l'URL demandée (cf. web/modele_commun.py). Une macro écrit
   une fois le HTML d'une entrée du menu ; son paramètre [prefixe], facultatif
   (href par défaut), est le début des chemins pour lesquels l'entrée est active.
   ========================================================================= #}
{% macro entree(href, cle, prefixe=none) %}
<li class="nav-item">
  <a class="nav-link{{ ' active' if chemin.startswith(prefixe or href) }}" href="{{ href }}">{{ t(cle) }}</a>
</li>
{% endmacro %}
<nav class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
  <span class="navbar-brand mb-0">{{ t("APP.TITLE") }}</span>

  {% if utilisateur %}
  <ul class="navbar-nav me-auto">
    {% if utilisateur.est_patient %}
    {# active pour /mon-espace ET /mon-compte #}
    {{ entree("/mon-espace", "APP.MENU_MY_SPACE", "/mon-") }}
    {% endif %}
    {{ entree("/agenda", "APP.MENU_AGENDA") }}
    {% if utilisateur.est_admin %}
    {{ entree("/medecins", "APP.MENU_DOCTORS") }}
    {{ entree("/clients", "APP.MENU_PATIENTS") }}
    {% endif %}
  </ul>
  {% else %}
  <div class="me-auto"></div>
  {% endif %}

  <div class="d-flex align-items-center gap-2">
    {# FR / EN : de simples liens (GET /langue/fr), la langue active en surbrillance #}
    <div class="btn-group btn-group-sm" role="group" aria-label="FR / EN">
      {% for code in ["fr", "en"] %}
      <a class="btn {{ 'btn-light' if langue == code else 'btn-outline-light' }}" href="/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="/logout" class="m-0">
      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
      <button type="submit" class="btn btn-sm btn-outline-light">{{ t("APP.LOGOUT") }}</button>
    </form>
    {% endif %}
  </div>
</nav>
  • lignes 15-19 : une entrée du menu, mise en évidence (classe [active]) si le chemin de la page courante commence par son préfixe. Le paramètre [prefixe] est facultatif ([none] en [Jinja2], comme [None] en [Python]) ; à défaut, c'est l'adresse de l'entrée ([prefixe or href]). L'entrée « Mon espace » reçoit le préfixe [/mon-] : elle est active pour [/mon-espace] comme pour [/mon-compte] ;
  • lignes 23-37 : le menu dépend du rôle : « Mon espace » pour un patient, « Médecins » et « Clients » pour l'administrateur. Ce n'est qu'un confort d'affichage : la vraie protection, ce sont les contrôles de rôle des actions. Un patient qui tape [/medecins] à la main obtient une page 403 ;
  • lignes 42-44 : les boutons FR / EN, de simples liens vers [/langue/fr] et [/langue/en] ;
  • lignes 48-54 : le rôle, le nom de l'utilisateur, et la déconnexion : un formulaire POST réduit à son bouton, avec le jeton anti-CSRF.

11.17.1.3. templates/fragments/flash.html

{# =========================================================================
   templates/fragments/flash.html - les messages de SUCCÈS déposés par
   l'action précédente (rendez-vous enregistré, médecin ajouté...),
   affichés une seule fois, en haut de la page (cf. web/flash.py).
   Un message flash d'ERREUR, lui, est affiché en bas de la page, par
   fragments/erreurs.html.

   [flashes] : la liste des couples (catégorie, message), lue par le gabarit.
   t(message.cle, message.parametres) : le message dont la clé est
   message.cle, avec en paramètre le dictionnaire des valeurs de {nom},
   {login}... (cf. web/traducteur.py).
   ========================================================================= #}
{% for categorie, message in flashes if categorie == "success" %}
<div class="alert alert-success" role="status">{{ t(message.cle, message.parametres) }}</div>
{% endfor %}
  • ligne 13 : une boucle [for] de [Jinja2] peut filtrer ses éléments : [for ... in ... if condition] ne parcourt que les éléments qui vérifient la condition. Chaque élément est un couple, dont les deux parties sont rangées dans deux variables ;
  • ligne 14 : le message est un dictionnaire (cf. [web/flash.py]) : sa clé et ses paramètres. La notation pointée de [Jinja2] lit aussi les clés d'un dictionnaire (exemple 13).

11.17.1.4. templates/fragments/erreurs.html

{# =========================================================================
   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).

   Elle regroupe les deux sources d'erreur possibles :
     - modele.erreur : l'erreur de la page affichée (formulaire refusé, login
       ou prénom + nom déjà utilisés, date invalide, mot de passe incorrect...) ;
     - les messages flash d'erreur, déposés par l'action précédente avant une
       redirection (créneau déjà réservé, suppression refusée...).

   Une boucle for peut filtrer ses éléments : "for ... in ... if condition".
   role="alert" : un lecteur d'écran annonce le message dès l'affichage de
   la page, même s'il se trouve en bas.
   ========================================================================= #}
{% set erreurs_flash = flashes | selectattr(0, "equalto", "danger") | list %}
{% if modele.erreur or erreurs_flash %}
<div class="zone-erreur" role="alert">
  {% if modele.erreur %}
  <div>{{ t(modele.erreur.cle, modele.erreur.parametres) }}</div>
  {% endif %}
  {% for categorie, message in erreurs_flash %}
  <div>{{ t(message.cle, message.parametres) }}</div>
  {% endfor %}
</div>
{% endif %}
  • ligne 15 : les messages flash d'erreur, extraits par deux filtres : [selectattr(0, "equalto", "danger")] ne garde que les éléments dont l'attribut [0] (le premier élément du couple, la catégorie) est égal à ["danger"] ; [list] transforme le résultat en liste (les filtres de ce genre rendent un générateur, qu'on ne peut parcourir qu'une fois, et dont on ne peut pas tester s'il est vide) ;
  • lignes 16-25 : la zone d'erreur regroupe les deux sources d'erreur : l'erreur de la page affichée (un formulaire refusé, une date invalide), et les erreurs déposées par l'action précédente avant une redirection (un créneau déjà pris, une suppression refusée). La zone n'existe que s'il y a au moins une erreur.

11.17.1.5. templates/fragments/confirmation.html

{# =========================================================================
   templates/fragments/confirmation.html - la fenêtre modale "Êtes-vous sûr ?".

   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.

   Mêmes classes Bootstrap que la fenêtre de réservation (fragments/reservation.html).
   ========================================================================= #}
<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">{{ t("CONFIRMATION.TITLE") }}</h5>
          <button type="button" class="btn-close" data-action="non" aria-label="{{ t('BOOKING_DIALOG.CLOSE_ARIA') }}"></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 ? », et un bouton "Annuler" y serait ambigu #}
          <button type="button" class="btn btn-secondary" data-action="non">{{ t("CONFIRMATION.NO") }}</button>
          <button type="button" class="btn btn-danger" data-action="oui">{{ t("CONFIRMATION.YES") }}</button>
        </div>
      </div>
    </div>
  </div>
</div>

La fenêtre de confirmation de l'exemple 21, avec des libellés traduits par le serveur (la boîte [window.confirm()] du navigateur affiche ses boutons dans la langue du navigateur, et non dans celle de la page), une croix de fermeture, et des attributs d'accessibilité : [aria-labelledby] et [aria-describedby] désignent le titre et le message de la fenêtre, que les lecteurs d'écran annoncent à son ouverture. Le bouton « Non, revenir » est préféré à « Annuler » : la question est parfois « Annuler ce rendez-vous ? », et un bouton Annuler y serait ambigu.

11.17.1.6. templates/fragments/captcha.html

{# =========================================================================
   templates/fragments/captcha.html - le captcha de la page d'authentification
   (cf. auth/captcha.py). Il protège à la fois la connexion et l'accès à la
   création de compte (bouton "Créer un compte client").
   Une macro, dont le paramètre est un objet Captcha (id, svg).

   Le filtre 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é captchaId 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 ([chemin] = l'URL courante).
   Une macro ne voit pas les variables de la page qui l'appelle ("with
   context" : si, lors de l'importation, cf. login.html) : ici, t et chemin.
   ========================================================================= #}
{% macro captcha(captcha) %}
<div class="mb-3">
  <label class="form-label" for="captcha">{{ t("CAPTCHA.LABEL") }}</label>
  <div class="d-flex align-items-center gap-3 mb-2">
    <div class="captcha-image" role="img" aria-label="{{ t('CAPTCHA.IMAGE_ALT') }}">{{ captcha.svg | safe }}</div>
    <a class="small" href="{{ chemin }}">{{ t("CAPTCHA.NEW_IMAGE") }}</a>
  </div>
  <input type="hidden" name="captchaId" 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">{{ t("CAPTCHA.HELP") }}</div>
</div>
{% endmacro %}
  • ligne 18 : une macro, qui reçoit un objet [Captcha] ;
  • ligne 22 : l'image SVG, insérée telle quelle (exemple 27). L'attribut [role="img"] et son [aria-label] décrivent l'image aux lecteurs d'écran ;
  • ligne 23 : le lien « Autre image » recharge simplement la page : chaque affichage fabrique un nouveau captcha ;
  • lignes 25-27 : l'identifiant du captcha, et la zone de saisie. [autocapitalize="off"] et [spellcheck="false"] empêchent les téléphones de modifier la saisie (majuscule initiale, correction orthographique).

Une macro importée ne voit pas, par défaut, les variables de la page qui l'importe : la fonction [t] et la variable [chemin], fournies par le processeur de contexte, lui seraient inconnues. C'est pourquoi [login.html] l'importe avec la mention [with context].

11.17.1.7. templates/fragments/champs_personne.html

{# =========================================================================
   templates/fragments/champs_personne.html - 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".
   Le fragment, inséré par {% include %}, lit la variable [modele] de la
   page qui l'insère (un FormulaireVue, cf. models/modeles_vues.py) :
   valeurs, message sous chaque champ, champs encadrés en rouge.

   selected : un attribut booléen, écrit si l'option est choisie, omis sinon.
   etat.classe_invalide(champ) : "is-invalid" (encadré rouge de Bootstrap)
   pour un champ refusé, "" sinon.
   ========================================================================= #}
{% set etat = modele.etat %}
<div class="mb-3">
  <label class="form-label" for="titre">{{ t("PERSONNES.TITLE_LABEL") }}</label>
  <select id="titre" name="titre" class="form-select {{ etat.classe_invalide('titre') }}">
    {% for titre in modele.titres %}
    <option value="{{ titre.valeur }}"{{ " selected" if titre.selectionne }}>{{ titre.valeur }}</option>
    {% endfor %}
  </select>
  {% if etat.erreur_de("titre") %}
  <div class="invalid-feedback">{{ t(etat.erreur_de("titre")) }}</div>
  {% endif %}
</div>

<div class="row g-3 mb-3">
  <div class="col-sm-6">
    <label class="form-label" for="prenom">{{ t("PERSONNES.FIRSTNAME_LABEL") }}</label>
    <input id="prenom" name="prenom" type="text" maxlength="20" required
           class="form-control {{ etat.classe_invalide('prenom') }}" value="{{ modele.valeurs.prenom }}">
    {% if etat.erreur_de("prenom") %}
    <div class="invalid-feedback">{{ t(etat.erreur_de("prenom")) }}</div>
    {% endif %}
  </div>
  <div class="col-sm-6">
    <label class="form-label" for="nom">{{ t("PERSONNES.LASTNAME_LABEL") }}</label>
    <input id="nom" name="nom" type="text" maxlength="20" required
           class="form-control {{ etat.classe_invalide('nom') }}" value="{{ modele.valeurs.nom }}">
    {% if etat.erreur_de("nom") %}
    <div class="invalid-feedback">{{ t(etat.erreur_de("nom")) }}</div>
    {% endif %}
  </div>
</div>

Les champs d'identité (titre, prénom, nom), communs aux trois formulaires de l'application. Le fragment, inséré par [{% include %}], lit la variable [modele] de la page qui l'insère : un modèle qui hérite de [FormulaireVue].

  • lignes 16-20 : la liste des titres, calculée par la propriété [titres] du modèle, avec le titre choisi sélectionné ;
  • lignes 29-30 : la zone de saisie du prénom, avec la valeur à réafficher, et la classe [is-invalid] si le champ est refusé ([etat.classe_invalide('prenom')]) ;
  • lignes 31-33 : le message sous le champ, traduit.

Les attributs [maxlength] et [required] sont des contrôles du navigateur. Les formulaires de l'application portent l'attribut [novalidate] (exemple 19) : ces contrôles ne sont pas faits avant l'envoi, et c'est le serveur qui valide. [maxlength], lui, empêche toujours de taper plus de caractères qu'une colonne n'en accepte.

11.17.1.8. templates/fragments/reservation.html

{# =========================================================================
   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 la variable [modele] de la page qui l'insère
   (un AgendaModele).
   ========================================================================= #}
{% set reservation = modele.reservation %}
<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="/agenda/reserver" class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title" id="titre-reservation">{{ t("BOOKING_DIALOG.TITLE") }}</h5>
        <a class="btn-close" href="{{ modele.url_retour }}" aria-label="{{ t('BOOKING_DIALOG.CLOSE_ARIA') }}"></a>
      </div>

      <div class="modal-body">
        <p>{{ t("BOOKING_DIALOG.SLOT", horaire=reservation.horaire) }}</p>

        {# ce que le serveur doit savoir en plus du patient choisi #}
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <input type="hidden" name="idMedecin" value="{{ modele.id_medecin }}">
        <input type="hidden" name="jour" value="{{ modele.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>{{ t("BOOKING_DIALOG.PATIENT_LABEL") }}</span> :
          <strong>{{ reservation.patient }}</strong>
        </p>
        {% else %}
        <label class="form-label" for="idClient">{{ t("BOOKING_DIALOG.PATIENT_LABEL") }}</label>
        <select id="idClient" name="idClient" class="form-select" required autofocus>
          <option value="">{{ t("BOOKING_DIALOG.CHOOSE_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="{{ modele.url_retour }}">{{ t("BOOKING_DIALOG.CANCEL") }}</a>
        <button type="submit" class="btn btn-primary">{{ t("BOOKING_DIALOG.CONFIRM") }}</button>
      </div>
    </form>
  </div>
</div>

La fenêtre de réservation. Elle utilise les vraies classes d'une fenêtre modale de [Bootstrap] ([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 (le paramètre [reserver] de l'URL), et la fermer revient à suivre un lien vers la même page, sans ce paramètre ([modele.url_retour]).

  • lignes 29-31 : les champs cachés : ce que le serveur doit savoir, en plus du patient choisi ;
  • lignes 35-48 : un patient réserve pour lui-même : son nom est affiché, sans liste à choisir (et le serveur ignorerait de toute façon un champ [idClient]). Pour un gestionnaire, la liste des patients, dont la première option, de valeur vide, invite à choisir.

11.17.2. Les pages

11.17.2.1. templates/login.html

{# =========================================================================
   templates/login.html - le formulaire de connexion (cf. controllers/auth.py).
   Un <form> HTML ordinaire : 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.
   ========================================================================= #}
{% extends "layout.html" %}
{# with context : la macro voit les variables de cette page (t, chemin) #}
{% from "fragments/captcha.html" import captcha with context %}

{% block contenu %}
{% set login_saisi = modele.login != "" %}
<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">{{ t("LOGIN.TITLE") }}</h5>

      <form method="post" action="/login">
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <div class="mb-3">
          <label class="form-label" for="login">{{ t("LOGIN.LOGIN_LABEL") }}</label>
          {# autofocus : un attribut booléen, écrit si la condition est vraie, omis sinon #}
          <input id="login" name="login" type="text" class="form-control" autocomplete="username"
                 value="{{ modele.login }}" required{{ " autofocus" if not login_saisi }}>
        </div>
        <div class="mb-3">
          <label class="form-label" for="password">{{ t("LOGIN.PASSWORD_LABEL") }}</label>
          <input id="password" name="password" type="password" class="form-control"
                 autocomplete="current-password" required{{ " autofocus" if login_saisi }}>
        </div>

        {{ captcha(modele.captcha) }}

        <button type="submit" class="btn btn-primary w-100">{{ t("LOGIN.SUBMIT") }}</button>

        {# 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. controllers/inscription.py).
           formnovalidate : le navigateur n'exige pas login et mot de passe
           (inutiles pour créer un compte) ; c'est le serveur qui vérifie
           le captcha. Entrée dans un champ déclenche toujours le PREMIER
           bouton du formulaire, "Se connecter". #}
        <button type="submit" class="btn btn-outline-primary w-100 mt-3"
                formaction="/inscription/acces" formnovalidate>{{ t("LOGIN.CREATE_ACCOUNT") }}</button>
        <div class="form-text text-center">{{ t("LOGIN.CREATE_ACCOUNT_HELP") }}</div>
      </form>

      <div class="text-muted small mt-3">
        <span>{{ t("LOGIN.DEMO_ACCOUNTS") }}</span>
        <ul class="mb-0 mt-1">
          <li><code>admin</code> - <span>{{ t("LOGIN.DEMO_ADMIN") }}</span></li>
          <li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - <span>{{ t("LOGIN.DEMO_DOCTOR") }}</span></li>
          <li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - <span>{{ t("LOGIN.DEMO_USER") }}</span></li>
        </ul>
      </div>
    </div>
  </div>
</div>
{% endblock %}
  • ligne 10 : l'importation de la macro, avec le contexte de la page ;
  • ligne 13 : le curseur est placé dans le champ login s'il est vide, dans le champ mot de passe sinon (après un échec, ou après la création d'un compte) : l'attribut booléen [autofocus] est écrit sur l'un ou l'autre ;
  • lignes 24-30 : les valeurs de l'attribut [autocomplete] aident les gestionnaires de mots de passe des navigateurs à remplir le formulaire ;
  • lignes 46-47 : le bouton « Créer un compte client » n'est pas un lien, mais un second bouton d'envoi de ce formulaire, qui part vers une autre adresse ([formaction], exemple 20). Le serveur y vérifie le captcha avant d'ouvrir la page de création de compte. [formnovalidate] : le navigateur n'exige pas le login et le mot de passe, marqués [required], et inutiles pour créer un compte. La touche Entrée, dans un champ, déclenche toujours le premier bouton du formulaire, « Se connecter ».

11.17.2.2. templates/agenda.html

{# =========================================================================
   templates/agenda.html - la page principale (cf. controllers/agenda.py).
   Trois blocs :
     1. le choix du médecin et du jour ;
     2. l'agenda ;
     3. la fenêtre de réservation (fragments/reservation.html).
   Une éventuelle erreur (date invalide, médecin inconnu) est affichée en
   bas de la page par le gabarit (cf. fragments/erreurs.html).
   ========================================================================= #}
{% extends "layout.html" %}

{% block contenu %}
  {# 1. choix du médecin et du jour : un formulaire GET, qui recharge
        simplement cette page avec ?idMedecin=...&jour=... dans l'URL #}
  <form method="get" action="/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">{{ t("DOCTOR_DAY_PICKER.DOCTOR_LABEL") }}</label>
        <select id="idMedecin" name="idMedecin" class="form-select" required>
          <option value="">{{ t("DOCTOR_DAY_PICKER.CHOOSE_DOCTOR") }}</option>
          {% for medecin in modele.medecins %}
          <option value="{{ medecin.id }}"{{ " selected" if medecin.selectionne }}>{{ medecin.libelle }}</option>
          {% endfor %}
        </select>
      </div>
      <div class="col-sm-4">
        <label class="form-label" for="jour">{{ t("DOCTOR_DAY_PICKER.DAY_LABEL") }}</label>
        <input id="jour" name="jour" type="date" class="form-control" value="{{ modele.jour }}" required>
      </div>
      <div class="col-sm-3">
        <button type="submit" class="btn btn-primary w-100">{{ t("DOCTOR_DAY_PICKER.VIEW_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 modele.info %}
  <div class="alert alert-info">
    <span>{{ t(modele.info) }}</span>
    <a href="/mon-espace" class="alert-link">{{ t("APP.MENU_MY_SPACE") }}</a>
  </div>
  {% endif %}

  {# 2. l'agenda #}
  {% set agenda = modele.agenda %}
  {% if agenda %}
  <div class="card p-3">
    <h5>{{ t("AGENDA.TITLE_PREFIX") }} {{ agenda.jour }} - {{ agenda.medecin }}</h5>

    {% if agenda.lignes %}
    <table class="table table-hover align-middle">
      <thead>
        <tr>
          <th>{{ t("AGENDA.COLUMN_SLOT") }}</th>
          <th>{{ t("AGENDA.COLUMN_STATUS") }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        {% for ligne in agenda.lignes %}
        <tr class="{{ 'creneau-libre' if ligne.reservable }}">
          <td>{{ ligne.horaire }}</td>
          <td>
            {# [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é » #}
            {% if ligne.libre %}
            <span class="badge text-bg-success">{{ t("AGENDA.FREE") }}</span>
            {% elif ligne.patient %}
            <span class="badge {{ 'text-bg-primary' if ligne.moi else 'text-bg-secondary' }}">{{ ligne.patient }}</span>
            {% else %}
            <span class="badge text-bg-secondary">{{ t("AGENDA.BOOKED_BY_OTHER") }}</span>
            {% endif %}
          </td>
          <td>
            {# les boutons affichés ont été décidés par le contrôleur
               (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 #}
            {% if ligne.reservable %}
            <a class="btn btn-sm btn-outline-success" href="{{ ligne.url_reserver }}">{{ t("AGENDA.BOOK") }}</a>
            {% endif %}
            {# "Annuler" MODIFIE des données : c'est donc un POST #}
            {% if ligne.id_rv %}
            <form method="post" action="/agenda/annuler" class="m-0"
                  data-confirmation="{{ t('AGENDA.CANCEL_CONFIRM') }}">
              <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
              <input type="hidden" name="idRv" value="{{ ligne.id_rv }}">
              <input type="hidden" name="idMedecin" value="{{ modele.id_medecin }}">
              <input type="hidden" name="jour" value="{{ modele.jour }}">
              <button type="submit" class="btn btn-sm btn-outline-danger">{{ t("AGENDA.CANCEL_APPOINTMENT") }}</button>
            </form>
            {% endif %}
          </td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
    {% else %}
    <p class="text-muted mb-0">{{ t("AGENDA.NO_SLOTS") }}</p>
    {% endif %}
  </div>
  {% else %}
  <p class="text-muted">{{ t("AGENDA.EMPTY_STATE") }}</p>
  {% endif %}

  {# 3. la fenêtre de réservation, seulement si l'URL contient &reserver=... #}
  {% if modele.reservation %}
  {% include "fragments/reservation.html" %}
  {% endif %}
{% endblock %}
  • lignes 15-34 : le choix du médecin et du jour : un formulaire GET (exemple 17), qui recharge simplement la page avec les paramètres [idMedecin] et [jour] dans l'URL. Le champ [<input type="date">] échange ses valeurs au format [aaaa-mm-jj], quelle que soit la langue dans laquelle le navigateur l'affiche ;
  • lignes 37-42 : le message d'information d'un patient qui a déjà un rendez-vous à venir ;
  • lignes 61-95 : une ligne de l'agenda. Trois cas pour le statut : libre, occupé avec le nom du patient (en bleu si c'est le rendez-vous du patient connecté), occupé sans le nom (« Réservé »). Les boutons affichés ont été décidés par l'action. « Réserver » est un simple lien : il rouvre cette page avec la fenêtre de réservation de ce créneau. « Annuler » modifie des données : c'est un formulaire POST, avec sa demande de confirmation (exemple 21) ;
  • lignes 108-110 : la fenêtre de réservation, seulement si l'action l'a préparée.

11.17.2.3. templates/personnes/liste.html

{# =========================================================================
   templates/personnes/liste.html - la liste des médecins OU des clients (ADMIN).
   Une seule vue pour les deux : les libellés propres à chacun arrivent dans
   modele.cles (cf. controllers/medecins.py / controllers/clients.py).
   ========================================================================= #}
{% extends "layout.html" %}

{% block contenu %}
  <div class="d-flex justify-content-between align-items-center mb-3">
    <h4 class="mb-0">{{ t(modele.cles.titre) }}</h4>
    <a class="btn btn-primary" href="{{ modele.url_ajouter }}">{{ t(modele.cles.ajouter) }}</a>
  </div>

  <div class="card p-3">
    {% if modele.personnes %}
    <table class="table table-hover align-middle mb-0">
      <thead>
        <tr>
          <th>{{ t("PERSONNES.COLUMN_TITLE") }}</th>
          <th>{{ t("PERSONNES.COLUMN_FIRSTNAME") }}</th>
          <th>{{ t("PERSONNES.COLUMN_LASTNAME") }}</th>
          <th>{{ t("PERSONNES.COLUMN_LOGIN") }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        {% for personne in modele.personnes %}
        <tr>
          <td>{{ personne.titre }}</td>
          <td>{{ personne.prenom }}</td>
          <td>{{ personne.nom }}</td>
          <td><code>{{ personne.login }}</code></td>
          <td class="text-end">
            <div class="d-inline-flex gap-2">
              <a class="btn btn-sm btn-outline-primary" href="{{ personne.url_modifier }}">{{ t("PERSONNES.EDIT") }}</a>
              {# data-confirmation : une fenêtre de confirmation s'ouvrira
                 avant l'envoi du formulaire (cf. static/js/app.js) #}
              <form method="post" action="{{ personne.url_supprimer }}" class="m-0"
                    data-confirmation="{{ t(modele.cles.confirmer_suppression) }}">
                <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
                <button type="submit" class="btn btn-sm btn-outline-danger">{{ t("PERSONNES.DELETE") }}</button>
              </form>
            </div>
          </td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
    {% else %}
    <p class="text-muted mb-0">{{ t(modele.cles.vide) }}</p>
    {% endif %}
  </div>
{% endblock %}

Une seule vue pour les deux listes, médecins et clients : les libellés propres à chacune arrivent dans [modele.cles] ; [t(modele.cles.titre)] traduit une clé qui est elle-même une variable. Chaque ligne a un lien « Modifier » et un formulaire « Supprimer », confirmé.

11.17.2.4. templates/personnes/formulaire.html

{# =========================================================================
   templates/personnes/formulaire.html - création OU modification d'un
   médecin ou d'un client (ADMIN). modele.creation distingue les deux cas.

   Quand la saisie est refusée (champ vide, login déjà pris...), l'action
   renvoie CETTE vue avec :
     - les valeurs déjà saisies (modele.valeurs) ;
     - 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, affiché EN BAS de la page par le
       gabarit (cf. fragments/erreurs.html).
   ========================================================================= #}
{% extends "layout.html" %}

{% block contenu %}
{% set etat = modele.etat %}
<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">{{ t(modele.titre_page) }}</h5>

      {# novalidate : on laisse le SERVEUR valider (c'est lui qui fait foi) ;
         les contrôles du navigateur (required...) ne sont qu'un confort,
         qu'un utilisateur peut toujours contourner #}
      <form method="post" action="{{ modele.action }}" novalidate>
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        {% if not modele.creation %}
        <input type="hidden" name="version" value="{{ modele.valeurs.version }}">
        {% endif %}

        {# titre, prénom, nom : fragment commun à trois formulaires #}
        {% include "fragments/champs_personne.html" %}

        <h6 class="mt-4 text-muted">{{ t("PERSONNES.ACCOUNT_SECTION") }}</h6>

        <div class="mb-3">
          <label class="form-label" for="login">{{ t("PERSONNES.LOGIN_LABEL") }}</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="off"
                 class="form-control {{ etat.classe_invalide('login') }}" value="{{ modele.valeurs.login }}">
          {% if etat.erreur_de("login") %}
          <div class="invalid-feedback">{{ t(etat.erreur_de("login")) }}</div>
          {% else %}
          <div class="form-text">{{ t("PERSONNES.LOGIN_HELP_CREATE" if modele.creation else "PERSONNES.LOGIN_HELP_EDIT") }}</div>
          {% endif %}
        </div>

        <div class="mb-4">
          <label class="form-label" for="password">{{ t("PERSONNES.PASSWORD_LABEL") }}</label>
          {# jamais pré-rempli : le mot de passe ne repart jamais vers le navigateur #}
          <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password"
                 class="form-control {{ etat.classe_invalide('password') }}">
          {% if etat.erreur_de("password") %}
          <div class="invalid-feedback">{{ t(etat.erreur_de("password")) }}</div>
          {% else %}
          <div class="form-text">{{ t("PERSONNES.PASSWORD_HELP_CREATE" if modele.creation else "PERSONNES.PASSWORD_HELP_EDIT") }}</div>
          {% endif %}
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">{{ t("PERSONNES.SAVE") }}</button>
          <a class="btn btn-secondary" href="{{ modele.url_liste }}">{{ t("PERSONNES.BACK") }}</a>
        </div>
      </form>
    </div>
  </div>
</div>
{% endblock %}
  • ligne 25 : l'adresse d'envoi est fournie par l'action : [/medecins/ajouter], [/clients/3/modifier]... ;
  • lignes 27-29 : en modification, la version de la fiche, dans un champ caché (verrou optimiste) ;
  • ligne 32 : les champs d'identité ;
  • lignes 38-44 : le login, facultatif, avec un texte d'aide qui dépend de la création ou de la modification ([t("..." if modele.creation else "...")]) ;
  • lignes 50-50 : le mot de passe n'est jamais pré-rempli : il ne repart jamais vers le navigateur. [autocomplete="new-password"] empêche le navigateur d'y proposer le mot de passe de l'administrateur.

11.17.2.5. templates/inscription.html

{# =========================================================================
   templates/inscription.html - "Créer un compte client" (page publique,
   cf. controllers/inscription.py). Mêmes champs d'identité que le formulaire
   de l'ADMIN (fragment champs_personne.html), 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").

   t(cle, min=...) : le message dont la clé est la valeur de [cle], avec le
   paramètre {min} (cf. web/traducteur.py).
   ========================================================================= #}
{% extends "layout.html" %}

{% block contenu %}
{% set etat = modele.etat %}
<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">{{ t("INSCRIPTION.TITLE") }}</h5>
      <p class="text-muted small mb-3">{{ t("INSCRIPTION.INTRO") }}</p>

      <form method="post" action="/inscription" novalidate>
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        {% include "fragments/champs_personne.html" %}

        <h6 class="mt-4 text-muted">{{ t("PERSONNES.ACCOUNT_SECTION") }}</h6>

        <div class="mb-3">
          <label class="form-label" for="login">{{ t("PERSONNES.LOGIN_LABEL") }}</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="username" required
                 class="form-control {{ etat.classe_invalide('login') }}" value="{{ modele.valeurs.login }}">
          {% if etat.erreur_de("login") %}
          <div class="invalid-feedback">{{ t(etat.erreur_de("login")) }}</div>
          {% else %}
          <div class="form-text">{{ t("INSCRIPTION.LOGIN_HELP") }}</div>
          {% endif %}
        </div>

        <div class="row g-3 mb-3">
          <div class="col-sm-6">
            <label class="form-label" for="password">{{ t("PERSONNES.PASSWORD_LABEL") }}</label>
            <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password" required
                   class="form-control {{ etat.classe_invalide('password') }}">
            {% if etat.erreur_de("password") %}
            <div class="invalid-feedback">{{ t(etat.erreur_de("password"), min=modele.mot_de_passe_min) }}</div>
            {% else %}
            <div class="form-text">{{ t("INSCRIPTION.PASSWORD_HELP", min=modele.mot_de_passe_min) }}</div>
            {% endif %}
          </div>
          <div class="col-sm-6">
            <label class="form-label" for="confirmation">{{ t("COMPTE.CONFIRMATION_LABEL") }}</label>
            <input id="confirmation" name="confirmation" type="password" maxlength="72" autocomplete="new-password" required
                   class="form-control {{ etat.classe_invalide('confirmation') }}">
            {% if etat.erreur_de("confirmation") %}
            <div class="invalid-feedback">{{ t(etat.erreur_de("confirmation")) }}</div>
            {% endif %}
          </div>
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">{{ t("INSCRIPTION.SUBMIT") }}</button>
          <a class="btn btn-secondary" href="/login">{{ t("INSCRIPTION.BACK_TO_LOGIN") }}</a>
        </div>
      </form>
    </div>
  </div>
</div>
{% endblock %}

Le formulaire de création d'un compte client : les champs d'identité, puis le login, le mot de passe et sa confirmation, obligatoires. Le message d'un mot de passe trop court a un paramètre, la longueur minimale : [t(etat.erreur_de("password"), min=modele.mot_de_passe_min)]. Pas de captcha ici : on n'arrive sur cette page qu'après avoir recopié celui de la page de connexion.

11.17.2.6. templates/espace.html

{# =========================================================================
   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.
   ========================================================================= #}
{% extends "layout.html" %}

{% block contenu %}
  <h4 class="mb-3">{{ t("ESPACE.HELLO", nom=modele.patient) }}</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="/mon-compte">
        <h5 class="mb-1">{{ t("ESPACE.MANAGE_ACCOUNT") }}</h5>
        <span class="text-muted">{{ t("ESPACE.MANAGE_ACCOUNT_HELP") }}</span>
      </a>
    </div>
    <div class="col-sm-6">
      <a class="card p-4 h-100 text-decoration-none carte-option" href="/agenda">
        <h5 class="mb-1">{{ t("ESPACE.VIEW_AGENDA") }}</h5>
        <span class="text-muted">{{ t("ESPACE.VIEW_AGENDA_HELP") }}</span>
      </a>
    </div>
  </div>

  <div class="card p-3">
    <h5>{{ t("ESPACE.MY_APPOINTMENT") }}</h5>
    {# normalement un seul (règle "un rendez-vous à venir à la fois"),
       mais la page sait en afficher plusieurs. loop.last : dernier élément ?
       Le bloc else d'une boucle for : la liste est vide #}
    {% for rv in modele.rendez_vous %}
    <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2{{ '' if loop.last else ' border-bottom' }}">
      <div>
        <div class="fw-semibold">{{ rv.jour }}, {{ rv.horaire }}</div>
        <div class="text-muted">
          <span>{{ rv.medecin }}</span> -
          <a href="{{ rv.url_agenda }}">{{ t("ESPACE.SEE_IN_AGENDA") }}</a>
        </div>
      </div>
      <form method="post" action="/mon-espace/annuler" class="m-0"
            data-confirmation="{{ t('AGENDA.CANCEL_CONFIRM') }}">
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <input type="hidden" name="idRv" value="{{ rv.id }}">
        <button type="submit" class="btn btn-outline-danger">{{ t("ESPACE.CANCEL_APPOINTMENT") }}</button>
      </form>
    </div>
    {% else %}
    <p class="text-muted mb-0">{{ t("ESPACE.NO_APPOINTMENT") }}</p>
    {% endfor %}
  </div>
{% endblock %}

L'espace du patient : deux grandes cartes (« Gérer mon compte », « Voir un agenda ») et ses rendez-vous à venir. La boucle [for ... else] (exemple 14) affiche un message si la liste est vide ; [loop.last] évite la bordure sous le dernier rendez-vous. La page sait en afficher plusieurs, même si la règle de gestion n'en permet qu'un.

11.17.2.7. templates/compte.html

{# =========================================================================
   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) ;
     2. la suppression du compte - qui annule aussi ses rendez-vous.
   L'un comme l'autre exigent le mot de passe ACTUEL.
   ========================================================================= #}
{% extends "layout.html" %}

{% block contenu %}
{% set etat = modele.etat %}
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">

    {# 1. modification #}
    <div class="card p-4 mb-4">
      <h5 class="card-title mb-3">{{ t("MON_COMPTE.TITLE") }}</h5>

      <form method="post" action="/mon-compte" novalidate>
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <input type="hidden" name="version" value="{{ modele.valeurs.version }}">

        {% include "fragments/champs_personne.html" %}

        <h6 class="mt-4 text-muted">{{ t("PERSONNES.ACCOUNT_SECTION") }}</h6>

        <div class="mb-3">
          <label class="form-label" for="login">{{ t("PERSONNES.LOGIN_LABEL") }}</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="username" required
                 class="form-control {{ etat.classe_invalide('login') }}" value="{{ modele.valeurs.login }}">
          {% if etat.erreur_de("login") %}
          <div class="invalid-feedback">{{ t(etat.erreur_de("login")) }}</div>
          {% endif %}
        </div>

        <div class="row g-3 mb-3">
          <div class="col-sm-6">
            <label class="form-label" for="password">{{ t("MON_COMPTE.NEW_PASSWORD_LABEL") }}</label>
            <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password"
                   class="form-control {{ etat.classe_invalide('password') }}">
            {% if etat.erreur_de("password") %}
            <div class="invalid-feedback">{{ t(etat.erreur_de("password"), min=modele.mot_de_passe_min) }}</div>
            {% else %}
            <div class="form-text">{{ t("PERSONNES.PASSWORD_HELP_EDIT") }}</div>
            {% endif %}
          </div>
          <div class="col-sm-6">
            <label class="form-label" for="confirmation">{{ t("COMPTE.CONFIRMATION_LABEL") }}</label>
            <input id="confirmation" name="confirmation" type="password" maxlength="72" autocomplete="new-password"
                   class="form-control {{ etat.classe_invalide('confirmation') }}">
            {% if etat.erreur_de("confirmation") %}
            <div class="invalid-feedback">{{ t(etat.erreur_de("confirmation")) }}</div>
            {% endif %}
          </div>
        </div>

        <div class="mb-4">
          <label class="form-label" for="mot_de_passe_actuel">{{ t("MON_COMPTE.CURRENT_PASSWORD_LABEL") }}</label>
          <input id="mot_de_passe_actuel" name="mot_de_passe_actuel" type="password" maxlength="72"
                 autocomplete="current-password" required
                 class="form-control {{ etat.classe_invalide('mot_de_passe_actuel') }}">
          {% if etat.erreur_de("mot_de_passe_actuel") %}
          <div class="invalid-feedback">{{ t(etat.erreur_de("mot_de_passe_actuel")) }}</div>
          {% else %}
          <div class="form-text">{{ t("MON_COMPTE.CURRENT_PASSWORD_HELP") }}</div>
          {% endif %}
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">{{ t("PERSONNES.SAVE") }}</button>
          <a class="btn btn-secondary" href="/mon-espace">{{ t("MON_COMPTE.BACK") }}</a>
        </div>
      </form>
    </div>

    {# 2. suppression #}
    <div class="card p-4 border-danger">
      <h5 class="card-title text-danger mb-1">{{ t("MON_COMPTE.DELETE_TITLE") }}</h5>
      <p class="text-muted small">{{ t("MON_COMPTE.DELETE_HELP") }}</p>
      <form method="post" action="/mon-compte/supprimer" class="d-flex flex-wrap gap-2 align-items-end"
            data-confirmation="{{ t('MON_COMPTE.DELETE_CONFIRM') }}">
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <div class="flex-grow-1">
          <label class="form-label" for="mot_de_passe_suppression">{{ t("MON_COMPTE.CURRENT_PASSWORD_LABEL") }}</label>
          <input id="mot_de_passe_suppression" name="mot_de_passe_suppression" type="password" maxlength="72"
                 autocomplete="current-password" required class="form-control">
        </div>
        <button type="submit" class="btn btn-danger">{{ t("MON_COMPTE.DELETE_SUBMIT") }}</button>
      </form>
    </div>

  </div>
</div>
{% endblock %}

Deux formulaires indépendants sur une même page : la modification du compte (lignes 19-73), et sa suppression (lignes 80-89), confirmée par la fenêtre de confirmation. L'un et l'autre exigent le mot de passe actuel, dans deux champs distincts (un formulaire n'envoie que ses propres champs).

11.17.2.8. templates/erreur.html

{# =========================================================================
   templates/erreur.html - la page d'erreur (400, 403, 404, 500...),
   fabriquée par web/erreurs.py.
   ========================================================================= #}
{% extends "layout.html" %}

{% 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">{{ modele.code }}</h1>
      <p class="lead">{{ t(modele.message) }}</p>
      <div><a class="btn btn-primary" href="/">{{ t("ERROR_PAGE.BACK") }}</a></div>
    </div>
  </div>
</div>
{% endblock %}

La page d'erreur : le code, en grand, et le message traduit. Elle étend le gabarit : même en cas d'erreur, l'utilisateur garde sa barre de navigation, et peut changer de langue.

11.18. Les dictionnaires

Les textes de l'interface sont dans deux dictionnaires JSON, [i18n/fr.json] et [i18n/en.json], lus par le traducteur au démarrage. Ce sont les mêmes fichiers que ceux des versions [NestJS], [ASP.NET Core MVC] et [Spring MVC] de l'application.

11.18.1. i18n/fr.json

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Se déconnecter",
    "MENU_AGENDA": "Agenda",
    "MENU_DOCTORS": "Médecins",
    "MENU_PATIENTS": "Clients",
    "MENU_MY_SPACE": "Mon espace"
  },
  "LOGIN": {
    "TITLE": "Connexion",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Mot de passe",
    "SUBMIT": "Se connecter",
    "ERROR": "Login ou mot de passe incorrect.",
    "DEMO_ACCOUNTS": "Comptes de démonstration (mot de passe = login) :",
    "DEMO_ADMIN": "rôle ADMIN : rendez-vous + gestion des médecins et des clients",
    "DEMO_DOCTOR": "rôle DOCTOR : prise et annulation de rendez-vous",
    "DEMO_USER": "rôle USER (patients) : réservation pour eux-mêmes, gestion de leur compte",
    "CREATE_ACCOUNT": "Créer un compte client",
    "CREATE_ACCOUNT_HELP": "Pour créer un compte, recopiez d'abord les caractères de l'image."
  },
  "DOCTOR_DAY_PICKER": {
    "DOCTOR_LABEL": "Médecin",
    "CHOOSE_DOCTOR": "-- choisir un médecin --",
    "DAY_LABEL": "Jour",
    "VIEW_AGENDA": "Voir l'agenda"
  },
  "AGENDA": {
    "PAGE_TITLE": "Agenda",
    "TITLE_PREFIX": "Agenda du",
    "COLUMN_SLOT": "Créneau",
    "COLUMN_STATUS": "Statut",
    "FREE": "Libre",
    "BOOK": "Réserver",
    "CANCEL_APPOINTMENT": "Annuler",
    "CANCEL_CONFIRM": "Annuler ce rendez-vous ?",
    "EMPTY_STATE": "Choisissez un médecin et un jour, puis cliquez sur « Voir l'agenda ».",
    "NO_SLOTS": "Ce médecin n'a aucun créneau horaire.",
    "BOOKED": "Rendez-vous enregistré.",
    "CANCELLED": "Rendez-vous annulé.",
    "BOOKED_BY_OTHER": "Réservé",
    "ALREADY_BOOKED_INFO": "Vous avez déjà un rendez-vous à venir : annulez-le pour en prendre un autre."
  },
  "BOOKING_DIALOG": {
    "TITLE": "Réserver ce créneau",
    "CLOSE_ARIA": "Fermer",
    "SLOT": "Créneau de {horaire}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choisir un patient --",
    "CANCEL": "Annuler",
    "CONFIRM": "Confirmer"
  },
  "PERSONNES": {
    "COLUMN_TITLE": "Titre",
    "COLUMN_FIRSTNAME": "Prénom",
    "COLUMN_LASTNAME": "Nom",
    "COLUMN_LOGIN": "Login",
    "EDIT": "Modifier",
    "DELETE": "Supprimer",
    "SAVE": "Enregistrer",
    "BACK": "Retour à la liste",
    "TITLE_LABEL": "Titre",
    "FIRSTNAME_LABEL": "Prénom",
    "LASTNAME_LABEL": "Nom",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Mot de passe",
    "LOGIN_HELP_CREATE": "Facultatif : par défaut, le nom en minuscules.",
    "LOGIN_HELP_EDIT": "Laisser vide pour ne pas le changer.",
    "PASSWORD_HELP_CREATE": "Facultatif : par défaut, identique au login.",
    "PASSWORD_HELP_EDIT": "Laisser vide pour ne pas le changer.",
    "ACCOUNT_SECTION": "Compte utilisateur"
  },
  "MEDECINS": {
    "TITLE": "Médecins",
    "ADD": "Ajouter un médecin",
    "EDIT": "Modifier un médecin",
    "EMPTY": "Aucun médecin.",
    "DELETE_CONFIRM": "Supprimer ce médecin, ses créneaux horaires et son compte utilisateur ?",
    "ADDED": "Le médecin {nom} a été ajouté (login : {login}).",
    "UPDATED": "Le médecin {nom} a été modifié.",
    "DELETED": "Le médecin {nom} a été supprimé."
  },
  "CLIENTS": {
    "TITLE": "Clients",
    "ADD": "Ajouter un client",
    "EDIT": "Modifier un client",
    "EMPTY": "Aucun client.",
    "DELETE_CONFIRM": "Supprimer ce client, son compte utilisateur et tous ses rendez-vous ?",
    "ADDED": "Le client {nom} a été ajouté (login : {login}).",
    "UPDATED": "Le client {nom} a été modifié.",
    "DELETED": "Le client {nom} a été supprimé."
  },
  "ERROR_PAGE": {
    "TITLE": "Erreur",
    "BACK": "Retour à l'accueil"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
    "DOCTOR_ALREADY_EXISTS": "Un médecin portant ce prénom et ce nom existe déjà.",
    "PATIENT_ALREADY_EXISTS": "Un client portant ce prénom et ce nom existe déjà.",
    "DOCTOR_HAS_APPOINTMENTS": "Ce médecin a des rendez-vous : annulez-les avant de le supprimer.",
    "SLOT_ALREADY_BOOKED": "Ce créneau vient d'être réservé par quelqu'un d'autre.",
    "CONCURRENT_UPDATE": "Cette fiche a été modifiée entre-temps par un autre utilisateur. Revenez à la liste et rouvrez-la avant de recommencer.",
    "DOCTOR_NOT_FOUND": "Ce médecin n'existe pas (ou plus).",
    "PATIENT_NOT_FOUND": "Ce client n'existe pas (ou plus).",
    "SLOT_NOT_FOUND": "Ce créneau n'existe pas (ou plus).",
    "APPOINTMENT_NOT_FOUND": "Ce rendez-vous n'existe pas (ou plus).",
    "INVALID_DATE": "La date demandée n'est pas valide.",
    "CHOOSE_PATIENT": "Choisissez un patient.",
    "INVALID_FORM": "Le formulaire contient des erreurs.",
    "INVALID_TITLE": "Choisissez un titre dans la liste.",
    "FIRSTNAME_REQUIRED": "Le prénom est obligatoire.",
    "FIRSTNAME_TOO_LONG": "Le prénom ne doit pas dépasser 20 caractères.",
    "LASTNAME_REQUIRED": "Le nom est obligatoire.",
    "LASTNAME_TOO_LONG": "Le nom ne doit pas dépasser 20 caractères.",
    "INVALID_LOGIN": "30 caractères au plus : lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
    "PASSWORD_TOO_LONG": "Le mot de passe ne doit pas dépasser 72 caractères.",
    "SESSION_EXPIRED": "Votre session a expiré : reconnectez-vous.",
    "BAD_REQUEST": "La requête est invalide.",
    "FORBIDDEN": "Votre rôle ne vous permet pas d'accéder à cette page.",
    "NOT_FOUND": "Cette page n'existe pas.",
    "SERVER_ERROR": "Une erreur inattendue s'est produite. Réessayez plus tard.",
    "TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {minutes} minute(s).",
    "WRONG_CAPTCHA": "Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.",
    "TOO_MANY_ACCOUNTS": "Trop de comptes ont été créés depuis votre connexion. Réessayez dans {minutes} minute(s).",
    "LOGIN_REQUIRED": "Le login est obligatoire.",
    "PASSWORD_REQUIRED": "Le mot de passe est obligatoire.",
    "PASSWORD_TOO_SHORT": "Le mot de passe doit comporter au moins {min} caractères.",
    "CONFIRMATION_REQUIRED": "Confirmez le mot de passe.",
    "PASSWORDS_DIFFER": "La confirmation ne correspond pas au mot de passe.",
    "CURRENT_PASSWORD_REQUIRED": "Saisissez votre mot de passe actuel.",
    "WRONG_CURRENT_PASSWORD": "Mot de passe actuel incorrect.",
    "PAST_DATE": "On ne peut pas prendre de rendez-vous à une date passée.",
    "PATIENT_HAS_UPCOMING_APPOINTMENT": "Ce patient a déjà un rendez-vous à venir : il faut l'annuler avant d'en prendre un autre.",
    "CAPTCHA_BEFORE_ACCOUNT": "Pour créer un compte, recopiez d'abord correctement les caractères de l'image, puis cliquez sur « Créer un compte client »."
  },
  "CAPTCHA": {
    "LABEL": "Recopiez les caractères de l'image",
    "IMAGE_ALT": "Image de vérification anti-robots",
    "NEW_IMAGE": "Autre image",
    "HELP": "Majuscules ou minuscules, peu importe."
  },
  "INSCRIPTION": {
    "TITLE": "Créer un compte client",
    "INTRO": "Ce compte vous permettra de prendre vous-même vos rendez-vous.",
    "LOGIN_HELP": "Lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
    "PASSWORD_HELP": "Au moins {min} caractères.",
    "SUBMIT": "Créer mon compte",
    "BACK_TO_LOGIN": "Retour à la connexion",
    "DONE": "Votre compte a été créé. Vous pouvez vous connecter avec le login {login}."
  },
  "COMPTE": {
    "CONFIRMATION_LABEL": "Confirmation du mot de passe"
  },
  "ESPACE": {
    "TITLE": "Mon espace",
    "HELLO": "Bonjour {nom}",
    "MANAGE_ACCOUNT": "Gérer mon compte",
    "MANAGE_ACCOUNT_HELP": "Modifier mes informations, mon mot de passe, ou supprimer mon compte.",
    "VIEW_AGENDA": "Voir un agenda",
    "VIEW_AGENDA_HELP": "Consulter l'agenda d'un médecin et réserver un créneau.",
    "MY_APPOINTMENT": "Mon rendez-vous",
    "SEE_IN_AGENDA": "voir dans l'agenda",
    "CANCEL_APPOINTMENT": "Annuler ce rendez-vous",
    "NO_APPOINTMENT": "Vous n'avez aucun rendez-vous à venir."
  },
  "MON_COMPTE": {
    "TITLE": "Gérer mon compte",
    "NEW_PASSWORD_LABEL": "Nouveau mot de passe",
    "CURRENT_PASSWORD_LABEL": "Mot de passe actuel",
    "CURRENT_PASSWORD_HELP": "Obligatoire pour enregistrer une modification.",
    "BACK": "Retour à mon espace",
    "DELETE_TITLE": "Supprimer mon compte",
    "DELETE_HELP": "Votre compte sera définitivement supprimé, ainsi que tous vos rendez-vous.",
    "DELETE_CONFIRM": "Supprimer définitivement votre compte ? Votre rendez-vous éventuel sera annulé.",
    "DELETE_SUBMIT": "Supprimer mon compte",
    "UPDATED": "Votre compte a été modifié.",
    "DELETED": "Votre compte a été supprimé."
  },
  "CONFIRMATION": {
    "TITLE": "Confirmation",
    "NO": "Non, revenir",
    "YES": "Oui, confirmer"
  }
}

Les clés sont regroupées par page ou par thème : [APP] (la barre de navigation), [LOGIN], [AGENDA], [BOOKING_DIALOG] (la fenêtre de réservation), [PERSONNES], [MEDECINS], [CLIENTS], [ERRORS] (tous les messages d'erreur, y compris ceux des validateurs des formulaires et de la couche métier)... Les paramètres des messages s'écrivent [{nom}], [{login}], [{minutes}], [{min}], [{horaire}].

11.18.2. i18n/en.json

Le dictionnaire anglais a exactement les mêmes clés. Quelques lignes, pour comparaison :

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Log out",
    "MENU_AGENDA": "Schedule",
    "MENU_DOCTORS": "Doctors",
    "MENU_PATIENTS": "Patients",
    "MENU_MY_SPACE": "My space"
  },
  "LOGIN": {
    "TITLE": "Login",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Password",
    "SUBMIT": "Log in",
    "ERROR": "Incorrect login or password.",
    "DEMO_ACCOUNTS": "Demo accounts (password = login):",
    "DEMO_ADMIN": "ADMIN role: appointments + doctor and patient management",
    "DEMO_DOCTOR": "DOCTOR role: booking and cancelling appointments",
    "DEMO_USER": "USER role (patients): booking for themselves, managing their account",
    "CREATE_ACCOUNT": "Create a patient account",
    "CREATE_ACCOUNT_HELP": "To create an account, first type the characters shown in the image."

Un dictionnaire de plus suffirait pour une troisième langue : il faudrait l'ajouter à la liste [LANGUES] du traducteur, et un bouton à la barre de navigation.

11.19. Les fichiers statiques

Le dossier [static] contient la feuille de style de l'application, son script, et [Bootstrap] (dossier [lib/bootstrap] : la feuille de style [bootstrap.min.css] de la distribution officielle, et sa licence ; l'application n'utilise pas le [JavaScript] de [Bootstrap]).

11.19.1. static/css/styles.css

/* =========================================================================
   static/css/styles.css - quelques réglages en plus de Bootstrap
   (les mêmes que dans les versions ASP.NET Core MVC et Spring MVC 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ègles en plus de [Bootstrap], les mêmes que dans les versions précédentes de l'application. La zone d'erreur (lignes 23-32) est collante ([position: sticky], [bottom: 1rem]) : si la page est plus haute que la fenêtre (un agenda de douze créneaux), elle reste visible au bas de la fenêtre, au lieu de disparaître sous la ligne de flottaison. [z-index: 1060] la place au-dessus du fond grisé des fenêtres modales.

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 script de l'exemple 21, complété :

  • lignes 16-18 : le script ne fait rien si la page n'a pas de fenêtre de confirmation ;
  • lignes 26-34 : à l'ouverture, le message est placé par [textContent], et non [innerHTML] : il 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-41 : à la fermeture, le focus revient à l'élément qui l'avait avant (le bouton Supprimer), ce qui compte pour la navigation au clavier ;
  • lignes 54-65 : les clics dans la fenêtre. [closest('[data-action]')] cherche l'élément qui porte l'attribut [data-action], à partir de l'élément cliqué en remontant vers ses parents : un clic sur une icône à l'intérieur d'un bouton est ainsi attribué au bouton. L'opérateur [?.] rend [undefined] au lieu de lever une erreur si aucun élément n'est trouvé. Un clic sur le fond grisé ferme la fenêtre ;
  • lignes 71-83 : le clavier : Échap ferme la fenêtre ; Tab et Maj-Tab font tourner le focus entre les boutons de la fenêtre, sans passer à la page grisée, derrière.