Skip to content

11. Case Study: The RdvMedecins Application

11.1. The Problem

The RdvMedecins application manages appointments for a medical practice. Each doctor has time slots (from 8:00 a.m. to 12:00 p.m. in 20-minute increments, for example); an appointment consists of a day, a time slot, and a client (a patient). This is the exact port, in [Flask], of the [Spring MVC] application from the previous lesson (which was itself ported from the [ASP.NET Core MVC] version): same database, same business rules, same pages, same URLs, same translation dictionaries. The HTML pages are generated by the server using [Jinja2] views; database access is handled by [SQLAlchemy].

Three user roles share the application:

Role
Who
What they can do
ADMIN
The administrator ([admin])
View schedules, make and cancel appointments for any patient; manage (list, add, edit, delete) doctors and clients
DOCTOR
Doctors
view schedules, make appointments, and cancel appointments for any patient
USER
clients (patients)
view schedules without seeing other patients’ names; book an appointment for themselves; cancel their appointment; manage their account; create an account

Key management rules:

  • No appointments in the past;
  • a time slot can only be booked once per day;
  • a patient may have only one upcoming appointment at a time;
  • Two doctors (or two clients) cannot have the same first and last names, and a login belongs to only one account;
  • A doctor with appointments cannot be deleted; A client is deleted along with their appointments;
  • the login page is protected against bots (CAPTCHA, limited attempts), and creating a client account requires first solving the CAPTCHA on that page.

11.2. Using the Application

11.2.1. The login page

Image

The page features two buttons: Log in and Create a patient account. Both require you to enter the characters shown in the image. The buttons FR / EN change the language:

Image

An error (in this case, an incorrectly entered CAPTCHA) is displayed at the bottom of the page on a light pink background:

Image

11.2.2. The calendar, as seen by the administrator and doctors

Once logged in, the administrator is taken to the calendar page. They select a doctor and a day:

Image

Image

Each time slot is either available (Book button) or occupied (patient’s name, Cancel button). Clicking Book opens a window asking for the patient’s information. This window is generated by the server, just like the rest of the page: the URL now contains the parameter [book=4], which is the number of the selected time slot:

Image

After the booking, a message confirms the appointment:

Image

Clicking “Cancel” prompts a confirmation (here by Dr. Pélissier, whose navigation bar does not include the administration menus):

Image

11.2.3. The Patient Portal

A patient (role USER) arrives at “My area”: their upcoming appointment, which they can cancel, and links to manage their account and view their calendar. Here, they do not yet have an appointment:

Image

In the calendar, they do not see the names of other patients; these are replaced by “Booked”: this name does not even appear in the page’s source code. They can book an available time slot:

Image

The booking window does not prompt him to select a patient: he books the appointment for himself.

Image

Image

His dashboard now shows his upcoming appointment, which he can cancel:

Image

Since he has an upcoming appointment, he cannot schedule another one: the “Book” button is not displayed, and on the calendar for another day or another doctor, a message explains why:

Image

Account management: edit their information, change their password, or delete their account (and appointments). The current password is required for any changes:

Image

A patient who attempts to open a page reserved for the administrator (here, [/doctors], typed manually) receives an error page:

Image

11.2.4. Managing Doctors and Patients

The administrator has two additional menus, Doctors and Patients:

Image

Image

The form for adding a doctor, submitted with errors:

Image

A name that is the same as an existing doctor’s is rejected, even when written in lowercase (the database itself compares names without distinguishing between uppercase and lowercase letters or accents; see example 28):

Image

Deleting a patient requires confirmation:

Image

11.2.5. Creating a patient account

On the login page, the "Create a patient account" button only opens the form if the CAPTCHA has been entered correctly:

Image

Once the account is created, the user is redirected back to the login page with their username already filled in:

Image

11.2.6. End of Session

The login token is valid for two hours. After that time (or if the token has been tampered with), the requested page redirects to the login page with the following message:

Image

11.3. Installation and Execution

The application is located in the [rdvmedecins2-flask] folder, next to the [examples] folder. It is a standalone project, with its own [requirements.txt] files and virtual environment. The [README.md] file in the folder contains all of the following. The introduction to this document has already described the installation; we’ll provide additional details here.

11.3.1. Required Software

Two software packages are sufficient:

  • [Python] 3.12 or later (the application uses the generic class syntax introduced in version 3.12). Verify the installation in a terminal:
python --version
Python 3.13.7
  • a [MySQL] 8 server, for example the one from [Laragon] running under [Windows] (see appendices). [MariaDB] also works: the application has been tested with it.

The libraries are downloaded by [pip] from the [PyPI] repository (*Python Package Index*): you must therefore be connected to the Internet during their installation. The recommended editor is [Visual Studio Code], along with the [Python] extension from Microsoft (see appendices); however, everything can be done via the command line.

11.3.2. Create the database

The [dbrdvmedecins2] database is created by the [database/dbrdvmedecins2.sql] script. Using [Laragon], open the [HeidiSQL] tool (Database button), then run the script via the File / Run SQL menu. From the command line, in the application folder:

mysql -u root -p < database/dbrdvmedecins2.sql

The [-p] option prompts for the password for [root]; omit it if the account does not have a password (as is the case with [Laragon]). This is the same database as the ones used by versions [ASP.NET Core] and [Spring MVC]: if it already exists, this step is optional. The script can be run at any time to revert to the original data.

11.3.3. Configure database access

All configuration settings are in the file [config.py] (discussed below). By default, the application connects to [MySQL] on [localhost], port 3306, using the user [root] without a password: these are the default settings for [Laragon]. If your [root] account has a password, the easiest way is to enter it in this file, on the line [DB_PASSWORD = ""]. To avoid writing a password in a project file, you can also specify it using an environment variable, which takes precedence over the file. Its name is the same as the variable in the file, preceded by [FLASK_] (example 25):

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

The variable applies only to the terminal where it was defined; the application is then launched from there.

11.3.4. Launching the Application

In a terminal window open to the [rdvmedecins2-flask] folder, with the virtual environment created and activated, and the libraries installed (see introduction):

python app.py

The introduction showed the console output. Next, open the URL [http://localhost:8081] in a browser, and stop the application using [Ctrl-C].

11.3.5. Demo Accounts

The password for each account is its username; you must also enter the characters from the image, in uppercase or lowercase:

Logins
Role
[admin]
ADMIN (the administrator)
[pelissier], [tatou], [dupont]
DOCTOR (doctors)
[martin], [durand], [bistrou], [german]
USER (patients)

A guide to exploring the app:

  • [admin]: Schedule, Ms. Marie PELISSIER, today: a time slot is already booked by Ms. Brigitte BISTROU. Book a slot for Mr. Jean MARTIN, then try to book a second appointment for him (declined). Doctors and Patients menus: add, edit, delete (Dr. PELISSIER, who has appointments, cannot be deleted);
  • [pelissier]: his daily schedule is displayed directly; the address [/doctors], entered manually, returns a 403 error page;
  • [martin]: My area, their appointment, the calendar where other patients appear as “Booked”; Manage my account;
  • login page: Create a patient account, then log in with the new account;
  • Buttons FR / EN: The entire interface changes languages, including dates.

11.3.6. Development mode

The [app.py] file launches the application with the [dev] profile (the [config_dev.py] file, discussed later): the console then displays each SQL command sent to the database by [SQLAlchemy]. The development server’s debug mode adds two features:

  • automatic reloading: the application restarts automatically as soon as a [.py] file is modified. A modified [templates/*.html] view is reloaded the next time the page is displayed;
  • the exception debug page (example 03), which is disabled here by the [PROPAGATE_EXCEPTIONS = False] setting (see below): the application displays its own error page, even during development, and the exception call stack is written to the console.

The [Visual Studio Code] debugger also allows you to step through the application: Set a breakpoint by clicking in the margin of a line of code, then launch the [app.py] file via the Run / Start Debugging menu ([F5] key), selecting “Python Debugger” and then “Python File.” Automatic reloading should be avoided (it launches the application in a separate process, which the debugger does not track): disable it by launching the application using the command [flask --app rdvmedecins run --port 8081] (without [--debug]).

11.3.7. A Production Server

The [Werkzeug] development server is not intended for production: it states this itself at startup. In production, the application is deployed to a WSGI server (see the chapter on the lifecycle). The application declares one in its [requirements.txt] file: [Waitress], a server written in [Python], which runs just as well on [Windows] as it does on [Linux]. It is already installed in the virtual environment:

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

The option [--call] specifies a factory to call ([create_app], within the [rdvmedecins] package) to retrieve the application. When launched this way, the application does not have the [dev] profile (the console does not display the SQL commands), nor is it in debug mode. [Waitress] processes multiple requests in parallel, across multiple threads (four by default; option [--threads]). Under [Linux], [Gunicorn] is also widely used; however, it does not work under [Windows]. In both cases, the WSGI server is typically placed behind a web server ([nginx], [Apache]), which serves requests in HTTPS mode and forwards them to the application.

In production, you must also change the secret keys and specify that the site is served via HTTPS using environment variables:

1
2
3
export FLASK_SECRET_KEY="<a long random string>"
export FLASK_JWT_SECRET="<another long random string>"
export FLASK_COOKIE_SECURE=true

A suitable random string can be obtained using [Python] itself: [python -c "import secrets; print(secrets.token_urlsafe(48))"].

11.3.8. Common Issues

Upon startup, the application reads the lists of doctors and clients: if the database is inaccessible, the application stops immediately, with an error in the console. This error is preceded by a long call stack; the relevant message is at the end, before the line [(Background on this error at: ...)]. For example, with a database that does not exist:

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

The most common messages:

Message
Cause
[Can't connect to MySQL server on 'localhost' ([Errno 111] Connection refused)] ([[WinError 10061]] under [Windows])
[MySQL] has not started, or is not listening on [localhost:3306]
[Access denied for user 'root'@'localhost']
The password for [MySQL] must be entered (see “Configure database access”)
[Unknown database 'dbrdvmedecins2']
The script [database/dbrdvmedecins2.sql] has not been executed
[JWT_SECRET must be at least 32 characters long (see config.py).]
The secret token key is too short in [config.py] (or in the variable [FLASK_JWT_SECRET])
[ModuleNotFoundError: No module named 'flask']
The virtual environment is not enabled, or the libraries have not been installed in it
[Address already in use] or [Port 8081 is in use by another program]
Port 8081 is already in use (is version [Spring MVC] running?): stop it, or change the port in [app.py]
['cryptography' package is required for sha256_password or caching_sha2_password auth methods]
The account [MySQL] uses the authentication settings from [MySQL] 8: The [cryptography] library, installed by [PyMySQL[rsa]] (see [requirements.txt]), is missing
[SyntaxError] at startup, on a [class ...[T: ...]] line
the version of [Python] is earlier than 3.12

11.4. The application architecture

Image

  • The browser sends its requests to the server (the [Werkzeug] development server, or [Waitress]), which forwards them to the [Flask] application. Each request first passes through the [before_request] functions (see the chapter on the lifecycle): the request language, then the user—identified based on the JWT token in their cookie—and the access rule, which denies protected pages to visitors who are not authorized to view them ([auth/security.py]);
  • [controllers]: Web-layer controllers, one blueprint per part of the application. They all pass through a single object, [ApplicationModel] (folder [web]), which stores the lists of doctors and clients in memory and forwards other requests to the business layer;
  • [auth]: authentication, JWT tokens, CAPTCHA, attempt limits, and account creation pass;
  • [web]: common elements across the web layer: the cache and the [ApplicationModel] facade, translation, flash messages, validation, variables common to all views, and error pages; [forms]: forms; [WTForms]; [models]: view templates;
  • [business]: business rules and transactions;
  • [repositories]: the DAO layer, one class per table;
  • [entities]: the entities that [SQLAlchemy] reads from and writes to the database; [domain]: the calendar objects, calculated by the business layer;
  • [templates]: the views [Jinja2], which generate the pages HTML; [i18n]: the dictionaries, used by the translator; [static]: the stylesheet, the page script, and [Bootstrap];
  • [rdvmedecins/__init__.py]: the [create_app] builder, which assembles everything; [config.py]: the configuration.

The application folder:

rdvmedecins2-flask/
├── app.py
├── config.py
├── config_dev.py
├── database/
│   ├── dbrdvmedecins2.sql
│   └── README.md
├── rdvmedecins/
│   ├── __init__.py
│   ├── auth/
│   │   ├── auth_service.py
│   │   ├── captcha.py
│   │   ├── captcha_drawing.py
│   │   ├── connected_user.py
│   │   ├── limiter.py
│   │   ├── passwords.py
│   │   ├── README.md
│   │   ├── registration_pass.py
│   │   └── security.py
│   ├── business/
│   │   ├── business.py
│   │   ├── dates.py
│   │   ├── exception.py
│   │   ├── person_data.py
│   │   ├── README.md
│   │   └── service.py
│   ├── controllers/
│   │   ├── area.py
│   │   ├── auth.py
│   │   ├── clients.py
│   │   ├── doctors.py
│   │   ├── language.py
│   │   ├── persons.py
│   │   ├── README.md
│   │   ├── registration.py
│   │   └── schedule.py
│   ├── domain/
│   │   ├── README.md
│   │   └── schedule.py
│   ├── entities/
│   │   ├── appointment.py
│   │   ├── base.py
│   │   ├── person.py
│   │   ├── README.md
│   │   ├── role.py
│   │   ├── slot.py
│   │   └── user.py
│   ├── forms/
│   │   ├── forms.py
│   │   ├── README.md
│   │   └── rules.py
│   ├── i18n/
│   │   ├── en.json
│   │   └── fr.json
│   ├── models/
│   │   ├── README.md
│   │   └── view_models.py
│   ├── repositories/
│   │   ├── appointment_repository.py
│   │   ├── client_repository.py
│   │   ├── doctor_repository.py
│   │   ├── person_repository.py
│   │   ├── README.md
│   │   ├── slot_repository.py
│   │   └── user_repository.py
│   ├── static/
│   │   ├── css/
│   │   │   └── styles.css
│   │   ├── js/
│   │   │   └── app.js
│   │   └── lib/
│   │       └── bootstrap/
│   ├── templates/
│   │   ├── account.html
│   │   ├── area.html
│   │   ├── error.html
│   │   ├── fragments/
│   │   │   ├── booking.html
│   │   │   ├── captcha.html
│   │   │   ├── confirmation.html
│   │   │   ├── errors.html
│   │   │   ├── flash.html
│   │   │   ├── navbar.html
│   │   │   └── person_fields.html
│   │   ├── layout.html
│   │   ├── login.html
│   │   ├── persons/
│   │   │   ├── form.html
│   │   │   └── list.html
│   │   ├── README.md
│   │   ├── registration.html
│   │   └── schedule.html
│   └── web/
│       ├── application_model.py
│       ├── common_model.py
│       ├── errors.py
│       ├── flash.py
│       ├── README.md
│       ├── services.py
│       ├── translator.py
│       ├── utils.py
│       └── validation.py
├── README.md
└── requirements.txt

Each package folder also contains a file named [__init__.py]—usually empty—which turns the folder into a [Python] package: this is what allows [from rdvmedecins.entities import Doctor] to be written. [Bootstrap] is part of the application (folder [static/lib/bootstrap]) this time: the case study is self-contained.

Note the choice made for the [rdvmedecins] package: the application is no longer a simple [app.py] module, but a package, whose [__init__.py] file contains the [create_app] factory. This is the structure recommended by the [Flask] documentation for an application of this size: the [app.py] file, located at the root, is used solely to start the development server.

11.5. The database

The [dbrdvmedecins2] database has five tables:

  • [users]: users authorized to log in;
  • [doctors] and [clients]: doctors and patients, each linked to their respective user accounts;
  • [slots]: each doctor’s available time slots;
  • [appointments]: appointments, one client per time slot on a given day.

11.5.1. database/dbrdvmedecins2.sql

-- =========================================================================
-- Database [dbrdvmedecins2] - MySQL 8
--
-- Evolution of [dbrdvmedecins]:
--   1. the [doctors] and [clients] tables get a [user] column,
--      foreign key to users(id): every doctor and every client is
--      now also a user able to log in;
--   2. a third role appears: DOCTOR (in addition to ADMIN and USER);
--   3. the script creates a [users] row for each doctor (role DOCTOR)
--      and for each client (role USER):
--        - login        = last name in lower case
--        - password     = last name in lower case, hashed with bcrypt (10 rounds)
--        - display name = first name + last name
--
--   4. uniqueness constraints, all NAMED (the application recognizes
--      the name of the violated constraint in the MySQL error message):
--        - unq_users_login                   : a login belongs to one account only;
--        - unq_doctors_first_name_last_name  : two doctors cannot have the
--                                              same first and last names;
--        - unq_clients_first_name_last_name  : same for the clients.
--      Beware: the utf8mb4_unicode_ci collation compares regardless of
--      case and accents - "Hélène DUPONT" and "helene dupont"
--      are therefore considered identical.
--
-- The [users] table must now be created BEFORE [doctors] and [clients]
-- (they reference it), and dropped AFTER them.
-- =========================================================================

-- the script is written in UTF-8
SET NAMES utf8mb4;

-- we start again from an empty database on each run of the script (handy in development)
CREATE DATABASE IF NOT EXISTS dbrdvmedecins2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbrdvmedecins2;

-- drop order: from the "child" tables to the "parent" tables
DROP TABLE IF EXISTS appointments;
DROP TABLE IF EXISTS slots;
DROP TABLE IF EXISTS clients;
DROP TABLE IF EXISTS doctors;
DROP TABLE IF EXISTS users;

-- -------------------------------------------------------------------------
-- table [users]: the users allowed to log in to the application
-- -------------------------------------------------------------------------
CREATE TABLE users (
  id       INT AUTO_INCREMENT PRIMARY KEY,
  version  INT NOT NULL DEFAULT 0,
  login    VARCHAR(30) NOT NULL,                 -- login identifier
  password VARCHAR(100) NOT NULL,                -- HASHED password (bcrypt) - never in clear
  name     VARCHAR(40) NOT NULL,                 -- name displayed once logged in
  role     VARCHAR(10) NOT NULL,                 -- 'ADMIN', 'DOCTOR' or 'USER'
  -- a login can belong to one account only
  CONSTRAINT unq_users_login UNIQUE (login)
);

-- -------------------------------------------------------------------------
-- table [doctors]: the doctors of the practice
-- -------------------------------------------------------------------------
CREATE TABLE doctors (
  id         INT AUTO_INCREMENT PRIMARY KEY,   -- primary key (technical identifier)
  version    INT NOT NULL DEFAULT 0,            -- row version number (optimistic lock)
  title      VARCHAR(5)  NOT NULL,              -- Melle, Mme, Mr
  last_name  VARCHAR(20) NOT NULL,
  first_name VARCHAR(20) NOT NULL,
  `user`     INT NOT NULL,                      -- foreign key -> users(id)
  CONSTRAINT fk_doctors_user FOREIGN KEY (`user`) REFERENCES users(id),
  -- one account per doctor, and one doctor per account
  CONSTRAINT unq_doctors_user UNIQUE (`user`),
  -- two doctors cannot have the same first and last names
  CONSTRAINT unq_doctors_first_name_last_name UNIQUE (first_name, last_name)
);

-- -------------------------------------------------------------------------
-- table [clients]: the doctors' patients
-- -------------------------------------------------------------------------
CREATE TABLE clients (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  version    INT NOT NULL DEFAULT 0,
  title      VARCHAR(5)  NOT NULL,
  last_name  VARCHAR(20) NOT NULL,
  first_name VARCHAR(20) NOT NULL,
  `user`     INT NOT NULL,                      -- foreign key -> users(id)
  CONSTRAINT fk_clients_user FOREIGN KEY (`user`) REFERENCES users(id),
  -- one account per client, and one client per account
  CONSTRAINT unq_clients_user UNIQUE (`user`),
  -- two clients cannot have the same first and last names
  CONSTRAINT unq_clients_first_name_last_name UNIQUE (first_name, last_name)
);

-- -------------------------------------------------------------------------
-- table [slots]: the time slots of each doctor
-- -------------------------------------------------------------------------
CREATE TABLE slots (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  version      INT NOT NULL DEFAULT 0,
  doctor_id    INT NOT NULL,                     -- foreign key -> doctors(id)
  start_hour   INT NOT NULL,                     -- start hour of the slot
  start_minute INT NOT NULL,                     -- start minute of the slot
  end_hour     INT NOT NULL,                     -- end hour of the slot
  end_minute   INT NOT NULL,                     -- end minute of the slot
  CONSTRAINT fk_slots_doctor FOREIGN KEY (doctor_id) REFERENCES doctors(id)
);

-- -------------------------------------------------------------------------
-- table [appointments]: the appointments booked by the clients
-- -------------------------------------------------------------------------
CREATE TABLE appointments (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  version    INT NOT NULL DEFAULT 0,
  day        DATE NOT NULL,                      -- day of the appointment
  slot_id    INT NOT NULL,                       -- foreign key -> slots(id)
  client_id  INT NOT NULL,                       -- foreign key -> clients(id)
  CONSTRAINT fk_appointments_slot   FOREIGN KEY (slot_id)   REFERENCES slots(id),
  CONSTRAINT fk_appointments_client FOREIGN KEY (client_id) REFERENCES clients(id),
  -- the same slot, on the same day, can be booked only once
  CONSTRAINT unq1_appointments UNIQUE (day, slot_id)
);

-- =========================================================================
-- Demo data
-- =========================================================================

-- the users: passwords hashed with bcrypt (10 salt rounds)
--   admin/admin (ADMIN)
--   one DOCTOR account per doctor, one USER account per client
--   (the user/user demo account is gone: a USER account is
--   now ALWAYS a client's account, who can book and manage the account)
--   (login = password = last name in lower case)
INSERT INTO users (login, password, name, role) VALUES
  ('admin',     '$2b$10$YSNBJXXHX9mp13nPZb6YGu8M1jR6uvPwO1s2X73z0QAqBR7TOQjoq', 'Administrator',    'ADMIN'),
  -- doctors
  ('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');

-- the doctors: the [user] column is looked up from the login,
-- rather than assuming the values of the primary keys of [users]
INSERT INTO doctors (title, last_name, first_name, `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 (title, last_name, first_name, `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'));

-- slots of doctor PELISSIER (doctor_id = 1): from 8:00 to 12:00 in 20-minute steps
INSERT INTO slots (doctor_id, start_hour, start_minute, end_hour, end_minute) 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);

-- slots of doctor TATOU (doctor_id = 2): from 14:00 to 18:00 in 20-minute steps
INSERT INTO slots (doctor_id, start_hour, start_minute, end_hour, end_minute) 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);

-- an appointment already booked, to see an occupied slot right away in the schedule
INSERT INTO appointments (day, slot_id, client_id) VALUES
  (CURDATE(), 2, 3);
  • line 33: the database is created if it does not exist, with the character set [utf8mb4] and the collation [utf8mb4_unicode_ci], which compares strings without regard to uppercase letters or accents (example 28);
  • lines 37–41: The tables are dropped and then recreated; the script restores the database to its initial state. The order matters: a table cannot be dropped as long as another table references it via a foreign key;
  • lines 46–54: users: a login, a password hashed by [bcrypt] (example 25), the display name once logged in, and the role ([ADMIN], [DOCTOR], or [USER]). The constraint [unq_users_login] ensures that a username belongs to only one account;
  • Lines 60–71: Each table has a primary key [id], generated by the database, and a column [version], which is the optimistic lock (Example 28). The column [user] is a foreign key referencing [users(id)]: the database rejects any value that is not the ID of an existing user. Since [user] is a reserved word for SQL, it is enclosed in grave accents. Three constraints: [fk_doctors_user] (the foreign key), [unq_doctors_user] (one account per doctor, and one doctor per account), and [unq_doctors_first_name_last_name] (no homonyms). All constraints are named: the application recognizes a violated constraint by its name;
  • lines 77–88: the [clients] table has exactly the same columns;
  • lines 94–102: a time slot belongs to a doctor; its start and end times are stored in hours and minutes;
  • lines 108–117: an appointment consists of a day (type [DATE], without a time), a time slot, and a client. The constraint [unq1_appointments] prevents the same time slot from being booked twice on the same day: the database enforces this rule, even if two users book it at the same time;
  • lines 130–140: demo users, whose password is their login. The hashes begin with [$2b$10$]: the algorithm ([bcrypt], variant [2b]) and the cost (10). Next come the salt and the hash;
  • lines 144–147: Each doctor’s [user] column is retrieved via a subquery based on the login, rather than by assuming the primary key values;
  • lines 170–171: an appointment already scheduled for today ([CURDATE()]) in Dr. PELISSIER’s second time slot for Ms. BISTROU: the calendar thus immediately shows a booked time slot.

11.6. Project configuration

11.6.1. requirements.txt

# the libraries of the 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
# a WSGI server for production (see README.md)
waitress==3.0.2

The application libraries, with their exact versions ([==]). Specifying the versions ensures that the application will function the same way on all machines, even a year from now. [PyMySQL[rsa]] installs the driver [PyMySQL] along with its optional dependencies [rsa], that is, the [cryptography] library, which is required for the authentication methods in [MySQL] 8. [pip install -r requirements.txt] installs these libraries and the ones they depend on ([Werkzeug], [Jinja2], [WTForms]...). The command [pip freeze] displays the complete list of what is installed in the virtual environment.

11.6.2. config.py

# =========================================================================
# config.py - the configuration of the application.
#
# Flask only keeps the UPPER CASE variables (see rdvmedecins/__init__.py,
# app.config.from_pyfile). Each value can be replaced, without modifying this
# file, by an environment variable prefixed with FLASK_:
# DB_PASSWORD is then written FLASK_DB_PASSWORD (app.config.from_prefixed_env).
# The values of these variables are read as JSON when possible:
# FLASK_LOGIN_MAX_FAILURES=3 gives the number 3, FLASK_COOKIE_SECURE=true the boolean True.
# =========================================================================

# ---- the MySQL database [dbrdvmedecins2] (script: database/dbrdvmedecins2.sql):
# the server, its port, the user and its password (those of Laragon:
# root, without password), the database
DB_HOST = "localhost"
DB_PORT = 3306
DB_USER = "root"
DB_PASSWORD = ""
DB_NAME = "dbrdvmedecins2"

# True: the SQL statements sent to the database are displayed in the console
SQL_ECHO = False

# ---- the secret key of Flask: it SIGNS the session cookie (flash
# messages, anti-CSRF token). Must absolutely be changed in production.
SECRET_KEY = "change-me-too-to-a-long-random-string"

# ---- authentication (see rdvmedecins/auth/)
# secret key of the JWT tokens: at least 32 characters (HMAC-SHA256).
# Must absolutely be changed in production (long random string).
JWT_SECRET = "change-me-to-a-long-random-string"
# validity duration of a token, in seconds (7200 s = 2 h); the cookie that
# carries the token has the same lifetime
JWT_EXPIRES_IN_SECONDS = 7200
# True in production (site served over HTTPS): the cookies of the application
# are then never sent over an unencrypted connection
COOKIE_SECURE = False

# ---- protection against robots (see rdvmedecins/auth/limiter.py)
# login: failures allowed for the same login from the same IP address...
LOGIN_MAX_FAILURES = 5
# ... before a block of this duration (the whole IP address is also blocked
# if it accumulates 5 times more failures, all logins together)
LOGIN_BLOCK_MINUTES = 15
# client account creation: maximum accounts created per hour and per IP
# address. In a classroom, all the computers often go out through the same address:
# increase if needed.
REGISTRATION_MAX_PER_HOUR = 10

The application configuration, read by [app.config.from_pyfile] (Example 25):

  • lines 15–19: database access: the server, its port, the username and password, and the database. These values are used to construct the URL connection string for [SQLAlchemy] (see below);
  • line 22: display of SQL commands, disabled by default;
  • line 26: the secret key for [Flask], which signs the session cookie (example 20);
  • lines 31–34: the key that signs the JWT tokens, and their lifetime: two hours;
  • line 37: in production, the application’s cookies have the attribute [Secure]; the browser sends them only over a HTTPS connection;
  • Lines 41–48: bot protection settings: the number of allowed failed login attempts and the duration of the resulting block; the number of customer accounts that a single IP address can create per hour.

Each value can be replaced by an environment variable prefixed with [FLASK_]. The two secret keys in the file are only development values: in production, they must be replaced (see “A Production Server”).

11.6.3. config_dev.py

1
2
3
4
5
6
# =========================================================================
# config_dev.py - completes config.py in development (see app.py, which starts
# the application with the "dev" profile): the SQL statements sent by
# SQLAlchemy are displayed in the console.
# =========================================================================
SQL_ECHO = True

The development profile: a second file, read after [config.py], whose values replace those of the first file. There is only one: the display of SQL commands. We could also write a [config_test.py] for testing, using a different database.

11.7. Starting the application

11.7.1. app.py

# =========================================================================
# app.py - starts the application IN DEVELOPMENT:   python app.py
#
# The Flask development server (Werkzeug), on port 8081, with:
#   - the "dev" profile (config_dev.py: the SQL statements are displayed);
#   - debug=True: the application restarts by itself as soon as a .py file
#     is modified, and a modified view is read again when the page is reloaded.
# In production, a real WSGI server is used (see README.md: waitress).
# =========================================================================
from rdvmedecins import create_app

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

The file that launches the application under development: the [create_app] factory is called with the [dev] profile, then the application is handed off to the development server on port 8081 in debug mode. In production, this file is not used: the WSGI server calls [create_app] (see “A Production Server”).

11.7.2. rdvmedecins/__init__.py

The application builder. This is the file that brings everything else together: it is the application’s “blueprint.”

# =========================================================================
# rdvmedecins/__init__.py
# -------------------------------------------------------------------------
# The application FACTORY (see example 02): create_app builds and
# configures the Flask application. This is where everything is assembled:
#   1. the configuration (config.py, config_dev.py, FLASK_... variables);
#   2. database access: the engine and the session factory of SQLAlchemy;
#   3. the shared objects: DAO -> business layer -> cache (ApplicationModel),
#      authentication, captcha, limiter, registration passes, translator -
#      this is dependency injection, written "by hand";
#   4. what runs on each request: language, authentication;
#   5. the blueprints (the controllers), the error handlers, the
#      anti-CSRF protection.
#
# This file makes [rdvmedecins] a Python PACKAGE: "from rdvmedecins
# import create_app" runs this file.
# =========================================================================
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 security
from rdvmedecins.auth.auth_service import AuthService
from rdvmedecins.auth.captcha import CaptchaService
from rdvmedecins.auth.limiter import AttemptLimiter
from rdvmedecins.auth.registration_pass import RegistrationPass
from rdvmedecins.controllers.schedule import schedule
from rdvmedecins.controllers.auth import auth
from rdvmedecins.controllers.clients import clients
from rdvmedecins.controllers.area import area
from rdvmedecins.controllers.registration import registration
from rdvmedecins.controllers.language import language
from rdvmedecins.controllers.doctors import doctors
from rdvmedecins.business.service import BusinessService
from rdvmedecins.repositories.client_repository import ClientRepository
from rdvmedecins.repositories.slot_repository import SlotRepository
from rdvmedecins.repositories.doctor_repository import DoctorRepository
from rdvmedecins.repositories.appointment_repository import AppointmentRepository
from rdvmedecins.repositories.user_repository import UserRepository
from rdvmedecins.web import errors, common_model, translator
from rdvmedecins.web.application_model import ApplicationModel
from rdvmedecins.web.translator import Translator

# the project folder (the one that contains config.py), parent of this package
ROOT = Path(__file__).resolve().parent.parent


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

    # ---- 1. the configuration: config.py, then config_<profile>.py if there
    # is a profile, then the FLASK_... environment variables
    app.config.from_pyfile(ROOT / "config.py")
    if profile:
        app.config.from_pyfile(ROOT / f"config_{profile}.py")
    app.config.from_prefixed_env()
    app.config.update(
        # the Flask session cookie (flash messages, anti-CSRF token)
        SESSION_COOKIE_NAME="rdvmedecins_session",
        SESSION_COOKIE_SAMESITE="Lax",
        SESSION_COOKIE_SECURE=app.config["COOKIE_SECURE"],
        # the anti-CSRF token stays valid as long as the browser session lasts
        WTF_CSRF_TIME_LIMIT=None,
        # an unhandled exception is handed over to our error handlers
        # (page 500), even in debug mode
        PROPAGATE_EXCEPTIONS=False,
    )

    # ---- 2. database access. The SQLAlchemy connection URL
    # (mysql+pymysql://root:@localhost:3306/dbrdvmedecins2?charset=utf8mb4),
    # built by URL.create: a password that contains special
    # characters (@, :, /...) is correctly encoded in it. str(...): a variable
    # FLASK_DB_PASSWORD=123456 would be read as a NUMBER (see 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"})
    # The engine manages a connection POOL; pool_pre_ping: a connection is
    # tested before being reused; pool_recycle: it is renewed after
    # one hour (MySQL closes by itself the connections that have been idle
    # for too long)
    engine = create_engine(url, echo=app.config["SQL_ECHO"], pool_pre_ping=True,
                           pool_recycle=3600)
    # expire_on_commit=False: the objects read remain usable after the end
    # of the transaction (by the views, by the cache)
    sessions = sessionmaker(engine, expire_on_commit=False)

    # ---- 3. the shared objects, created once and for all
    business = BusinessService(sessions, DoctorRepository(), ClientRepository(),
                               SlotRepository(), AppointmentRepository(), UserRepository())
    # the cache reads the doctors and the clients: an unreachable database stops
    # the application HERE, at startup
    app.extensions["rdvmedecins.application"] = ApplicationModel(business)
    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.limiter"] = AttemptLimiter(
        app.config["LOGIN_MAX_FAILURES"], app.config["LOGIN_BLOCK_MINUTES"],
        app.config["REGISTRATION_MAX_PER_HOUR"])
    app.extensions["rdvmedecins.passes"] = RegistrationPass()
    trans = Translator(Path(app.root_path) / "i18n")

    # ---- 4. on each request, in the order of registration:
    # the language (rdvmedecins_language cookie, English otherwise)...
    @app.before_request
    def set_language():
        g.language = translator.language(request.cookies.get(translator.LANGUAGE_COOKIE))

    # ... then the user, and the access rule (see auth/security.py)
    security.register(app, auth_service)
    # the variables common to all the views
    common_model.register(app, trans)

    # ---- 5. the controllers, the error pages, the anti-CSRF protection
    for blueprint in (auth, language, schedule, doctors, clients, registration, area):
        app.register_blueprint(blueprint)
    errors.register(app)
    # every POST request must send back the anti-CSRF token of the page
    # (hidden field csrf_token): otherwise, response 400
    CSRFProtect(app)
    return app
  • line 48: the project folder, which contains [config.py]: the parent folder ([parent.parent]) of this file’s folder;
  • lines 52–54: the application; [__name__] is equivalent here to [rdvmedecins], the package name: [Flask] therefore searches for the folders [templates] and [static] within the package folder;
  • lines 58–61: the configuration, in three steps: the [config.py] file, the profile file (if there is one), and the environment variables. Each source overrides the values from the previous ones;
  • lines 62–71: a few settings for [Flask] and [Flask-WTF], written here rather than in [config.py] because they are not intended to be changed. [SESSION_COOKIE_NAME] renames the session cookie (default: [session]) so that it does not conflict with that of another application served by [localhost], as shown in the examples; [SESSION_COOKIE_SAMESITE] and [SESSION_COOKIE_SECURE] assign it the attributes [SameSite=Lax] and, in production, [Secure] (example 25). [WTF_CSRF_TIME_LIMIT=None]: By default, an anti-CSRF token is valid for only one hour, and a form left open longer than that would be rejected; here, it remains valid for the duration of the browser session. [PROPAGATE_EXCEPTIONS=False]: In debug mode, [Flask] allows certain exceptions to be propagated up to the debug page; this setting ensures that they pass through our error handlers;
  • lines 79–82: the URL login token for [SQLAlchemy] (Example 28), generated by [URL.create] from its components. When written by hand, a URL should encode the special characters in a password ([@], [:], [/]...), which make sense within a URL; [URL.create] does this on its own. [str(...)] and [int(...)] protect against environment variable conversions: [FLASK_DB_PASSWORD=123456] would be interpreted as a number;
  • lines 87–88: the engine and its connection pool. [pool_recycle=3600] renews a connection after one hour; [MySQL] automatically closes connections that have been inactive for too long (eight hours by default);
  • line 91: the session factory (example 28);
  • lines 94–106: shared objects, created once and for all, and stored in [app.extensions] (example 06). This is dependency injection, written by hand: each object receives the objects it needs in its constructor. The business layer receives the session factory and the five classes from the DAO layer; the [ApplicationModel] cache receives the business layer; the authentication service receives the session factory, the DAO user class, and the token configuration. The keys in [app.extensions] are prefixed with [rdvmedecins.] to avoid conflicting with those in the extensions ([Flask-WTF] also stores its objects there). The [ApplicationModel] constructor reads the list of doctors and clients: if the database is inaccessible, the application stops here, at startup, rather than on the first request;
  • line 107: the translation service (example 22);
  • Lines 111–113: The first function, [before_request], retrieves the request language from the [rdvmedecins_language] cookie;
  • line 116: the second variable: the request user, and the access rule (see [auth/security.py]). The order matters: the login page to which an unauthorized visitor is redirected needs to know the language;
  • line 118: the variables common to all views (the context processor from Example 15, see [web/common_model.py]);
  • lines 121–122: the application’s seven blueprints;
  • line 123: the error handlers (see [web/errors.py]);
  • line 126: CSRF protection for all requests (POST, Example 21).

This file is equivalent to the startup class and configuration classes of a [Spring Boot] application, and to the [Program.cs] file of a [ASP.NET Core] application. The main difference: [Flask] does not create or inject anything on its own. Everything that exists in the application was created here explicitly, and you can trace each object from its creation to its use.

11.8. Entities

The [entities] folder contains one class per table: the entities that [SQLAlchemy] reads from and writes to the database (Example 28). Their attributes are named after the columns, unless otherwise noted.

11.8.1. entities/base.py

# =========================================================================
# entities/base.py
# -------------------------------------------------------------------------
# The parent class of all the entities, and the two columns common to
# all the tables: the identifier [id] and the version number [version].
#
# "Declarative" SQLAlchemy: each child class of Base describes a table
# (__tablename__), each Mapped[...] attribute a column. SQLAlchemy reads
# these declarations at startup to know which class goes into which
# table, which attribute into which column.
# =========================================================================
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column


class Base(DeclarativeBase):
    pass


# a MIXIN: a class without a table, whose columns are added to those
# of the classes that inherit from it (Doctor, Client, Slot, Appointment, User)
class WithIdAndVersion:

    # the primary key, generated by the database (AUTO_INCREMENT column):
    # SQLAlchemy reads its value back after each INSERT. An entity not yet
    # saved has a None id.
    id: Mapped[int] = mapped_column(primary_key=True)

    # the optimistic lock. Each UPDATE sent by SQLAlchemy carries the
    # condition "WHERE id = ... AND version = <value read>", and writes the
    # version increased by 1. If someone has modified the row in the meantime,
    # no row is updated: SQLAlchemy raises a StaleDataError
    # (see business/service.py).
    version: Mapped[int] = mapped_column()

    # @declared_attr: an attribute computed for EACH child class;
    # version_id_col: the column used as optimistic lock
    @declared_attr.directive
    def __mapper_args__(cls):
        return {"version_id_col": cls.version}
  • lines 15–16: the parent class of all entities (Example 28);
  • lines 21–39: the two columns common to all tables, [id] and [version], defined only once. [WithIdAndVersion] is a mixin: a class that has no table and is never created on its own, but from which child classes inherit columns. An entity thus inherits from two classes: [class User(WithIdAndVersion, Base)] (multiple inheritance from [Python]);
  • lines 37–39: the optimistic lock (example 28). [__mapper_args__] must refer to the [version] column of each child class, not that of the mixin: the [@declared_attr.directive] decorator turns this method into a computed attribute for each class that inherits from the mixin. [SQLAlchemy] calls it when it parses the child class, passing the child class as a parameter ([cls]).

11.8.2. entities/role.py

# =========================================================================
# entities/role.py
# -------------------------------------------------------------------------
# The three roles of a user (users.role column):
#   - ADMIN  : the administrator - appointments + management of doctors and clients;
#   - DOCTOR : a doctor - books and cancels appointments;
#   - USER   : a client (patient) - books for themselves, manages their account.
#
# An enumeration (Enum): no other value is possible. StrEnum: its
# values are also strings ("ADMIN"), stored as is in the database,
# as the SQL script does (see user.py).
# =========================================================================
from enum import StrEnum


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

An enumeration: a type whose possible values are fixed once and for all ([Role.ADMIN], [Role.DOCTOR], [Role.USER]). [StrEnum] (standard module [enum], [Python] 3.11): each value is also a string, equal to ["ADMIN"], etc. It can therefore be compared to a string, written to a JWT token, or included in a page without conversion.

11.8.3. entities/user.py

# =========================================================================
# entities/user.py
# -------------------------------------------------------------------------
# A user allowed to log in to the application (table [users]).
# Each doctor and each client has THEIR user account (column
# [user] of the tables [doctors] and [clients], see person.py).
#
# SQL uniqueness constraint: unq_users_login (a login belongs to only one
# account). It is created by the script database/dbrdvmedecins2.sql;
# the business layer recognizes it by its NAME (see business/service.py).
# =========================================================================
from sqlalchemy import Enum, String
from sqlalchemy.orm import Mapped, mapped_column

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


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

    login: Mapped[str] = mapped_column(String(30))
    # the HASHED password (bcrypt) - never in clear
    password: Mapped[str] = mapped_column(String(100))
    # the name displayed once logged in - for a doctor or a client:
    # "first name LAST NAME" (e.g.: "Marie PELISSIER")
    name: Mapped[str] = mapped_column(String(40))
    # ADMIN, DOCTOR or USER, stored in the database as text.
    # native_enum=False: a simple VARCHAR column (and not the MySQL ENUM type)
    role: Mapped[Role] = mapped_column(Enum(Role, native_enum=False, length=10))
  • line 19: the entity inherits the ID and version from the mixin, and from [Base];
  • line 24: the hashed password;
  • line 30: an attribute of type [Role]. The type [Enum] of [SQLAlchemy] converts the enumeration to text on write (["ADMIN"]), and the text to an enumeration on read. [native_enum=False]: The column is a simple [VARCHAR], as in the SQL script, and not the [ENUM] type specific to [MySQL].

11.8.4. entities/person.py

# =========================================================================
# entities/person.py
# -------------------------------------------------------------------------
# The doctors (table [doctors]) and the clients (table [clients]): a
# title, a last name, a first name - and the associated user account ([user] column).
#
# The two tables have exactly the same columns: they are described
# ONCE, in the Person mixin, from which Doctor and Client inherit. A
# relationship declared in a mixin must be declared by a @declared_attr method:
# SQLAlchemy calls it for each child class, which thus receives ITS relationship.
#
# Beware: [Client] here designates a PATIENT of the medical practice, not an
# HTTP client! It is the vocabulary of the original business domain, kept
# as is (table [clients]).
#
# SQL uniqueness constraints: unq_doctors_first_name_last_name, unq_clients_first_name_last_name
# (two doctors, or two clients, cannot have the same first and
# last names) - created by the SQL script, recognized by their name in the business layer.
# =========================================================================
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship

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


class Person(WithIdAndVersion):

    title: Mapped[str] = mapped_column(String(5))  # Melle, Mme, Mr
    last_name: Mapped[str] = mapped_column(String(20))
    first_name: Mapped[str] = mapped_column(String(20))

    # the foreign key to users(id), read as a simple number: we thus know
    # the id of the account without loading the User. "user" is the name of the
    # column (an SQL keyword, that SQLAlchemy itself surrounds with
    # backticks `user` if the DBMS requires it); user_id, that of the Python attribute
    @declared_attr
    def user_id(cls) -> Mapped[int]:
        return mapped_column("user", ForeignKey("users.id"))

    # -----------------------------------------------------------------------
    # The relationship to the user account: a doctor (a client) has ONE
    # account. relationship: the [user] attribute is a User object, and not a
    # number. SQLAlchemy only reads it from the database if asked: in the
    # query (joinedload options, see repositories/), or on the first access to
    # the attribute, as long as the session is open.
    # -----------------------------------------------------------------------
    @declared_attr
    def user(cls) -> Mapped[User]:
        return relationship()


class Doctor(Person, Base):
    __tablename__ = "doctors"


class Client(Person, Base):
    __tablename__ = "clients"
  • Line 27: Since the tables [doctors] and [clients] have exactly the same columns, they are described once in the mixin [Person], which itself inherits from the mixin [WithIdAndVersion];
  • lines 29–31: the regular columns, declared as in Example 28;
  • lines 9–39: the foreign key to the user account. In a mixin, a column that serves as a foreign key must be declared using the [@declared_attr] method: each child class thus receives its own column. [ForeignKey("users.id")] specifies the referenced column; the first parameter of [mapped_column] is the name of the column in the table ([user]), which differs from that of the [Python] attribute ([user_id]);
  • Lines 37–50: the relationship to the account: the attribute [user] is an object [User], not a number. [SQLAlchemy] infers the foreign key join. A relation is not a column: it is a means of obtaining the linked object. By default, [SQLAlchemy] only reads it from the database upon the first access to the attribute (lazy loading), which assumes that the session is still open;
  • lines 53–58: two entities, which differ only in their table.

Note the terminology: a [Client] here refers to a patient at the medical practice, not a client HTTP. This is the terminology from the original application, retained as-is.

11.8.5. entities/slot.py

# =========================================================================
# entities/slot.py
# -------------------------------------------------------------------------
# A time slot of a doctor (table [slots]): 8:00 - 8:20, etc.
# =========================================================================
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

from rdvmedecins.entities.base import WithIdAndVersion, Base
from rdvmedecins.entities.person import Doctor


class Slot(WithIdAndVersion, Base):
    __tablename__ = "slots"

    start_hour: Mapped[int]  # start hour of the slot
    start_minute: Mapped[int]  # start minute of the slot
    end_hour: Mapped[int]  # end hour of the slot
    end_minute: Mapped[int]  # end minute of the slot

    # several slots for the same doctor: the foreign key [doctor_id]
    # (a number), and the relationship [doctor] (the Doctor object)
    doctor_id: Mapped[int] = mapped_column(ForeignKey("doctors.id"))
    doctor: Mapped[Doctor] = relationship()

An appointment slot and its doctor: the foreign key [doctor_id] (a number) and the relationship [doctor] (the object). Without a mixin, the relationship is declared directly, as an attribute.

11.8.6. entities/appointment.py

# =========================================================================
# entities/appointment.py
# -------------------------------------------------------------------------
# An appointment (table [appointments]): a client, on a slot, on a given day.
#
# SQL uniqueness constraint: unq1_appointments (day, slot_id) - a slot is
# booked only once per day. Created by the SQL script, recognized by its
# name in the business layer.
# =========================================================================
from datetime import date

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

from rdvmedecins.entities.base import WithIdAndVersion, Base
from rdvmedecins.entities.slot import Slot
from rdvmedecins.entities.person import Client


class Appointment(WithIdAndVersion, Base):
    __tablename__ = "appointments"

    # date: a date WITHOUT time, which corresponds exactly to the SQL DATE type
    # of the [day] column
    day: Mapped[date]

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

    slot_id: Mapped[int] = mapped_column(ForeignKey("slots.id"))
    slot: Mapped[Slot] = relationship()
  • line 25: the type [date] of [Python], a date without a time, exactly matches the type SQL [DATE];
  • Lines 27–31: The two foreign keys and their relationships.

11.8.7. entities/__init__.py

# =========================================================================
# entities/ - the ENTITIES: one class per table of the dbrdvmedecins2 database.
# This file groups the imports: "from rdvmedecins.entities import
# Doctor" is enough elsewhere in the application.
# =========================================================================
from rdvmedecins.entities.base import Base
from rdvmedecins.entities.slot import Slot
from rdvmedecins.entities.person import Client, Doctor, Person
from rdvmedecins.entities.role import Role
from rdvmedecins.entities.appointment import Appointment
from rdvmedecins.entities.user import User

__all__ = ["Base", "Client", "Slot", "Doctor", "Person", "Role", "Appointment", "User"]

A package’s [__init__.py] file is executed when the package is imported. This file imports the entities from their respective modules; elsewhere, [from rdvmedecins.entities import Doctor] is sufficient, without needing to know in which module the class is defined. The list [__all__] declares the names made available by the package.

11.9. SQLAlchemy: Sessions and Loading Relationships

Example 28 covered the basics of [SQLAlchemy]. The case study uses two additional, interrelated aspects of it.

The lifetime of read objects. Each method in the business layer opens a session and a transaction, and closes them upon exit (Example 28). The entities it returns are then detached: they are no longer associated with any session. Thanks to the [expire_on_commit=False] setting, their regular attributes remain readable. However, a relationship that has not been loaded can no longer be loaded: this would require querying the database, and there is no longer a session. Accessing the attribute then throws a [DetachedInstanceError] exception. This is the problem that [Spring Boot] solves, along with [Hibernate], through the [open-in-view] setting (a session open for the duration of the request), which the [Spring MVC] version of the case study disabled for the same reason we did: a view should not trigger SQL requests. The rule is therefore: each method in the DAO layer explicitly loads the relations that the caller will need.

Loading options. [SQLAlchemy] offers several ways to load a relationship along with the entity:

  • [joinedload(Doctor.user)]: Adds a join ([LEFT OUTER JOIN users ...]) to the query, whose columns are used to construct the [User] objects. A single query returns the doctors and their accounts;
  • [contains_eager(Appointment.slot)]: when the query already contains a join ([select(Appointment).join(Appointment.slot)], written to filter on a column in the joined table), this option instructs [SQLAlchemy] to use the columns from that join to populate the relation, rather than adding a second one;
  • The options are chained together to load a relation from another relation: [contains_eager(Appointment.slot).joinedload(Slot.doctor)] loads the time slot for each appointment, then the doctor for each time slot.

These are the equivalents of [JOIN FETCH] for [JPA] and of [Include] for [Entity Framework Core].

11.10. The domain: the calendar

11.10.1. domain/schedule.py

# =========================================================================
# domain/schedule.py
# -------------------------------------------------------------------------
# Computed "business" objects, which do not correspond to any table:
#   - DoctorDaySlot: a time slot, and the appointment possibly
#     booked on this slot on a given day (None if the slot is free);
#   - DoctorDaySchedule: the complete schedule of a doctor for a given day,
#     the list of their slots, each one free or booked. Computed by
#     BusinessService.get_doctor_day_schedule, turned into an HTML table by
#     controllers/schedule.py.
#
# frozen=True: immutable objects, whose fields are set once
# and for all by the constructor - exactly what is needed for a
# computed object, that nobody modifies afterwards.
# =========================================================================
from dataclasses import dataclass
from datetime import date

from rdvmedecins.entities import Slot, Doctor, Appointment


@dataclass(frozen=True)
class DoctorDaySlot:
    slot: Slot
    appointment: Appointment | None


@dataclass(frozen=True)
class DoctorDaySchedule:
    doctor: Doctor
    day: date
    doctor_day_slots: list[DoctorDaySlot]

Two classes that do not correspond to any table: a doctor’s schedule for a single day, calculated by the business layer. [DoctorDaySlot] associates a time slot with the appointment booked for that slot on that day, or with [None] if it is available; [DoctorDaySchedule] combines the doctor, the day, and the list of the doctor’s time slots. These are immutable [dataclass] objects ([frozen=True]): a calculated object that no one modifies afterward.

11.11. The DAO layer

The [repositories] folder (the common English name for DAO classes, “repositories”) contains one class per entity. As in Example 28, each method receives the session opened by the business layer and contains no business logic.

11.11.1. repositories/person_repository.py

# =========================================================================
# repositories/person_repository.py
# -------------------------------------------------------------------------
# The DAO layer common to the doctors and the clients.
#
# Each method receives the SQLAlchemy SESSION opened by the business
# layer (see business/service.py): all the queries of the same operation
# thus use the same connection and the same TRANSACTION.
#
# The queries are written with the SQLAlchemy objects (select, where,
# order_by...), which apply to the CLASSES and their attributes; SQLAlchemy
# translates them into SQL. The values (first_name, last_name...) become PARAMETERS
# of the query: no string concatenation, hence no SQL injection.
#
# The DAOs of the doctors and of the clients would be identical: this
# GENERIC class writes them only once. [T: Person]: a type parameter,
# which is Doctor or Client; self.entity is the corresponding class.
# =========================================================================
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, joinedload

from rdvmedecins.entities import Person


class PersonRepository[T: Person]:

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

    # list sorted by last name then first name, user account included.
    # joinedload(user): ALSO brings back the account, through a join
    # (SELECT ... FROM doctors LEFT OUTER JOIN users ...). Without it, the
    # account would only be read on first access - impossible once the session
    # is closed: these lists are kept in memory by the cache
    # (web/application_model.py), long after the end of the request.
    def list(self, session: Session) -> list[T]:
        e = self.entity
        query = select(e).options(joinedload(e.user)).order_by(e.last_name, e.first_name)
        return list(session.scalars(query))

    # a person and their account; None if they do not exist
    def read(self, session: Session, id: int) -> T | None:
        return session.get(self.entity, id, options=[joinedload(self.entity.user)])

    # the person whose user account is [user_id]
    def read_by_user(self, session: Session, user_id: int) -> T | None:
        e = self.entity
        query = select(e).options(joinedload(e.user)).where(e.user_id == user_id)
        return session.scalars(query).first()

    # the person who has these first and last names, if there is one. The comparison
    # is made by MySQL, hence with the collation of the database
    # (utf8mb4_unicode_ci: regardless of case and accents) -
    # exactly the same rule as that of the SQL uniqueness constraint.
    def namesake(self, session: Session, first_name: str, last_name: str) -> T | None:
        e = self.entity
        return session.scalars(select(e).where(e.first_name == first_name, e.last_name == last_name).limit(1)).first()

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

    # a DELETE sent directly to the database, without reading the person first
    def remove(self, session: Session, id: int):
        session.execute(delete(self.entity).where(self.entity.id == id))
  • Line 25: a generic class, written using the [Python] 3.12 syntax (PEP 695). [T] is a type parameter: the class will be used as [PersonRepository[Doctor]] or [PersonRepository[Client]]. [T: Person] restricts [T] to classes that inherit from [Person]. Types are not checked by [Python] at runtime (example 06): they document the code and allow the editor to know that [list()] returns a list of [Doctor] for the DAO of doctors. Prior to [Python] 3.12, one would have written [T = TypeVar("T", bound=Person)] followed by [class PersonRepository(Generic[T])];
  • lines 27–28: the type parameter does not exist at runtime: the actual class ([Doctor] or [Client]) is therefore passed to the constructor and stored in [self.entity]. [type[T]] is the type of a class (not an object);
  • lines 36–39: the sorted list, with the count for each person, loaded via a join ([joinedload]). This is essential here: these lists are kept in memory by the cache [ApplicationModel], well after the session has ended;
  • lines 42–43: the primary key read also accepts loading options;
  • lines 46–49: the person associated with an account—this is how we identify the customer who logged in;
  • lines 55–57: searching for a namesake. The comparison is performed by [MySQL], using the database’s collation: this is exactly the rule for the uniqueness constraint;
  • lines 59–61: the addition, without [flush]: the business layer decides when the SQL commands are sent;
  • lines 64–65: a [DELETE] sent directly to the database.

11.11.2. repositories/doctor_repository.py

# =========================================================================
# repositories/doctor_repository.py
# -------------------------------------------------------------------------
# The DAO of the doctors: everything is inherited from PersonRepository.
# =========================================================================
from rdvmedecins.entities import Doctor
from rdvmedecins.repositories.person_repository import PersonRepository


class DoctorRepository(PersonRepository[Doctor]):

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

The DAO class for doctors inherits from [PersonRepository[Doctor]] and passes the [Doctor] class to its parent class constructor; everything else is inherited.

11.11.3. repositories/client_repository.py

# =========================================================================
# repositories/client_repository.py
# -------------------------------------------------------------------------
# The DAO of the clients: that of the persons, plus a locking method.
# =========================================================================
from sqlalchemy import select
from sqlalchemy.orm import Session

from rdvmedecins.entities import Client
from rdvmedecins.repositories.person_repository import PersonRepository


class ClientRepository(PersonRepository[Client]):

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

    # -----------------------------------------------------------------------
    # puts a LOCK on the row of a client until the end of the
    # current transaction (SELECT ... FOR UPDATE), and returns its id (None
    # if the client does not exist). A second transaction that asks for the same
    # lock WAITS until the first one is finished.
    # Used for booking an appointment (see BusinessService.add_appointment):
    # two simultaneous bookings for the same patient are thus processed
    # one AFTER the other, and the second sees the appointment created by the first.
    # with_for_update: adds "FOR UPDATE" to the query.
    # -----------------------------------------------------------------------
    def lock(self, session: Session, id: int) -> int | None:
        return session.scalars(select(Client.id).where(Client.id == id).with_for_update()).first()
  • Lines 28–29: one more method, which places a lock on a client’s record: [with_for_update()] adds [FOR UPDATE] to the query ([SELECT clients.id FROM clients WHERE clients.id = ? FOR UPDATE]). Until the transaction ends, any other transaction requesting the same lock waits. The business layer uses this for scheduling appointments: two simultaneous appointments for the same patient are thus processed one after the other, and the second appointment sees the appointment created by the first (rule: only one upcoming appointment per patient). This pessimistic lock complements the optimistic lock: the optimistic lock detects a concurrent modification of a row, while the pessimistic lock prevents two transactions from making a decision based on the same data at the same time.

11.11.4. repositories/slot_repository.py

# =========================================================================
# repositories/slot_repository.py
# -------------------------------------------------------------------------
# DAO layer of the [Slot] entity.
# =========================================================================
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, joinedload

from rdvmedecins.entities import Slot


class SlotRepository:

    # a slot and its doctor
    def read(self, session: Session, id: int) -> Slot | None:
        return session.get(Slot, id, options=[joinedload(Slot.doctor)])

    # the slots of a doctor, in chronological order
    def list_by_doctor(self, session: Session, doctor_id: int) -> list[Slot]:
        query = (select(Slot).where(Slot.doctor_id == doctor_id)
                 .order_by(Slot.start_hour, Slot.start_minute))
        return list(session.scalars(query))

    # deletes all the slots of a doctor (deletion of a doctor):
    #   DELETE FROM slots WHERE doctor_id = ?
    def delete_by_doctor(self, session: Session, doctor_id: int):
        session.execute(delete(Slot).where(Slot.doctor_id == doctor_id))

An appointment slot (with a doctor), a doctor’s appointment slots in chronological order, and the deletion of a doctor’s appointment slots (when the doctor is removed).

11.11.5. repositories/appointment_repository.py

# =========================================================================
# repositories/appointment_repository.py
# -------------------------------------------------------------------------
# DAO layer of the [Appointment] entity.
#
# join(Appointment.slot): an SQL join, written from the RELATIONSHIP between
# the two classes (SQLAlchemy knows the foreign key). contains_eager:
# the columns of the joined table ALSO serve to fill the relationship
# (appointment.slot), without a new query.
# =========================================================================
from datetime import date

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

from rdvmedecins.entities import Slot, Appointment


class AppointmentRepository:

    # an appointment, its client and its slot
    def read(self, session: Session, id: int) -> Appointment | None:
        return session.get(Appointment, id, options=[joinedload(Appointment.client), joinedload(Appointment.slot)])

    # the appointments of a doctor on a given day, client and slot included.
    # The [appointments] table does not reference the doctor directly: we go through the slot.
    def doctor_day_appointments(self, session: Session, doctor_id: int, day: date) -> list[Appointment]:
        query = (select(Appointment).join(Appointment.slot)
                 .options(contains_eager(Appointment.slot), joinedload(Appointment.client))
                 .where(Slot.doctor_id == doctor_id, Appointment.day == day))
        return list(session.scalars(query))

    # the appointments of a client from the day [since] (included), in
    # chronological order, with the slot AND the doctor of this slot
    def client_upcoming_appointments(self, session: Session, client_id: int, since: date) -> list[Appointment]:
        query = (select(Appointment).join(Appointment.slot)
                 .options(contains_eager(Appointment.slot).joinedload(Slot.doctor))
                 .where(Appointment.client_id == client_id, Appointment.day >= since)
                 .order_by(Appointment.day, Slot.start_hour, Slot.start_minute))
        return list(session.scalars(query))

    # number of appointments of a client from the day [since] (included)
    def count_client_upcoming(self, session: Session, client_id: int, since: date) -> int:
        query = select(func.count(Appointment.id)).where(Appointment.client_id == client_id, Appointment.day >= since)
        return session.scalar(query)

    # number of appointments (past or future) of a doctor
    def count_by_doctor(self, session: Session, doctor_id: int) -> int:
        query = select(func.count(Appointment.id)).join(Appointment.slot).where(Slot.doctor_id == doctor_id)
        return session.scalar(query)

    def add(self, session: Session, appointment: Appointment) -> Appointment:
        session.add(appointment)
        return appointment

    # deletes an appointment: DELETE FROM appointments WHERE id = ?
    def remove(self, session: Session, id: int):
        session.execute(delete(Appointment).where(Appointment.id == id))

    # deletes ALL the appointments (past and future) of a client
    def delete_by_client(self, session: Session, client_id: int):
        session.execute(delete(Appointment).where(Appointment.client_id == client_id))
  • Lines 27–31: a doctor’s appointments on a given day. The table [appointments] does not directly reference the doctor: it goes through the appointment slot. [join(Appointment.slot)] writes the join based on the relationship; the filter applies to a column in the joined table ([Slot.doctor_id]). [contains_eager] reuses the join to populate [appointment.slot]; [joinedload] loads the client: the schedule is built in a single query, SQL;
  • lines 35–39: a client’s upcoming appointments, with their time slots and the physician assigned to each slot;
  • lines 43–45: [func.count] generates the functions SQL, [COUNT], and [session.scalar] (in the singular); [session.scalar] returns the first column of the first row: the count;
  • lines 48–50: the number of appointments for a doctor, to determine whether the doctor can be deleted;
  • lines 61–62: the deletion of a client’s appointments (when the client is deleted).

11.11.6. repositories/user_repository.py

# =========================================================================
# repositories/user_repository.py
# -------------------------------------------------------------------------
# DAO layer of the [User] entity. Creating (or deleting) a doctor or a
# client also creates (or deletes) their account - see business/service.py.
# =========================================================================
from sqlalchemy import delete, select
from sqlalchemy.orm import Session

from rdvmedecins.entities import User


class UserRepository:

    # None if no user has this login
    def read_by_login(self, session: Session, login: str) -> User | None:
        return session.scalars(select(User).where(User.login == login)).first()

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

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

Reading a user by their login (for login), adding and deleting an account.

11.12. The business layer

The [business] folder contains the business rules, and only those rules. It does not recognize [Flask], HTML, or the HTTP query: it could be used as-is in a command-line application or in a API.

11.12.1. business/dates.py

# =========================================================================
# business/dates.py
# -------------------------------------------------------------------------
# Today's date. It is used by BUSINESS RULES (no booking
# in the past, a single UPCOMING appointment per patient): it therefore lives
# in the business layer. The web layer reuses it.
# =========================================================================
from datetime import date


# today's date, in the time zone of the server
def today() -> date:
    return date.today()

The current date is used for two business rules (no appointments in the past, only one upcoming appointment per patient): it belongs in the business layer. Isolating it in a function has another advantage: an automated test could replace it with a fixed date.

11.12.2. business/exception.py

# =========================================================================
# business/exception.py
# -------------------------------------------------------------------------
# The exception raised by the business layer when a BUSINESS RULE is
# violated (login already taken, doctor who still has appointments, slot
# already booked...). It does not carry a text, but a translation
# KEY (e.g.: "ERRORS.LOGIN_TAKEN"): it is the web layer that will
# translate it into French or English when displaying the page
# (see i18n/fr.json, i18n/en.json).
#
# A BusinessException is not an anomaly: it is a normal response
# ("this login is already taken"), displayed to the user. Any other
# exception is a real anomaly, which ends up on the 500 error page.
# =========================================================================


class BusinessException(Exception):

    def __init__(self, key: str, params: dict[str, str] | None = None):
        super().__init__(key)
        # translation key of the message
        self.key = key
        # possible parameters of the message ({minutes}...)
        self.params = params or {}

The business layer exception does not carry text, as in Example 28, but rather a translation key ([ERRORS.LOGIN_TAKEN]) and any parameters: the business layer does not know in which language the user is viewing the page. The web layer will translate the key. [super().__init__(key)] passes the key to the constructor of the [Exception] class: this is the value that is displayed if the exception is logged.

A [BusinessException] is not an error: it is a normal response (“this username is already taken”), which is displayed to the user. Any other exception is a true error, which results in a 500 error page.

11.12.3. business/person_data.py

# =========================================================================
# business/person_data.py
# -------------------------------------------------------------------------
# The data typed in the "doctor" or "client" form, as
# the web layer passes them to the business layer.
# =========================================================================
from dataclasses import dataclass


@dataclass(frozen=True)
class PersonData:
    title: str  # Mr, Mme, Melle
    last_name: str
    first_name: str
    # creation: optional (None), defaults to the last name in lower case
    # edit: optional, the login is unchanged if it is missing
    login: str | None = None
    # creation: optional, defaults to the login
    # edit: optional, the password is unchanged if it is missing
    password: str | None = None
    # edit: the version of the record at the time the form was
    # displayed (optimistic lock, see entities/base.py)
    version: int | None = None

Data from a “doctor” or “client” form, transmitted by the web layer (example 28). The login, password, and version are optional: the comments explain what their absence means during creation and modification.

11.12.4. business/business.py

# =========================================================================
# business/business.py
# -------------------------------------------------------------------------
# The CONTRACT of the business layer: reads, schedule, appointments, management
# of the doctors and of the clients.
#
# A Protocol describes the methods an object must have, without saying how
# they are written (the equivalent of an interface). BusinessService (the
# business layer) and ApplicationModel (the cache of the web layer) both
# comply with it. Python checks nothing at runtime: it is
# the analysis tools (that of VS Code, for example) that report a mismatch.
#
# T | None: a result that can be missing (a doctor who does not exist...).
# =========================================================================
from datetime import date
from typing import Protocol

from rdvmedecins.domain.schedule import DoctorDaySchedule
from rdvmedecins.entities import Client, Slot, Doctor, Appointment
from rdvmedecins.business.person_data import PersonData


class Business(Protocol):

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

    def get_all_doctors(self) -> list[Doctor]: ...

    def get_all_slots(self, doctor_id: int) -> list[Slot]: ...

    def get_doctor_day_appointments(self, doctor_id: int, day: date) -> list[Appointment]: ...

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

    def get_doctor_by_id(self, id: int) -> Doctor | None: ...

    def get_appointment_by_id(self, id: int) -> Appointment | None: ...

    def get_slot_by_id(self, id: int) -> Slot | None: ...

    def get_doctor_day_schedule(self, doctor_id: int, day: date) -> DoctorDaySchedule | None: ...

    # the client (patient) whose user account is [user_id]
    def get_client_by_user_id(self, user_id: int) -> Client | None: ...

    # the appointments of a client, of today or future, with slot and doctor
    def get_client_upcoming_appointments(self, client_id: int) -> list[Appointment]: ...

    # ------------------------------------------------ appointments (ADMIN, DOCTOR, USER)
    # raises a BusinessException if:
    #  - the day is past ("ERRORS.PAST_DATE");
    #  - the patient already has an upcoming appointment ("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT");
    #  - the slot is already taken on that day ("ERRORS.SLOT_ALREADY_BOOKED").
    def add_appointment(self, day: date, slot: Slot, client: Client) -> Appointment: ...

    def delete_appointment(self, appointment: Appointment) -> None: ...

    # ------------------------------------------------ doctors (ADMIN)
    # creates the doctor AND their user account (DOCTOR role)
    def add_doctor(self, data: PersonData) -> Doctor: ...

    # edits the doctor AND their user account
    def edit_doctor(self, id: int, data: PersonData) -> Doctor: ...

    # deletes the doctor, their slots AND their account - refused if they have appointments
    def delete_doctor(self, id: int) -> None: ...

    # ------------------------------------------------ clients
    # (the ADMIN; add_client is also used for the registration of a patient,
    # edit_client and delete_client by the patient for their own account)
    # creates the client AND their user account (USER role)
    def add_client(self, data: PersonData) -> Client: ...

    def edit_client(self, id: int, data: PersonData) -> Client: ...

    # deletes the client, their account AND all their appointments (past and future)
    def delete_client(self, id: int) -> None: ...
  • Line 23: The business layer contract: a list of its methods, along with their parameters and return values. A [Protocol] (standard module [typing]) is the [Python] equivalent of an interface: it describes the methods an object must have, without specifying how they are implemented. A class conforms to it as long as it has these methods, without having to declare it (this is known as structural typing, or *duck typing*: “if it walks like a duck…”). Two classes conform to it: [BusinessService], the business layer, and [ApplicationModel], the web layer cache (discussed later). [Python] does not check anything at runtime: it is the analysis tools, such as the one in [Visual Studio Code], that flag a discrepancy;
  • line 26: the body of a protocol method is reduced to [...] (the [Ellipsis] object of [Python], which does nothing);
  • lines 55–78: the comments document the business logic and error codes for each method.

11.12.5. business/service.py

The business layer implementation. This is the most important file in the application.

# =========================================================================
# business/service.py
# -------------------------------------------------------------------------
# Implementation of the business layer.
#
# The management of the doctors and of the clients follows three business rules:
#
#   1. a person (doctor or client) and their user account ALWAYS go
#      together: they are created, edited and deleted together, in
#      the same TRANSACTION - either everything succeeds, or nothing is written;
#   2. the login is unique in the whole application, and two doctors (or
#      two clients) cannot have the same first and last names;
#   3. deletion:
#      - a DOCTOR who has appointments cannot be deleted (their
#        patients would lose their appointment without being warned): they must
#        first be cancelled. Their time slots disappear with them;
#      - a CLIENT, on the other hand, disappears with ALL their appointments (past and
#        future) - whether the deletion comes from the ADMIN or from the patient
#        themselves.
#
# And two rules for booking appointments, whatever the role of
# the one who books (ADMIN, DOCTOR or the patient themselves):
#   4. no appointment in the past;
#   5. a patient has only ONE upcoming appointment at a time.
#
# Doctors and clients follow exactly the same rules: the common code
# is written ONCE (private methods _xxx_person), and parameterized by a
# small "descriptor" that says what type of person we are dealing with.
#
# The TRANSACTIONS are opened by the _transaction method (at the end of the
# file), which also translates into BusinessException the errors that the database
# reports when the SQL statements are sent to it.
# =========================================================================
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.passwords import hash_password
from rdvmedecins.domain.schedule import DoctorDaySchedule, DoctorDaySlot
from rdvmedecins.entities import Client, Slot, Doctor, Person, Role, Appointment, User
from rdvmedecins.business import dates
from rdvmedecins.business.person_data import PersonData
from rdvmedecins.business.exception import BusinessException
from rdvmedecins.repositories.client_repository import ClientRepository
from rdvmedecins.repositories.slot_repository import SlotRepository
from rdvmedecins.repositories.doctor_repository import DoctorRepository
from rdvmedecins.repositories.person_repository import PersonRepository
from rdvmedecins.repositories.appointment_repository import AppointmentRepository
from rdvmedecins.repositories.user_repository import UserRepository


# everything that distinguishes a doctor from a client for the business rules
@dataclass(frozen=True)
class Descriptor:
    dao: PersonRepository
    factory: type[Person]  # Doctor or Client
    role: Role  # DOCTOR or USER
    not_found_key: str  # message if the person does not exist
    already_exists_key: str  # message if the same first and last names already exist
    # optional (None): if present, the deletion is REFUSED as long as the
    # person has appointments (count_appointments > 0), with the message has_appointments_key
    count_appointments: Callable[[Session, int], int] | None
    has_appointments_key: str | None
    # optional: what must be deleted BEFORE the person (the rows that
    # reference it by a foreign key)
    before_delete: Callable[[Session, int], None] | None


# -----------------------------------------------------------------------
# Database errors -> BusinessException
#
# When a UNIQUE constraint refuses a write, SQLAlchemy raises an
# IntegrityError, whose [orig] attribute is the original error, that of the
# PyMySQL driver. Its message quotes the NAME of the constraint:
#   Duplicate entry 'Marie-PELISSIER' for key 'doctors.unq_doctors_first_name_last_name'
# (MySQL 8 prefixes the name with that of the table, MariaDB does not). This is
# why the SQL script NAMES all its constraints.
# -----------------------------------------------------------------------
CONSTRAINT_MESSAGES = {
    "unq_users_login": "ERRORS.LOGIN_TAKEN",
    "unq_doctors_first_name_last_name": "ERRORS.DOCTOR_ALREADY_EXISTS",
    "unq_clients_first_name_last_name": "ERRORS.PATIENT_ALREADY_EXISTS",
    "unq1_appointments": "ERRORS.SLOT_ALREADY_BOOKED",
}

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


class BusinessService:

    # all the dependencies are provided by the application factory
    # (see rdvmedecins/__init__.py): this is dependency injection "by
    # hand". sessions: the factory of the SQLAlchemy sessions.
    def __init__(self, sessions: sessionmaker, doctors: DoctorRepository,
                 clients: ClientRepository, slots: SlotRepository, appointments: AppointmentRepository,
                 users: UserRepository):
        self._sessions = sessions
        self._doctors = doctors
        self._clients = clients
        self._slots = slots
        self._appointments = appointments
        self._users = users

        self._doctor = Descriptor(
            dao=doctors, factory=Doctor, role=Role.DOCTOR,
            not_found_key="ERRORS.DOCTOR_NOT_FOUND",
            already_exists_key="ERRORS.DOCTOR_ALREADY_EXISTS",
            count_appointments=appointments.count_by_doctor,
            has_appointments_key="ERRORS.DOCTOR_HAS_APPOINTMENTS",
            # a doctor without appointments may still have time slots:
            # they make no sense without them, they are deleted with them (otherwise
            # the foreign key slots.doctor_id would forbid the deletion)
            before_delete=slots.delete_by_doctor)

        self._client = Descriptor(
            dao=clients, factory=Client, role=Role.USER,
            not_found_key="ERRORS.PATIENT_NOT_FOUND",
            already_exists_key="ERRORS.PATIENT_ALREADY_EXISTS",
            # no count_appointments: the deletion of a client is never refused;
            # their appointments (past and future) are deleted with them (otherwise
            # the foreign key appointments.client_id would forbid the deletion)
            count_appointments=None, has_appointments_key=None,
            before_delete=appointments.delete_by_client)

    # =======================================================================
    # reads: a session, for the time of the read; the objects read remain
    # usable afterwards (expire_on_commit=False, see rdvmedecins/__init__.py)
    # =======================================================================

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

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

    def get_all_doctors(self) -> list[Doctor]:
        return self._read(self._doctors.list)

    def get_all_slots(self, doctor_id: int) -> list[Slot]:
        return self._read(lambda s: self._slots.list_by_doctor(s, doctor_id))

    def get_doctor_day_appointments(self, doctor_id: int, day: date) -> list[Appointment]:
        return self._read(lambda s: self._appointments.doctor_day_appointments(s, doctor_id, day))

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

    def get_doctor_by_id(self, id: int) -> Doctor | None:
        return self._read(lambda s: self._doctors.read(s, id))

    def get_appointment_by_id(self, id: int) -> Appointment | None:
        return self._read(lambda s: self._appointments.read(s, id))

    def get_slot_by_id(self, id: int) -> Slot | None:
        return self._read(lambda s: self._slots.read(s, id))

    def get_client_by_user_id(self, user_id: int) -> Client | None:
        return self._read(lambda s: self._clients.read_by_user(s, user_id))

    def get_client_upcoming_appointments(self, client_id: int) -> list[Appointment]:
        return self._read(lambda s: self._appointments.client_upcoming_appointments(s, client_id, dates.today()))

    # the schedule of a doctor on a given day: their slots, and for each one the
    # appointment booked on that day (None if it is free)
    def get_doctor_day_schedule(self, doctor_id: int, day: date) -> DoctorDaySchedule | None:
        doctor = self.get_doctor_by_id(doctor_id)
        if doctor is None:
            return None
        time_slots = self.get_all_slots(doctor_id)
        # slot no. -> appointment booked on this slot (a dictionary "comprehension")
        bookings = {a.slot_id: a for a in self.get_doctor_day_appointments(doctor_id, day)}
        doctor_day_slots = [DoctorDaySlot(slot, bookings.get(slot.id))
                            for slot in time_slots]
        return DoctorDaySchedule(doctor, day, doctor_day_slots)

    # =======================================================================
    # appointments
    # =======================================================================

    # -----------------------------------------------------------------------
    # booking of an appointment, in a transaction:
    #   1. no appointment in the past;
    #   2. the row of the patient is LOCKED (SELECT ... FOR UPDATE): if the
    #      same patient books two slots at the same moment (two tabs,
    #      double click...), the second transaction waits for the end of the
    #      first one - and then sees the appointment it has just created;
    #   3. a single upcoming appointment per patient;
    #   4. finally the insertion. If the slot has just been taken by ANOTHER
    #      patient, it is the SQL constraint unq1_appointments (day, slot_id) that
    #      refuses the insertion - translated into BusinessException by _transaction.
    # -----------------------------------------------------------------------
    def add_appointment(self, day: date, slot: Slot, client: Client) -> Appointment:
        def work(session: Session) -> Appointment:
            today = dates.today()
            if day < today:
                raise BusinessException("ERRORS.PAST_DATE")
            patient_id = self._clients.lock(session, client.id)
            if patient_id is None:
                raise BusinessException("ERRORS.PATIENT_NOT_FOUND")
            if self._appointments.count_client_upcoming(session, patient_id, today) > 0:
                raise BusinessException("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT")
            # knowing the ids of the slot and of the patient is enough to write
            # the foreign keys: no need to read these objects again in the session
            return self._appointments.add(session, Appointment(day=day, slot_id=slot.id,
                                                               client_id=patient_id))

        return self._transaction(work)

    def delete_appointment(self, appointment: Appointment) -> None:
        self._transaction(lambda session: self._appointments.remove(session, appointment.id))

    # =======================================================================
    # doctors and clients - the public methods delegate to the common
    # private methods, with the right descriptor
    # =======================================================================

    def add_doctor(self, data: PersonData) -> Doctor:
        return self._add_person(self._doctor, data)

    def edit_doctor(self, id: int, data: PersonData) -> Doctor:
        return self._edit_person(self._doctor, id, data)

    def delete_doctor(self, id: int) -> None:
        self._delete_person(self._doctor, id)

    def add_client(self, data: PersonData) -> Client:
        return self._add_person(self._client, data)

    def edit_client(self, id: int, data: PersonData) -> Client:
        return self._edit_person(self._client, id, data)

    # the client, their account AND all their appointments (ADMIN, or the patient
    # themselves from "Manage my account": same method, same rule)
    def delete_client(self, id: int) -> None:
        self._delete_person(self._client, id)

    # -----------------------------------------------------------------------
    # creation: the user account AND the person, in ONE transaction.
    # -----------------------------------------------------------------------
    def _add_person(self, d: Descriptor, data: PersonData):
        last_name = data.last_name.strip().upper()
        first_name = data.first_name.strip()
        # the rules of the dbrdvmedecins2 database: login = last name in lower case,
        # password = login - unless the ADMIN has typed other ones
        login = (data.login or last_name).lower()
        password = data.password or login

        def work(session: Session):
            self._check_person_unique(session, d, first_name, last_name, None)
            self._check_login_free(session, login)
            user = User(login=login, password=hash_password(password), name=f"{first_name} {last_name}", role=d.role)
            person = d.factory(title=data.title, last_name=last_name, first_name=first_name, user=user)
            # the person references their account: SQLAlchemy first inserts the
            # account (to know its id), then the person
            d.dao.add(session, person)
            # flush: the INSERTs leave right away (and not at COMMIT); a
            # violated constraint therefore raises its error HERE
            session.flush()
            return person

        return self._transaction(work)

    # -----------------------------------------------------------------------
    # edit: the person AND their account (the displayed name "first name LAST NAME"
    # must follow; the login and the password only change if they have been
    # typed).
    # -----------------------------------------------------------------------
    def _edit_person(self, d: Descriptor, id: int, data: PersonData):
        def work(session: Session):
            person = d.dao.read(session, id)
            if person is None:
                raise BusinessException(d.not_found_key)

            # optimistic lock: if the version in the database is no longer that of the
            # form, someone has edited this record in the meantime - we refuse
            # rather than silently overwrite their changes.
            # (If the concurrent edit happens AFTER this read, it is
            # the "WHERE version = ..." clause of the UPDATE that detects it.)
            if data.version is not None and data.version != person.version:
                raise BusinessException("ERRORS.CONCURRENT_UPDATE")

            last_name = data.last_name.strip().upper()
            first_name = data.first_name.strip()
            self._check_person_unique(session, d, first_name, last_name, id)

            # we merely modify the objects: they are "tracked" by the
            # session, which will itself send the necessary UPDATEs
            person.title = data.title
            person.last_name = last_name
            person.first_name = first_name

            user = person.user
            user.name = f"{first_name} {last_name}"
            if data.login and data.login.lower() != user.login:
                login = data.login.lower()
                self._check_login_free(session, login)
                user.login = login
            if data.password:
                user.password = hash_password(data.password)
            session.flush()
            return person

        return self._transaction(work)

    # -----------------------------------------------------------------------
    # deletion, in ONE transaction, in the order imposed by the foreign
    # keys:
    #   1. possible refusal if appointments remain (doctor);
    #   2. what references the person (slots of a doctor, appointments
    #      of a client);
    #   3. the person (it references its account);
    #   4. its account.
    # -----------------------------------------------------------------------
    def _delete_person(self, d: Descriptor, id: int) -> None:
        def work(session: Session):
            person = d.dao.read(session, id)
            if person is None:
                raise BusinessException(d.not_found_key)
            if d.count_appointments is not None and d.count_appointments(session, id) > 0:
                raise BusinessException(d.has_appointments_key)
            if d.before_delete is not None:
                d.before_delete(session, id)
            d.dao.remove(session, id)
            self._users.remove(session, person.user_id)

        self._transaction(work)

    # refuses first and last names already used by ANOTHER person of the same type
    # (current_id: the person being edited, who obviously has the
    # right to keep their own first and last names; None for a creation)
    @staticmethod
    def _check_person_unique(session: Session, d: Descriptor, first_name: str, last_name: str,
                             current_id: int | None):
        namesake = d.dao.namesake(session, first_name, last_name)
        if namesake is not None and namesake.id != current_id:
            raise BusinessException(d.already_exists_key)

    def _check_login_free(self, session: Session, login: str):
        if self._users.read_by_login(session, login) is not None:
            raise BusinessException("ERRORS.LOGIN_TAKEN")

    # -----------------------------------------------------------------------
    # runs [work] in a transaction:
    #   sessions.begin() -> a session and its transaction (START TRANSACTION);
    #   on leaving the with block: COMMIT if all went well; if an
    #   exception leaves it (BusinessException included), ROLLBACK: nothing is written.
    #
    # The "free login" and "unique first + last names" checks are made BEFORE
    # the write, to give a clear message. But two ADMINs could
    # save the same login (or the same doctor) at the same moment: in this
    # rare case, it is an SQL uniqueness constraint that refuses the second
    # write. The database errors are therefore intercepted AROUND the
    # transaction, to translate them into the same BusinessException:
    #   - StaleDataError: the UPDATE "WHERE id = ... AND version = ..." has
    #     modified no row (optimistic lock);
    #   - IntegrityError: a constraint of the database has refused the write.
    # Any other error is a real anomaly: it is let through.
    # -----------------------------------------------------------------------
    def _transaction[R](self, work: Callable[[Session], R]) -> R:
        try:
            with self._sessions.begin() as session:
                return work(session)
        except StaleDataError:
            raise BusinessException("ERRORS.CONCURRENT_UPDATE")
        except IntegrityError as e:
            match = CONSTRAINT.search(str(e.orig))
            if match and match.group(1) in CONSTRAINT_MESSAGES:
                raise BusinessException(CONSTRAINT_MESSAGES[match.group(1)])
            raise

11.12.5.1. The descriptors

Doctors and clients follow exactly the same business rules, with a few minor differences: a different DAO, a different role for their account, different error messages, and a different deletion rule. Rather than writing each method twice, the common code is written once (the methods [_add_person], [_edit_person], [_delete_person]) and configured by a descriptor that specifies which type of person is being handled:

  • lines 59–71: the descriptor is an immutable [dataclass]. Three of its fields are functions: [Callable[[Session, int], int]] is the type of a function that takes a session and an integer, and returns an integer. [count_appointments] counts a person’s appointments (to prevent the deletion of a doctor who has any); [before_delete] deletes references to the person (a doctor’s time slots, a client’s appointments);
  • lines 109–118: the doctors’ descriptor. [appointments.count_by_doctor] is a method associated with the object [appointments] (Example 22): a function that will receive the session and the ID;
  • lines 120–128: the one for clients: no deletion denial ([count_appointments=None]), but the prior deletion of all their appointments.

11.12.5.2. Read operations and the calendar

  • lines 135–137: the reads are handled by a small generic method: [R] is the result type, which is the same as that of the function [work]. The session is opened by [self._sessions()] (without [begin]) while the function is executing, then closed by the block [with]. [SQLAlchemy] still starts a transaction on the first request ([BEGIN (implicit)] in the console); since nothing was written, closing the session cancels it ([ROLLBACK]): these are the lines the console displays when the application starts;
  • lines 139–167: each read instructs [_read] to call the method DAO. When the DAO method has parameters other than the session, an anonymous function ([lambda s: ...]) provides them;
  • Lines 171–180: A doctor’s schedule for a single day. The day’s appointments are stored in a dictionary ([{key: value for ...}]) that associates each time slot number with its corresponding appointment; Each time slot is then associated with its appointment, or with [None] ([bookings.get(slot.id)]). Three reads, across three sessions: any inconsistency between them (an appointment scheduled between two reads) would be inconsequential, since the schedule is merely a display.

11.12.5.3. Scheduling an appointment

  • Lines 198–213: The method defines an internal function, [work], which contains the operations to be performed in the transaction, and passes it to the method [_transaction] (see below). The rules, in order: - No appointments in the past ([day < today]: the dates in [Python] are compared using the usual operators); - the patient’s record is locked ([lock], see [ClientRepository]): if the same patient books two time slots at the same time (two tabs, a double-click), the second transaction waits for the first one to complete and then sees the appointment it created; - only one upcoming appointment per patient; - finally, the insertion. The appointment is created using the slot ID and the patient ID: it is sufficient to know the values of the foreign keys; there is no need to re-read the objects. The named parameters of the constructor ([Appointment(day=..., slot_id=...)]) are provided by [SQLAlchemy] to any entity that does not have its own constructor.
  • There is one remaining scenario: the time slot has just been taken by another patient between the time the calendar is displayed and the time the reservation is made. In this case, the database constraint [unq1_appointments] prevents the insertion during the [COMMIT] operation. The [_transaction] method translates this error into [BusinessException];
  • lines 215–216: canceling an appointment. Permission checks (a patient can only cancel their own appointments) are performed by the web layer, which knows the user.

11.12.5.4. Management of doctors and clients

  • lines 223–241: public methods delegate to common methods, using the correct descriptor;
  • lines 246–267: creation. The last name is capitalized, and spaces are removed from the first name; the default username is the last name in lowercase, and the password is the username (the rule for demo accounts in the SQL script). In the transaction: validations (no homonyms, free login), followed by the creation of the account and the person. The person receives their account through their relationship ([user=user]): [SQLAlchemy] knows it must first insert the account to obtain its ID, then the person. [session.flush()] sends [INSERT] immediately: a violated constraint triggers an error in the transaction, and the returned person has its ID;
  • Lines 274–309: the update. The form version is compared to the database version (optimistic lock, example 28); then we simply update the retrieved objects: they are tracked by the session, which will itself send the necessary [UPDATE] records. The displayed account name follows the first name and last name; the login and password change only if they have been entered;
  • lines 320–332: deletion, in the order dictated by the foreign keys: possible rejection (a doctor who has appointments), deletion of the references to the person, the person themselves, and then their account. In the reverse order, the database would reject each [DELETE]. All of this occurs in a single transaction: if any one step fails, none of the steps are executed;
  • lines 337–342: a homonym is rejected, unless it is the person currently being edited, who has the right to keep their own first and last names;
  • lines 344–346: A username can belong to only one account.

11.12.5.5. Transactions and uniqueness constraints

  • lines 365–375: All updates go through this method. [sessions.begin()] opens a session and its transaction; Upon exiting the [with] block, the transaction is committed ([COMMIT]) if everything went well, or rolled back ([ROLLBACK]) if an exception occurs, including [BusinessException];
  • Lines 369–370: The optimistic lock detected a concurrent modification (example 28);
  • lines 371–374: a database constraint rejected the write. The checks performed before the write (unique username, no homonyms) provide a clear message in the vast majority of cases; however, two administrators could register the same username at the same time, or two patients could book the same time slot. The uniqueness constraint then rejects the second write, and [SQLAlchemy] releases a [IntegrityError]. Its original message ([e.orig], the error for [PyMySQL]) cites the name of the constraint (here with [MariaDB], for the second time slot booked twice on the same day):
(1062, "Duplicate entry '2026-09-26-2' for key 'unq1_appointments'")
  • The regular expression [CONSTRAINT] (line 91) extracts this name. [MySQL] 8 adds the prefix with the table name (['appointments.unq1_appointments']), [MariaDB] does not: the part [(?:[^'.]*\.)?] accepts this optional prefix ([(?:...)] is a non-capturing group, [?] makes it optional). The dictionary [CONSTRAINT_MESSAGES] (lines 84–88) associates each known constraint with the key of its message. This is why the script SQL names all of its constraints;
  • line 375: any other error is a true anomaly: [raise], with no parameters, rethrows the current exception, which will result in a 500 error page.

Note the structure of all write methods: an internal function [work], which contains the operations, is passed to [_transaction], which executes it within a transaction and translates the errors. This is equivalent to the annotation ***[@Transactional]*** of *[Spring]*, written in just a few lines: a function is an object like any other, which can be passed as a parameter.

11.13. Authentication

The [auth] folder incorporates the mechanisms from Examples 25 through 27, with three differences: the access rule is reversed (everything is prohibited except what is declared public), users are retrieved from the database, and the creation of a customer account is protected by a “pass.”

11.13.1. auth/passwords.py

# =========================================================================
# auth/passwords.py
# -------------------------------------------------------------------------
# Password hashing: bcrypt, 10 rounds, version $2b$ - like the
# hashes of the SQL script.
#
# bcrypt only takes into account the first 72 BYTES of a password (an
# accented character takes two of them, in UTF-8); the bcrypt library
# even refuses a longer password. We therefore only pass it these
# 72 bytes, on creation as on verification.
# =========================================================================
import bcrypt


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


# the hash to store in the database: "$2b$10$" + the salt + the result
def hash_password(password: str) -> str:
    return bcrypt.hashpw(_bytes(password), bcrypt.gensalt(rounds=10)).decode("ascii")


# checkpw hashes the typed password with the "salt" contained in the stored
# hash, then compares the two results
def verify(password: str, hashed: str) -> bool:
    return bcrypt.checkpw(_bytes(password), hashed.encode("ascii"))
  • Lines 15–16: [bcrypt] considers only the first 72 bytes of a password, and version 5 of the library rejects longer passwords (Example 25). Therefore, only these 72 bytes are passed to it, both during creation and verification: a longer password is accepted, but characters beyond the 72nd byte are ignored. An accented character occupies two bytes in [UTF-8];
  • lines 20–21: the hash is stored in text form: [decode("ascii")] converts the bytes returned by [bcrypt] into a string;
  • lines 26–27: verification (example 25). This module is used by authentication and by the business layer (account creation, password change).

11.13.2. auth/connected_user.py

# =========================================================================
# auth/connected_user.py
# -------------------------------------------------------------------------
# What the JWT token contains, and how to read it back.
#
# The claims of our tokens (see AuthService.set_token):
#   sub   : the id of the user account (JWT convention: "subject"), as text
#   login : the login
#   name  : the displayed name ("Marie PELISSIER"), for the navigation bar
#   role  : ADMIN, DOCTOR or USER
#   iat, exp : creation and expiration dates of the token
# =========================================================================
from dataclasses import dataclass

from rdvmedecins.entities import Role


# the connected user, in a typed form
@dataclass(frozen=True)
class ConnectedUser:
    user_id: int
    login: str
    name: str
    role: Role

    # the user described by the claims of an already verified token; None if
    # a claim is missing or does not have the expected form
    @staticmethod
    def of(claims: dict) -> "ConnectedUser | None":
        try:
            return ConnectedUser(int(claims["sub"]), claims["login"], claims["name"],
                                 Role(claims["role"]))
        except (KeyError, ValueError, TypeError):
            return None

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

    @property
    def is_patient(self) -> bool:
        return self.role == Role.USER
  • lines 20–24: the logged-in user, in a typed form: Example 25 stored the token’s assertion dictionary in [g.user]; the application stores this object, whose attributes are known to the editor ([g.user.role]), and whose role is a value from the enumeration [Role];
  • Lines 29–34: Construction based on the assertions of an already verified token. The result type is written in quotation marks (["ConnectedUser | None"]): when [Python] reads the method definition, the class [ConnectedUser] is not yet fully defined, and its name cannot be used directly. A token with a missing assertion ([KeyError]) or an unexpected value ([ValueError]: an unknown role, a [sub] that is not a number) results in [None]: the user is not authenticated;
  • lines 36–42: two properties: the [@property] decorator transforms a method into a computed property, which is used without parentheses: [g.user.is_admin].

The token contains the account ID ([sub], in text form, as required by the JWT standard), the login, the display name, and the role: everything the navigation bar and access rules need, without having to query the database for every request.

11.13.3. auth/auth_service.py

# =========================================================================
# auth/auth_service.py
# -------------------------------------------------------------------------
# The authentication logic:
#   - validate_user() : checks a login / password pair;
#   - set_token()     : builds the JWT token of a validated user
#                       and puts it in a cookie of the response;
#   - clear_token()   : deletes this cookie;
#   - current_user()  : the user described by the cookie of the request.
#
# Where does the JWT token travel?
# With a JavaScript client, it was the client code that kept the token
# and added it itself to each request ("Authorization: Bearer
# ..." header). A browser that displays ordinary HTML pages cannot
# do that: for it to present the token by itself on each request, it
# must be stored in a COOKIE. The options of this cookie matter for
# security:
#   - HttpOnly: the JavaScript of the page cannot read the cookie (an
#     injected script could therefore not steal the token);
#   - SameSite=Lax: the browser does NOT send the cookie with a POST coming
#     from another site (first protection against the so-called CSRF attack; the
#     second is the anti-CSRF token of the forms, see rdvmedecins/__init__.py);
#   - Secure: in production (HTTPS), the cookie never travels in clear;
#   - Max-Age: the cookie disappears at the same time as the token expires.
# =========================================================================
from datetime import datetime, timedelta, timezone

import jwt
from flask import Request, Response

from rdvmedecins.auth.passwords import verify
from rdvmedecins.auth.connected_user import ConnectedUser
from rdvmedecins.entities import User
from rdvmedecins.repositories.user_repository import UserRepository

# the name of the cookie that carries the token
JWT_COOKIE = "rdvmedecins_jwt"


class AuthService:

    def __init__(self, sessions, users: UserRepository, secret: str, duration_seconds: int,
                 cookie_secure: bool):
        # HMAC-SHA256 requires a key of at least 256 bits: we say so
        # clearly at startup rather than at the first login
        if len(secret.encode("utf-8")) < 32:
            raise RuntimeError("JWT_SECRET must be at least 32 characters long (see config.py).")
        self._sessions = sessions
        self._users = users
        self._secret = secret
        self._duration = timedelta(seconds=duration_seconds)
        self._cookie_secure = cookie_secure

    # None if the login does not exist or if the password is wrong
    def validate_user(self, login: str, password: str) -> User | None:
        with self._sessions() as session:
            user = self._users.read_by_login(session, login)
        if user is None or not verify(password, user.password):
            return None
        return user

    # -----------------------------------------------------------------------
    # builds the JWT token of [user] and puts it in a cookie.
    # The token is SIGNED (HMAC-SHA256) with the secret key: the browser
    # can read it (it is not encrypted), but not modify it - any
    # modification would make the signature invalid.
    # -----------------------------------------------------------------------
    def set_token(self, response: Response, user: User):
        now = datetime.now(timezone.utc)
        claims = {
            "sub": str(user.id),
            "login": user.login,
            "name": user.name,
            "role": user.role.value,
            "iat": now,
            "exp": now + self._duration,
        }
        token = jwt.encode(claims, self._secret, algorithm="HS256")
        response.set_cookie(JWT_COOKIE, token, max_age=self._duration, path="/", httponly=True,
                            samesite="Lax", secure=self._cookie_secure)

    # clears the cookie: it is set again, empty, with a zero lifetime
    @staticmethod
    def clear_token(response: Response):
        response.delete_cookie(JWT_COOKIE, path="/")

    # the token present in the request, whether valid or not
    @staticmethod
    def token_of(request: Request) -> str | None:
        return request.cookies.get(JWT_COOKIE)

    # -----------------------------------------------------------------------
    # the user described by the cookie of the request; None if there is no
    # token, or if the token is expired, forged or malformed.
    # jwt.decode checks the signature AND the expiration date (exp claim).
    # -----------------------------------------------------------------------
    def current_user(self, request: Request) -> ConnectedUser | None:
        token = self.token_of(request)
        if not token:
            return None
        try:
            claims = jwt.decode(token, self._secret, algorithms=["HS256"])
        except jwt.InvalidTokenError:
            return None
        return ConnectedUser.of(claims)
  • lines 46–47: a signature key that is too short is flagged when the application starts, rather than on the first login: [create_app] creates the service, and the exception terminates the application with this message;
  • Lines 55–60: Verification of a username and password. The service reads the user via DAO, in a session that remains open only for the duration of this read; password verification ([bcrypt], which is slow) occurs after the session is closed, to avoid maintaining an unnecessary connection to the database;
  • Lines 68–80: the token (example 25) and its cookie, [rdvmedecins_jwt]. [user.role.value]: the enumeration value, the string ["USER"]. The cookie is assigned the attribute [Secure] in production ([COOKIE_SECURE]);
  • lines 84–85: deleting the cookie. This is a static method: it does not require an object and is called on the class ([AuthService.clear_token(response)]);
  • lines 97–105: the user identified by the request cookie, or [None].

11.13.4. auth/security.py

The core of security: authentication of each request and the access rule.

# =========================================================================
# auth/security.py
# -------------------------------------------------------------------------
# Who can access what, and how the user is recognized on each
# request (see examples 25 and 26).
#
# For each request, the authenticate function (a before_request of
# the application):
#   1. reads the token of the cookie, checks its signature and its expiration date,
#      and stores the user in g.user (None if there is none);
#   2. applies the access rule: ALL the actions require an authenticated
#      user, except those marked @public (login,
#      account creation, choice of language) and the static files.
#      It is the rule "everything is forbidden except what is allowed": an action
#      added later, that one would have forgotten to protect, is protected by default.
# Only then does Flask call the action, whose decorator
# @roles_required(...) checks the role.
#
# Two failure cases, two responses suited to an HTML application:
#   - no authenticated user (no token, expired or forged token)
#     -> redirect to the login page;
#   - insufficient role -> response 403, which web/errors.py turns into a 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_error


# the token is valid, but the account it designates no longer exists: it has been
# deleted since the login (by the patient themselves in another
# tab, or by the ADMIN). This case is handled like an expired session:
# web/errors.py clears the cookie and sends back to the login page.
class InvalidSession(Exception):
    pass


# a decorator that MARKS an action as public (see example 24)
def public(action):
    action.public = True
    return action


def register(app, auth_service: AuthService):

    @app.before_request
    def authenticate():
        g.user = auth_service.current_user(request)
        # no route matches (the 404 page will follow), or a static file
        if request.endpoint is None or request.endpoint == "static":
            return None
        action = current_app.view_functions[request.endpoint]
        if getattr(action, "public", False) or g.user is not None:
            return None
        # a response returned by a before_request stops the processing:
        # the action is not called
        return to_login()


# -----------------------------------------------------------------------
# no authenticated user -> login page. If a cookie was
# present, its token is no longer valid: the session has expired.
# The cookie is then cleared, and the user is warned by a flash message.
# -----------------------------------------------------------------------
def to_login():
    response = redirect(url_for("auth.show_login"))
    if AuthService.token_of(request):
        AuthService.clear_token(response)
        flash_error("ERRORS.SESSION_EXPIRED")
    return response


# -----------------------------------------------------------------------
# the role of the user must be one of [roles]; otherwise, response 403.
# require_roles is called from a blueprint before_request (all the
# actions of the blueprint, see controllers/persons.py); roles_required is
# the decorator of an action (see example 26).
# -----------------------------------------------------------------------
def require_roles(*roles: Role):
    if g.user is None:
        raise InvalidSession()
    if g.user.role not in roles:
        abort(403)


def roles_required(*roles: Role):
    def decorator(action):
        @wraps(action)
        def protected_action(*args, **kwargs):
            require_roles(*roles)
            return action(*args, **kwargs)

        return protected_action

    return decorator
  • lines 37–38: an exception for a special case: the token is valid, but the account it refers to no longer exists (the patient deleted it in another tab, or the administrator deleted it). Actions that require the account raise this exception; the error handler treats it as an expired session (see [web/errors.py]);
  • lines 42–44: the decorator that marks an action as public (example 24);
  • lines 50–60: the application’s [before_request] function. First, the user is authenticated for all requests, including public requests: the login page must know whether the user is already logged in. Next, the access rule: a request that does not match any route ([request.endpoint is None]: the 404 page will follow) or that requests a static file is allowed; an action marked as public is allowed; an authenticated user is allowed. Otherwise, the function returns a response (redirecting to the login page), which stops processing: the action is not called.

This is the “everything is prohibited unless explicitly permitted” rule: an action added later—even if you forgot to secure it—is automatically secured. Forgetting to include [@public] on a public action is immediately obvious (the page redirects to the login page); failing to protect a private action is not immediately apparent: the default rule must be the safest. This is also the rule for version [Spring MVC] ([anyRequest().authenticated()]);

  • lines 68–73: the redirect to the login page. If the request contained a token cookie, it means that token is no longer valid (expired or tampered with): the cookie is deleted, and a pop-up message explains to the user that their session has expired (the screenshot of the end-of-session message, shown above);
  • lines 82–86: authorization. The parameter [*roles] accepts any number of roles in a tuple: [require_roles(Role.ADMIN, Role.DOCTOR)]. A role missing from the list results in a 403 error. The [g.user is None] test should never be true (the access rule has already filtered out anonymous visitors), unless [require_roles] were called from a public action: this provides an additional layer of security;
  • lines 89–98: the parameter decorator from Example 26, which calls [require_roles]. [require_roles] can also be used on its own, within the [before_request] of a blueprint, to protect all of its actions at once (see [controllers/persons.py]).

11.13.5. auth/captcha_drawing.py

The image rendering is the same as in Example 27, reproduced exactly as shown. Its header comment highlights an important point: each character is drawn using lines ([<path>] elements), and never written as text (a [<text>] element); otherwise, a bot would simply have to read the image’s source code.

11.13.6. auth/captcha.py

# =========================================================================
# auth/captcha.py
# -------------------------------------------------------------------------
# A CAPTCHA ("test to tell a human from a robot") made by the
# server itself, without any external service: a few distorted characters,
# scrambled by lines, in an SVG image (see captcha_drawing.py). A
# human reads them easily; a robot that merely sends
# forms in a loop cannot do it.
#
# How it works:
#   1. generate() draws a random text, makes an image of it, and stores the
#      right answer IN THE SERVER'S MEMORY, under a random identifier;
#   2. the page displays the image and puts the identifier in a hidden field of the
#      form (the answer itself never leaves the server);
#   3. verify(id, typed) compares the input with the expected answer, then
#      FORGETS this captcha: each image is used only once, a robot cannot
#      replay an answer already found.
#
# A single CaptchaService object for the whole server, used by several
# requests IN PARALLEL (the server processes them on several threads): a
# lock (Lock) guarantees that only one thread at a time reads or modifies the
# dictionary of pending captchas.
#
# Accepted limit: the memory of a single process. With several processes
# or several servers, a shared storage would be needed.
# =========================================================================
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock

from rdvmedecins.auth import captcha_drawing

# validity duration of an image: one has 10 minutes to fill in the form
LIFETIME = timedelta(minutes=10)

# memory safeguard: beyond this, all the pending captchas are forgotten
MAX_CAPTCHAS = 10_000


# a captcha: id, to put in the hidden field "captchaId" of the form;
# svg, the image, to insert as is into the page
@dataclass(frozen=True)
class Captcha:
    id: str
    svg: str


class CaptchaService:

    def __init__(self):
        # id -> (expected answer, expiration date)
        self._pending: dict[str, tuple[str, datetime]] = {}
        self._lock = Lock()

    def generate(self) -> Captcha:
        text = captcha_drawing.random_text(5)  # 5 characters
        id = secrets.token_urlsafe(16)  # a random identifier, impossible to guess
        with self._lock:
            self._purge()
            self._pending[id] = (text, datetime.now() + LIFETIME)
        return Captcha(id, captcha_drawing.svg(text))

    # true if [typed] is the right answer to captcha [id] (regardless of
    # case). The captcha is consumed in all cases: if two
    # requests present the same captcha at the same moment, only one
    # removes it (pop, under the lock) - the other fails.
    def verify(self, id: str | None, typed: str | None) -> bool:
        with self._lock:
            expected = self._pending.pop(id, None) if id else None
        if expected is None or typed is None:
            return False
        text, expires = expected
        return expires > datetime.now() and text.lower() == typed.strip().lower()

    # (called under the lock)
    def _purge(self):
        now = datetime.now()
        for id in [id for id, (_, expires) in self._pending.items() if expires <= now]:
            del self._pending[id]
        if len(self._pending) >= MAX_CAPTCHAS:
            self._pending.clear()

The service from Example 27, with an addition: the [_purge] method (lines 77–82), called each time a CAPTCHA is created, discards expired CAPTCHAs. Without it, captchas that are displayed but never used (such as when a login page is opened and then abandoned) would accumulate in memory. [MAX_CAPTCHAS] acts as a safeguard: a bot requesting millions of login pages would not be able to exhaust the server’s memory. The number [10_000] is written with an underscore, which separates the thousands for readability ([Python] ignores this). The list of identifiers to be deleted is constructed before the deletion loop: you cannot delete keys from a dictionary while iterating through it.

11.13.7. auth/limiter.py

# =========================================================================
# auth/limiter.py
# -------------------------------------------------------------------------
# Second line of defense against robots (the first is the captcha): we COUNT
# the attempts, and beyond a threshold we BLOCK for a while.
#
#   - login: after LOGIN_MAX_FAILURES failures (wrong captcha or wrong
#     password) for the same login from the same IP address, this pair
#     is blocked LOGIN_BLOCK_MINUTES minutes. The same IP address is also
#     blocked if it accumulates 5 times more failures, all logins together (a
#     robot that tries thousands of different logins);
#   - account creation: at most REGISTRATION_MAX_PER_HOUR accounts created
#     per hour from the same IP address.
#
# Beware in a classroom: all the computers often go out on the Internet
# through the SAME IP address. This is why the login block applies
# first to the (IP, login) pair - a student who mistypes their
# password does not block their neighbors - and why the thresholds can be set
# in config.py.
#
# A single object for the whole server, called by parallel requests:
# all the public methods take the lock - only one thread at a
# time reads AND modifies the counters.
# =========================================================================
import math
from datetime import datetime, timedelta
from threading import Lock

ONE_HOUR = timedelta(hours=1)


class Counter:
    def __init__(self, start: datetime):
        self.number = 0  # attempts counted in the current window
        self.start = start  # start of the counting window
        self.blocked_until = datetime.min  # datetime.min if not blocked


class AttemptLimiter:

    def __init__(self, login_max_failures: int, login_block_minutes: int,
                 registration_max_per_hour: int):
        self._counters: dict[str, Counter] = {}
        self._lock = Lock()
        self._max_login_failures = login_max_failures
        self._login_block_duration = timedelta(minutes=login_block_minutes)
        self._max_registrations = registration_max_per_hour

    # ---------------------------------------------------------------- login

    # number of remaining block minutes (0 = the attempt is allowed)
    def login_block_minutes(self, ip: str, login: str) -> int:
        with self._lock:
            return max(self._remaining_minutes(self._login_key(ip, login)),
                       self._remaining_minutes(self._ip_key(ip)))

    def login_failure(self, ip: str, login: str):
        with self._lock:
            self._count(self._login_key(ip, login), self._max_login_failures,
                        self._login_block_duration, self._login_block_duration)
            self._count(self._ip_key(ip), self._max_login_failures * 5,
                        self._login_block_duration, self._login_block_duration)

    # a successful login resets the counter of THIS login
    def login_success(self, ip: str, login: str):
        with self._lock:
            self._counters.pop(self._login_key(ip, login), None)

    # ---------------------------------------------------------------- registration

    def registration_block_minutes(self, ip: str) -> int:
        with self._lock:
            return self._remaining_minutes(f"registration|{ip}")

    # one-hour window; once the quota is reached, blocked until the end of the hour
    def registration_success(self, ip: str):
        with self._lock:
            self._count(f"registration|{ip}", self._max_registrations, ONE_HOUR, ONE_HOUR)

    # ---------------------------------------------------------------- common mechanics
    # (the private methods are always called under the lock)

    def _remaining_minutes(self, key: str) -> int:
        counter = self._counters.get(key)
        if counter is None:
            return 0
        remaining = (counter.blocked_until - datetime.now()).total_seconds()
        # rounded UP to the minute: 30 s of remaining block -> "1 minute"
        return max(0, math.ceil(remaining / 60))

    # counts an attempt; if the threshold is reached within the window, blocks
    def _count(self, key: str, maximum: int, window: timedelta, block: timedelta):
        now = datetime.now()
        self._purge(now)
        counter = self._counters.get(key)
        if counter is None or now - counter.start > window:
            counter = Counter(now)
            self._counters[key] = counter
        counter.number += 1
        if counter.number >= maximum:
            counter.blocked_until = now + block
            counter.number = 0  # after the block, we start again from zero
            counter.start = counter.blocked_until

    # we forget the counters that are no longer useful (neither blocked, nor recent)
    def _purge(self, now: datetime):
        if len(self._counters) < 1000:
            return
        for key in [key for key, c in self._counters.items()
                    if c.blocked_until < now and now - c.start > ONE_HOUR]:
            del self._counters[key]

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

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

A more complete version of the limiter from Example 27:

  • The connection block first targets the pair (address IP, login): in a classroom, all computers often access the Internet through the same address, IP, and a student who enters the wrong password should not block their neighbors. However, an address is also blocked if it accumulates five times as many failed attempts, across all logins: this is the case for a bot that tries thousands of different logins (lines 57–61);
  • account creation is limited to a certain number of accounts per hour per IP address (lines 71–78);
  • The counters are stored in a single dictionary, under keys prefixed by their usage ([login|...], [ip|...], [registration|...]);
  • lines 92–103: A counter tracks attempts within a time window; an attempt that occurs after the window ends triggers a new one. When the threshold is reached, the key is locked, and the counter resets to zero;
  • lines 106–111: Once the counter exceeds 1,000 entries, those that are no longer in use (neither blocked nor recent) are discarded.

Like the CAPTCHA, the limiter stores its data in the process’s memory: this is acceptable for an application served by a single process (the development server, [Waitress]), but not for multiple processes or multiple servers, which would not share their counters (shared storage would then be required, such as [Redis]).

11.13.8. auth/registration_pass.py

# =========================================================================
# auth/registration_pass.py
# -------------------------------------------------------------------------
# The "pass" of account creation.
#
# To open the page "Create a patient account", one must first have
# typed the captcha of the authentication page (see
# controllers/registration.py, POST /register/access). The server then issues
# a pass:
#   - a random identifier, stored in an HttpOnly cookie;
#   - valid 15 minutes (the time to fill in the form, even when trying
#     several times after input errors);
#   - consumed as soon as an account is created: ONE captcha solved = ONE account at
#     most. A robot cannot create accounts in series.
#
# Without a valid pass, GET and POST /register send back to the
# authentication page: typing the URL /register directly does not make it
# possible to bypass the captcha.
#
# Like the captchas, the passes live in the memory of the process.
# =========================================================================
import secrets
from datetime import datetime, timedelta
from threading import Lock

from flask import Response

COOKIE = "rdvmedecins_registration"

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


class RegistrationPass:

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

    # issues a new pass; returns its identifier
    def issue(self) -> str:
        id = secrets.token_urlsafe(16)
        with self._lock:
            self._purge()
            self._passes[id] = datetime.now() + LIFETIME
        return id

    # true if [id] is an existing, non-expired pass
    def is_valid(self, id: str | None) -> bool:
        with self._lock:
            expires = self._passes.get(id) if id else None
        return expires is not None and expires > datetime.now()

    # the pass has been used: it is forgotten
    def consume(self, id: str | None):
        with self._lock:
            self._passes.pop(id, None)

    # (called under the lock)
    def _purge(self):
        now = datetime.now()
        for id in [id for id, expires in self._passes.items() if expires <= now]:
            del self._passes[id]
        if len(self._passes) >= MAX_PASSES:
            self._passes.clear()


# the cookie is sent by the browser only to the /register addresses...
def set_cookie(response: Response, id: str, secure: bool):
    response.set_cookie(COOKIE, id, max_age=LIFETIME, path="/register", httponly=True,
                        samesite="Lax", secure=secure)


# ... and it is cleared by setting it again empty, with a zero lifetime
def clear_cookie(response: Response):
    response.delete_cookie(COOKIE, path="/register")

Creating a patient account is a public page: a bot could create accounts in bulk. It is therefore protected by the login page’s CAPTCHA: the “Create a patient account” button sends this CAPTCHA to the server, which, if it is correct, issues a token:

  • lines 42–47: a random identifier, valid for 15 minutes, stored in memory like the CAPTCHAs;
  • lines 50–53: The registration page verifies the pass for each request—GET is treated the same as POST;
  • lines 56–58: Creating an account consumes the pass: one solved CAPTCHA equals one account at most;
  • lines 70–72: The pass is stored in a cookie. Its attribute, [path="/register"], limits the requests to which the browser attaches it: only addresses beginning with [/register];
  • lines 76–77: To delete a cookie, you must specify the same path as when it was set.

11.13.9. controllers/auth.py

The connection controller, which uses all of these services.

# =========================================================================
# controllers/auth.py
# -------------------------------------------------------------------------
# The PUBLIC routes of the login:
#   GET  /        redirects to the home page of the role (or to /login)
#   GET  /login   displays the login form (with its captcha)
#   POST /login   checks attempt limit, captcha, login / password
#   POST /logout  the logout
#
# Protection against robots, in this order:
#   1. too many recent failures for this login from this IP address?
#      -> immediate refusal, without even looking at the password;
#   2. wrong answer to the captcha? -> refusal (counted as a failure);
#   3. only then, verification of the password.
# The password of an attempt refused at steps 1 or 2 is NEVER
# checked: a robot can therefore learn nothing by insisting.
# =========================================================================
from flask import Blueprint, g, redirect, render_template, request, url_for

from rdvmedecins.auth.auth_service import AuthService
from rdvmedecins.auth.security import public
from rdvmedecins.entities import Role
from rdvmedecins.models.view_models import LoginModel, Message
from rdvmedecins.web.services import auth_service, captchas, limiter

auth = Blueprint("auth", __name__)


# the home page of each role, right after the login
def home_page(role: Role) -> str:
    return url_for("area.patient_area") if role == Role.USER else url_for("schedule.show")


# the IP address of the visitor. Behind a reverse proxy (nginx...), it would be
# that of the proxy: the application would then have to be wrapped in the
# ProxyFix middleware of Werkzeug, which reads the X-Forwarded-For header.
def ip_address() -> str:
    return request.remote_addr or ""


# -----------------------------------------------------------------------
# GET /
# The route is public: the user has been established, if there is one, by
# auth/security.py (g.user), but nothing is required.
# -----------------------------------------------------------------------
@auth.get("/")
@public
def home():
    return redirect(home_page(g.user.role) if g.user else url_for("auth.show_login"))


# -----------------------------------------------------------------------
# GET /login[?login=xxx]: the optional parameter pre-fills the login
# field (used right after the creation of an account, see controllers/registration.py)
# -----------------------------------------------------------------------
@auth.get("/login")
@public
def show_login():
    # already logged in (token still valid): straight to the home page
    if g.user:
        return redirect(home_page(g.user.role))
    return form(request.args.get("login", "")[:30], None)


# -----------------------------------------------------------------------
# POST /login
# request.form.get: a missing field gives None (and not a 400 error) -
# it is the code that decides what to do with it.
# -----------------------------------------------------------------------
@auth.post("/login")
@public
def log_in():
    login = (request.form.get("login") or "").strip()
    password = request.form.get("password") or ""
    ip = ip_address()

    # 1. too many recent failures?
    minutes = limiter().login_block_minutes(ip, login)
    if minutes > 0:
        return form(login, Message("ERRORS.TOO_MANY_ATTEMPTS", {"minutes": str(minutes)}))

    # 2. the captcha
    if not captchas().verify(request.form.get("captchaId"), request.form.get("captcha")):
        limiter().login_failure(ip, login)
        return form(login, Message("ERRORS.WRONG_CAPTCHA"))

    # 3. the password
    user = auth_service().validate_user(login, password) if login and password else None
    if user is None:
        limiter().login_failure(ip, login)
        return form(login, Message("LOGIN.ERROR"))

    limiter().login_success(ip, login)
    response = redirect(home_page(user.role))
    auth_service().set_token(response, user)
    return response


# -----------------------------------------------------------------------
# POST /logout: clear the cookie of the token, then go to the login
# page. POST only: a simple link (GET), placed on another site,
# cannot log the user out without their knowledge.
# -----------------------------------------------------------------------
@auth.post("/logout")
@public
def log_out():
    response = redirect(url_for("auth.show_login"))
    AuthService.clear_token(response)
    return response


# the form, always with a NEW captcha (each image is used only
# once); the typed login is displayed again, never the password
def form(login: str, error: Message | None):
    model = LoginModel("LOGIN.TITLE", error, login, captchas().generate())
    return render_template("login.html", model=model)
  • line 24: Shared services are obtained through small functions in the [web/services.py] module (discussed later), which retrieve them from [app.extensions];
  • lines 30–31: Each role has its own home page: a patient portal for patients, and the calendar for others;
  • lines 37–38: the visitor’s address IP. Behind a reverse proxy ([nginx], [Apache]), this would be the proxy’s address, which is the same for all visitors: the application would then need to be wrapped in the [ProxyFix] middleware from [Werkzeug] (see the chapter on the lifecycle), which reads the original address from the [X-Forwarded-For] header added by the proxy;
  • lines 46–49: the public root of the site: it redirects to the user’s home page or to the login page;
  • lines 56–62: the login page. A user who is already logged in (whose token is still valid) is sent directly to their home page. The optional parameter [login] pre-fills the field (after an account is created); [[:30]] limits its length to that of a username;
  • Lines 70–96: the login process, in three steps, in this order: potential blocking, the CAPTCHA, and then only the password. An attempt rejected in the first two steps never checks the password: a bot cannot learn anything by persisting. [request.form.get(...) or ""]: an empty field returns an empty string. The error message is an object [Message]: a translation key and its parameters (discussed later);
  • lines 104–109: logout, in POST (example 25). It is public: a user whose session has expired must be able to click “Log out” without first being redirected;
  • lines 114–116: the login page, still with a new CAPTCHA. Its template is a [LoginModel] object (see [models/view_models.py]).

Note that the login form does not use [WTForms]: its three fields have no validation rules (an empty username simply results in a failed login), and the form is protected against CSRF attacks by [CSRFProtect], like all forms in the application.

11.14. The Web Layer: Shared Services

The [web] folder contains what the controllers have in common. Each module in it answers a question that all controllers ask themselves: how to obtain a service, translate a text, display a message after a redirect, redisplay a rejected form, or convert an error into a page.

11.14.1. web/services.py

# =========================================================================
# web/services.py
# -------------------------------------------------------------------------
# The shared objects of the application (see example 06): they are created ONCE,
# by the application factory (rdvmedecins/__init__.py), and
# stored in the app.extensions dictionary. These small functions find
# them there, for the application that processes the current request
# (current_app); they also give their TYPE to the analysis tools.
# =========================================================================
from flask import current_app

from rdvmedecins.auth.auth_service import AuthService
from rdvmedecins.auth.captcha import CaptchaService
from rdvmedecins.auth.limiter import AttemptLimiter
from rdvmedecins.auth.registration_pass import RegistrationPass
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 limiter() -> AttemptLimiter:
    return current_app.extensions["rdvmedecins.limiter"]


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

The shared objects, created by the factory and stored in [app.extensions], are retrieved by these small functions (example 06). They eliminate the need to repeat the dictionary key in each controller and provide their type to analysis tools: in a controller, the editor knows that [application()] is a [ApplicationModel] and offers its methods.

11.14.2. web/translator.py

# =========================================================================
# web/translator.py
# -------------------------------------------------------------------------
# French / English translation of the interface (see example 22).
#
# The dictionaries are the SAME files as those of the NestJS,
# ASP.NET Core and Spring MVC versions of the application (i18n/fr.json, i18n/en.json),
# read only once at startup.
#
# In the views, the t function (see web/common_model.py):
#   {{ t("LOGIN.TITLE") }}                            without parameter;
#   {{ t("AREA.HELLO", name=model.patient) }}         named parameters;
#   {{ t(message.key, message.params) }}              a dictionary of parameters.
#
# THE CURRENT LANGUAGE: g.language, set on each request from a cookie
# (see rdvmedecins/__init__.py, set_language function).
# =========================================================================
import json
import re
from pathlib import Path

from flask import g

LANGUAGES = ("fr", "en")
DEFAULT_LANGUAGE = "en"

# the cookie that remembers the chosen language (see controllers/language.py)
LANGUAGE_COOKIE = "rdvmedecins_language"

# {name}: a parameter in a text
PARAMETER = re.compile(r"\{(\w+)\}")


# the supported language that matches [code]: "fr" -> "fr"; "de" or None -> "en"
def language(code: str | None) -> str:
    return code if code in LANGUAGES else DEFAULT_LANGUAGE


class Translator:

    def __init__(self, folder: Path):
        # language -> (full key "LOGIN.TITLE" -> text)
        self._dictionaries: dict[str, dict[str, str]] = {}
        for code in LANGUAGES:
            with open(folder / f"{code}.json", encoding="utf-8") as file:
                self._dictionaries[code] = self._flatten(json.load(file))

    # -----------------------------------------------------------------------
    # translate("DOCTORS.ADDED", {"name": "Marie PELISSIER"})
    #   -> looks for "DOCTORS.ADDED" in the dictionary of the language, and
    #      replaces {name} by its value. A key missing from the requested language
    #      is looked for in English; missing everywhere, the key itself
    #      is displayed (an omission is thus spotted immediately).
    # The parameters: a dictionary, and/or named parameters.
    # -----------------------------------------------------------------------
    def translate(self, key: str | None, params: dict | None = None, **others) -> str:
        if not key:
            return ""
        text = self._dictionaries[language(g.get("language"))].get(key)
        if text is None:
            text = self._dictionaries[DEFAULT_LANGUAGE].get(key, key)
        values = {**(params or {}), **others}
        if not values:
            return text
        return PARAMETER.sub(lambda m: str(values.get(m.group(1), m.group(0))), text)

    # { "LOGIN": { "TITLE": "Login" } } -> { "LOGIN.TITLE": "Login" }
    @classmethod
    def _flatten(cls, node: dict, prefix: str = "") -> dict[str, str]:
        result = {}
        for key, value in node.items():
            full_key = f"{prefix}.{key}" if prefix else key
            if isinstance(value, dict):
                result.update(cls._flatten(value, full_key))
            else:
                result[full_key] = value
        return result

The translator from Example 22, with three additions:

  • Lines 56–65: A text’s parameters can be provided by a dictionary (the second parameter, [params]), by named parameters ([**others]), or both. Application messages (the [Message] class, discussed later) carry their parameters in a dictionary; views, on the other hand, prefer named parameters. [{**a, **b}] creates a dictionary that combines those from [a] and [b];
  • line 61: a key missing from the requested language is looked up in English before being displayed as-is;
  • line 59: [g.get("language")] returns [None] if [g] lacks the [language] attribute, rather than throwing an exception: the translator works even if an error occurs before the [before_request] function, which sets the language.

The name of the language cookie is prefixed with the application name ([rdvmedecins_language]), just like the token and pass: cookies are associated with a domain name ([localhost]), not a port. The examples, which are also served by [localhost], could otherwise read the application’s cookies.

11.14.3. web/common_model.py

# =========================================================================
# web/common_model.py
# -------------------------------------------------------------------------
# What ALL the views need, whatever the page (see example
# 15): a "context processor", run before each view is
# built, adds its variables to the model of all the views:
#   - user     : the connected user (or None), for the navigation bar;
#   - language : "fr" or "en";
#   - path     : the requested URL (active menu of the navigation bar,
#                "Another image" link of the captcha);
#   - t        : the translation function (see web/translator.py).
# The flash messages are read by the layout (get_flashed_messages).
# =========================================================================
from flask import g, request

from rdvmedecins.web.translator import Translator


def register(app, translator: Translator):

    @app.context_processor
    def common_model():
        return {
            "user": g.get("user"),
            "language": g.get("language", "en"),
            "path": request.path,
            "t": translator.translate,
        }

The context processor from Example 15: four variables available in all views, without actions having to provide them: the logged-in user (for the navigation bar), the language (for the page’s [lang] attribute and the FR / EN buttons), the path of the requested URL (for the active menu and the “Another image” link in the CAPTCHA), and the translation function [t]. [g.get("user")] returns [None] if the attribute does not exist (for example, a fabricated error page before authentication).

11.14.4. web/flash.py

# =========================================================================
# web/flash.py
# -------------------------------------------------------------------------
# The "flash" messages: a message to display ONCE, on the NEXT
# page (see example 20). It is the natural complement of the
# "Post / Redirect / Get" pattern used by all the forms of the application:
# a successful POST does not build a page, it redirects (302) to a
# GET page; the message, however, must survive the redirect.
#
# Flask stores them in the SESSION, that is, in a cookie SIGNED by the
# secret key (SECRET_KEY): the server remains STATELESS, it keeps nothing in
# memory between two requests. The browser can read this cookie (it is
# not encrypted), but not modify it without the signature giving it away.
#
# A message does not contain a text, but a translation KEY and its
# parameters: it is translated at display time, in the language of
# the page (see templates/layout.html). Its category: "success" (displayed at
# the top of the page) or "danger" (an error, displayed at the bottom).
# =========================================================================
from flask import flash


def flash_success(key: str, **params):
    flash({"key": key, "params": params}, "success")


def flash_error(key: str, **params):
    flash({"key": key, "params": params}, "danger")

The flash messages from Example 20. A message is not text, but a dictionary: the translation key and its parameters. It will be translated at the time of display, by the template, into the page’s language. [Flask] stores the messages in the session, which is a cookie: to do this, it converts them to JSON, which accepts a dictionary of strings. The message category ([success] or [danger], the color names from [Bootstrap]) determines its location: at the top of the page for a success, at the bottom for an error.

11.14.5. web/errors.py

# =========================================================================
# web/errors.py
# -------------------------------------------------------------------------
# How the errors become HTML pages. Three handlers share
# the work (see example 24):
#
#   1. an InvalidSession (account deleted since the login) -> back to
#      the login page, as for an expired session;
#   2. an HTTP error: abort(400), abort(403) (insufficient role), unknown
#      page (404), unsupported method (405), anti-CSRF token
#      missing or wrong (400)... -> the error page, with the original code;
#   3. any other exception (a real anomaly) -> written in the log
#      of the server, with its stack trace; the user sees the
#      500 error page - never the stack trace.
# =========================================================================
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.security import InvalidSession
from rdvmedecins.models.view_models import ErrorModel
from rdvmedecins.web.flash import flash_error

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


def error_page(code: int):
    model = ErrorModel("ERROR_PAGE.TITLE", None, code, MESSAGES.get(code, "ERRORS.SERVER_ERROR"))
    # the page keeps the error code (404 stays 404...)
    return render_template("error.html", model=model), code


def register(app):

    @app.errorhandler(InvalidSession)
    def invalid_session(e):
        response = redirect(url_for("auth.show_login"))
        AuthService.clear_token(response)
        flash_error("ERRORS.SESSION_EXPIRED")
        return response

    @app.errorhandler(HTTPException)
    def http_error(e: HTTPException):
        return error_page(e.code or 500)

    # Exception: the parent class of all the exceptions; this handler
    # only receives those that no more specific handler has processed
    @app.errorhandler(Exception)
    def anomaly(e: Exception):
        current_app.logger.exception("unexpected error")
        return error_page(500)

The error handlers in Example 24:

  • lines 31–34: the error page, with the original error code (a 404 page remains a 404 response) and a translated message chosen based on the code;
  • lines 39–44: an account deleted since login: redirect to the login page, as with an expired session;
  • lines 46–48: all HTTP errors: [abort(403)], an unknown page (404), a forbidden method (405), a missing or invalid anti-CSRF token (400, handled by [CSRFProtect])...;
  • lines 52–55: any other exception, i.e., a true anomaly (a programming error, a database that has become inaccessible). [current_app.logger] is the application log (an object of the standard module [logging]); its method [exception] writes the message to it, followed by the call stack of the current exception. The user, however, sees only the 500 error page: never the call stack, which would reveal internal details of the application. [Flask] always selects the most specific handler: a HTTP error is handled by the second handler, not by this one.

11.14.6. web/application_model.py

# =========================================================================
# web/application_model.py
# -------------------------------------------------------------------------
# The cache / facade of the business layer (the equivalent of the
# [ApplicationModel] class of the previous versions of the application): the
# lists of doctors and of clients are read once at startup, then
# served from memory.
#
# These lists can CHANGE (the ADMIN adds, edits, deletes
# doctors and clients): each write therefore goes through this facade,
# which reloads the list concerned right after. This is the whole point of a
# SINGLE entry point to the business layer: the rule "write =>
# cache reload" is written only here.
#
# A single object for the whole application, used by requests
# processed IN PARALLEL, on several threads. A list is therefore never modified
# in place: it is REPLACED entirely by a new one. In Python,
# the assignment of an attribute is atomic: a thread that reads the list sees
# the old or the new one, never a half-built list.
# =========================================================================
from datetime import date

from rdvmedecins.domain.schedule import DoctorDaySchedule
from rdvmedecins.entities import Client, Slot, Doctor, Appointment
from rdvmedecins.business.person_data import PersonData
from rdvmedecins.business.business import Business


# same contract as the business layer (Business)
class ApplicationModel:

    def __init__(self, business: Business):
        self._business = business
        # initial loading of the two lists, at application startup.
        # A read error (unreachable database...) is not caught:
        # it stops the server, rather than letting it start "empty"
        self._doctors: list[Doctor] = []
        self._clients: list[Client] = []
        self._reload_doctors()
        self._reload_clients()

    def _reload_doctors(self):
        self._doctors = list(self._business.get_all_doctors())

    def _reload_clients(self):
        self._clients = list(self._business.get_all_clients())

    # ------------------------------------------------ reads from the cache

    def get_all_doctors(self) -> list[Doctor]:
        return self._doctors

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

    # the doctor whose user account is [user_id] - used to preselect,
    # for a connected doctor, their own schedule
    def get_doctor_by_user_id(self, user_id: int) -> Doctor | None:
        return next((d for d in self._doctors if d.user_id == user_id), None)

    # ------------------------------------------------ simple delegations

    def get_all_slots(self, doctor_id: int) -> list[Slot]:
        return self._business.get_all_slots(doctor_id)

    def get_doctor_day_appointments(self, doctor_id: int, day: date) -> list[Appointment]:
        return self._business.get_doctor_day_appointments(doctor_id, day)

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

    def get_doctor_by_id(self, id: int) -> Doctor | None:
        return self._business.get_doctor_by_id(id)

    def get_appointment_by_id(self, id: int) -> Appointment | None:
        return self._business.get_appointment_by_id(id)

    def get_slot_by_id(self, id: int) -> Slot | None:
        return self._business.get_slot_by_id(id)

    def get_doctor_day_schedule(self, doctor_id: int, day: date) -> DoctorDaySchedule | None:
        return self._business.get_doctor_day_schedule(doctor_id, day)

    # no cache here: it is the most recent data that matters (a patient
    # who has just edited or deleted their account)
    def get_client_by_user_id(self, user_id: int) -> Client | None:
        return self._business.get_client_by_user_id(user_id)

    def get_client_upcoming_appointments(self, client_id: int) -> list[Appointment]:
        return self._business.get_client_upcoming_appointments(client_id)

    def add_appointment(self, day: date, slot: Slot, client: Client) -> Appointment:
        return self._business.add_appointment(day, slot, client)

    def delete_appointment(self, appointment: Appointment) -> None:
        self._business.delete_appointment(appointment)

    # ------------------------------------------------ writes => reload

    def add_doctor(self, data: PersonData) -> Doctor:
        doctor = self._business.add_doctor(data)
        self._reload_doctors()
        return doctor

    def edit_doctor(self, id: int, data: PersonData) -> Doctor:
        doctor = self._business.edit_doctor(id, data)
        self._reload_doctors()
        return doctor

    def delete_doctor(self, id: int) -> None:
        self._business.delete_doctor(id)
        self._reload_doctors()

    def add_client(self, data: PersonData) -> Client:
        client = self._business.add_client(data)
        self._reload_clients()
        return client

    def edit_client(self, id: int, data: PersonData) -> Client:
        client = self._business.edit_client(id, data)
        self._reload_clients()
        return client

    def delete_client(self, id: int) -> None:
        self._business.delete_client(id)
        self._reload_clients()

The web layer cache and its interface to the business layer:

  • lines 32–40: the lists of doctors and patients are read once, at startup. They are used every time the calendar is displayed (the drop-down list of doctors, the list of patients): keeping them in memory avoids two SQL requests per page. A read error is not caught: it causes the application to crash at startup (see “Common Problems”);
  • lines 50–59: data retrieved from the cache. [get_doctor_by_user_id] identifies the logged-in doctor to preselect their own calendar;
  • lines 63–96: the other read operations, and the appointments, are simply relayed to the business layer. [get_client_by_user_id] does not use the cache: a patient who has just modified or deleted their account must see the most recent data;
  • lines 100–126: Each update to doctors or clients refreshes the relevant list. This is the benefit of a single entry point to the business layer: the rule “update, then refresh the cache” is implemented only here.

This unique object is used by requests processed in parallel across multiple threads: therefore, a list is never modified in place (by [append] or [remove]); instead, it is replaced entirely with a new one. In [Python], assigning an attribute is atomic: a thread reading the list sees either the old or the new version, never a partially constructed list. Therefore, no lock is needed.

[ApplicationModel] adheres to the [Business] contract (it has the same methods as [BusinessService]): for the controllers, it is the business layer. We could remove the cache without touching the controllers by directly placing [BusinessService] inside [app.extensions].

11.14.7. web/validation.py

# =========================================================================
# web/validation.py
# -------------------------------------------------------------------------
# What is needed to DISPLAY AGAIN a refused form: the input errors
# field by field, and the fields to outline in red.
# =========================================================================
from flask_wtf import FlaskForm

from rdvmedecins.models.view_models import FormState, Message


# -----------------------------------------------------------------------
# the validated form (see forms/forms.py) -> the dictionary
# field -> key of the message, keeping only the first message of each
# field: { "last_name": "ERRORS.LASTNAME_REQUIRED", ... }
#
# Our messages are translation keys ("ERRORS...."). A message that
# WTForms has written itself (in English) is replaced by the key
# ERRORS.INVALID_FORM.
# -----------------------------------------------------------------------
def errors_of(form: FlaskForm) -> dict[str, str]:
    errors = {}
    for field, messages in form.errors.items():
        message = str(messages[0])
        errors[field] = message if message.startswith("ERRORS.") else "ERRORS.INVALID_FORM"
    return errors


# -----------------------------------------------------------------------
# the form fields concerned by each "input" business error
# (a duplicate): they are outlined in red, the message being at the bottom of the page
# -----------------------------------------------------------------------
BUSINESS_ERROR_FIELDS = {
    "ERRORS.LOGIN_TAKEN": ["login"],
    "ERRORS.DOCTOR_ALREADY_EXISTS": ["first_name", "last_name"],
    "ERRORS.PATIENT_ALREADY_EXISTS": ["first_name", "last_name"],
}


# the state of a form to display (again): a message under each field
# refused by the validation (errors), and the fields to outline in red,
# those plus possible others (other_fields)
def state(errors: dict[str, str], other_fields: list[str] | None = None) -> FormState:
    return FormState(dict(errors), set(errors) | set(other_fields or []))


# THE error message of the page, displayed at the bottom of the page: [error] if
# there is one, otherwise "The form contains errors." if there are
# field errors, otherwise nothing
def page_message(errors: dict[str, str], error: Message | None) -> Message | None:
    if error is not None:
        return error
    return Message("ERRORS.INVALID_FORM") if errors else None

What is needed to redisplay a rejected form:

  • lines 21–26: the errors from a [WTForms] form (example 19), in the form of a “field → message key” dictionary, keeping only the first message for each field. The messages from our validators are translation keys ([ERRORS.LASTNAME_REQUIRED], see [forms/forms.py]); a message that [WTForms] would have generated itself, in English, is replaced by a generic key;
  • lines 33–36: Some business-logic errors pertain to specific fields—such as a username that’s already taken or a name with the same spelling. Their error messages appear at the bottom of the page, but the affected fields are highlighted in red so the user can immediately see what to correct (see the screenshot of the name with the same spelling above);
  • lines 43–44: the state of a form to be redisplayed: the messages below the fields, and all fields to be highlighted in red. [set(errors)] is the set of keys in the dictionary; the operator [|] performs a union of two sets;
  • lines 50–53: the general footer message.

11.14.8. web/utils.py

# =========================================================================
# web/utils.py
# -------------------------------------------------------------------------
# Small utility functions of the web layer:
#   - labels: "Mme Alice DURAND";
#   - dates: "yyyy-mm-dd", times "8:05", days "Wednesday 23 September 2026";
#   - input: reading a positive integer sent by the browser;
#   - user: the connected user, and their patient (Client entity).
# =========================================================================
import re
from datetime import date, datetime

from babel.dates import format_date
from flask import g

from rdvmedecins.auth.security import InvalidSession
from rdvmedecins.auth.connected_user import ConnectedUser
from rdvmedecins.entities import Client, Slot, Person

# ----------------------------------------------------------------- labels


# "Mme Alice DURAND"
def label(p: Person) -> str:
    return f"{p.title} {p.first_name} {p.last_name}"


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

# the format of the dates in the URLs and in the <input type="date"> field
ISO_FORMAT = re.compile(r"\d{4}-\d{2}-\d{2}")


# the date, if [text] is a date that exists in the yyyy-mm-dd format; None otherwise.
# strptime checks that the date exists: "2026-02-30" is refused
def valid_date(text: str | None) -> date | None:
    if text is None or not ISO_FORMAT.fullmatch(text):
        return None
    try:
        return datetime.strptime(text, "%Y-%m-%d").date()
    except ValueError:
        return None


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


# "8:05" rather than "8:5"
def format_time(h: int, m: int) -> str:
    return f"{h}:{m:02d}"


def time(s: Slot) -> str:
    return f"{format_time(s.start_hour, s.start_minute)} - {format_time(s.end_hour, s.end_minute)}"


# "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", according to the
# language of the request (g.language). The Babel format: EEEE the day of the
# week in full, d the day, MMMM the month in full, y the year
def format_day(day: date) -> str:
    return format_date(day, "EEEE d MMMM y", locale=g.language)


# ----------------------------------------------------------------- input


# "3" -> 3 ; "", "abc", "-2", "+2", None -> None
def positive_integer(value: str | None) -> int | None:
    if value is None or not re.fullmatch(r"\d{1,9}", value):
        return None
    n = int(value)
    return n if n > 0 else None


# ----------------------------------------------------------------- user


# the connected user (see auth/security.py). The protected actions are
# only called if they exist; as a precaution, their absence is handled
# as an expired session.
def user() -> ConnectedUser:
    if g.user is None:
        raise InvalidSession()
    return g.user


# -----------------------------------------------------------------------
# The patient (Client entity) that corresponds to the connected user.
#
# The JWT token only contains the id of the ACCOUNT (users.id). For a USER
# role, the corresponding client is found thanks to the clients.user column.
#
# If there is none, the account has been deleted since the
# login while its token is still valid: it is handled as an expired
# session (see InvalidSession).
# -----------------------------------------------------------------------
def connected_patient(application) -> Client:
    patient = application.get_client_by_user_id(user().user_id)
    if patient is None:
        raise InvalidSession()
    return patient

Utility functions used by multiple controllers:

  • line 24: a person’s title, “Mme Alice DURAND”;
  • lines 31–42: parsing a date in the [yyyy-mm-dd] format, which is the same as that used for the URL entries in the calendar ([day=2026-09-26]) and the [<input type="date">] field. The regular expression checks the format ([fullmatch]: the entire string must match), while [strptime] checks for the existence of the date. An invalid date results in [None]: the action determines the outcome;
  • lines 50–51: the format [02d] outputs a two-digit integer, padded with zeros: [8:05];
  • lines 61–62: a date written out in full, in the language of the query, with [Babel] (example 16). The format is explicitly specified here: [EEEE], the day of the week spelled out; [d], the day; [MMMM], the month spelled out; [y], the year;
  • lines 69–73: reading an identifier sent by the browser (a parameter of URL, a hidden field). [\d{1,9}]: one to nine digits, which excludes characters, spaces, and numbers too large for a [INT] column. Anything coming from the browser may have been modified: it must be verified before use;
  • lines 82–85: the logged-in user; its absence (which should not occur in a protected action) is treated as an expired session;
  • lines 98–102: the patient (the entity [Client]) corresponding to the logged-in user. The token contains only the account ID: the patient is identified by the [clients.user] column. If it does not exist, it means the account has been deleted since the user logged in.

11.15. The forms and templates for the views

11.15.1. forms/rules.py

# =========================================================================
# forms/rules.py
# -------------------------------------------------------------------------
# The input rules common to the "person" forms, and the FILTERS
# of WTForms (see example 19): functions that transform the value
# received BEFORE the validation.
# =========================================================================
TITLES = ("Mr", "Mme", "Melle")

# login: lower-case letters, digits, dot, hyphen, underscore - 30 characters at most
LOGIN_FORMAT = r"^[a-z0-9._-]{1,30}$"

# minimum length of a password CHOSEN by a patient
PASSWORD_MIN = 6


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


# "  Dupont " -> "dupont": a login is always in lower case
def login(value):
    value = clean(value)
    return value.lower() if isinstance(value, str) else value


# an empty field -> None; for a password, the spaces are NOT removed
# (they can be part of it)
def empty_to_none(value):
    return value if value else None


# the hidden field [version]: an integer, or None if it is missing or empty.
# A text that is not a number: a ValueError, that WTForms turns
# into a field error, with this message (a translation key)
def version(value):
    if value in (None, ""):
        return None
    try:
        return int(value)
    except (TypeError, ValueError):
        raise ValueError("ERRORS.INVALID_FORM")

Input rules common to all forms, and the [WTForms] filters (example 19):

  • lines 8–14: allowed titles, the format of a username (lowercase letters, numbers, period, hyphen, underscore, up to 30 characters), and the minimum length of a password chosen by a patient;
  • Lines 18–21: The spaces surrounding an entry are removed; an empty entry becomes [None]. [a or b] is equivalent to [b] if [a] is “false” (in this case, an empty string);
  • lines 25–27: a username is always in lowercase;
  • lines 32–33: for a password, spaces are not removed, as they may be part of the password;
  • lines 39–45: the hidden version field (the optimistic lock): an integer, or [None]. A filter that returns a value like [ValueError] triggers a field error, with the error message matching that of the exception: a browser that submits anything other than a number would receive the “invalid form” error.

11.15.2. forms/forms.py

# =========================================================================
# forms/forms.py
# -------------------------------------------------------------------------
# The fields of the "person" forms and their constraints (see examples
# 19 and 28). Three forms share the same identity fields
# (title, first name, last name):
#
#   PersonFields                  title, first name, last name
#    ├─ PersonForm                + OPTIONAL login / password, version
#    │                              (the ADMIN adds or edits a doctor / client)
#    ├─ RegistrationForm          + MANDATORY login / password / confirmation
#    │                              (a patient creates their account)
#    └─ MyAccountForm             + login, optional new password,
#                                   mandatory CURRENT password, version
#                                   (a patient edits their account)
#
# A WTForms class that inherits from another one also inherits its fields.
#
# BINDING: PersonForm() itself reads the fields with the same name of the
# current POST request (request.form), and applies its filters to them
# (superfluous spaces removed, login in lower case...).
# VALIDATION: form.validate() then checks the constraints of each
# field, in order; the first one that fails stores its message in
# form.<field>.errors. These messages are translation KEYS (see i18n/*.json).
#
# The forms are only used here to read and validate the input: the views
# write their <input> tags themselves, from the view models
# (see models/view_models.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 rules


# ------------------------------------------------------------------ common base
class PersonFields(FlaskForm):

    title = StringField(validators=[AnyOf(rules.TITLES, message="ERRORS.INVALID_TITLE")])

    # maximum lengths = those of the SQL columns (VARCHAR(20)).
    # DataRequired stops the validation of the field if it is empty: the message
    # of Length is therefore never added to that one
    first_name = StringField(filters=[rules.clean], validators=[
        DataRequired("ERRORS.FIRSTNAME_REQUIRED"),
        Length(max=20, message="ERRORS.FIRSTNAME_TOO_LONG")])

    last_name = StringField(filters=[rules.clean], validators=[
        DataRequired("ERRORS.LASTNAME_REQUIRED"),
        Length(max=20, message="ERRORS.LASTNAME_TOO_LONG")])


# ------------------------------------------------------------------ ADMIN
class PersonForm(PersonFields):

    # optional (see business/person_data.py): Optional stops the validation of an
    # empty field, without error
    login = StringField(filters=[rules.login], validators=[
        Optional(), Regexp(rules.LOGIN_FORMAT, message="ERRORS.INVALID_LOGIN")])

    # optional; 72 characters at most (beyond that, bcrypt ignores the rest)
    password = PasswordField(filters=[rules.empty_to_none], validators=[
        Optional(strip_whitespace=False), Length(max=72, message="ERRORS.PASSWORD_TOO_LONG")])

    # hidden field of the edit form (optimistic lock)
    version = StringField(filters=[rules.version])


# ------------------------------------------------------------------ registration of a patient
class RegistrationForm(PersonFields):

    # InputRequired: the input must not be empty
    login = StringField(filters=[rules.login], validators=[
        InputRequired("ERRORS.LOGIN_REQUIRED"),
        Regexp(rules.LOGIN_FORMAT, message="ERRORS.INVALID_LOGIN")])

    # two Length constraints, each with its message
    password = PasswordField(filters=[rules.empty_to_none], validators=[
        InputRequired("ERRORS.PASSWORD_REQUIRED"),
        Length(min=rules.PASSWORD_MIN, message="ERRORS.PASSWORD_TOO_SHORT"),
        Length(max=72, message="ERRORS.PASSWORD_TOO_LONG")])

    confirmation = PasswordField(filters=[rules.empty_to_none], validators=[
        InputRequired("ERRORS.CONFIRMATION_REQUIRED")])


# ------------------------------------------------------------------ "Manage my account"
class MyAccountForm(PersonFields):

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

    # optional: empty = password unchanged
    password = PasswordField(filters=[rules.empty_to_none], validators=[
        Optional(strip_whitespace=False),
        Length(min=rules.PASSWORD_MIN, message="ERRORS.PASSWORD_TOO_SHORT"),
        Length(max=72, message="ERRORS.PASSWORD_TOO_LONG")])

    confirmation = PasswordField(filters=[rules.empty_to_none])

    # the CURRENT password, required for any edit of the account
    current_password = PasswordField(filters=[rules.empty_to_none], validators=[
        InputRequired("ERRORS.CURRENT_PASSWORD_REQUIRED")])

    version = StringField(filters=[rules.version])
  • Lines 38–51: the fields common to all three forms: title, first name, last name. The validator messages are translation keys: the view will translate them into the page’s language. The [AnyOf] validator accepts a value from a list. The maximum lengths are those of the SQL columns: a longer entry would be rejected by the database;
  • lines 55–67: the administrator form: a login and an optional password (the [Optional] validator stops validating an empty field without reporting an error; [strip_whitespace=False]: a password consisting of spaces is not considered empty), and the version;
  • lines 71–85: the patient registration form: username, password, and confirmation are required. Two validators, [Length], each with its own message: too short, too long;
  • lines 89–107: “Manage my account”: an optional new password, and the current password, which is required for any changes.

A [WTForms] class that inherits from another class, inherits its fields: the three forms share the fields of [PersonFields]. The comparison of the password and its confirmation is not handled by the [EqualTo] validator (Example 19), but is performed by the action, which thus selects the message and the relevant field.

Note that these forms are used only to read and validate user input: the views do not call the [WTForms] fields to render them (Example 18); instead, they write their own [<input>] tags based on the view templates. This approach keeps the views identical across all three forms and gives the action complete control over what is displayed (never a password, for example). This is also the approach used in previous versions of the application.

11.15.3. models/view_models.py

# =========================================================================
# models/view_models.py
# -------------------------------------------------------------------------
# The "view models": what each action passes to its
# view, under the name [model]. They are dataclasses: objects built
# in one go by the action, that the view merely reads
# ({{ model.schedule.rows }}...).
#
# Rule followed by all the pages: no logic in the views. The
# actions prepare data ALREADY formatted ("Mme Marie
# PELISSIER", "8:00 - 8:20", selected = True...) and already filtered according to
# the role of the user; the views merely place them in the HTML.
#
# Each page model has two fields, read by the layout (templates/layout.html):
# page_title, the key of the page title, and error, THE error message of
# the page, displayed at the bottom of the page (None: no error).
# =========================================================================
from dataclasses import dataclass, field

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


# a message to translate: its key, and its parameters
@dataclass(frozen=True)
class Message:
    key: str
    params: dict[str, str] = field(default_factory=dict)


# an option of a drop-down list (doctor, patient)
@dataclass(frozen=True)
class OptionView:
    id: int
    label: str
    selected: bool


# ------------------------------------------------------------------ login
@dataclass
class LoginModel:
    page_title: str
    error: Message | None
    login: str
    captcha: Captcha


# ------------------------------------------------------------------ schedule
@dataclass(frozen=True)
class ScheduleRowView:
    slot_id: int
    time: str  # "8:00 - 8:20"
    free: bool
    patient: str | None  # name displayed for a booked slot; None = "Booked"
    mine: bool  # the appointment of the connected patient
    bookable: bool  # display the "Book" button?
    book_url: str
    appointment_id: int | None  # filled only if the "Cancel" button is displayed


@dataclass(frozen=True)
class ScheduleView:
    doctor: str
    day: str
    rows: list[ScheduleRowView]


@dataclass(frozen=True)
class BookingView:
    slot_id: int
    time: str
    clients: list[OptionView]  # ADMIN / DOCTOR: the patients to choose from
    patient: str | None  # USER: the patient is themselves


@dataclass
class ScheduleModel:
    page_title: str
    error: Message | None
    doctors: list[OptionView]
    doctor_id: int | None
    day: str  # "yyyy-mm-dd"
    schedule: ScheduleView | None
    booking: BookingView | None
    back_url: str  # the displayed schedule, without booking dialog
    # patient who already has an upcoming appointment: the message that explains
    # why there is no "Book" button
    info: str | None


# ------------------------------------------------------------------ "person" forms

# the values displayed in the fields of the form (never the password)
@dataclass(frozen=True)
class PersonValues:
    title: str = "Mr"
    first_name: str = ""
    last_name: str = ""
    login: str = ""
    version: int | None = None


# the state of a refused form: the message displayed UNDER each field
# (field -> key of the message), and the fields to outline in red (Bootstrap
# class is-invalid): those that have a message, plus possible others
# (duplicate first + last names, login taken...)
@dataclass(frozen=True)
class FormState:
    errors: dict[str, str] = field(default_factory=dict)
    invalid_fields: set[str] = field(default_factory=set)

    def error_of(self, field: str) -> str | None:
        return self.errors.get(field)

    def invalid_class(self, field: str) -> str:
        return "is-invalid" if field in self.invalid_fields else ""


# an option of the list of titles
@dataclass(frozen=True)
class TitleOption:
    value: str
    selected: bool


# what the common fragment person_fields.html uses (title, first name, last name):
# a base class, from which the three form models inherit
@dataclass
class FormView:
    page_title: str
    error: Message | None
    values: PersonValues
    state: FormState

    # a property: is read like an attribute (model.titles), but is computed
    @property
    def titles(self) -> list[TitleOption]:
        return [TitleOption(t, t == self.values.title) for t in rules.TITLES]

    @property
    def password_min(self) -> int:
        return rules.PASSWORD_MIN


@dataclass
class PersonFormModel(FormView):
    creating: bool  # True: add; False: edit
    action: str  # URL of the POST
    list_url: str


@dataclass
class RegistrationModel(FormView):
    pass


@dataclass
class MyAccountModel(FormView):
    pass


# ------------------------------------------------------------------ doctor / client lists

# the translation keys specific to each type of person
@dataclass(frozen=True)
class PersonKeys:
    title: str  # "Doctors"
    add: str  # "Add a doctor"
    edit: str  # "Edit a doctor"
    empty: str  # "No doctors."
    confirm_delete: str  # "Delete this doctor and their account?"
    added: str  # "Doctor {name} has been added (login: {login})."
    edited: str
    deleted: str


@dataclass(frozen=True)
class PersonView:
    title: str
    first_name: str
    last_name: str
    login: str
    edit_url: str
    delete_url: str


@dataclass
class PersonListModel:
    page_title: str
    error: Message | None
    keys: PersonKeys
    add_url: str
    persons: list[PersonView]


# ------------------------------------------------------------------ patient's area
@dataclass(frozen=True)
class AppointmentView:
    id: int
    day: str
    time: str
    doctor: str
    schedule_url: str


@dataclass
class AreaModel:
    page_title: str
    error: Message | None
    patient: str
    appointments: list[AppointmentView]


# ------------------------------------------------------------------ error page
@dataclass
class ErrorModel:
    page_title: str
    error: Message | None
    code: int
    message: str

View models (*view models*): what each action passes to its view, under the name [model]. The rule followed by all pages: no logic in the views. Actions prepare data that is already formatted (“Mme Marie PELISSIER,” “8:00 – 8:20”) and already filtered according to the user’s role; views simply place this data into the HTML.

  • lines 26–28: a message to be translated: a key and its parameters. [field(default_factory=dict)]: the default value is a new, empty dictionary for each object. You cannot write [= {}]: this dictionary would be created only once and shared by all objects;
  • lines 33–36: an option from a drop-down list;
  • lines 41–45: the login page template. Each page template has two fields read by the template: [page_title], the page title key, and [error], the error message displayed at the bottom of the page;
  • lines 50–58: a row in the calendar, already populated for the logged-in user: [patient] is equivalent to [None] for a time slot occupied by another patient, when the user is a patient (the view will display “Booked”) ; [bookable] indicates whether to display the “Book” button; [appointment_id] is populated only if the “Cancel” button is to be displayed. All these decisions are made by the action;
  • lines 69–73: the booking window: the list of patients for an administrator or doctor, the patient’s name for a patient;
  • lines 77–88: the calendar page template;
  • lines 108–116: the state of a rejected form, with two methods called by the views: the message for a field, and the CSS class that highlights it in red;
  • lines 129–142: the base class for the three form templates, used by the common fragment [person_fields.html]. Its two properties calculate the list of titles (including the selected title) and the minimum length of a password;
  • Lines 146–158: The three form templates inherit from [FormView]. A [dataclass] that inherits from another adds its fields to those of its parent class;
  • lines 166–174: translation keys specific to doctors or clients: the same view displays both lists;
  • lines 198–211: the patient area;
  • lines 216–220: the error page.

11.16. The Web Layer: Controllers

The [controllers] folder contains one blueprint per part of the application (the login controller, [controllers/auth.py], was introduced with authentication):

Blueprint
Addresses
[language]
[GET /language/<code>]
[schedule]
[GET /schedule], [POST /schedule/book], [POST /schedule/cancel]
[doctors], [clients]
[/doctors/...], [/clients/...]: list, add, edit, delete
[registration]
[POST /register/access], [GET], and [POST /register]
[area]
[/my-area], [/my-area/cancel], [/my-account], [/my-account/delete]

All actions follow the same pattern: read and validate the request, call the business layer (via [ApplicationModel]), build the view model; or, for an action such as POST, post a flash message and redirect (flow PRG, example 20).

11.16.1. controllers/language.py

# =========================================================================
# controllers/language.py
# -------------------------------------------------------------------------
# GET /language/fr, GET /language/en: the two FR / EN buttons of the
# navigation bar. Public route: one must be able to change the language before
# logging in.
#
# The choice is remembered in a cookie: on each following request, the
# set_language function (see rdvmedecins/__init__.py) reads it again and sets the
# language of the request (g.language). Then we go back to the page we came from.
# =========================================================================
from datetime import timedelta
from urllib.parse import urlsplit

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

from rdvmedecins.auth.security import public
from rdvmedecins.web.translator import LANGUAGE_COOKIE, LANGUAGES

language = Blueprint("language", __name__)


@language.get("/language/<code>")
@public
def change(code: str):
    if code not in LANGUAGES:
        abort(404)  # -> 404 error page (see web/errors.py)
    response = redirect(origin_page())
    response.set_cookie(LANGUAGE_COOKIE, code, max_age=timedelta(days=365), path="/",
                        httponly=True, samesite="Lax", secure=current_app.config["COOKIE_SECURE"])
    return response


# -----------------------------------------------------------------------
# the previous page, according to the HTTP "Referer" header sent by the
# browser. Only its path (/schedule?...) is kept: redirecting to
# a full address provided by the request would make it possible to send
# the user to another site (so-called "open redirect" flaw).
# -----------------------------------------------------------------------
def origin_page() -> str:
    referer = request.headers.get("Referer")
    if not referer:
        return "/"
    url = urlsplit(referer)
    path = url.path
    # "//site.com/..." would be understood by the browser as an address
    # on ANOTHER site: refused too
    if not url.scheme or not path.startswith("/") or path.startswith("//") \
            or path.startswith("/language/"):
        return "/"
    return f"{path}?{url.query}" if url.query else path

The language selection from Example 22, with two additional precautions in [origin_page] (lines 40–51): a [Referer] without a schema ([http], [https]) is not a complete address, and a [Referer]—which would itself be a [/language/...] address—would redirect to this action, creating a loop. In both cases, the user is redirected back to the site’s root. The cookie’s lifetime is specified by a [timedelta]: [set_cookie] accepts this just as well as a number of seconds.

11.16.2. controllers/schedule.py

The application’s main page, for all roles.

# =========================================================================
# controllers/schedule.py
# -------------------------------------------------------------------------
# The schedule page: choice of a doctor and a day, schedule of this
# doctor on that day, booking and cancelling of appointments.
#
#   GET  /schedule?doctorId=1&day=2026-09-23              the page
#   GET  /schedule?doctorId=1&day=2026-09-23&book=7        the same page, with
#                                                          the booking dialog
#                                                          of slot 7 open
#   POST /schedule/book                                    creates an appointment
#   POST /schedule/cancel                                  cancels an appointment
#
# The whole page is described by its URL: it can be reloaded, bookmarked,
# the "Back" button of the browser can be used - without a single line
# of JavaScript.
#
# What each one sees and can do:
#
#   | role          | booked slot displayed             | Book               | Cancel  |
#   |---------------|-----------------------------------|--------------------|---------|
#   | ADMIN, DOCTOR | name of the patient               | for a patient      | yes     |
#   |               |                                   | chosen in a list   |         |
#   | USER (patient)| "Booked", or THEIR name if it is  | for themselves, if | no (*)  |
#   |               | their own appointment             | they have no       |         |
#   |               |                                   | upcoming appt      |         |
#
#   (*) the patient cancels their appointment from their "My area" page.
#
# CONFIDENTIALITY: for a patient, the name of the OTHER patients is not
# simply hidden by the view - it is never put in the model
# passed to the view. The HTML sent to the browser therefore does not contain it,
# not even hidden (a simple "View page source" would reveal it otherwise).
#
# No booking in the past: the "Book" button only appears
# for today and the following days (and the business layer would refuse it
# anyway).
#
# Security: all the /schedule URLs require an authenticated user
# (see auth/security.py: everything that is not @public). @roles_required
# on an action additionally requires one of the listed roles. Without a valid token:
# back to /login; insufficient role: 403 page.
# =========================================================================
from dataclasses import dataclass
from datetime import date

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

from rdvmedecins.auth.security import roles_required
from rdvmedecins.domain.schedule import DoctorDaySchedule
from rdvmedecins.entities import Client, Person, Role
from rdvmedecins.business import dates
from rdvmedecins.business.exception import BusinessException
from rdvmedecins.models.view_models import (ScheduleModel, ScheduleView, ScheduleRowView, Message,
                                            OptionView, BookingView)
from rdvmedecins.web import utils
from rdvmedecins.web.flash import flash_error, flash_success
from rdvmedecins.web.services import application

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


# what the controller knows about the user, to decide what to display
@dataclass(frozen=True)
class Rights:
    manager: bool  # ADMIN or DOCTOR
    patient: Client | None  # the connected client (USER role), otherwise None
    patient_can_book: bool  # USER without an upcoming appointment


# -----------------------------------------------------------------------
# GET /schedule
# The URL parameters are read raw (request.args.get), to
# distinguish a MISSING parameter (None) from an EMPTY parameter (?doctorId=).
# -----------------------------------------------------------------------
@schedule.get("")
def show():
    error = None
    user = utils.user()
    rights = compute_rights()

    # the day: that of the URL if it is valid, otherwise today
    day = dates.today()
    raw_day = request.args.get("day")
    if raw_day is not None:
        requested_day = utils.valid_date(raw_day)
        if requested_day is not None:
            day = requested_day
        else:
            error = "ERRORS.INVALID_DATE"
    day_bookable = day >= dates.today()

    # the doctor: that of the URL; failing that, for a connected doctor, themselves
    raw_doctor_id = request.args.get("doctorId")
    doctor_id = utils.positive_integer(raw_doctor_id)
    if raw_doctor_id is None and user.role == Role.DOCTOR:
        doctor = application().get_doctor_by_user_id(user.user_id)
        doctor_id = doctor.id if doctor else None

    # the drop-down list of the doctors
    doctors = [option(d, d.id == doctor_id) for d in application().get_all_doctors()]

    # the schedule, if a doctor is chosen (and the date valid)
    view = None
    if doctor_id is not None and error is None:
        doctor_schedule = application().get_doctor_day_schedule(doctor_id, day)
        if doctor_schedule is None:
            error = "ERRORS.DOCTOR_NOT_FOUND"
        else:
            view = schedule_view(doctor_schedule, rights, day_bookable)

    # the booking dialog: only if the requested slot is
    # bookable by this user
    booking = None
    slot_id = utils.positive_integer(request.args.get("book"))
    if view is not None and slot_id is not None:
        row = next((r for r in view.rows if r.slot_id == slot_id and r.bookable),
                   None)
        if row is not None:
            booking = BookingView(
                row.slot_id,
                row.time,
                [option(c, False) for c in application().get_all_clients()]
                if rights.manager else [],
                utils.label(rights.patient) if rights.patient else None)

    model = ScheduleModel(
        page_title="SCHEDULE.PAGE_TITLE",
        error=Message(error) if error else None,
        doctors=doctors,
        doctor_id=doctor_id,
        day=utils.iso(day),
        schedule=view,
        booking=booking,
        back_url=schedule_url(doctor_id, day) if doctor_id is not None else url_for("schedule.show"),
        info=patient_info(rights, view))
    return render_template("schedule.html", model=model)


# -----------------------------------------------------------------------
# POST /schedule/book   fields: doctorId, day, slotId, clientId
#
# For a patient (USER), the clientId field is IGNORED: they always book
# for themselves. Trusting a field sent by the
# browser would allow a clever patient to book in the name of another one.
#
# "Post / Redirect / Get" pattern: whatever the result, no page is
# built here - a flash message is set and we redirect
# to the schedule (see example 20).
# -----------------------------------------------------------------------
@schedule.post("/book")
@roles_required(Role.ADMIN, Role.DOCTOR, Role.USER)
def book():
    doctor_id, day = read_schedule_request()
    slot_id = utils.positive_integer(request.form.get("slotId"))
    if doctor_id is None or slot_id is None:
        abort(400)  # -> "invalid request" page (see web/errors.py)
    user = utils.user()

    if user.is_patient:
        client = utils.connected_patient(application())
    else:
        client_id = utils.positive_integer(request.form.get("clientId"))
        if client_id is None:
            # no patient chosen: the booking dialog is opened again
            flash_error("ERRORS.CHOOSE_PATIENT")
            return redirect(schedule_url(doctor_id, day, slot_id))
        client = application().get_client_by_id(client_id)

    slot = application().get_slot_by_id(slot_id)
    if client is None:
        flash_error("ERRORS.PATIENT_NOT_FOUND")
    elif slot is None or slot.doctor_id != doctor_id:
        flash_error("ERRORS.SLOT_NOT_FOUND")
    else:
        try:
            # the rules (not in the past, a single upcoming appointment per
            # patient, free slot) are checked by the business layer
            application().add_appointment(day, slot, client)
            flash_success("SCHEDULE.BOOKED")
        except BusinessException as e:
            # same rule, but a message in the 2nd person when it is the
            # patient themselves who books ("You already have..." rather
            # than "This patient..."). The other exceptions (real
            # anomalies) are not caught: 500 page.
            if e.key == "ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT" and user.is_patient:
                flash_error("SCHEDULE.ALREADY_BOOKED_INFO")
            else:
                flash_error(e.key, **e.params)
    return redirect(schedule_url(doctor_id, day))


# -----------------------------------------------------------------------
# POST /schedule/cancel   fields: appointmentId, doctorId, day
# (a patient cancels THEIR appointment from "My area", not from here)
# -----------------------------------------------------------------------
@schedule.post("/cancel")
@roles_required(Role.ADMIN, Role.DOCTOR)
def cancel():
    doctor_id, day = read_schedule_request()
    appointment_id = utils.positive_integer(request.form.get("appointmentId"))
    if doctor_id is None or appointment_id is None:
        abort(400)
    appointment = application().get_appointment_by_id(appointment_id)
    if appointment is None:
        # already cancelled (by someone else, or double click)
        flash_error("ERRORS.APPOINTMENT_NOT_FOUND")
    else:
        application().delete_appointment(appointment)
        flash_success("SCHEDULE.CANCELLED")
    return redirect(schedule_url(doctor_id, day))


# -----------------------------------------------------------------------
# who is connected, and what are they allowed to do on this page?
# -----------------------------------------------------------------------
def compute_rights() -> Rights:
    if not utils.user().is_patient:
        return Rights(True, None, False)
    patient = utils.connected_patient(application())
    upcoming = application().get_client_upcoming_appointments(patient.id)
    return Rights(False, patient, not upcoming)


# -----------------------------------------------------------------------
# the doctor's schedule, formatted for the view - and filtered according to the rights
# -----------------------------------------------------------------------
def schedule_view(doctor_schedule: DoctorDaySchedule, rights: Rights, day_bookable: bool) -> ScheduleView:
    doctor_id = doctor_schedule.doctor.id
    day = doctor_schedule.day
    rows = []
    for dds in doctor_schedule.doctor_day_slots:
        slot, appointment = dds.slot, dds.appointment
        free = appointment is None
        # the appointment of the connected patient?
        mine = appointment is not None and rights.patient is not None and appointment.client_id == rights.patient.id
        rows.append(ScheduleRowView(
            slot_id=slot.id,
            time=utils.time(slot),
            free=free,
            # THE confidentiality point: a patient only receives the name
            # of THEIR appointment; for the others, None (the view displays "Booked")
            patient=utils.label(appointment.client) if appointment is not None and (rights.manager or mine) else None,
            mine=mine,
            bookable=free and day_bookable and (rights.manager or rights.patient_can_book),
            book_url=schedule_url(doctor_id, day, slot.id),
            appointment_id=appointment.id if appointment is not None and rights.manager else None))
    return ScheduleView(utils.label(doctor_schedule.doctor), utils.format_day(day), rows)


# ----------------------------------------------------------------- utility functions

# the hidden fields doctorId / day of the POST forms; (None, None) if they are invalid
def read_schedule_request() -> tuple[int | None, date | None]:
    doctor_id = utils.positive_integer(request.form.get("doctorId"))
    day = utils.valid_date(request.form.get("day"))
    return (None, None) if doctor_id is None or day is None else (doctor_id, day)


# /schedule?doctorId=1&day=2026-09-23[&book=7]: url_for adds at the end of
# the URL the parameters that are not variables of the path
def schedule_url(doctor_id: int, day: date, book: int | None = None) -> str:
    if book is None:
        return url_for("schedule.show", doctorId=doctor_id, day=utils.iso(day))
    return url_for("schedule.show", doctorId=doctor_id, day=utils.iso(day), book=book)


def option(p: Person, selected: bool) -> OptionView:
    return OptionView(p.id, utils.label(p), selected)


# -----------------------------------------------------------------------
# the information message of a patient who already has an upcoming appointment
# (why there is no "Book" button), or None. Useless if THEIR
# appointment appears in the displayed schedule (in blue): this is the case just
# after they have just booked it, when the message would be out of place.
# -----------------------------------------------------------------------
def patient_info(rights: Rights, view: ScheduleView | None) -> str | None:
    if rights.patient is None or rights.patient_can_book:
        return None
    appointment_shown = view is not None and any(row.mine for row in view.rows)
    return None if appointment_shown else "SCHEDULE.ALREADY_BOOKED_INFO"

11.16.2.1. The URL for the page

The entire page is described by its URL: [/schedule?doctorId=1&day=2026-09-26] displays Doctor #1’s schedule for that day; [&book=4] also opens the booking window for slot #4. The appointment booking window is therefore not opened by [JavaScript]: the server generates a page containing the window. This means you can reload the page, bookmark it, use the browser’s “Back” button, and the application works without [JavaScript] (the application’s only script is used for confirmation windows, and it works without it as well; see example 21).

11.16.2.2. GET /schedule

  • lines 65–68: what the action knows about the user to determine the display: a “manager” (ADMIN or DOCTOR) or a patient, who may or may not make a reservation;
  • lines 83–91: the day: the day specified by URL if it is valid; otherwise, today. Parameters are read in raw format ([request.args.get]) to distinguish between a missing parameter ([None]: the default value is used, with no error) and an invalid parameter (an error is reported). Only today and the following days are bookable;
  • lines 94–98: the doctor: the one from URL; otherwise, for a logged-in doctor, the doctor themselves: a doctor can view their daily schedule directly upon logging in;
  • line 101: the drop-down list of doctors, read from the cache, with the selected doctor highlighted;
  • lines 104–110: the schedule, if a doctor is selected and the date is valid. A nonexistent doctor (an ID manually modified in URL) triggers an error message, not an error page;
  • lines 114–125: the booking window, only if the requested time slot exists and is bookable by this user: a patient who adds [&book=2] to URL for a time slot that is already booked will see nothing. [next(...)] searches for the corresponding calendar entry (example 21). The window displays a list of patients to an administrator; for each patient, it displays their own name;
  • Lines 127–137: The page template. [back_url] is the URL for the calendar without the booking window; the "Cancel" button in the window takes you there.

11.16.2.3. POST /schedule/book

  • line 152: All roles can make reservations. The decorator documents the rule and protects it in case a fourth role were to appear in the future;
  • lines 154–157: the form’s hidden fields. Invalid fields can only result from a manually crafted request: error 400;
  • lines 160–168: the patient for the appointment. For a patient, it is always the patient themselves: the field [clientId] is ignored. Trusting a field sent by the browser would allow a patient to book on behalf of another. For an administrator who has not selected a patient, the booking window reopens with a message;
  • lines 170–189: validations (the patient exists, the time slot exists and belongs to the doctor in the calendar), followed by the booking. Business rules are verified by the business logic layer; a [BusinessException] becomes a flash error message. The message “only one upcoming appointment” is adapted when the patient makes the booking themselves (“You already have...” rather than “This patient already has...”);
  • line 190: in all cases, return to the calendar (schema PRG).

11.16.2.4. POST /schedule/cancel

  • Lines 197–211: Canceling an appointment, reserved for administrators (a patient cancels their appointment from their account). An appointment that has already been canceled (by someone else or by double-clicking) displays a message, not an error.

11.16.2.5. Permissions, the formatted calendar, and utility functions

  • lines 217–222: user permissions. For a patient, it is necessary to view their upcoming appointments: an empty list is “incorrect,” hence [not upcoming];
  • lines 228–248: the domain’s calendar, formatted for display and filtered according to permissions. This is where patient confidentiality comes into play (line 243): for a patient, the names of other patients are not merely hidden from view; they are never included in the template. The HTML sent to the browser therefore does not contain it, even if hidden (otherwise, a simple “View Source” would reveal it). The “Book” button appears only when there is an available time slot, a bookable day, and a user with the right to make a reservation; the “Cancel” button appears only for an administrator;
  • lines 254–257: the hidden fields [doctorId] and [day] in the POST forms, validated;
  • lines 262–265: the URL from the calendar. [url_for] adds the named parameters that are not path variables to URL as parameters (example 16);
  • lines 278–282: the informational message for a patient who already has an upcoming appointment: it explains why the “Book” buttons are missing. It is not displayed if the patient’s own appointment appears in the calendar (in blue), which is the case immediately after booking. [any(...)] returns [True] if either condition is true.

11.16.3. controllers/persons.py

The management of doctors and clients, reserved for the administrator, is identical: same list, same form, same rules. Rather than writing the same actions twice, we write them once, in a blueprint factory.

# =========================================================================
# controllers/persons.py
# -------------------------------------------------------------------------
# The management of the doctors and that of the clients (reserved for the ADMIN) are
# IDENTICAL: same list, same form, same rules. Rather than
# writing the same actions twice, they are written once in a
# BLUEPRINT FACTORY: the create_blueprint function builds a
# complete blueprint, from a "configuration" that says WHAT to call and
# WHICH labels to display. controllers/doctors.py and controllers/clients.py
# each call it with their own. We thus get, for each of them:
#
#   GET  /doctors                    list
#   GET  /doctors/add                empty form
#   POST /doctors/add                creation
#   GET  /doctors/<id>/edit          pre-filled form
#   POST /doctors/<id>/edit          edit
#   POST /doctors/<id>/delete        deletion
#
# (and the same URLs under /clients). The views are common as well:
# templates/persons/list.html and templates/persons/form.html.
#
# SECURITY: a before_request OF THE BLUEPRINT requires the ADMIN role. It
# runs before EACH action of the blueprint - including those that would be
# added to it later: the rule is written once and for all.
# =========================================================================
from collections.abc import Callable
from dataclasses import dataclass

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

from rdvmedecins.auth.security import require_roles
from rdvmedecins.entities import Person, Role
from rdvmedecins.forms.forms import PersonForm
from rdvmedecins.business.person_data import PersonData
from rdvmedecins.business.exception import BusinessException
from rdvmedecins.models.view_models import (PersonKeys, FormState, PersonFormModel,
                                            PersonListModel, Message, PersonView,
                                            PersonValues)
from rdvmedecins.web import validation
from rdvmedecins.web.flash import flash_error, flash_success


# -----------------------------------------------------------------------
# what each type of person must provide. The last five fields
# are FUNCTIONS: doctors.py and clients.py put lambdas in them that
# call the methods of the ApplicationModel (get_all_doctors...).
# -----------------------------------------------------------------------
@dataclass(frozen=True)
class PersonsConfig:
    name: str  # "doctors" or "clients": the name of the blueprint, and the start of the URLs
    keys: PersonKeys
    list: Callable[[], list[Person]]  # () -> list
    find: Callable[[int], Person | None]  # id -> person
    add: Callable[[PersonData], Person]  # data -> created person
    edit: Callable[[int, PersonData], Person]  # (id, data) -> edited person
    delete: Callable[[int], None]  # id -> nothing


def create_blueprint(config: PersonsConfig) -> Blueprint:
    bp = Blueprint(config.name, __name__, url_prefix=f"/{config.name}")

    # the URL of an action of THIS blueprint: url("list_persons") -> "/doctors"
    def url(action: str, **params) -> str:
        return url_for(f"{config.name}.{action}", **params)

    # all the actions of the blueprint are reserved for the ADMIN
    @bp.before_request
    def admin_only():
        require_roles(Role.ADMIN)

    # -------------------------------------------------------------------
    # GET /doctors (or /clients)
    # -------------------------------------------------------------------
    @bp.get("")
    def list_persons():
        persons = [PersonView(p.title, p.first_name, p.last_name, login(p),
                              url("edit_form", id=p.id),
                              url("delete", id=p.id))
                   for p in config.list()]
        model = PersonListModel(config.keys.title, None, config.keys, url("add_form"),
                                persons)
        return render_template("persons/list.html", model=model)

    # -------------------------------------------------------------------
    # GET /doctors/add
    # -------------------------------------------------------------------
    @bp.get("/add")
    def add_form():
        return show_form(None, PersonValues(), {}, None, None)

    # -------------------------------------------------------------------
    # POST /doctors/add
    # PersonForm() reads the input; validate() checks it.
    # -------------------------------------------------------------------
    @bp.post("/add")
    def add():
        form = PersonForm()
        if not form.validate():
            return show_form(None, entered_values(form), validation.errors_of(form),
                             None, None)
        try:
            person = config.add(data(form))
        except BusinessException as e:
            return show_business_error(None, entered_values(form), e)
        flash_success(config.keys.added, **params(person))
        return redirect(url("list_persons"))

    # -------------------------------------------------------------------
    # GET /doctors/<id>/edit
    # <int:id>: the id must be made of digits - /doctors/abc/edit does
    # not match any route (404 page)
    # -------------------------------------------------------------------
    @bp.get("/<int:id>/edit")
    def edit_form(id: int):
        person = config.find(id)
        if person is None:
            abort(404)
        values = PersonValues(person.title, person.first_name, person.last_name, login(person),
                              person.version)
        return show_form(id, values, {}, None, None)

    # -------------------------------------------------------------------
    # POST /doctors/<id>/edit
    # -------------------------------------------------------------------
    @bp.post("/<int:id>/edit")
    def edit(id: int):
        form = PersonForm()
        if not form.validate():
            return show_form(id, entered_values(form), validation.errors_of(form),
                             None, None)
        try:
            person = config.edit(id, data(form))
        except BusinessException as e:
            return show_business_error(id, entered_values(form), e)
        flash_success(config.keys.edited, **params(person))
        return redirect(url("list_persons"))

    # -------------------------------------------------------------------
    # POST /doctors/<id>/delete
    # (the "Are you sure?" confirmation is requested before the form
    # is sent, see static/js/app.js)
    # -------------------------------------------------------------------
    @bp.post("/<int:id>/delete")
    def delete(id: int):
        person = config.find(id)
        try:
            config.delete(id)
            flash_success(config.keys.deleted, **params(person))
        except BusinessException as e:
            flash_error(e.key, **e.params)
        return redirect(url("list_persons"))

    # ----------------------------------------------------------------- internal functions

    # id None: creation form; otherwise: edit form.
    # errors: field -> key of the message; error: the key of the message at the bottom
    # of the page; other_fields: additional fields to outline in red
    def show_form(id: int | None, values: PersonValues, errors: dict[str, str],
                  error: str | None, other_fields: list[str] | None):
        creating = id is None
        state: FormState = validation.state(errors, other_fields)
        model = PersonFormModel(
            page_title=config.keys.add if creating else config.keys.edit,
            error=validation.page_message(errors, Message(error) if error else None),
            values=values,
            state=state,
            creating=creating,
            action=url("add") if creating else url("edit", id=id),
            list_url=url("list_persons"))
        return render_template("persons/form.html", model=model)

    # a business rule has refused the write: the form is displayed again,
    # with the user's input and the message of the business layer
    def show_business_error(id: int | None, values: PersonValues, e: BusinessException):
        if e.key in ("ERRORS.DOCTOR_NOT_FOUND", "ERRORS.PATIENT_NOT_FOUND"):
            # deleted in the meantime by another ADMIN: back to the list
            flash_error(e.key)
            return redirect(url("list_persons"))
        # the message goes at the bottom of the page; the fields concerned, if any, are
        # outlined in red so that the user immediately sees what to correct
        return show_form(id, values, {}, e.key, validation.BUSINESS_ERROR_FIELDS.get(e.key))

    return bp


# what the user has typed (password excluded: it is never sent back
# to the browser). form.<field>.data: the value, after the filters
def entered_values(form: PersonForm) -> PersonValues:
    return PersonValues(form.title.data or "", form.first_name.data or "", form.last_name.data or "",
                        form.login.data or "", form.version.data)


# the validated form -> the data expected by the business layer
def data(form: PersonForm) -> PersonData:
    return PersonData(form.title.data, form.last_name.data, form.first_name.data, form.login.data,
                      form.password.data, form.version.data)


# {name} and {login} of the "added / edited / deleted" messages
def params(person: Person | None) -> dict[str, str]:
    if person is None:
        return {"name": "", "login": ""}
    return {"name": f"{person.first_name} {person.last_name}", "login": login(person)}


def login(person: Person) -> str:
    return person.user.login if person.user else ""
  • lines 49–56: what each type of entity must provide: the blueprint name (which is also the beginning of URL), the translation keys, and five functions: list, find, add, edit, and delete. This is the same process as for business layer descriptors;
  • Lines 59–60: The factory creates a blueprint, whose name and the prefix of URL come from the configuration. The actions are internal functions of [create_blueprint]: they have access to the factory’s variable [config]. Each call to [create_blueprint] creates a new blueprint, with its own actions;
  • lines 63–64: the URL for an action in this blueprint: [url("list_persons")] returns [/doctors] for the doctors’ blueprint and [/clients] for the clients’ blueprint;
  • lines 67–69: a blueprint [before_request]: it runs before every action in the blueprint (and only in this one), after those of the application. All actions are thus reserved for the administrator, including any that might be added later: the rule is written once and applies to all;
  • lines 74–82: the list. [@bp.get("")]: the route is the prefix of the blueprint itself ([/doctors]);
  • lines 95–106: the add operation: form validation (input errors, displayed below the fields), call to the business layer (business errors, displayed at the bottom of the page), followed by a flash message and redirection to the list. The flash messages receive parameters: the person’s name and login;
  • lines 113–120: the edit form, pre-filled with the record’s version (optimistic locking). A nonexistent person results in a 404 page;
  • lines 125–136: the update, modeled after the add operation;
  • lines 143–151: the delete operation, confirmed beforehand by the confirmation window (example 21). A rejection by the business layer (a doctor who has appointments) results in a flash error message;
  • lines 158–170: the add form ([id] equals [None]) or edit form, along with its status (values, errors, fields highlighted in red);
  • lines 174–181: a business error: a person who has since been deleted by another administrator is returned to the list; the others redisplay the form, with the relevant fields highlighted in red;
  • lines 188–190: the values to be redisplayed: what the user entered, never the password;
  • lines 194–196: the validated form, converted into data for the business layer;
  • lines 200–203: the parameters for the “added/modified/deleted” messages.

11.16.4. controllers/doctors.py and controllers/clients.py

# =========================================================================
# controllers/doctors.py
# -------------------------------------------------------------------------
# Management of the doctors (ADMIN role only). All the actions are
# built by controllers/persons.py: all that remains here is to say WHAT
# to call and WHICH labels to display.
#
# The functions of the configuration are LAMBDAS (functions without a
# name, written on the spot): application() is only called when
# the action runs, during a request - the ApplicationModel does not exist
# yet when this module is imported.
# =========================================================================
from rdvmedecins.controllers.persons import PersonsConfig, create_blueprint
from rdvmedecins.models.view_models import PersonKeys
from rdvmedecins.web.services import application

doctors = create_blueprint(PersonsConfig(
    name="doctors",
    keys=PersonKeys(
        title="DOCTORS.TITLE",
        add="DOCTORS.ADD",
        edit="DOCTORS.EDIT",
        empty="DOCTORS.EMPTY",
        confirm_delete="DOCTORS.DELETE_CONFIRM",
        added="DOCTORS.ADDED",
        edited="DOCTORS.UPDATED",
        deleted="DOCTORS.DELETED"),
    list=lambda: application().get_all_doctors(),
    find=lambda id: application().get_doctor_by_id(id),
    add=lambda data: application().add_doctor(data),
    edit=lambda id, data: application().edit_doctor(id, data),
    delete=lambda id: application().delete_doctor(id)))

The doctors’ blueprint, generated by [create_blueprint] along with its configuration. The configuration functions are anonymous functions ([lambda]), which call [ApplicationModel]. Why not write [list=application().get_all_doctors] directly? Because this module is executed when it is imported, at startup, before the application is created: [application()], which requires [current_app], would fail. The anonymous function defers the call until the action is executed, during a request.

# =========================================================================
# controllers/clients.py
# -------------------------------------------------------------------------
# Management of the clients (the PATIENTS of the practice), ADMIN role only.
# Same principle as controllers/doctors.py (see its comments).
# =========================================================================
from rdvmedecins.controllers.persons import PersonsConfig, create_blueprint
from rdvmedecins.models.view_models import PersonKeys
from rdvmedecins.web.services import application

clients = create_blueprint(PersonsConfig(
    name="clients",
    keys=PersonKeys(
        title="CLIENTS.TITLE",
        add="CLIENTS.ADD",
        edit="CLIENTS.EDIT",
        empty="CLIENTS.EMPTY",
        confirm_delete="CLIENTS.DELETE_CONFIRM",
        added="CLIENTS.ADDED",
        edited="CLIENTS.UPDATED",
        deleted="CLIENTS.DELETED"),
    list=lambda: application().get_all_clients(),
    find=lambda id: application().get_client_by_id(id),
    add=lambda data: application().add_client(data),
    edit=lambda id, data: application().edit_client(id, data),
    delete=lambda id: application().delete_client(id)))

The client blueprint, following the same pattern.

11.16.5. controllers/registration.py

# =========================================================================
# controllers/registration.py
# -------------------------------------------------------------------------
# "Create a patient account": a patient who does not have an account yet
# creates it themselves. PUBLIC routes (one does not have an account yet, by
# definition):
#
#   POST /register/access     "Create a patient account" button of the
#                             authentication page: checks the captcha of
#                             THAT page, then opens the form
#   GET  /register            the form
#   POST /register            the creation, then back to the login page
#
# The flow is protected against robots by ONE captcha, that of the
# authentication page:
#   1. the "Create a patient account" button sends the login form
#      to /register/access; if the captcha is typed correctly, the server
#      issues a pass (see auth/registration_pass.py);
#   2. without a valid pass, /register sends back to the
#      authentication page - typing the URL directly is useless;
#   3. the pass is consumed on creation: one solved captcha
#      makes it possible to create ONE account;
#   4. in addition, the number of accounts created per hour and per IP address is
#      limited (see auth/limiter.py).
#
# The account created is exactly the one the ADMIN would create from the
# "Patients" page (same method of the business layer: add_client), except
# that the patient CHOOSES their login and their password (mandatory,
# with confirmation).
# =========================================================================
from flask import Blueprint, current_app, redirect, render_template, request, url_for

from rdvmedecins.auth import registration_pass
from rdvmedecins.auth.security import public
from rdvmedecins.controllers.auth import ip_address
from rdvmedecins.forms.forms import RegistrationForm
from rdvmedecins.business.person_data import PersonData
from rdvmedecins.business.exception import BusinessException
from rdvmedecins.models.view_models import RegistrationModel, Message, PersonValues
from rdvmedecins.web import validation
from rdvmedecins.web.flash import flash_error, flash_success
from rdvmedecins.web.services import application, captchas, limiter, passes

registration = Blueprint("registration", __name__, url_prefix="/register")


# -----------------------------------------------------------------------
# POST /register/access   fields: captchaId, captcha (those of the
# authentication page; login and password, also sent, are ignored)
# -----------------------------------------------------------------------
@registration.post("/access")
@public
def access():
    ip = ip_address()

    # too many accounts created recently from this address: no need to go further
    minutes = limiter().registration_block_minutes(ip)
    if minutes > 0:
        flash_error("ERRORS.TOO_MANY_ACCOUNTS", minutes=str(minutes))
        return redirect(url_for("auth.show_login"))

    # captcha missing or wrong: back to the authentication page, with
    # a new image (a failure counts as a login failure)
    if not captchas().verify(request.form.get("captchaId"), request.form.get("captcha")):
        limiter().login_failure(ip, "")
        flash_error("ERRORS.CAPTCHA_BEFORE_ACCOUNT")
        return redirect(url_for("auth.show_login"))

    # correct captcha: the pass is issued and the form is opened
    response = redirect(url_for("registration.form"))
    registration_pass.set_cookie(response, passes().issue(), current_app.config["COOKIE_SECURE"])
    return response


# -----------------------------------------------------------------------
# GET /register
# -----------------------------------------------------------------------
@registration.get("")
@public
def form():
    if not passes().is_valid(request.cookies.get(registration_pass.COOKIE)):
        return back_to_login()
    return show(PersonValues(), {}, None, None)


# -----------------------------------------------------------------------
# POST /register
# -----------------------------------------------------------------------
@registration.post("")
@public
def create():
    pass_id = request.cookies.get(registration_pass.COOKIE)
    if not passes().is_valid(pass_id):
        return back_to_login()
    ip = ip_address()
    form = RegistrationForm()
    form.validate()
    values = PersonValues(form.title.data or "Mr", form.first_name.data or "",
                          form.last_name.data or "", form.login.data or "", None)

    # 1. too many accounts created recently from this address?
    minutes = limiter().registration_block_minutes(ip)
    if minutes > 0:
        return show(values, {}, Message("ERRORS.TOO_MANY_ACCOUNTS", {"minutes": str(minutes)}),
                    None)

    # 2. all the input errors at once (fields + confirmation):
    #    the user corrects them all in one go
    errors = validation.errors_of(form)
    if "password" not in errors and "confirmation" not in errors \
            and form.password.data != form.confirmation.data:
        errors["confirmation"] = "ERRORS.PASSWORDS_DIFFER"
    if errors:
        return show(values, errors, None, None)

    # 3. the creation (client + USER account, in a transaction)
    try:
        application().add_client(PersonData(form.title.data, form.last_name.data,
                                            form.first_name.data, form.login.data,
                                            form.password.data))
    except BusinessException as e:
        # login or first name + last name already taken: the pass remains valid
        return show(values, {}, Message(e.key), validation.BUSINESS_ERROR_FIELDS.get(e.key))

    # 4. the pass has been used: it will not allow creating a 2nd account
    passes().consume(pass_id)
    limiter().registration_success(ip)

    # 5. back to the login page, login pre-filled
    flash_success("REGISTRATION.DONE", login=form.login.data)
    response = redirect(url_for("auth.show_login", login=form.login.data))
    registration_pass.clear_cookie(response)
    return response


# ----------------------------------------------------------------- internal functions

# no valid pass: back to the authentication page
def back_to_login():
    flash_error("ERRORS.CAPTCHA_BEFORE_ACCOUNT")
    return redirect(url_for("auth.show_login"))


# the form; never a password sent back to the browser
def show(values: PersonValues, errors: dict[str, str], error: Message | None,
         other_fields: list[str] | None):
    model = RegistrationModel("REGISTRATION.TITLE", validation.page_message(errors, error),
                              values, validation.state(errors, other_fields))
    return render_template("registration.html", model=model)
  • Lines 51–72: The “Create a patient account” button on the login page submits the login form to this action (using the [formaction] attribute, Example 20). It checks the limit on account creations, then the CAPTCHA on the login page; if it is correct, it issues a token ([auth/registration_pass.py]) and redirects to the registration form. A failure is counted as a failed login attempt (with an empty username);
  • lines 78–83: the form, only with a valid passcode: typing the address [/register] directly redirects to the login page;
  • lines 89–133: account creation: - the passcode, again; - [form.validate()] is called, but its result is ignored: we want all input errors at once, including any mismatch between the password and its confirmation, which is then verified by the action (provided both fields are otherwise valid). The user corrects them all at once; - the client and their account are created using the same business-logic method as for the administrator; - the pass is consumed, and the creation is counted by the limiter; - return to the login page, with a message and the login pre-filled (parameter [login] from URL). The pass cookie is cleared.

11.16.6. controllers/area.py

The patient’s personal pages.

# =========================================================================
# controllers/area.py
# -------------------------------------------------------------------------
# The PERSONAL pages of the patient (USER role), the one they see right
# after logging in:
#
#   GET  /my-area                  home page: [Manage my account],
#                                  [View a schedule], and their upcoming appointment
#   POST /my-area/cancel           cancels THEIR appointment
#   GET  /my-account               form to edit their account
#   POST /my-account               edit
#   POST /my-account/delete        deletion of the account (and of its appointments)
#
# Golden rule of this controller: the patient concerned is ALWAYS the one of the
# token (utils.connected_patient), never an identifier sent by the
# browser. A patient can therefore neither see, nor edit, nor cancel what
# belongs to another patient, even by crafting their requests themselves.
#
# Any edit or deletion of the account requires the CURRENT
# password: someone who found a session left open could
# neither change the password, nor delete the account. Wrong current
# password errors are counted as login failures (same limit).
# =========================================================================
from flask import Blueprint, redirect, render_template, request, url_for

from rdvmedecins.auth.auth_service import AuthService
from rdvmedecins.auth.security import require_roles
from rdvmedecins.controllers.auth import ip_address
from rdvmedecins.entities import Client, Role
from rdvmedecins.forms.forms import MyAccountForm
from rdvmedecins.business.person_data import PersonData
from rdvmedecins.business.exception import BusinessException
from rdvmedecins.models.view_models import (AreaModel, Message, MyAccountModel, AppointmentView,
                                            PersonValues)
from rdvmedecins.web import utils, validation
from rdvmedecins.web.flash import flash_error, flash_success
from rdvmedecins.web.services import application, auth_service, limiter

area = Blueprint("area", __name__)


# all the actions of this blueprint are reserved for the USER role
@area.before_request
def patient_only():
    require_roles(Role.USER)


# -----------------------------------------------------------------------
# GET /my-area
# -----------------------------------------------------------------------
@area.get("/my-area")
def patient_area():
    patient = utils.connected_patient(application())
    appointments = [AppointmentView(
        a.id,
        utils.format_day(a.day),
        utils.time(a.slot),
        utils.label(a.slot.doctor),
        # link to the doctor's schedule on that day: the patient sees their name there
        url_for("schedule.show", doctorId=a.slot.doctor_id, day=utils.iso(a.day)))
        for a in application().get_client_upcoming_appointments(patient.id)]
    model = AreaModel("AREA.TITLE", None, utils.label(patient), appointments)
    return render_template("area.html", model=model)


# -----------------------------------------------------------------------
# POST /my-area/cancel   field: appointmentId
# -----------------------------------------------------------------------
@area.post("/my-area/cancel")
def cancel_appointment():
    patient = utils.connected_patient(application())
    appointment_id = utils.positive_integer(request.form.get("appointmentId"))
    appointment = application().get_appointment_by_id(appointment_id) if appointment_id is not None else None
    # an appointment that does not exist, and that of ANOTHER patient, receive
    # the same response: nothing is revealed about the appointments of others
    if appointment is None or appointment.client_id != patient.id:
        flash_error("ERRORS.APPOINTMENT_NOT_FOUND")
    else:
        application().delete_appointment(appointment)
        flash_success("SCHEDULE.CANCELLED")
    return redirect(url_for("area.patient_area"))


# -----------------------------------------------------------------------
# GET /my-account
# -----------------------------------------------------------------------
@area.get("/my-account")
def account_form():
    patient = utils.connected_patient(application())
    values = PersonValues(patient.title, patient.first_name, patient.last_name, patient.user.login,
                          patient.version)
    return show(values, {}, None, None)


# -----------------------------------------------------------------------
# POST /my-account
# -----------------------------------------------------------------------
@area.post("/my-account")
def edit_account():
    patient = utils.connected_patient(application())
    form = MyAccountForm()
    form.validate()
    values = PersonValues(form.title.data or patient.title, form.first_name.data or "",
                          form.last_name.data or "", form.login.data or "", form.version.data)

    errors = validation.errors_of(form)
    if "password" not in errors and form.password.data is not None \
            and form.password.data != form.confirmation.data:
        errors["confirmation"] = "ERRORS.PASSWORDS_DIFFER"
    if errors:
        return show(values, errors, None, None)

    # the current password
    password_error = check_current_password(patient, form.current_password.data)
    if password_error is not None:
        # wrong password: the message also goes UNDER the field
        field_errors = ({"current_password": password_error.key}
                        if password_error.key == "ERRORS.WRONG_CURRENT_PASSWORD" else {})
        return show(values, field_errors, password_error, None)

    try:
        client = application().edit_client(patient.id, PersonData(
            form.title.data, form.last_name.data, form.first_name.data, form.login.data,
            form.password.data,  # None = unchanged
            form.version.data))
    except BusinessException as e:
        return show(values, {}, Message(e.key), validation.BUSINESS_ERROR_FIELDS.get(e.key))
    # the login and the displayed name are IN the token: a new one is
    # issued, otherwise the navigation bar would keep the old name
    flash_success("MY_ACCOUNT.UPDATED")
    response = redirect(url_for("area.patient_area"))
    auth_service().set_token(response, client.user)
    return response


# -----------------------------------------------------------------------
# POST /my-account/delete   field: delete_password
# The account, the client and ALL their appointments disappear together,
# then the patient is logged out.
# -----------------------------------------------------------------------
@area.post("/my-account/delete")
def delete_account():
    patient = utils.connected_patient(application())
    password_error = check_current_password(
        patient, request.form.get("delete_password"))
    if password_error is not None:
        flash_error(password_error.key, **password_error.params)
        return redirect(url_for("area.account_form"))
    application().delete_client(patient.id)
    flash_success("MY_ACCOUNT.DELETED")
    response = redirect(url_for("auth.show_login"))
    AuthService.clear_token(response)
    return response


# ----------------------------------------------------------------- internal functions

# None if the current password is correct; otherwise the error message
def check_current_password(patient: Client, password: str | None) -> Message | None:
    ip = ip_address()
    login = patient.user.login
    minutes = limiter().login_block_minutes(ip, login)
    if minutes > 0:
        return Message("ERRORS.TOO_MANY_ATTEMPTS", {"minutes": str(minutes)})
    ok = bool(password) and auth_service().validate_user(login, password) is not None
    if not ok:
        limiter().login_failure(ip, login)
        return Message("ERRORS.WRONG_CURRENT_PASSWORD")
    return None


def show(values: PersonValues, errors: dict[str, str], error: Message | None,
         other_fields: list[str] | None):
    model = MyAccountModel("MY_ACCOUNT.TITLE", validation.page_message(errors, error), values,
                           validation.state(errors, other_fields))
    return render_template("account.html", model=model)
  • lines 43–45: All actions in the blueprint are reserved for the USER role;
  • lines 51–63: the patient’s dashboard: their upcoming appointments, formatted, each with a link to the doctor’s schedule for that day. The patient is always the one associated with the token ([utils.connected_patient]), never an identifier sent by the browser: a patient cannot view, modify, or cancel another patient’s information;
  • lines 69–81: Canceling the patient’s appointment. A nonexistent appointment and another patient’s appointment receive the same response: no information about other patients’ appointments is disclosed (otherwise, a patient could discover which appointment numbers exist);
  • lines 87–92: the user’s account form, pre-filled;
  • lines 98–133: the edit: validation of fields and confirmation, verification of the current password, then a call to the business layer. The login and the displayed name are in the token: the action issues a new one; otherwise, the navigation bar would retain the old name until the next login;
  • lines 141–153: account deletion, which is also protected by the current password. The account, the client, and all their appointments are deleted together; the patient is logged out;
  • lines 159–169: The current password protects the account from anyone who might find a session that has been left open. Mistakes are counted as login failures, with the same limit: they cannot be used to guess the password.

11.17. The Views

The [templates] folder contains the application’s [Jinja2] views: one template, seven fragments, and eight pages. All follow the same rule: no logic in the views. Actions pass them pre-prepared templates ([models/view_models.py]); the views simply place the data into HTML and translate the text using the [t] function. They contain no hard-coded text: everything goes through the dictionaries.

11.17.1. The template and fragments

11.17.1.1. templates/layout.html

<!doctype html>
{# =========================================================================
   templates/layout.html
   -------------------------------------------------------------------------
   The layout COMMON to all the pages (see example 15): each page
   (schedule.html, login.html...) EXTENDS it ({% extends %}) and fills its block
   [content].

   Jinja2 ESCAPES everything it displays with {{ ... }} (the characters
   < > & " ' become &lt; &gt;...): protection against HTML injection
   via a typed name, for example. To insert HTML as is, it must be
   requested explicitly (safe filter, see fragments/captcha.html).

   Messages: a success is displayed at the TOP of the page, an error at the BOTTOM, on
   a light pink background (errors.html fragment).

   The layout shares the variables of the page: [model] (the view model
   passed by the action, see models/view_models.py), where it reads the key of the
   page title; [language], [user], [path], [t], added to
   all the views by web/common_model.py.
   ========================================================================= #}
<html lang="{{ language }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# t(key): the translation of a key; here, the key is a VARIABLE (model.page_title) #}
  <title>{{ t(model.page_title) }} - {{ 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>
  {# the flash messages set by the previous request: read ONCE
     (get_flashed_messages forgets them immediately), then split: the successes
     at the top, the errors at the bottom #}
  {% set flashes = get_flashed_messages(with_categories=true) %}
  <div class="container py-4">
    {% include "fragments/navbar.html" %}
    {% include "fragments/flash.html" %}
    <main>
      {% block content %}{% endblock %}
    </main>
    {% include "fragments/errors.html" %}
  </div>
  {# the "Are you sure?" dialog, hidden until it is needed #}
  {% include "fragments/confirmation.html" %}
  <script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>
  • line 22: the page language, provided by the context processor;
  • line 27: the page title. The title key is a variable ([model.page_title]): each page template provides one (the process from Example 22, using the view template instead of a [{% set %}] variable);
  • line 28: [Bootstrap] is part of the application, located in its [static] folder;
  • Line 35: Flash messages are read once by the template; [get_flashed_messages] immediately forgets them, and a second call would return an empty list. With [with_categories=true], the function returns tuples (category, message). The variable [flashes] is then read by the two fragments that display them: success messages at the top, error messages at the bottom. A variable defined by [{% set %}] is visible to the fragments included by [{% include %}];
  • lines 37–42: the navigation bar, success messages, page content, and error area;
  • lines 45–46: the confirmation window and the script that manages it (Example 21).

11.17.1.2. templates/fragments/navbar.html

{# =========================================================================
   templates/fragments/navbar.html - the navigation bar.

   Title, FR/EN, role, name, logout - and a menu whose entries
   depend on the role: "Doctors" and "Patients" only appear for
   the ADMIN. It is only a display convenience: the real protection is
   the role checks of the actions (a USER who types /doctors by hand
   gets a 403 page). A patient (USER) additionally has their "My area" menu.

   The menu of the current page is highlighted ("active" class):
   [path] is the requested URL (see web/common_model.py). A macro writes
   once the HTML of a menu entry; its optional parameter [prefix]
   (href by default), is the start of the paths for which the entry is active.
   ========================================================================= #}
{% macro entry(href, key, prefix=none) %}
<li class="nav-item">
  <a class="nav-link{{ ' active' if path.startswith(prefix or href) }}" href="{{ href }}">{{ t(key) }}</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 user %}
  <ul class="navbar-nav me-auto">
    {% if user.is_patient %}
    {# active for /my-area AND /my-account #}
    {{ entry("/my-area", "APP.MENU_MY_AREA", "/my-") }}
    {% endif %}
    {{ entry("/schedule", "APP.MENU_SCHEDULE") }}
    {% if user.is_admin %}
    {{ entry("/doctors", "APP.MENU_DOCTORS") }}
    {{ entry("/clients", "APP.MENU_PATIENTS") }}
    {% endif %}
  </ul>
  {% else %}
  <div class="me-auto"></div>
  {% endif %}

  <div class="d-flex align-items-center gap-2">
    {# FR / EN: simple links (GET /language/fr), the active language highlighted #}
    <div class="btn-group btn-group-sm" role="group" aria-label="FR / EN">
      {% for code in ["fr", "en"] %}
      <a class="btn {{ 'btn-light' if language == code else 'btn-outline-light' }}" href="/language/{{ code }}">{{ code | upper }}</a>
      {% endfor %}
    </div>

    {% if user %}
    <span class="badge text-bg-light text-primary">{{ user.role }}</span>
    <span class="text-white">{{ user.name }}</span>
    {# the logout is a POST: a form reduced to its button (and to the anti-CSRF token) #}
    <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>
  • lines 15–19: a menu item, highlighted (class [active]) if the path of the current page begins with its prefix. The parameter [prefix] is optional ([none] to [Jinja2], just as [None] to [Python]); otherwise, it is the entry’s address ([prefix or href]). The “My area” entry is prefixed with [/my-]: it is active for both [/my-area] and [/my-account];
  • lines 23–37: the menu depends on the role: “My area” for a patient, “Doctors” and “Patients” for the administrator. This is merely a display convenience; the real security comes from role-based controls on actions. A patient who manually enters [/doctors] will receive a 403 error page;
  • lines 42–44: the FR and EN buttons are simply links to [/language/fr] and [/language/en];
  • lines 48–54: role, username, and logout: a POST form reduced to its button, with the anti-CSRF token.

11.17.1.3. templates/fragments/flash.html

{# =========================================================================
   templates/fragments/flash.html - the SUCCESS messages set by
   the previous action (appointment saved, doctor added...),
   displayed only once, at the top of the page (see web/flash.py).
   An ERROR flash message is displayed at the bottom of the page, by
   fragments/errors.html.

   [flashes]: the list of the (category, message) pairs, read by the layout.
   t(message.key, message.params): the message whose key is
   message.key, with as parameter the dictionary of the values of {name},
   {login}... (see web/translator.py).
   ========================================================================= #}
{% for category, message in flashes if category == "success" %}
<div class="alert alert-success" role="status">{{ t(message.key, message.params) }}</div>
{% endfor %}
  • line 13: a [for] loop from [Jinja2] can filter its elements: [for ... in ... if condition] iterates only over elements that satisfy the condition. Each element is a pair, with both parts stored in two variables;
  • line 14: the message is a dictionary (see [web/flash.py]): its key and its parameters. The dot notation in [Jinja2] also reads the keys of a dictionary (example 13).

11.17.1.4. templates/fragments/errors.html

{# =========================================================================
   templates/fragments/errors.html - the error zone, at the BOTTOM of each
   page, on a light pink background (.error-zone class, see static/css/styles.css).

   It groups the two possible sources of error:
     - model.error: the error of the displayed page (refused form, login
       or first + last names already used, invalid date, wrong password...);
     - the error flash messages, set by the previous action before a
       redirect (slot already booked, deletion refused...).

   A for loop can filter its elements: "for ... in ... if condition".
   role="alert": a screen reader announces the message as soon as the page is
   displayed, even if it is at the bottom.
   ========================================================================= #}
{% set flash_errors = flashes | selectattr(0, "equalto", "danger") | list %}
{% if model.error or flash_errors %}
<div class="error-zone" role="alert">
  {% if model.error %}
  <div>{{ t(model.error.key, model.error.params) }}</div>
  {% endif %}
  {% for category, message in flash_errors %}
  <div>{{ t(message.key, message.params) }}</div>
  {% endfor %}
</div>
{% endif %}
  • line 15: error flash messages, extracted by two filters: [selectattr(0, "equalto", "danger")] keeps only those elements whose [0] attribute (the first element of the pair, the category) is equal to ["danger"]; [list] converts the result into a list (filters of this type return an iterator that can only be traversed once and cannot be checked to see if it is empty);
  • Lines 16–25: The error area combines the two sources of errors: errors from the displayed page (a rejected form, an invalid date) and errors generated by the previous action before a redirect (a time slot already taken, a deletion rejected). The area exists only if there is at least one error.

11.17.1.5. templates/fragments/confirmation.html

{# =========================================================================
   templates/fragments/confirmation.html - the "Are you sure?" modal dialog.

   It replaces the window.confirm() box of the browser, which can neither be
   styled, nor translated (its buttons stay in the language of the
   browser, not in that of the page).

   A SINGLE dialog for the whole page, hidden (d-none) at first. Its
   labels are translated here, by the server, like the rest of the page;
   the small script static/js/app.js merely displays it, puts in it
   the message specific to the form (data-confirmation attribute), and sends
   the form if the user confirms.

   Same Bootstrap classes as the booking dialog (fragments/booking.html).
   ========================================================================= #}
<div id="confirmation-modal" 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="confirmation-modal-title" aria-describedby="confirmation-modal-message">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="confirmation-modal-title">{{ t("CONFIRMATION.TITLE") }}</h5>
          <button type="button" class="btn-close" data-action="no" aria-label="{{ t('BOOKING_DIALOG.CLOSE_ARIA') }}"></button>
        </div>
        <div class="modal-body">
          <p class="mb-0" id="confirmation-modal-message"></p>
        </div>
        <div class="modal-footer">
          {# "No, go back" rather than "Cancel": the question is sometimes
             "Cancel this appointment?", and a "Cancel" button would be ambiguous there #}
          <button type="button" class="btn btn-secondary" data-action="no">{{ t("CONFIRMATION.NO") }}</button>
          <button type="button" class="btn btn-danger" data-action="yes">{{ t("CONFIRMATION.YES") }}</button>
        </div>
      </div>
    </div>
  </div>
</div>

The confirmation window from Example 21, with labels translated by the server (the browser’s [window.confirm()] box displays its buttons in the browser’s language, not the page’s language), a close button, and accessibility attributes: [aria-labelledby] and [aria-describedby] refer to the window’s title and message, which screen readers announce when the window opens. The “No, go back” button is preferred over “Cancel”: the question is sometimes “Cancel this appointment?”, and a “Cancel” button would be ambiguous in that context.

11.17.1.6. templates/fragments/captcha.html

{# =========================================================================
   templates/fragments/captcha.html - the captcha of the authentication page
   (see auth/captcha.py). It protects both the login and the access to
   account creation ("Create a patient account" button).
   A macro, whose parameter is a Captcha object (id, svg).

   The safe filter: the SVG image is inserted AS IS into the page, without
   escaping. It is harmless here because this SVG is built by the
   server itself and contains nothing that comes from the user - one would
   NEVER do that with an input.

   The hidden field captchaId tells the server WHICH captcha to check; the right
   answer itself is never sent to the browser.
   "Another image" simply reloads the page ([path] = the current URL).
   A macro does not see the variables of the page that calls it ("with
   context": it does, on import, see login.html): here, t and path.
   ========================================================================= #}
{% 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="{{ path }}">{{ 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 %}
  • line 18: a macro that receives an object [Captcha];
  • line 22: the SVG image, inserted as-is (example 27). The attribute [role="img"] and its [aria-label] describe the image to screen readers;
  • line 23: the “Another image” link simply reloads the page: each view generates a new CAPTCHA;
  • Lines 25–27: the CAPTCHA ID and the input field. [autocapitalize="off"] and [spellcheck="false"] prevent phones from modifying the input (initial uppercase letter, spell check).

By default, an imported macro cannot access the variables of the page that imports it: the function [t] and the variable [path], provided by the context processor, would be unknown to it. This is why [login.html] imports it with the reference [with context].

11.17.1.7. templates/fragments/person_fields.html

{# =========================================================================
   templates/fragments/person_fields.html - the identity fields (title,
   first name, last name), common to three forms: doctor/client of the ADMIN,
   "Create a patient account" and "Manage my account".
   The fragment, inserted by {% include %}, reads the [model] variable of the
   page that inserts it (a FormView, see models/view_models.py):
   values, message under each field, fields outlined in red.

   selected: a boolean attribute, written if the option is chosen, omitted otherwise.
   state.invalid_class(field): "is-invalid" (red outline of Bootstrap)
   for a refused field, "" otherwise.
   ========================================================================= #}
{% set state = model.state %}
<div class="mb-3">
  <label class="form-label" for="title">{{ t("PERSONS.TITLE_LABEL") }}</label>
  <select id="title" name="title" class="form-select {{ state.invalid_class('title') }}">
    {% for title in model.titles %}
    <option value="{{ title.value }}"{{ " selected" if title.selected }}>{{ title.value }}</option>
    {% endfor %}
  </select>
  {% if state.error_of("title") %}
  <div class="invalid-feedback">{{ t(state.error_of("title")) }}</div>
  {% endif %}
</div>

<div class="row g-3 mb-3">
  <div class="col-sm-6">
    <label class="form-label" for="first_name">{{ t("PERSONS.FIRSTNAME_LABEL") }}</label>
    <input id="first_name" name="first_name" type="text" maxlength="20" required
           class="form-control {{ state.invalid_class('first_name') }}" value="{{ model.values.first_name }}">
    {% if state.error_of("first_name") %}
    <div class="invalid-feedback">{{ t(state.error_of("first_name")) }}</div>
    {% endif %}
  </div>
  <div class="col-sm-6">
    <label class="form-label" for="last_name">{{ t("PERSONS.LASTNAME_LABEL") }}</label>
    <input id="last_name" name="last_name" type="text" maxlength="20" required
           class="form-control {{ state.invalid_class('last_name') }}" value="{{ model.values.last_name }}">
    {% if state.error_of("last_name") %}
    <div class="invalid-feedback">{{ t(state.error_of("last_name")) }}</div>
    {% endif %}
  </div>
</div>

The identity fields (title, first name, last name), which are common to all three forms in the application. The fragment, inserted by [{% include %}], reads the variable [model] from the page that inserts it: a template that inherits from [FormView].

  • Lines 16–20: the list of titles, calculated by the template’s [titles] property, with the selected title highlighted;
  • lines 29–30: the first name input field, with the value to be redisplayed, and the class [is-invalid] if the field is rejected ([state.invalid_class('first_name')]);
  • lines 31–33: the message below the field, translated.

The attributes [maxlength] and [required] are browser controls. The application’s forms include the attribute [novalidate] (Example 19): these checks are not performed before submission; validation is handled by the server. [maxlength], on the other hand, always prevents users from entering more characters than a column can accommodate.

11.17.1.8. templates/fragments/booking.html

{# =========================================================================
   templates/fragments/booking.html - the booking dialog of the
   schedule.

   The REAL Bootstrap classes of a modal dialog (.modal,
   .modal-backdrop...), made visible "hard-coded" (show d-block) rather than
   by the JavaScript of Bootstrap. It is the SERVER that decides to display it
   (&book=... parameter of the URL), and closing it amounts to following a link
   to the same page without this parameter.

   The fragment directly reads the [model] variable of the page that inserts it
   (a ScheduleModel).
   ========================================================================= #}
{% set booking = model.booking %}
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="booking-title">
  <div class="modal-dialog modal-dialog-centered">
    <form method="post" action="/schedule/book" class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title" id="booking-title">{{ t("BOOKING_DIALOG.TITLE") }}</h5>
        <a class="btn-close" href="{{ model.back_url }}" aria-label="{{ t('BOOKING_DIALOG.CLOSE_ARIA') }}"></a>
      </div>

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

        {# what the server must know in addition to the chosen patient #}
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <input type="hidden" name="doctorId" value="{{ model.doctor_id }}">
        <input type="hidden" name="day" value="{{ model.day }}">
        <input type="hidden" name="slotId" value="{{ booking.slot_id }}">

        {# a patient books for themselves: no list to choose from
           (and the server ignores a possible clientId anyway) #}
        {% if booking.patient %}
        <p class="mb-0">
          <span>{{ t("BOOKING_DIALOG.PATIENT_LABEL") }}</span>:
          <strong>{{ booking.patient }}</strong>
        </p>
        {% else %}
        <label class="form-label" for="clientId">{{ t("BOOKING_DIALOG.PATIENT_LABEL") }}</label>
        <select id="clientId" name="clientId" class="form-select" required autofocus>
          <option value="">{{ t("BOOKING_DIALOG.CHOOSE_PATIENT") }}</option>
          {% for client in booking.clients %}
          <option value="{{ client.id }}">{{ client.label }}</option>
          {% endfor %}
        </select>
        {% endif %}
      </div>

      <div class="modal-footer">
        <a class="btn btn-secondary" href="{{ model.back_url }}">{{ t("BOOKING_DIALOG.CANCEL") }}</a>
        <button type="submit" class="btn btn-primary">{{ t("BOOKING_DIALOG.CONFIRM") }}</button>
      </div>
    </form>
  </div>
</div>

The reservation window. It uses the actual classes of a modal window from [Bootstrap] ([modal], [modal-backdrop]...), made visible "hard-coded" ([show d-block]) rather than via the [JavaScript] of [Bootstrap]: the server decides whether to display it (the [book] parameter of URL), and closing it is equivalent to following a link to the same page without that parameter ([model.back_url]).

  • Lines 29–31: Hidden fields: information the server needs to know, in addition to the selected patient;
  • lines 35–48: a patient makes a reservation for themselves: their name is displayed, with no list to choose from (and the server would ignore a [clientId] field anyway). For an administrator, the list of patients—whose first option, with an empty value, prompts a selection—is displayed.

11.17.2. The pages

11.17.2.1. templates/login.html

{# =========================================================================
   templates/login.html - the login form (see controllers/auth.py).
   An ordinary HTML <form>: the browser itself sends login,
   password and captcha answer in POST /login, without JavaScript. On
   failure, the server sends back THIS page, with the login already typed, a
   NEW captcha and the error, displayed at the bottom of the page by the layout.
   ========================================================================= #}
{% extends "layout.html" %}
{# with context: the macro sees the variables of this page (t, path) #}
{% from "fragments/captcha.html" import captcha with context %}

{% block content %}
{% set login_typed = model.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: a boolean attribute, written if the condition is true, omitted otherwise #}
          <input id="login" name="login" type="text" class="form-control" autocomplete="username"
                 value="{{ model.login }}" required{{ " autofocus" if not login_typed }}>
        </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_typed }}>
        </div>

        {{ captcha(model.captcha) }}

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

        {# no account yet: a patient can create one themselves.
           It is NOT a simple link: it is a second submit button of
           THIS form, which goes to another address (formaction). The
           server checks the captcha above there before opening the
           account creation page (see controllers/registration.py).
           formnovalidate: the browser does not require login and password
           (useless to create an account); it is the server that checks
           the captcha. Enter in a field always triggers the FIRST
           button of the form, "Log in". #}
        <button type="submit" class="btn btn-outline-primary w-100 mt-3"
                formaction="/register/access" 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 %}
  • line 10: the macro is imported, along with the page context;
  • line 13: the cursor is placed in the login field if it is empty, or in the password field otherwise (after a failed login attempt or after creating an account): the Boolean attribute [autofocus] is set to one or the other;
  • lines 24–30: the values of the [autocomplete] attribute help browser password managers fill out the form;
  • lines 46–47: The “Create a patient account” button is not a link, but a second button to submit this form, which sends it to a different address ([formaction], example 20). The server verifies the CAPTCHA there before opening the account creation page. [formnovalidate]: The browser does not require the username and password, marked as [required], which are unnecessary for creating an account. Pressing the Enter key in a field always triggers the first button on the form, “Log in.”

11.17.2.2. templates/schedule.html

{# =========================================================================
   templates/schedule.html - the main page (see controllers/schedule.py).
   Three blocks:
     1. the choice of the doctor and of the day;
     2. the schedule;
     3. the booking dialog (fragments/booking.html).
   A possible error (invalid date, unknown doctor) is displayed at the
   bottom of the page by the layout (see fragments/errors.html).
   ========================================================================= #}
{% extends "layout.html" %}

{% block content %}
  {# 1. choice of the doctor and of the day: a GET form, which simply
        reloads this page with ?doctorId=...&day=... in the URL #}
  <form method="get" action="/schedule" class="card p-3 mb-3">
    <div class="row g-2 align-items-end">
      <div class="col-sm-5">
        <label class="form-label" for="doctorId">{{ t("DOCTOR_DAY_PICKER.DOCTOR_LABEL") }}</label>
        <select id="doctorId" name="doctorId" class="form-select" required>
          <option value="">{{ t("DOCTOR_DAY_PICKER.CHOOSE_DOCTOR") }}</option>
          {% for doctor in model.doctors %}
          <option value="{{ doctor.id }}"{{ " selected" if doctor.selected }}>{{ doctor.label }}</option>
          {% endfor %}
        </select>
      </div>
      <div class="col-sm-4">
        <label class="form-label" for="day">{{ t("DOCTOR_DAY_PICKER.DAY_LABEL") }}</label>
        <input id="day" name="day" type="date" class="form-control" value="{{ model.day }}" required>
      </div>
      <div class="col-sm-3">
        <button type="submit" class="btn btn-primary w-100">{{ t("DOCTOR_DAY_PICKER.VIEW_SCHEDULE") }}</button>
      </div>
    </div>
  </form>

  {# patient who already has an upcoming appointment: why there is no "Book" button #}
  {% if model.info %}
  <div class="alert alert-info">
    <span>{{ t(model.info) }}</span>
    <a href="/my-area" class="alert-link">{{ t("APP.MENU_MY_AREA") }}</a>
  </div>
  {% endif %}

  {# 2. the schedule #}
  {% set schedule = model.schedule %}
  {% if schedule %}
  <div class="card p-3">
    <h5>{{ t("SCHEDULE.TITLE_PREFIX") }} {{ schedule.day }} - {{ schedule.doctor }}</h5>

    {% if schedule.rows %}
    <table class="table table-hover align-middle">
      <thead>
        <tr>
          <th>{{ t("SCHEDULE.COLUMN_SLOT") }}</th>
          <th>{{ t("SCHEDULE.COLUMN_STATUS") }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        {% for row in schedule.rows %}
        <tr class="{{ 'free-slot' if row.bookable }}">
          <td>{{ row.time }}</td>
          <td>
            {# [patient] is filled only if the user is allowed to
               see this name (ADMIN, DOCTOR, or the patient for THEIR
               appointment) - otherwise, simply "Booked" #}
            {% if row.free %}
            <span class="badge text-bg-success">{{ t("SCHEDULE.FREE") }}</span>
            {% elif row.patient %}
            <span class="badge {{ 'text-bg-primary' if row.mine else 'text-bg-secondary' }}">{{ row.patient }}</span>
            {% else %}
            <span class="badge text-bg-secondary">{{ t("SCHEDULE.BOOKED_BY_OTHER") }}</span>
            {% endif %}
          </td>
          <td>
            {# the displayed buttons have been decided by the controller
               (bookable, appointment_id) according to the role of the user.
               "Book" is a simple link: it reopens this page
               with the booking dialog of this slot #}
            {% if row.bookable %}
            <a class="btn btn-sm btn-outline-success" href="{{ row.book_url }}">{{ t("SCHEDULE.BOOK") }}</a>
            {% endif %}
            {# "Cancel" MODIFIES data: it is therefore a POST #}
            {% if row.appointment_id %}
            <form method="post" action="/schedule/cancel" class="m-0"
                  data-confirmation="{{ t('SCHEDULE.CANCEL_CONFIRM') }}">
              <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
              <input type="hidden" name="appointmentId" value="{{ row.appointment_id }}">
              <input type="hidden" name="doctorId" value="{{ model.doctor_id }}">
              <input type="hidden" name="day" value="{{ model.day }}">
              <button type="submit" class="btn btn-sm btn-outline-danger">{{ t("SCHEDULE.CANCEL_APPOINTMENT") }}</button>
            </form>
            {% endif %}
          </td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
    {% else %}
    <p class="text-muted mb-0">{{ t("SCHEDULE.NO_SLOTS") }}</p>
    {% endif %}
  </div>
  {% else %}
  <p class="text-muted">{{ t("SCHEDULE.EMPTY_STATE") }}</p>
  {% endif %}

  {# 3. the booking dialog, only if the URL contains &book=... #}
  {% if model.booking %}
  {% include "fragments/booking.html" %}
  {% endif %}
{% endblock %}
  • Lines 15–34: Selecting a doctor and a day: a GET form (Example 17), which simply reloads the page with the parameters [doctorId] and [day] in URL. The [<input type="date">] field displays its values in the [yyyy-mm-dd] format, regardless of the language in which the browser displays it;
  • lines 37–42: the informational message for a patient who already has an upcoming appointment;
  • Lines 61–95: An entry in the schedule. There are three possible statuses: available, booked with the patient’s name (in blue if it’s the logged-in patient’s appointment), or booked without the name (“Booked”). The buttons displayed are determined by the action. “Book” is a simple link: it reopens this page with the reservation window for that time slot. “Cancel” modifies data: it is a POST form, with its confirmation request (example 21);
  • lines 108–110: the booking window, only if the action has prepared it.

11.17.2.3. templates/persons/list.html

{# =========================================================================
   templates/persons/list.html - the list of the doctors OR of the clients (ADMIN).
   A single view for both: the labels specific to each one arrive in
   model.keys (see controllers/doctors.py / controllers/clients.py).
   ========================================================================= #}
{% extends "layout.html" %}

{% block content %}
  <div class="d-flex justify-content-between align-items-center mb-3">
    <h4 class="mb-0">{{ t(model.keys.title) }}</h4>
    <a class="btn btn-primary" href="{{ model.add_url }}">{{ t(model.keys.add) }}</a>
  </div>

  <div class="card p-3">
    {% if model.persons %}
    <table class="table table-hover align-middle mb-0">
      <thead>
        <tr>
          <th>{{ t("PERSONS.COLUMN_TITLE") }}</th>
          <th>{{ t("PERSONS.COLUMN_FIRSTNAME") }}</th>
          <th>{{ t("PERSONS.COLUMN_LASTNAME") }}</th>
          <th>{{ t("PERSONS.COLUMN_LOGIN") }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        {% for person in model.persons %}
        <tr>
          <td>{{ person.title }}</td>
          <td>{{ person.first_name }}</td>
          <td>{{ person.last_name }}</td>
          <td><code>{{ person.login }}</code></td>
          <td class="text-end">
            <div class="d-inline-flex gap-2">
              <a class="btn btn-sm btn-outline-primary" href="{{ person.edit_url }}">{{ t("PERSONS.EDIT") }}</a>
              {# data-confirmation: a confirmation dialog will open
                 before the form is sent (see static/js/app.js) #}
              <form method="post" action="{{ person.delete_url }}" class="m-0"
                    data-confirmation="{{ t(model.keys.confirm_delete) }}">
                <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
                <button type="submit" class="btn btn-sm btn-outline-danger">{{ t("PERSONS.DELETE") }}</button>
              </form>
            </div>
          </td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
    {% else %}
    <p class="text-muted mb-0">{{ t(model.keys.empty) }}</p>
    {% endif %}
  </div>
{% endblock %}

A single view for both lists, doctors and clients: the labels specific to each appear in [model.keys]; [t(model.keys.title)] renders a key that is itself a variable. Each row has an “Edit” link and a “Delete” form, which requires confirmation.

11.17.2.4. templates/persons/form.html

{# =========================================================================
   templates/persons/form.html - creation OR edit of a
   doctor or of a client (ADMIN). model.creating distinguishes the two cases.

   When the input is refused (empty field, login already taken...), the action
   sends back THIS view with:
     - the values already typed (model.values);
     - the faulty fields outlined in red (Bootstrap class "is-invalid");
     - under a field refused by the validation, its message;
     - the error message of the page, displayed AT THE BOTTOM of the page by the
       layout (see fragments/errors.html).
   ========================================================================= #}
{% extends "layout.html" %}

{% block content %}
{% set state = model.state %}
<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(model.page_title) }}</h5>

      {# novalidate: we let the SERVER validate (it is the authority);
         the checks of the browser (required...) are only a convenience,
         that a user can always bypass #}
      <form method="post" action="{{ model.action }}" novalidate>
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        {% if not model.creating %}
        <input type="hidden" name="version" value="{{ model.values.version }}">
        {% endif %}

        {# title, first name, last name: fragment common to three forms #}
        {% include "fragments/person_fields.html" %}

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

        <div class="mb-3">
          <label class="form-label" for="login">{{ t("PERSONS.LOGIN_LABEL") }}</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="off"
                 class="form-control {{ state.invalid_class('login') }}" value="{{ model.values.login }}">
          {% if state.error_of("login") %}
          <div class="invalid-feedback">{{ t(state.error_of("login")) }}</div>
          {% else %}
          <div class="form-text">{{ t("PERSONS.LOGIN_HELP_CREATE" if model.creating else "PERSONS.LOGIN_HELP_EDIT") }}</div>
          {% endif %}
        </div>

        <div class="mb-4">
          <label class="form-label" for="password">{{ t("PERSONS.PASSWORD_LABEL") }}</label>
          {# never pre-filled: the password never goes back to the browser #}
          <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password"
                 class="form-control {{ state.invalid_class('password') }}">
          {% if state.error_of("password") %}
          <div class="invalid-feedback">{{ t(state.error_of("password")) }}</div>
          {% else %}
          <div class="form-text">{{ t("PERSONS.PASSWORD_HELP_CREATE" if model.creating else "PERSONS.PASSWORD_HELP_EDIT") }}</div>
          {% endif %}
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">{{ t("PERSONS.SAVE") }}</button>
          <a class="btn btn-secondary" href="{{ model.list_url }}">{{ t("PERSONS.BACK") }}</a>
        </div>
      </form>
    </div>
  </div>
</div>
{% endblock %}
  • line 25: the mailing address is provided by the action: [/doctors/add], [/clients/3/edit]...;
  • lines 27–29: on update, the record version is stored in a hidden field (optimistic lock);
  • line 32: the identity fields;
  • lines 38–44: the login, optional, with a tooltip that depends on whether the record is being created or edited ([t("..." if model.creating else "...")]);
  • lines 50-50: the password is never pre-filled; it is never sent back to the browser. [autocomplete="new-password"] prevents the browser from suggesting the administrator’s password here.

11.17.2.5. templates/registration.html

{# =========================================================================
   templates/registration.html - "Create a patient account" (public page,
   see controllers/registration.py). Same identity fields as the form
   of the ADMIN (person_fields.html fragment), but the login and the
   password are mandatory, with confirmation.
   No captcha HERE: one only gets to this page after having typed
   that of the authentication page ("Create a patient account" button).

   t(key, min=...): the message whose key is the value of [key], with the
   {min} parameter (see web/translator.py).
   ========================================================================= #}
{% extends "layout.html" %}

{% block content %}
{% set state = model.state %}
<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("REGISTRATION.TITLE") }}</h5>
      <p class="text-muted small mb-3">{{ t("REGISTRATION.INTRO") }}</p>

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

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

        <div class="mb-3">
          <label class="form-label" for="login">{{ t("PERSONS.LOGIN_LABEL") }}</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="username" required
                 class="form-control {{ state.invalid_class('login') }}" value="{{ model.values.login }}">
          {% if state.error_of("login") %}
          <div class="invalid-feedback">{{ t(state.error_of("login")) }}</div>
          {% else %}
          <div class="form-text">{{ t("REGISTRATION.LOGIN_HELP") }}</div>
          {% endif %}
        </div>

        <div class="row g-3 mb-3">
          <div class="col-sm-6">
            <label class="form-label" for="password">{{ t("PERSONS.PASSWORD_LABEL") }}</label>
            <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password" required
                   class="form-control {{ state.invalid_class('password') }}">
            {% if state.error_of("password") %}
            <div class="invalid-feedback">{{ t(state.error_of("password"), min=model.password_min) }}</div>
            {% else %}
            <div class="form-text">{{ t("REGISTRATION.PASSWORD_HELP", min=model.password_min) }}</div>
            {% endif %}
          </div>
          <div class="col-sm-6">
            <label class="form-label" for="confirmation">{{ t("ACCOUNT.CONFIRMATION_LABEL") }}</label>
            <input id="confirmation" name="confirmation" type="password" maxlength="72" autocomplete="new-password" required
                   class="form-control {{ state.invalid_class('confirmation') }}">
            {% if state.error_of("confirmation") %}
            <div class="invalid-feedback">{{ t(state.error_of("confirmation")) }}</div>
            {% endif %}
          </div>
        </div>

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

The patient account creation form: the personal information fields, followed by the username, password, and password confirmation, are required. The message indicating that a password is too short has a parameter specifying the minimum length: [t(state.error_of("password"), min=model.password_min)]. There is no CAPTCHA here: you can only access this page after entering the CAPTCHA from the login page.

11.17.2.6. templates/area.html

{# =========================================================================
   templates/area.html - "My area": the home page of the patient
   (USER role), displayed right after they log in.
     - two options: [Manage my account] and [View a schedule];
     - their upcoming appointment, if they have one, with the means to cancel it.
   ========================================================================= #}
{% extends "layout.html" %}

{% block content %}
  <h4 class="mb-3">{{ t("AREA.HELLO", name=model.patient) }}</h4>

  <div class="row g-3 mb-4">
    <div class="col-sm-6">
      <a class="card p-4 h-100 text-decoration-none option-card" href="/my-account">
        <h5 class="mb-1">{{ t("AREA.MANAGE_ACCOUNT") }}</h5>
        <span class="text-muted">{{ t("AREA.MANAGE_ACCOUNT_HELP") }}</span>
      </a>
    </div>
    <div class="col-sm-6">
      <a class="card p-4 h-100 text-decoration-none option-card" href="/schedule">
        <h5 class="mb-1">{{ t("AREA.VIEW_SCHEDULE") }}</h5>
        <span class="text-muted">{{ t("AREA.VIEW_SCHEDULE_HELP") }}</span>
      </a>
    </div>
  </div>

  <div class="card p-3">
    <h5>{{ t("AREA.MY_APPOINTMENT") }}</h5>
    {# normally only one (rule "one upcoming appointment at a time"),
       but the page can display several of them. loop.last: last element?
       The else block of a for loop: the list is empty #}
    {% for appointment in model.appointments %}
    <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">{{ appointment.day }}, {{ appointment.time }}</div>
        <div class="text-muted">
          <span>{{ appointment.doctor }}</span> -
          <a href="{{ appointment.schedule_url }}">{{ t("AREA.SEE_IN_SCHEDULE") }}</a>
        </div>
      </div>
      <form method="post" action="/my-area/cancel" class="m-0"
            data-confirmation="{{ t('SCHEDULE.CANCEL_CONFIRM') }}">
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <input type="hidden" name="appointmentId" value="{{ appointment.id }}">
        <button type="submit" class="btn btn-outline-danger">{{ t("AREA.CANCEL_APPOINTMENT") }}</button>
      </form>
    </div>
    {% else %}
    <p class="text-muted mb-0">{{ t("AREA.NO_APPOINTMENT") }}</p>
    {% endfor %}
  </div>
{% endblock %}

The patient’s dashboard: two large tabs (“Manage my account,” “View a schedule”) and their upcoming appointments. The [for ... else] loop (example 14) displays a message if the list is empty; [loop.last] removes the border below the last appointment. The page can display multiple appointments, even if the management rule allows only one.

11.17.2.7. templates/account.html

{# =========================================================================
   templates/account.html - "Manage my account" (USER role).
   Two independent forms on the same page:
     1. the edit (identity, login, optional new password);
     2. the deletion of the account - which also cancels its appointments.
   Both require the CURRENT password.
   ========================================================================= #}
{% extends "layout.html" %}

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

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

      <form method="post" action="/my-account" novalidate>
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <input type="hidden" name="version" value="{{ model.values.version }}">

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

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

        <div class="mb-3">
          <label class="form-label" for="login">{{ t("PERSONS.LOGIN_LABEL") }}</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="username" required
                 class="form-control {{ state.invalid_class('login') }}" value="{{ model.values.login }}">
          {% if state.error_of("login") %}
          <div class="invalid-feedback">{{ t(state.error_of("login")) }}</div>
          {% endif %}
        </div>

        <div class="row g-3 mb-3">
          <div class="col-sm-6">
            <label class="form-label" for="password">{{ t("MY_ACCOUNT.NEW_PASSWORD_LABEL") }}</label>
            <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password"
                   class="form-control {{ state.invalid_class('password') }}">
            {% if state.error_of("password") %}
            <div class="invalid-feedback">{{ t(state.error_of("password"), min=model.password_min) }}</div>
            {% else %}
            <div class="form-text">{{ t("PERSONS.PASSWORD_HELP_EDIT") }}</div>
            {% endif %}
          </div>
          <div class="col-sm-6">
            <label class="form-label" for="confirmation">{{ t("ACCOUNT.CONFIRMATION_LABEL") }}</label>
            <input id="confirmation" name="confirmation" type="password" maxlength="72" autocomplete="new-password"
                   class="form-control {{ state.invalid_class('confirmation') }}">
            {% if state.error_of("confirmation") %}
            <div class="invalid-feedback">{{ t(state.error_of("confirmation")) }}</div>
            {% endif %}
          </div>
        </div>

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

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">{{ t("PERSONS.SAVE") }}</button>
          <a class="btn btn-secondary" href="/my-area">{{ t("MY_ACCOUNT.BACK") }}</a>
        </div>
      </form>
    </div>

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

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

Two independent forms on the same page: account modification (lines 19–73) and account deletion (lines 80–89), confirmed by the confirmation window. Both require the current password, entered in two separate fields (a form submits only its own fields).

11.17.2.8. templates/error.html

{# =========================================================================
   templates/error.html - the error page (400, 403, 404, 500...),
   built by web/errors.py.
   ========================================================================= #}
{% extends "layout.html" %}

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

The error page: the code, displayed prominently, and the translated message. It extends the template: even in the event of an error, the user retains their navigation bar and can switch languages.

11.18. The dictionaries

The interface texts are contained in two JSON dictionaries, [i18n/fr.json] and [i18n/en.json], which the translator reads upon startup. These are the same files as those in versions [NestJS], [ASP.NET Core MVC], and [Spring MVC] of the application.

11.18.1. i18n/fr.json

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Se déconnecter",
    "MENU_SCHEDULE": "Agenda",
    "MENU_DOCTORS": "Médecins",
    "MENU_PATIENTS": "Clients",
    "MENU_MY_AREA": "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_SCHEDULE": "Voir l'agenda"
  },
  "SCHEDULE": {
    "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 {time}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choisir un patient --",
    "CANCEL": "Annuler",
    "CONFIRM": "Confirmer"
  },
  "PERSONS": {
    "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"
  },
  "DOCTORS": {
    "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 {name} a été ajouté (login : {login}).",
    "UPDATED": "Le médecin {name} a été modifié.",
    "DELETED": "Le médecin {name} 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 {name} a été ajouté (login : {login}).",
    "UPDATED": "Le client {name} a été modifié.",
    "DELETED": "Le client {name} 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."
  },
  "REGISTRATION": {
    "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}."
  },
  "ACCOUNT": {
    "CONFIRMATION_LABEL": "Confirmation du mot de passe"
  },
  "AREA": {
    "TITLE": "Mon espace",
    "HELLO": "Bonjour {name}",
    "MANAGE_ACCOUNT": "Gérer mon compte",
    "MANAGE_ACCOUNT_HELP": "Modifier mes informations, mon mot de passe, ou supprimer mon compte.",
    "VIEW_SCHEDULE": "Voir un agenda",
    "VIEW_SCHEDULE_HELP": "Consulter l'agenda d'un médecin et réserver un créneau.",
    "MY_APPOINTMENT": "Mon rendez-vous",
    "SEE_IN_SCHEDULE": "voir dans l'agenda",
    "CANCEL_APPOINTMENT": "Annuler ce rendez-vous",
    "NO_APPOINTMENT": "Vous n'avez aucun rendez-vous à venir."
  },
  "MY_ACCOUNT": {
    "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"
  }
}

The keys are grouped by page or by theme: [APP] (the navigation bar), [LOGIN], [SCHEDULE], [BOOKING_DIALOG] (the reservation window), [PERSONS], [DOCTORS], [CLIENTS], [ERRORS] (all error messages, including those from form validators and the business layer)... The message parameters are written as [{name}], [{login}], [{minutes}], [{min}], [{time}].

11.18.2. i18n/en.json

The English dictionary has exactly the same keys. Here are a few lines for comparison:

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Log out",
    "MENU_SCHEDULE": "Schedule",
    "MENU_DOCTORS": "Doctors",
    "MENU_PATIENTS": "Patients",
    "MENU_MY_AREA": "My area"
  },
  "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."

One more dictionary would be enough for a third language: it would need to be added to the translator’s list ([LANGUAGES]) and a button added to the navigation bar.

11.19. Static files

The [static] folder contains the application's style sheet, its script, and [Bootstrap] ([lib/bootstrap] folder: the [bootstrap.min.css] style sheet from the official distribution, and its license; the application does not use [JavaScript] from [Bootstrap]).

11.19.1. static/css/styles.css

/* =========================================================================
   static/css/styles.css - a few settings on top of Bootstrap
   (the same as in the ASP.NET Core MVC and Spring MVC versions of this application).
   ========================================================================= */

body {
  background-color: #f5f7fa;
}

.free-slot:hover {
  background-color: #e9f7ef;
}

/* -------------------------------------------------------------------------
   .error-zone: the error messages, at the bottom of the page, on a light pink background
   (see templates/fragments/errors.html).
   position: sticky + bottom: if the page is taller than the window (a
   schedule of 12 slots, for example), the zone stays stuck to the bottom of the
   window instead of disappearing below the fold - without ever
   covering the content when the page is short.
   z-index: above the grey background of the booking dialog (.modal-backdrop).
   ------------------------------------------------------------------------- */
.error-zone {
  position: sticky;
  bottom: 1rem;
  z-index: 1060;
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef; /* light pink */
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

/* the two big options of the patient's "My area" page */
.option-card {
  transition: border-color 0.15s, box-shadow 0.15s;
}

.option-card:hover {
  border-color: #0d6efd;
  box-shadow: 0 0.25rem 0.75rem rgba(13, 110, 253, 0.15);
}

/* the captcha image (SVG of 150 x 50 pixels) */
.captcha-image svg {
  display: block;
  border: 1px solid #dee2e6;
  border-radius: 0.375rem;
}

A few additional rules for [Bootstrap], the same as in previous versions of the application. The error area (lines 23–32) is sticky ([position: sticky], [bottom: 1rem]): if the page is taller than the window (a calendar with twelve time slots), it remains visible at the bottom of the window instead of disappearing below the fold. [z-index: 1060] places it above the gray background of modal windows.

11.19.2. static/js/app.js

// =========================================================================
// static/js/app.js - the ONLY JavaScript of the application.
// -------------------------------------------------------------------------
// The whole application works without JavaScript. This script adds a
// convenience: before sending a form that carries the attribute
// data-confirmation="...", a modal dialog asks "Are you sure?"
// (the dialog itself is in the page: templates/fragments/confirmation.html).
// If JavaScript is disabled, the form is simply sent without
// confirmation - nothing breaks ("progressive enhancement" principle).
//
// Why not window.confirm()? Its box cannot be styled, and its
// buttons stay in the language of the browser, not in that of the page.
// =========================================================================

(() => {
  const modal = document.getElementById('confirmation-modal');
  if (!modal) {
    return;
  }
  const message = document.getElementById('confirmation-modal-message');
  const noButton = modal.querySelector('.modal-footer [data-action="no"]');

  let pendingForm = null; // the form to send if the user confirms
  let previousFocus = null; // the element to give the focus back to on closing

  function open(form) {
    pendingForm = form;
    previousFocus = document.activeElement;
    // textContent (and not innerHTML): the message is displayed as TEXT,
    // never interpreted as HTML
    message.textContent = form.dataset.confirmation;
    modal.classList.remove('d-none');
    // the focus goes on "No": an absent-minded press on Enter deletes nothing
    noButton.focus();
  }

  function close() {
    modal.classList.add('d-none');
    pendingForm = null;
    if (previousFocus) {
      previousFocus.focus();
    }
  }

  // 1. we intercept the sending of the forms to confirm
  document.querySelectorAll('form[data-confirmation]').forEach((form) => {
    form.addEventListener('submit', (event) => {
      event.preventDefault();
      open(form);
    });
  });

  // 2. the buttons of the dialog (Yes / No / close cross)
  modal.addEventListener('click', (event) => {
    const action = event.target.closest('[data-action]')?.dataset.action;
    if (action === 'yes') {
      const form = pendingForm;
      close();
      // form.submit() sends the form WITHOUT triggering the
      // 'submit' event again: no new confirmation request
      form.submit();
    } else if (action === 'no') {
      close();
    } else if (event.target.classList.contains('modal')) {
      close(); // click on the grey background, outside the dialog
    }
  });

  // 3. keyboard: Escape closes the dialog; Tab stays INSIDE the dialog
  //    (otherwise the focus would go to the greyed-out page, behind)
  modal.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
      close();
    } else if (event.key === 'Tab') {
      const buttons = [...modal.querySelectorAll('button')];
      const first = buttons[0];
      const last = buttons[buttons.length - 1];
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }
  });
})();

The script from Example 21, completed:

  • lines 16–18: the script does nothing if the page does not have a confirmation window;
  • lines 26–34: upon opening, the message is placed using [textContent], not [innerHTML]: it is displayed as text and is never interpreted as HTML. The focus moves to the “No” button: accidentally pressing Enter does not delete anything;
  • lines 37–41: Upon closing, the focus returns to the element that had it previously (the “Delete” button), which is important for keyboard navigation;
  • lines 54–65: clicks within the window. [closest('[data-action]')] searches for the element with the attribute [data-action], starting from the clicked element and moving up through its parent elements: a click on an icon inside a button is thus attributed to the button. The operator [?.] returns [undefined] instead of raising an error if no element is found. A click on the grayed-out background closes the window;
  • lines 71–83: keyboard behavior: Esc closes the window; Tab and Shift-Tab cycle the focus among the buttons in the window, without moving to the grayed-out page behind it.