# [COURS] EXERCICES CORRIGÉS FLASK - ULTRA DÉTAILLÉS

## [LIVRE] INTRODUCTION

Ce document contient **5 exercices pratiques corrigés** sur Flask, le framework web Python minimaliste et puissant. Chaque exercice est conçu pour :

- **Renforcer** tes compétences en développement web Python
- **Consolider** les concepts de Flask et des APIs REST
- **Simuler** des situations réelles en entreprise
- **Te préparer** à des projets professionnels

**Niveau de progression :**
- [VERT] Exercice 1 : Débutant - API REST Simple
- [JAUNE] Exercice 2 : Intermédiaire - CRUD avec SQLAlchemy
- [JAUNE] Exercice 3 : Intermédiaire - Authentification JWT
- [ROUGE] Exercice 4 : Avancé - Application Web Complète
- [ROUGE] Exercice 5 : Expert - API Production-Ready

**Chaque exercice contient :**
- [OK] Énoncé détaillé avec contexte professionnel
- [OK] Prérequis et objectifs pédagogiques
- [OK] Solution complète étape par étape
- [OK] Code commenté ligne par ligne
- [OK] Tests de validation
- [OK] Erreurs courantes et solutions
- [OK] Points clés à retenir
- [OK] Pour aller plus loin

**Prérequis généraux :**
- Python 3.8+ installé
- Connaissances de base en Python
- Notions de HTTP/REST
- Terminal/ligne de commande

**Bon courage ! [RAPIDE]**

---

---

# [VERT] EXERCICE 1 : API REST SIMPLE (TODO LIST)

## [LISTE] ÉNONCÉ

### Contexte professionnel

Tu es développeur backend dans une startup. Le product manager te demande de créer une API REST simple pour gérer une liste de tâches (TODO list). C'est un POC (Proof of Concept) pour tester Flask avant de l'utiliser dans des projets plus importants.

### Cahier des charges

L'API doit permettre de :
- **GET /api/tasks** : Récupérer toutes les tâches
- **GET /api/tasks/<id>** : Récupérer une tâche spécifique
- **POST /api/tasks** : Créer une nouvelle tâche
- **PUT /api/tasks/<id>** : Modifier une tâche
- **DELETE /api/tasks/<id>** : Supprimer une tâche

### Contraintes techniques

- Flask sans base de données (liste en mémoire)
- Réponses en JSON
- Codes HTTP appropriés (200, 201, 404, 400, etc.)
- Validation basique des données
- Gestion des erreurs
- Temps estimé : 1-2 heures

---

## [OBJECTIF] OBJECTIFS PÉDAGOGIQUES

À la fin de cet exercice, tu sauras :

- [OK] Installer Flask et créer un environnement virtuel
- [OK] Créer une application Flask de base
- [OK] Définir des routes avec différentes méthodes HTTP
- [OK] Retourner des réponses JSON
- [OK] Gérer les paramètres d'URL et le corps de requête
- [OK] Utiliser les codes de statut HTTP appropriés
- [OK] Gérer les erreurs avec des gestionnaires personnalisés
- [OK] Tester une API avec curl et Postman

---

## [DOCS] PRÉREQUIS

- Python 3.8+ installé
- pip (gestionnaire de paquets Python)
- Éditeur de code (VS Code, PyCharm, etc.)
- Terminal/ligne de commande
- Connaissances de base en Python (dictionnaires, listes, fonctions)

---

## [OK] SOLUTION COMPLÈTE

### ÉTAPE 1 : Créer l'environnement de développement

**Créer un dossier pour le projet :**

```bash
mkdir flask-todo-api
cd flask-todo-api
```

---

**Créer un environnement virtuel Python :**

```bash
python3 -m venv venv
```

**Explication de l'environnement virtuel :**

Un **environnement virtuel** (virtual environment) est un environnement Python isolé qui permet d'installer des paquets spécifiques à un projet sans affecter le système global.

**Pourquoi utiliser un environnement virtuel ?**

| Sans venv | Avec venv |
|-----------|-----------|
| Paquets installés globalement | Paquets isolés par projet |
| Conflits de versions | Pas de conflits |
| `pip install flask` affecte tout | `pip install flask` seulement pour ce projet |
| Difficile de reproduire l'environnement | requirements.txt facile |

**Structure créée :**

```
flask-todo-api/
└── venv/
    ├── bin/         # Exécutables (python, pip, etc.)
    ├── lib/         # Bibliothèques Python installées
    └── pyvenv.cfg   # Configuration
```

---

**Activer l'environnement virtuel :**

**Linux/macOS :**

```bash
source venv/bin/activate
```

**Windows (PowerShell) :**

```powershell
venv\Scripts\Activate.ps1
```

**Windows (CMD) :**

```cmd
venv\Scripts\activate.bat
```

**Indicateur d'activation :**

Ton prompt devrait maintenant afficher `(venv)` au début :

```bash
(venv) user@machine:~/flask-todo-api$
```

**[OK] L'environnement virtuel est actif !**

---

**Installer Flask :**

```bash
pip install Flask
```

**Résultat :**

```
Collecting Flask
  Downloading Flask-3.0.0-py3-none-any.whl (...)
Collecting Werkzeug>=3.0.0
  Downloading Werkzeug-3.0.1-py3-none-any.whl (...)
Collecting Jinja2>=3.1.2
  Downloading Jinja2-3.1.2-py3-none-any.whl (...)
...
Successfully installed Flask-3.0.0 Werkzeug-3.0.1 Jinja2-3.1.2 ...
```

**Flask installe automatiquement ses dépendances :**

- **Werkzeug** : Boîte à outils WSGI (Web Server Gateway Interface)
- **Jinja2** : Moteur de templates
- **Click** : Framework CLI (Command Line Interface)
- **MarkupSafe** : Échappement HTML sécurisé
- **itsdangerous** : Signatures cryptographiques (sessions, cookies)

---

**Vérifier l'installation :**

```bash
python -c "import flask; print(flask.__version__)"
```

**Résultat :**

```
3.0.0
```

**[OK] Flask est installé !**

---

**Créer le fichier requirements.txt (bonne pratique) :**

```bash
pip freeze > requirements.txt
```

**Contenu de `requirements.txt` :**

```
blinker==1.7.0
click==8.1.7
Flask==3.0.0
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.3
Werkzeug==3.0.1
```

**Utilité de requirements.txt :**

Permet de reproduire l'environnement sur une autre machine :

```bash
pip install -r requirements.txt
```

---

### ÉTAPE 2 : Créer l'application Flask de base

**Créer le fichier `app.py` :**

```bash
touch app.py
```

**Ouvrir dans un éditeur et ajouter :**

```python
# ═══════════════════════════════════════════════════════════════
# API REST TODO LIST - FLASK
# ═══════════════════════════════════════════════════════════════
# Description : API simple pour gérer une liste de tâches
# Auteur : [Ton Nom]
# Date : Décembre 2024
# ═══════════════════════════════════════════════════════════════

from flask import Flask, jsonify, request, abort
from datetime import datetime

# ───────────────────────────────────────────────────────────────
# INITIALISATION DE L'APPLICATION FLASK
# ───────────────────────────────────────────────────────────────

app = Flask(__name__)

"""
Flask(__name__) :
Crée une instance de l'application Flask

__name__ : Nom du module Python actuel
- Si exécuté directement : __name__ = '__main__'
- Si importé : __name__ = 'app'

Flask utilise __name__ pour :
- Déterminer le chemin racine de l'application
- Localiser les ressources (templates, static files)
- Configuration du logging
"""

# Configuration de l'application
app.config['JSON_SORT_KEYS'] = False  # Ne pas trier les clés JSON
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True  # JSON indenté

"""
app.config :
Dictionnaire de configuration de Flask

JSON_SORT_KEYS = False :
Par défaut, Flask trie les clés JSON alphabétiquement
False = Garde l'ordre d'insertion (Python 3.7+)

JSONIFY_PRETTYPRINT_REGULAR = True :
Indente le JSON pour une meilleure lisibilité
{
  "id": 1,
  "title": "Tâche"
}
au lieu de
{"id":1,"title":"Tâche"}
"""

# ───────────────────────────────────────────────────────────────
# DONNÉES EN MÉMOIRE (SIMULATION BASE DE DONNÉES)
# ───────────────────────────────────────────────────────────────

tasks = [
    {
        'id': 1,
        'title': 'Apprendre Flask',
        'description': 'Suivre le tutoriel complet de Flask',
        'completed': False,
        'created_at': '2024-12-16T10:00:00'
    },
    {
        'id': 2,
        'title': 'Créer une API',
        'description': 'Développer une API REST avec Flask',
        'completed': False,
        'created_at': '2024-12-16T11:00:00'
    },
    {
        'id': 3,
        'title': 'Tester l\'API',
        'description': 'Tester tous les endpoints avec Postman',
        'completed': True,
        'created_at': '2024-12-16T12:00:00'
    }
]

"""
Structure de données :
Liste de dictionnaires Python (simule une table de base de données)

Chaque tâche contient :
- id : Identifiant unique (int)
- title : Titre de la tâche (str)
- description : Description détaillée (str)
- completed : État de complétion (bool)
- created_at : Date de création (str ISO 8601)

[ATTENTION] Données en MÉMOIRE :
Toutes les modifications sont perdues au redémarrage du serveur
Pour persister les données, il faut une base de données (voir exercice 2)
"""

# Compteur pour générer des IDs uniques
next_id = 4

"""
next_id :
Variable globale pour générer des IDs incrémentaux
4 car on a déjà 3 tâches (IDs 1, 2, 3)

À chaque création de tâche :
task['id'] = next_id
next_id += 1

Alternative en production : UUID, auto-increment base de données
"""

# ═══════════════════════════════════════════════════════════════
# ROUTES DE L'API
# ═══════════════════════════════════════════════════════════════

# ───────────────────────────────────────────────────────────────
# ROUTE RACINE (TEST)
# ───────────────────────────────────────────────────────────────

@app.route('/')
def index():
    """
    Route racine de l'API
    Retourne un message de bienvenue
    """
    return jsonify({
        'message': 'Bienvenue sur l\'API TODO List',
        'version': '1.0.0',
        'endpoints': {
            'GET /api/tasks': 'Récupérer toutes les tâches',
            'GET /api/tasks/<id>': 'Récupérer une tâche spécifique',
            'POST /api/tasks': 'Créer une nouvelle tâche',
            'PUT /api/tasks/<id>': 'Modifier une tâche',
            'DELETE /api/tasks/<id>': 'Supprimer une tâche'
        }
    })

"""
@app.route('/') :
Décorateur qui associe une fonction à une route URL

Quand l'utilisateur accède à http://localhost:5000/
Flask exécute la fonction index()

jsonify() :
Fonction Flask qui convertit un dictionnaire Python en réponse JSON
Définit automatiquement le header Content-Type: application/json
Ajoute le code de statut 200 OK par défaut

Retour :
{
  "message": "Bienvenue...",
  "version": "1.0.0",
  "endpoints": { ... }
}
"""

# ───────────────────────────────────────────────────────────────
# GET /api/tasks - RÉCUPÉRER TOUTES LES TÂCHES
# ───────────────────────────────────────────────────────────────

@app.route('/api/tasks', methods=['GET'])
def get_tasks():
    """
    Récupère toutes les tâches
    
    Returns:
        JSON: Liste de toutes les tâches avec code 200
    """
    return jsonify({
        'tasks': tasks,
        'total': len(tasks)
    }), 200

"""
@app.route('/api/tasks', methods=['GET']) :

'/api/tasks' : Chemin de la route
methods=['GET'] : Méthodes HTTP acceptées
- Par défaut : ['GET']
- Si méthode non autorisée : erreur 405 Method Not Allowed

Exemple de requête :
GET http://localhost:5000/api/tasks

Réponse :
{
  "tasks": [
    { "id": 1, "title": "...", ... },
    { "id": 2, "title": "...", ... },
    { "id": 3, "title": "...", ... }
  ],
  "total": 3
}

, 200 :
Code de statut HTTP explicite
Sans ça, Flask retourne 200 par défaut
Mais c'est une bonne pratique de l'expliciter
"""

# ───────────────────────────────────────────────────────────────
# GET /api/tasks/<id> - RÉCUPÉRER UNE TÂCHE SPÉCIFIQUE
# ───────────────────────────────────────────────────────────────

@app.route('/api/tasks/<int:task_id>', methods=['GET'])
def get_task(task_id):
    """
    Récupère une tâche spécifique par son ID
    
    Args:
        task_id (int): ID de la tâche
        
    Returns:
        JSON: Détails de la tâche avec code 200
        ou erreur 404 si tâche non trouvée
    """
    # Chercher la tâche par ID
    task = next((task for task in tasks if task['id'] == task_id), None)
    
    """
    next() :
    Retourne le premier élément d'un itérateur
    
    (task for task in tasks if task['id'] == task_id) :
    Générateur qui parcourt tasks et filtre par ID
    
    None :
    Valeur par défaut si aucune tâche trouvée
    
    Équivalent à :
    task = None
    for t in tasks:
        if t['id'] == task_id:
            task = t
            break
    
    Mais next() + générateur = plus Pythonic et performant
    """
    
    if task is None:
        abort(404, description=f"Tâche avec l'ID {task_id} non trouvée")
    
    """
    abort(404, ...) :
    Arrête l'exécution et retourne une erreur HTTP
    
    404 : Code de statut (Not Found)
    description : Message d'erreur personnalisé
    
    Flask retourne automatiquement :
    {
      "error": "Not Found",
      "message": "Tâche avec l'ID 5 non trouvée"
    }
    
    Sans abort(), il faudrait faire :
    return jsonify({'error': '...'}), 404
    """
    
    return jsonify(task), 200

"""
<int:task_id> :
Paramètre d'URL avec conversion de type

Exemples :
/api/tasks/1 -> task_id = 1 (int)
/api/tasks/abc -> Erreur 404 (pas un int)

Autres convertisseurs :
<task_id> : str (par défaut)
<float:prix> : float
<path:chemin> : str avec slashes (/)
<uuid:user_id> : UUID
"""

# ───────────────────────────────────────────────────────────────
# POST /api/tasks - CRÉER UNE NOUVELLE TÂCHE
# ───────────────────────────────────────────────────────────────

@app.route('/api/tasks', methods=['POST'])
def create_task():
    """
    Crée une nouvelle tâche
    
    Expected JSON:
        {
            "title": "Titre de la tâche",
            "description": "Description optionnelle"
        }
        
    Returns:
        JSON: Tâche créée avec code 201
        ou erreur 400 si données invalides
    """
    global next_id  # Pour modifier la variable globale
    
    """
    global next_id :
    Permet de modifier la variable globale next_id
    
    Sans global :
    next_id += 1  # [X] UnboundLocalError
    
    Avec global :
    next_id += 1  # [OK] OK
    
    Alternative (meilleure pratique) :
    Utiliser une classe ou une base de données
    """
    
    # Récupérer les données JSON de la requête
    data = request.get_json()
    
    """
    request.get_json() :
    Parse le corps de la requête HTTP en JSON
    Retourne un dictionnaire Python ou None
    
    Requête HTTP :
    POST /api/tasks
    Content-Type: application/json
    
    {
      "title": "Ma tâche",
      "description": "Description"
    }
    
    -> data = {'title': 'Ma tâche', 'description': 'Description'}
    
    Si Content-Type n'est pas application/json :
    -> data = None
    
    Options :
    request.get_json(force=True) : Parse même sans le bon Content-Type
    request.get_json(silent=True) : Retourne None au lieu d'erreur
    """
    
    # Validation des données
    if not data:
        abort(400, description="Aucune donnée fournie")
    
    if 'title' not in data or not data['title'].strip():
        abort(400, description="Le champ 'title' est obligatoire")
    
    """
    Validation basique :
    
    1. Vérifier que data existe (pas None)
    2. Vérifier que 'title' existe dans data
    3. Vérifier que title n'est pas vide (après strip des espaces)
    
    data['title'].strip() :
    Supprime les espaces au début et à la fin
    "  Ma tâche  ".strip() -> "Ma tâche"
    "   ".strip() -> "" (vide)
    
    not data['title'].strip() :
    True si la chaîne est vide après strip
    
    En production, utiliser des bibliothèques de validation :
    - marshmallow
    - pydantic
    - flask-inputs
    """
    
    # Créer la nouvelle tâche
    new_task = {
        'id': next_id,
        'title': data['title'].strip(),
        'description': data.get('description', '').strip(),
        'completed': False,
        'created_at': datetime.now().isoformat()
    }
    
    """
    Création de la tâche :
    
    id : next_id (sera incrémenté après)
    title : data['title'] nettoyé (strip)
    description : data.get('description', '') avec valeur par défaut
    completed : False par défaut (nouvelle tâche)
    created_at : Timestamp actuel au format ISO 8601
    
    data.get('description', '') :
    Récupère 'description' si existe, sinon ''
    Évite KeyError si description non fournie
    
    datetime.now().isoformat() :
    Retourne la date/heure actuelle au format ISO 8601
    Exemple : '2024-12-16T15:30:45.123456'
    
    Format ISO 8601 :
    - Standard international
    - Facilement parsable
    - Compatible JSON
    """
    
    # Ajouter à la liste
    tasks.append(new_task)
    
    # Incrémenter l'ID pour la prochaine tâche
    next_id += 1
    
    # Retourner la tâche créée avec le code 201 Created
    return jsonify(new_task), 201

"""
Code 201 Created :
Indique qu'une ressource a été créée avec succès
Standard REST pour les requêtes POST

Autres codes courants :
200 OK : Requête réussie
201 Created : Ressource créée
204 No Content : Succès sans contenu
400 Bad Request : Requête invalide
404 Not Found : Ressource non trouvée
500 Internal Server Error : Erreur serveur
"""

# ───────────────────────────────────────────────────────────────
# PUT /api/tasks/<id> - MODIFIER UNE TÂCHE
# ───────────────────────────────────────────────────────────────

@app.route('/api/tasks/<int:task_id>', methods=['PUT'])
def update_task(task_id):
    """
    Modifie une tâche existante
    
    Args:
        task_id (int): ID de la tâche à modifier
        
    Expected JSON:
        {
            "title": "Nouveau titre",
            "description": "Nouvelle description",
            "completed": true
        }
        
    Returns:
        JSON: Tâche modifiée avec code 200
        ou erreur 404/400
    """
    # Trouver la tâche
    task = next((task for task in tasks if task['id'] == task_id), None)
    
    if task is None:
        abort(404, description=f"Tâche avec l'ID {task_id} non trouvée")
    
    # Récupérer les données
    data = request.get_json()
    
    if not data:
        abort(400, description="Aucune donnée fournie")
    
    # Mettre à jour les champs fournis
    if 'title' in data:
        if not data['title'].strip():
            abort(400, description="Le champ 'title' ne peut pas être vide")
        task['title'] = data['title'].strip()
    
    if 'description' in data:
        task['description'] = data['description'].strip()
    
    if 'completed' in data:
        # Valider que c'est un booléen
        if not isinstance(data['completed'], bool):
            abort(400, description="Le champ 'completed' doit être un booléen")
        task['completed'] = data['completed']
    
    """
    Mise à jour partielle :
    On ne modifie que les champs présents dans data
    
    Exemple :
    PUT /api/tasks/1
    { "completed": true }
    
    -> Seul 'completed' est modifié
    -> 'title' et 'description' restent inchangés
    
    isinstance(data['completed'], bool) :
    Vérifie que la valeur est un booléen Python
    
    True, False : bool [OK]
    "true", "false" : str [X]
    1, 0 : int [X]
    
    En JSON, les booléens sont true/false (minuscules)
    Flask les convertit automatiquement en True/False Python
    """
    
    return jsonify(task), 200

"""
PUT vs PATCH :

PUT : Remplacement complet de la ressource
- Tous les champs doivent être fournis
- Champs non fournis = supprimés ou réinitialisés

PATCH : Modification partielle
- Seuls les champs fournis sont modifiés
- Champs non fournis = inchangés

Ici, on implémente plutôt un PATCH (partiel)
Mais on utilise PUT par convention REST simple

En production, mieux vaut :
- PUT pour remplacement complet
- PATCH pour modification partielle
"""

# ───────────────────────────────────────────────────────────────
# DELETE /api/tasks/<id> - SUPPRIMER UNE TÂCHE
# ───────────────────────────────────────────────────────────────

@app.route('/api/tasks/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
    """
    Supprime une tâche
    
    Args:
        task_id (int): ID de la tâche à supprimer
        
    Returns:
        JSON: Message de confirmation avec code 200
        ou erreur 404
    """
    global tasks  # Pour modifier la liste globale
    
    # Trouver l'index de la tâche
    task_index = next(
        (index for index, task in enumerate(tasks) if task['id'] == task_id),
        None
    )
    
    """
    enumerate(tasks) :
    Retourne des tuples (index, element)
    
    [(0, task1), (1, task2), (2, task3)]
    
    next() avec enumerate :
    Retourne l'index de la première tâche qui correspond
    
    Pourquoi l'index et pas la tâche ?
    Pour pouvoir supprimer avec del tasks[index]
    ou tasks.pop(index)
    """
    
    if task_index is None:
        abort(404, description=f"Tâche avec l'ID {task_id} non trouvée")
    
    # Récupérer la tâche avant de la supprimer (pour la retourner)
    deleted_task = tasks[task_index]
    
    # Supprimer la tâche
    tasks.pop(task_index)
    
    """
    tasks.pop(index) :
    Supprime et retourne l'élément à l'index donné
    
    Alternative :
    del tasks[index] : Supprime mais ne retourne rien
    
    tasks.remove(task) : Supprime par valeur (pas index)
    Mais nécessite d'avoir la référence exacte de l'objet
    """
    
    return jsonify({
        'message': 'Tâche supprimée avec succès',
        'deleted_task': deleted_task
    }), 200

"""
Code 200 vs 204 pour DELETE :

200 OK avec corps :
Retourne des infos sur la ressource supprimée
Utile pour confirmation ou annulation

204 No Content sans corps :
Indique juste le succès
Plus léger (pas de JSON)

Les deux sont valides en REST
On choisit 200 pour retourner la tâche supprimée
"""

# ═══════════════════════════════════════════════════════════════
# GESTIONNAIRES D'ERREURS PERSONNALISÉS
# ═══════════════════════════════════════════════════════════════

@app.errorhandler(404)
def not_found(error):
    """
    Gestionnaire d'erreur 404
    Retourne une réponse JSON au lieu de HTML
    """
    return jsonify({
        'error': 'Not Found',
        'message': str(error.description) if hasattr(error, 'description') else 'Ressource non trouvée'
    }), 404

"""
@app.errorhandler(404) :
Intercepte toutes les erreurs 404 (Not Found)

Sans ce gestionnaire :
Flask retourne une page HTML par défaut
Pas pratique pour une API (on veut du JSON)

Avec ce gestionnaire :
Toutes les 404 retournent du JSON
{
  "error": "Not Found",
  "message": "..."
}

error.description :
Message personnalisé fourni dans abort()
abort(404, description="Tâche non trouvée")
-> error.description = "Tâche non trouvée"

hasattr(error, 'description') :
Vérifie que l'attribut existe
Évite AttributeError si description absente
"""

@app.errorhandler(400)
def bad_request(error):
    """
    Gestionnaire d'erreur 400
    """
    return jsonify({
        'error': 'Bad Request',
        'message': str(error.description) if hasattr(error, 'description') else 'Requête invalide'
    }), 400

@app.errorhandler(405)
def method_not_allowed(error):
    """
    Gestionnaire d'erreur 405
    Méthode HTTP non autorisée pour cette route
    """
    return jsonify({
        'error': 'Method Not Allowed',
        'message': 'Méthode HTTP non autorisée pour cette route'
    }), 405

"""
Erreur 405 Method Not Allowed :
Levée automatiquement par Flask quand la méthode HTTP n'est pas dans methods=[]

Exemple :
@app.route('/api/tasks', methods=['GET'])

Si l'utilisateur fait :
POST /api/tasks -> Erreur 405

Réponse avec le gestionnaire :
{
  "error": "Method Not Allowed",
  "message": "..."
}
"""

@app.errorhandler(500)
def internal_error(error):
    """
    Gestionnaire d'erreur 500
    Erreur interne du serveur
    """
    return jsonify({
        'error': 'Internal Server Error',
        'message': 'Une erreur interne est survenue'
    }), 500

"""
Erreur 500 Internal Server Error :
Erreur non gérée dans le code

Exemple :
division_result = 10 / 0  # ZeroDivisionError

Sans gestionnaire 500 :
Flask retourne une trace complète (dangereux en prod)

Avec gestionnaire 500 :
Message générique sans détails techniques

[ATTENTION] En production :
- Logger l'erreur complète (fichier, Sentry, etc.)
- Retourner message générique à l'utilisateur
- Ne JAMAIS exposer les détails techniques
"""

# ═══════════════════════════════════════════════════════════════
# POINT D'ENTRÉE DE L'APPLICATION
# ═══════════════════════════════════════════════════════════════

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=5000)

"""
if __name__ == '__main__':
Exécuté seulement si le fichier est lancé directement
python app.py -> __name__ = '__main__' -> Démarre le serveur
import app -> __name__ = 'app' -> Ne démarre PAS le serveur

app.run() :
Démarre le serveur de développement Flask

debug=True :
Mode debug activé
- Recharge automatiquement le code modifié (hot reload)
- Affiche les erreurs détaillées dans le navigateur
- Active le debugger interactif

[ATTENTION] NE JAMAIS utiliser debug=True en production !
Risque de sécurité (exécution de code arbitraire)

host='0.0.0.0' :
Écoute sur toutes les interfaces réseau
- 127.0.0.1 : Seulement localhost (défaut Flask)
- 0.0.0.0 : Toutes les interfaces (accessible depuis le réseau)

Utile pour :
- Tester depuis un autre appareil
- Conteneur Docker
- Machine virtuelle

port=5000 :
Port d'écoute (défaut Flask)
Accessible sur http://localhost:5000

[ATTENTION] En production, utiliser un serveur WSGI :
- Gunicorn
- uWSGI
- Waitress
Pas app.run() !
"""
```

**Sauvegarde du fichier `app.py`.**

---

### ÉTAPE 3 : Démarrer le serveur Flask

```bash
python app.py
```

**Résultat :**

```
 * Serving Flask app 'app'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://192.168.1.100:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 123-456-789
```

**[OK] Le serveur Flask est démarré !**

**Signification des messages :**

- **Debug mode: on** : Mode debug activé (hot reload)
- **Running on http://127.0.0.1:5000** : Accessible en local
- **Running on http://192.168.1.100:5000** : Accessible depuis le réseau
- **Debugger PIN** : Code pour déboguer en cas d'erreur

---

### ÉTAPE 4 : Tester l'API

**Test 1 : Route racine**

```bash
curl http://localhost:5000/
```

**Résultat :**

```json
{
  "endpoints": {
    "DELETE /api/tasks/<id>": "Supprimer une tâche",
    "GET /api/tasks": "Récupérer toutes les tâches",
    "GET /api/tasks/<id>": "Récupérer une tâche spécifique",
    "POST /api/tasks": "Créer une nouvelle tâche",
    "PUT /api/tasks/<id>": "Modifier une tâche"
  },
  "message": "Bienvenue sur l'API TODO List",
  "version": "1.0.0"
}
```

**[OK] La route racine fonctionne !**

---

**Test 2 : GET toutes les tâches**

```bash
curl http://localhost:5000/api/tasks
```

**Résultat :**

```json
{
  "tasks": [
    {
      "completed": false,
      "created_at": "2024-12-16T10:00:00",
      "description": "Suivre le tutoriel complet de Flask",
      "id": 1,
      "title": "Apprendre Flask"
    },
    {
      "completed": false,
      "created_at": "2024-12-16T11:00:00",
      "description": "Développer une API REST avec Flask",
      "id": 2,
      "title": "Créer une API"
    },
    {
      "completed": true,
      "created_at": "2024-12-16T12:00:00",
      "description": "Tester tous les endpoints avec Postman",
      "id": 3,
      "title": "Tester l'API"
    }
  ],
  "total": 3
}
```

**[OK] Récupération de toutes les tâches OK !**

---

**Test 3 : GET une tâche spécifique**

```bash
curl http://localhost:5000/api/tasks/1
```

**Résultat :**

```json
{
  "completed": false,
  "created_at": "2024-12-16T10:00:00",
  "description": "Suivre le tutoriel complet de Flask",
  "id": 1,
  "title": "Apprendre Flask"
}
```

**[OK] Récupération d'une tâche spécifique OK !**

---

**Test 4 : GET tâche inexistante**

```bash
curl http://localhost:5000/api/tasks/999
```

**Résultat :**

```json
{
  "error": "Not Found",
  "message": "Tâche avec l'ID 999 non trouvée"
}
```

**Code de statut : 404**

**[OK] Gestion d'erreur 404 OK !**

---

**Test 5 : POST créer une tâche**

```bash
curl -X POST http://localhost:5000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Déployer l'\''API",
    "description": "Mettre l'\''API en production"
  }'
```

**Résultat :**

```json
{
  "completed": false,
  "created_at": "2024-12-16T15:30:45.123456",
  "description": "Mettre l'API en production",
  "id": 4,
  "title": "Déployer l'API"
}
```

**Code de statut : 201 Created**

**[OK] Création de tâche OK !**

---

**Test 6 : PUT modifier une tâche**

```bash
curl -X PUT http://localhost:5000/api/tasks/4 \
  -H "Content-Type: application/json" \
  -d '{
    "completed": true
  }'
```

**Résultat :**

```json
{
  "completed": true,
  "created_at": "2024-12-16T15:30:45.123456",
  "description": "Mettre l'API en production",
  "id": 4,
  "title": "Déployer l'API"
}
```

**[OK] Modification de tâche OK !**

---

**Test 7 : DELETE supprimer une tâche**

```bash
curl -X DELETE http://localhost:5000/api/tasks/4
```

**Résultat :**

```json
{
  "deleted_task": {
    "completed": true,
    "created_at": "2024-12-16T15:30:45.123456",
    "description": "Mettre l'API en production",
    "id": 4,
    "title": "Déployer l'API"
  },
  "message": "Tâche supprimée avec succès"
}
```

**[OK] Suppression de tâche OK !**

---

**Vérifier que la tâche est bien supprimée :**

```bash
curl http://localhost:5000/api/tasks
```

**Résultat : Seulement 3 tâches (l'ID 4 n'est plus là).**

---

### [OK] TESTS DE VALIDATION

**Fonctionnalités de base :**
- [ ] GET /api/tasks retourne toutes les tâches
- [ ] GET /api/tasks/1 retourne la tâche ID 1
- [ ] GET /api/tasks/999 retourne erreur 404
- [ ] POST /api/tasks crée une tâche (code 201)
- [ ] PUT /api/tasks/1 modifie la tâche
- [ ] DELETE /api/tasks/1 supprime la tâche

**Validation des données :**
- [ ] POST sans title -> erreur 400
- [ ] POST avec title vide -> erreur 400
- [ ] PUT avec completed non-booléen -> erreur 400

**Codes de statut HTTP :**
- [ ] Succès -> 200 ou 201
- [ ] Ressource non trouvée -> 404
- [ ] Requête invalide -> 400
- [ ] Méthode non autorisée -> 405

---

### [ROUGE] ERREURS COURANTES ET SOLUTIONS

#### Erreur 1 : ModuleNotFoundError: No module named 'flask'

**Cause : Flask pas installé ou environnement virtuel pas activé**

**Solution :**

```bash
# Activer venv
source venv/bin/activate  # Linux/Mac
venv\Scripts\activate     # Windows

# Installer Flask
pip install Flask
```

---

#### Erreur 2 : Address already in use (port 5000)

**Cause : Un autre processus utilise déjà le port 5000**

**Solution 1 : Trouver et tuer le processus**

```bash
# Linux/Mac
lsof -i :5000
kill -9 <PID>

# Windows
netstat -ano | findstr :5000
taskkill /PID <PID> /F
```

**Solution 2 : Changer de port**

```python
app.run(debug=True, port=5001)
```

---

#### Erreur 3 : 400 Bad Request en créant une tâche

**Cause : Content-Type manquant ou incorrect**

**Vérifier :**

```bash
curl -X POST http://localhost:5000/api/tasks \
  -H "Content-Type: application/json" \  # <- Important !
  -d '{"title": "Test"}'
```

---

#### Erreur 4 : TypeError: 'NoneType' object is not subscriptable

**Cause : request.get_json() retourne None**

**Débogage :**

```python
data = request.get_json()
print(f"Data reçue : {data}")  # Vérifier ce qui est reçu

if not data:
    abort(400, description="Aucune donnée JSON fournie")
```

---

### [IMPORTANT] POINTS CLÉS À RETENIR

**1. Structure Flask de base**
- `Flask(__name__)` : Crée l'application
- `@app.route()` : Définit les routes
- `jsonify()` : Convertit dict -> JSON
- `request.get_json()` : Parse le JSON de la requête

**2. Méthodes HTTP**
- **GET** : Récupérer (lecture)
- **POST** : Créer
- **PUT** : Modifier (complet)
- **PATCH** : Modifier (partiel)
- **DELETE** : Supprimer

**3. Codes de statut HTTP**
- 200 OK : Succès
- 201 Created : Ressource créée
- 400 Bad Request : Requête invalide
- 404 Not Found : Ressource non trouvée
- 405 Method Not Allowed : Méthode HTTP invalide
- 500 Internal Server Error : Erreur serveur

**4. Validation des données**
- Toujours vérifier que les données existent
- Valider les types (bool, int, str)
- Valider les valeurs (non vide, format, etc.)

**5. Gestion des erreurs**
- `abort()` pour arrêter et retourner une erreur
- Gestionnaires `@app.errorhandler()` pour JSON
- Messages d'erreur clairs et descriptifs

---

### [RAPIDE] POUR ALLER PLUS LOIN

**1. Ajouter un filtre de recherche**

```python
@app.route('/api/tasks/search', methods=['GET'])
def search_tasks():
    query = request.args.get('q', '').lower()
    results = [
        task for task in tasks 
        if query in task['title'].lower() or query in task['description'].lower()
    ]
    return jsonify({'tasks': results, 'total': len(results)}), 200
```

**Test :**

```bash
curl "http://localhost:5000/api/tasks/search?q=flask"
```

---

**2. Pagination**

```python
@app.route('/api/tasks', methods=['GET'])
def get_tasks():
    page = request.args.get('page', 1, type=int)
    per_page = request.args.get('per_page', 10, type=int)
    
    start = (page - 1) * per_page
    end = start + per_page
    
    paginated_tasks = tasks[start:end]
    
    return jsonify({
        'tasks': paginated_tasks,
        'page': page,
        'per_page': per_page,
        'total': len(tasks)
    }), 200
```

---

**3. Tri des résultats**

```python
@app.route('/api/tasks', methods=['GET'])
def get_tasks():
    sort_by = request.args.get('sort_by', 'created_at')
    order = request.args.get('order', 'asc')
    
    sorted_tasks = sorted(
        tasks,
        key=lambda x: x.get(sort_by, ''),
        reverse=(order == 'desc')
    )
    
    return jsonify({'tasks': sorted_tasks}), 200
```

---

**4. Statistiques**

```python
@app.route('/api/tasks/stats', methods=['GET'])
def get_stats():
    total = len(tasks)
    completed = len([t for t in tasks if t['completed']])
    pending = total - completed
    
    return jsonify({
        'total': total,
        'completed': completed,
        'pending': pending,
        'completion_rate': round((completed / total * 100), 2) if total > 0 else 0
    }), 200
```

---

## [COURS] CONCLUSION DE L'EXERCICE 1

**[OK] Félicitations ! Tu as créé ta première API REST avec Flask !**

**Ce que tu as appris :**
- Créer une application Flask
- Définir des routes avec différentes méthodes HTTP
- Retourner des réponses JSON
- Gérer les paramètres d'URL
- Parser le corps de requête JSON
- Valider les données
- Gérer les erreurs avec des codes HTTP appropriés
- Créer des gestionnaires d'erreurs personnalisés

**Compétences acquises :**
- [OK] Flask de base (niveau débutant)
- [OK] API REST (CRUD complet)
- [OK] Validation et gestion d'erreurs
- [OK] Tests avec curl

**Temps moyen de réalisation :** 1-2 heures

**Prochaine étape :** Exercice 2 - CRUD avec SQLAlchemy et base de données ! [ARCHIVE]

---

*La suite des exercices 2, 3, 4 et 5 suivra le même niveau de détail...*

# [JAUNE] EXERCICE 2 : CRUD AVEC SQLALCHEMY ET BASE DE DONNÉES

## [LISTE] ÉNONCÉ

### Contexte professionnel

Tu es développeur backend dans une entreprise qui veut moderniser sa gestion de tâches. L'exercice 1 a convaincu l'équipe, mais les données en mémoire ne suffisent pas. Il faut maintenant persister les données dans une vraie base de données.

### Cahier des charges

Reprendre l'API de l'exercice 1 et :
- **Remplacer** la liste en mémoire par une base de données SQLite
- **Utiliser** SQLAlchemy comme ORM (Object-Relational Mapping)
- **Ajouter** la gestion des catégories de tâches
- **Implémenter** les relations entre tables (One-to-Many)
- **Gérer** les migrations de base de données
- **Ajouter** des filtres avancés (par catégorie, statut, date)

### Contraintes techniques

- SQLAlchemy 2.0+
- Flask-Migrate pour les migrations
- Validation avec marshmallow
- Structure de projet modulaire
- Tests unitaires basiques
- Temps estimé : 2-3 heures

---

## [OBJECTIF] OBJECTIFS PÉDAGOGIQUES

À la fin de cet exercice, tu sauras :

- [OK] Configurer SQLAlchemy avec Flask
- [OK] Créer des modèles de données (tables)
- [OK] Gérer les relations entre tables (Foreign Keys)
- [OK] Effectuer des opérations CRUD avec SQLAlchemy
- [OK] Utiliser Flask-Migrate pour les migrations
- [OK] Valider les données avec marshmallow
- [OK] Structurer un projet Flask de manière professionnelle
- [OK] Gérer les transactions et le rollback
- [OK] Optimiser les requêtes (eager loading, lazy loading)

---

## [DOCS] PRÉREQUIS

- Exercice 1 terminé et compris
- Compréhension des bases de données relationnelles
- Notions de SQL (CREATE, SELECT, INSERT, UPDATE, DELETE)
- Connaissance des clés étrangères et relations

---

## [OK] SOLUTION COMPLÈTE

### ÉTAPE 1 : Créer la structure du projet

**Structure professionnelle du projet :**

```
flask-todo-db/
├── venv/                    # Environnement virtuel
├── app/                     # Package principal de l'application
│   ├── __init__.py         # Initialisation de l'app Flask
│   ├── models.py           # Modèles SQLAlchemy
│   ├── schemas.py          # Schémas marshmallow (validation)
│   ├── routes/             # Routes organisées par module
│   │   ├── __init__.py
│   │   ├── tasks.py        # Routes des tâches
│   │   └── categories.py  # Routes des catégories
│   └── config.py           # Configuration
├── migrations/             # Dossier des migrations (créé auto)
├── instance/               # Données spécifiques à l'instance
│   └── todo.db            # Base de données SQLite
├── tests/                  # Tests unitaires
│   ├── __init__.py
│   └── test_tasks.py
├── run.py                  # Point d'entrée de l'application
├── requirements.txt        # Dépendances
└── .env                    # Variables d'environnement (optionnel)
```

**Explication de la structure :**

**`app/` package :**
- Organisation modulaire du code
- `__init__.py` rend `app` importable comme package
- Séparation des responsabilités (models, schemas, routes)

**`migrations/` :**
- Géré par Flask-Migrate
- Contient l'historique des modifications de la base de données
- Permet de versionner le schéma de la base

**`instance/` :**
- Données spécifiques à chaque installation
- Exclus du contrôle de version (git)
- Base de données SQLite en développement

**`tests/` :**
- Tests unitaires et d'intégration
- Séparé du code de l'application

---

**Créer la structure :**

```bash
# Créer le dossier principal
mkdir flask-todo-db
cd flask-todo-db

# Créer l'environnement virtuel
python3 -m venv venv
source venv/bin/activate  # Linux/Mac
# venv\Scripts\activate   # Windows

# Créer la structure de dossiers
mkdir -p app/routes tests instance
touch app/__init__.py
touch app/models.py
touch app/schemas.py
touch app/config.py
touch app/routes/__init__.py
touch app/routes/tasks.py
touch app/routes/categories.py
touch tests/__init__.py
touch tests/test_tasks.py
touch run.py
```

---

### ÉTAPE 2 : Installer les dépendances

```bash
pip install Flask
pip install Flask-SQLAlchemy
pip install Flask-Migrate
pip install marshmallow
pip install python-dotenv
```

**Explication des paquets :**

**Flask-SQLAlchemy :**
- Extension Flask pour SQLAlchemy
- Simplifie l'intégration de SQLAlchemy avec Flask
- Fournit des helpers (db.session, db.Model, etc.)

**Flask-Migrate :**
- Gestion des migrations de base de données
- Basé sur Alembic
- Commandes CLI : `flask db init`, `flask db migrate`, `flask db upgrade`

**marshmallow :**
- Sérialisation/désérialisation de données
- Validation de schémas
- Conversion objet Python <-> JSON

**python-dotenv :**
- Charge les variables d'environnement depuis un fichier `.env`
- Utile pour la configuration (secrets, clés API, etc.)

---

**Créer `requirements.txt` :**

```bash
pip freeze > requirements.txt
```

**Contenu de `requirements.txt` :**

```
alembic==1.13.0
blinker==1.7.0
click==8.1.7
Flask==3.0.0
Flask-Migrate==4.0.5
Flask-SQLAlchemy==3.1.1
greenlet==3.0.1
itsdangerous==2.1.2
Jinja2==3.1.2
Mako==1.3.0
MarkupSafe==2.1.3
marshmallow==3.20.1
packaging==23.2
python-dotenv==1.0.0
SQLAlchemy==2.0.23
typing_extensions==4.9.0
Werkzeug==3.0.1
```

---

### ÉTAPE 3 : Configuration de l'application

**Créer `app/config.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# CONFIGURATION DE L'APPLICATION FLASK
# ═══════════════════════════════════════════════════════════════

import os
from pathlib import Path

# Chemin de base du projet
BASE_DIR = Path(__file__).resolve().parent.parent

"""
Path(__file__).resolve().parent.parent :

__file__ : Chemin du fichier actuel (config.py)
Exemple : /home/user/flask-todo-db/app/config.py

Path(__file__) : Convertit en objet Path
resolve() : Résout les liens symboliques et chemins relatifs
parent : Dossier parent (app/)
parent.parent : Dossier parent du parent (flask-todo-db/)

Résultat : BASE_DIR = /home/user/flask-todo-db/
"""

class Config:
    """
    Configuration de base
    Héritée par toutes les autres configurations
    """
    
    # Clé secrète pour les sessions et CSRF
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
    
    """
    SECRET_KEY :
    Clé utilisée pour :
    - Signer les sessions (cookies)
    - Tokens CSRF
    - Signatures cryptographiques
    
    os.environ.get('SECRET_KEY') :
    Essaie de lire depuis les variables d'environnement
    Permet de définir différentes clés par environnement
    
    or 'dev-secret-key...' :
    Valeur par défaut si SECRET_KEY non définie
    
    [ATTENTION] En production :
    - Générer une clé aléatoire forte
    - Stocker dans variable d'environnement
    - NE JAMAIS committer dans git
    
    Générer une clé :
    python -c 'import secrets; print(secrets.token_hex(32))'
    """
    
    # Configuration de la base de données
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
        'sqlite:///' + str(BASE_DIR / 'instance' / 'todo.db')
    
    """
    SQLALCHEMY_DATABASE_URI :
    URL de connexion à la base de données
    Format général : dialect+driver://username:password@host:port/database
    
    Exemples :
    SQLite : 'sqlite:///path/to/database.db'
    PostgreSQL : 'postgresql://user:pass@localhost:5432/mydb'
    MySQL : 'mysql+pymysql://user:pass@localhost:3306/mydb'
    
    'sqlite:///' + str(BASE_DIR / 'instance' / 'todo.db') :
    Construit le chemin vers la base SQLite
    
    BASE_DIR / 'instance' / 'todo.db' :
    Utilise l'opérateur / de pathlib
    Équivalent à os.path.join(BASE_DIR, 'instance', 'todo.db')
    
    Résultat : sqlite:////home/user/flask-todo-db/instance/todo.db
    
    Pourquoi instance/ ?
    - Données spécifiques à l'installation
    - Facile à exclure du contrôle de version
    - Séparation code/données
    """
    
    # Désactiver le tracking des modifications
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    
    """
    SQLALCHEMY_TRACK_MODIFICATIONS :
    Si True, SQLAlchemy émet des signaux à chaque modification d'objet
    
    Problèmes si True :
    - Consomme de la mémoire supplémentaire
    - Impact sur les performances
    - Rarement utilisé en pratique
    
    False (recommandé) :
    - Économise des ressources
    - Pas de perte de fonctionnalité pour la plupart des cas
    
    Note : Flask-SQLAlchemy émet un warning si non défini
    """
    
    # Configuration JSON
    JSON_SORT_KEYS = False
    JSONIFY_PRETTYPRINT_REGULAR = True
    
    # Pagination par défaut
    TASKS_PER_PAGE = 10
    
    """
    Configuration personnalisée :
    On peut ajouter n'importe quelle clé de configuration
    Accessible via app.config['TASKS_PER_PAGE']
    """


class DevelopmentConfig(Config):
    """
    Configuration pour le développement
    """
    DEBUG = True
    TESTING = False
    
    """
    DEBUG = True :
    - Active le mode debug Flask
    - Rechargement automatique du code
    - Erreurs détaillées dans le navigateur
    - Debugger interactif
    
    [ATTENTION] NE JAMAIS utiliser en production !
    """


class ProductionConfig(Config):
    """
    Configuration pour la production
    """
    DEBUG = False
    TESTING = False
    
    # En production, toujours lire depuis les variables d'environnement
    SECRET_KEY = os.environ.get('SECRET_KEY')
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
    
    """
    En production :
    - Pas de valeur par défaut pour SECRET_KEY
    - Force l'utilisation de variables d'environnement
    - Erreur si variables non définies (voulu)
    """


class TestingConfig(Config):
    """
    Configuration pour les tests
    """
    TESTING = True
    DEBUG = False
    
    # Base de données en mémoire pour les tests
    SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
    
    """
    sqlite:///:memory: :
    Base de données SQLite en mémoire (RAM)
    
    Avantages :
    - Très rapide (pas d'I/O disque)
    - Isolé (pas de fichier)
    - Reset automatique à chaque test
    
    Utilisation :
    - Tests unitaires
    - Tests d'intégration
    - CI/CD
    """
    
    # Désactiver CSRF pour les tests
    WTF_CSRF_ENABLED = False


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

"""
Dictionnaire de configurations :
Permet de sélectionner facilement la config

Utilisation :
config_name = os.environ.get('FLASK_ENV', 'development')
app.config.from_object(config[config_name])
"""
```

**Sauvegarde.**

---

### ÉTAPE 4 : Créer les modèles SQLAlchemy

**Créer `app/models.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# MODÈLES SQLALCHEMY - DÉFINITION DES TABLES
# ═══════════════════════════════════════════════════════════════

from datetime import datetime
from flask_sqlalchemy import SQLAlchemy

# Initialisation de SQLAlchemy
db = SQLAlchemy()

"""
db = SQLAlchemy() :
Crée une instance de SQLAlchemy

Cette instance sera configurée avec l'application Flask
dans app/__init__.py via db.init_app(app)

db fournit :
- db.Model : Classe de base pour les modèles
- db.session : Session pour les requêtes
- db.Column, db.Integer, db.String, etc. : Types de colonnes
- db.create_all(), db.drop_all() : Gestion des tables
"""

# ───────────────────────────────────────────────────────────────
# MODÈLE CATEGORY (CATÉGORIE)
# ───────────────────────────────────────────────────────────────

class Category(db.Model):
    """
    Modèle pour les catégories de tâches
    
    Table : categories
    Relations : One-to-Many avec Task
    """
    
    # Nom de la table (optionnel, par défaut = nom de la classe en minuscules)
    __tablename__ = 'categories'
    
    """
    __tablename__ :
    Nom explicite de la table dans la base de données
    
    Sans __tablename__ :
    SQLAlchemy utilise le nom de la classe en minuscules
    Category -> category (au singulier)
    
    Avec __tablename__ = 'categories' :
    On force le pluriel (convention REST)
    """
    
    # Colonnes
    id = db.Column(db.Integer, primary_key=True)
    
    """
    db.Column(db.Integer, primary_key=True) :
    
    db.Column : Définit une colonne
    db.Integer : Type de donnée (INT en SQL)
    primary_key=True : Clé primaire
    
    Clé primaire :
    - Identifiant unique de chaque ligne
    - Auto-incrémenté par défaut (1, 2, 3, ...)
    - Index automatique (recherche rapide)
    - NOT NULL implicite
    
    Équivalent SQL :
    id INTEGER PRIMARY KEY AUTOINCREMENT
    """
    
    name = db.Column(db.String(50), unique=True, nullable=False)
    
    """
    db.String(50) :
    Chaîne de caractères de longueur max 50
    Équivalent SQL : VARCHAR(50)
    
    unique=True :
    Valeur unique dans toute la table
    Crée un index unique automatiquement
    
    Exemple :
    - "Travail" OK
    - "Personnel" OK
    - "Travail" [X] Erreur (déjà existe)
    
    nullable=False :
    Colonne obligatoire (NOT NULL)
    INSERT sans 'name' -> Erreur
    
    Équivalent SQL :
    name VARCHAR(50) UNIQUE NOT NULL
    """
    
    description = db.Column(db.String(200))
    
    """
    Sans nullable=False :
    nullable=True par défaut (NULL autorisé)
    
    Équivalent SQL :
    description VARCHAR(200) NULL
    """
    
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    
    """
    db.DateTime :
    Type DATETIME en SQL
    Stocke date + heure
    
    default=datetime.utcnow :
    Valeur par défaut = fonction datetime.utcnow
    
    [ATTENTION] IMPORTANT : datetime.utcnow sans parenthèses !
    Correct : default=datetime.utcnow
    Incorrect : default=datetime.utcnow()
    
    Sans () : Passe la FONCTION
    SQLAlchemy appellera la fonction à chaque insertion
    
    Avec () : Passe la VALEUR
    Toutes les lignes auraient la même date (celle de la définition du modèle)
    
    datetime.utcnow vs datetime.now :
    utcnow : Heure UTC (recommandé, pas de problème de fuseau)
    now : Heure locale (dépend du serveur)
    """
    
    # Relation One-to-Many avec Task
    tasks = db.relationship('Task', backref='category', lazy='dynamic', cascade='all, delete-orphan')
    
    """
    db.relationship() :
    Définit une relation entre modèles
    N'est PAS une colonne dans la table !
    C'est une abstraction SQLAlchemy
    
    'Task' :
    Nom du modèle lié (chaîne de caractères)
    Pourquoi string ? Pour éviter les imports circulaires
    
    backref='category' :
    Crée automatiquement l'attribut 'category' sur Task
    task.category -> Retourne l'objet Category
    
    Exemple :
    category = Category(name="Travail")
    task = Task(title="Réunion", category=category)
    
    category.tasks -> Liste des tâches de cette catégorie
    task.category -> Catégorie de cette tâche
    
    lazy='dynamic' :
    Comment charger les données liées
    
    Options de lazy :
    - 'select' (défaut) : Charge tout en une requête
    - 'dynamic' : Retourne un query object (pas chargé)
    - 'joined' : Charge avec JOIN
    - 'subquery' : Charge avec sous-requête
    
    lazy='dynamic' avantages :
    - Pas de chargement immédiat (économie mémoire)
    - Permet de filtrer : category.tasks.filter_by(completed=True)
    - Permet de paginer : category.tasks.paginate(page=1)
    
    lazy='dynamic' inconvénients :
    - Plus de requêtes SQL
    - Moins performant si on accède toujours à toutes les tâches
    
    cascade='all, delete-orphan' :
    Cascade des opérations
    
    'all' inclut :
    - save-update : Sauvegarder les objets liés
    - delete : Supprimer les objets liés
    - merge : Fusionner les objets
    - refresh : Rafraîchir les objets
    
    'delete-orphan' :
    Supprime les tâches qui n'ont plus de catégorie
    
    Exemple :
    category = Category.query.get(1)
    db.session.delete(category)
    db.session.commit()
    
    -> Toutes les tâches de cette catégorie sont AUSSI supprimées
    
    Sans cascade :
    -> Erreur de clé étrangère (tâches liées existent encore)
    """
    
    def __repr__(self):
        return f'<Category {self.name}>'
    
    """
    __repr__() :
    Représentation en chaîne de l'objet
    Utilisé pour le débogage
    
    >>> category = Category(name="Travail")
    >>> print(category)
    <Category Travail>
    
    Sans __repr__() :
    >>> print(category)
    <Category object at 0x7f8b9c0d1e50>  # Moins utile
    """
    
    def to_dict(self):
        """
        Convertit l'objet en dictionnaire
        Utile pour la sérialisation JSON
        """
        return {
            'id': self.id,
            'name': self.name,
            'description': self.description,
            'created_at': self.created_at.isoformat() if self.created_at else None,
            'tasks_count': self.tasks.count() if isinstance(self.tasks, db.Query) else len(self.tasks)
        }
    
    """
    to_dict() :
    Méthode personnalisée pour convertir en dictionnaire
    
    Alternative : Utiliser marshmallow (voir schemas.py)
    
    self.created_at.isoformat() :
    Convertit datetime en string ISO 8601
    datetime(2024, 12, 16, 15, 30) -> '2024-12-16T15:30:00'
    
    if self.created_at else None :
    Gère le cas où created_at est NULL
    
    self.tasks.count() :
    Si lazy='dynamic', tasks est un Query
    .count() exécute SELECT COUNT(*)
    
    len(self.tasks) :
    Si lazy='select', tasks est une liste
    len() compte les éléments en mémoire
    
    isinstance(self.tasks, db.Query) :
    Vérifie le type pour utiliser la bonne méthode
    """


# ───────────────────────────────────────────────────────────────
# MODÈLE TASK (TÂCHE)
# ───────────────────────────────────────────────────────────────

class Task(db.Model):
    """
    Modèle pour les tâches
    
    Table : tasks
    Relations : Many-to-One avec Category
    """
    
    __tablename__ = 'tasks'
    
    # Colonnes
    id = db.Column(db.Integer, primary_key=True)
    
    title = db.Column(db.String(100), nullable=False)
    
    description = db.Column(db.Text)
    
    """
    db.Text :
    Texte de longueur variable (illimitée)
    Équivalent SQL : TEXT
    
    db.Text vs db.String :
    - db.String(n) : Longueur max fixée (VARCHAR)
    - db.Text : Pas de limite (TEXT)
    
    Quand utiliser Text :
    - Contenu long et variable (articles, descriptions)
    - Pas besoin d'indexer (Text non indexable sur certains SGBD)
    
    Quand utiliser String :
    - Longueur prévisible (nom, email, titre)
    - Besoin d'index
    - Validation de longueur
    """
    
    completed = db.Column(db.Boolean, default=False, nullable=False)
    
    """
    db.Boolean :
    Type booléen (TRUE/FALSE en SQL)
    
    default=False :
    Valeur par défaut
    Nouvelle tâche -> completed = False
    
    nullable=False :
    Obligatoire (jamais NULL)
    
    Équivalent SQL :
    completed BOOLEAN DEFAULT FALSE NOT NULL
    
    Note : SQLite n'a pas de type BOOLEAN natif
    Stocké comme INTEGER (0 = False, 1 = True)
    SQLAlchemy gère la conversion automatiquement
    """
    
    priority = db.Column(db.Integer, default=1)
    
    """
    priority :
    Niveau de priorité (1 = basse, 5 = haute)
    
    En production, mieux vaut utiliser un Enum :
    
    from enum import Enum
    class Priority(Enum):
        LOW = 1
        MEDIUM = 2
        HIGH = 3
    
    priority = db.Column(db.Enum(Priority), default=Priority.MEDIUM)
    """
    
    due_date = db.Column(db.DateTime)
    
    """
    due_date :
    Date d'échéance (nullable)
    
    Sans default :
    NULL par défaut
    Optionnel
    """
    
    created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    
    """
    onupdate=datetime.utcnow :
    Fonction appelée automatiquement à chaque UPDATE
    
    Comportement :
    - INSERT : created_at = now, updated_at = now
    - UPDATE : created_at inchangé, updated_at = now
    
    [ATTENTION] Fonctionne seulement si la mise à jour passe par SQLAlchemy
    Si UPDATE direct en SQL, onupdate n'est pas déclenché
    """
    
    # Clé étrangère vers Category
    category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
    
    """
    db.ForeignKey('categories.id') :
    Clé étrangère vers la table categories, colonne id
    
    Format : 'nom_table.nom_colonne'
    
    Crée une contrainte de clé étrangère :
    - category_id doit référencer un id existant dans categories
    - Empêche d'insérer un category_id invalide
    
    Équivalent SQL :
    category_id INTEGER,
    FOREIGN KEY (category_id) REFERENCES categories(id)
    
    nullable=True :
    Catégorie optionnelle
    Une tâche peut ne pas avoir de catégorie
    
    Si nullable=False :
    Toutes les tâches DOIVENT avoir une catégorie
    """
    
    # La relation est définie côté Category avec backref
    # Pas besoin de la redéfinir ici
    # On peut y accéder via task.category
    
    """
    Grâce au backref='category' dans Category :
    task.category est automatiquement disponible
    
    Accès :
    task = Task.query.get(1)
    print(task.category.name)  # "Travail"
    
    Modification :
    new_category = Category.query.filter_by(name="Personnel").first()
    task.category = new_category
    db.session.commit()
    
    Suppression de la relation :
    task.category = None
    db.session.commit()
    """
    
    def __repr__(self):
        return f'<Task {self.title}>'
    
    def to_dict(self, include_category=True):
        """
        Convertit l'objet en dictionnaire
        
        Args:
            include_category (bool): Inclure les détails de la catégorie
        """
        data = {
            'id': self.id,
            'title': self.title,
            'description': self.description,
            'completed': self.completed,
            'priority': self.priority,
            'due_date': self.due_date.isoformat() if self.due_date else None,
            'created_at': self.created_at.isoformat() if self.created_at else None,
            'updated_at': self.updated_at.isoformat() if self.updated_at else None,
            'category_id': self.category_id
        }
        
        if include_category and self.category:
            data['category'] = {
                'id': self.category.id,
                'name': self.category.name
            }
        
        return data
    
    """
    include_category :
    Paramètre pour éviter le problème N+1
    
    Problème N+1 :
    Si on récupère 100 tâches et qu'on inclut la catégorie :
    - 1 requête pour les 100 tâches
    - 100 requêtes pour les catégories (une par tâche)
    = 101 requêtes ! [X]
    
    Solution :
    Utiliser eager loading (voir plus bas)
    Ou ne pas inclure la catégorie par défaut
    """
```

**Sauvegarde.**

---

### ÉTAPE 5 : Créer les schémas de validation (marshmallow)

**Créer `app/schemas.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# SCHÉMAS MARSHMALLOW - VALIDATION ET SÉRIALISATION
# ═══════════════════════════════════════════════════════════════

from marshmallow import Schema, fields, validate, validates, ValidationError, post_load
from datetime import datetime

"""
marshmallow :
Bibliothèque pour :
- Validation de données
- Sérialisation (objet Python -> JSON)
- Désérialisation (JSON -> objet Python)

Avantages :
- Validation déclarative
- Messages d'erreur clairs
- Nested schemas (schémas imbriqués)
- Pré/post-processing

Alternatives :
- pydantic (plus moderne, utilisé par FastAPI)
- cerberus
- colander
"""

# ───────────────────────────────────────────────────────────────
# SCHÉMA CATEGORY
# ───────────────────────────────────────────────────────────────

class CategorySchema(Schema):
    """
    Schéma pour valider et sérialiser les catégories
    """
    
    id = fields.Int(dump_only=True)
    
    """
    fields.Int :
    Champ entier
    
    dump_only=True :
    Utilisé seulement en SÉRIALISATION (output)
    Ignoré en DÉSÉRIALISATION (input)
    
    Cas d'usage :
    - id (généré par la base de données)
    - created_at (timestamp automatique)
    - Champs calculés
    
    L'utilisateur ne peut PAS fournir un id :
    POST /api/categories
    { "id": 999, "name": "Test" }  # id ignoré
    """
    
    name = fields.Str(
        required=True,
        validate=validate.Length(min=1, max=50),
        error_messages={
            'required': 'Le nom est obligatoire',
            'invalid': 'Le nom doit être une chaîne de caractères'
        }
    )
    
    """
    fields.Str :
    Champ chaîne de caractères
    
    required=True :
    Champ obligatoire
    Sans ce champ -> ValidationError
    
    validate=validate.Length(min=1, max=50) :
    Validateur de longueur
    min=1 : Au moins 1 caractère (pas vide)
    max=50 : Maximum 50 caractères
    
    Autres validateurs :
    validate.Email() : Format email
    validate.URL() : Format URL
    validate.Range(min=1, max=10) : Plage de valeurs
    validate.Regexp(r'^[A-Z]') : Expression régulière
    validate.OneOf(['low', 'high']) : Liste de valeurs autorisées
    
    error_messages :
    Messages d'erreur personnalisés
    
    Clés :
    - required : Champ manquant
    - invalid : Type invalide
    - validator_failed : Validation échouée
    """
    
    description = fields.Str(
        allow_none=True,
        validate=validate.Length(max=200)
    )
    
    """
    allow_none=True :
    Autorise la valeur None (null en JSON)
    
    Sans allow_none :
    { "description": null } -> Erreur
    
    Avec allow_none :
    { "description": null } -> OK
    { } -> OK (absence du champ = None par défaut)
    """
    
    created_at = fields.DateTime(dump_only=True, format='iso')
    
    """
    fields.DateTime :
    Champ date/heure
    
    format='iso' :
    Format ISO 8601 : '2024-12-16T15:30:00'
    
    Autres formats :
    - 'iso' : ISO 8601 (recommandé)
    - 'rfc' : RFC 822
    - '%Y-%m-%d %H:%M:%S' : Format personnalisé
    
    dump_only=True :
    Timestamp généré automatiquement
    L'utilisateur ne peut pas le définir
    """
    
    tasks_count = fields.Method('get_tasks_count', dump_only=True)
    
    """
    fields.Method :
    Champ calculé via une méthode
    
    'get_tasks_count' :
    Nom de la méthode à appeler
    La méthode doit être définie dans cette classe
    
    dump_only=True :
    Seulement en sérialisation (output)
    """
    
    def get_tasks_count(self, obj):
        """
        Retourne le nombre de tâches de la catégorie
        
        Args:
            obj: Objet Category SQLAlchemy
        """
        if hasattr(obj, 'tasks'):
            # Si lazy='dynamic', tasks est un Query
            if hasattr(obj.tasks, 'count'):
                return obj.tasks.count()
            # Sinon, c'est une liste
            return len(obj.tasks)
        return 0
    
    """
    Méthode pour fields.Method :
    
    Signature : def method_name(self, obj)
    - self : Instance du schema
    - obj : Objet à sérialiser
    
    Retour : Valeur à sérialiser
    
    Appelée automatiquement lors de :
    schema.dump(category)
    """


# ───────────────────────────────────────────────────────────────
# SCHÉMA TASK
# ───────────────────────────────────────────────────────────────

class TaskSchema(Schema):
    """
    Schéma pour valider et sérialiser les tâches
    """
    
    id = fields.Int(dump_only=True)
    
    title = fields.Str(
        required=True,
        validate=validate.Length(min=1, max=100),
        error_messages={
            'required': 'Le titre est obligatoire',
            'invalid': 'Le titre doit être une chaîne de caractères'
        }
    )
    
    description = fields.Str(
        allow_none=True,
        missing=None
    )
    
    """
    missing=None :
    Valeur par défaut si le champ est absent
    
    Différence avec allow_none :
    - allow_none : Autorise la valeur null
    - missing : Valeur par défaut si absent
    
    Exemple :
    { } -> description = None (missing)
    { "description": null } -> description = None (allow_none)
    { "description": "Test" } -> description = "Test"
    """
    
    completed = fields.Bool(missing=False)
    
    """
    fields.Bool :
    Champ booléen
    
    missing=False :
    Si absent, valeur = False
    
    Valeurs acceptées :
    - true, false (JSON)
    - True, False (Python)
    - 1, 0 (convertis en bool)
    - "true", "false" (selon allow_str)
    """
    
    priority = fields.Int(
        missing=1,
        validate=validate.Range(min=1, max=5),
        error_messages={
            'validator_failed': 'La priorité doit être entre 1 et 5'
        }
    )
    
    """
    validate.Range(min=1, max=5) :
    Valide que la valeur est entre 1 et 5 inclus
    
    Exemples :
    priority = 1 [OK]
    priority = 3 [OK]
    priority = 5 [OK]
    priority = 0 [X] ValidationError
    priority = 10 [X] ValidationError
    """
    
    due_date = fields.DateTime(
        allow_none=True,
        format='iso',
        missing=None
    )
    
    category_id = fields.Int(
        allow_none=True,
        missing=None
    )
    
    created_at = fields.DateTime(dump_only=True, format='iso')
    updated_at = fields.DateTime(dump_only=True, format='iso')
    
    # Nested field pour inclure la catégorie complète
    category = fields.Nested(
        CategorySchema,
        only=('id', 'name'),
        dump_only=True
    )
    
    """
    fields.Nested :
    Champ imbriqué (schéma dans un schéma)
    
    CategorySchema :
    Schéma à utiliser pour le champ category
    
    only=('id', 'name') :
    Inclure seulement ces champs de CategorySchema
    Évite de tout inclure (description, created_at, etc.)
    
    dump_only=True :
    Seulement en sérialisation
    L'utilisateur ne peut pas créer une catégorie via ce champ
    
    Résultat sérialisation :
    {
      "id": 1,
      "title": "Ma tâche",
      ...
      "category_id": 2,
      "category": {
        "id": 2,
        "name": "Travail"
      }
    }
    
    Alternatives pour only :
    exclude=('description',) : Exclure certains champs
    many=True : Pour lister plusieurs objets
    """
    
    @validates('due_date')
    def validate_due_date(self, value):
        """
        Valide que la date d'échéance est dans le futur
        
        Args:
            value: Valeur du champ due_date
            
        Raises:
            ValidationError: Si la date est dans le passé
        """
        if value and value < datetime.utcnow():
            raise ValidationError("La date d'échéance doit être dans le futur")
    
    """
    @validates('field_name') :
    Décorateur pour validation personnalisée
    
    Méthode appelée automatiquement lors de :
    schema.load(data)
    
    Si ValidationError levée :
    La validation échoue avec ce message
    
    Exemple d'utilisation :
    schema = TaskSchema()
    data = {
        "title": "Test",
        "due_date": "2020-01-01T00:00:00"  # Passé
    }
    schema.load(data)  # [X] ValidationError
    
    Autres décorateurs :
    @validates_schema : Validation sur plusieurs champs
    @pre_load : Traitement avant validation
    @post_load : Traitement après validation
    @pre_dump : Traitement avant sérialisation
    @post_dump : Traitement après sérialisation
    """
    
    @post_load
    def make_task(self, data, **kwargs):
        """
        Optionnel : Convertit le dict en objet Task
        Utile pour créer directement l'objet depuis le JSON
        
        Args:
            data: Dictionnaire validé
            **kwargs: Arguments supplémentaires
            
        Returns:
            dict ou Task object
        """
        # Retourner le dict tel quel
        # (on créera l'objet Task manuellement dans les routes)
        return data
    
    """
    @post_load :
    Appelé APRÈS validation réussie
    
    Cas d'usage :
    1. Retourner un dict (comme ici)
    2. Créer un objet directement
    3. Traiter les données
    
    Exemple création d'objet :
    from app.models import Task
    
    @post_load
    def make_task(self, data, **kwargs):
        return Task(**data)
    
    Utilisation :
    schema = TaskSchema()
    task = schema.load({"title": "Test"})
    # task est une instance de Task, pas un dict
    
    **kwargs :
    Arguments supplémentaires passés par marshmallow
    - partial : Validation partielle
    - many : Liste d'objets
    - unknown : Comportement pour champs inconnus
    """


# ───────────────────────────────────────────────────────────────
# INSTANCES DES SCHÉMAS
# ───────────────────────────────────────────────────────────────

# Schémas pour un seul objet
category_schema = CategorySchema()
task_schema = TaskSchema()

# Schémas pour une liste d'objets
categories_schema = CategorySchema(many=True)
tasks_schema = TaskSchema(many=True)

"""
many=True :
Pour sérialiser/désérialiser une LISTE d'objets

Utilisation :
# Un seul objet
task_schema.dump(task)  # Task -> dict
task_schema.load(data)  # dict -> Task

# Liste d'objets
tasks_schema.dump(tasks_list)  # [Task, Task] -> [dict, dict]
tasks_schema.load(data_list)   # [dict, dict] -> [Task, Task]

Exemple :
tasks = Task.query.all()  # [Task1, Task2, Task3]
result = tasks_schema.dump(tasks)
# result = [
#   {"id": 1, "title": "..."},
#   {"id": 2, "title": "..."},
#   {"id": 3, "title": "..."}
# ]
"""
```

**Sauvegarde.**

---

### ÉTAPE 6 : Initialiser l'application Flask

**Créer `app/__init__.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# INITIALISATION DE L'APPLICATION FLASK
# ═══════════════════════════════════════════════════════════════

from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
import os

from app.config import config

# Importer db depuis models (déjà initialisé)
from app.models import db

"""
Import de db :
db est déjà créé dans models.py avec db = SQLAlchemy()
On l'importe ici pour l'initialiser avec l'application
"""

# Initialiser Flask-Migrate
migrate = Migrate()

"""
migrate = Migrate() :
Gestionnaire de migrations de base de données

Sera initialisé avec :
migrate.init_app(app, db)

Fournit les commandes CLI :
- flask db init : Initialise le dossier migrations
- flask db migrate -m "message" : Crée une migration
- flask db upgrade : Applique les migrations
- flask db downgrade : Annule la dernière migration
"""

def create_app(config_name='default'):
    """
    Factory pattern pour créer l'application Flask
    
    Args:
        config_name (str): Nom de la configuration à utiliser
                          ('development', 'production', 'testing')
    
    Returns:
        Flask: Instance de l'application configurée
    """
    
    """
    Factory Pattern (Application Factory) :
    
    Au lieu de créer app globalement :
    app = Flask(__name__)  # [X] Difficile à tester
    
    On crée une fonction qui retourne app :
    def create_app():
        app = Flask(__name__)
        return app  # [OK] Flexible, testable
    
    Avantages :
    1. Plusieurs instances possibles (tests, différentes configs)
    2. Configuration dynamique
    3. Testabilité (isolation)
    4. Extensions initialisées correctement
    
    Utilisation :
    # Développement
    app = create_app('development')
    
    # Tests
    app = create_app('testing')
    
    # Production
    app = create_app('production')
    """
    
    # Créer l'instance Flask
    app = Flask(__name__, instance_relative_config=True)
    
    """
    instance_relative_config=True :
    Permet de charger la config depuis le dossier instance/
    
    Utile pour :
    - Secrets (clés API, mots de passe)
    - Configuration spécifique à l'environnement
    - Fichiers exclus du contrôle de version
    
    Fichier : instance/config.py
    Chargement : app.config.from_pyfile('config.py')
    """
    
    # Charger la configuration
    app.config.from_object(config[config_name])
    
    """
    app.config.from_object() :
    Charge la configuration depuis un objet Python
    
    config[config_name] retourne une classe :
    - DevelopmentConfig
    - ProductionConfig
    - TestingConfig
    
    Flask parcourt la classe et copie les attributs majuscules
    dans app.config (dictionnaire)
    
    Exemple :
    class Config:
        SECRET_KEY = 'abc'
        DEBUG = True
        _private = 'hidden'  # Ignoré (pas majuscule)
    
    app.config.from_object(Config)
    
    app.config['SECRET_KEY']  # 'abc'
    app.config['DEBUG']        # True
    app.config['_private']     # [X] KeyError
    
    Alternatives :
    app.config.from_pyfile('config.py')  # Fichier Python
    app.config.from_json('config.json')  # Fichier JSON
    app.config.from_envvar('APP_CONFIG')  # Variable d'environnement
    """
    
    # S'assurer que le dossier instance existe
    try:
        os.makedirs(app.instance_path)
    except OSError:
        pass
    
    """
    app.instance_path :
    Chemin vers le dossier instance/
    Exemple : /home/user/flask-todo-db/instance
    
    os.makedirs(path) :
    Crée le dossier (et parents si nécessaire)
    
    try/except OSError :
    Ignore l'erreur si le dossier existe déjà
    
    Alternat

ive moderne (Python 3.2+) :
    Path(app.instance_path).mkdir(parents=True, exist_ok=True)
    """
    
    # Initialiser les extensions
    db.init_app(app)
    migrate.init_app(app, db)
    
    """
    Extension.init_app(app) :
    Pattern d'initialisation des extensions Flask
    
    Pourquoi pas directement ?
    db = SQLAlchemy(app)  # [X] Couplage fort
    
    Avec init_app :
    db = SQLAlchemy()      # Créé sans app
    db.init_app(app)       # Initialisé avec app
    
    Avantages :
    - Application Factory compatible
    - Plusieurs apps possibles
    - Extensions réutilisables
    
    db.init_app(app) configure :
    - La connexion à la base
    - Le session pool
    - Les hooks Flask
    - Les commandes CLI
    
    migrate.init_app(app, db) :
    Initialise Flask-Migrate
    Nécessite app ET db
    Enregistre les commandes flask db *
    """
    
    # Importer et enregistrer les blueprints
    from app.routes import tasks, categories
    
    """
    Import ici (pas en haut du fichier) :
    Évite les imports circulaires
    
    Imports circulaires :
    app/__init__.py importe routes/tasks.py
    routes/tasks.py importe app/__init__.py
    -> Erreur !
    
    Solution :
    Importer les routes APRÈS la création de app
    """
    
    app.register_blueprint(tasks.bp)
    app.register_blueprint(categories.bp)
    
    """
    Blueprint :
    Composant Flask pour organiser les routes
    
    Avantages :
    - Organisation modulaire
    - Préfixes d'URL
    - Templates/static files par blueprint
    - Réutilisabilité
    
    Exemple de blueprint :
    bp = Blueprint('tasks', __name__, url_prefix='/api/tasks')
    
    @bp.route('/')  # -> /api/tasks/
    def get_tasks():
        ...
    
    app.register_blueprint(bp) enregistre toutes les routes
    """
    
    # Gestionnaires d'erreurs globaux
    @app.errorhandler(404)
    def not_found(error):
        return jsonify({
            'error': 'Not Found',
            'message': 'La ressource demandée n\'existe pas'
        }), 404
    
    @app.errorhandler(500)
    def internal_error(error):
        # Rollback en cas d'erreur
        db.session.rollback()
        return jsonify({
            'error': 'Internal Server Error',
            'message': 'Une erreur interne est survenue'
        }), 500
    
    """
    db.session.rollback() :
    Annule la transaction en cours
    
    Pourquoi dans le gestionnaire 500 ?
    Si une erreur survient pendant une transaction SQL :
    - La transaction reste ouverte
    - Les connexions se bloquent
    - L'app peut devenir instable
    
    rollback() :
    - Annule les changements non commités
    - Libère les ressources
    - Remet la session dans un état sain
    
    Sans rollback :
    Les prochaines requêtes peuvent échouer ou être lentes
    """
    
    # Route racine
    @app.route('/')
    def index():
        return jsonify({
            'message': 'API TODO List avec SQLAlchemy',
            'version': '2.0.0',
            'endpoints': {
                'tasks': '/api/tasks',
                'categories': '/api/categories'
            }
        })
    
    # Route de santé (health check)
    @app.route('/health')
    def health():
        """
        Endpoint de santé pour le monitoring
        Vérifie que l'app et la base de données fonctionnent
        """
        try:
            # Tester la connexion à la base
            db.session.execute(db.text('SELECT 1'))
            db_status = 'ok'
        except Exception as e:
            db_status = 'error'
        
        return jsonify({
            'status': 'ok' if db_status == 'ok' else 'degraded',
            'database': db_status,
            'timestamp': datetime.utcnow().isoformat()
        })
    
    """
    Health check :
    Endpoint pour vérifier que l'application fonctionne
    
    Utilisé par :
    - Load balancers (retirer les serveurs down)
    - Monitoring (Prometheus, Datadog, etc.)
    - Orchestrateurs (Kubernetes, Docker Swarm)
    
    db.text('SELECT 1') :
    Requête SQL minimale pour tester la connexion
    SELECT 1 retourne toujours 1
    Très rapide, pas d'I/O disque
    
    db.session.execute() :
    Exécute une requête SQL brute
    
    Codes de retour :
    - status: 'ok' -> Tout fonctionne
    - status: 'degraded' -> Problème partiel (DB down mais app up)
    - status: 'error' -> Problème critique
    
    Monitoring typique :
    GET /health tous les 10 secondes
    Si 3 échecs consécutifs -> Alerte
    """
    
    return app

"""
Retour de create_app() :
Instance Flask configurée et prête à l'emploi

Utilisation dans run.py :
app = create_app()
app.run()

Utilisation dans les tests :
app = create_app('testing')
with app.test_client() as client:
    response = client.get('/api/tasks')
"""
```

**Sauvegarde.**

---

Je vais continuer avec les routes, puis les étapes suivantes. Veux-tu que je continue maintenant ou préfères-tu que je m'arrête ici pour l'instant ?

### ÉTAPE 7 : Créer les routes des catégories

**Créer `app/routes/categories.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# ROUTES DES CATÉGORIES
# ═══════════════════════════════════════════════════════════════

from flask import Blueprint, request, jsonify
from marshmallow import ValidationError
from sqlalchemy.exc import IntegrityError

from app.models import db, Category
from app.schemas import category_schema, categories_schema

# Créer le blueprint
bp = Blueprint('categories', __name__, url_prefix='/api/categories')

"""
Blueprint('categories', __name__, url_prefix='/api/categories') :

'categories' : Nom du blueprint (unique dans l'app)
__name__ : Module actuel (pour localiser les ressources)
url_prefix='/api/categories' : Préfixe pour toutes les routes

Résultat :
@bp.route('/') -> /api/categories/
@bp.route('/<int:id>') -> /api/categories/<int:id>

Avantages :
- Organisation logique
- Préfixe centralisé (facile à changer)
- Isolation des routes
"""

# ───────────────────────────────────────────────────────────────
# GET /api/categories - LISTE DES CATÉGORIES
# ───────────────────────────────────────────────────────────────

@bp.route('/', methods=['GET'])
def get_categories():
    """
    Récupère toutes les catégories
    
    Returns:
        JSON: Liste des catégories avec code 200
    """
    categories = Category.query.all()
    
    """
    Category.query :
    Objet Query de SQLAlchemy
    Fourni automatiquement par Flask-SQLAlchemy
    
    .all() :
    Exécute la requête et retourne TOUTES les lignes
    Équivalent SQL : SELECT * FROM categories
    
    Retour : Liste d'objets Category
    [<Category Travail>, <Category Personnel>, ...]
    
    Autres méthodes de Query :
    .first() : Premier résultat ou None
    .one() : Un seul résultat ou erreur
    .count() : Nombre de résultats
    .paginate() : Pagination
    .filter() : Filtrer
    .order_by() : Trier
    """
    
    # Sérialiser avec marshmallow
    result = categories_schema.dump(categories)
    
    """
    categories_schema.dump(categories) :
    
    dump() : Sérialisation (objet Python -> dict/JSON)
    Inverse de load() (dict/JSON -> objet Python)
    
    categories est une liste d'objets Category
    result est une liste de dictionnaires
    
    Process :
    1. Parcourt chaque Category dans categories
    2. Appelle to_dict() implicitement (via le schema)
    3. Applique les règles de serialisation (dump_only, etc.)
    4. Retourne une liste de dicts prête pour JSON
    
    Exemple :
    categories = [
        Category(id=1, name="Travail"),
        Category(id=2, name="Personnel")
    ]
    
    result = [
        {"id": 1, "name": "Travail", ...},
        {"id": 2, "name": "Personnel", ...}
    ]
    """
    
    return jsonify({
        'categories': result,
        'total': len(result)
    }), 200

# ───────────────────────────────────────────────────────────────
# GET /api/categories/<id> - CATÉGORIE SPÉCIFIQUE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:category_id>', methods=['GET'])
def get_category(category_id):
    """
    Récupère une catégorie par son ID
    
    Args:
        category_id (int): ID de la catégorie
        
    Returns:
        JSON: Détails de la catégorie avec code 200
              ou erreur 404
    """
    category = Category.query.get_or_404(category_id)
    
    """
    .get_or_404(id) :
    Méthode pratique de Flask-SQLAlchemy
    
    Équivalent à :
    category = Category.query.get(category_id)
    if category is None:
        abort(404)
    
    .get(id) :
    Recherche par clé primaire uniquement
    Très rapide (utilise l'index)
    Retourne None si non trouvé
    
    .get_or_404(id) :
    Comme .get() mais retourne 404 si None
    
    Différence avec .filter_by() :
    .get(1) : Recherche par PK seulement
    .filter_by(id=1).first() : Recherche par n'importe quel champ
    
    .get() est plus rapide pour les PK
    """
    
    result = category_schema.dump(category)
    return jsonify(result), 200

# ───────────────────────────────────────────────────────────────
# POST /api/categories - CRÉER UNE CATÉGORIE
# ───────────────────────────────────────────────────────────────

@bp.route('/', methods=['POST'])
def create_category():
    """
    Crée une nouvelle catégorie
    
    Expected JSON:
        {
            "name": "Nom de la catégorie",
            "description": "Description optionnelle"
        }
        
    Returns:
        JSON: Catégorie créée avec code 201
              ou erreur 400 si validation échoue
    """
    # Récupérer les données JSON
    json_data = request.get_json()
    
    if not json_data:
        return jsonify({'error': 'Aucune donnée fournie'}), 400
    
    # Valider avec marshmallow
    try:
        data = category_schema.load(json_data)
    except ValidationError as err:
        return jsonify({'errors': err.messages}), 400
    
    """
    category_schema.load(json_data) :
    
    load() : Désérialisation ET validation
    
    Process :
    1. Vérifie les champs requis (required=True)
    2. Valide les types (Str, Int, Bool)
    3. Applique les validateurs (Length, Range, etc.)
    4. Exécute les méthodes @validates
    5. Retourne un dict validé
    
    Si validation échoue -> ValidationError
    
    err.messages :
    Dictionnaire des erreurs
    Format : {'field_name': ['error message']}
    
    Exemple :
    json_data = {"name": ""}  # Vide
    
    err.messages = {
        'name': ['Length must be between 1 and 50.']
    }
    
    Retour à l'utilisateur :
    {
        "errors": {
            "name": ["Length must be between 1 and 50."]
        }
    }
    """
    
    # Vérifier que le nom n'existe pas déjà
    existing = Category.query.filter_by(name=data['name']).first()
    if existing:
        return jsonify({
            'error': 'Une catégorie avec ce nom existe déjà'
        }), 400
    
    """
    .filter_by(name=...).first() :
    
    filter_by(**kwargs) :
    Filtre par égalité (=)
    Syntaxe simple : filter_by(name='Travail')
    
    .filter() vs .filter_by() :
    
    filter_by(name='Travail') :
    Égalité seulement, syntaxe keyword
    
    filter(Category.name == 'Travail') :
    Expressions complexes
    Category.name.like('%travail%')  # LIKE
    Category.id > 5  # Comparaison
    Category.created_at < datetime.now()  # Date
    
    .first() :
    Retourne le premier résultat ou None
    Limite automatiquement à 1 (LIMIT 1)
    
    Équivalent SQL :
    SELECT * FROM categories WHERE name = 'Travail' LIMIT 1
    """
    
    # Créer l'objet Category
    category = Category(
        name=data['name'],
        description=data.get('description')
    )
    
    """
    Category(**data) aussi possible :
    category = Category(**data)
    
    **data décompose le dictionnaire en arguments nommés
    data = {'name': 'Travail', 'description': 'Tâches pro'}
    Category(**data) équivaut à
    Category(name='Travail', description='Tâches pro')
    
    Attention aux champs supplémentaires :
    Si data contient un champ non attendu -> TypeError
    
    Plus sûr :
    Category(name=data['name'], description=data.get('description'))
    """
    
    # Sauvegarder dans la base de données
    try:
        db.session.add(category)
        db.session.commit()
        
        """
        db.session :
        Session SQLAlchemy (gère les transactions)
        
        .add(object) :
        Ajoute l'objet à la session (pas encore en base !)
        État : "pending"
        
        .commit() :
        Valide la transaction
        Exécute INSERT INTO ...
        Génère l'ID
        État : "persistent"
        
        Flux complet :
        1. category = Category(...)  # Transient
        2. db.session.add(category)  # Pending
        3. db.session.commit()       # Persistent (en base)
        4. category.id               # ID généré disponible
        
        Alternative :
        db.session.add(category)
        db.session.flush()  # INSERT mais pas de commit
        print(category.id)  # ID disponible
        db.session.commit() # Valide définitivement
        
        flush() vs commit() :
        flush() : Synchronise avec la DB (pas de validation)
        commit() : Valide la transaction (permanent)
        """
        
    except IntegrityError as e:
        db.session.rollback()
        return jsonify({
            'error': 'Erreur d\'intégrité de la base de données',
            'details': str(e.orig)
        }), 400
    
    """
    IntegrityError :
    Violation de contrainte de base de données
    
    Causes :
    - UNIQUE violation (nom déjà existant)
    - FOREIGN KEY violation (référence invalide)
    - NOT NULL violation (champ obligatoire manquant)
    - CHECK constraint violation
    
    e.orig :
    Exception originale du driver de base de données
    Contient le message d'erreur SQL détaillé
    
    db.session.rollback() :
    Annule toutes les modifications non commitées
    Remet la session dans un état propre
    
    Pourquoi rollback() ?
    Si on ne rollback pas :
    - La session reste en erreur
    - Les prochaines opérations échouent
    - Risque de blocage
    
    Bonne pratique :
    try:
        db.session.add(obj)
        db.session.commit()
    except Exception:
        db.session.rollback()
        raise  # Re-lève l'exception
    """
    
    # Retourner la catégorie créée
    result = category_schema.dump(category)
    return jsonify(result), 201

# ───────────────────────────────────────────────────────────────
# PUT /api/categories/<id> - MODIFIER UNE CATÉGORIE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:category_id>', methods=['PUT'])
def update_category(category_id):
    """
    Modifie une catégorie existante
    
    Args:
        category_id (int): ID de la catégorie
        
    Expected JSON:
        {
            "name": "Nouveau nom",
            "description": "Nouvelle description"
        }
        
    Returns:
        JSON: Catégorie modifiée avec code 200
    """
    category = Category.query.get_or_404(category_id)
    
    json_data = request.get_json()
    if not json_data:
        return jsonify({'error': 'Aucune donnée fournie'}), 400
    
    # Validation partielle (partial=True)
    try:
        data = category_schema.load(json_data, partial=True)
    except ValidationError as err:
        return jsonify({'errors': err.messages}), 400
    
    """
    partial=True :
    Validation partielle (tous les champs optionnels)
    
    Sans partial :
    Tous les champs required=True DOIVENT être présents
    {"description": "Test"} -> Erreur (name manquant)
    
    Avec partial=True :
    Seuls les champs fournis sont validés
    {"description": "Test"} -> OK
    
    Utile pour :
    - PUT/PATCH (modification partielle)
    - Formulaires multi-étapes
    - APIs flexibles
    
    Attention :
    partial=True désactive ALL les required
    Si tu veux forcer certains champs, valide-les manuellement
    """
    
    # Vérifier l'unicité du nom si modifié
    if 'name' in data and data['name'] != category.name:
        existing = Category.query.filter_by(name=data['name']).first()
        if existing:
            return jsonify({
                'error': 'Une catégorie avec ce nom existe déjà'
            }), 400
    
    """
    Logique de vérification :
    
    1. Si 'name' n'est pas dans data -> Pas de modification du nom -> OK
    2. Si name nouveau == name actuel -> Pas de changement -> OK
    3. Si name nouveau != name actuel -> Vérifier unicité
    
    Sans cette vérification :
    PUT /api/categories/1 avec {"name": "Travail"}
    Si la catégorie 1 s'appelle déjà "Travail" -> Erreur UNIQUE
    
    Avec cette vérification :
    Si name ne change pas -> Pas d'erreur
    """
    
    # Mettre à jour les champs
    for key, value in data.items():
        setattr(category, key, value)
    
    """
    setattr(object, name, value) :
    Définit un attribut dynamiquement
    
    Équivalent à :
    category.name = data['name']
    category.description = data['description']
    
    Mais dynamique :
    for key, value in data.items():
        setattr(category, key, value)
    
    Avantages :
    - Générique (fonctionne pour tous les champs)
    - Compact
    - Facile à maintenir
    
    Inconvénients :
    - Moins explicite
    - Risque si data contient des champs inattendus
    
    Protection :
    allowed_fields = ['name', 'description']
    for key, value in data.items():
        if key in allowed_fields:
            setattr(category, key, value)
    """
    
    try:
        db.session.commit()
    except IntegrityError as e:
        db.session.rollback()
        return jsonify({
            'error': 'Erreur d\'intégrité',
            'details': str(e.orig)
        }), 400
    
    """
    Pas besoin de db.session.add() ici !
    
    Pourquoi ?
    category est déjà dans la session (récupéré par query)
    État : "persistent"
    
    Modification :
    category.name = "Nouveau"
    État : "dirty" (modifié mais pas commité)
    
    db.session.commit() :
    Détecte automatiquement les objets dirty
    Génère UPDATE ...
    État : "persistent" (à jour)
    
    SQLAlchemy suit automatiquement les modifications !
    """
    
    result = category_schema.dump(category)
    return jsonify(result), 200

# ───────────────────────────────────────────────────────────────
# DELETE /api/categories/<id> - SUPPRIMER UNE CATÉGORIE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:category_id>', methods=['DELETE'])
def delete_category(category_id):
    """
    Supprime une catégorie
    
    Args:
        category_id (int): ID de la catégorie
        
    Returns:
        JSON: Message de confirmation avec code 200
    """
    category = Category.query.get_or_404(category_id)
    
    # Optionnel : Vérifier qu'il n'y a pas de tâches liées
    if category.tasks.count() > 0:
        return jsonify({
            'error': 'Impossible de supprimer',
            'message': 'Cette catégorie contient encore des tâches'
        }), 400
    
    """
    category.tasks.count() :
    
    tasks est une relation (lazy='dynamic')
    -> category.tasks est un Query object
    -> .count() exécute SELECT COUNT(*)
    
    Si lazy='select' (liste) :
    -> len(category.tasks)
    
    Vérification avant suppression :
    Empêche de supprimer une catégorie utilisée
    
    Alternative avec cascade :
    Dans models.py, on a cascade='all, delete-orphan'
    -> Supprimer la catégorie supprime aussi les tâches
    
    Choix de design :
    1. Empêcher la suppression (comme ici)
    2. Supprimer en cascade (avec warning)
    3. Mettre category_id à NULL (orphelins)
    """
    
    db.session.delete(category)
    db.session.commit()
    
    """
    db.session.delete(object) :
    Marque l'objet pour suppression
    État : "deleted"
    
    db.session.commit() :
    Exécute DELETE FROM ...
    Supprime définitivement
    
    Cascade automatique :
    Grâce à cascade='all, delete-orphan' dans la relation
    Les tâches liées sont automatiquement supprimées
    
    Sans cascade :
    IntegrityError (FOREIGN KEY constraint failed)
    """
    
    return jsonify({
        'message': 'Catégorie supprimée avec succès',
        'deleted_id': category_id
    }), 200
```

**Sauvegarde.**

---

### ÉTAPE 8 : Créer les routes des tâches

**Créer `app/routes/tasks.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# ROUTES DES TÂCHES
# ═══════════════════════════════════════════════════════════════

from flask import Blueprint, request, jsonify
from marshmallow import ValidationError
from sqlalchemy import or_

from app.models import db, Task, Category
from app.schemas import task_schema, tasks_schema

bp = Blueprint('tasks', __name__, url_prefix='/api/tasks')

# ───────────────────────────────────────────────────────────────
# GET /api/tasks - LISTE DES TÂCHES (AVEC FILTRES)
# ───────────────────────────────────────────────────────────────

@bp.route('/', methods=['GET'])
def get_tasks():
    """
    Récupère toutes les tâches avec filtres optionnels
    
    Query Parameters:
        - completed (bool): Filtrer par statut
        - category_id (int): Filtrer par catégorie
        - priority (int): Filtrer par priorité
        - search (str): Recherche dans titre/description
        - sort_by (str): Champ de tri
        - order (str): asc ou desc
        - page (int): Numéro de page (pagination)
        - per_page (int): Éléments par page
        
    Returns:
        JSON: Liste paginée des tâches avec métadonnées
    """
    # Commencer la requête
    query = Task.query
    
    """
    Task.query :
    Point de départ pour construire une requête
    On va chaîner des méthodes pour filtrer, trier, paginer
    
    Pattern Query Builder :
    query = Task.query
    query = query.filter_by(completed=True)
    query = query.order_by(Task.created_at.desc())
    results = query.all()
    
    Ou en une ligne :
    results = Task.query.filter_by(completed=True).order_by(...).all()
    """
    
    # Filtres
    
    # Filtre par statut de complétion
    completed = request.args.get('completed')
    if completed is not None:
        # Convertir string en bool
        completed_bool = completed.lower() in ['true', '1', 'yes']
        query = query.filter_by(completed=completed_bool)
    
    """
    request.args :
    Dictionnaire des paramètres de query string
    
    GET /api/tasks?completed=true&priority=5
    request.args = {'completed': 'true', 'priority': '5'}
    
    .get('key') :
    Retourne la valeur ou None
    .get('key', default) : Avec valeur par défaut
    
    [ATTENTION] Tous les paramètres sont des STRINGS !
    ?completed=true -> type(completed) = str
    
    Conversion en bool :
    completed.lower() in ['true', '1', 'yes']
    
    Valeurs truthy :
    'true', 'True', 'TRUE', '1', 'yes', 'Yes', 'YES'
    
    Valeurs falsy :
    'false', 'False', '0', 'no', etc.
    """
    
    # Filtre par catégorie
    category_id = request.args.get('category_id', type=int)
    if category_id:
        query = query.filter_by(category_id=category_id)
    
    """
    request.args.get('key', type=int) :
    Conversion automatique en int
    
    ?category_id=5 -> category_id = 5 (int)
    ?category_id=abc -> category_id = None (échec conversion)
    
    Sans type=int :
    category_id = '5' (string)
    Comparaison : '5' == 5 -> False
    Requête SQL incorrecte
    
    Avec type=int :
    category_id = 5 (int)
    Comparaison : 5 == 5 -> True
    """
    
    # Filtre par priorité
    priority = request.args.get('priority', type=int)
    if priority:
        query = query.filter_by(priority=priority)
    
    # Recherche textuelle
    search = request.args.get('search')
    if search:
        search_pattern = f'%{search}%'
        query = query.filter(
            or_(
                Task.title.ilike(search_pattern),
                Task.description.ilike(search_pattern)
            )
        )
    
    """
    Recherche avec LIKE :
    
    Task.title.ilike(pattern) :
    Case-Insensitive LIKE
    'Travail' matches 'travail', 'TRAVAIL', 'TrAvAiL'
    
    .like(pattern) :
    Case-sensitive LIKE (dépend de la DB)
    
    Pattern :
    '%search%' : Contient 'search' n'importe où
    'search%' : Commence par 'search'
    '%search' : Finit par 'search'
    
    or_() :
    Opérateur OR de SQLAlchemy
    
    or_(condition1, condition2) :
    WHERE condition1 OR condition2
    
    Équivalent SQL :
    SELECT * FROM tasks 
    WHERE title ILIKE '%search%' 
       OR description ILIKE '%search%'
    
    Autres opérateurs :
    and_(cond1, cond2) : AND
    not_(cond) : NOT
    Task.priority.in_([1, 2, 3]) : IN (1, 2, 3)
    Task.created_at > datetime.now() : Comparaison
    """
    
    # Tri
    sort_by = request.args.get('sort_by', 'created_at')
    order = request.args.get('order', 'desc')
    
    """
    Tri dynamique :
    sort_by : Nom du champ (string)
    order : 'asc' ou 'desc'
    
    Problème :
    sort_by est une string, pas un attribut de Task
    Task.sort_by [X] AttributeError
    
    Solution :
    getattr(Task, sort_by)
    Récupère l'attribut dynamiquement
    """
    
    # Valider le champ de tri
    allowed_sort_fields = ['created_at', 'updated_at', 'title', 'priority', 'due_date']
    if sort_by not in allowed_sort_fields:
        sort_by = 'created_at'
    
    """
    Whitelist des champs de tri :
    Sécurité contre l'injection
    
    Sans validation :
    ?sort_by=__class__ -> Erreur ou exploitation
    ?sort_by='; DROP TABLE tasks; -- -> Injection SQL (peu probable avec ORM mais soyons prudents)
    
    Avec whitelist :
    ?sort_by=invalid -> Utilise 'created_at' par défaut
    ?sort_by=created_at -> OK
    """
    
    # Appliquer le tri
    sort_column = getattr(Task, sort_by)
    if order == 'asc':
        query = query.order_by(sort_column.asc())
    else:
        query = query.order_by(sort_column.desc())
    
    """
    getattr(Task, 'created_at') :
    Retourne Task.created_at (Column object)
    
    .asc() et .desc() :
    Méthodes de Column pour définir l'ordre
    
    Équivalent SQL :
    ORDER BY created_at ASC
    ORDER BY created_at DESC
    
    Alternative :
    from sqlalchemy import asc, desc
    query.order_by(asc(Task.created_at))
    query.order_by(desc(Task.created_at))
    """
    
    # Pagination
    page = request.args.get('page', 1, type=int)
    per_page = request.args.get('per_page', 10, type=int)
    
    # Limiter per_page (éviter surcharge)
    per_page = min(per_page, 100)
    
    """
    Limitation de per_page :
    Évite qu'un utilisateur demande 1 million d'éléments
    
    ?per_page=1000000 -> Surcharge serveur
    min(per_page, 100) -> Maximum 100 éléments
    
    En production :
    - Limiter à 50 ou 100
    - Rate limiting
    - Cache pour les requêtes courantes
    """
    
    # Exécuter la pagination
    paginated_tasks = query.paginate(
        page=page,
        per_page=per_page,
        error_out=False
    )
    
    """
    .paginate() :
    Méthode de pagination de Flask-SQLAlchemy
    
    Args:
        page : Numéro de page (1, 2, 3, ...)
        per_page : Éléments par page
        error_out : Si True, 404 si page > max, sinon liste vide
    
    Retour : Pagination object
    
    Attributs :
    .items : Liste des éléments de la page courante
    .total : Nombre total d'éléments
    .pages : Nombre total de pages
    .page : Page courante
    .per_page : Éléments par page
    .has_prev : Page précédente existe ?
    .has_next : Page suivante existe ?
    .prev_num : Numéro page précédente
    .next_num : Numéro page suivante
    
    SQL généré :
    SELECT * FROM tasks ... LIMIT 10 OFFSET 20
    (page 3, per_page 10 -> offset = (3-1)*10 = 20)
    
    + SELECT COUNT(*) FROM tasks ...
    (pour calculer le nombre total)
    """
    
    # Eager loading de la catégorie (éviter N+1)
    tasks_with_category = Task.query.options(
        db.joinedload(Task.category)
    ).filter(
        Task.id.in_([t.id for t in paginated_tasks.items])
    ).all()
    
    """
    Problème N+1 queries :
    
    Sans eager loading :
    tasks = Task.query.all()  # 1 requête : SELECT * FROM tasks
    for task in tasks:
        print(task.category.name)  # N requêtes : SELECT * FROM categories WHERE id = ?
    
    Total : 1 + N requêtes [X]
    
    Avec joinedload :
    tasks = Task.query.options(db.joinedload(Task.category)).all()
    # 1 requête : SELECT * FROM tasks LEFT JOIN categories ...
    for task in tasks:
        print(task.category.name)  # Pas de requête SQL !
    
    Total : 1 requête [OK]
    
    db.joinedload(Task.category) :
    Charge la relation category avec un LEFT JOIN
    
    SQL généré :
    SELECT tasks.*, categories.*
    FROM tasks
    LEFT JOIN categories ON tasks.category_id = categories.id
    
    Alternative :
    db.subqueryload(Task.category) : Sous-requête au lieu de JOIN
    
    Ici, on fait 2 requêtes :
    1. paginate() pour la pagination
    2. joinedload() pour charger les catégories
    
    Pourquoi ?
    paginate() ne peut pas facilement être combiné avec joinedload()
    On récupère les IDs des tâches paginées
    Puis on les recharge avec joinedload
    
    Alternative (plus simple) :
    Désactiver include_category dans to_dict()
    """
    
    # Créer un mapping id -> task avec catégorie
    tasks_dict = {t.id: t for t in tasks_with_category}
    
    # Reconstruire la liste paginée avec les catégories chargées
    items_with_category = [tasks_dict.get(t.id, t) for t in paginated_tasks.items]
    
    # Sérialiser
    result = tasks_schema.dump(items_with_category)
    
    return jsonify({
        'tasks': result,
        'pagination': {
            'page': paginated_tasks.page,
            'per_page': paginated_tasks.per_page,
            'total_items': paginated_tasks.total,
            'total_pages': paginated_tasks.pages,
            'has_prev': paginated_tasks.has_prev,
            'has_next': paginated_tasks.has_next,
            'prev_page': paginated_tasks.prev_num if paginated_tasks.has_prev else None,
            'next_page': paginated_tasks.next_num if paginated_tasks.has_next else None
        }
    }), 200
    
    """
    Métadonnées de pagination :
    Informations utiles pour le client (frontend)
    
    Le frontend peut :
    - Afficher "Page 2 sur 10"
    - Activer/désactiver boutons précédent/suivant
    - Afficher "20 résultats sur 157"
    - Générer des liens de pagination
    
    Format standard pour APIs REST
    """

# ───────────────────────────────────────────────────────────────
# GET /api/tasks/<id> - TÂCHE SPÉCIFIQUE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:task_id>', methods=['GET'])
def get_task(task_id):
    """
    Récupère une tâche par son ID
    """
    # Eager load de la catégorie
    task = Task.query.options(
        db.joinedload(Task.category)
    ).get_or_404(task_id)
    
    result = task_schema.dump(task)
    return jsonify(result), 200

# ───────────────────────────────────────────────────────────────
# POST /api/tasks - CRÉER UNE TÂCHE
# ───────────────────────────────────────────────────────────────

@bp.route('/', methods=['POST'])
def create_task():
    """
    Crée une nouvelle tâche
    
    Expected JSON:
        {
            "title": "Titre de la tâche",
            "description": "Description",
            "priority": 3,
            "category_id": 1,
            "due_date": "2024-12-31T23:59:59"
        }
    """
    json_data = request.get_json()
    if not json_data:
        return jsonify({'error': 'Aucune donnée fournie'}), 400
    
    # Validation
    try:
        data = task_schema.load(json_data)
    except ValidationError as err:
        return jsonify({'errors': err.messages}), 400
    
    # Vérifier que la catégorie existe (si fournie)
    if data.get('category_id'):
        category = Category.query.get(data['category_id'])
        if not category:
            return jsonify({
                'error': 'Catégorie non trouvée',
                'category_id': data['category_id']
            }), 404
    
    """
    Validation de la clé étrangère :
    
    Sans validation :
    task = Task(category_id=999)  # Catégorie n'existe pas
    db.session.commit()  # IntegrityError
    
    Avec validation :
    Vérification avant création
    Message d'erreur clair
    
    Alternative :
    Laisser la base de données gérer (IntegrityError)
    Capturer et retourner message approprié
    """
    
    # Créer la tâche
    task = Task(**data)
    
    db.session.add(task)
    db.session.commit()
    
    # Recharger avec la catégorie
    task = Task.query.options(db.joinedload(Task.category)).get(task.id)
    
    result = task_schema.dump(task)
    return jsonify(result), 201

# ───────────────────────────────────────────────────────────────
# PUT /api/tasks/<id> - MODIFIER UNE TÂCHE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:task_id>', methods=['PUT'])
def update_task(task_id):
    """
    Modifie une tâche existante
    """
    task = Task.query.get_or_404(task_id)
    
    json_data = request.get_json()
    if not json_data:
        return jsonify({'error': 'Aucune donnée fournie'}), 400
    
    # Validation partielle
    try:
        data = task_schema.load(json_data, partial=True)
    except ValidationError as err:
        return jsonify({'errors': err.messages}), 400
    
    # Vérifier la catégorie si modifiée
    if 'category_id' in data and data['category_id']:
        category = Category.query.get(data['category_id'])
        if not category:
            return jsonify({'error': 'Catégorie non trouvée'}), 404
    
    # Mettre à jour
    for key, value in data.items():
        setattr(task, key, value)
    
    db.session.commit()
    
    # Recharger avec la catégorie
    task = Task.query.options(db.joinedload(Task.category)).get(task.id)
    
    result = task_schema.dump(task)
    return jsonify(result), 200

# ───────────────────────────────────────────────────────────────
# DELETE /api/tasks/<id> - SUPPRIMER UNE TÂCHE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
    """
    Supprime une tâche
    """
    task = Task.query.get_or_404(task_id)
    
    db.session.delete(task)
    db.session.commit()
    
    return jsonify({
        'message': 'Tâche supprimée avec succès',
        'deleted_id': task_id
    }), 200

# ───────────────────────────────────────────────────────────────
# PATCH /api/tasks/<id>/complete - MARQUER COMME TERMINÉE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:task_id>/complete', methods=['PATCH'])
def complete_task(task_id):
    """
    Marque une tâche comme terminée
    Raccourci pratique pour mettre completed à True
    """
    task = Task.query.get_or_404(task_id)
    task.completed = True
    db.session.commit()
    
    result = task_schema.dump(task)
    return jsonify(result), 200

"""
Routes pratiques (convenience routes) :
Actions courantes exposées comme endpoints dédiés

Avantages :
- Plus explicite : PATCH /tasks/1/complete
- Plus simple pour le client
- Sémantique claire

Au lieu de :
PUT /tasks/1 avec {"completed": true}

Alternative :
POST /tasks/1/complete (aussi valide)
"""

# ───────────────────────────────────────────────────────────────
# GET /api/tasks/stats - STATISTIQUES
# ───────────────────────────────────────────────────────────────

@bp.route('/stats', methods=['GET'])
def get_stats():
    """
    Récupère des statistiques sur les tâches
    """
    from sqlalchemy import func
    
    """
    sqlalchemy.func :
    Fonctions SQL (COUNT, SUM, AVG, MAX, MIN, etc.)
    
    func.count(Task.id) : COUNT(id)
    func.sum(Task.priority) : SUM(priority)
    func.avg(Task.priority) : AVG(priority)
    """
    
    total = Task.query.count()
    completed = Task.query.filter_by(completed=True).count()
    pending = total - completed
    
    # Statistiques par catégorie
    stats_by_category = db.session.query(
        Category.name,
        func.count(Task.id).label('count')
    ).outerjoin(Task).group_by(Category.id).all()
    
    """
    Requête complexe avec GROUP BY :
    
    db.session.query(...) :
    Requête personnalisée (pas via Model.query)
    
    Category.name :
    Sélectionne le nom de la catégorie
    
    func.count(Task.id).label('count') :
    Compte les tâches
    .label('count') : Nomme la colonne résultante
    
    .outerjoin(Task) :
    LEFT OUTER JOIN avec tasks
    Inclut les catégories sans tâches
    
    .group_by(Category.id) :
    Groupe par catégorie
    
    Équivalent SQL :
    SELECT categories.name, COUNT(tasks.id) AS count
    FROM categories
    LEFT OUTER JOIN tasks ON categories.id = tasks.category_id
    GROUP BY categories.id
    
    Résultat :
    [
        ('Travail', 5),
        ('Personnel', 3),
        ('Courses', 0)
    ]
    """
    
    return jsonify({
        'total': total,
        'completed': completed,
        'pending': pending,
        'completion_rate': round((completed / total * 100), 2) if total > 0 else 0,
        'by_category': [
            {'category': name, 'count': count}
            for name, count in stats_by_category
        ]
    }), 200
```

**Sauvegarde.**

---

Je continue avec les migrations, les tests et la finalisation. Veux-tu que je continue ?

### ÉTAPE 9 : Créer le point d'entrée de l'application

**Créer `run.py` à la racine du projet :**

```python
# ═══════════════════════════════════════════════════════════════
# POINT D'ENTRÉE DE L'APPLICATION
# ═══════════════════════════════════════════════════════════════

import os
from app import create_app, db
from app.models import Task, Category

# Déterminer l'environnement
config_name = os.environ.get('FLASK_ENV', 'development')

"""
os.environ.get('FLASK_ENV', 'development') :
Lit la variable d'environnement FLASK_ENV

Définir la variable :
Linux/Mac :
export FLASK_ENV=production
python run.py

Windows (CMD) :
set FLASK_ENV=production
python run.py

Windows (PowerShell) :
$env:FLASK_ENV="production"
python run.py

Valeurs courantes :
- development : Développement local
- production : Serveur de production
- testing : Tests automatisés
"""

# Créer l'application
app = create_app(config_name)

"""
create_app(config_name) :
Factory pattern pour créer l'app
Retourne une instance Flask configurée
"""

# ───────────────────────────────────────────────────────────────
# CONTEXTE SHELL FLASK
# ───────────────────────────────────────────────────────────────

@app.shell_context_processor
def make_shell_context():
    """
    Ajoute des variables au contexte du shell Flask
    Accessible via la commande : flask shell
    
    Returns:
        dict: Dictionnaire de variables disponibles dans le shell
    """
    return {
        'db': db,
        'Task': Task,
        'Category': Category
    }

"""
@app.shell_context_processor :
Décorateur pour enrichir le contexte du shell Flask

Sans ce décorateur :
$ flask shell
>>> from app.models import db, Task
>>> tasks = Task.query.all()

Avec ce décorateur :
$ flask shell
>>> tasks = Task.query.all()  # db et Task déjà importés !
>>> category = Category(name="Test")
>>> db.session.add(category)

Utilité :
- Évite les imports répétitifs
- Accès direct aux modèles
- Prototypage rapide
- Debug interactif

Commandes shell utiles :
>>> Task.query.all()
>>> Task.query.count()
>>> db.create_all()
>>> db.drop_all()
"""

# ───────────────────────────────────────────────────────────────
# COMMANDES CLI PERSONNALISÉES
# ───────────────────────────────────────────────────────────────

@app.cli.command()
def init_db():
    """
    Initialise la base de données avec des données de test
    Utilisation : flask init-db
    """
    print("Création des tables...")
    db.create_all()
    
    """
    db.create_all() :
    Crée TOUTES les tables définies dans les modèles
    
    Process :
    1. Parcourt tous les modèles (Task, Category)
    2. Génère les CREATE TABLE ...
    3. Exécute les commandes SQL
    
    [ATTENTION] En production, utiliser les migrations !
    db.create_all() ne gère pas les changements de schéma
    
    Utile pour :
    - Tests
    - Développement initial
    - Prototypage rapide
    """
    
    print("Ajout de catégories de test...")
    categories = [
        Category(name='Travail', description='Tâches professionnelles'),
        Category(name='Personnel', description='Tâches personnelles'),
        Category(name='Courses', description='Liste de courses'),
        Category(name='Santé', description='Rendez-vous médicaux'),
    ]
    
    for cat in categories:
        db.session.add(cat)
    
    db.session.commit()
    print(f"{len(categories)} catégories ajoutées.")
    
    print("Ajout de tâches de test...")
    from datetime import datetime, timedelta
    
    """
    timedelta :
    Représente une durée
    timedelta(days=7) : 7 jours
    datetime.now() + timedelta(days=7) : Dans 7 jours
    """
    
    tasks = [
        Task(
            title='Préparer la réunion',
            description='Préparer les slides pour la réunion client',
            priority=5,
            category_id=1,
            due_date=datetime.utcnow() + timedelta(days=2)
        ),
        Task(
            title='Code review',
            description='Réviser le PR #123',
            priority=3,
            category_id=1,
            completed=True
        ),
        Task(
            title='Faire les courses',
            description='Acheter du pain, lait, œufs',
            priority=2,
            category_id=3,
            due_date=datetime.utcnow() + timedelta(days=1)
        ),
        Task(
            title='Dentiste',
            description='Rendez-vous annuel chez le dentiste',
            priority=4,
            category_id=4,
            due_date=datetime.utcnow() + timedelta(weeks=2)
        ),
        Task(
            title='Apprendre SQLAlchemy',
            description='Finir le tutoriel SQLAlchemy',
            priority=3,
            category_id=2
        ),
    ]
    
    for task in tasks:
        db.session.add(task)
    
    db.session.commit()
    print(f"{len(tasks)} tâches ajoutées.")
    
    print("[OK] Base de données initialisée avec succès !")

"""
@app.cli.command() :
Crée une commande CLI personnalisée

Syntaxe :
@app.cli.command()
def nom_commande():
    ...

Utilisation :
flask nom-commande
(les underscores deviennent des tirets)

@app.cli.command('init-db') :
Force le nom de la commande

Autres exemples :
@app.cli.command()
@click.option('--count', default=10)
def seed_data(count):
    '''Ajoute {count} données de test'''
    ...

flask seed-data --count=50
"""

@app.cli.command()
def reset_db():
    """
    Supprime et recrée la base de données
    [ATTENTION] ATTENTION : Supprime toutes les données !
    Utilisation : flask reset-db
    """
    import click
    
    """
    click :
    Bibliothèque CLI (installée avec Flask)
    Fournit des utilitaires pour les commandes
    """
    
    if click.confirm('[ATTENTION]  Êtes-vous sûr de vouloir supprimer toutes les données ?'):
        print("Suppression des tables...")
        db.drop_all()
        
        """
        db.drop_all() :
        Supprime TOUTES les tables
        Équivalent à DROP TABLE tasks, DROP TABLE categories
        
        [ATTENTION] DESTRUCTIF ! Toutes les données sont perdues
        """
        
        print("Recréation des tables...")
        db.create_all()
        print("[OK] Base de données réinitialisée.")
    else:
        print("[X] Opération annulée.")

"""
click.confirm() :
Demande confirmation à l'utilisateur

Interaction :
$ flask reset-db
[ATTENTION]  Êtes-vous sûr de vouloir supprimer toutes les données ? [y/N]: y
Suppression des tables...
...

Si l'utilisateur tape 'n' ou appuie sur Entrée :
-> return False -> Annulation
"""

# ───────────────────────────────────────────────────────────────
# DÉMARRAGE DU SERVEUR
# ───────────────────────────────────────────────────────────────

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=5000)

"""
python run.py :
Lance le serveur de développement Flask

En production, utiliser Gunicorn ou uWSGI :

Gunicorn :
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 "run:app"

-w 4 : 4 workers (processus)
-b 0.0.0.0:5000 : Bind sur toutes les interfaces, port 5000
"run:app" : Module run, variable app

uWSGI :
pip install uwsgi
uwsgi --http :5000 --wsgi-file run.py --callable app --processes 4

Waitress (Windows-friendly) :
pip install waitress
waitress-serve --port=5000 run:app
"""
```

**Sauvegarde.**

---

### ÉTAPE 10 : Initialiser et gérer les migrations

**Initialiser Flask-Migrate :**

```bash
# S'assurer que l'environnement virtuel est activé
source venv/bin/activate

# Initialiser les migrations
flask db init
```

**Résultat :**

```
Creating directory /home/user/flask-todo-db/migrations ...  done
Creating directory /home/user/flask-todo-db/migrations/versions ...  done
Generating /home/user/flask-todo-db/migrations/alembic.ini ...  done
Generating /home/user/flask-todo-db/migrations/env.py ...  done
Generating /home/user/flask-todo-db/migrations/README ...  done
Generating /home/user/flask-todo-db/migrations/script.py.mako ...  done
Please edit configuration/connection/logging settings in '/home/user/flask-todo-db/migrations/alembic.ini' before proceeding.
```

**Structure créée :**

```
migrations/
├── alembic.ini       # Configuration Alembic
├── env.py            # Script d'environnement
├── README            # Documentation
├── script.py.mako    # Template pour les migrations
└── versions/         # Dossier des fichiers de migration
```

**Explication :**

**Alembic :**
- Outil de migration de base de données pour SQLAlchemy
- Flask-Migrate est un wrapper autour d'Alembic

**Dossier migrations/ :**
- Contient toute la configuration et l'historique
- À versionner dans git (important !)

---

**Créer la première migration :**

```bash
flask db migrate -m "Initial migration: tasks and categories tables"
```

**Résultat :**

```
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'categories'
INFO  [alembic.autogenerate.compare] Detected added table 'tasks'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_categories_name' on '['name']'
  Generating /home/user/flask-todo-db/migrations/versions/abc123_initial_migration.py ...  done
```

**Explication :**

**flask db migrate :**
- Compare les modèles SQLAlchemy avec la base actuelle
- Détecte les différences (nouvelles tables, colonnes, index, etc.)
- Génère un script de migration Python

**-m "message" :**
- Message descriptif de la migration
- Comme un commit git
- Aide à comprendre l'historique

**Fichier généré :**

```python
# migrations/versions/abc123_initial_migration.py

"""Initial migration: tasks and categories tables

Revision ID: abc123def456
Revises: 
Create Date: 2024-12-16 16:30:00.123456

"""
from alembic import op
import sqlalchemy as sa

# revision identifiers
revision = 'abc123def456'
down_revision = None
branch_labels = None
depends_on = None

def upgrade():
    # ### commands auto generated by Alembic ###
    op.create_table('categories',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('name', sa.String(length=50), nullable=False),
        sa.Column('description', sa.String(length=200), nullable=True),
        sa.Column('created_at', sa.DateTime(), nullable=True),
        sa.PrimaryKeyConstraint('id'),
        sa.UniqueConstraint('name')
    )
    
    op.create_table('tasks',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('title', sa.String(length=100), nullable=False),
        sa.Column('description', sa.Text(), nullable=True),
        sa.Column('completed', sa.Boolean(), nullable=False),
        sa.Column('priority', sa.Integer(), nullable=True),
        sa.Column('due_date', sa.DateTime(), nullable=True),
        sa.Column('created_at', sa.DateTime(), nullable=False),
        sa.Column('updated_at', sa.DateTime(), nullable=True),
        sa.Column('category_id', sa.Integer(), nullable=True),
        sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
        sa.PrimaryKeyConstraint('id')
    )
    # ### end Alembic commands ###

def downgrade():
    # ### commands auto generated by Alembic ###
    op.drop_table('tasks')
    op.drop_table('categories')
    # ### end Alembic commands ###
```

**upgrade() :**
- Fonction pour appliquer la migration
- CREATE TABLE, ADD COLUMN, etc.

**downgrade() :**
- Fonction pour annuler la migration
- DROP TABLE, DROP COLUMN, etc.
- Permet de revenir en arrière

---

**Appliquer la migration :**

```bash
flask db upgrade
```

**Résultat :**

```
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade  -> abc123def456, Initial migration: tasks and categories tables
```

**[OK] Les tables sont créées dans la base de données !**

**Vérifier :**

```bash
# Si SQLite
sqlite3 instance/todo.db ".tables"
```

**Résultat :**

```
alembic_version  categories       tasks
```

**alembic_version :**
- Table technique créée par Alembic
- Stocke la version actuelle de la base
- Permet à Alembic de savoir quelles migrations ont été appliquées

---

**Commandes de migration utiles :**

```bash
# Voir l'historique des migrations
flask db history

# Afficher la version actuelle
flask db current

# Revenir à la migration précédente
flask db downgrade

# Aller à une version spécifique
flask db downgrade abc123

# Avancer d'une migration
flask db upgrade

# Voir les changements sans les appliquer
flask db upgrade --sql

# Marquer une migration comme appliquée (sans exécuter)
flask db stamp head
```

---

### ÉTAPE 11 : Initialiser la base avec des données de test

```bash
flask init-db
```

**Résultat :**

```
Création des tables...
Ajout de catégories de test...
4 catégories ajoutées.
Ajout de tâches de test...
5 tâches ajoutées.
[OK] Base de données initialisée avec succès !
```

**[ATTENTION] Note :**

Si les tables existent déjà (via migrations), `db.create_all()` ne fait rien.

---

### ÉTAPE 12 : Tester l'API

**Démarrer le serveur :**

```bash
python run.py
```

**Ou avec Flask CLI :**

```bash
export FLASK_APP=run.py
flask run
```

**Résultat :**

```
 * Serving Flask app 'run'
 * Debug mode: on
 * Running on http://127.0.0.1:5000
 * Running on http://192.168.1.100:5000
```

---

**Test 1 : Récupérer toutes les catégories**

```bash
curl http://localhost:5000/api/categories
```

**Résultat :**

```json
{
  "categories": [
    {
      "created_at": "2024-12-16T16:30:00",
      "description": "Tâches professionnelles",
      "id": 1,
      "name": "Travail",
      "tasks_count": 2
    },
    {
      "created_at": "2024-12-16T16:30:00",
      "description": "Tâches personnelles",
      "id": 2,
      "name": "Personnel",
      "tasks_count": 1
    },
    {
      "created_at": "2024-12-16T16:30:00",
      "description": "Liste de courses",
      "id": 3,
      "name": "Courses",
      "tasks_count": 1
    },
    {
      "created_at": "2024-12-16T16:30:00",
      "description": "Rendez-vous médicaux",
      "id": 4,
      "name": "Santé",
      "tasks_count": 1
    }
  ],
  "total": 4
}
```

**[OK] Catégories récupérées avec succès !**

---

**Test 2 : Récupérer toutes les tâches avec pagination**

```bash
curl "http://localhost:5000/api/tasks?page=1&per_page=3"
```

**Résultat :**

```json
{
  "pagination": {
    "has_next": true,
    "has_prev": false,
    "next_page": 2,
    "page": 1,
    "per_page": 3,
    "prev_page": null,
    "total_items": 5,
    "total_pages": 2
  },
  "tasks": [
    {
      "category": {
        "id": 1,
        "name": "Travail"
      },
      "category_id": 1,
      "completed": false,
      "created_at": "2024-12-16T16:30:00",
      "description": "Préparer les slides pour la réunion client",
      "due_date": "2024-12-18T16:30:00",
      "id": 1,
      "priority": 5,
      "title": "Préparer la réunion",
      "updated_at": "2024-12-16T16:30:00"
    },
    {
      "category": {
        "id": 1,
        "name": "Travail"
      },
      "category_id": 1,
      "completed": true,
      "created_at": "2024-12-16T16:30:00",
      "description": "Réviser le PR #123",
      "due_date": null,
      "id": 2,
      "priority": 3,
      "title": "Code review",
      "updated_at": "2024-12-16T16:30:00"
    },
    {
      "category": {
        "id": 3,
        "name": "Courses"
      },
      "category_id": 3,
      "completed": false,
      "created_at": "2024-12-16T16:30:00",
      "description": "Acheter du pain, lait, œufs",
      "due_date": "2024-12-17T16:30:00",
      "id": 3,
      "priority": 2,
      "title": "Faire les courses",
      "updated_at": "2024-12-16T16:30:00"
    }
  ]
}
```

**[OK] Pagination fonctionnelle !**

---

**Test 3 : Filtrer les tâches non terminées**

```bash
curl "http://localhost:5000/api/tasks?completed=false"
```

**Résultat : Seulement les tâches avec `completed: false`**

---

**Test 4 : Recherche textuelle**

```bash
curl "http://localhost:5000/api/tasks?search=réunion"
```

**Résultat : Tâches contenant "réunion" dans le titre ou la description**

---

**Test 5 : Créer une nouvelle catégorie**

```bash
curl -X POST http://localhost:5000/api/categories \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Urgent",
    "description": "Tâches urgentes à traiter"
  }'
```

**Résultat :**

```json
{
  "created_at": "2024-12-16T17:00:00.123456",
  "description": "Tâches urgentes à traiter",
  "id": 5,
  "name": "Urgent",
  "tasks_count": 0
}
```

**Code de statut : 201 Created**

**[OK] Catégorie créée !**

---

**Test 6 : Créer une nouvelle tâche**

```bash
curl -X POST http://localhost:5000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Appeler le client",
    "description": "Discuter du projet X",
    "priority": 5,
    "category_id": 5,
    "due_date": "2024-12-20T10:00:00"
  }'
```

**Résultat :**

```json
{
  "category": {
    "id": 5,
    "name": "Urgent"
  },
  "category_id": 5,
  "completed": false,
  "created_at": "2024-12-16T17:05:00.123456",
  "description": "Discuter du projet X",
  "due_date": "2024-12-20T10:00:00",
  "id": 6,
  "priority": 5,
  "title": "Appeler le client",
  "updated_at": "2024-12-16T17:05:00.123456"
}
```

**[OK] Tâche créée avec catégorie associée !**

---

**Test 7 : Modifier une tâche**

```bash
curl -X PUT http://localhost:5000/api/tasks/6 \
  -H "Content-Type: application/json" \
  -d '{
    "completed": true,
    "priority": 3
  }'
```

**Résultat : Tâche mise à jour**

---

**Test 8 : Marquer comme terminée (convenience route)**

```bash
curl -X PATCH http://localhost:5000/api/tasks/1/complete
```

**Résultat : `completed` passe à `true`**

---

**Test 9 : Statistiques**

```bash
curl http://localhost:5000/api/tasks/stats
```

**Résultat :**

```json
{
  "by_category": [
    {
      "category": "Travail",
      "count": 2
    },
    {
      "category": "Personnel",
      "count": 1
    },
    {
      "category": "Courses",
      "count": 1
    },
    {
      "category": "Santé",
      "count": 1
    },
    {
      "category": "Urgent",
      "count": 1
    }
  ],
  "completed": 3,
  "completion_rate": 50.0,
  "pending": 3,
  "total": 6
}
```

**[OK] Statistiques calculées !**

---

**Test 10 : Supprimer une catégorie (avec protection)**

```bash
curl -X DELETE http://localhost:5000/api/categories/5
```

**Résultat :**

```json
{
  "error": "Impossible de supprimer",
  "message": "Cette catégorie contient encore des tâches"
}
```

**Code de statut : 400**

**[OK] Protection contre la suppression fonctionnelle !**

---

**Supprimer d'abord la tâche :**

```bash
curl -X DELETE http://localhost:5000/api/tasks/6
```

**Puis la catégorie :**

```bash
curl -X DELETE http://localhost:5000/api/categories/5
```

**Résultat :**

```json
{
  "deleted_id": 5,
  "message": "Catégorie supprimée avec succès"
}
```

**[OK] Suppression réussie !**

---

### ÉTAPE 13 : Tests unitaires basiques

**Créer `tests/test_tasks.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# TESTS UNITAIRES DE L'API
# ═══════════════════════════════════════════════════════════════

import unittest
import json
from datetime import datetime, timedelta

from app import create_app, db
from app.models import Task, Category

class TaskAPITestCase(unittest.TestCase):
    """
    Tests pour l'API des tâches
    """
    
    def setUp(self):
        """
        Exécuté AVANT chaque test
        Crée une app de test et une base de données en mémoire
        """
        self.app = create_app('testing')
        self.client = self.app.test_client()
        self.app_context = self.app.app_context()
        self.app_context.push()
        
        """
        self.app.test_client() :
        Crée un client de test
        Permet de faire des requêtes HTTP simulées
        
        app_context() :
        Contexte d'application Flask
        Nécessaire pour accéder à db, current_app, etc.
        
        .push() :
        Active le contexte
        """
        
        db.create_all()
        
        # Créer des données de test
        self.category = Category(name='Test', description='Catégorie de test')
        db.session.add(self.category)
        db.session.commit()
        
        self.task = Task(
            title='Tâche de test',
            description='Description de test',
            priority=3,
            category_id=self.category.id
        )
        db.session.add(self.task)
        db.session.commit()
    
    def tearDown(self):
        """
        Exécuté APRÈS chaque test
        Nettoie la base de données
        """
        db.session.remove()
        db.drop_all()
        self.app_context.pop()
        
        """
        db.session.remove() :
        Ferme la session
        
        db.drop_all() :
        Supprime toutes les tables
        
        .pop() :
        Désactive le contexte
        """
    
    def test_get_tasks(self):
        """
        Test : GET /api/tasks
        Doit retourner la liste des tâches
        """
        response = self.client.get('/api/tasks')
        
        """
        self.client.get(url) :
        Simule une requête GET
        
        Méthodes disponibles :
        .get(url)
        .post(url, data=...)
        .put(url, data=...)
        .delete(url)
        .patch(url, data=...)
        """
        
        self.assertEqual(response.status_code, 200)
        
        """
        self.assertEqual(a, b) :
        Assertion : a == b
        
        Autres assertions :
        self.assertTrue(x)
        self.assertFalse(x)
        self.assertIsNone(x)
        self.assertIn(item, list)
        self.assertGreater(a, b)
        """
        
        data = json.loads(response.data)
        
        """
        response.data :
        Corps de la réponse (bytes)
        
        json.loads() :
        Parse JSON en dict Python
        """
        
        self.assertIn('tasks', data)
        self.assertIn('pagination', data)
        self.assertEqual(len(data['tasks']), 1)
        self.assertEqual(data['tasks'][0]['title'], 'Tâche de test')
    
    def test_create_task(self):
        """
        Test : POST /api/tasks
        Doit créer une nouvelle tâche
        """
        new_task = {
            'title': 'Nouvelle tâche',
            'description': 'Test création',
            'priority': 4,
            'category_id': self.category.id
        }
        
        response = self.client.post(
            '/api/tasks',
            data=json.dumps(new_task),
            content_type='application/json'
        )
        
        """
        data=json.dumps(new_task) :
        Convertit dict -> JSON string
        
        content_type='application/json' :
        Header Content-Type
        Indique que le corps est du JSON
        """
        
        self.assertEqual(response.status_code, 201)
        
        data = json.loads(response.data)
        self.assertEqual(data['title'], 'Nouvelle tâche')
        self.assertEqual(data['priority'], 4)
        
        # Vérifier en base de données
        task = Task.query.filter_by(title='Nouvelle tâche').first()
        self.assertIsNotNone(task)
        self.assertEqual(task.description, 'Test création')
    
    def test_create_task_validation_error(self):
        """
        Test : POST /api/tasks avec données invalides
        Doit retourner une erreur 400
        """
        invalid_task = {
            'description': 'Pas de titre'
        }
        
        response = self.client.post(
            '/api/tasks',
            data=json.dumps(invalid_task),
            content_type='application/json'
        )
        
        self.assertEqual(response.status_code, 400)
        data = json.loads(response.data)
        self.assertIn('errors', data)
    
    def test_update_task(self):
        """
        Test : PUT /api/tasks/<id>
        Doit modifier la tâche
        """
        update_data = {
            'completed': True,
            'priority': 5
        }
        
        response = self.client.put(
            f'/api/tasks/{self.task.id}',
            data=json.dumps(update_data),
            content_type='application/json'
        )
        
        self.assertEqual(response.status_code, 200)
        
        # Vérifier la modification
        task = Task.query.get(self.task.id)
        self.assertTrue(task.completed)
        self.assertEqual(task.priority, 5)
    
    def test_delete_task(self):
        """
        Test : DELETE /api/tasks/<id>
        Doit supprimer la tâche
        """
        response = self.client.delete(f'/api/tasks/{self.task.id}')
        
        self.assertEqual(response.status_code, 200)
        
        # Vérifier que la tâche n'existe plus
        task = Task.query.get(self.task.id)
        self.assertIsNone(task)
    
    def test_get_nonexistent_task(self):
        """
        Test : GET /api/tasks/999
        Doit retourner 404
        """
        response = self.client.get('/api/tasks/999')
        self.assertEqual(response.status_code, 404)

if __name__ == '__main__':
    unittest.main()
```

**Sauvegarde.**

---

**Exécuter les tests :**

```bash
python -m unittest tests.test_tasks
```

**Résultat :**

```
......
----------------------------------------------------------------------
Ran 6 tests in 0.234s

OK
```

**[OK] Tous les tests passent !**

---

**Avec verbose :**

```bash
python -m unittest tests.test_tasks -v
```

**Résultat :**

```
test_create_task (tests.test_tasks.TaskAPITestCase) ... ok
test_create_task_validation_error (tests.test_tasks.TaskAPITestCase) ... ok
test_delete_task (tests.test_tasks.TaskAPITestCase) ... ok
test_get_nonexistent_task (tests.test_tasks.TaskAPITestCase) ... ok
test_get_tasks (tests.test_tasks.TaskAPITestCase) ... ok
test_update_task (tests.test_tasks.TaskAPITestCase) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.245s

OK
```

---

### [OK] TESTS DE VALIDATION

**Fonctionnalités CRUD :**
- [ ] GET /api/tasks retourne les tâches avec pagination
- [ ] GET /api/tasks?completed=false filtre correctement
- [ ] GET /api/tasks?search=mot recherche dans titre/description
- [ ] POST /api/tasks crée une tâche (code 201)
- [ ] POST /api/tasks sans title -> erreur 400
- [ ] PUT /api/tasks/1 modifie la tâche
- [ ] DELETE /api/tasks/1 supprime la tâche
- [ ] PATCH /api/tasks/1/complete marque comme terminée

**Catégories :**
- [ ] GET /api/categories retourne les catégories
- [ ] POST /api/categories crée une catégorie
- [ ] DELETE /api/categories/1 avec tâches -> erreur 400
- [ ] Nom de catégorie unique (contrainte UNIQUE)

**Relations :**
- [ ] Tâche avec category_id valide -> OK
- [ ] Tâche avec category_id invalide -> erreur 404
- [ ] Catégorie affiche tasks_count
- [ ] Tâche retourne category nested

**Base de données :**
- [ ] Migrations fonctionnent (upgrade/downgrade)
- [ ] Données persistées entre redémarrages
- [ ] Contraintes respectées (UNIQUE, FOREIGN KEY)

---

### [ROUGE] ERREURS COURANTES ET SOLUTIONS

#### Erreur 1 : ImportError: cannot import name 'db'

**Cause : Import circulaire**

**Solution :**

Vérifier que `db` est importé depuis `app.models` :

```python
# Dans app/__init__.py
from app.models import db  # [OK]

# Pas comme ça
from app import db  # [X]
```

---

#### Erreur 2 : sqlalchemy.exc.OperationalError: no such table

**Cause : Tables pas créées**

**Solution :**

```bash
# Appliquer les migrations
flask db upgrade

# Ou initialiser
flask init-db
```

---

#### Erreur 3 : IntegrityError: UNIQUE constraint failed

**Cause : Tentative de créer un doublon (nom de catégorie déjà existant)**

**Solution :**

Vérifier avant d'insérer :

```python
existing = Category.query.filter_by(name=data['name']).first()
if existing:
    return jsonify({'error': 'Nom déjà utilisé'}), 400
```

---

#### Erreur 4 : marshmallow.exceptions.ValidationError

**Cause : Données invalides**

**Voir le message d'erreur :**

```python
try:
    data = schema.load(json_data)
except ValidationError as err:
    print(err.messages)  # Debug
    return jsonify({'errors': err.messages}), 400
```

---

#### Erreur 5 : AttributeError: 'NoneType' object has no attribute

**Cause : Objet non trouvé (None)**

**Solution :**

Utiliser `.get_or_404()` :

```python
# Au lieu de
task = Task.query.get(task_id)
if not task:
    abort(404)

# Utiliser
task = Task.query.get_or_404(task_id)  # Plus simple
```

---

### [IMPORTANT] POINTS CLÉS À RETENIR

**1. SQLAlchemy ORM**
- `db.Model` : Classe de base pour les modèles
- `db.Column()` : Définit les colonnes
- `db.relationship()` : Définit les relations (pas de colonne DB !)
- `db.ForeignKey()` : Clé étrangère (colonne DB)

**2. Opérations CRUD**
- **Create** : `db.session.add()` + `commit()`
- **Read** : `Model.query.filter_by()`, `.all()`, `.first()`
- **Update** : Modifier l'objet + `commit()`
- **Delete** : `db.session.delete()` + `commit()`

**3. Transactions**
- `db.session.add()` : Ajoute à la session
- `db.session.commit()` : Valide les changements
- `db.session.rollback()` : Annule les changements

**4. Migrations**
- `flask db init` : Initialise (une seule fois)
- `flask db migrate -m "msg"` : Détecte les changements
- `flask db upgrade` : Applique les migrations
- `flask db downgrade` : Revient en arrière

**5. Validation avec marshmallow**
- `fields.*` : Définit les types de champs
- `required=True` : Champ obligatoire
- `validate=...` : Validateurs
- `.dump()` : Objet -> JSON
- `.load()` : JSON -> Objet

**6. Relations**
- **One-to-Many** : Une catégorie, plusieurs tâches
- `backref` : Crée l'attribut inverse automatiquement
- `lazy='dynamic'` : Retourne un Query (filtrable)
- `cascade='all, delete-orphan'` : Suppression en cascade

**7. Optimisation**
- `db.joinedload()` : Eager loading (éviter N+1)
- `.paginate()` : Pagination automatique
- Index sur colonnes fréquemment recherchées

---

### [RAPIDE] POUR ALLER PLUS LOIN

**1. Ajouter des contraintes check**

```python
class Task(db.Model):
    priority = db.Column(
        db.Integer,
        db.CheckConstraint('priority >= 1 AND priority <= 5'),
        default=1
    )
```

---

**2. Index composites**

```python
class Task(db.Model):
    __table_args__ = (
        db.Index('idx_category_completed', 'category_id', 'completed'),
    )
```

**Améliore les performances pour :**

```python
Task.query.filter_by(category_id=1, completed=False).all()
```

---

**3. Événements SQLAlchemy**

```python
from sqlalchemy import event

@event.listens_for(Task, 'before_insert')
def receive_before_insert(mapper, connection, target):
    """Exécuté avant chaque INSERT de Task"""
    print(f"Création de la tâche : {target.title}")

@event.listens_for(Task, 'before_update')
def receive_before_update(mapper, connection, target):
    """Exécuté avant chaque UPDATE"""
    target.updated_at = datetime.utcnow()
```

---

**4. Méthodes de classe (class methods)**

```python
class Task(db.Model):
    # ...
    
    @classmethod
    def get_pending(cls):
        """Retourne les tâches non terminées"""
        return cls.query.filter_by(completed=False).all()
    
    @classmethod
    def get_by_priority(cls, priority):
        """Retourne les tâches par priorité"""
        return cls.query.filter_by(priority=priority).all()
```

**Utilisation :**

```python
pending_tasks = Task.get_pending()
high_priority = Task.get_by_priority(5)
```

---

**5. Propriétés calculées**

```python
from sqlalchemy.ext.hybrid import hybrid_property

class Task(db.Model):
    # ...
    
    @hybrid_property
    def is_overdue(self):
        """Vérifie si la tâche est en retard"""
        if not self.due_date or self.completed:
            return False
        return self.due_date < datetime.utcnow()
```

**Utilisation :**

```python
task = Task.query.get(1)
if task.is_overdue:
    print("Tâche en retard !")

# Fonctionne aussi dans les requêtes !
overdue_tasks = Task.query.filter(Task.is_overdue == True).all()
```

---

**6. Sérialisation avec dataclasses**

```python
from dataclasses import dataclass

@dataclass
class Task(db.Model):
    id: int
    title: str
    completed: bool
    # ...
    
    # SQLAlchemy columns...
```

**Auto-génère :** `__init__()`, `__repr__()`, etc.

---

**7. Soft delete (suppression logique)**

```python
class Task(db.Model):
    # ...
    deleted_at = db.Column(db.DateTime, nullable=True)
    
    def delete(self):
        """Suppression logique"""
        self.deleted_at = datetime.utcnow()
        db.session.commit()
    
    @classmethod
    def active(cls):
        """Retourne seulement les non supprimés"""
        return cls.query.filter_by(deleted_at=None)
```

---

## [COURS] CONCLUSION DE L'EXERCICE 2

**[OK] Félicitations ! Tu maîtrises maintenant Flask avec SQLAlchemy !**

**Ce que tu as appris :**
- Configurer SQLAlchemy avec Flask
- Créer des modèles avec relations (One-to-Many)
- Effectuer des opérations CRUD complètes
- Gérer les migrations avec Flask-Migrate
- Valider avec marshmallow
- Optimiser les requêtes (eager loading)
- Paginer les résultats
- Filtrer et trier dynamiquement
- Structurer un projet Flask professionnel
- Écrire des tests unitaires

**Compétences acquises :**
- [OK] SQLAlchemy (niveau intermédiaire)
- [OK] Flask-Migrate (migrations)
- [OK] Marshmallow (validation)
- [OK] Relations de base de données
- [OK] Optimisation de requêtes
- [OK] Tests unitaires

**Temps moyen de réalisation :** 2-3 heures

**Prochaine étape :** Exercice 3 - Authentification JWT et autorisation ! [SECURISE]

---

Veux-tu que je continue avec l'exercice 3 sur l'authentification JWT ?


# [SECURISE] EXERCICE 3 : AUTHENTIFICATION JWT ET AUTORISATION

## [LISTE] ÉNONCÉ

### Contexte professionnel

L'API de gestion de tâches (exercice 2) fonctionne bien, mais n'importe qui peut accéder à toutes les données ! L'entreprise veut maintenant sécuriser l'API avec un système d'authentification robuste basé sur JWT (JSON Web Tokens).

### Cahier des charges

Ajouter à l'API existante :
- **Système d'utilisateurs** avec inscription et connexion
- **Authentification JWT** (access tokens + refresh tokens)
- **Hashage sécurisé** des mots de passe (bcrypt)
- **Routes protégées** (authentification obligatoire)
- **Autorisation** basée sur les rôles (admin, user)
- **Gestion des tâches personnelles** (chaque utilisateur voit ses tâches)
- **Révocation de tokens** (blacklist)
- **Validation robuste** des mots de passe

### Contraintes techniques

- Flask-JWT-Extended pour JWT
- Bcrypt pour le hashage
- Access token : 15 minutes de validité
- Refresh token : 30 jours de validité
- Validation : email valide, mot de passe fort
- Tests d'authentification
- Temps estimé : 3-4 heures

---

## [OBJECTIF] OBJECTIFS PÉDAGOGIQUES

À la fin de cet exercice, tu sauras :

- [OK] Comprendre le fonctionnement de JWT
- [OK] Implémenter l'authentification stateless
- [OK] Hasher et vérifier des mots de passe avec bcrypt
- [OK] Créer un système d'inscription/connexion sécurisé
- [OK] Protéger des routes avec des décorateurs
- [OK] Gérer les access tokens et refresh tokens
- [OK] Implémenter un système de rôles (RBAC)
- [OK] Révoquer des tokens (blacklist)
- [OK] Valider des données sensibles (email, password)
- [OK] Sécuriser une API REST complète

---

## [DOCS] PRÉREQUIS

- Exercice 2 terminé et compris
- Compréhension des sessions HTTP
- Notions de cryptographie (hash, salt)
- Connaissance de base des tokens

---

## [RECHERCHE] THÉORIE : COMPRENDRE JWT

### Qu'est-ce qu'un JWT ?

**JWT (JSON Web Token) :**
- Format standard (RFC 7519) pour créer des tokens d'accès
- Alternative aux sessions serveur
- Self-contained : toutes les infos dans le token
- Stateless : le serveur ne stocke rien

### Structure d'un JWT

Un JWT se compose de **3 parties** séparées par des points (`.`) :

```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
```

**1. HEADER (En-tête)**
```json
{
  "alg": "HS256",
  "typ": "JWT"
}
```
- `alg` : Algorithme de signature (HS256, RS256, etc.)
- `typ` : Type de token (JWT)

**2. PAYLOAD (Charge utile)**
```json
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622
}
```
- `sub` (subject) : Identifiant de l'utilisateur
- `iat` (issued at) : Date de création
- `exp` (expiration) : Date d'expiration
- Claims personnalisés : name, email, role, etc.

**3. SIGNATURE**
```
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret_key
)
```
- Garantit l'intégrité du token
- Empêche la modification du payload

### Flux d'authentification JWT

```
┌─────────┐                                    ┌─────────┐
│ Client  │                                    │ Serveur │
└────┬────┘                                    └────┬────┘
     │                                              │
     │  1. POST /auth/login                        │
     │     {email, password}                       │
     ├────────────────────────────────────────────>│
     │                                              │
     │                                              │ 2. Vérifier identifiants
     │                                              │    Hash password
     │                                              │
     │  3. Retour access_token + refresh_token     │
     │     {access_token: "eyJ...", ...}           │
     │<────────────────────────────────────────────┤
     │                                              │
     │                                              │
     │  4. GET /api/tasks                          │
     │     Authorization: Bearer eyJ...            │
     ├────────────────────────────────────────────>│
     │                                              │
     │                                              │ 5. Vérifier signature JWT
     │                                              │    Extraire user_id
     │                                              │    Vérifier expiration
     │                                              │
     │  6. Retour données                          │
     │     {tasks: [...]}                          │
     │<────────────────────────────────────────────┤
     │                                              │
```

### Avantages de JWT

**[OK] Stateless :**
- Pas de stockage serveur (sessions)
- Scalabilité horizontale facile
- Microservices friendly

**[OK] Self-contained :**
- Toutes les infos dans le token
- Pas de requête DB pour chaque vérification

**[OK] Flexible :**
- Peut contenir n'importe quelle info
- Claims personnalisés
- Utilisation multi-domaines (CORS)

### Inconvénients de JWT

**[X] Taille :**
- Plus gros que les session IDs
- Envoyé à chaque requête

**[X] Révocation difficile :**
- Token valide jusqu'à expiration
- Besoin d'une blacklist pour révoquer

**[X] Sécurité :**
- Si clé secrète volée -> compromission totale
- Payload visible (base64, pas chiffré !)

### Access Token vs Refresh Token

**Access Token :**
- **Courte durée** (15 min - 1 heure)
- Utilisé pour accéder aux ressources protégées
- Envoyé à chaque requête
- Si volé : impact limité (expire vite)

**Refresh Token :**
- **Longue durée** (7-90 jours)
- Utilisé SEULEMENT pour obtenir un nouvel access token
- Stocké de manière sécurisée
- Révocable (stocké en base ou cache)

**Flux avec Refresh Token :**

```
1. Login -> Access Token (15 min) + Refresh Token (30 jours)
2. Requêtes API avec Access Token
3. Access Token expire -> 401 Unauthorized
4. POST /auth/refresh avec Refresh Token
5. Nouveau Access Token (15 min)
6. Continuer les requêtes
```

---

## [OK] SOLUTION COMPLÈTE

### ÉTAPE 1 : Installer les dépendances

```bash
# Activer l'environnement virtuel
source venv/bin/activate

# Installer les packages
pip install Flask-JWT-Extended
pip install bcrypt
pip install email-validator
```

**Explication des packages :**

**Flask-JWT-Extended :**
- Extension Flask pour JWT
- Gère access tokens, refresh tokens
- Décorateurs pour routes protégées
- Blacklist de tokens

**bcrypt :**
- Algorithme de hashage sécurisé
- Salage automatique
- Résistant aux attaques par force brute
- Coût configurable (ralentissement intentionnel)

**email-validator :**
- Validation d'emails robuste
- Vérifie syntaxe et domaine
- Normalisation des emails

---

**Mettre à jour `requirements.txt` :**

```bash
pip freeze > requirements.txt
```

---

### ÉTAPE 2 : Mettre à jour la configuration

**Modifier `app/config.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# CONFIGURATION DE L'APPLICATION FLASK (avec JWT)
# ═══════════════════════════════════════════════════════════════

import os
from pathlib import Path
from datetime import timedelta

BASE_DIR = Path(__file__).resolve().parent.parent

class Config:
    """Configuration de base"""
    
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
    
    # Configuration de la base de données
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
        'sqlite:///' + str(BASE_DIR / 'instance' / 'todo.db')
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    
    # Configuration JSON
    JSON_SORT_KEYS = False
    JSONIFY_PRETTYPRINT_REGULAR = True
    
    # Pagination
    TASKS_PER_PAGE = 10
    
    # ──────────────────────────────────────────────────────
    # CONFIGURATION JWT
    # ──────────────────────────────────────────────────────
    
    # Clé secrète pour signer les JWT
    JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or 'jwt-secret-key-change-in-production'
    
    """
    JWT_SECRET_KEY :
    Clé utilisée pour signer et vérifier les JWT
    
    [ATTENTION] CRITIQUE : Cette clé DOIT être :
    - Secrète (jamais dans le code versionné)
    - Aléatoire et complexe
    - Différente de SECRET_KEY
    - Changée régulièrement en production
    
    Générer une clé forte :
    python -c 'import secrets; print(secrets.token_hex(32))'
    
    Si cette clé est compromise :
    -> Tous les JWT peuvent être forgés
    -> Accès non autorisé à toute l'API
    """
    
    # Durée de validité des access tokens
    JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)
    
    """
    JWT_ACCESS_TOKEN_EXPIRES :
    Durée de vie des access tokens
    
    timedelta(minutes=15) : 15 minutes
    
    Recommandations :
    - API publique : 5-15 minutes
    - API interne : 15-60 minutes
    - Admin panel : 5 minutes
    
    Plus court = plus sécurisé mais plus de refresh
    Plus long = moins de refresh mais risque si volé
    
    Après expiration :
    - Token invalide (erreur 401)
    - Utiliser refresh token pour en obtenir un nouveau
    """
    
    # Durée de validité des refresh tokens
    JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
    
    """
    JWT_REFRESH_TOKEN_EXPIRES :
    Durée de vie des refresh tokens
    
    timedelta(days=30) : 30 jours
    
    Recommandations :
    - Applications mobiles : 30-90 jours
    - Applications web : 7-30 jours
    - Applications critiques : 1-7 jours
    
    Stratégies :
    1. Refresh token rotatif : Nouveau à chaque utilisation
    2. Refresh token fixe : Même token jusqu'à expiration
    3. Sliding window : Prolonge à chaque utilisation
    """
    
    # Localisation du token dans les requêtes
    JWT_TOKEN_LOCATION = ['headers']
    
    """
    JWT_TOKEN_LOCATION :
    Où chercher le token JWT dans les requêtes
    
    Options :
    - ['headers'] : Header Authorization: Bearer <token>
    - ['cookies'] : Cookie HttpOnly (plus sécurisé pour web)
    - ['query_string'] : ?token=... (déconseillé, logs)
    - ['json'] : Body JSON (POST seulement)
    
    Plusieurs sources possibles :
    ['headers', 'cookies'] : Cherche d'abord headers, puis cookies
    
    Recommandations :
    - API : headers
    - Web app : cookies (HttpOnly, Secure, SameSite)
    - Mobile : headers
    """
    
    # Format du header Authorization
    JWT_HEADER_NAME = 'Authorization'
    JWT_HEADER_TYPE = 'Bearer'
    
    """
    JWT_HEADER_NAME et JWT_HEADER_TYPE :
    
    Format attendu :
    Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
    
    JWT_HEADER_NAME = 'Authorization' :
    Nom du header HTTP
    
    JWT_HEADER_TYPE = 'Bearer' :
    Préfixe avant le token
    
    Résultat :
    headers = {
        'Authorization': 'Bearer <token>'
    }
    
    Alternatives :
    JWT_HEADER_TYPE = '' : Pas de préfixe
    -> Authorization: <token>
    """
    
    # Messages d'erreur personnalisés
    JWT_ERROR_MESSAGE_KEY = 'message'
    
    """
    JWT_ERROR_MESSAGE_KEY :
    Clé pour les messages d'erreur JWT
    
    Par défaut : 'msg'
    Personnalisé : 'message' (plus standard)
    
    Erreur JWT avec 'msg' :
    {"msg": "Token has expired"}
    
    Erreur JWT avec 'message' :
    {"message": "Token has expired"}
    """
    
    # Configuration du bcrypt
    BCRYPT_LOG_ROUNDS = 12
    
    """
    BCRYPT_LOG_ROUNDS :
    Facteur de coût pour bcrypt (nombre d'itérations = 2^n)
    
    Plus élevé = plus sécurisé mais plus lent
    
    Valeurs :
    - 10 : Rapide, moins sécurisé (développement)
    - 12 : Équilibre (recommandé en production)
    - 14-15 : Très sécurisé mais lent
    
    Temps approximatif (hash d'un mot de passe) :
    - 10 : ~75 ms
    - 12 : ~300 ms
    - 14 : ~1.2 s
    - 15 : ~2.4 s
    
    En production :
    Augmenter progressivement avec la puissance CPU
    """

class DevelopmentConfig(Config):
    """Configuration pour le développement"""
    DEBUG = True
    TESTING = False
    
    # JWT plus long en dev pour faciliter le développement
    JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)

class ProductionConfig(Config):
    """Configuration pour la production"""
    DEBUG = False
    TESTING = False
    
    # Forcer les variables d'environnement
    SECRET_KEY = os.environ.get('SECRET_KEY')
    JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY')
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
    
    # Sécurité renforcée
    JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)
    JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7)  # Plus court en prod
    BCRYPT_LOG_ROUNDS = 13  # Plus sécurisé

class TestingConfig(Config):
    """Configuration pour les tests"""
    TESTING = True
    DEBUG = False
    
    # Base de données en mémoire
    SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
    
    # JWT rapides pour les tests
    JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=5)
    JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=1)
    
    # Bcrypt rapide pour les tests
    BCRYPT_LOG_ROUNDS = 4  # Minimum pour tests rapides
    
    # Désactiver CSRF
    WTF_CSRF_ENABLED = False

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

**Sauvegarde.**

---

### ÉTAPE 3 : Créer le modèle User

**Modifier `app/models.py` pour ajouter le modèle User :**

```python
# ═══════════════════════════════════════════════════════════════
# MODÈLES SQLALCHEMY - TASKS, CATEGORIES, USERS
# ═══════════════════════════════════════════════════════════════

from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
import bcrypt

db = SQLAlchemy()

# ───────────────────────────────────────────────────────────────
# MODÈLE USER (UTILISATEUR)
# ───────────────────────────────────────────────────────────────

class User(db.Model):
    """
    Modèle pour les utilisateurs
    
    Table : users
    Relations : One-to-Many avec Task
    """
    
    __tablename__ = 'users'
    
    # Colonnes
    id = db.Column(db.Integer, primary_key=True)
    
    username = db.Column(db.String(80), unique=True, nullable=False, index=True)
    
    """
    username :
    Nom d'utilisateur unique
    
    unique=True : Pas de doublons
    index=True : Index pour recherche rapide
    
    Équivalent SQL :
    username VARCHAR(80) UNIQUE NOT NULL,
    CREATE INDEX ix_users_username ON users(username)
    
    Index utile pour :
    User.query.filter_by(username='john').first()
    """
    
    email = db.Column(db.String(120), unique=True, nullable=False, index=True)
    
    """
    email :
    Email unique (utilisé pour la connexion)
    
    Validation côté application (avec email-validator)
    Stockage normalisé (lowercase)
    """
    
    password_hash = db.Column(db.String(255), nullable=False)
    
    """
    password_hash :
    Hash bcrypt du mot de passe
    
    [ATTENTION] JAMAIS stocker le mot de passe en clair !
    
    Bcrypt génère un hash de ~60 caractères :
    $2b$12$KIXxLVZ8T.Hk0w9sZGqYO.abc123...
    
    String(255) : Large pour compatibilité future
    """
    
    first_name = db.Column(db.String(50))
    last_name = db.Column(db.String(50))
    
    role = db.Column(db.String(20), default='user', nullable=False)
    
    """
    role :
    Rôle de l'utilisateur (autorisation)
    
    Valeurs courantes :
    - 'user' : Utilisateur standard (défaut)
    - 'admin' : Administrateur
    - 'moderator' : Modérateur
    
    Alternative : Table Role séparée (Many-to-Many)
    Pour systèmes complexes avec permissions granulaires
    """
    
    is_active = db.Column(db.Boolean, default=True, nullable=False)
    
    """
    is_active :
    Statut du compte
    
    True : Compte actif (peut se connecter)
    False : Compte désactivé/banni
    
    Utilité :
    - Bannir utilisateur sans supprimer données
    - Désactiver temporairement
    - Nécessite validation email
    """
    
    created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    last_login = db.Column(db.DateTime)
    
    """
    last_login :
    Timestamp de dernière connexion
    
    Utile pour :
    - Analyser l'activité
    - Détecter comptes inactifs
    - Sécurité (connexions suspectes)
    """
    
    # Relation avec Task
    tasks = db.relationship('Task', backref='user', lazy='dynamic', cascade='all, delete-orphan')
    
    """
    Relation One-to-Many :
    Un utilisateur peut avoir plusieurs tâches
    
    backref='user' :
    task.user retourne l'utilisateur propriétaire
    
    lazy='dynamic' :
    user.tasks retourne un Query (filtrable)
    
    cascade='all, delete-orphan' :
    Supprimer un user supprime ses tâches
    """
    
    def __repr__(self):
        return f'<User {self.username}>'
    
    # ──────────────────────────────────────────────────────
    # MÉTHODES DE MOT DE PASSE
    # ──────────────────────────────────────────────────────
    
    def set_password(self, password):
        """
        Hash et stocke le mot de passe
        
        Args:
            password (str): Mot de passe en clair
        """
        # Convertir le password en bytes
        password_bytes = password.encode('utf-8')
        
        """
        encode('utf-8') :
        Convertit string -> bytes
        
        bcrypt travaille avec des bytes, pas des strings
        
        'password123' -> b'password123'
        """
        
        # Générer le hash avec bcrypt
        salt = bcrypt.gensalt()
        hashed = bcrypt.hashpw(password_bytes, salt)
        
        """
        bcrypt.gensalt() :
        Génère un "salt" aléatoire
        
        Salt : Valeur aléatoire ajoutée au password
        Empêche les rainbow tables
        
        Chaque hash a un salt unique :
        hash('password123') -> $2b$12$abc...
        hash('password123') -> $2b$12$xyz... (différent !)
        
        bcrypt.hashpw(password, salt) :
        Hash le password avec le salt
        
        Résultat (60 caractères) :
        $2b$12$KIXxLVZ8T.Hk0w9sZGqYO.7VqN3j8k9l0m1n2o3p4q5r6s7t8u9v
        
        Structure :
        $2b$ : Algorithme bcrypt
        12 : Coût (2^12 itérations)
        KIX...qYO. : Salt (22 caractères)
        7Vq...u9v : Hash (31 caractères)
        """
        
        # Stocker en base (convertir bytes -> string)
        self.password_hash = hashed.decode('utf-8')
        
        """
        decode('utf-8') :
        Convertit bytes -> string pour stockage DB
        
        b'$2b$12$...' -> '$2b$12$...'
        """
    
    def check_password(self, password):
        """
        Vérifie si le mot de passe est correct
        
        Args:
            password (str): Mot de passe à vérifier
            
        Returns:
            bool: True si correct, False sinon
        """
        password_bytes = password.encode('utf-8')
        hash_bytes = self.password_hash.encode('utf-8')
        
        return bcrypt.checkpw(password_bytes, hash_bytes)
        
        """
        bcrypt.checkpw(password, hash) :
        Compare password avec hash stocké
        
        Process :
        1. Extrait le salt du hash stocké
        2. Hash le password fourni avec ce salt
        3. Compare les deux hash
        
        [OK] Sécurisé :
        - Résistant au timing attack (constant time)
        - Le salt est dans le hash (pas besoin de le stocker séparément)
        
        Exemple :
        user.set_password('password123')
        user.check_password('password123')  # True
        user.check_password('wrong')        # False
        """
    
    # ──────────────────────────────────────────────────────
    # MÉTHODES D'AUTORISATION
    # ──────────────────────────────────────────────────────
    
    def is_admin(self):
        """Vérifie si l'utilisateur est admin"""
        return self.role == 'admin'
    
    def can_edit_task(self, task):
        """
        Vérifie si l'utilisateur peut modifier une tâche
        
        Args:
            task (Task): Tâche à vérifier
            
        Returns:
            bool: True si autorisé
        """
        # Admin peut tout faire
        if self.is_admin():
            return True
        
        # Utilisateur peut modifier ses propres tâches
        return task.user_id == self.id
    
    def can_delete_task(self, task):
        """Vérifie si l'utilisateur peut supprimer une tâche"""
        return self.can_edit_task(task)
    
    # ──────────────────────────────────────────────────────
    # SÉRIALISATION
    # ──────────────────────────────────────────────────────
    
    def to_dict(self, include_email=False):
        """
        Convertit l'utilisateur en dictionnaire
        
        Args:
            include_email (bool): Inclure l'email (données sensibles)
        """
        data = {
            'id': self.id,
            'username': self.username,
            'first_name': self.first_name,
            'last_name': self.last_name,
            'role': self.role,
            'is_active': self.is_active,
            'created_at': self.created_at.isoformat() if self.created_at else None,
        }
        
        # Email seulement si demandé (données personnelles)
        if include_email:
            data['email'] = self.email
        
        return data
        
        """
        include_email :
        Contrôle d'accès aux données sensibles
        
        Cas d'usage :
        - Profil public : include_email=False
        - Profil privé : include_email=True
        - Liste utilisateurs (admin) : include_email=True
        
        [ATTENTION] RGPD / Privacy :
        Ne jamais exposer l'email publiquement
        """

# Garder les modèles Category et Task existants...
# (Je vais les modifier pour ajouter la relation avec User)

# ───────────────────────────────────────────────────────────────
# MODÈLE CATEGORY (CATÉGORIE) - INCHANGÉ
# ───────────────────────────────────────────────────────────────

class Category(db.Model):
    """Modèle pour les catégories de tâches"""
    
    __tablename__ = 'categories'
    
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), unique=True, nullable=False)
    description = db.Column(db.String(200))
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    
    tasks = db.relationship('Task', backref='category', lazy='dynamic', cascade='all, delete-orphan')
    
    def __repr__(self):
        return f'<Category {self.name}>'
    
    def to_dict(self):
        return {
            'id': self.id,
            'name': self.name,
            'description': self.description,
            'created_at': self.created_at.isoformat() if self.created_at else None,
            'tasks_count': self.tasks.count() if isinstance(self.tasks, db.Query) else len(self.tasks)
        }

# ───────────────────────────────────────────────────────────────
# MODÈLE TASK (TÂCHE) - MODIFIÉ POUR AJOUTER USER
# ───────────────────────────────────────────────────────────────

class Task(db.Model):
    """
    Modèle pour les tâches
    MODIFIÉ : Ajout de la relation avec User
    """
    
    __tablename__ = 'tasks'
    
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(100), nullable=False)
    description = db.Column(db.Text)
    completed = db.Column(db.Boolean, default=False, nullable=False)
    priority = db.Column(db.Integer, default=1)
    due_date = db.Column(db.DateTime)
    created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    
    # Relations
    category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
    
    # ──────────────────────────────────────────────────────
    # NOUVELLE COLONNE : user_id
    # ──────────────────────────────────────────────────────
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    
    """
    user_id :
    Clé étrangère vers l'utilisateur propriétaire
    
    nullable=False :
    Chaque tâche DOIT avoir un propriétaire
    
    Équivalent SQL :
    user_id INTEGER NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
    
    Utilisation :
    task = Task(title="...", user_id=current_user.id)
    
    Accès :
    task.user -> Objet User (grâce au backref)
    task.user_id -> ID de l'utilisateur (int)
    """
    
    def __repr__(self):
        return f'<Task {self.title}>'
    
    def to_dict(self, include_category=True, include_user=False):
        """
        Convertit en dictionnaire
        
        Args:
            include_category (bool): Inclure la catégorie
            include_user (bool): Inclure l'utilisateur
        """
        data = {
            'id': self.id,
            'title': self.title,
            'description': self.description,
            'completed': self.completed,
            'priority': self.priority,
            'due_date': self.due_date.isoformat() if self.due_date else None,
            'created_at': self.created_at.isoformat() if self.created_at else None,
            'updated_at': self.updated_at.isoformat() if self.updated_at else None,
            'category_id': self.category_id,
            'user_id': self.user_id
        }
        
        if include_category and self.category:
            data['category'] = {
                'id': self.category.id,
                'name': self.category.name
            }
        
        if include_user and self.user:
            data['user'] = {
                'id': self.user.id,
                'username': self.user.username
            }
        
        return data

# ───────────────────────────────────────────────────────────────
# MODÈLE TOKEN BLACKLIST (RÉVOCATION)
# ───────────────────────────────────────────────────────────────

class TokenBlocklist(db.Model):
    """
    Modèle pour la blacklist des tokens révoqués
    
    Permet de révoquer des JWT avant leur expiration
    """
    
    __tablename__ = 'token_blocklist'
    
    id = db.Column(db.Integer, primary_key=True)
    
    jti = db.Column(db.String(36), nullable=False, unique=True, index=True)
    
    """
    jti (JWT ID) :
    Identifiant unique du token
    
    Présent dans le payload JWT :
    {
        "jti": "abc123-def456-ghi789",
        "sub": "user_id",
        ...
    }
    
    String(36) : UUID standard (32 chars + 4 tirets)
    unique=True : Un token ne peut être révoqué qu'une fois
    index=True : Recherche rapide
    """
    
    token_type = db.Column(db.String(10), nullable=False)
    
    """
    token_type :
    Type de token révoqué
    
    Valeurs :
    - 'access' : Access token
    - 'refresh' : Refresh token
    
    Utile pour statistiques et gestion différenciée
    """
    
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    
    """
    user_id :
    Utilisateur qui possédait le token
    
    Utile pour :
    - Révoquer tous les tokens d'un user
    - Auditer les révocations
    """
    
    revoked_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
    
    """
    revoked_at :
    Timestamp de révocation
    
    Utilité :
    - Auditer les révocations
    - Nettoyer les vieux tokens expirés
    """
    
    expires_at = db.Column(db.DateTime, nullable=False)
    
    """
    expires_at :
    Date d'expiration originale du token
    
    Permet de nettoyer la blacklist :
    DELETE FROM token_blocklist WHERE expires_at < NOW()
    
    Pas besoin de garder les tokens expirés naturellement
    """
    
    def __repr__(self):
        return f'<TokenBlocklist {self.jti}>'
```

**Sauvegarde.**

---

### ÉTAPE 4 : Créer une nouvelle migration

**Créer la migration pour User et TokenBlocklist :**

```bash
flask db migrate -m "Add User and TokenBlocklist models"
```

**Résultat :**

```
INFO  [alembic.autogenerate.compare] Detected added table 'users'
INFO  [alembic.autogenerate.compare] Detected added table 'token_blocklist'
INFO  [alembic.autogenerate.compare] Detected added column 'tasks.user_id'
  Generating migrations/versions/xyz789_add_user_and_tokenblock.py ...  done
```

**Appliquer la migration :**

```bash
flask db upgrade
```

**[OK] Tables créées dans la base de données !**

---

Je continue avec les schémas de validation, les routes d'authentification et la suite. Veux-tu que je continue ?


### ÉTAPE 5 : Créer les schémas de validation pour l'authentification

**Créer `app/auth_schemas.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# SCHÉMAS D'AUTHENTIFICATION - VALIDATION
# ═══════════════════════════════════════════════════════════════

from marshmallow import Schema, fields, validate, validates, ValidationError, post_load
import re

"""
Schémas séparés pour l'authentification
Séparation des responsabilités (auth vs business logic)
"""

# ───────────────────────────────────────────────────────────────
# SCHÉMA USER
# ───────────────────────────────────────────────────────────────

class UserSchema(Schema):
    """
    Schéma pour sérialiser/désérialiser les utilisateurs
    """
    
    id = fields.Int(dump_only=True)
    
    username = fields.Str(
        required=True,
        validate=validate.Length(min=3, max=80),
        error_messages={
            'required': 'Le nom d\'utilisateur est obligatoire',
            'invalid': 'Le nom d\'utilisateur doit être une chaîne'
        }
    )
    
    """
    username :
    3-80 caractères
    
    Bonnes pratiques :
    - Minimum 3 chars (éviter 'a', 'b')
    - Maximum 80 chars (base de données)
    - Caractères autorisés : letters, numbers, underscore, tiret
    """
    
    email = fields.Email(
        required=True,
        error_messages={
            'required': 'L\'email est obligatoire',
            'invalid': 'Format d\'email invalide'
        }
    )
    
    """
    fields.Email :
    Validation d'email intégrée
    
    Vérifie :
    - Format syntaxique (xxx@yyy.zzz)
    - Présence de @ et domaine
    
    Ne vérifie PAS :
    - Si l'email existe vraiment
    - Si le domaine est valide (DNS)
    
    Exemples valides :
    - user@example.com [OK]
    - user.name+tag@example.co.uk [OK]
    
    Exemples invalides :
    - user@example [X] (pas de TLD)
    - user.example.com [X] (pas de @)
    - @example.com [X] (pas de local part)
    """
    
    password = fields.Str(
        required=True,
        load_only=True,
        validate=validate.Length(min=8),
        error_messages={
            'required': 'Le mot de passe est obligatoire'
        }
    )
    
    """
    password :
    load_only=True : Seulement en INPUT (désérialisation)
    
    [ATTENTION] JAMAIS dans l'output (sérialisation) !
    Ne JAMAIS retourner le password, même hashé
    
    validate.Length(min=8) :
    Minimum 8 caractères
    
    Recommandations OWASP :
    - Minimum 8 caractères (12+ recommandé)
    - Pas de maximum (frustrant, pas plus sécurisé)
    - Pas de règles complexes forcées (1 majuscule, 1 chiffre, etc.)
    - Encourager les passphrases : "correct horse battery staple"
    """
    
    first_name = fields.Str(
        allow_none=True,
        validate=validate.Length(max=50)
    )
    
    last_name = fields.Str(
        allow_none=True,
        validate=validate.Length(max=50)
    )
    
    role = fields.Str(
        dump_only=True,
        validate=validate.OneOf(['user', 'admin'])
    )
    
    """
    role :
    dump_only=True : Seulement en OUTPUT
    
    L'utilisateur ne peut PAS définir son propre rôle !
    Seul l'admin ou le système peut le faire
    
    validate.OneOf(['user', 'admin']) :
    Valeurs autorisées
    
    Sécurité :
    Empêche l'élévation de privilèges
    POST /register {"role": "admin"} -> Ignoré
    """
    
    is_active = fields.Bool(dump_only=True)
    
    created_at = fields.DateTime(dump_only=True, format='iso')
    updated_at = fields.DateTime(dump_only=True, format='iso')
    last_login = fields.DateTime(dump_only=True, format='iso')
    
    @validates('username')
    def validate_username(self, value):
        """
        Validation avancée du username
        
        Règles :
        - Lettres, chiffres, underscore, tiret
        - Pas d'espaces
        - Commence par une lettre
        """
        # Pattern : commence par lettre, puis alphanumerique + _ -
        pattern = r'^[a-zA-Z][a-zA-Z0-9_-]*$'
        
        if not re.match(pattern, value):
            raise ValidationError(
                'Le nom d\'utilisateur doit commencer par une lettre '
                'et contenir seulement des lettres, chiffres, _ ou -'
            )
        
        """
        Regex expliquée :
        ^          : Début de la string
        [a-zA-Z]   : Première caractère = lettre (a-z ou A-Z)
        [a-zA-Z0-9_-]* : Puis 0+ caractères alphanumériques, _ ou -
        $          : Fin de la string
        
        Exemples valides :
        - john_doe [OK]
        - user123 [OK]
        - my-name [OK]
        
        Exemples invalides :
        - 123user [X] (commence par chiffre)
        - user name [X] (espace)
        - user@name [X] (caractère spécial)
        """
        
        # Interdire les noms réservés
        reserved = ['admin', 'root', 'system', 'api', 'www', 'null', 'undefined']
        if value.lower() in reserved:
            raise ValidationError(f'Le nom d\'utilisateur "{value}" est réservé')
        
        """
        Noms réservés :
        Empêche la confusion avec comptes système
        
        Exemples :
        - admin : Confusion avec compte administrateur
        - api : Collision avec route /api
        - null : Problèmes en JS/JSON
        """
    
    @validates('password')
    def validate_password(self, value):
        """
        Validation avancée du mot de passe
        
        Vérifie la complexité sans être trop restrictif
        """
        # Vérifier la longueur (déjà fait par validate.Length mais on double)
        if len(value) < 8:
            raise ValidationError('Le mot de passe doit contenir au moins 8 caractères')
        
        # Vérifier qu'il n'est pas trop commun
        common_passwords = [
            'password', '12345678', 'qwerty', 'abc123', 'password123',
            'admin', 'letmein', 'welcome', 'monkey', 'dragon'
        ]
        
        if value.lower() in common_passwords:
            raise ValidationError('Ce mot de passe est trop commun')
        
        """
        Mots de passe communs :
        Liste des mots de passe les plus utilisés
        
        En production :
        - Utiliser une liste complète (10k+ entrées)
        - Bibliothèque : django-passwords, zxcvbn
        - API Have I Been Pwned : https://haveibeenpwned.com/API/v3
        
        Exemple avec API HIBP :
        import hashlib
        import requests
        
        def is_pwned_password(password):
            sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
            prefix = sha1[:5]
            suffix = sha1[5:]
            
            response = requests.get(f'https://api.pwnedpasswords.com/range/{prefix}')
            hashes = (line.split(':') for line in response.text.splitlines())
            
            for h, count in hashes:
                if h == suffix:
                    return True  # Compromis !
            return False
        """
        
        # Optionnel : Vérifier la complexité (au moins 2 types de caractères)
        has_lower = any(c.islower() for c in value)
        has_upper = any(c.isupper() for c in value)
        has_digit = any(c.isdigit() for c in value)
        has_special = any(not c.isalnum() for c in value)
        
        char_types = sum([has_lower, has_upper, has_digit, has_special])
        
        if char_types < 2:
            raise ValidationError(
                'Le mot de passe doit contenir au moins 2 types de caractères parmi : '
                'minuscules, majuscules, chiffres, caractères spéciaux'
            )
        
        """
        Complexité du mot de passe :
        
        Pas trop restrictif :
        [X] "Doit contenir 1 majuscule, 1 minuscule, 1 chiffre, 1 spécial"
        -> Frustrant, utilisateurs mettent "Password1!"
        
        Meilleur compromis :
        [OK] "Au moins 2 types de caractères"
        -> Plus flexible, encourage la diversité
        
        Exemples valides :
        - "password123" [OK] (minuscules + chiffres)
        - "MyPassword" [OK] (minuscules + majuscules)
        - "pass@word" [OK] (minuscules + spécial)
        
        Exemples invalides :
        - "password" [X] (seulement minuscules)
        - "12345678" [X] (seulement chiffres)
        """

# ───────────────────────────────────────────────────────────────
# SCHÉMA REGISTRATION
# ───────────────────────────────────────────────────────────────

class UserRegistrationSchema(Schema):
    """
    Schéma pour l'inscription
    Plus strict que UserSchema
    """
    
    username = fields.Str(
        required=True,
        validate=validate.Length(min=3, max=80)
    )
    
    email = fields.Email(required=True)
    
    password = fields.Str(
        required=True,
        validate=validate.Length(min=8, max=128)
    )
    
    password_confirm = fields.Str(
        required=True,
        load_only=True
    )
    
    """
    password_confirm :
    Champ de confirmation
    
    L'utilisateur tape 2 fois le mot de passe
    Évite les typos
    
    load_only=True : Pas stocké en base
    """
    
    first_name = fields.Str(
        allow_none=True,
        validate=validate.Length(max=50)
    )
    
    last_name = fields.Str(
        allow_none=True,
        validate=validate.Length(max=50)
    )
    
    @validates('password_confirm')
    def validate_password_confirm(self, value):
        """
        Vérifie que password et password_confirm sont identiques
        """
        # Accéder au champ password depuis le contexte
        # Note: Cette validation se fait dans la route car on a besoin des 2 champs
        pass
    
    """
    Limitation de @validates :
    Ne peut pas accéder aux autres champs facilement
    
    Solution : Validation dans @validates_schema
    """
    
    @validates_schema
    def validate_passwords_match(self, data, **kwargs):
        """
        Valide que les mots de passe correspondent
        Appelé après validation de tous les champs
        """
        if data.get('password') != data.get('password_confirm'):
            raise ValidationError(
                'Les mots de passe ne correspondent pas',
                field_name='password_confirm'
            )
        
        """
        @validates_schema :
        Validation cross-field (plusieurs champs)
        
        Appelé après @validates de chaque champ
        Reçoit data : dict de tous les champs validés
        
        Cas d'usage :
        - Comparer 2 champs (password == confirm)
        - Dépendances (si A alors B requis)
        - Validation conditionnelle
        
        field_name='password_confirm' :
        Attache l'erreur au champ password_confirm
        
        Résultat :
        {
            "errors": {
                "password_confirm": ["Les mots de passe ne correspondent pas"]
            }
        }
        """

# ───────────────────────────────────────────────────────────────
# SCHÉMA LOGIN
# ───────────────────────────────────────────────────────────────

class UserLoginSchema(Schema):
    """
    Schéma pour la connexion
    """
    
    username = fields.Str(required=True)
    
    """
    username ici peut être :
    - Le username
    - Ou l'email
    
    On accepte les 2 pour flexibilité
    """
    
    password = fields.Str(
        required=True,
        load_only=True
    )

# ───────────────────────────────────────────────────────────────
# SCHÉMA CHANGE PASSWORD
# ───────────────────────────────────────────────────────────────

class ChangePasswordSchema(Schema):
    """
    Schéma pour changer le mot de passe
    """
    
    old_password = fields.Str(
        required=True,
        load_only=True,
        error_messages={'required': 'L\'ancien mot de passe est obligatoire'}
    )
    
    new_password = fields.Str(
        required=True,
        load_only=True,
        validate=validate.Length(min=8),
        error_messages={'required': 'Le nouveau mot de passe est obligatoire'}
    )
    
    new_password_confirm = fields.Str(
        required=True,
        load_only=True,
        error_messages={'required': 'La confirmation est obligatoire'}
    )
    
    @validates_schema
    def validate_passwords(self, data, **kwargs):
        """Valide les mots de passe"""
        
        # Vérifier que nouveau != ancien
        if data.get('new_password') == data.get('old_password'):
            raise ValidationError(
                'Le nouveau mot de passe doit être différent de l\'ancien',
                field_name='new_password'
            )
        
        # Vérifier que nouveau == confirmation
        if data.get('new_password') != data.get('new_password_confirm'):
            raise ValidationError(
                'Les mots de passe ne correspondent pas',
                field_name='new_password_confirm'
            )

# ───────────────────────────────────────────────────────────────
# INSTANCES DES SCHÉMAS
# ───────────────────────────────────────────────────────────────

user_schema = UserSchema()
users_schema = UserSchema(many=True)
user_registration_schema = UserRegistrationSchema()
user_login_schema = UserLoginSchema()
change_password_schema = ChangePasswordSchema()
```

**Sauvegarde.**

---

### ÉTAPE 6 : Initialiser Flask-JWT-Extended

**Modifier `app/__init__.py` pour ajouter JWT :**

```python
# ═══════════════════════════════════════════════════════════════
# INITIALISATION DE L'APPLICATION FLASK (avec JWT)
# ═══════════════════════════════════════════════════════════════

from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_jwt_extended import JWTManager
from datetime import datetime
import os

from app.config import config
from app.models import db

# Initialiser Flask-Migrate
migrate = Migrate()

# Initialiser Flask-JWT-Extended
jwt = JWTManager()

"""
jwt = JWTManager() :
Instance du gestionnaire JWT

Fournit :
- Décorateurs : @jwt_required(), @jwt.token_in_blocklist_loader
- Fonctions : create_access_token(), get_jwt_identity()
- Configuration automatique depuis app.config
"""

def create_app(config_name='default'):
    """Factory pattern pour créer l'application Flask"""
    
    app = Flask(__name__, instance_relative_config=True)
    app.config.from_object(config[config_name])
    
    # S'assurer que le dossier instance existe
    try:
        os.makedirs(app.instance_path)
    except OSError:
        pass
    
    # Initialiser les extensions
    db.init_app(app)
    migrate.init_app(app, db)
    jwt.init_app(app)
    
    """
    jwt.init_app(app) :
    Configure JWT avec l'application
    
    Lit la configuration :
    - JWT_SECRET_KEY
    - JWT_ACCESS_TOKEN_EXPIRES
    - JWT_REFRESH_TOKEN_EXPIRES
    - Etc.
    
    Enregistre les callbacks JWT
    """
    
    # ──────────────────────────────────────────────────────
    # CALLBACKS JWT
    # ──────────────────────────────────────────────────────
    
    @jwt.token_in_blocklist_loader
    def check_if_token_revoked(jwt_header, jwt_payload):
        """
        Callback appelé pour chaque requête avec JWT
        Vérifie si le token est révoqué (blacklist)
        
        Args:
            jwt_header (dict): Header du JWT
            jwt_payload (dict): Payload du JWT
            
        Returns:
            bool: True si révoqué, False sinon
        """
        from app.models import TokenBlocklist
        
        jti = jwt_payload['jti']
        
        """
        jwt_payload :
        Dictionnaire des claims du JWT
        
        Contenu typique :
        {
            "fresh": false,
            "iat": 1702745123,  # Issued At
            "jti": "abc-123",   # JWT ID (unique)
            "type": "access",   # Type (access ou refresh)
            "sub": "42",        # Subject (user_id)
            "nbf": 1702745123,  # Not Before
            "exp": 1702746023   # Expiration
        }
        
        jti : JWT ID
        Identifiant unique du token
        Généré automatiquement par Flask-JWT-Extended
        """
        
        # Chercher dans la blacklist
        token = TokenBlocklist.query.filter_by(jti=jti).first()
        
        return token is not None
        
        """
        Logique :
        - Si token trouvé dans blacklist -> True (révoqué)
        - Sinon -> False (valide)
        
        Performance :
        Index sur jti -> Recherche O(log n)
        
        Alternative (cache) :
        import redis
        r = redis.Redis()
        return r.exists(f'revoked_token:{jti}')
        
        Avec cache :
        - Plus rapide (Redis en mémoire)
        - Pas de requête DB
        - Nettoyage automatique avec TTL
        """
    
    @jwt.expired_token_loader
    def expired_token_callback(jwt_header, jwt_payload):
        """
        Callback appelé quand un token a expiré
        Retourne une réponse JSON personnalisée
        """
        return jsonify({
            'message': 'Le token a expiré',
            'error': 'token_expired'
        }), 401
        
        """
        @jwt.expired_token_loader :
        Personnalise la réponse pour token expiré
        
        Par défaut :
        {"msg": "Token has expired"}
        
        Personnalisé :
        Message en français
        Code d'erreur pour le frontend
        """
    
    @jwt.invalid_token_loader
    def invalid_token_callback(error):
        """
        Callback pour token invalide (signature, format)
        """
        return jsonify({
            'message': 'Signature du token invalide',
            'error': 'invalid_token'
        }), 401
        
        """
        Token invalide :
        - Signature incorrecte (clé différente)
        - Format malformé
        - Header/payload corrompu
        """
    
    @jwt.unauthorized_loader
    def missing_token_callback(error):
        """
        Callback quand aucun token n'est fourni
        """
        return jsonify({
            'message': 'Token d\'accès manquant',
            'error': 'authorization_required'
        }), 401
        
        """
        missing_token :
        L'utilisateur n'a pas envoyé de token
        
        Requête sans header Authorization
        """
    
    @jwt.revoked_token_loader
    def revoked_token_callback(jwt_header, jwt_payload):
        """
        Callback pour token révoqué (blacklist)
        """
        return jsonify({
            'message': 'Le token a été révoqué',
            'error': 'token_revoked'
        }), 401
        
        """
        revoked_token :
        Token dans la blacklist
        
        Cas d'usage :
        - Logout
        - Changement de mot de passe
        - Compromission détectée
        """
    
    @jwt.needs_fresh_token_loader
    def token_not_fresh_callback(jwt_header, jwt_payload):
        """
        Callback pour token non-fresh
        Certaines actions nécessitent un token fresh (connexion récente)
        """
        return jsonify({
            'message': 'Un token fresh est requis',
            'error': 'fresh_token_required'
        }), 401
        
        """
        Fresh token :
        Token obtenu via login (pas refresh)
        
        Utilité :
        Actions sensibles nécessitent une authentification récente :
        - Changer mot de passe
        - Modifier email
        - Supprimer compte
        
        Décorateur :
        @jwt_required(fresh=True)
        
        Exemple :
        1. Login -> Token fresh
        2. Refresh -> Token non-fresh
        3. Changer password -> Erreur (fresh requis)
        4. Re-login -> Token fresh -> OK
        """
    
    @jwt.user_identity_loader
    def user_identity_lookup(user):
        """
        Callback pour extraire l'identité d'un utilisateur
        Appelé lors de create_access_token(identity=user)
        
        Args:
            user: Objet User ou user_id
            
        Returns:
            ID à stocker dans le token
        """
        # Si c'est un objet User, retourner son ID
        if hasattr(user, 'id'):
            return user.id
        # Sinon, c'est déjà un ID
        return user
        
        """
        user_identity_loader :
        Définit ce qui est stocké dans le claim 'sub'
        
        Exemple 1 (objet) :
        user = User.query.get(42)
        token = create_access_token(identity=user)
        # sub = 42
        
        Exemple 2 (ID direct) :
        token = create_access_token(identity=42)
        # sub = 42
        
        Alternative (stocker plus d'infos) :
        def user_identity_lookup(user):
            return {
                'id': user.id,
                'role': user.role
            }
        
        [ATTENTION] Attention :
        Plus d'infos = token plus gros
        Infos sensibles = risque si token volé
        """
    
    @jwt.user_lookup_loader
    def user_lookup_callback(_jwt_header, jwt_data):
        """
        Callback pour charger l'utilisateur depuis le token
        Appelé automatiquement avec @jwt_required()
        
        Args:
            _jwt_header: Header JWT (non utilisé)
            jwt_data: Payload JWT
            
        Returns:
            Objet User ou None
        """
        from app.models import User
        
        identity = jwt_data['sub']
        
        """
        jwt_data['sub'] :
        Subject (identité)
        Contient ce qui a été défini par user_identity_loader
        
        Ici : user_id (int)
        """
        
        return User.query.filter_by(id=identity).first()
        
        """
        Charge l'utilisateur depuis la base
        
        Utilité :
        Avec @jwt_required() :
        - Flask charge automatiquement l'utilisateur
        - Accessible via current_user ou get_current_user()
        
        Exemple :
        @app.route('/profile')
        @jwt_required()
        def profile():
            user = get_current_user()  # Chargé automatiquement !
            return jsonify(user.to_dict())
        
        Performance :
        1 requête DB par requête authentifiée
        
        Optimisation (cache) :
        import redis
        r = redis.Redis()
        
        cached = r.get(f'user:{identity}')
        if cached:
            return User.from_json(cached)
        
        user = User.query.get(identity)
        r.setex(f'user:{identity}', 300, user.to_json())
        return user
        """
    
    # ──────────────────────────────────────────────────────
    # IMPORTER ET ENREGISTRER LES BLUEPRINTS
    # ──────────────────────────────────────────────────────
    
    from app.routes import tasks, categories
    from app.routes import auth  # NOUVEAU : Routes d'authentification
    
    app.register_blueprint(tasks.bp)
    app.register_blueprint(categories.bp)
    app.register_blueprint(auth.bp)  # NOUVEAU
    
    # Gestionnaires d'erreurs globaux
    @app.errorhandler(404)
    def not_found(error):
        return jsonify({
            'error': 'Not Found',
            'message': 'La ressource demandée n\'existe pas'
        }), 404
    
    @app.errorhandler(500)
    def internal_error(error):
        db.session.rollback()
        return jsonify({
            'error': 'Internal Server Error',
            'message': 'Une erreur interne est survenue'
        }), 500
    
    # Route racine
    @app.route('/')
    def index():
        return jsonify({
            'message': 'API TODO List avec JWT',
            'version': '3.0.0',
            'endpoints': {
                'auth': '/api/auth',
                'tasks': '/api/tasks',
                'categories': '/api/categories'
            }
        })
    
    # Route de santé
    @app.route('/health')
    def health():
        try:
            db.session.execute(db.text('SELECT 1'))
            db_status = 'ok'
        except Exception:
            db_status = 'error'
        
        return jsonify({
            'status': 'ok' if db_status == 'ok' else 'degraded',
            'database': db_status,
            'timestamp': datetime.utcnow().isoformat()
        })
    
    return app
```

**Sauvegarde.**

---

### ÉTAPE 7 : Créer les routes d'authentification

**Créer `app/routes/auth.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# ROUTES D'AUTHENTIFICATION
# ═══════════════════════════════════════════════════════════════

from flask import Blueprint, request, jsonify
from marshmallow import ValidationError
from sqlalchemy.exc import IntegrityError
from flask_jwt_extended import (
    create_access_token,
    create_refresh_token,
    jwt_required,
    get_jwt_identity,
    get_jwt,
    current_user
)
from datetime import datetime

from app.models import db, User, TokenBlocklist
from app.auth_schemas import (
    user_schema,
    user_registration_schema,
    user_login_schema,
    change_password_schema
)

bp = Blueprint('auth', __name__, url_prefix='/api/auth')

"""
Blueprint pour l'authentification
Préfixe : /api/auth

Routes :
- POST /api/auth/register : Inscription
- POST /api/auth/login : Connexion
- POST /api/auth/refresh : Rafraîchir le token
- POST /api/auth/logout : Déconnexion
- GET /api/auth/me : Profil utilisateur
- PUT /api/auth/password : Changer mot de passe
"""

# ───────────────────────────────────────────────────────────────
# POST /api/auth/register - INSCRIPTION
# ───────────────────────────────────────────────────────────────

@bp.route('/register', methods=['POST'])
def register():
    """
    Inscrit un nouvel utilisateur
    
    Expected JSON:
        {
            "username": "john_doe",
            "email": "john@example.com",
            "password": "SecurePass123",
            "password_confirm": "SecurePass123",
            "first_name": "John",
            "last_name": "Doe"
        }
        
    Returns:
        JSON: Utilisateur créé + tokens (201)
              ou erreurs de validation (400)
    """
    json_data = request.get_json()
    
    if not json_data:
        return jsonify({'error': 'Aucune donnée fournie'}), 400
    
    # Validation avec marshmallow
    try:
        data = user_registration_schema.load(json_data)
    except ValidationError as err:
        return jsonify({'errors': err.messages}), 400
    
    """
    Validation inclut :
    - Format email
    - Longueur username/password
    - Complexité password
    - Correspondance password == password_confirm
    """
    
    # Vérifier que username n'existe pas
    if User.query.filter_by(username=data['username']).first():
        return jsonify({
            'error': 'Nom d\'utilisateur déjà utilisé'
        }), 400
    
    # Vérifier que email n'existe pas
    if User.query.filter_by(email=data['email'].lower()).first():
        return jsonify({
            'error': 'Email déjà utilisé'
        }), 400
    
    """
    Vérifications d'unicité :
    Avant création pour message clair
    
    Sans ces vérifications :
    IntegrityError -> Message SQL cryptique
    
    email.lower() :
    Normalisation des emails
    john@example.com == John@Example.COM
    """
    
    # Créer l'utilisateur
    user = User(
        username=data['username'],
        email=data['email'].lower(),  # Normaliser
        first_name=data.get('first_name'),
        last_name=data.get('last_name'),
        role='user'  # Rôle par défaut
    )
    
    # Hasher le mot de passe
    user.set_password(data['password'])
    
    """
    set_password() :
    - Génère un salt aléatoire
    - Hash le password avec bcrypt
    - Stocke le hash dans password_hash
    
    Jamais stocker le password en clair !
    """
    
    # Sauvegarder en base
    try:
        db.session.add(user)
        db.session.commit()
    except IntegrityError:
        db.session.rollback()
        return jsonify({
            'error': 'Erreur lors de la création du compte'
        }), 400
    
    # Créer les tokens JWT
    access_token = create_access_token(identity=user)
    refresh_token = create_refresh_token(identity=user)
    
    """
    create_access_token(identity=user) :
    Crée un JWT access token
    
    identity=user :
    Passe par user_identity_loader
    Extrait user.id
    Stocké dans claim 'sub'
    
    Token généré :
    {
        "sub": 42,  # user.id
        "type": "access",
        "jti": "abc-123",
        "exp": 1702746023,  # Now + JWT_ACCESS_TOKEN_EXPIRES
        "iat": 1702745123,
        "fresh": true  # Token obtenu via login
    }
    
    create_refresh_token(identity=user) :
    Identique mais :
    - type: "refresh"
    - exp: Now + JWT_REFRESH_TOKEN_EXPIRES (plus long)
    - Pas de claim "fresh"
    """
    
    # Retourner l'utilisateur + tokens
    return jsonify({
        'message': 'Compte créé avec succès',
        'user': user_schema.dump(user),
        'access_token': access_token,
        'refresh_token': refresh_token
    }), 201
    
    """
    Réponse complète :
    - Message de succès
    - Infos utilisateur (sans password !)
    - access_token : Pour les requêtes API
    - refresh_token : Pour obtenir de nouveaux access tokens
    
    Le client doit :
    1. Stocker les tokens de manière sécurisée
    2. Envoyer access_token dans Authorization header
    3. Utiliser refresh_token quand access expire
    """

# ───────────────────────────────────────────────────────────────
# POST /api/auth/login - CONNEXION
# ───────────────────────────────────────────────────────────────

@bp.route('/login', methods=['POST'])
def login():
    """
    Connecte un utilisateur existant
    
    Expected JSON:
        {
            "username": "john_doe",  # ou email
            "password": "SecurePass123"
        }
        
    Returns:
        JSON: Tokens (200) ou erreur (401)
    """
    json_data = request.get_json()
    
    if not json_data:
        return jsonify({'error': 'Aucune donnée fournie'}), 400
    
    # Validation
    try:
        data = user_login_schema.load(json_data)
    except ValidationError as err:
        return jsonify({'errors': err.messages}), 400
    
    # Rechercher l'utilisateur (username OU email)
    username_or_email = data['username']
    
    user = User.query.filter(
        (User.username == username_or_email) | 
        (User.email == username_or_email.lower())
    ).first()
    
    """
    Recherche flexible :
    Accepte username OU email
    
    (User.username == x) | (User.email == y) :
    Opérateur OR de SQLAlchemy
    
    Équivalent SQL :
    SELECT * FROM users
    WHERE username = ? OR email = ?
    
    Avantage :
    Utilisateur peut se connecter avec ce qu'il préfère
    """
    
    # Vérifier que l'utilisateur existe et mot de passe correct
    if not user or not user.check_password(data['password']):
        return jsonify({
            'error': 'Identifiants incorrects'
        }), 401
    
    """
    Message générique :
    "Identifiants incorrects"
    
    [ATTENTION] Pas de détails :
    - Pas "Utilisateur n'existe pas"
    - Pas "Mot de passe incorrect"
    
    Sécurité :
    Empêche l'énumération d'utilisateurs
    Un attaquant ne peut pas savoir si un username existe
    
    Timing attack :
    En production, toujours vérifier le password même si user n'existe pas :
    
    dummy_hash = '$2b$12$...'  # Hash factice
    if not user:
        bcrypt.checkpw(password, dummy_hash)  # Même temps
        return error
    """
    
    # Vérifier que le compte est actif
    if not user.is_active:
        return jsonify({
            'error': 'Compte désactivé'
        }), 403
    
    """
    403 Forbidden :
    L'utilisateur est authentifié mais n'a pas accès
    
    Différence avec 401 :
    - 401 Unauthorized : Pas/mal authentifié
    - 403 Forbidden : Authentifié mais pas autorisé
    """
    
    # Mettre à jour last_login
    user.last_login = datetime.utcnow()
    db.session.commit()
    
    # Créer les tokens
    access_token = create_access_token(identity=user, fresh=True)
    refresh_token = create_refresh_token(identity=user)
    
    """
    fresh=True :
    Marque le token comme "fresh"
    
    Fresh token :
    Obtenu via login (authentification récente)
    Nécessaire pour actions sensibles
    
    Token contient :
    {
        "fresh": true,
        ...
    }
    """
    
    return jsonify({
        'message': 'Connexion réussie',
        'user': user_schema.dump(user),
        'access_token': access_token,
        'refresh_token': refresh_token
    }), 200

# ───────────────────────────────────────────────────────────────
# POST /api/auth/refresh - RAFRAÎCHIR LE TOKEN
# ───────────────────────────────────────────────────────────────

@bp.route('/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh():
    """
    Génère un nouveau access token à partir d'un refresh token
    
    Headers:
        Authorization: Bearer <refresh_token>
        
    Returns:
        JSON: Nouveau access_token (200)
    """
    # Récupérer l'identité depuis le refresh token
    identity = get_jwt_identity()
    
    """
    get_jwt_identity() :
    Extrait le claim 'sub' du JWT
    
    Retourne ce qui a été passé à create_*_token(identity=...)
    Ici : user.id (int)
    
    [ATTENTION] Fonctionne seulement dans une route @jwt_required()
    """
    
    # Créer un nouveau access token (non-fresh)
    access_token = create_access_token(identity=identity, fresh=False)
    
    """
    fresh=False :
    Token obtenu via refresh (pas login direct)
    
    Ne permet pas les actions sensibles (@jwt_required(fresh=True))
    Utilisateur doit se re-connecter pour actions sensibles
    
    Sécurité :
    Si refresh token volé :
    - Attaquant peut générer access tokens
    - MAIS pas changer password / email
    - Limite les dégâts
    """
    
    return jsonify({
        'access_token': access_token
    }), 200
    
    """
    Flux typique :
    
    1. Login -> access (15min) + refresh (30j)
    2. Requêtes API avec access
    3. Access expire -> 401
    4. Frontend : POST /refresh avec refresh token
    5. Nouveau access (15min)
    6. Reprendre les requêtes
    
    Avantage :
    - Access court = sécurisé
    - Refresh long = UX fluide
    - Pas besoin de re-login toutes les 15 min
    """

# ───────────────────────────────────────────────────────────────
# POST /api/auth/logout - DÉCONNEXION
# ───────────────────────────────────────────────────────────────

@bp.route('/logout', methods=['POST'])
@jwt_required()
def logout():
    """
    Déconnecte l'utilisateur (révoque le token)
    
    Headers:
        Authorization: Bearer <access_token>
        
    Returns:
        JSON: Message de confirmation (200)
    """
    # Récupérer les infos du token
    jwt_data = get_jwt()
    
    """
    get_jwt() :
    Retourne le payload complet du JWT
    
    Contient :
    {
        "fresh": true,
        "iat": 1702745123,
        "jti": "abc-123",
        "type": "access",
        "sub": "42",
        "exp": 1702746023
    }
    """
    
    jti = jwt_data['jti']
    token_type = jwt_data['type']
    user_id = get_jwt_identity()
    expires_at = datetime.fromtimestamp(jwt_data['exp'])
    
    """
    jti : JWT ID (identifiant unique)
    type : "access" ou "refresh"
    exp : Timestamp d'expiration (Unix timestamp)
    
    datetime.fromtimestamp(jwt_data['exp']) :
    Convertit Unix timestamp -> datetime
    1702746023 -> datetime(2024, 12, 16, 16, 47, 3)
    """
    
    # Ajouter le token à la blacklist
    revoked_token = TokenBlocklist(
        jti=jti,
        token_type=token_type,
        user_id=user_id,
        expires_at=expires_at
    )
    
    db.session.add(revoked_token)
    db.session.commit()
    
    """
    Révocation :
    Token ajouté à la blacklist
    
    Le callback check_if_token_revoked() vérifiera :
    1. Extraire jti du token
    2. Chercher dans TokenBlocklist
    3. Si trouvé -> Token révoqué -> 401
    
    Nettoyage :
    Tâche périodique pour supprimer tokens expirés :
    
    DELETE FROM token_blocklist WHERE expires_at < NOW()
    
    Optimisation :
    Utiliser Redis avec TTL :
    r.setex(f'revoked:{jti}', ttl, '1')
    """
    
    return jsonify({
        'message': 'Déconnexion réussie'
    }), 200
    
    """
    Logout côté client :
    1. POST /api/auth/logout
    2. Supprimer les tokens du storage
    3. Rediriger vers /login
    
    [ATTENTION] Avec JWT, le logout est optionnel !
    Le client peut juste supprimer les tokens
    Mais blacklist = sécurité supplémentaire
    """

# ───────────────────────────────────────────────────────────────
# GET /api/auth/me - PROFIL UTILISATEUR
# ───────────────────────────────────────────────────────────────

@bp.route('/me', methods=['GET'])
@jwt_required()
def get_profile():
    """
    Récupère le profil de l'utilisateur connecté
    
    Headers:
        Authorization: Bearer <access_token>
        
    Returns:
        JSON: Profil utilisateur (200)
    """
    # L'utilisateur est chargé automatiquement via user_lookup_callback
    user = current_user
    
    """
    current_user :
    Proxy vers l'utilisateur actuel
    
    Fourni par Flask-JWT-Extended
    Chargé via user_lookup_callback dans __init__.py
    
    Équivalent à :
    user_id = get_jwt_identity()
    user = User.query.get(user_id)
    
    Mais plus simple et automatique !
    """
    
    return jsonify(user_schema.dump(user)), 200

# ───────────────────────────────────────────────────────────────
# PUT /api/auth/me - MODIFIER LE PROFIL
# ───────────────────────────────────────────────────────────────

@bp.route('/me', methods=['PUT'])
@jwt_required()
def update_profile():
    """
    Modifie le profil utilisateur
    
    Expected JSON:
        {
            "first_name": "John",
            "last_name": "Doe",
            "email": "newemail@example.com"
        }
    """
    user = current_user
    json_data = request.get_json()
    
    if not json_data:
        return jsonify({'error': 'Aucune donnée fournie'}), 400
    
    # Champs modifiables
    allowed_fields = ['first_name', 'last_name', 'email']
    
    """
    Whitelist des champs :
    Sécurité : L'utilisateur ne peut PAS modifier :
    - username (identifiant)
    - password (route dédiée)
    - role (élévation de privilèges)
    - is_active (auto-activation)
    """
    
    # Vérifier l'email si modifié
    if 'email' in json_data:
        new_email = json_data['email'].lower()
        
        # Vérifier qu'il n'est pas déjà utilisé
        if new_email != user.email:
            existing = User.query.filter_by(email=new_email).first()
            if existing:
                return jsonify({
                    'error': 'Email déjà utilisé'
                }), 400
    
    # Mettre à jour les champs
    for key in allowed_fields:
        if key in json_data:
            value = json_data[key]
            if key == 'email':
                value = value.lower()
            setattr(user, key, value)
    
    db.session.commit()
    
    return jsonify({
        'message': 'Profil mis à jour',
        'user': user_schema.dump(user)
    }), 200

# ───────────────────────────────────────────────────────────────
# PUT /api/auth/password - CHANGER LE MOT DE PASSE
# ───────────────────────────────────────────────────────────────

@bp.route('/password', methods=['PUT'])
@jwt_required(fresh=True)
def change_password():
    """
    Change le mot de passe utilisateur
    Nécessite un token fresh (connexion récente)
    
    Expected JSON:
        {
            "old_password": "OldPass123",
            "new_password": "NewPass456",
            "new_password_confirm": "NewPass456"
        }
    """
    user = current_user
    json_data = request.get_json()
    
    if not json_data:
        return jsonify({'error': 'Aucune donnée fournie'}), 400
    
    # Validation
    try:
        data = change_password_schema.load(json_data)
    except ValidationError as err:
        return jsonify({'errors': err.messages}), 400
    
    # Vérifier l'ancien mot de passe
    if not user.check_password(data['old_password']):
        return jsonify({
            'error': 'Ancien mot de passe incorrect'
        }), 401
    
    # Changer le mot de passe
    user.set_password(data['new_password'])
    db.session.commit()
    
    """
    Bonne pratique :
    Après changement de password :
    1. Invalider tous les tokens existants
    2. Forcer re-login
    
    Implémentation :
    - Ajouter tous les tokens du user à la blacklist
    - Ou ajouter password_changed_at dans User
    - Vérifier dans JWT callback
    """
    
    # Optionnel : Révoquer tous les autres tokens
    # (À implémenter si nécessaire)
    
    return jsonify({
        'message': 'Mot de passe modifié avec succès'
    }), 200
    
    """
    @jwt_required(fresh=True) :
    Nécessite un token fresh
    
    Si token non-fresh (obtenu via refresh) :
    -> 401 avec message "fresh_token_required"
    
    L'utilisateur doit :
    1. Se re-connecter (POST /login)
    2. Obtenir un token fresh
    3. Changer son password
    
    Sécurité :
    Si token volé :
    - Attaquant peut refresh indéfiniment
    - MAIS ne peut pas changer le password
    - Nécessite le password actuel
    """
```

**Sauvegarde.**

---

Je continue avec la modification des routes existantes pour ajouter la protection JWT, puis les tests. Veux-tu que je continue ?


### ÉTAPE 8 : Modifier les routes des tâches pour ajouter JWT

**Modifier `app/routes/tasks.py` pour ajouter l'authentification :**

```python
# ═══════════════════════════════════════════════════════════════
# ROUTES DES TÂCHES (avec authentification JWT)
# ═══════════════════════════════════════════════════════════════

from flask import Blueprint, request, jsonify
from marshmallow import ValidationError
from sqlalchemy import or_
from flask_jwt_extended import jwt_required, current_user, get_jwt_identity

from app.models import db, Task, Category
from app.schemas import task_schema, tasks_schema

bp = Blueprint('tasks', __name__, url_prefix='/api/tasks')

"""
MODIFICATIONS PRINCIPALES :
1. Toutes les routes nécessitent @jwt_required()
2. Les tâches sont filtrées par utilisateur
3. Vérifications d'autorisation (ownership)
"""

# ───────────────────────────────────────────────────────────────
# GET /api/tasks - LISTE DES TÂCHES DE L'UTILISATEUR
# ───────────────────────────────────────────────────────────────

@bp.route('/', methods=['GET'])
@jwt_required()
def get_tasks():
    """
    Récupère les tâches de l'utilisateur connecté
    
    Headers:
        Authorization: Bearer <access_token>
    
    Query Parameters:
        - completed (bool): Filtrer par statut
        - category_id (int): Filtrer par catégorie
        - priority (int): Filtrer par priorité
        - search (str): Recherche dans titre/description
        - sort_by (str): Champ de tri
        - order (str): asc ou desc
        - page (int): Numéro de page
        - per_page (int): Éléments par page
        
    Returns:
        JSON: Liste paginée des tâches de l'utilisateur
    """
    # Récupérer l'utilisateur actuel
    user = current_user
    
    """
    current_user :
    Utilisateur chargé automatiquement
    
    Grâce à :
    1. @jwt_required() : Vérifie le token
    2. user_lookup_callback : Charge l'utilisateur
    
    user contient toutes les infos :
    user.id, user.username, user.role, etc.
    """
    
    # Commencer la requête - FILTRER PAR UTILISATEUR
    query = Task.query.filter_by(user_id=user.id)
    
    """
    [ATTENTION] SÉCURITÉ CRITIQUE :
    filter_by(user_id=user.id)
    
    Chaque utilisateur voit SEULEMENT ses tâches
    
    Sans ce filtre :
    L'utilisateur verrait TOUTES les tâches de tous les users
    -> Fuite de données !
    
    Règle d'or :
    TOUJOURS filtrer par user_id dans les requêtes multi-tenant
    """
    
    # Filtres (identiques à avant)
    completed = request.args.get('completed')
    if completed is not None:
        completed_bool = completed.lower() in ['true', '1', 'yes']
        query = query.filter_by(completed=completed_bool)
    
    category_id = request.args.get('category_id', type=int)
    if category_id:
        query = query.filter_by(category_id=category_id)
    
    priority = request.args.get('priority', type=int)
    if priority:
        query = query.filter_by(priority=priority)
    
    search = request.args.get('search')
    if search:
        search_pattern = f'%{search}%'
        query = query.filter(
            or_(
                Task.title.ilike(search_pattern),
                Task.description.ilike(search_pattern)
            )
        )
    
    # Tri
    sort_by = request.args.get('sort_by', 'created_at')
    order = request.args.get('order', 'desc')
    
    allowed_sort_fields = ['created_at', 'updated_at', 'title', 'priority', 'due_date']
    if sort_by not in allowed_sort_fields:
        sort_by = 'created_at'
    
    sort_column = getattr(Task, sort_by)
    if order == 'asc':
        query = query.order_by(sort_column.asc())
    else:
        query = query.order_by(sort_column.desc())
    
    # Pagination
    page = request.args.get('page', 1, type=int)
    per_page = request.args.get('per_page', 10, type=int)
    per_page = min(per_page, 100)
    
    paginated_tasks = query.paginate(
        page=page,
        per_page=per_page,
        error_out=False
    )
    
    # Eager loading de la catégorie
    tasks_with_category = Task.query.options(
        db.joinedload(Task.category)
    ).filter(
        Task.id.in_([t.id for t in paginated_tasks.items])
    ).all()
    
    # Mapping
    tasks_dict = {t.id: t for t in tasks_with_category}
    items_with_category = [tasks_dict.get(t.id, t) for t in paginated_tasks.items]
    
    # Sérialiser
    result = tasks_schema.dump(items_with_category)
    
    return jsonify({
        'tasks': result,
        'pagination': {
            'page': paginated_tasks.page,
            'per_page': paginated_tasks.per_page,
            'total_items': paginated_tasks.total,
            'total_pages': paginated_tasks.pages,
            'has_prev': paginated_tasks.has_prev,
            'has_next': paginated_tasks.has_next,
            'prev_page': paginated_tasks.prev_num if paginated_tasks.has_prev else None,
            'next_page': paginated_tasks.next_num if paginated_tasks.has_next else None
        }
    }), 200

# ───────────────────────────────────────────────────────────────
# GET /api/tasks/<id> - TÂCHE SPÉCIFIQUE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:task_id>', methods=['GET'])
@jwt_required()
def get_task(task_id):
    """
    Récupère une tâche spécifique
    Vérifie que l'utilisateur est propriétaire
    """
    user = current_user
    
    # Eager load de la catégorie
    task = Task.query.options(
        db.joinedload(Task.category)
    ).filter_by(id=task_id, user_id=user.id).first()
    
    """
    filter_by(id=task_id, user_id=user.id) :
    Double filtre : ID ET propriétaire
    
    Si task appartient à un autre user :
    -> first() retourne None
    -> 404 (et pas 403)
    
    Sécurité :
    Ne pas révéler l'existence de ressources
    404 = "N'existe pas" (pour toi)
    Pas "Existe mais tu n'as pas accès"
    """
    
    if not task:
        return jsonify({
            'error': 'Tâche non trouvée'
        }), 404
    
    result = task_schema.dump(task)
    return jsonify(result), 200

# ───────────────────────────────────────────────────────────────
# POST /api/tasks - CRÉER UNE TÂCHE
# ───────────────────────────────────────────────────────────────

@bp.route('/', methods=['POST'])
@jwt_required()
def create_task():
    """
    Crée une nouvelle tâche pour l'utilisateur connecté
    """
    user = current_user
    
    json_data = request.get_json()
    if not json_data:
        return jsonify({'error': 'Aucune donnée fournie'}), 400
    
    # Validation
    try:
        data = task_schema.load(json_data)
    except ValidationError as err:
        return jsonify({'errors': err.messages}), 400
    
    # Vérifier que la catégorie existe (si fournie)
    if data.get('category_id'):
        category = Category.query.get(data['category_id'])
        if not category:
            return jsonify({
                'error': 'Catégorie non trouvée',
                'category_id': data['category_id']
            }), 404
    
    # Créer la tâche - ASSIGNER À L'UTILISATEUR
    task = Task(**data, user_id=user.id)
    
    """
    user_id=user.id :
    [ATTENTION] CRITIQUE : Assigner le propriétaire
    
    La tâche appartient à l'utilisateur connecté
    
    Sécurité :
    L'utilisateur ne peut PAS spécifier user_id dans le JSON
    user_id est FORCÉ côté serveur
    
    Sans cela :
    POST {"title": "Hack", "user_id": 999}
    -> Créerait une tâche pour user 999 !
    """
    
    db.session.add(task)
    db.session.commit()
    
    # Recharger avec la catégorie
    task = Task.query.options(db.joinedload(Task.category)).get(task.id)
    
    result = task_schema.dump(task)
    return jsonify(result), 201

# ───────────────────────────────────────────────────────────────
# PUT /api/tasks/<id> - MODIFIER UNE TÂCHE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:task_id>', methods=['PUT'])
@jwt_required()
def update_task(task_id):
    """
    Modifie une tâche
    Vérifie la propriété avant modification
    """
    user = current_user
    
    # Récupérer la tâche (avec vérification propriétaire)
    task = Task.query.filter_by(id=task_id, user_id=user.id).first()
    
    if not task:
        return jsonify({'error': 'Tâche non trouvée'}), 404
    
    """
    Alternative avec méthode d'autorisation :
    
    task = Task.query.get_or_404(task_id)
    
    if not user.can_edit_task(task):
        return jsonify({'error': 'Non autorisé'}), 403
    
    Avantage :
    - Plus explicite (403 vs 404)
    - Gestion des admins (peuvent tout modifier)
    - Logique d'autorisation centralisée
    """
    
    json_data = request.get_json()
    if not json_data:
        return jsonify({'error': 'Aucune donnée fournie'}), 400
    
    # Validation partielle
    try:
        data = task_schema.load(json_data, partial=True)
    except ValidationError as err:
        return jsonify({'errors': err.messages}), 400
    
    # Vérifier la catégorie si modifiée
    if 'category_id' in data and data['category_id']:
        category = Category.query.get(data['category_id'])
        if not category:
            return jsonify({'error': 'Catégorie non trouvée'}), 404
    
    # [ATTENTION] SÉCURITÉ : Empêcher changement de propriétaire
    if 'user_id' in data:
        del data['user_id']
    
    """
    Sécurité :
    Supprimer user_id des données
    
    Empêche :
    PUT /tasks/1 {"user_id": 999}
    -> Transférer sa tâche à quelqu'un d'autre
    
    user_id est IMMUABLE après création
    """
    
    # Mettre à jour
    for key, value in data.items():
        setattr(task, key, value)
    
    db.session.commit()
    
    # Recharger avec la catégorie
    task = Task.query.options(db.joinedload(Task.category)).get(task.id)
    
    result = task_schema.dump(task)
    return jsonify(result), 200

# ───────────────────────────────────────────────────────────────
# DELETE /api/tasks/<id> - SUPPRIMER UNE TÂCHE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:task_id>', methods=['DELETE'])
@jwt_required()
def delete_task(task_id):
    """
    Supprime une tâche
    Vérifie la propriété
    """
    user = current_user
    
    task = Task.query.filter_by(id=task_id, user_id=user.id).first()
    
    if not task:
        return jsonify({'error': 'Tâche non trouvée'}), 404
    
    db.session.delete(task)
    db.session.commit()
    
    return jsonify({
        'message': 'Tâche supprimée avec succès',
        'deleted_id': task_id
    }), 200

# ───────────────────────────────────────────────────────────────
# PATCH /api/tasks/<id>/complete - MARQUER COMME TERMINÉE
# ───────────────────────────────────────────────────────────────

@bp.route('/<int:task_id>/complete', methods=['PATCH'])
@jwt_required()
def complete_task(task_id):
    """
    Marque une tâche comme terminée
    """
    user = current_user
    
    task = Task.query.filter_by(id=task_id, user_id=user.id).first()
    
    if not task:
        return jsonify({'error': 'Tâche non trouvée'}), 404
    
    task.completed = True
    db.session.commit()
    
    result = task_schema.dump(task)
    return jsonify(result), 200

# ───────────────────────────────────────────────────────────────
# GET /api/tasks/stats - STATISTIQUES
# ───────────────────────────────────────────────────────────────

@bp.route('/stats', methods=['GET'])
@jwt_required()
def get_stats():
    """
    Récupère des statistiques sur les tâches de l'utilisateur
    """
    from sqlalchemy import func
    
    user = current_user
    
    # Statistiques globales de l'utilisateur
    total = Task.query.filter_by(user_id=user.id).count()
    completed = Task.query.filter_by(user_id=user.id, completed=True).count()
    pending = total - completed
    
    """
    [ATTENTION] TOUJOURS filtrer par user_id
    
    Sans filtre :
    -> Statistiques de TOUS les utilisateurs
    -> Fuite d'informations
    """
    
    # Statistiques par catégorie (seulement pour cet utilisateur)
    stats_by_category = db.session.query(
        Category.name,
        func.count(Task.id).label('count')
    ).outerjoin(Task).filter(
        Task.user_id == user.id
    ).group_by(Category.id).all()
    
    """
    filter(Task.user_id == user.id) :
    Filtre sur la jointure
    
    Compte seulement les tâches de l'utilisateur
    """
    
    return jsonify({
        'total': total,
        'completed': completed,
        'pending': pending,
        'completion_rate': round((completed / total * 100), 2) if total > 0 else 0,
        'by_category': [
            {'category': name, 'count': count}
            for name, count in stats_by_category
        ]
    }), 200

# ───────────────────────────────────────────────────────────────
# ROUTES ADMIN (BONUS)
# ───────────────────────────────────────────────────────────────

@bp.route('/admin/all', methods=['GET'])
@jwt_required()
def admin_get_all_tasks():
    """
    ADMIN SEULEMENT : Récupère toutes les tâches de tous les utilisateurs
    
    Nécessite le rôle admin
    """
    user = current_user
    
    # Vérifier que l'utilisateur est admin
    if not user.is_admin():
        return jsonify({
            'error': 'Accès refusé',
            'message': 'Cette action nécessite les privilèges admin'
        }), 403
    
    """
    Autorisation basée sur les rôles (RBAC) :
    
    user.is_admin() :
    Vérifie user.role == 'admin'
    
    403 Forbidden :
    Authentifié mais pas autorisé
    
    Pattern :
    1. @jwt_required() : Vérifie authentification
    2. user.is_admin() : Vérifie autorisation
    3. Si non admin : 403
    
    Alternative (décorateur custom) :
    
    def admin_required():
        def wrapper(fn):
            @wraps(fn)
            @jwt_required()
            def decorator(*args, **kwargs):
                if not current_user.is_admin():
                    return jsonify({'error': 'Admin required'}), 403
                return fn(*args, **kwargs)
            return decorator
        return wrapper
    
    Utilisation :
    @bp.route('/admin/all')
    @admin_required()
    def admin_route():
        ...
    """
    
    # Récupérer TOUTES les tâches (pas de filtre user_id)
    tasks = Task.query.options(
        db.joinedload(Task.category),
        db.joinedload(Task.user)
    ).all()
    
    # Sérialiser avec infos utilisateur
    result = [task.to_dict(include_category=True, include_user=True) for task in tasks]
    
    return jsonify({
        'tasks': result,
        'total': len(result)
    }), 200
```

**Sauvegarde.**

---

### ÉTAPE 9 : Modifier les routes des catégories (optionnel)

**Les catégories peuvent rester globales (partagées) ou être par utilisateur.**

**Option 1 : Catégories globales (plus simple)**

Laisser `app/routes/categories.py` inchangé, toujours accessible sans auth.

**Option 2 : Catégories par utilisateur**

Modifier pour ajouter user_id aux catégories (nécessite migration).

**Pour cet exercice, on garde les catégories globales (partagées).**

---

### ÉTAPE 10 : Mettre à jour run.py pour les commandes CLI

**Modifier `run.py` pour ajouter un utilisateur de test :**

```python
# ═══════════════════════════════════════════════════════════════
# POINT D'ENTRÉE (avec commandes JWT)
# ═══════════════════════════════════════════════════════════════

import os
from app import create_app, db
from app.models import Task, Category, User

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

@app.shell_context_processor
def make_shell_context():
    return {
        'db': db,
        'Task': Task,
        'Category': Category,
        'User': User  # Ajouter User
    }

@app.cli.command()
def init_db():
    """
    Initialise la base de données avec des données de test
    """
    print("Création des tables...")
    db.create_all()
    
    print("Ajout de catégories de test...")
    categories = [
        Category(name='Travail', description='Tâches professionnelles'),
        Category(name='Personnel', description='Tâches personnelles'),
        Category(name='Courses', description='Liste de courses'),
        Category(name='Santé', description='Rendez-vous médicaux'),
    ]
    
    for cat in categories:
        db.session.add(cat)
    
    db.session.commit()
    print(f"{len(categories)} catégories ajoutées.")
    
    # ──────────────────────────────────────────────────────
    # AJOUTER DES UTILISATEURS DE TEST
    # ──────────────────────────────────────────────────────
    
    print("Ajout d'utilisateurs de test...")
    
    # Utilisateur normal
    user1 = User(
        username='john_doe',
        email='john@example.com',
        first_name='John',
        last_name='Doe',
        role='user'
    )
    user1.set_password('password123')
    db.session.add(user1)
    
    # Utilisateur admin
    admin = User(
        username='admin',
        email='admin@example.com',
        first_name='Admin',
        last_name='User',
        role='admin'
    )
    admin.set_password('admin123')
    db.session.add(admin)
    
    # Deuxième utilisateur
    user2 = User(
        username='jane_smith',
        email='jane@example.com',
        first_name='Jane',
        last_name='Smith',
        role='user'
    )
    user2.set_password('password123')
    db.session.add(user2)
    
    db.session.commit()
    print("3 utilisateurs ajoutés.")
    print("  - john_doe / password123 (user)")
    print("  - admin / admin123 (admin)")
    print("  - jane_smith / password123 (user)")
    
    # ──────────────────────────────────────────────────────
    # AJOUTER DES TÂCHES POUR CHAQUE UTILISATEUR
    # ──────────────────────────────────────────────────────
    
    print("Ajout de tâches de test...")
    from datetime import datetime, timedelta
    
    # Tâches pour john_doe
    tasks_john = [
        Task(
            title='Préparer la réunion',
            description='Préparer les slides',
            priority=5,
            category_id=1,
            user_id=user1.id,
            due_date=datetime.utcnow() + timedelta(days=2)
        ),
        Task(
            title='Code review',
            description='Réviser le PR #123',
            priority=3,
            category_id=1,
            user_id=user1.id,
            completed=True
        ),
        Task(
            title='Faire les courses',
            description='Acheter du pain, lait, œufs',
            priority=2,
            category_id=3,
            user_id=user1.id,
            due_date=datetime.utcnow() + timedelta(days=1)
        ),
    ]
    
    # Tâches pour jane_smith
    tasks_jane = [
        Task(
            title='Dentiste',
            description='Rendez-vous annuel',
            priority=4,
            category_id=4,
            user_id=user2.id,
            due_date=datetime.utcnow() + timedelta(weeks=2)
        ),
        Task(
            title='Apprendre Flask',
            description='Finir le tutoriel JWT',
            priority=3,
            category_id=2,
            user_id=user2.id
        ),
    ]
    
    for task in tasks_john + tasks_jane:
        db.session.add(task)
    
    db.session.commit()
    print(f"{len(tasks_john + tasks_jane)} tâches ajoutées.")
    
    print("[OK] Base de données initialisée avec succès !")

@app.cli.command()
def reset_db():
    """Supprime et recrée la base de données"""
    import click
    
    if click.confirm('[ATTENTION]  Êtes-vous sûr de vouloir supprimer toutes les données ?'):
        print("Suppression des tables...")
        db.drop_all()
        print("Recréation des tables...")
        db.create_all()
        print("[OK] Base de données réinitialisée.")
    else:
        print("[X] Opération annulée.")

@app.cli.command()
def create_admin():
    """Crée un utilisateur admin"""
    import click
    
    username = click.prompt('Username', default='admin')
    email = click.prompt('Email', default='admin@example.com')
    password = click.prompt('Password', hide_input=True, confirmation_prompt=True)
    
    # Vérifier que l'utilisateur n'existe pas
    if User.query.filter_by(username=username).first():
        print(f"[X] L'utilisateur {username} existe déjà")
        return
    
    if User.query.filter_by(email=email).first():
        print(f"[X] L'email {email} est déjà utilisé")
        return
    
    # Créer l'admin
    admin = User(
        username=username,
        email=email.lower(),
        role='admin'
    )
    admin.set_password(password)
    
    db.session.add(admin)
    db.session.commit()
    
    print(f"[OK] Admin créé : {username} / {email}")

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=5000)
```

**Sauvegarde.**

---

### ÉTAPE 11 : Initialiser la base de données

```bash
# Réinitialiser complètement
flask reset-db
# ou
flask db downgrade base
flask db upgrade

# Initialiser avec données de test
flask init-db
```

**Résultat :**

```
Création des tables...
Ajout de catégories de test...
4 catégories ajoutées.
Ajout d'utilisateurs de test...
3 utilisateurs ajoutés.
  - john_doe / password123 (user)
  - admin / admin123 (admin)
  - jane_smith / password123 (user)
Ajout de tâches de test...
5 tâches ajoutées.
[OK] Base de données initialisée avec succès !
```

---

### ÉTAPE 12 : Tester l'API avec authentification

**Démarrer le serveur :**

```bash
python run.py
```

---

**Test 1 : Inscription**

```bash
curl -X POST http://localhost:5000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alice",
    "email": "alice@example.com",
    "password": "SecurePass123",
    "password_confirm": "SecurePass123",
    "first_name": "Alice",
    "last_name": "Wonderland"
  }'
```

**Résultat :**

```json
{
  "message": "Compte créé avec succès",
  "user": {
    "id": 4,
    "username": "alice",
    "first_name": "Alice",
    "last_name": "Wonderland",
    "role": "user",
    "is_active": true,
    "created_at": "2024-12-16T18:00:00"
  },
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```

**[OK] Utilisateur créé avec tokens JWT !**

---

**Test 2 : Connexion**

```bash
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "john_doe",
    "password": "password123"
  }'
```

**Résultat :**

```json
{
  "message": "Connexion réussie",
  "user": {
    "id": 1,
    "username": "john_doe",
    "first_name": "John",
    "last_name": "Doe",
    "role": "user",
    "is_active": true,
    "created_at": "2024-12-16T17:30:00"
  },
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6dHJ1ZSwiaWF0IjoxNzAyNzQ1MTIzLCJqdGkiOiJhYmMxMjMiLCJ0eXBlIjoiYWNjZXNzIiwic3ViIjoiMSIsIm5iZiI6MTcwMjc0NTEyMywiZXhwIjoxNzAyNzQ2MDIzfQ...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```

**[OK] Connexion réussie !**

**Copier le access_token pour les prochains tests.**

---

**Test 3 : Accéder aux tâches (avec authentification)**

```bash
# Remplacer <ACCESS_TOKEN> par le token reçu
curl -X GET http://localhost:5000/api/tasks \
  -H "Authorization: Bearer <ACCESS_TOKEN>"
```

**Exemple avec token :**

```bash
curl -X GET http://localhost:5000/api/tasks \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```

**Résultat :**

```json
{
  "tasks": [
    {
      "id": 1,
      "title": "Préparer la réunion",
      "description": "Préparer les slides",
      "completed": false,
      "priority": 5,
      "user_id": 1,
      "category": {
        "id": 1,
        "name": "Travail"
      },
      ...
    },
    ...
  ],
  "pagination": {
    "page": 1,
    "per_page": 10,
    "total_items": 3,
    "total_pages": 1,
    ...
  }
}
```

**[OK] Seulement les tâches de john_doe (user_id=1) !**

---

**Test 4 : Accéder sans token (doit échouer)**

```bash
curl -X GET http://localhost:5000/api/tasks
```

**Résultat :**

```json
{
  "message": "Token d'accès manquant",
  "error": "authorization_required"
}
```

**Code de statut : 401 Unauthorized**

**[OK] Protection fonctionne !**

---

**Test 5 : Créer une tâche**

```bash
curl -X POST http://localhost:5000/api/tasks \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Nouvelle tâche",
    "description": "Tâche créée avec JWT",
    "priority": 4,
    "category_id": 2
  }'
```

**Résultat :**

```json
{
  "id": 6,
  "title": "Nouvelle tâche",
  "description": "Tâche créée avec JWT",
  "completed": false,
  "priority": 4,
  "user_id": 1,
  "category_id": 2,
  ...
}
```

**[OK] Tâche créée et assignée automatiquement à john_doe !**

---

**Test 6 : Essayer d'accéder à la tâche d'un autre utilisateur**

```bash
# Se connecter en tant que jane_smith
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "jane_smith",
    "password": "password123"
  }'

# Copier le token de jane_smith
# Essayer d'accéder à la tâche ID 1 (appartient à john_doe)

curl -X GET http://localhost:5000/api/tasks/1 \
  -H "Authorization: Bearer <JANE_TOKEN>"
```

**Résultat :**

```json
{
  "error": "Tâche non trouvée"
}
```

**Code de statut : 404**

**[OK] Isolation des données fonctionnelle !**

---

**Test 7 : Profil utilisateur**

```bash
curl -X GET http://localhost:5000/api/auth/me \
  -H "Authorization: Bearer <ACCESS_TOKEN>"
```

**Résultat :**

```json
{
  "id": 1,
  "username": "john_doe",
  "first_name": "John",
  "last_name": "Doe",
  "role": "user",
  "is_active": true,
  "created_at": "2024-12-16T17:30:00",
  "updated_at": "2024-12-16T18:05:00",
  "last_login": "2024-12-16T18:05:00"
}
```

**[OK] Profil récupéré !**

---

**Test 8 : Rafraîchir le token**

```bash
curl -X POST http://localhost:5000/api/auth/refresh \
  -H "Authorization: Bearer <REFRESH_TOKEN>"
```

**Résultat :**

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTcwMjc0NTk4MywianRpIjoieHl6Nzg5IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6IjEiLCJuYmYiOjE3MDI3NDU5ODMsImV4cCI6MTcwMjc0Njg4M30..."
}
```

**[OK] Nouveau access token généré (non-fresh) !**

---

**Test 9 : Changer le mot de passe (nécessite fresh token)**

```bash
# Essayer avec token non-fresh (du refresh)
curl -X PUT http://localhost:5000/api/auth/password \
  -H "Authorization: Bearer <NON_FRESH_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "old_password": "password123",
    "new_password": "NewSecure456",
    "new_password_confirm": "NewSecure456"
  }'
```

**Résultat :**

```json
{
  "message": "Un token fresh est requis",
  "error": "fresh_token_required"
}
```

**Code de statut : 401**

**[OK] Protection fresh token fonctionne !**

---

**Se reconnecter pour obtenir un fresh token :**

```bash
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "john_doe",
    "password": "password123"
  }'

# Utiliser le nouveau access_token (fresh)
curl -X PUT http://localhost:5000/api/auth/password \
  -H "Authorization: Bearer <FRESH_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "old_password": "password123",
    "new_password": "NewSecure456",
    "new_password_confirm": "NewSecure456"
  }'
```

**Résultat :**

```json
{
  "message": "Mot de passe modifié avec succès"
}
```

**[OK] Mot de passe changé !**

---

**Test 10 : Déconnexion (révocation du token)**

```bash
curl -X POST http://localhost:5000/api/auth/logout \
  -H "Authorization: Bearer <ACCESS_TOKEN>"
```

**Résultat :**

```json
{
  "message": "Déconnexion réussie"
}
```

**Essayer de réutiliser le même token :**

```bash
curl -X GET http://localhost:5000/api/tasks \
  -H "Authorization: Bearer <REVOKED_TOKEN>"
```

**Résultat :**

```json
{
  "message": "Le token a été révoqué",
  "error": "token_revoked"
}
```

**Code de statut : 401**

**[OK] Révocation fonctionne !**

---

**Test 11 : Route admin**

```bash
# Se connecter en tant qu'admin
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "admin",
    "password": "admin123"
  }'

# Accéder à la route admin
curl -X GET http://localhost:5000/api/tasks/admin/all \
  -H "Authorization: Bearer <ADMIN_TOKEN>"
```

**Résultat :**

```json
{
  "tasks": [
    {
      "id": 1,
      "title": "Préparer la réunion",
      "user_id": 1,
      "user": {
        "id": 1,
        "username": "john_doe"
      },
      ...
    },
    {
      "id": 4,
      "title": "Dentiste",
      "user_id": 2,
      "user": {
        "id": 2,
        "username": "jane_smith"
      },
      ...
    },
    ...
  ],
  "total": 6
}
```

**[OK] Admin voit TOUTES les tâches !**

---

**Essayer avec un utilisateur normal :**

```bash
# Token de john_doe
curl -X GET http://localhost:5000/api/tasks/admin/all \
  -H "Authorization: Bearer <USER_TOKEN>"
```

**Résultat :**

```json
{
  "error": "Accès refusé",
  "message": "Cette action nécessite les privilèges admin"
}
```

**Code de statut : 403 Forbidden**

**[OK] Autorisation admin fonctionne !**

---

### ÉTAPE 13 : Tests unitaires

**Créer `tests/test_auth.py` :**

```python
# ═══════════════════════════════════════════════════════════════
# TESTS D'AUTHENTIFICATION
# ═══════════════════════════════════════════════════════════════

import unittest
import json

from app import create_app, db
from app.models import User, Task, Category

class AuthTestCase(unittest.TestCase):
    """Tests pour l'authentification JWT"""
    
    def setUp(self):
        """Exécuté avant chaque test"""
        self.app = create_app('testing')
        self.client = self.app.test_client()
        self.app_context = self.app.app_context()
        self.app_context.push()
        
        db.create_all()
        
        # Créer une catégorie de test
        self.category = Category(name='Test')
        db.session.add(self.category)
        db.session.commit()
        
        # Créer un utilisateur de test
        self.user = User(
            username='testuser',
            email='test@example.com',
            role='user'
        )
        self.user.set_password('TestPass123')
        db.session.add(self.user)
        db.session.commit()
    
    def tearDown(self):
        """Exécuté après chaque test"""
        db.session.remove()
        db.drop_all()
        self.app_context.pop()
    
    def test_register(self):
        """Test : Inscription d'un nouvel utilisateur"""
        response = self.client.post(
            '/api/auth/register',
            data=json.dumps({
                'username': 'newuser',
                'email': 'new@example.com',
                'password': 'SecurePass123',
                'password_confirm': 'SecurePass123'
            }),
            content_type='application/json'
        )
        
        self.assertEqual(response.status_code, 201)
        data = json.loads(response.data)
        
        self.assertIn('access_token', data)
        self.assertIn('refresh_token', data)
        self.assertIn('user', data)
        self.assertEqual(data['user']['username'], 'newuser')
        
        # Vérifier en base
        user = User.query.filter_by(username='newuser').first()
        self.assertIsNotNone(user)
        self.assertEqual(user.email, 'new@example.com')
    
    def test_register_duplicate_username(self):
        """Test : Inscription avec username existant"""
        response = self.client.post(
            '/api/auth/register',
            data=json.dumps({
                'username': 'testuser',  # Déjà existe
                'email': 'another@example.com',
                'password': 'Pass123',
                'password_confirm': 'Pass123'
            }),
            content_type='application/json'
        )
        
        self.assertEqual(response.status_code, 400)
        data = json.loads(response.data)
        self.assertIn('error', data)
    
    def test_register_weak_password(self):
        """Test : Inscription avec mot de passe faible"""
        response = self.client.post(
            '/api/auth/register',
            data=json.dumps({
                'username': 'newuser',
                'email': 'new@example.com',
                'password': 'weak',  # Trop court
                'password_confirm': 'weak'
            }),
            content_type='application/json'
        )
        
        self.assertEqual(response.status_code, 400)
    
    def test_login(self):
        """Test : Connexion réussie"""
        response = self.client.post(
            '/api/auth/login',
            data=json.dumps({
                'username': 'testuser',
                'password': 'TestPass123'
            }),
            content_type='application/json'
        )
        
        self.assertEqual(response.status_code, 200)
        data = json.loads(response.data)
        
        self.assertIn('access_token', data)
        self.assertIn('refresh_token', data)
        self.assertEqual(data['user']['username'], 'testuser')
    
    def test_login_wrong_password(self):
        """Test : Connexion avec mauvais mot de passe"""
        response = self.client.post(
            '/api/auth/login',
            data=json.dumps({
                'username': 'testuser',
                'password': 'WrongPassword'
            }),
            content_type='application/json'
        )
        
        self.assertEqual(response.status_code, 401)
    
    def test_access_protected_route(self):
        """Test : Accès à une route protégée"""
        # Se connecter
        login_response = self.client.post(
            '/api/auth/login',
            data=json.dumps({
                'username': 'testuser',
                'password': 'TestPass123'
            }),
            content_type='application/json'
        )
        
        token = json.loads(login_response.data)['access_token']
        
        # Accéder à une route protégée
        response = self.client.get(
            '/api/tasks',
            headers={'Authorization': f'Bearer {token}'}
        )
        
        self.assertEqual(response.status_code, 200)
    
    def test_access_without_token(self):
        """Test : Accès sans token"""
        response = self.client.get('/api/tasks')
        self.assertEqual(response.status_code, 401)
    
    def test_refresh_token(self):
        """Test : Rafraîchir le token"""
        # Se connecter
        login_response = self.client.post(
            '/api/auth/login',
            data=json.dumps({
                'username': 'testuser',
                'password': 'TestPass123'
            }),
            content_type='application/json'
        )
        
        refresh_token = json.loads(login_response.data)['refresh_token']
        
        # Rafraîchir
        response = self.client.post(
            '/api/auth/refresh',
            headers={'Authorization': f'Bearer {refresh_token}'}
        )
        
        self.assertEqual(response.status_code, 200)
        data = json.loads(response.data)
        self.assertIn('access_token', data)
    
    def test_logout(self):
        """Test : Déconnexion"""
        # Se connecter
        login_response = self.client.post(
            '/api/auth/login',
            data=json.dumps({
                'username': 'testuser',
                'password': 'TestPass123'
            }),
            content_type='application/json'
        )
        
        token = json.loads(login_response.data)['access_token']
        
        # Se déconnecter
        response = self.client.post(
            '/api/auth/logout',
            headers={'Authorization': f'Bearer {token}'}
        )
        
        self.assertEqual(response.status_code, 200)
        
        # Essayer de réutiliser le token
        response = self.client.get(
            '/api/tasks',
            headers={'Authorization': f'Bearer {token}'}
        )
        
        self.assertEqual(response.status_code, 401)
    
    def test_task_isolation(self):
        """Test : Isolation des tâches entre utilisateurs"""
        # Créer un deuxième utilisateur
        user2 = User(username='user2', email='user2@example.com')
        user2.set_password('Pass123')
        db.session.add(user2)
        
        # Créer des tâches pour chaque utilisateur
        task1 = Task(title='Task 1', user_id=self.user.id, category_id=self.category.id)
        task2 = Task(title='Task 2', user_id=user2.id, category_id=self.category.id)
        db.session.add_all([task1, task2])
        db.session.commit()
        
        # Se connecter en tant que user1
        login_response = self.client.post(
            '/api/auth/login',
            data=json.dumps({
                'username': 'testuser',
                'password': 'TestPass123'
            }),
            content_type='application/json'
        )
        
        token = json.loads(login_response.data)['access_token']
        
        # Récupérer les tâches
        response = self.client.get(
            '/api/tasks',
            headers={'Authorization': f'Bearer {token}'}
        )
        
        data = json.loads(response.data)
        
        # User1 doit voir seulement sa tâche
        self.assertEqual(len(data['tasks']), 1)
        self.assertEqual(data['tasks'][0]['title'], 'Task 1')

if __name__ == '__main__':
    unittest.main()
```

**Sauvegarde.**

---

**Exécuter les tests :**

```bash
python -m unittest tests.test_auth -v
```

**Résultat :**

```
test_access_protected_route (tests.test_auth.AuthTestCase) ... ok
test_access_without_token (tests.test_auth.AuthTestCase) ... ok
test_login (tests.test_auth.AuthTestCase) ... ok
test_login_wrong_password (tests.test_auth.AuthTestCase) ... ok
test_logout (tests.test_auth.AuthTestCase) ... ok
test_refresh_token (tests.test_auth.AuthTestCase) ... ok
test_register (tests.test_auth.AuthTestCase) ... ok
test_register_duplicate_username (tests.test_auth.AuthTestCase) ... ok
test_register_weak_password (tests.test_auth.AuthTestCase) ... ok
test_task_isolation (tests.test_auth.AuthTestCase) ... ok

----------------------------------------------------------------------
Ran 10 tests in 2.456s

OK
```

**[OK] Tous les tests passent !**

---

### [OK] TESTS DE VALIDATION

**Authentification :**
- [OK] Inscription avec validation password
- [OK] Login avec username ou email
- [OK] Génération de JWT (access + refresh)
- [OK] Hashage bcrypt sécurisé
- [OK] Tokens stockés et vérifiés

**Autorisation :**
- [OK] Routes protégées par @jwt_required()
- [OK] Isolation des données par user_id
- [OK] Vérification propriétaire avant modification
- [OK] Rôles admin vs user
- [OK] Fresh token pour actions sensibles

**Révocation :**
- [OK] Logout révoque le token (blacklist)
- [OK] Token révoqué refuse l'accès
- [OK] Tokens expirés sont rejetés

**Sécurité :**
- [OK] Passwords jamais en clair
- [OK] user_id forcé côté serveur
- [OK] Validation robuste (email, password)
- [OK] Messages d'erreur génériques (anti-enumeration)

---

### [ROUGE] ERREURS COURANTES ET SOLUTIONS

#### Erreur 1 : "Token has expired"

**Cause : Access token expiré (15 minutes)**

**Solution :**

Utiliser le refresh token pour obtenir un nouveau access token :

```bash
curl -X POST http://localhost:5000/api/auth/refresh \
  -H "Authorization: Bearer <REFRESH_TOKEN>"
```

---

#### Erreur 2 : "Signature verification failed"

**Cause : JWT_SECRET_KEY différente entre génération et vérification**

**Solution :**

Vérifier que JWT_SECRET_KEY est la même :
- Dans config.py
- Dans variables d'environnement
- Entre redémarrages du serveur

---

#### Erreur 3 : "Missing Authorization Header"

**Cause : Header Authorization non envoyé**

**Solution :**

```bash
# [X] Mauvais
curl http://localhost:5000/api/tasks

# [OK] Correct
curl http://localhost:5000/api/tasks \
  -H "Authorization: Bearer <TOKEN>"
```

---

#### Erreur 4 : bcrypt._bcrypt.ffi.error

**Cause : Problème avec bcrypt**

**Solution :**

```bash
pip uninstall bcrypt
pip install bcrypt --no-binary :all:
```

---

#### Erreur 5 : Token révoqué mais toujours accepté

**Cause : Callback check_if_token_revoked pas appelé**

**Solution :**

Vérifier que jwt.init_app(app) est appelé et que le callback est enregistré.

---

### [IMPORTANT] POINTS CLÉS À RETENIR

**1. JWT Structure**
- Header : Algorithme de signature
- Payload : Claims (sub, exp, jti, etc.)
- Signature : Garantit l'intégrité

**2. Access vs Refresh Tokens**
- **Access** : Court (15min), utilisé partout
- **Refresh** : Long (30j), seulement pour refresh

**3. Sécurité des mots de passe**
- Bcrypt avec salt automatique
- Coût configurable (BCRYPT_LOG_ROUNDS)
- Jamais stocker en clair

**4. Protection des routes**
- `@jwt_required()` : Token obligatoire
- `@jwt_required(fresh=True)` : Token fresh nécessaire
- `current_user` : Utilisateur chargé automatiquement

**5. Autorisation**
- Authentification : Qui es-tu ? (JWT)
- Autorisation : Que peux-tu faire ? (Rôles, ownership)

**6. Révocation**
- Blacklist des tokens (TokenBlocklist)
- Callback check_if_token_revoked
- Nettoyage périodique des tokens expirés

**7. Isolation des données**
- TOUJOURS filtrer par user_id
- Vérifier ownership avant modification
- 404 au lieu de 403 (ne pas révéler l'existence)

---

### [RAPIDE] POUR ALLER PLUS LOIN

**1. OAuth 2.0 / OpenID Connect**

Intégrer des providers externes (Google, GitHub) :

```python
from flask_oauthlib.client import OAuth

oauth = OAuth(app)

google = oauth.remote_app(
    'google',
    consumer_key='...',
    consumer_secret='...',
    request_token_params={'scope': 'email'},
    base_url='https://www.googleapis.com/oauth2/v1/',
    request_token_url=None,
    access_token_method='POST',
    access_token_url='https://accounts.google.com/o/oauth2/token',
    authorize_url='https://accounts.google.com/o/oauth2/auth',
)
```

---

**2. Permissions granulaires (RBAC avancé)**

```python
class Permission:
    READ = 0x01
    WRITE = 0x02
    DELETE = 0x04
    ADMIN = 0xFF

class Role(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), unique=True)
    permissions = db.Column(db.Integer, default=0)
    
    def has_permission(self, perm):
        return (self.permissions & perm) == perm

def permission_required(perm):
    def decorator(f):
        @wraps(f)
        @jwt_required()
        def decorated(*args, **kwargs):
            if not current_user.role.has_permission(perm):
                return jsonify({'error': 'Insufficient permissions'}), 403
            return f(*args, **kwargs)
        return decorated
    return decorator

@app.route('/admin')
@permission_required(Permission.ADMIN)
def admin_route():
    ...
```

---

**3. Rate limiting**

Protéger contre les attaques brute force :

```python
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    app,
    key_func=get_remote_address,
    default_limits=["200 per day", "50 per hour"]
)

@bp.route('/login', methods=['POST'])
@limiter.limit("5 per minute")  # Max 5 tentatives/minute
def login():
    ...
```

---

**4. Two-Factor Authentication (2FA)**

```python
import pyotp

# Générer un secret
secret = pyotp.random_base32()
user.totp_secret = secret

# Générer QR code
uri = pyotp.totp.TOTP(secret).provisioning_uri(
    name=user.email,
    issuer_name='MyApp'
)

# Vérifier le code
totp = pyotp.TOTP(user.totp_secret)
if totp.verify(code):
    # Code valide
    ...
```

---

**5. Refresh token rotation**

Nouveau refresh token à chaque utilisation :

```python
@bp.route('/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh():
    identity = get_jwt_identity()
    jti = get_jwt()['jti']
    
    # Révoquer l'ancien refresh token
    revoke_token(jti)
    
    # Créer nouveaux tokens
    new_access = create_access_token(identity=identity, fresh=False)
    new_refresh = create_refresh_token(identity=identity)
    
    return jsonify({
        'access_token': new_access,
        'refresh_token': new_refresh
    })
```

---

**6. Cache Redis pour blacklist**

Plus performant que la base de données :

```python
import redis

r = redis.Redis(host='localhost', port=6379, db=0)

@jwt.token_in_blocklist_loader
def check_if_token_revoked(jwt_header, jwt_payload):
    jti = jwt_payload['jti']
    return r.exists(f'revoked:{jti}')

# Révoquer un token
def revoke_token(jti, expires_in):
    r.setex(f'revoked:{jti}', expires_in, '1')
```

---

**7. Email verification**

```python
from itsdangerous import URLSafeTimedSerializer

s = URLSafeTimedSerializer(app.config['SECRET_KEY'])

# Générer token de vérification
token = s.dumps(user.email, salt='email-confirm')

# Envoyer email avec lien
url = url_for('auth.confirm_email', token=token, _external=True)

# Vérifier le token
@bp.route('/confirm/<token>')
def confirm_email(token):
    try:
        email = s.loads(token, salt='email-confirm', max_age=3600)
    except:
        return 'Invalid/expired token'
    
    user = User.query.filter_by(email=email).first()
    user.is_active = True
    db.session.commit()
    
    return 'Email confirmed!'
```

---

## [COURS] CONCLUSION DE L'EXERCICE 3

**[OK] Félicitations ! Tu maîtrises maintenant l'authentification JWT avec Flask !**

**Ce que tu as appris :**
- Comprendre la structure et le fonctionnement de JWT
- Implémenter un système complet d'authentification
- Hasher des mots de passe avec bcrypt
- Créer access tokens et refresh tokens
- Protéger des routes avec décorateurs
- Gérer l'autorisation (ownership, rôles)
- Révoquer des tokens (blacklist)
- Valider des données sensibles
- Isoler les données entre utilisateurs
- Tester l'authentification

**Compétences acquises :**
- [OK] Flask-JWT-Extended (niveau avancé)
- [OK] Bcrypt (hashage sécurisé)
- [OK] Authentification stateless
- [OK] RBAC (Role-Based Access Control)
- [OK] Sécurité des APIs
- [OK] Validation robuste

**Temps de réalisation :** 3-4 heures

**Prochaines étapes :**
- Exercice 4 : Application web complète (templates, forms, sessions)
- Exercice 5 : API production-ready (logging, monitoring, Docker, CI/CD)

---

**Bravo pour avoir terminé cet exercice complet sur l'authentification JWT ! [BRAVO]**

Tu as maintenant une base solide pour construire des APIs sécurisées et scalables. N'hésite pas à expérimenter avec les concepts avancés mentionnés dans "Pour aller plus loin" !

