# [RAPIDE] FastAPI Fil Rouge — BuildrAPI
## Devenir Expert FastAPI : Du Débutant Absolu au Niveau Professionnel

---

## [OBJECTIF] Le Projet Central : **BuildrAPI**

Tout au long de cette formation, tu vas construire **BuildrAPI** : une API complète de gestion de projets freelance. À la fin, tu auras une vraie application en production avec :

- [UTILISATEUR] Gestion des utilisateurs et authentification JWT
- [DOSSIER] Gestion de projets, clients et tâches
- [ARGENT] Module de facturation avec génération PDF
- [EMAIL] Notifications par e-mail et webhooks
- [RAPIDE] WebSockets pour les mises à jour en temps réel
- [GRAPHIQUE] Tableau de bord avec métriques et reporting
- [DOCKER] Déploiement Docker sur le cloud

> **Pourquoi ce projet ?** BuildrAPI est suffisamment complexe pour couvrir tous les concepts avancés, et suffisamment concret pour être réutilisable dans un vrai portfolio professionnel.

---

## [DOCS] Structure de la Formation

| Fichier | Contenu | Niveau |
|---------|---------|--------|
| `01_PARTIE1_FONDAMENTAUX.md` | Chapitres 1–4 : Bases de FastAPI | [VERT] Débutant |
| `02_PARTIE2_ARCHITECTURE.md` | Chapitres 5–9 : Structure & BDD | [JAUNE] Intermédiaire |
| `03_PARTIE3_SECURITE.md` | Chapitres 10–12 : Auth & Sécurité | [JAUNE] Intermédiaire |
| `04_PARTIE4_PERFORMANCE.md` | Chapitres 13–14 : Async & Cache | [ORANGE] Avancé |
| `05_PARTIE5_COMMUNICATION.md` | Chapitres 15–18 : WebSocket, Upload, Mail | [ORANGE] Avancé |
| `06_PARTIE6_TESTS_DEPLOY.md` | Chapitres 19–22 : Tests, CI/CD, Déploiement | [ORANGE] Avancé |
| `07_PARTIE7_EXPERT.md` | Chapitres 23–26 : Architecture & Scalabilité | [ROUGE] Expert |

---

## [WORLD_MAP] Roadmap du Projet BuildrAPI

```
Semaine 1-2 : Partie 1 -> Premier endpoint, Pydantic, Swagger
Semaine 3-4 : Partie 2 -> Architecture, Base de données, CRUD
Semaine 5-6 : Partie 3 -> Authentification JWT, Rôles
Semaine 7   : Partie 4 -> Async, Redis, Performance
Semaine 8   : Partie 5 -> Uploads, WebSockets, Emails
Semaine 9   : Partie 6 -> Tests, Docker, CI/CD
Semaine 10+ : Partie 7 -> Architecture avancée, Microservices
```

---

## [OUTILS] Prérequis Techniques

Avant de commencer, installe :

```bash
# Python 3.11+
python --version

# pip à jour
pip install --upgrade pip

# Virtualenv recommandé
python -m venv venv
source venv/bin/activate  # Linux/Mac
venv\Scripts\activate     # Windows

# Outils de base
pip install fastapi uvicorn[standard]
```

**Éditeur recommandé :** VS Code avec les extensions Python, REST Client, et SQLite Viewer.

---

## [CONSTRUCTION] Architecture Finale de BuildrAPI

```
buildrapi/
├── app/
│   ├── main.py                  <- Point d'entrée
│   ├── api/
│   │   ├── routes/
│   │   │   ├── auth.py
│   │   │   ├── users.py
│   │   │   ├── projects.py
│   │   │   ├── tasks.py
│   │   │   ├── clients.py
│   │   │   ├── invoices.py
│   │   │   └── websockets.py
│   │   └── dependencies.py
│   ├── core/
│   │   ├── config.py
│   │   ├── security.py
│   │   └── middleware.py
│   ├── db/
│   │   ├── base.py
│   │   ├── session.py
│   │   └── migrations/
│   ├── models/                  <- Modèles SQLAlchemy (tables)
│   │   ├── user.py
│   │   ├── project.py
│   │   ├── task.py
│   │   ├── client.py
│   │   └── invoice.py
│   ├── schemas/                 <- Schémas Pydantic (validation)
│   │   ├── user.py
│   │   ├── project.py
│   │   ├── task.py
│   │   ├── client.py
│   │   └── invoice.py
│   ├── services/                <- Logique métier
│   │   ├── email.py
│   │   ├── pdf.py
│   │   └── notifications.py
│   └── tests/
│       ├── conftest.py
│       ├── test_auth.py
│       ├── test_projects.py
│       └── test_tasks.py
├── .env
├── docker-compose.yml
├── Dockerfile
└── requirements.txt
```

---

## [IDEE] Convention de Lecture

Dans chaque fichier de la formation, tu trouveras :

- [GUIDE] **Explication théorique** — le "pourquoi" avant le "comment"
- [CODE] **Code commenté ligne par ligne** — rien n'est laissé sans explication
- [LOGIQUE] **Point clé** — ce qu'il faut absolument retenir
- [OK] **Bonne pratique** — les habitudes des développeurs professionnels
- [ATTENTION] **Piège classique** — les erreurs que font les débutants
- [OUTIL] **Exercice de complétion** — du code à trous pour pratiquer
- [OBJECTIF] **Mini-projet BuildrAPI** — application directe au projet central

---

> **Commence par `01_PARTIE1_FONDAMENTAUX.md` ->**

# [LIVRE] PARTIE 1 — Fondamentaux de FastAPI
## Chapitres 1 à 4 : Du Zéro à une API Documentée

> **Objectif de cette partie :** Comprendre ce qu'est FastAPI, créer tes premiers endpoints, valider les données automatiquement avec Pydantic, et exploiter la documentation auto-générée. À la fin, tu auras le squelette de BuildrAPI fonctionnel.

---

# Chapitre 1 : Introduction à FastAPI

## 1.1 Qu'est-ce que FastAPI ?

FastAPI est un **framework web Python moderne** créé par Sebastián Ramírez en 2018. Il est conçu pour construire des APIs (Application Programming Interfaces) de façon rapide, robuste et avec peu de code.

Contrairement à un site web classique qui renvoie du HTML, une API renvoie des **données structurées** (JSON), que d'autres applications (applications mobiles, frontends React/Vue, d'autres APIs) peuvent consommer.

### Pourquoi FastAPI est-il si apprécié ?

| Fonctionnalité | Ce que ça signifie concrètement |
|---|---|
| **Rapide à l'exécution** | Basé sur Starlette et Pydantic, FastAPI est l'un des frameworks Python les plus rapides, comparable à Node.js et Go |
| **Rapide à coder** | Tu écris moins de code pour faire plus de choses |
| **Typage Python natif** | Tu utilises les annotations de type Python (`str`, `int`, `List[str]`...) et FastAPI fait le reste |
| **Documentation automatique** | Une interface Swagger UI est générée automatiquement, sans aucun effort |
| **Validation automatique** | Les données entrantes sont vérifiées automatiquement via Pydantic |
| **Asynchrone natif** | Supporte `async/await` pour les opérations longues (base de données, requêtes HTTP) |

### Comparaison avec d'autres frameworks

```
Flask          -> Minimaliste, flexible, mais tu dois tout faire toi-même
                 Pas de validation auto, pas de doc auto, pas d'async natif

Django         -> Très complet (ORM, admin, auth...), mais lourd pour une simple API
                 Convention over configuration : rigide pour les débutants

FastAPI        -> Juste milieu parfait pour les APIs
                 Validation + doc + async out of the box
                 Courbe d'apprentissage douce

Express.js     -> Node.js, donc JavaScript, donc différent écosystème
                 Comparable en vitesse, mais moins de "magie" côté validation
```

---

## 1.2 Installation

```bash
# Crée un dossier pour ton projet
mkdir buildrapi && cd buildrapi

# Crée un environnement virtuel (TOUJOURS faire ça !)
# Un virtualenv isole les dépendances de ton projet des autres projets Python
python -m venv venv

# Active l'environnement virtuel
source venv/bin/activate        # Linux/Mac
# OU
venv\Scripts\activate           # Windows

# Installe FastAPI et Uvicorn
# FastAPI : le framework
# uvicorn[standard] : le serveur ASGI qui exécute ton app
pip install fastapi uvicorn[standard]

# Vérifie l'installation
python -c "import fastapi; print(fastapi.__version__)"
```

> **[LOGIQUE] Point clé — Uvicorn :** FastAPI est un framework ASGI (Asynchronous Server Gateway Interface). Il ne peut pas s'exécuter seul comme un script Python. Il a besoin d'un **serveur ASGI** pour fonctionner. Uvicorn est ce serveur. C'est lui qui reçoit les connexions HTTP et les passe à FastAPI.

---

## 1.3 Ton Premier "Hello World"

Crée le fichier `main.py` :

```python
# main.py

# On importe FastAPI depuis le package fastapi
from fastapi import FastAPI

# On crée une instance de l'application FastAPI
# C'est l'objet central qui gère toutes tes routes
app = FastAPI(
    title="BuildrAPI",           # Titre affiché dans la doc Swagger
    description="API de gestion de projets freelance",  # Description
    version="0.1.0"              # Version de ton API
)

# Le décorateur @app.get("/") dit à FastAPI :
# "Quand quelqu'un fait une requête GET sur l'URL '/', exécute cette fonction"
@app.get("/")
def read_root():
    # On retourne un dictionnaire Python
    # FastAPI le convertit AUTOMATIQUEMENT en JSON
    return {"message": "Bienvenue sur BuildrAPI [RAPIDE]", "status": "ok"}


# @app.get("/health") est une route classique pour vérifier que l'API tourne
# Les load balancers et outils de monitoring appellent souvent /health
@app.get("/health")
def health_check():
    return {"status": "healthy", "version": "0.1.0"}
```

### Lance le serveur :

```bash
uvicorn main:app --reload

# Décryptage de la commande :
# uvicorn          -> le serveur
# main             -> le fichier main.py (sans l'extension)
# :app             -> la variable 'app' dans ce fichier (ton instance FastAPI)
# --reload         -> redémarre automatiquement si tu modifies le code
#                    (NE JAMAIS utiliser en production !)
```

Tu devrais voir :
```
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process
```

Ouvre `http://127.0.0.1:8000` dans ton navigateur -> tu vois `{"message": "Bienvenue sur BuildrAPI [RAPIDE]", "status": "ok"}`.

> [OK] **Bonne pratique :** Utilise `--reload` uniquement en développement. En production, utilise Gunicorn avec des workers Uvicorn (Chapitre 21).

> [ATTENTION] **Piège classique :** Beaucoup de débutants oublient d'activer leur virtualenv avant d'installer les packages. Résultat : les packages s'installent dans Python global et créent des conflits entre projets.

---

## 1.4 Exercice de Complétion — Chapitre 1

Complète le code suivant pour créer une route `/info` qui retourne les informations de base de BuildrAPI :

```python
from fastapi import FastAPI

app = FastAPI(title="BuildrAPI", version="0.1.0")

# TODO : Crée une route GET sur "/info"
# Elle doit retourner un dictionnaire avec :
# - "name" : le nom de l'API
# - "version" : "0.1.0"
# - "author" : ton prénom
# - "technologies" : une liste contenant "FastAPI", "Python", "SQLAlchemy"

@app.___("___")
def ___():
    return {
        "name": ___,
        "version": ___,
        "author": ___,
        "technologies": ___
    }
```

<details>
<summary>[IDEE] Solution</summary>

```python
@app.get("/info")
def get_info():
    return {
        "name": "BuildrAPI",
        "version": "0.1.0",
        "author": "Ton Prénom",
        "technologies": ["FastAPI", "Python", "SQLAlchemy"]
    }
```
</details>

---

# Chapitre 2 : Routes et Méthodes HTTP

## 2.1 Les Méthodes HTTP — Le Vocabulaire du Web

HTTP (HyperText Transfer Protocol) est le protocole utilisé par le web. Chaque requête a une **méthode** qui indique l'intention :

| Méthode | Usage conventionnel | Exemple BuildrAPI |
|---------|--------------------|--------------------|
| `GET` | Lire des données | Récupérer la liste des projets |
| `POST` | Créer une ressource | Créer un nouveau projet |
| `PUT` | Remplacer complètement | Remplacer toutes les infos d'un projet |
| `PATCH` | Modifier partiellement | Changer seulement le statut d'un projet |
| `DELETE` | Supprimer | Supprimer un projet |

> **[LOGIQUE] Point clé — REST :** Ces conventions font partie du style architectural **REST** (REpresentational State Transfer). Une API qui suit ces conventions est dite "RESTful". FastAPI facilite naturellement la création d'APIs RESTful.

---

## 2.2 Types de Paramètres dans FastAPI

FastAPI distingue **3 types de paramètres** selon leur emplacement dans la requête :

### A) Paramètre de chemin (Path Parameter)

```python
# L'accolade {project_id} dans l'URL indique un paramètre de chemin
# FastAPI extrait automatiquement la valeur et la passe à la fonction
@app.get("/projects/{project_id}")
def get_project(project_id: int):  # Le type 'int' force la conversion automatique
    # Si l'utilisateur met /projects/abc, FastAPI renvoie une erreur 422
    # Car "abc" ne peut pas être converti en int
    return {"project_id": project_id, "name": "Site e-commerce"}

# URL : GET /projects/42  -> project_id = 42 (int)
# URL : GET /projects/abc -> Erreur 422 Unprocessable Entity
```

### B) Paramètre de requête (Query Parameter)

```python
# Les paramètres de query sont dans l'URL après le "?"
# Ex : /projects?status=active&page=1&limit=10
@app.get("/projects")
def list_projects(
    status: str = None,   # Optionnel, défaut None si absent
    page: int = 1,        # Optionnel, défaut 1
    limit: int = 10       # Optionnel, défaut 10
):
    return {
        "status_filter": status,
        "page": page,
        "limit": limit,
        "projects": []  # On remplira ça avec la vraie DB plus tard
    }

# GET /projects                       -> status=None, page=1, limit=10
# GET /projects?status=active         -> status="active", page=1, limit=10
# GET /projects?page=2&limit=5        -> status=None, page=2, limit=5
```

### C) Corps de la requête (Request Body)

```python
# Le body est envoyé dans le corps de la requête HTTP (POST, PUT, PATCH)
# On le définit avec Pydantic (voir Chapitre 3)
# Pour l'instant, utilisons juste un dict basique

from fastapi import FastAPI, Body

@app.post("/projects")
def create_project(
    # Body() indique que ce paramètre vient du corps de la requête
    name: str = Body(...),         # ... signifie "obligatoire"
    description: str = Body(None)  # None = optionnel
):
    return {"message": f"Projet '{name}' créé avec succès"}
```

---

## 2.3 Exemple Complet : Routes CRUD de Base

```python
# Dans main.py — Version améliorée

from fastapi import FastAPI, HTTPException, Body
from typing import Optional, List

app = FastAPI(title="BuildrAPI", version="0.1.0")

# Simulation d'une "base de données" en mémoire
# (On remplacera ça par une vraie DB au Chapitre 6)
fake_projects_db = [
    {"id": 1, "name": "Site vitrine", "status": "active", "client": "Dupont SARL"},
    {"id": 2, "name": "App mobile", "status": "pending", "client": "StartupXYZ"},
]


# ─────────────────────────────────────────────
# GET /projects -> Liste tous les projets
# ─────────────────────────────────────────────
@app.get("/projects")
def list_projects(
    status: Optional[str] = None,  # Filtre optionnel par statut
    limit: int = 10,               # Nombre max de résultats
    offset: int = 0                # Décalage pour la pagination
):
    results = fake_projects_db

    # Si un filtre de statut est fourni, on filtre
    if status:
        results = [p for p in results if p["status"] == status]

    # Pagination manuelle
    return results[offset : offset + limit]


# ─────────────────────────────────────────────
# GET /projects/{project_id} -> Un projet spécifique
# ─────────────────────────────────────────────
@app.get("/projects/{project_id}")
def get_project(project_id: int):
    # On cherche le projet dans notre "base de données"
    for project in fake_projects_db:
        if project["id"] == project_id:
            return project

    # Si non trouvé, on lève une exception HTTP
    # HTTPException génère une réponse d'erreur avec le bon code HTTP
    raise HTTPException(
        status_code=404,
        detail=f"Projet avec l'id {project_id} introuvable"
    )


# ─────────────────────────────────────────────
# POST /projects -> Créer un projet
# ─────────────────────────────────────────────
@app.post("/projects", status_code=201)  # 201 = Created
def create_project(
    name: str = Body(..., min_length=3, max_length=100),
    client: str = Body(...),
    status: str = Body("pending")
):
    new_id = max(p["id"] for p in fake_projects_db) + 1
    new_project = {"id": new_id, "name": name, "client": client, "status": status}
    fake_projects_db.append(new_project)
    return new_project


# ─────────────────────────────────────────────
# DELETE /projects/{project_id} -> Supprimer
# ─────────────────────────────────────────────
@app.delete("/projects/{project_id}", status_code=204)  # 204 = No Content
def delete_project(project_id: int):
    for i, project in enumerate(fake_projects_db):
        if project["id"] == project_id:
            fake_projects_db.pop(i)
            return  # 204 ne renvoie pas de body

    raise HTTPException(status_code=404, detail="Projet introuvable")
```

> [OK] **Bonne pratique :** Utilise toujours le bon code de statut HTTP. `201` pour une création, `204` pour une suppression sans contenu de réponse, `404` pour "non trouvé", `422` pour données invalides.

> [ATTENTION] **Piège classique :** Ne pas confondre paramètre de chemin et paramètre de requête. `/projects/{id}` vs `/projects?id=1` — ce sont deux choses très différentes.

---

## 2.4 Exercice de Complétion — Chapitre 2

Complète ces routes pour la gestion des **clients** dans BuildrAPI :

```python
# Liste de clients simulée
fake_clients_db = [
    {"id": 1, "name": "Dupont SARL", "email": "contact@dupont.fr", "active": True},
    {"id": 2, "name": "StartupXYZ", "email": "hello@startupxyz.com", "active": False},
]

# TODO 1 : Route GET /clients qui retourne tous les clients actifs par défaut
# Paramètre optionnel 'include_inactive' (bool, défaut False)
@app.___("/clients")
def list_clients(include_inactive: ___ = ___):
    if include_inactive:
        return ___
    return [c for c in fake_clients_db if ___]


# TODO 2 : Route GET /clients/{client_id}
# Si non trouvé, lever une HTTPException 404
@app.get("___")
def get_client(___: int):
    for client in ___:
        if client["id"] == ___:
            return ___
    raise ___(status_code=___, detail="Client introuvable")


# TODO 3 : Route POST /clients avec status_code 201
# Paramètres body : name (obligatoire), email (obligatoire)
@app.___(___,  status_code=___)
def create_client(name: str = Body(___), email: str = Body(___)):
    new_client = {"id": len(fake_clients_db) + 1, "name": name, "email": email, "active": True}
    ___.append(new_client)
    return ___
```

<details>
<summary>[IDEE] Solution</summary>

```python
@app.get("/clients")
def list_clients(include_inactive: bool = False):
    if include_inactive:
        return fake_clients_db
    return [c for c in fake_clients_db if c["active"]]

@app.get("/clients/{client_id}")
def get_client(client_id: int):
    for client in fake_clients_db:
        if client["id"] == client_id:
            return client
    raise HTTPException(status_code=404, detail="Client introuvable")

@app.post("/clients", status_code=201)
def create_client(name: str = Body(...), email: str = Body(...)):
    new_client = {"id": len(fake_clients_db) + 1, "name": name, "email": email, "active": True}
    fake_clients_db.append(new_client)
    return new_client
```
</details>

---

# Chapitre 3 : Typage et Validation Automatique avec Pydantic

## 3.1 Le Problème que Pydantic Résout

Sans validation, voici ce qui peut arriver :

```python
# Sans Pydantic — DANGEREUX
@app.post("/projects")
def create_project(data: dict):
    # L'utilisateur peut envoyer n'importe quoi...
    # Pas de nom ? Plante en production.
    # Un prix négatif ? Données corrompues.
    # Un email invalide ? Problème d'envoi de mails.
    save_to_db(data)  # Risque d'injection, données incohérentes...
```

Pydantic résout ça en définissant **exactement** la forme que les données doivent avoir.

---

## 3.2 Qu'est-ce que Pydantic ?

**Pydantic** est une bibliothèque de validation de données basée sur les annotations de type Python. Elle est installée automatiquement avec FastAPI.

Son principe : tu définis une **classe** héritant de `BaseModel`, tu déclares les champs avec leurs types et contraintes, et Pydantic s'occupe de tout le reste.

```python
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum

# ─────────────────────────────────────────────
# Définition d'un schéma Pydantic
# ─────────────────────────────────────────────

# On peut utiliser des Enum pour des valeurs limitées
class ProjectStatus(str, Enum):
    PENDING = "pending"      # En attente
    ACTIVE = "active"        # En cours
    COMPLETED = "completed"  # Terminé
    CANCELLED = "cancelled"  # Annulé


class ProjectCreate(BaseModel):
    """Schéma pour créer un nouveau projet."""

    # str avec contraintes de longueur
    name: str = Field(
        ...,                    # "..." = champ obligatoire
        min_length=3,
        max_length=100,
        description="Nom du projet",
        example="Site e-commerce Dupont"
    )

    # Champ optionnel (peut être None)
    description: Optional[str] = Field(
        None,                   # None = valeur par défaut si absent
        max_length=500,
        description="Description détaillée du projet"
    )

    # On utilise notre Enum pour limiter les valeurs possibles
    status: ProjectStatus = Field(
        default=ProjectStatus.PENDING,
        description="Statut du projet"
    )

    # float avec contrainte de valeur minimale
    budget: float = Field(
        ...,
        ge=0,                   # ge = greater or equal = >= 0
        description="Budget en euros"
    )

    # Date de livraison prévue
    deadline: Optional[datetime] = None
```

---

## 3.3 Les Contraintes Pydantic — Référence Complète

```python
from pydantic import BaseModel, Field, validator, EmailStr
from typing import Optional, List

class ClientCreate(BaseModel):
    # ── Contraintes sur les chaînes ──
    name: str = Field(..., min_length=2, max_length=100)
    email: EmailStr  # Validation de format email automatique (pip install pydantic[email])

    # ── Contraintes numériques ──
    # ge = >= (greater or equal)
    # gt = >  (greater than)
    # le = <= (less or equal)
    # lt = <  (less than)
    hourly_rate: float = Field(..., ge=0, le=1000, description="Tarif horaire")
    priority: int = Field(1, ge=1, le=5)  # Priorité de 1 à 5

    # ── Valeur avec regex ──
    phone: Optional[str] = Field(None, regex=r"^\+?[0-9\s\-]{8,15}$")

    # ── Validateur personnalisé ──
    @validator("name")
    def name_must_not_be_empty(cls, v):
        # cls = la classe elle-même (pas l'instance)
        # v = la valeur du champ
        if v.strip() == "":
            raise ValueError("Le nom ne peut pas être vide ou juste des espaces")
        return v.strip()  # On retourne la valeur nettoyée
```

---

## 3.4 Schémas pour BuildrAPI — La Vraie Application

Voici comment on structure les schémas Pydantic dans un vrai projet :

```python
# schemas/project.py — Dans le vrai projet BuildrAPI

from pydantic import BaseModel, Field, validator
from typing import Optional, List
from datetime import datetime
from enum import Enum


class ProjectStatus(str, Enum):
    PENDING = "pending"
    ACTIVE = "active"
    COMPLETED = "completed"
    CANCELLED = "cancelled"


# ─── Schéma de BASE (champs communs) ───────────────────────────────────
class ProjectBase(BaseModel):
    """Champs partagés entre création et affichage."""
    name: str = Field(..., min_length=3, max_length=100)
    description: Optional[str] = Field(None, max_length=1000)
    status: ProjectStatus = ProjectStatus.PENDING
    budget: float = Field(..., ge=0)
    deadline: Optional[datetime] = None


# ─── Schéma de CRÉATION (données que l'utilisateur envoie) ─────────────
class ProjectCreate(ProjectBase):
    """Utilisé quand l'utilisateur crée un projet (POST /projects)."""
    client_id: int = Field(..., description="ID du client associé")
    # Pas d'id, pas de created_at : c'est le serveur qui les génère


# ─── Schéma de MISE À JOUR (tous les champs optionnels) ────────────────
class ProjectUpdate(BaseModel):
    """Utilisé pour la mise à jour partielle (PATCH /projects/{id})."""
    name: Optional[str] = Field(None, min_length=3, max_length=100)
    description: Optional[str] = None
    status: Optional[ProjectStatus] = None
    budget: Optional[float] = Field(None, ge=0)
    deadline: Optional[datetime] = None
    # Pour PATCH : TOUS les champs sont Optional car on ne veut modifier que ce qui est fourni


# ─── Schéma de RÉPONSE (ce qu'on renvoie à l'utilisateur) ──────────────
class ProjectResponse(ProjectBase):
    """Ce que l'API renvoie. Inclut les champs générés par le serveur."""
    id: int
    client_id: int
    created_at: datetime
    updated_at: datetime

    class Config:
        # Permet à Pydantic de lire les données depuis un ORM (SQLAlchemy)
        # au lieu d'un simple dictionnaire
        orm_mode = True
        # (En Pydantic v2 : from_attributes = True)
```

### Utilisation dans les routes :

```python
from fastapi import FastAPI

app = FastAPI()

# ProjectCreate est utilisé comme paramètre de body
# FastAPI comprend automatiquement que tout vient du body (pas de Body(...) nécessaire)
@app.post("/projects", response_model=ProjectResponse, status_code=201)
def create_project(project: ProjectCreate):
    # 'project' est déjà validé et typé par Pydantic
    # project.name est un str garanti, project.budget est un float >= 0 garanti
    # Si les données ne correspondent pas, FastAPI renvoie une erreur 422 AUTOMATIQUEMENT

    # Simulation de sauvegarde en DB
    saved = {
        "id": 1,
        **project.dict(),      # Convertit le modèle Pydantic en dict
        "created_at": datetime.now(),
        "updated_at": datetime.now()
    }
    return saved


# response_model=ProjectResponse dit à FastAPI :
# 1. Filtre la réponse pour n'inclure que les champs de ProjectResponse
# 2. Valide que la réponse correspond bien au schéma
# 3. Affiche le schéma correct dans la doc Swagger
```

> [LOGIQUE] **Point clé :** Le pattern **3 schémas** (`Base`, `Create`, `Response`) est une convention professionnelle. Elle permet de contrôler exactement ce que l'utilisateur peut envoyer, et ce que l'API renvoie. Par exemple, `id` et `created_at` ne sont jamais dans `Create` (l'utilisateur ne choisit pas son ID), mais toujours dans `Response`.

> [OK] **Bonne pratique :** Toujours utiliser `response_model=` dans tes routes. Ça agit comme un filtre de sécurité : si tu retournes accidentellement un mot de passe haché, `response_model` l'empêchera d'apparaître dans la réponse si ce champ n'est pas dans le schéma de réponse.

---

## 3.5 Exercice de Complétion — Chapitre 3

Crée les schémas Pydantic pour les **tâches** (tasks) de BuildrAPI :

```python
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from enum import Enum

# TODO 1 : Crée un Enum TaskPriority avec LOW, MEDIUM, HIGH, URGENT
class TaskPriority(str, Enum):
    LOW = ___
    MEDIUM = ___
    HIGH = ___
    URGENT = ___


# TODO 2 : Crée TaskBase avec :
# - title : str, obligatoire, 3-200 caractères
# - description : str optionnel
# - priority : TaskPriority, défaut MEDIUM
# - estimated_hours : float optionnel, minimum 0
class TaskBase(BaseModel):
    title: ___ = Field(___, min_length=___, max_length=___)
    description: ___ = None
    priority: ___ = ___
    estimated_hours: ___ = Field(___, ge=___)


# TODO 3 : TaskCreate hérite de TaskBase et ajoute project_id (int, obligatoire)
class TaskCreate(___):
    project_id: ___ = Field(..., description="___")


# TODO 4 : TaskResponse hérite de TaskBase et ajoute id, project_id, created_at
class TaskResponse(___):
    id: ___
    project_id: ___
    created_at: ___

    class Config:
        orm_mode = ___
```

<details>
<summary>[IDEE] Solution</summary>

```python
class TaskPriority(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    URGENT = "urgent"

class TaskBase(BaseModel):
    title: str = Field(..., min_length=3, max_length=200)
    description: Optional[str] = None
    priority: TaskPriority = TaskPriority.MEDIUM
    estimated_hours: Optional[float] = Field(None, ge=0)

class TaskCreate(TaskBase):
    project_id: int = Field(..., description="ID du projet parent")

class TaskResponse(TaskBase):
    id: int
    project_id: int
    created_at: datetime

    class Config:
        orm_mode = True
```
</details>

---

# Chapitre 4 : Documentation Automatique

## 4.1 Swagger UI et ReDoc — Magie Out of the Box

FastAPI génère automatiquement deux interfaces de documentation :

- **Swagger UI** -> `http://localhost:8000/docs`
  - Interactive : tu peux tester les endpoints directement dans le navigateur
  - Basée sur la norme OpenAPI 3.0

- **ReDoc** -> `http://localhost:8000/redoc`
  - Plus lisible, idéale pour la documentation publique
  - Affiche les schémas de façon claire

**OpenAPI JSON** -> `http://localhost:8000/openapi.json`
- Le fichier de spec brut, utilisé par des outils comme Postman

---

## 4.2 Personnaliser la Documentation

```python
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi

app = FastAPI(
    title="BuildrAPI",
    description="""
## [RAPIDE] API de Gestion de Projets Freelance

BuildrAPI permet de gérer l'ensemble de votre activité freelance :

- **Projets** : création, suivi, facturation
- **Clients** : base de données et historique
- **Tâches** : gestion du temps et des priorités
- **Factures** : génération automatique en PDF

### Authentification
Utilisez le bouton **Authorize** avec votre token JWT.
""",
    version="1.0.0",
    contact={
        "name": "Support BuildrAPI",
        "email": "support@buildrapi.dev"
    },
    license_info={
        "name": "MIT License",
        "url": "https://opensource.org/licenses/MIT"
    }
)
```

---

## 4.3 Tags — Organiser les Routes dans Swagger

```python
from fastapi import FastAPI

# Les tags regroupent les routes visuellement dans Swagger
# On les définit au niveau de l'app pour avoir un ordre et des descriptions
app = FastAPI(
    openapi_tags=[
        {"name": "auth", "description": "Authentification et gestion des tokens"},
        {"name": "users", "description": "Gestion des utilisateurs"},
        {"name": "projects", "description": "Gestion des projets freelance"},
        {"name": "clients", "description": "Base de données clients"},
        {"name": "tasks", "description": "Gestion des tâches et du temps"},
        {"name": "invoices", "description": "Facturation et comptabilité"},
    ]
)


# On associe chaque route à un tag
@app.get(
    "/projects",
    tags=["projects"],                    # Tag Swagger
    summary="Liste des projets",          # Titre court dans Swagger
    description="""
Retourne la liste paginée de tous les projets.

Filtres disponibles :
- `status` : filtre par statut (pending, active, completed, cancelled)
- `client_id` : filtre par client
- `page` et `limit` : pagination
""",                                      # Description longue
    response_description="Liste paginée des projets"  # Description de la réponse
)
def list_projects():
    return []
```

---

## 4.4 Documenter les Modèles avec des Exemples

```python
from pydantic import BaseModel, Field
from typing import Optional

class ProjectCreate(BaseModel):
    name: str = Field(
        ...,
        example="Site e-commerce Dupont SARL",  # Exemple dans Swagger
        description="Nom unique du projet"
    )
    budget: float = Field(
        ...,
        example=5000.0,
        description="Budget total en euros HT"
    )

    class Config:
        # Exemple complet pour le body dans Swagger
        schema_extra = {
            "example": {
                "name": "Site e-commerce Dupont SARL",
                "description": "Refonte complète du site avec Shopify",
                "status": "pending",
                "budget": 5000.0,
                "client_id": 1
            }
        }
```

---

## 4.5 [OBJECTIF] Mini-Projet BuildrAPI — Fin de Partie 1

À ce stade, tu dois avoir un fichier `main.py` avec :

```python
from fastapi import FastAPI, HTTPException, Body
from pydantic import BaseModel, Field, EmailStr
from typing import Optional, List
from datetime import datetime
from enum import Enum

# ────────────────────────────────────────────────────────────────────────
# APPLICATION
# ────────────────────────────────────────────────────────────────────────
app = FastAPI(
    title="BuildrAPI",
    description="API de gestion de projets freelance",
    version="0.1.0",
    openapi_tags=[
        {"name": "projects", "description": "Gestion des projets"},
        {"name": "clients", "description": "Gestion des clients"},
        {"name": "tasks", "description": "Gestion des tâches"},
    ]
)


# ────────────────────────────────────────────────────────────────────────
# SCHÉMAS PYDANTIC
# ────────────────────────────────────────────────────────────────────────
class ProjectStatus(str, Enum):
    PENDING = "pending"
    ACTIVE = "active"
    COMPLETED = "completed"

class ProjectCreate(BaseModel):
    name: str = Field(..., min_length=3, max_length=100)
    status: ProjectStatus = ProjectStatus.PENDING
    budget: float = Field(..., ge=0)
    client_id: int

    class Config:
        schema_extra = {
            "example": {
                "name": "Site e-commerce",
                "status": "pending",
                "budget": 3000.0,
                "client_id": 1
            }
        }


# ────────────────────────────────────────────────────────────────────────
# STOCKAGE EN MÉMOIRE (temporaire, remplacé par DB au Chapitre 6)
# ────────────────────────────────────────────────────────────────────────
projects_db = []
clients_db = [{"id": 1, "name": "Dupont SARL", "email": "contact@dupont.fr"}]
next_id = {"projects": 1, "clients": 2}


# ────────────────────────────────────────────────────────────────────────
# ROUTES
# ────────────────────────────────────────────────────────────────────────
@app.get("/", tags=["root"])
def root():
    return {"message": "BuildrAPI v0.1.0 [RAPIDE]", "docs": "/docs"}

@app.get("/projects", tags=["projects"], summary="Lister tous les projets")
def list_projects(status: Optional[ProjectStatus] = None):
    if status:
        return [p for p in projects_db if p["status"] == status.value]
    return projects_db

@app.post("/projects", tags=["projects"], status_code=201, summary="Créer un projet")
def create_project(project: ProjectCreate):
    new_project = {
        "id": next_id["projects"],
        **project.dict(),
        "created_at": datetime.now().isoformat()
    }
    projects_db.append(new_project)
    next_id["projects"] += 1
    return new_project

@app.get("/projects/{project_id}", tags=["projects"])
def get_project(project_id: int):
    for p in projects_db:
        if p["id"] == project_id:
            return p
    raise HTTPException(status_code=404, detail=f"Projet {project_id} introuvable")
```

**Lance et teste :**
1. `uvicorn main:app --reload`
2. Ouvre `http://localhost:8000/docs`
3. Essaie de créer un projet via l'interface Swagger
4. Essaie d'envoyer un budget négatif -> observe l'erreur 422

---

## 4.6 Exercice Final — Partie 1

**Objectif :** Ajoute à ton `main.py` la gestion complète des tâches.

```python
# TODO : Implémente les 4 routes suivantes pour les tâches

# 1. GET /tasks -> Liste toutes les tâches
#    Paramètre optionnel : project_id (filtre par projet)

# 2. POST /tasks -> Créer une tâche
#    Body : title (str, obligatoire), project_id (int, obligatoire),
#           estimated_hours (float optionnel, >= 0)

# 3. GET /tasks/{task_id} -> Une tâche spécifique
#    404 si introuvable

# 4. DELETE /tasks/{task_id} -> Supprimer une tâche
#    204 si succès, 404 si introuvable

# Toutes les routes doivent avoir le tag "tasks"
# Toutes les routes doivent avoir un summary en français
```

---

> **[BRAVO] Félicitations !** Tu as terminé la Partie 1.
> Tu sais créer des endpoints, valider des données avec Pydantic, et exploiter la documentation Swagger.
>
> **-> Prochaine étape : `02_PARTIE2_ARCHITECTURE.md`** — Structure du projet, base de données, CRUD complet.

# [LIVRE] PARTIE 2 — Architecture & Base de Données
## Chapitres 5 à 9 : Structure Modulaire, SQL, CRUD et Gestion des Erreurs

> **Objectif :** Passer d'un `main.py` monolithique à un projet professionnel modulaire, connecté à une vraie base de données PostgreSQL. À la fin, BuildrAPI aura une architecture scalable et une couche de données persistante.

---

# Chapitre 5 : Structuration du Projet

## 5.1 Pourquoi Structurer son Projet ?

Un `main.py` avec 500 lignes, c'est un cauchemar à maintenir. Imagine une équipe de 3 développeurs qui modifient tous le même fichier simultanément.

Le principe fondamental : **1 fichier = 1 responsabilité**. C'est le principe SRP (Single Responsibility Principle).

```
[X] MAUVAISE architecture (tout dans main.py) :
main.py (500 lignes) -> routes + schémas + DB + logique métier + sécurité

[OK] BONNE architecture (modulaire) :
main.py          -> Configuration centrale, assemblage des modules
api/routes/      -> Uniquement les routes HTTP
schemas/         -> Uniquement la validation Pydantic
models/          -> Uniquement les modèles de base de données
services/        -> Uniquement la logique métier
core/            -> Config, sécurité, middleware
```

---

## 5.2 Créer l'Architecture de BuildrAPI

```bash
# Commandes pour créer toute la structure
mkdir -p buildrapi/app/{api/routes,core,db,models,schemas,services,tests}
cd buildrapi

touch app/main.py
touch app/api/__init__.py
touch app/api/routes/__init__.py
touch app/api/routes/{auth,users,projects,tasks,clients,invoices}.py
touch app/api/dependencies.py
touch app/core/{config,security,middleware}.py
touch app/db/{base,session}.py
touch app/models/{user,project,task,client,invoice}.py
touch app/schemas/{user,project,task,client,invoice}.py
touch app/services/{email,pdf,notifications}.py
touch app/tests/{conftest,test_projects,test_auth}.py
touch .env requirements.txt
```

---

## 5.3 APIRouter — Décomposer les Routes

`APIRouter` est comme un mini-FastAPI. Il permet de définir des routes dans un fichier séparé, puis de les "brancher" sur l'application principale.

```python
# app/api/routes/projects.py

from fastapi import APIRouter, HTTPException, Depends
from typing import List, Optional

# Crée un router dédié aux projets
# prefix = toutes les routes auront /projects/ devant
# tags = groupement dans Swagger
router = APIRouter(
    prefix="/projects",
    tags=["projects"],
    responses={
        404: {"description": "Projet non trouvé"},
        422: {"description": "Données invalides"}
    }
)

# Maintenant on écrit @router.get au lieu de @app.get
@router.get("/", summary="Lister les projets")
def list_projects():
    return []

@router.get("/{project_id}", summary="Obtenir un projet")
def get_project(project_id: int):
    raise HTTPException(status_code=404, detail="Projet introuvable")

@router.post("/", status_code=201, summary="Créer un projet")
def create_project():
    return {}
```

```python
# app/api/routes/clients.py — Même principe

from fastapi import APIRouter

router = APIRouter(
    prefix="/clients",
    tags=["clients"]
)

@router.get("/")
def list_clients():
    return []
```

---

## 5.4 Le main.py Final — Point d'Assemblage

```python
# app/main.py

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

# Import de tous les routers
from app.api.routes import projects, clients, tasks, auth, users, invoices
from app.core.config import settings

# ────────────────────────────────────────────────────────────────────────
# CRÉATION DE L'APPLICATION
# ────────────────────────────────────────────────────────────────────────
app = FastAPI(
    title=settings.APP_NAME,
    description=settings.APP_DESCRIPTION,
    version=settings.APP_VERSION,
    docs_url="/docs",          # URL de Swagger UI
    redoc_url="/redoc",        # URL de ReDoc
    openapi_url="/openapi.json"
)

# ────────────────────────────────────────────────────────────────────────
# MIDDLEWARE (ordre important : s'exécutent dans l'ordre inverse d'ajout)
# ────────────────────────────────────────────────────────────────────────
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.ALLOWED_ORIGINS,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ────────────────────────────────────────────────────────────────────────
# INCLUSION DES ROUTERS
# ────────────────────────────────────────────────────────────────────────
# On "branche" chaque router sur l'app
# Le prefix "/api/v1" est ajouté devant tous les préfixes des routers
app.include_router(auth.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
app.include_router(projects.router, prefix="/api/v1")
app.include_router(clients.router, prefix="/api/v1")
app.include_router(tasks.router, prefix="/api/v1")
app.include_router(invoices.router, prefix="/api/v1")
# Résultat : /api/v1/projects, /api/v1/clients, etc.

# ────────────────────────────────────────────────────────────────────────
# EVENTS DE CYCLE DE VIE
# ────────────────────────────────────────────────────────────────────────
@app.on_event("startup")
async def startup_event():
    """Exécuté au démarrage de l'application."""
    print("[RAPIDE] BuildrAPI démarré")
    # Ici on connecterait la base de données

@app.on_event("shutdown")
async def shutdown_event():
    """Exécuté à l'arrêt de l'application."""
    print("[WAVING_HAND_SIGN] BuildrAPI arrêté")
    # Ici on fermerait la connexion DB

# ────────────────────────────────────────────────────────────────────────
# ROUTE RACINE
# ────────────────────────────────────────────────────────────────────────
@app.get("/", tags=["root"], include_in_schema=False)
def root():
    return {
        "app": "BuildrAPI",
        "version": settings.APP_VERSION,
        "docs": "/docs"
    }
```

---

## 5.5 Fichier de Configuration Central

```python
# app/core/config.py

from pydantic import BaseSettings
from typing import List
import os

class Settings(BaseSettings):
    """
    Toute la configuration de l'app en un seul endroit.
    Les valeurs sont lues depuis les variables d'environnement
    ou depuis le fichier .env
    """
    # Infos de l'application
    APP_NAME: str = "BuildrAPI"
    APP_DESCRIPTION: str = "API de gestion de projets freelance"
    APP_VERSION: str = "1.0.0"
    ENV: str = "development"  # development, staging, production

    # Base de données
    DATABASE_URL: str = "sqlite:///./buildrapi.db"  # SQLite pour dev

    # Sécurité JWT
    SECRET_KEY: str = "change-this-in-production-use-openssl-rand-hex-32"
    ALGORITHM: str = "HS256"
    ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
    REFRESH_TOKEN_EXPIRE_DAYS: int = 7

    # CORS
    ALLOWED_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080"]

    # Email
    SMTP_HOST: str = "smtp.gmail.com"
    SMTP_PORT: int = 587
    SMTP_USER: str = ""
    SMTP_PASSWORD: str = ""

    class Config:
        # Lit automatiquement le fichier .env
        env_file = ".env"
        case_sensitive = True  # DATABASE_URL ≠ database_url

# Instance unique partagée dans toute l'app (Singleton pattern)
settings = Settings()
```

```ini
# .env — NE JAMAIS committer ce fichier !
# Ajoutez .env à votre .gitignore

APP_NAME=BuildrAPI
ENV=development
DATABASE_URL=postgresql+asyncpg://user:password@localhost/buildrapi
SECRET_KEY=votre-cle-secrete-ultra-longue-generee-avec-openssl
SMTP_USER=votre@email.com
SMTP_PASSWORD=votre-mot-de-passe
```

> [OK] **Bonne pratique :** Ajoute TOUJOURS `.env` à ton `.gitignore`. Ne commite jamais de secrets. Utilise des valeurs par défaut sûres en dev, mais impose les vraies valeurs en production.

---

## 5.6 Exercice de Complétion — Chapitre 5

Crée le router pour les **tâches** (`tasks.py`) en suivant le même pattern que `projects.py` :

```python
# app/api/routes/tasks.py

from fastapi import APIRouter, ___
from typing import ___

router = ___(
    prefix=___,
    tags=___,
)

# TODO 1 : GET /tasks/ — liste toutes les tâches
# Paramètre optionnel : project_id (int) pour filtrer par projet
@router.___(
    "/",
    summary="___"
)
def list_tasks(project_id: ___ = None):
    return []

# TODO 2 : GET /tasks/{task_id} — une tâche spécifique
@router.get("/{task_id}")
def get_task(task_id: ___):
    raise ___(status_code=___, detail="Tâche introuvable")

# TODO 3 : POST /tasks/ — créer une tâche
# status_code 201
@router.___("/", status_code=___)
def create_task():
    return {}

# TODO 4 : DELETE /tasks/{task_id} — supprimer, retourner 204
@router.delete("/{task_id}", status_code=___)
def delete_task(task_id: int):
    pass  # 204 = pas de body de retour
```

---

# Chapitre 6 : Base de Données avec SQLAlchemy

## 6.1 ORM — Qu'est-ce que c'est ?

Un **ORM** (Object-Relational Mapper) est une bibliothèque qui fait la traduction entre le monde Python (classes, objets) et le monde SQL (tables, lignes).

```python
# Sans ORM -> SQL brut
cursor.execute("INSERT INTO projects (name, budget) VALUES (?, ?)", ("Mon projet", 5000))

# Avec ORM -> Python pur
db.add(Project(name="Mon projet", budget=5000))
db.commit()
```

L'ORM choisi pour BuildrAPI : **SQLAlchemy** (le standard de l'industrie Python).

---

## 6.2 Installation

```bash
pip install sqlalchemy alembic psycopg2-binary
# sqlalchemy -> l'ORM
# alembic    -> gestion des migrations de schéma DB
# psycopg2   -> driver PostgreSQL

# Pour SQLite en dev (pas besoin de driver supplémentaire)
# SQLite est inclus dans Python
```

---

## 6.3 Connexion à la Base de Données

```python
# app/db/session.py

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings

# ─── Création du moteur ───────────────────────────────────────────────────
# Le moteur est la connexion à la base de données
# check_same_thread=False nécessaire uniquement pour SQLite
engine = create_engine(
    settings.DATABASE_URL,
    connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
)

# ─── Session Factory ──────────────────────────────────────────────────────
# SessionLocal est une "usine à sessions"
# Chaque requête HTTP aura sa propre session DB
SessionLocal = sessionmaker(
    autocommit=False,  # On gère les commits manuellement
    autoflush=False,   # On gère les flushes manuellement
    bind=engine        # Liée à notre moteur
)

# ─── Base pour les modèles ────────────────────────────────────────────────
# Tous les modèles ORM héritent de Base
Base = declarative_base()


# ─── Dépendance FastAPI pour la session DB ────────────────────────────────
def get_db():
    """
    Générateur de session DB pour FastAPI.
    Utilisé comme dépendance dans les routes.

    Le yield crée un pattern "setup / teardown" :
    - Avant yield : ouvre la session
    - yield db    : donne la session à la route
    - Après yield : ferme la session (même en cas d'erreur)
    """
    db = SessionLocal()
    try:
        yield db  # Donne la session à la fonction qui en a besoin
    finally:
        db.close()  # TOUJOURS fermer, même si une exception est levée
```

---

## 6.4 Modèles SQLAlchemy — Tables en Python

```python
# app/models/project.py

from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.session import Base
import enum


class ProjectStatus(str, enum.Enum):
    PENDING = "pending"
    ACTIVE = "active"
    COMPLETED = "completed"
    CANCELLED = "cancelled"


class Project(Base):
    """
    Modèle ORM correspondant à la table 'projects' en base de données.
    Chaque attribut de classe = une colonne de la table.
    """
    __tablename__ = "projects"  # Nom de la table SQL

    # ─── Colonnes ─────────────────────────────────────────────────────────
    id = Column(
        Integer,
        primary_key=True,  # Clé primaire, auto-incrémentée
        index=True         # Index pour les recherches rapides par id
    )
    name = Column(
        String(100),       # VARCHAR(100)
        nullable=False,    # NOT NULL
        index=True
    )
    description = Column(String(1000), nullable=True)  # Peut être NULL
    status = Column(
        SAEnum(ProjectStatus),
        default=ProjectStatus.PENDING,
        nullable=False
    )
    budget = Column(Float, nullable=False, default=0.0)
    deadline = Column(DateTime, nullable=True)

    # Clé étrangère vers la table 'clients'
    client_id = Column(Integer, ForeignKey("clients.id"), nullable=False)

    # Clé étrangère vers la table 'users' (propriétaire du projet)
    owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)

    # ─── Timestamps automatiques ───────────────────────────────────────────
    # server_default=func.now() -> la DB gère la date automatiquement
    created_at = Column(DateTime, server_default=func.now(), nullable=False)
    # onupdate=func.now() -> mis à jour automatiquement à chaque modification
    updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())

    # ─── Relations ORM ────────────────────────────────────────────────────
    # "back_populates" crée une relation bidirectionnelle
    # client.projects -> liste des projets du client
    # project.client  -> le client du projet
    client = relationship("Client", back_populates="projects")
    owner = relationship("User", back_populates="projects")
    tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
    invoices = relationship("Invoice", back_populates="project")
```

```python
# app/models/client.py

from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.session import Base


class Client(Base):
    __tablename__ = "clients"

    id = Column(Integer, primary_key=True, index=True)
    name = Column(String(100), nullable=False)
    email = Column(String(255), unique=True, nullable=False, index=True)
    phone = Column(String(20), nullable=True)
    company = Column(String(100), nullable=True)
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime, server_default=func.now())

    # Relation inverse : un client a plusieurs projets
    projects = relationship("Project", back_populates="client")
```

---

## 6.5 Initialisation de la Base de Données

```python
# app/db/base.py
# Ce fichier importe tous les modèles pour qu'Alembic les détecte

from app.db.session import Base  # noqa
# Importe tous les modèles ici
from app.models.user import User  # noqa
from app.models.project import Project  # noqa
from app.models.client import Client  # noqa
from app.models.task import Task  # noqa
from app.models.invoice import Invoice  # noqa
```

```python
# Script à exécuter une fois pour créer les tables
# create_tables.py (à la racine du projet)

from app.db.session import engine
import app.db.base  # noqa — importe tous les modèles

# Crée toutes les tables définies dans les modèles
app.db.base.Base.metadata.create_all(bind=engine)
print("[OK] Tables créées avec succès")
```

```bash
# Lance le script
python create_tables.py
```

> [LOGIQUE] **Point clé — Modèle vs Schéma :** Dans BuildrAPI, on a deux types de classes séparées :
> - **Modèles** (`models/`) -> Décrivent la structure de la base de données (tables SQL). Héritent de `Base`.
> - **Schémas** (`schemas/`) -> Décrivent la forme des données échangées via l'API (validation). Héritent de `BaseModel`.
> Ces deux mondes communiquent mais restent **séparés**. C'est une bonne pratique cruciale.

---

# Chapitre 7 : CRUD Complet

## 7.1 Les Fonctions CRUD — La Couche "Service"

On crée des fonctions dédiées aux opérations DB, dans `services/` ou parfois dans un dossier `crud/`. Ça évite de mettre la logique DB directement dans les routes.

```python
# app/services/project_service.py

from sqlalchemy.orm import Session
from sqlalchemy import and_
from fastapi import HTTPException
from typing import List, Optional

from app.models.project import Project
from app.schemas.project import ProjectCreate, ProjectUpdate


def get_project(db: Session, project_id: int) -> Project:
    """Récupère un projet par son ID ou lève une 404."""
    project = db.query(Project).filter(Project.id == project_id).first()
    if not project:
        raise HTTPException(
            status_code=404,
            detail=f"Projet avec l'id {project_id} introuvable"
        )
    return project


def list_projects(
    db: Session,
    owner_id: int,
    status: Optional[str] = None,
    skip: int = 0,
    limit: int = 10
) -> List[Project]:
    """Liste les projets avec filtres et pagination."""
    # On commence par filtrer par propriétaire
    query = db.query(Project).filter(Project.owner_id == owner_id)

    # Filtre optionnel par statut
    if status:
        query = query.filter(Project.status == status)

    # Tri par date de création (les plus récents d'abord)
    query = query.order_by(Project.created_at.desc())

    # Pagination
    return query.offset(skip).limit(limit).all()


def create_project(db: Session, project_in: ProjectCreate, owner_id: int) -> Project:
    """Crée un nouveau projet."""
    # Convertit le schéma Pydantic en dict
    project_data = project_in.dict()

    # Crée l'objet ORM
    db_project = Project(**project_data, owner_id=owner_id)

    # Ajoute à la session (pas encore en DB)
    db.add(db_project)

    # Envoie les changements en DB dans une transaction
    db.commit()

    # Recharge l'objet depuis la DB (pour avoir id, created_at, etc.)
    db.refresh(db_project)

    return db_project


def update_project(
    db: Session,
    project_id: int,
    project_update: ProjectUpdate,
    owner_id: int
) -> Project:
    """Met à jour un projet (PATCH — mise à jour partielle)."""
    # Récupère le projet (lève 404 si introuvable)
    db_project = get_project(db, project_id)

    # Vérifie que l'utilisateur est bien le propriétaire
    if db_project.owner_id != owner_id:
        raise HTTPException(
            status_code=403,
            detail="Vous n'avez pas la permission de modifier ce projet"
        )

    # exclude_unset=True -> n'inclut que les champs réellement envoyés
    # Si l'utilisateur envoie {"status": "active"}, seul status sera mis à jour
    update_data = project_update.dict(exclude_unset=True)

    for field, value in update_data.items():
        setattr(db_project, field, value)  # Équivalent de db_project.field = value

    db.commit()
    db.refresh(db_project)
    return db_project


def delete_project(db: Session, project_id: int, owner_id: int) -> None:
    """Supprime un projet."""
    db_project = get_project(db, project_id)

    if db_project.owner_id != owner_id:
        raise HTTPException(status_code=403, detail="Permission refusée")

    db.delete(db_project)
    db.commit()
```

---

## 7.2 Routes CRUD Complètes avec Dépendances DB

```python
# app/api/routes/projects.py — Version complète

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional

from app.db.session import get_db
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
from app.services import project_service

router = APIRouter(prefix="/projects", tags=["projects"])


@router.get("/", response_model=List[ProjectResponse])
def list_projects(
    status: Optional[str] = Query(None, description="Filtrer par statut"),
    page: int = Query(1, ge=1, description="Numéro de page"),
    limit: int = Query(10, ge=1, le=100, description="Résultats par page"),
    db: Session = Depends(get_db)  # <- Injection de dépendance DB
    # current_user = Depends(get_current_user) <- On ajoutera ça au Chapitre 10
):
    """Liste les projets avec pagination et filtres optionnels."""
    skip = (page - 1) * limit  # Calcul de l'offset
    projects = project_service.list_projects(
        db=db,
        owner_id=1,  # Temporaire, remplacé par current_user.id au Chapitre 10
        status=status,
        skip=skip,
        limit=limit
    )
    return projects


@router.get("/{project_id}", response_model=ProjectResponse)
def get_project(
    project_id: int,
    db: Session = Depends(get_db)
):
    """Récupère un projet par son ID."""
    return project_service.get_project(db, project_id)


@router.post("/", response_model=ProjectResponse, status_code=201)
def create_project(
    project_in: ProjectCreate,
    db: Session = Depends(get_db)
):
    """Crée un nouveau projet."""
    return project_service.create_project(db, project_in, owner_id=1)


@router.patch("/{project_id}", response_model=ProjectResponse)
def update_project(
    project_id: int,
    project_update: ProjectUpdate,
    db: Session = Depends(get_db)
):
    """Met à jour partiellement un projet."""
    return project_service.update_project(db, project_id, project_update, owner_id=1)


@router.delete("/{project_id}", status_code=204)
def delete_project(
    project_id: int,
    db: Session = Depends(get_db)
):
    """Supprime un projet définitivement."""
    project_service.delete_project(db, project_id, owner_id=1)
    # Retourner None avec status_code=204 (pas de body)
```

> [LOGIQUE] **Point clé — `Depends(get_db)`:** C'est le système d'**injection de dépendances** de FastAPI. Au lieu de créer une session DB manuellement dans chaque route, on déclare `db: Session = Depends(get_db)` et FastAPI s'occupe de :
> 1. Appeler `get_db()` au début de la requête
> 2. Passer la session à la route
> 3. Appeler le `finally: db.close()` à la fin, même en cas d'erreur

---

## 7.3 Exercice de Complétion — Chapitre 7

Implémente le service CRUD pour les **clients** :

```python
# app/services/client_service.py

from sqlalchemy.orm import Session
from fastapi import HTTPException
from typing import List, Optional
from app.models.client import Client
from app.schemas.client import ClientCreate, ClientUpdate

# TODO 1 : get_client(db, client_id) -> retourne le client ou 404
def get_client(db: ___, client_id: ___) -> ___:
    client = db.query(___).filter(___.id == ___).first()
    if not ___:
        raise HTTPException(status_code=___, detail="Client introuvable")
    return client


# TODO 2 : list_clients(db, skip, limit, active_only)
# Si active_only=True, filtre is_active=True
def list_clients(
    db: Session,
    skip: int = 0,
    limit: int = 10,
    active_only: bool = True
) -> List[Client]:
    query = db.query(___)
    if ___:
        query = query.filter(___.is_active == True)
    return query.offset(___).limit(___).all()


# TODO 3 : create_client(db, client_in)
# Vérifier si email déjà utilisé -> HTTPException 400
def create_client(db: Session, client_in: ClientCreate) -> Client:
    # Vérification email unique
    existing = db.query(Client).filter(Client.email == ___).first()
    if ___:
        raise HTTPException(status_code=___, detail="Email déjà utilisé")

    db_client = Client(**___.dict())
    db.add(___)
    db.___()
    db.refresh(___)
    return ___


# TODO 4 : delete_client(db, client_id)
def delete_client(db: ___, client_id: ___) -> None:
    client = get_client(___, ___)
    db.delete(___)
    db.commit()
```

---

# Chapitre 8 : Gestion des Erreurs et Exceptions

## 8.1 Les Types d'Erreurs dans FastAPI

FastAPI gère plusieurs types d'erreurs :

```python
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
import logging

app = FastAPI()

# ─── 1. HTTPException — Erreurs métier explicites ─────────────────────────
# Tu lèves ces erreurs toi-même dans le code
@app.get("/projects/{id}")
def get_project(id: int):
    if id <= 0:
        raise HTTPException(
            status_code=400,
            detail="L'ID doit être positif",
            headers={"X-Error": "invalid-id"}  # En-têtes optionnels
        )
    raise HTTPException(status_code=404, detail="Projet introuvable")

# ─── 2. RequestValidationError — Erreurs de validation Pydantic ───────────
# FastAPI les lève automatiquement quand les données sont invalides
# Par défaut, renvoie un JSON avec tous les détails d'erreur
# On peut les personnaliser :

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    """Personnalise le format des erreurs de validation."""
    errors = []
    for error in exc.errors():
        errors.append({
            "field": ".".join(str(loc) for loc in error["loc"]),
            "message": error["msg"],
            "type": error["type"]
        })

    return JSONResponse(
        status_code=422,
        content={
            "error": "Données invalides",
            "details": errors
        }
    )


# ─── 3. Gestionnaire Global d'Exceptions ─────────────────────────────────
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    """
    Capture toutes les exceptions non gérées.
    IMPORTANT : Ne jamais exposer les détails en production !
    """
    # Log l'erreur pour les développeurs
    logging.error(f"Exception non gérée : {exc}", exc_info=True)

    # Réponse générique pour l'utilisateur
    return JSONResponse(
        status_code=500,
        content={
            "error": "Une erreur interne est survenue",
            "request_id": request.headers.get("X-Request-ID")
            # Pas de détails techniques exposés à l'utilisateur !
        }
    )
```

---

## 8.2 Exceptions Métier Personnalisées

```python
# app/core/exceptions.py

from fastapi import HTTPException


class BuildrAPIException(HTTPException):
    """Exception de base pour BuildrAPI."""
    def __init__(self, status_code: int, detail: str, error_code: str = None):
        super().__init__(status_code=status_code, detail=detail)
        self.error_code = error_code


class ProjectNotFoundException(BuildrAPIException):
    def __init__(self, project_id: int):
        super().__init__(
            status_code=404,
            detail=f"Projet #{project_id} introuvable",
            error_code="PROJECT_NOT_FOUND"
        )


class UnauthorizedException(BuildrAPIException):
    def __init__(self):
        super().__init__(
            status_code=403,
            detail="Vous n'avez pas la permission d'effectuer cette action",
            error_code="UNAUTHORIZED"
        )


class DuplicateEmailException(BuildrAPIException):
    def __init__(self, email: str):
        super().__init__(
            status_code=400,
            detail=f"L'email '{email}' est déjà utilisé",
            error_code="DUPLICATE_EMAIL"
        )
```

---

## 8.3 Modèles de Réponse d'Erreur Standardisés

```python
# app/schemas/error.py

from pydantic import BaseModel
from typing import Optional, List

class ErrorDetail(BaseModel):
    """Détail d'une erreur de validation."""
    field: str
    message: str

class ErrorResponse(BaseModel):
    """Format standard des erreurs dans BuildrAPI."""
    error: str               # Message d'erreur court
    error_code: Optional[str] = None  # Code d'erreur machine
    details: Optional[List[ErrorDetail]] = None  # Détails de validation
    request_id: Optional[str] = None  # ID de requête pour le support

# Utilisation dans les routes :
# @router.get("/{id}", responses={404: {"model": ErrorResponse}})
```

> [OK] **Bonne pratique :** Ne jamais exposer les stack traces ou les erreurs SQL en production. Un attaquant qui voit `ERROR: relation "users" does not exist` sait que tu utilises PostgreSQL et le nom de ta table. Log en interne, réponse générique en externe.

---

# Chapitre 9 : Middleware

## 9.1 Qu'est-ce qu'un Middleware ?

Un middleware est un **intercepteur** qui se place entre la requête entrante et la route qui la traite (et entre la réponse et le client). C'est comme des filtres que chaque requête doit traverser.

```
Client -> [CORS Middleware] -> [Logging Middleware] -> [Auth Middleware] -> Route
Client <- [CORS Middleware] <- [Logging Middleware] <- [Auth Middleware] <- Réponse
```

---

## 9.2 Middleware de Logging des Requêtes

```python
# app/core/middleware.py

import time
import uuid
import logging
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware

logger = logging.getLogger(__name__)


class RequestLoggingMiddleware(BaseHTTPMiddleware):
    """
    Middleware qui log chaque requête avec :
    - Méthode et chemin
    - Durée de traitement
    - Code de réponse
    - ID unique de requête (utile pour le debug)
    """
    async def dispatch(self, request: Request, call_next):
        # ─── AVANT la route ──────────────────────────────────────────────
        request_id = str(uuid.uuid4())[:8]  # ID court pour les logs
        start_time = time.time()

        # Ajoute l'ID à l'état de la requête pour y accéder partout
        request.state.request_id = request_id

        logger.info(
            f"[{request_id}] -> {request.method} {request.url.path}"
        )

        # ─── PENDANT la route ──────────────────────────────────────────────
        response: Response = await call_next(request)

        # ─── APRÈS la route ───────────────────────────────────────────────
        duration = round((time.time() - start_time) * 1000, 2)  # en ms

        logger.info(
            f"[{request_id}] <- {response.status_code} "
            f"({duration}ms) {request.method} {request.url.path}"
        )

        # Ajoute des en-têtes utiles à la réponse
        response.headers["X-Request-ID"] = request_id
        response.headers["X-Process-Time"] = f"{duration}ms"

        return response
```

```python
# Dans app/main.py — Ajout du middleware

from app.core.middleware import RequestLoggingMiddleware

app.add_middleware(RequestLoggingMiddleware)

# CORS doit être ajouté EN DERNIER (s'exécute en premier)
app.add_middleware(CORSMiddleware, ...)
```

---

## 9.3 Exercice de Complétion — Chapitre 9

Crée un middleware qui ajoute un **rate limiting basique** (limite à 100 requêtes par minute par IP) :

```python
# app/core/middleware.py — À compléter

import time
from collections import defaultdict
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

class RateLimitMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
        super().__init__(app)
        self.max_requests = max_requests
        self.window_seconds = window_seconds
        # Dictionnaire {ip: [timestamp1, timestamp2, ...]}
        self._requests = defaultdict(___)  # TODO : quelle structure ?

    async def dispatch(self, request: Request, call_next):
        # TODO 1 : Récupère l'IP du client
        client_ip = request.___.get("X-Forwarded-For", request.client.___)

        # TODO 2 : Obtiens l'heure actuelle
        now = ___.___.()

        # TODO 3 : Filtre les requêtes dans la fenêtre de temps
        # Garde seulement les timestamps > now - window_seconds
        self._requests[client_ip] = [
            ts for ts in self._requests[client_ip]
            if ___ > ___ - ___
        ]

        # TODO 4 : Si trop de requêtes, retourne 429
        if len(self._requests[client_ip]) >= ___:
            return JSONResponse(
                status_code=___,
                content={"error": "Trop de requêtes. Réessayez dans 60 secondes."}
            )

        # TODO 5 : Ajoute le timestamp actuel et continue
        self._requests[client_ip].___(now)
        response = await ___(request)
        return response
```

---

## 9.4 [OBJECTIF] Mini-Projet BuildrAPI — Fin de Partie 2

À ce stade, ton projet BuildrAPI doit avoir :

```
[OK] Architecture modulaire avec APIRouter
[OK] Fichier de configuration centralisé (.env)
[OK] Connexion SQLite (dev) avec SQLAlchemy
[OK] Modèles ORM : Project, Client, User
[OK] Schémas Pydantic : ProjectCreate, ProjectResponse, etc.
[OK] Services CRUD : project_service.py, client_service.py
[OK] Routes CRUD complètes pour /projects et /clients
[OK] Gestionnaire d'erreurs global
[OK] Middleware de logging
```

**Test final :**
```bash
# Lance le projet
uvicorn app.main:app --reload

# Crée un client
curl -X POST http://localhost:8000/api/v1/clients \
  -H "Content-Type: application/json" \
  -d '{"name": "Dupont SARL", "email": "contact@dupont.fr"}'

# Crée un projet
curl -X POST http://localhost:8000/api/v1/projects \
  -H "Content-Type: application/json" \
  -d '{"name": "Site vitrine", "budget": 3000, "client_id": 1}'

# Liste les projets
curl http://localhost:8000/api/v1/projects
```

---

> **-> Prochaine étape : `03_PARTIE3_SECURITE.md`** — Authentification JWT, refresh tokens, rôles et permissions.

# [VERROUILLE] PARTIE 3 — Sécurité & Authentification
## Chapitres 10 à 12 : JWT, Rôles, CORS et Protection des Routes

> **Objectif :** Sécuriser BuildrAPI avec une authentification JWT professionnelle, gérer les rôles (admin/user), protéger toutes les routes sensibles, et configurer correctement les protections CORS et CSRF.

---

# Chapitre 10 : Authentification Basique — JWT

## 10.1 Pourquoi JWT ?

JWT (JSON Web Token) est le standard pour l'authentification dans les APIs modernes. Voici comment ça fonctionne :

```
1. L'utilisateur envoie email + mot de passe
2. L'API vérifie les credentials
3. Si OK -> génère un TOKEN JWT signé
4. L'utilisateur envoie ce token dans chaque requête suivante
5. L'API vérifie la signature du token (pas besoin de DB !)

Avantages vs Session :
- Stateless : pas besoin de stocker les sessions côté serveur
- Scalable : n'importe quel serveur peut valider le token
- Standard : compatible avec tous les langages/frameworks
```

### Anatomie d'un Token JWT

```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9  <- Header (algorithme)
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzAwMDAwMDAwfQ  <- Payload (données)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  <- Signature (vérification)

Le payload contient (décodé) :
{
  "sub": "42",           <- subject = user_id
  "email": "john@example.com",
  "role": "admin",
  "exp": 1700000000      <- expiration timestamp
}
```

---

## 10.2 Installation

```bash
pip install python-jose[cryptography] passlib[bcrypt] python-multipart
# python-jose  -> génération et validation des JWT
# passlib      -> hachage sécurisé des mots de passe (bcrypt)
# python-multipart -> nécessaire pour OAuth2PasswordRequestForm
```

---

## 10.3 Hachage des Mots de Passe avec bcrypt

```python
# app/core/security.py

from passlib.context import CryptContext
from jose import jwt, JWTError
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from app.core.config import settings

# ─── Configuration du hachage ─────────────────────────────────────────────
# bcrypt est l'algorithme recommandé pour les mots de passe
# Il est intentionnellement LENT (pour résister aux attaques brute-force)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def hash_password(password: str) -> str:
    """
    Hash un mot de passe en clair.
    Chaque hash est unique même pour le même mot de passe
    (grâce au 'salt' aléatoire intégré dans bcrypt).

    Exemple :
    "motdepasse123" -> "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPj6hsxq5b/9."
    """
    return pwd_context.hash(password)


def verify_password(plain_password: str, hashed_password: str) -> bool:
    """
    Vérifie qu'un mot de passe correspond à son hash.
    Ne jamais comparer les mots de passe directement !
    """
    return pwd_context.verify(plain_password, hashed_password)


# ─── Génération des Tokens JWT ────────────────────────────────────────────

def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
    """
    Crée un token JWT d'accès (courte durée : 30 min par défaut).
    """
    to_encode = data.copy()  # Ne jamais modifier le dict original

    # Calcul de l'expiration
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(
            minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
        )

    # Ajout des claims standards JWT
    to_encode.update({
        "exp": expire,       # Expiration (claim standard)
        "iat": datetime.utcnow(),  # Issued At (quand il a été émis)
        "type": "access"     # Type personnalisé pour distinguer access/refresh
    })

    # Signature avec la clé secrète
    return jwt.encode(
        to_encode,
        settings.SECRET_KEY,
        algorithm=settings.ALGORITHM  # HS256 recommandé
    )


def create_refresh_token(data: Dict[str, Any]) -> str:
    """
    Crée un token de rafraîchissement (longue durée : 7 jours).
    Utilisé pour obtenir un nouveau access token sans re-saisir le mot de passe.
    """
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
    to_encode.update({"exp": expire, "type": "refresh"})
    return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)


def decode_token(token: str) -> Dict[str, Any]:
    """
    Décode et valide un token JWT.
    Lève JWTError si le token est invalide ou expiré.
    """
    return jwt.decode(
        token,
        settings.SECRET_KEY,
        algorithms=[settings.ALGORITHM]
    )
```

---

## 10.4 Modèle Utilisateur et Schémas

```python
# app/models/user.py

from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum as SAEnum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.session import Base
import enum


class UserRole(str, enum.Enum):
    USER = "user"
    ADMIN = "admin"
    SUPER_ADMIN = "super_admin"


class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    email = Column(String(255), unique=True, nullable=False, index=True)
    username = Column(String(50), unique=True, nullable=False)
    hashed_password = Column(String(255), nullable=False)  # Jamais en clair !
    role = Column(SAEnum(UserRole), default=UserRole.USER, nullable=False)
    is_active = Column(Boolean, default=True)
    is_verified = Column(Boolean, default=False)  # Email vérifié ?
    last_login = Column(DateTime, nullable=True)
    created_at = Column(DateTime, server_default=func.now())

    projects = relationship("Project", back_populates="owner")
```

```python
# app/schemas/user.py

from pydantic import BaseModel, EmailStr, Field, validator
from typing import Optional
from datetime import datetime
from app.models.user import UserRole


class UserCreate(BaseModel):
    email: EmailStr
    username: str = Field(..., min_length=3, max_length=50, regex=r"^[a-zA-Z0-9_]+$")
    password: str = Field(..., min_length=8)

    @validator("password")
    def password_strength(cls, v):
        """Vérifie que le mot de passe est assez fort."""
        if not any(c.isupper() for c in v):
            raise ValueError("Le mot de passe doit contenir au moins une majuscule")
        if not any(c.isdigit() for c in v):
            raise ValueError("Le mot de passe doit contenir au moins un chiffre")
        return v


class UserResponse(BaseModel):
    id: int
    email: EmailStr
    username: str
    role: UserRole
    is_active: bool
    created_at: datetime

    class Config:
        orm_mode = True
    # IMPORTANT : hashed_password N'EST PAS dans ce schéma -> jamais exposé


class TokenResponse(BaseModel):
    """Réponse du endpoint /login."""
    access_token: str
    refresh_token: str
    token_type: str = "bearer"
    expires_in: int  # Durée en secondes


class TokenData(BaseModel):
    """Données extraites du JWT (payload)."""
    user_id: Optional[int] = None
    email: Optional[str] = None
    role: Optional[str] = None
```

---

## 10.5 Routes d'Authentification

```python
# app/api/routes/auth.py

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import timedelta
from jose import JWTError

from app.db.session import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserResponse, TokenResponse
from app.core.security import (
    hash_password, verify_password,
    create_access_token, create_refresh_token, decode_token
)
from app.core.config import settings

router = APIRouter(prefix="/auth", tags=["auth"])

# OAuth2PasswordBearer dit à FastAPI où trouver le token dans les requêtes
# tokenUrl = l'endpoint où on obtient le token (utilisé dans Swagger)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")


# ─────────────────────────────────────────────────────────────────────────
# INSCRIPTION
# ─────────────────────────────────────────────────────────────────────────
@router.post("/register", response_model=UserResponse, status_code=201)
def register(user_in: UserCreate, db: Session = Depends(get_db)):
    """Inscrit un nouvel utilisateur."""

    # Vérification email unique
    if db.query(User).filter(User.email == user_in.email).first():
        raise HTTPException(
            status_code=400,
            detail="Un compte avec cet email existe déjà"
        )

    # Vérification username unique
    if db.query(User).filter(User.username == user_in.username).first():
        raise HTTPException(
            status_code=400,
            detail="Ce nom d'utilisateur est déjà pris"
        )

    # Création de l'utilisateur avec mot de passe haché
    db_user = User(
        email=user_in.email,
        username=user_in.username,
        hashed_password=hash_password(user_in.password)  # JAMAIS en clair
    )
    db.add(db_user)
    db.commit()
    db.refresh(db_user)

    return db_user


# ─────────────────────────────────────────────────────────────────────────
# CONNEXION
# ─────────────────────────────────────────────────────────────────────────
@router.post("/login", response_model=TokenResponse)
def login(
    form_data: OAuth2PasswordRequestForm = Depends(),  # Formulaire username/password
    db: Session = Depends(get_db)
):
    """
    Authentifie un utilisateur et retourne ses tokens JWT.

    OAuth2PasswordRequestForm attend un form-data avec :
    - username (on accepte l'email ici)
    - password
    """
    # Cherche l'utilisateur par email
    user = db.query(User).filter(User.email == form_data.username).first()

    # Message d'erreur GÉNÉRIQUE : ne pas préciser si c'est l'email ou le mdp
    # Un attaquant ne doit pas savoir si l'email existe !
    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=401,
            detail="Email ou mot de passe incorrect",
            headers={"WWW-Authenticate": "Bearer"}
        )

    if not user.is_active:
        raise HTTPException(status_code=403, detail="Compte désactivé")

    # Payload du token
    token_data = {
        "sub": str(user.id),   # "sub" = subject = identifiant de l'utilisateur
        "email": user.email,
        "role": user.role.value
    }

    return {
        "access_token": create_access_token(token_data),
        "refresh_token": create_refresh_token(token_data),
        "token_type": "bearer",
        "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
    }


# ─────────────────────────────────────────────────────────────────────────
# RAFRAÎCHISSEMENT DU TOKEN
# ─────────────────────────────────────────────────────────────────────────
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(
    token: str,
    db: Session = Depends(get_db)
):
    """Échange un refresh_token contre un nouveau access_token."""
    try:
        payload = decode_token(token)

        # Vérifie que c'est bien un refresh token
        if payload.get("type") != "refresh":
            raise HTTPException(status_code=401, detail="Token invalide")

        user_id = int(payload.get("sub"))
        user = db.query(User).filter(User.id == user_id).first()

        if not user or not user.is_active:
            raise HTTPException(status_code=401, detail="Utilisateur introuvable")

        token_data = {"sub": str(user.id), "email": user.email, "role": user.role.value}
        return {
            "access_token": create_access_token(token_data),
            "refresh_token": create_refresh_token(token_data),
            "token_type": "bearer",
            "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
        }

    except JWTError:
        raise HTTPException(status_code=401, detail="Token invalide ou expiré")
```

> [OK] **Bonne pratique :** Le message d'erreur "Email ou mot de passe incorrect" est intentionnellement vague. Dire "Email inconnu" révèle quels emails sont enregistrés dans ta base — une fuite d'information exploitable.

---

# Chapitre 11 : Authentification Avancée & Permissions

## 11.1 La Dépendance `get_current_user`

C'est la dépendance la plus importante du projet : elle vérifie le token JWT et retourne l'utilisateur connecté.

```python
# app/api/dependencies.py

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from jose import JWTError
from typing import Optional

from app.db.session import get_db
from app.models.user import User, UserRole
from app.core.security import decode_token

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")


def get_current_user(
    token: str = Depends(oauth2_scheme),  # Extrait le token du header Authorization
    db: Session = Depends(get_db)
) -> User:
    """
    Dépendance principale d'authentification.

    Comment ça marche :
    1. FastAPI extrait le token du header : "Authorization: Bearer eyJ..."
    2. On décode le JWT et on extrait l'user_id
    3. On récupère l'utilisateur depuis la DB
    4. On retourne l'utilisateur à la route

    Si le token est absent, invalide, ou expiré -> 401 automatiquement
    """
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Token invalide ou expiré",
        headers={"WWW-Authenticate": "Bearer"}
    )

    try:
        payload = decode_token(token)
        user_id: Optional[int] = int(payload.get("sub"))
        if user_id is None:
            raise credentials_exception

    except (JWTError, ValueError):
        raise credentials_exception

    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise credentials_exception

    return user


def get_current_active_user(
    current_user: User = Depends(get_current_user)
) -> User:
    """Vérifie en plus que le compte est actif."""
    if not current_user.is_active:
        raise HTTPException(
            status_code=403,
            detail="Votre compte a été désactivé"
        )
    return current_user


# ─── Factories pour les contrôles de rôle ─────────────────────────────────

def require_role(*roles: UserRole):
    """
    Crée une dépendance qui vérifie que l'utilisateur a l'un des rôles requis.

    Usage :
    @router.delete("/{id}", dependencies=[Depends(require_role(UserRole.ADMIN))])
    """
    def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
        if current_user.role not in roles:
            raise HTTPException(
                status_code=403,
                detail=f"Cette action requiert le rôle : {', '.join(r.value for r in roles)}"
            )
        return current_user
    return role_checker


# Raccourcis pratiques
get_admin_user = require_role(UserRole.ADMIN, UserRole.SUPER_ADMIN)
get_super_admin = require_role(UserRole.SUPER_ADMIN)
```

---

## 11.2 Protéger les Routes avec les Dépendances

```python
# app/api/routes/projects.py — Version sécurisée

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.user import User, UserRole
from app.api.dependencies import get_current_active_user, require_role
from app.schemas.project import ProjectCreate, ProjectResponse, ProjectUpdate
from app.services import project_service
from typing import List

router = APIRouter(prefix="/projects", tags=["projects"])


# ─── Route publique ── (pas de protection)
@router.get("/public", response_model=List[ProjectResponse])
def list_public_projects(db: Session = Depends(get_db)):
    """Projets visibles publiquement (ex: portfolio)."""
    return []  # Implémentation à compléter


# ─── Route protégée ── (user connecté requis)
@router.get("/", response_model=List[ProjectResponse])
def list_my_projects(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_active_user)  # 401 si non connecté
):
    """Liste les projets de l'utilisateur connecté."""
    return project_service.list_projects(db, owner_id=current_user.id)


@router.post("/", response_model=ProjectResponse, status_code=201)
def create_project(
    project_in: ProjectCreate,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_active_user)
):
    """Crée un projet appartenant à l'utilisateur connecté."""
    return project_service.create_project(db, project_in, owner_id=current_user.id)


@router.get("/{project_id}", response_model=ProjectResponse)
def get_project(
    project_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_active_user)
):
    project = project_service.get_project(db, project_id)
    # Vérification de propriété : un user ne peut voir que ses propres projets
    if project.owner_id != current_user.id and current_user.role not in [UserRole.ADMIN]:
        from fastapi import HTTPException
        raise HTTPException(status_code=403, detail="Accès refusé")
    return project


# ─── Route admin ── (admin requis)
@router.delete(
    "/{project_id}",
    status_code=204,
    dependencies=[Depends(require_role(UserRole.ADMIN))]  # Dependency-level auth
)
def admin_delete_project(project_id: int, db: Session = Depends(get_db)):
    """Supprime n'importe quel projet (admin uniquement)."""
    project_service.admin_delete_project(db, project_id)
```

---

## 11.3 Exercice de Complétion — Chapitre 11

Implémente la route `/api/v1/users/me` qui permet à l'utilisateur connecté de voir et modifier son profil :

```python
# app/api/routes/users.py

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.user import User
from app.api.dependencies import ___  # TODO : quelle dépendance ?
from app.schemas.user import UserResponse, UserUpdate  # À créer

router = APIRouter(prefix="/users", tags=["users"])

# TODO 1 : GET /users/me — retourne l'utilisateur courant
@router.get("/me", response_model=___)
def get_my_profile(current_user: User = Depends(___)):
    return ___

# TODO 2 : PATCH /users/me — modifie username ou email de l'utilisateur courant
# UserUpdate doit avoir username et email optionnels
@router.patch("/me", response_model=___)
def update_my_profile(
    user_update: ___,
    db: Session = Depends(get_db),
    current_user: User = Depends(___)
):
    update_data = user_update.dict(exclude_unset=___)
    for field, value in ___.items():
        setattr(___, ___, ___)
    db.commit()
    db.refresh(___)
    return ___

# TODO 3 : GET /users — liste tous les utilisateurs (ADMIN uniquement)
@router.get("/", response_model=List[UserResponse])
def list_users(
    db: Session = Depends(get_db),
    ___ = Depends(require_role(___))
):
    return db.query(User).all()
```

---

# Chapitre 12 : Sécurité Applicative

## 12.1 Configuration CORS

CORS (Cross-Origin Resource Sharing) est un mécanisme de sécurité du navigateur. Il empêche un site malveillant de faire des requêtes à ton API en utilisant les credentials de l'utilisateur.

```python
# app/main.py — Configuration CORS

from fastapi.middleware.cors import CORSMiddleware

# En développement : permissif
if settings.ENV == "development":
    allowed_origins = ["*"]  # Tout accepter en dev
else:
    # En production : STRICT - uniquement ton frontend
    allowed_origins = [
        "https://buildrapi.com",
        "https://app.buildrapi.com",
        "https://www.buildrapi.com",
    ]

app.add_middleware(
    CORSMiddleware,
    allow_origins=allowed_origins,
    allow_credentials=True,    # Autorise les cookies et Authorization headers
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
    max_age=3600,              # Cache les réponses preflight 1h
)
```

---

## 12.2 Validation Stricte des Entrées

```python
# Exemple de validation anti-injection dans les schémas

from pydantic import BaseModel, Field, validator
import re

class ProjectCreate(BaseModel):
    name: str = Field(..., min_length=3, max_length=100)

    @validator("name")
    def name_no_special_chars(cls, v):
        """Empêche l'injection de caractères spéciaux dangereux."""
        # Autorise : lettres, chiffres, espaces, tirets, apostrophes
        if not re.match(r"^[a-zA-ZÀ-ÿ0-9\s\-']+$", v):
            raise ValueError(
                "Le nom ne peut contenir que des lettres, chiffres, espaces et tirets"
            )
        return v.strip()  # Supprime les espaces en début/fin
```

---

## 12.3 Sécurisation des En-têtes HTTP

```python
# app/core/middleware.py — Middleware de sécurité des en-têtes

from starlette.middleware.base import BaseHTTPMiddleware

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    """
    Ajoute des en-têtes de sécurité HTTP à chaque réponse.
    Ces en-têtes protègent contre XSS, clickjacking, etc.
    """
    async def dispatch(self, request, call_next):
        response = await call_next(request)

        # Empêche le navigateur de deviner le type MIME
        response.headers["X-Content-Type-Options"] = "nosniff"

        # Protection XSS pour les anciens navigateurs
        response.headers["X-XSS-Protection"] = "1; mode=block"

        # Empêche l'inclusion dans une iframe (protection clickjacking)
        response.headers["X-Frame-Options"] = "DENY"

        # Force HTTPS (HSTS)
        if settings.ENV == "production":
            response.headers["Strict-Transport-Security"] = (
                "max-age=31536000; includeSubDomains"
            )

        # Supprime les infos sur le serveur (sécurité par obscurité)
        response.headers.pop("server", None)

        return response
```

---

## 12.4 [OBJECTIF] Mini-Projet BuildrAPI — Fin de Partie 3

**Vérifications :**

```bash
# 1. Inscris un utilisateur
curl -X POST http://localhost:8000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "test@test.com", "username": "testuser", "password": "SecurePass123"}'

# 2. Connecte-toi et récupère le token
curl -X POST http://localhost:8000/api/v1/auth/login \
  -d "username=test@test.com&password=SecurePass123"
# -> Copie l'access_token

# 3. Utilise le token pour créer un projet
curl -X POST http://localhost:8000/api/v1/projects \
  -H "Authorization: Bearer VOTRE_TOKEN_ICI" \
  -H "Content-Type: application/json" \
  -d '{"name": "Mon projet", "budget": 5000, "client_id": 1}'

# 4. Essaie sans token -> doit retourner 401
curl -X POST http://localhost:8000/api/v1/projects \
  -H "Content-Type: application/json" \
  -d '{"name": "Test", "budget": 0, "client_id": 1}'
```

**Checklist sécurité :**
```
[OK] Mots de passe hashés avec bcrypt
[OK] JWT avec expiration courte (30 min)
[OK] Refresh token pour renouveler sans re-login
[OK] Messages d'erreur non-informatifs
[OK] CORS configuré correctement
[OK] Routes protégées par dépendances
[OK] Contrôle de rôles (user/admin)
[OK] Validation stricte des entrées
[OK] En-têtes de sécurité
```

---

> **-> Prochaine étape : `04_PARTIE4_PERFORMANCE.md`** — Async natif, caching Redis, pagination avancée.

# [RAPIDE] PARTIE 4 & 5 — Performance, Communication & Intégrations
## Chapitres 13 à 18 : Async, Cache Redis, WebSockets, Uploads, Emails

---

# Chapitre 13 : Programmation Asynchrone

## 13.1 Sync vs Async — La Différence Fondamentale

```
[LENT] SYNCHRONE (blocking) :
Imagine un seul serveur qui prend les commandes au restaurant.
Quand il attend que la cuisine prépare le plat, il reste debout à attendre.
Il ne peut pas prendre d'autres commandes pendant ce temps.

[RAPIDE] ASYNCHRONE (non-blocking) :
Le serveur prend la commande, l'envoie en cuisine,
et PENDANT QUE la cuisine prépare, il prend d'autres commandes.
Quand le plat est prêt, il revient le chercher.
```

En pratique :

```python
import asyncio
import time

# ─── VERSION SYNCHRONE ───────────────────────────────────────────────────
def fetch_data_sync():
    """Bloque pendant 2 secondes -> les autres requêtes attendent !"""
    time.sleep(2)  # Simule une requête DB ou API externe
    return {"data": "result"}

@app.get("/sync")
def sync_endpoint():
    # Pendant ces 2 secondes, FastAPI ne peut pas traiter d'autres requêtes
    result = fetch_data_sync()
    return result


# ─── VERSION ASYNCHRONE ──────────────────────────────────────────────────
async def fetch_data_async():
    """Libère le thread pendant 2 secondes -> les autres requêtes continuent !"""
    await asyncio.sleep(2)  # Simule une requête DB async ou API externe
    return {"data": "result"}

@app.get("/async")
async def async_endpoint():
    # Pendant ces 2 secondes, FastAPI traite les autres requêtes !
    result = await fetch_data_async()
    return result
```

---

## 13.2 Quand Utiliser `async def` vs `def` ?

```python
# [OK] Toujours async pour :
# - Requêtes base de données (avec asyncpg, SQLAlchemy async)
# - Requêtes HTTP vers des APIs externes (avec httpx, aiohttp)
# - Lecture/écriture de fichiers (avec aiofiles)
# - WebSockets

@app.get("/projects")
async def list_projects(db: AsyncSession = Depends(get_async_db)):
    result = await db.execute(select(Project))  # Async DB query
    return result.scalars().all()


# [ATTENTION] def ordinaire pour :
# - Calculs CPU intensifs (ne bloquent pas le réseau)
# - Code synchrone qui ne fait pas d'I/O
# - Appels à des bibliothèques synchrones

@app.get("/calculate")
def compute_stats():
    result = sum(range(10_000_000))  # CPU, pas I/O -> def OK
    return {"result": result}
```

---

## 13.3 SQLAlchemy Async — Configuration

```bash
pip install sqlalchemy[asyncio] asyncpg aiofiles
```

```python
# app/db/async_session.py

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.core.config import settings

# Note : postgresql+asyncpg:// au lieu de postgresql://
ASYNC_DATABASE_URL = settings.DATABASE_URL.replace(
    "postgresql://", "postgresql+asyncpg://"
)

async_engine = create_async_engine(
    ASYNC_DATABASE_URL,
    echo=False,  # True = log toutes les requêtes SQL (dev uniquement)
    pool_size=10,        # Taille du pool de connexions
    max_overflow=20,     # Connexions supplémentaires si pool plein
)

AsyncSessionLocal = sessionmaker(
    async_engine,
    class_=AsyncSession,
    expire_on_commit=False  # Évite les erreurs "détached instance"
)


async def get_async_db():
    async with AsyncSessionLocal() as session:
        try:
            yield session
        finally:
            await session.close()
```

```python
# Exemple de route async complète

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from fastapi import APIRouter, Depends
from app.models.project import Project
from app.db.async_session import get_async_db

router = APIRouter()

@router.get("/projects")
async def list_projects(db: AsyncSession = Depends(get_async_db)):
    # Syntaxe SQLAlchemy 2.0 async
    result = await db.execute(
        select(Project)
        .where(Project.is_active == True)
        .order_by(Project.created_at.desc())
        .limit(10)
    )
    return result.scalars().all()
```

---

## 13.4 BackgroundTasks — Tâches en Arrière-Plan

```python
# Pour les opérations longues qui ne nécessitent pas de réponse immédiate

from fastapi import BackgroundTasks

def send_welcome_email(email: str, username: str):
    """Fonction synchrone exécutée en arrière-plan après la réponse."""
    # Envoi d'email, notifications, logs...
    print(f"Envoi d'email de bienvenue à {email}")
    time.sleep(2)  # Simulation envoi email
    print(f"Email envoyé à {email}")


@router.post("/auth/register", status_code=201)
def register(
    user_in: UserCreate,
    background_tasks: BackgroundTasks,
    db: Session = Depends(get_db)
):
    # Crée l'utilisateur
    user = create_user(db, user_in)

    # Ajoute l'email en tâche de fond
    # La réponse est renvoyée IMMÉDIATEMENT, l'email est envoyé ensuite
    background_tasks.add_task(
        send_welcome_email,
        email=user.email,
        username=user.username
    )

    return user  # Réponse instantanée, email envoyé en background
```

> [LOGIQUE] **Point clé :** `BackgroundTasks` est simple et intégré à FastAPI, parfait pour les tâches légères (emails, logs). Pour des tâches complexes ou longues (traitement de fichiers, génération de rapports), utilise **Celery** avec Redis (Chapitre 17).

---

## 13.5 Exercice — Chapitre 13

Convertis la route de création de projet en version async :

```python
# TODO : Convertis cette route synchrone en async

from sqlalchemy.orm import Session
from app.db.session import get_db

@router.post("/projects", response_model=ProjectResponse)
def create_project(           # TODO : ajouter async
    project_in: ProjectCreate,
    background_tasks: BackgroundTasks,
    db: Session = Depends(get_db),  # TODO : changer pour AsyncSession
    current_user: User = Depends(get_current_active_user)
):
    # TODO : ajouter await
    project = project_service.create_project(db, project_in, current_user.id)

    # TODO : ajouter une tâche de fond pour notifier le client
    # (envoie un email au client associé au projet)
    background_tasks.add_task(
        ___,  # fonction à créer
        client_id=project_in.client_id,
        project_name=project_in.name
    )

    return project
```

---

# Chapitre 14 : Optimisation & Caching

## 14.1 Caching avec Redis

```bash
pip install redis aioredis fastapi-cache2[redis]
# Démarre Redis (Docker recommandé)
docker run -d -p 6379:6379 redis:alpine
```

```python
# app/core/cache.py

from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend
from fastapi_cache.decorator import cache
from redis import asyncio as aioredis
from app.core.config import settings


async def init_cache():
    """Initialise la connexion Redis au démarrage."""
    redis = aioredis.from_url(
        settings.REDIS_URL,  # "redis://localhost:6379"
        encoding="utf8",
        decode_responses=True
    )
    FastAPICache.init(RedisBackend(redis), prefix="buildrapi-cache")


# Dans main.py :
@app.on_event("startup")
async def startup():
    await init_cache()
```

```python
# Utilisation du cache dans les routes

from fastapi_cache.decorator import cache

@router.get("/projects/stats")
@cache(expire=300)  # Cache 5 minutes (300 secondes)
async def get_project_stats(db: AsyncSession = Depends(get_async_db)):
    """
    Statistiques globales.
    Coûteux en DB -> on met en cache 5 minutes.
    Les résultats ne changent pas souvent.
    """
    total = await db.scalar(select(func.count(Project.id)))
    active = await db.scalar(
        select(func.count(Project.id)).where(Project.status == "active")
    )
    total_budget = await db.scalar(select(func.sum(Project.budget)))

    return {
        "total_projects": total,
        "active_projects": active,
        "total_budget": total_budget
    }
```

---

## 14.2 Pagination Efficace

```python
# app/schemas/pagination.py

from pydantic import BaseModel
from typing import Generic, TypeVar, List

T = TypeVar("T")

class PaginatedResponse(BaseModel, Generic[T]):
    """Réponse paginée générique réutilisable pour tous les endpoints."""
    items: List[T]
    total: int          # Nombre total d'éléments (toutes pages)
    page: int           # Page actuelle
    limit: int          # Éléments par page
    pages: int          # Nombre total de pages
    has_next: bool      # Page suivante disponible ?
    has_prev: bool      # Page précédente disponible ?


# Utilisation dans les routes :
@router.get("/", response_model=PaginatedResponse[ProjectResponse])
async def list_projects(
    page: int = Query(1, ge=1),
    limit: int = Query(10, ge=1, le=100),
    db: AsyncSession = Depends(get_async_db)
):
    skip = (page - 1) * limit

    # Compte total (nécessaire pour la pagination)
    total_result = await db.execute(select(func.count(Project.id)))
    total = total_result.scalar()

    # Requête paginée
    result = await db.execute(
        select(Project).offset(skip).limit(limit)
    )
    projects = result.scalars().all()

    return {
        "items": projects,
        "total": total,
        "page": page,
        "limit": limit,
        "pages": (total + limit - 1) // limit,  # Division entière arrondie
        "has_next": skip + limit < total,
        "has_prev": page > 1
    }
```

---

# Chapitre 15 : Uploads & Downloads de Fichiers

## 15.1 Upload de Fichiers

```bash
pip install python-multipart aiofiles pillow
```

```python
# app/api/routes/files.py

from fastapi import APIRouter, UploadFile, File, HTTPException, Depends
from fastapi.responses import FileResponse, StreamingResponse
import aiofiles
import os
import uuid
from pathlib import Path

router = APIRouter(prefix="/files", tags=["files"])

# Dossier de stockage
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)

# Configurations
MAX_FILE_SIZE = 10 * 1024 * 1024  # 10 MB
ALLOWED_TYPES = {"image/jpeg", "image/png", "application/pdf", "text/csv"}


@router.post("/upload")
async def upload_file(
    file: UploadFile = File(..., description="Fichier à uploader"),
    current_user: User = Depends(get_current_active_user)
):
    """
    Upload un fichier avec validation de type et de taille.
    Retourne l'URL du fichier uploadé.
    """
    # ─── Validation du type MIME ──────────────────────────────────────────
    if file.content_type not in ALLOWED_TYPES:
        raise HTTPException(
            status_code=400,
            detail=f"Type de fichier non autorisé : {file.content_type}. "
                   f"Types acceptés : {', '.join(ALLOWED_TYPES)}"
        )

    # ─── Validation de la taille ──────────────────────────────────────────
    # Lit par chunks pour ne pas charger tout en mémoire
    content = b""
    chunk_size = 1024 * 64  # 64 KB chunks
    while chunk := await file.read(chunk_size):
        content += chunk
        if len(content) > MAX_FILE_SIZE:
            raise HTTPException(
                status_code=413,
                detail=f"Fichier trop volumineux (max {MAX_FILE_SIZE // 1024 // 1024} MB)"
            )

    # ─── Génération d'un nom unique ───────────────────────────────────────
    extension = Path(file.filename).suffix.lower()
    unique_name = f"{uuid.uuid4().hex}{extension}"
    file_path = UPLOAD_DIR / f"user_{current_user.id}" / unique_name

    # Crée le dossier utilisateur si nécessaire
    file_path.parent.mkdir(parents=True, exist_ok=True)

    # ─── Sauvegarde asynchrone ────────────────────────────────────────────
    async with aiofiles.open(file_path, "wb") as f:
        await f.write(content)

    return {
        "filename": unique_name,
        "original_name": file.filename,
        "size": len(content),
        "content_type": file.content_type,
        "url": f"/api/v1/files/{unique_name}"
    }


@router.post("/upload/multiple")
async def upload_multiple_files(
    files: list[UploadFile] = File(...),
    current_user: User = Depends(get_current_active_user)
):
    """Upload plusieurs fichiers en une seule requête."""
    if len(files) > 10:
        raise HTTPException(status_code=400, detail="Maximum 10 fichiers par upload")

    results = []
    for file in files:
        # Réutilise la logique d'upload simple
        result = await upload_file(file, current_user)
        results.append(result)

    return {"uploaded": results, "count": len(results)}
```

---

## 15.2 Téléchargement et Génération de PDF

```bash
pip install reportlab
```

```python
# app/services/pdf.py

from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph
from reportlab.lib import colors
import io


def generate_invoice_pdf(invoice_data: dict) -> bytes:
    """
    Génère un PDF de facture en mémoire.
    Retourne les bytes du PDF (pas de fichier créé sur le disque).
    """
    buffer = io.BytesIO()
    doc = SimpleDocTemplate(buffer, pagesize=A4)
    styles = getSampleStyleSheet()
    elements = []

    # ─── Titre ───────────────────────────────────────────────────────────
    elements.append(Paragraph("FACTURE", styles["Title"]))
    elements.append(Paragraph(
        f"Facture N° {invoice_data['number']} — {invoice_data['date']}",
        styles["Normal"]
    ))

    # ─── Tableau des articles ─────────────────────────────────────────────
    table_data = [
        ["Description", "Quantité", "Prix unitaire", "Total"],
    ]
    for item in invoice_data["items"]:
        table_data.append([
            item["description"],
            str(item["quantity"]),
            f"{item['unit_price']:.2f} €",
            f"{item['quantity'] * item['unit_price']:.2f} €"
        ])

    table = Table(table_data)
    table.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, 0), colors.grey),
        ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
        ("GRID", (0, 0), (-1, -1), 1, colors.black),
    ]))
    elements.append(table)

    doc.build(elements)
    buffer.seek(0)
    return buffer.read()


# Route pour télécharger une facture PDF
@router.get("/invoices/{invoice_id}/pdf")
async def download_invoice_pdf(
    invoice_id: int,
    db: AsyncSession = Depends(get_async_db),
    current_user: User = Depends(get_current_active_user)
):
    """Génère et télécharge une facture en PDF."""
    invoice = await get_invoice(db, invoice_id, current_user.id)

    pdf_bytes = generate_invoice_pdf({
        "number": f"INV-{invoice.id:04d}",
        "date": invoice.created_at.strftime("%d/%m/%Y"),
        "items": [
            {"description": "Développement site web", "quantity": 40, "unit_price": 75.0}
        ]
    })

    # StreamingResponse = envoie par chunks (efficace pour les gros fichiers)
    def iterfile():
        yield pdf_bytes

    return StreamingResponse(
        iterfile(),
        media_type="application/pdf",
        headers={"Content-Disposition": f"attachment; filename=facture-{invoice_id}.pdf"}
    )
```

---

# Chapitre 16 : WebSockets & Streaming en Temps Réel

## 16.1 Introduction aux WebSockets

HTTP est un protocole **request-response** : le client envoie une requête, le serveur répond, connexion fermée.

WebSocket est une connexion **bidirectionnelle persistante** : une fois établie, serveur et client peuvent s'envoyer des messages à tout moment.

```
HTTP  : Client -> [requête] -> Serveur -> [réponse] -> fin de connexion
WS    : Client <-> Serveur (connexion ouverte en permanence)
```

**Cas d'usage dans BuildrAPI :**
- Notifications en temps réel (nouveau commentaire, changement de statut)
- Chat entre freelance et client
- Progression en temps réel d'une tâche longue

---

## 16.2 Implémentation WebSocket pour BuildrAPI

```python
# app/api/routes/websockets.py

from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from typing import Dict, List
import json
import asyncio

router = APIRouter()


class ConnectionManager:
    """
    Gère toutes les connexions WebSocket actives.
    Permet d'envoyer des messages à des utilisateurs spécifiques
    ou à tous les membres d'un projet.
    """
    def __init__(self):
        # {user_id: websocket}
        self.active_connections: Dict[int, WebSocket] = {}
        # {project_id: [user_id1, user_id2, ...]}
        self.project_rooms: Dict[int, List[int]] = {}

    async def connect(self, websocket: WebSocket, user_id: int):
        """Accepte et enregistre une connexion."""
        await websocket.accept()
        self.active_connections[user_id] = websocket
        print(f"[OK] User {user_id} connecté. Total: {len(self.active_connections)}")

    def disconnect(self, user_id: int):
        """Supprime une connexion."""
        self.active_connections.pop(user_id, None)
        # Retire l'utilisateur de tous ses projets
        for room in self.project_rooms.values():
            if user_id in room:
                room.remove(user_id)
        print(f"[X] User {user_id} déconnecté")

    def join_project(self, user_id: int, project_id: int):
        """Ajoute un utilisateur à la "room" d'un projet."""
        if project_id not in self.project_rooms:
            self.project_rooms[project_id] = []
        if user_id not in self.project_rooms[project_id]:
            self.project_rooms[project_id].append(user_id)

    async def send_to_user(self, user_id: int, message: dict):
        """Envoie un message à un utilisateur spécifique."""
        websocket = self.active_connections.get(user_id)
        if websocket:
            try:
                await websocket.send_json(message)
            except Exception:
                self.disconnect(user_id)

    async def broadcast_to_project(self, project_id: int, message: dict, exclude_user: int = None):
        """Envoie un message à tous les membres d'un projet."""
        members = self.project_rooms.get(project_id, [])
        for user_id in members:
            if user_id != exclude_user:  # Exclut l'émetteur si souhaité
                await self.send_to_user(user_id, message)


# Instance globale (partagée entre toutes les connexions)
manager = ConnectionManager()


@router.websocket("/ws/{user_id}")
async def websocket_endpoint(
    websocket: WebSocket,
    user_id: int,
    token: str = Query(...)  # Token JWT passé en query param
):
    """
    Endpoint WebSocket principal.

    Connexion : ws://localhost:8000/ws/42?token=eyJ...

    Messages supportés (format JSON) :
    - {"action": "join_project", "project_id": 1}
    - {"action": "task_update", "project_id": 1, "task_id": 5, "status": "completed"}
    - {"action": "ping"}
    """
    # Validation du token JWT (similaire à get_current_user)
    try:
        payload = decode_token(token)
        if int(payload.get("sub")) != user_id:
            await websocket.close(code=4001)
            return
    except Exception:
        await websocket.close(code=4001)
        return

    # Connexion acceptée
    await manager.connect(websocket, user_id)

    try:
        while True:  # Boucle infinie tant que connecté
            # Attend un message du client
            data = await websocket.receive_text()
            message = json.loads(data)
            action = message.get("action")

            if action == "join_project":
                project_id = message["project_id"]
                manager.join_project(user_id, project_id)
                await websocket.send_json({
                    "type": "joined",
                    "project_id": project_id,
                    "message": f"Connecté au projet #{project_id}"
                })

            elif action == "task_update":
                # Notifie tous les membres du projet
                await manager.broadcast_to_project(
                    project_id=message["project_id"],
                    message={
                        "type": "task_updated",
                        "task_id": message["task_id"],
                        "status": message["status"],
                        "updated_by": user_id
                    },
                    exclude_user=user_id  # L'émetteur n'a pas besoin d'être notifié
                )

            elif action == "ping":
                await websocket.send_json({"type": "pong"})

    except WebSocketDisconnect:
        # Déconnexion propre du client
        manager.disconnect(user_id)
    except Exception as e:
        # Déconnexion imprévue
        manager.disconnect(user_id)
        print(f"WebSocket error: {e}")
```

---

## 16.3 Client WebSocket JavaScript (pour tester)

```html
<!-- test_websocket.html -->
<!DOCTYPE html>
<html>
<body>
<script>
const userId = 1;
const token = "VOTRE_TOKEN_JWT";
const ws = new WebSocket(`ws://localhost:8000/ws/${userId}?token=${token}`);

ws.onopen = () => {
    console.log("Connecté !");
    // Rejoindre le projet 1
    ws.send(JSON.stringify({ action: "join_project", project_id: 1 }));
};

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log("Message reçu:", data);
};

ws.onclose = () => console.log("Déconnecté");

// Envoyer une mise à jour de tâche
function updateTask() {
    ws.send(JSON.stringify({
        action: "task_update",
        project_id: 1,
        task_id: 5,
        status: "completed"
    }));
}
</script>
<button onclick="updateTask()">Marquer tâche terminée</button>
</body>
</html>
```

---

# Chapitre 17 : Background Tasks & Scheduling

## 17.1 Celery pour les Tâches Longues

```bash
pip install celery redis flower
```

```python
# app/celery_app.py

from celery import Celery
from app.core.config import settings

# Celery utilise Redis comme broker (file de messages) et backend (résultats)
celery_app = Celery(
    "buildrapi",
    broker=settings.REDIS_URL,
    backend=settings.REDIS_URL
)

celery_app.conf.update(
    task_serializer="json",
    accept_content=["json"],
    result_expires=3600,  # Résultats conservés 1h
    timezone="Europe/Paris",
)
```

```python
# app/tasks/email_tasks.py

from app.celery_app import celery_app
import time

@celery_app.task(name="send_invoice_email", bind=True, max_retries=3)
def send_invoice_email(self, invoice_id: int, user_email: str):
    """
    Tâche Celery : envoie une facture par email.
    Réessaie jusqu'à 3 fois en cas d'échec.
    bind=True donne accès à 'self' (l'objet task) pour les retries
    """
    try:
        # Génère le PDF
        from app.services.pdf import generate_invoice_pdf
        pdf = generate_invoice_pdf({"id": invoice_id})

        # Envoie l'email
        from app.services.email import send_email_with_attachment
        send_email_with_attachment(
            to=user_email,
            subject=f"Votre facture #{invoice_id}",
            attachment=pdf
        )
        return {"status": "sent", "invoice_id": invoice_id}

    except Exception as exc:
        # Réessaie dans 60 secondes si erreur
        raise self.retry(exc=exc, countdown=60)


# Dans une route FastAPI :
@router.post("/invoices/{invoice_id}/send")
def send_invoice(invoice_id: int, current_user: User = Depends(get_current_active_user)):
    # Envoie la tâche à Celery (non-bloquant !)
    task = send_invoice_email.delay(invoice_id, current_user.email)
    return {"task_id": task.id, "status": "queued"}

# Route pour vérifier le statut d'une tâche
@router.get("/tasks/{task_id}")
def get_task_status(task_id: str):
    from celery.result import AsyncResult
    result = AsyncResult(task_id, app=celery_app)
    return {
        "task_id": task_id,
        "status": result.status,  # PENDING, STARTED, SUCCESS, FAILURE
        "result": result.result if result.ready() else None
    }
```

---

# Chapitre 18 : Envoi d'Emails

## 18.1 Setup avec fastapi-mail

```bash
pip install fastapi-mail jinja2
```

```python
# app/services/email.py

from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
from app.core.config import settings

# Configuration de la connexion SMTP
mail_config = ConnectionConfig(
    MAIL_USERNAME=settings.SMTP_USER,
    MAIL_PASSWORD=settings.SMTP_PASSWORD,
    MAIL_FROM=settings.SMTP_USER,
    MAIL_PORT=settings.SMTP_PORT,
    MAIL_SERVER=settings.SMTP_HOST,
    MAIL_STARTTLS=True,
    MAIL_SSL_TLS=False,
    USE_CREDENTIALS=True,
    TEMPLATE_FOLDER=Path(__file__).parent.parent / "templates" / "emails"
)

fastmail = FastMail(mail_config)


async def send_welcome_email(user_email: str, username: str):
    """Envoie l'email de bienvenue avec un template HTML."""
    message = MessageSchema(
        subject="Bienvenue sur BuildrAPI [RAPIDE]",
        recipients=[user_email],
        template_body={
            "username": username,
            "login_url": "https://buildrapi.com/login"
        },
        subtype=MessageType.html
    )
    # send_message est async -> ne bloque pas l'API
    await fastmail.send_message(message, template_name="welcome.html")


async def send_invoice_email(user_email: str, invoice_number: str, pdf_bytes: bytes):
    """Envoie une facture par email avec pièce jointe PDF."""
    message = MessageSchema(
        subject=f"Votre facture {invoice_number}",
        recipients=[user_email],
        body=f"Veuillez trouver en pièce jointe votre facture {invoice_number}.",
        subtype=MessageType.plain,
        attachments=[
            {
                "file": pdf_bytes,
                "filename": f"facture-{invoice_number}.pdf",
                "mime_type": "application/pdf"
            }
        ]
    )
    await fastmail.send_message(message)
```

```html
<!-- app/templates/emails/welcome.html -->
<!DOCTYPE html>
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  <div style="background: #2563eb; padding: 24px; text-align: center;">
    <h1 style="color: white;">BuildrAPI [RAPIDE]</h1>
  </div>
  <div style="padding: 32px;">
    <h2>Bienvenue, {{ username }} !</h2>
    <p>Ton compte a bien été créé. Tu peux maintenant te connecter et gérer tes projets freelance.</p>
    <a href="{{ login_url }}"
       style="background: #2563eb; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none;">
      Se connecter
    </a>
  </div>
</body>
</html>
```

---

## 18.2 Exercice de Complétion — Chapitres 15-18

```python
# TODO : Implémente un endpoint d'export CSV des projets

from fastapi.responses import StreamingResponse
import csv
import io

@router.get("/projects/export/csv")
async def export_projects_csv(
    db: AsyncSession = Depends(get_async_db),
    current_user: User = Depends(get_current_active_user)
):
    """
    Exporte tous les projets de l'utilisateur en CSV.
    Doit retourner un StreamingResponse avec le bon Content-Type.
    """
    # TODO 1 : Récupère tous les projets de current_user
    projects = ___

    # TODO 2 : Crée le contenu CSV en mémoire
    output = io.StringIO()
    writer = csv.DictWriter(output, fieldnames=___)  # Quelles colonnes ?
    writer.writeheader()
    for project in ___:
        writer.writerow({
            "id": ___,
            "name": ___,
            "status": ___,
            "budget": ___,
        })

    # TODO 3 : Repositionne le curseur au début
    output.seek(___)

    # TODO 4 : Retourne un StreamingResponse
    return StreamingResponse(
        iter([output.getvalue()]),
        media_type=___,  # Quel media_type pour le CSV ?
        headers={"Content-Disposition": "attachment; filename=projets.csv"}
    )
```

---

## 18.3 [OBJECTIF] Mini-Projet BuildrAPI — Fin des Parties 4 & 5

À ce stade, BuildrAPI est une API complète avec :

```
[OK] Routes async avec SQLAlchemy async
[OK] Cache Redis sur les endpoints coûteux
[OK] Pagination avec métadonnées
[OK] Upload de fichiers avec validation
[OK] Génération de PDF (factures)
[OK] WebSockets pour les notifications temps réel
[OK] Celery pour les tâches longues
[OK] Envoi d'emails avec templates HTML
```

**Test WebSocket :**
```bash
# Ouvre test_websocket.html dans ton navigateur
# Connecte-toi et rejoins le projet 1
# Dans un autre onglet, met à jour une tâche via l'API REST
# -> Tu dois voir la notification apparaître en temps réel
```

---

> **-> Prochaine étape : `06_PARTIE6_TESTS_DEPLOY.md`** — Tests avec pytest, Docker, CI/CD GitHub Actions, déploiement cloud.

# [TEST] PARTIE 6 — Tests, CI/CD & Déploiement
## Chapitres 19 à 22 : pytest, Docker, GitHub Actions, Cloud

> **Objectif :** Rendre BuildrAPI production-ready. Tests automatisés, conteneurisation Docker, pipeline CI/CD, et déploiement sur le cloud. Un code sans tests n'est pas du code professionnel.

---

# Chapitre 19 : Tests Unitaires et d'Intégration

## 19.1 Pourquoi Tester ?

```
Sans tests :
- Tu modifies une route -> tu casses une autre sans le savoir
- Tu déploies en prod -> les utilisateurs découvrent les bugs
- Tu passes des heures à déboguer en prod sous pression

Avec tests :
- Chaque modification est validée automatiquement
- Les bugs sont trouvés avant la prod
- Tu déploies avec confiance
- Le code est documenté par les tests eux-mêmes
```

---

## 19.2 Installation

```bash
pip install pytest pytest-asyncio httpx pytest-cov
# pytest      -> framework de test
# pytest-asyncio -> support des tests async
# httpx       -> client HTTP pour tester FastAPI (remplace requests)
# pytest-cov  -> rapport de couverture de code
```

---

## 19.3 Configuration des Tests

```python
# app/tests/conftest.py
# conftest.py est lu automatiquement par pytest
# Il définit les "fixtures" = ressources partagées entre tests

import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

from app.main import app
from app.db.session import Base, get_db
from app.core.security import hash_password
from app.models.user import User

# ─── Base de données de test (SQLite en mémoire) ──────────────────────────
# On utilise une DB séparée pour les tests
# StaticPool = toutes les connexions partagent la même DB en mémoire
TEST_DATABASE_URL = "sqlite:///:memory:"

engine_test = create_engine(
    TEST_DATABASE_URL,
    connect_args={"check_same_thread": False},
    poolclass=StaticPool
)

TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine_test)


@pytest.fixture(autouse=True)
def setup_database():
    """
    Crée et détruit les tables avant/après chaque test.
    autouse=True = appliqué à TOUS les tests sans avoir à le déclarer.
    """
    Base.metadata.create_all(bind=engine_test)
    yield  # Les tests s'exécutent ici
    Base.metadata.drop_all(bind=engine_test)


@pytest.fixture
def db_session():
    """Session DB de test."""
    session = TestingSessionLocal()
    try:
        yield session
    finally:
        session.close()


@pytest.fixture
def override_db(db_session):
    """
    Remplace la dépendance get_db de l'app par la DB de test.
    C'est le "mock" de la base de données.
    """
    def get_test_db():
        yield db_session

    # Override la dépendance dans l'app
    app.dependency_overrides[get_db] = get_test_db
    yield db_session
    # Nettoie après le test
    app.dependency_overrides.clear()


@pytest_asyncio.fixture
async def client(override_db):
    """
    Client HTTP de test.
    Utilise l'app FastAPI avec la DB de test injectée.
    """
    async with AsyncClient(app=app, base_url="http://test") as ac:
        yield ac


# ─── Fixtures de données ──────────────────────────────────────────────────

@pytest.fixture
def test_user(override_db):
    """Crée un utilisateur de test dans la DB."""
    user = User(
        email="test@buildrapi.com",
        username="testuser",
        hashed_password=hash_password("SecurePass123"),
        is_active=True
    )
    override_db.add(user)
    override_db.commit()
    override_db.refresh(user)
    return user


@pytest_asyncio.fixture
async def auth_headers(client, test_user):
    """
    Crée un utilisateur et retourne les headers d'authentification.
    Utilisé dans les tests qui nécessitent un utilisateur connecté.
    """
    response = await client.post(
        "/api/v1/auth/login",
        data={"username": test_user.email, "password": "SecurePass123"}
    )
    token = response.json()["access_token"]
    return {"Authorization": f"Bearer {token}"}
```

---

## 19.4 Tests des Routes Auth

```python
# app/tests/test_auth.py

import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
class TestRegistration:
    """Tests du endpoint POST /api/v1/auth/register"""

    async def test_register_success(self, client: AsyncClient):
        """Inscription réussie avec des données valides."""
        response = await client.post("/api/v1/auth/register", json={
            "email": "nouveau@test.com",
            "username": "nouveauuser",
            "password": "SecurePass123"
        })

        assert response.status_code == 201
        data = response.json()
        assert data["email"] == "nouveau@test.com"
        assert data["username"] == "nouveauuser"
        # CRITIQUE : le mot de passe ne doit JAMAIS apparaître dans la réponse
        assert "password" not in data
        assert "hashed_password" not in data

    async def test_register_duplicate_email(self, client: AsyncClient, test_user):
        """L'inscription avec un email déjà existant doit échouer."""
        response = await client.post("/api/v1/auth/register", json={
            "email": test_user.email,  # Email déjà utilisé
            "username": "autreuser",
            "password": "SecurePass123"
        })

        assert response.status_code == 400
        assert "email" in response.json()["detail"].lower()

    async def test_register_weak_password(self, client: AsyncClient):
        """Un mot de passe trop simple doit être rejeté."""
        response = await client.post("/api/v1/auth/register", json={
            "email": "user@test.com",
            "username": "user123",
            "password": "abc"  # Trop court, pas de majuscule, pas de chiffre
        })

        assert response.status_code == 422  # Erreur de validation Pydantic

    async def test_register_invalid_email(self, client: AsyncClient):
        """Un email invalide doit être rejeté."""
        response = await client.post("/api/v1/auth/register", json={
            "email": "pas-un-email",
            "username": "user",
            "password": "SecurePass123"
        })
        assert response.status_code == 422


@pytest.mark.asyncio
class TestLogin:
    """Tests du endpoint POST /api/v1/auth/login"""

    async def test_login_success(self, client: AsyncClient, test_user):
        """Connexion réussie."""
        response = await client.post(
            "/api/v1/auth/login",
            data={"username": test_user.email, "password": "SecurePass123"}
        )

        assert response.status_code == 200
        data = response.json()
        assert "access_token" in data
        assert "refresh_token" in data
        assert data["token_type"] == "bearer"
        assert isinstance(data["expires_in"], int)

    async def test_login_wrong_password(self, client: AsyncClient, test_user):
        """Mot de passe incorrect -> 401."""
        response = await client.post(
            "/api/v1/auth/login",
            data={"username": test_user.email, "password": "MauvaisMotdePasse"}
        )
        assert response.status_code == 401
        # Message générique, ne révèle pas si c'est le mdp ou l'email
        assert "incorrect" in response.json()["detail"].lower()

    async def test_login_nonexistent_user(self, client: AsyncClient):
        """Email inconnu -> 401 avec même message que mot de passe incorrect."""
        response = await client.post(
            "/api/v1/auth/login",
            data={"username": "inexistant@test.com", "password": "AnyPassword1"}
        )
        assert response.status_code == 401
```

---

## 19.5 Tests des Routes Projects

```python
# app/tests/test_projects.py

import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
class TestProjects:

    async def test_list_projects_unauthorized(self, client: AsyncClient):
        """Accéder aux projets sans token -> 401."""
        response = await client.get("/api/v1/projects/")
        assert response.status_code == 401

    async def test_create_project_success(self, client: AsyncClient, auth_headers, override_db):
        """Création d'un projet valide."""
        # Crée d'abord un client
        from app.models.client import Client
        test_client = Client(name="Test Client", email="client@test.com")
        override_db.add(test_client)
        override_db.commit()
        override_db.refresh(test_client)

        response = await client.post(
            "/api/v1/projects/",
            json={
                "name": "Projet Test",
                "budget": 5000.0,
                "client_id": test_client.id
            },
            headers=auth_headers
        )

        assert response.status_code == 201
        data = response.json()
        assert data["name"] == "Projet Test"
        assert data["budget"] == 5000.0
        assert "id" in data
        assert "created_at" in data

    async def test_create_project_invalid_budget(self, client: AsyncClient, auth_headers):
        """Budget négatif -> 422."""
        response = await client.post(
            "/api/v1/projects/",
            json={"name": "Projet", "budget": -100, "client_id": 1},
            headers=auth_headers
        )
        assert response.status_code == 422

    async def test_get_project_not_found(self, client: AsyncClient, auth_headers):
        """Projet inexistant -> 404."""
        response = await client.get("/api/v1/projects/99999", headers=auth_headers)
        assert response.status_code == 404

    async def test_list_projects_pagination(self, client: AsyncClient, auth_headers, override_db):
        """Test de la pagination."""
        # Crée 15 projets
        from app.models.project import Project
        from app.models.client import Client

        test_client = Client(name="Client", email="c@test.com")
        override_db.add(test_client)
        override_db.commit()

        for i in range(15):
            project = Project(
                name=f"Projet {i}",
                budget=1000,
                client_id=test_client.id,
                owner_id=1  # ID du test_user
            )
            override_db.add(project)
        override_db.commit()

        # Page 1 : 10 projets
        response = await client.get("/api/v1/projects/?page=1&limit=10", headers=auth_headers)
        assert response.status_code == 200
        data = response.json()
        assert data["total"] == 15
        assert len(data["items"]) == 10
        assert data["has_next"] == True
        assert data["pages"] == 2

        # Page 2 : 5 projets
        response = await client.get("/api/v1/projects/?page=2&limit=10", headers=auth_headers)
        assert len(response.json()["items"]) == 5
```

---

## 19.6 Rapport de Couverture

```bash
# Lance tous les tests avec rapport de couverture
pytest app/tests/ -v --cov=app --cov-report=html --cov-report=term-missing

# Options :
# -v          : mode verbeux (affiche chaque test)
# --cov=app   : mesure la couverture du dossier 'app'
# --cov-report=html  : génère un rapport HTML dans htmlcov/
# --cov-report=term-missing : affiche les lignes non couvertes

# Objectif minimal : 80% de couverture
# Ouvre htmlcov/index.html pour le rapport visuel
```

---

## 19.7 Exercice de Complétion — Chapitre 19

Écris les tests pour les routes clients :

```python
# app/tests/test_clients.py

import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
class TestClients:

    # TODO 1 : test_list_clients_success
    # Crée 3 clients en DB, vérifie que GET /clients/ retourne bien 3 éléments
    async def test_list_clients_success(self, client: AsyncClient, auth_headers, override_db):
        from app.models.client import Client
        for i in range(3):
            ___.add(Client(name=f"Client {i}", email=f"client{i}@test.com"))
        override_db.___()

        response = await client.get("/api/v1/clients/", headers=___)
        assert response.status_code == ___
        assert len(response.json()["items"]) == ___

    # TODO 2 : test_create_client_duplicate_email
    # Crée un client, puis essaie de créer un autre avec le même email -> 400
    async def test_create_client_duplicate_email(self, client, auth_headers, override_db):
        pass  # À compléter

    # TODO 3 : test_delete_client_not_owner
    # Crée un client avec user A, essaie de le supprimer avec user B -> 403
    async def test_delete_client_not_owner(self, client, auth_headers, override_db):
        pass  # À compléter
```

---

# Chapitre 20 : Logging & Monitoring

## 20.1 Configuration du Logging

```python
# app/core/logging_config.py

import logging
import sys
from app.core.config import settings

def setup_logging():
    """Configure le système de logging pour BuildrAPI."""

    # Format pour le développement (lisible par un humain)
    dev_format = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"

    # Format JSON pour la production (lisible par Datadog, ELK, etc.)
    prod_format = '{"time": "%(asctime)s", "level": "%(levelname)s", "logger": "%(name)s", "message": "%(message)s"}'

    log_format = prod_format if settings.ENV == "production" else dev_format

    logging.basicConfig(
        level=logging.INFO if settings.ENV == "production" else logging.DEBUG,
        format=log_format,
        handlers=[
            logging.StreamHandler(sys.stdout),  # Console
        ]
    )

    # Réduit le bruit des bibliothèques tierces
    logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
    logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)

# Dans main.py :
# from app.core.logging_config import setup_logging
# setup_logging()

# Dans les routes/services :
logger = logging.getLogger(__name__)

def some_function():
    logger.info("Début du traitement du projet #42")
    logger.warning("Budget dépassé pour le projet #42")
    logger.error("Échec de l'envoi de l'email", exc_info=True)
```

---

## 20.2 Intégration avec Sentry

```bash
pip install sentry-sdk[fastapi]
```

```python
# app/main.py

import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration

if settings.ENV == "production":
    sentry_sdk.init(
        dsn=settings.SENTRY_DSN,  # Récupéré depuis sentry.io
        integrations=[
            FastApiIntegration(),      # Capture les exceptions FastAPI
            SqlalchemyIntegration(),   # Trace les requêtes SQL lentes
        ],
        traces_sample_rate=0.1,    # Sample 10% des requêtes pour le tracing
        environment=settings.ENV
    )
```

---

# Chapitre 21 : Dockerisation

## 21.1 Dockerfile Optimisé

```dockerfile
# Dockerfile

# ─── Étape 1 : Builder ────────────────────────────────────────────────────
# Image avec tous les outils de build (plus lourde)
FROM python:3.11-slim AS builder

WORKDIR /app

# Copie et installe les dépendances (séparé du code pour le cache Docker)
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt


# ─── Étape 2 : Production ────────────────────────────────────────────────
# Image finale légère (sans les outils de build)
FROM python:3.11-slim AS production

WORKDIR /app

# Utilisateur non-root pour la sécurité
RUN adduser --disabled-password --gecos "" appuser

# Copie les dépendances installées depuis le builder
COPY --from=builder /install /usr/local

# Copie le code de l'application
COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini .

# Change le propriétaire des fichiers
RUN chown -R appuser:appuser /app

USER appuser

# Variables d'environnement par défaut
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

EXPOSE 8000

# Gunicorn + Uvicorn workers = production-ready
# -w 4 = 4 workers (règle = 2 × nb_CPU + 1)
CMD ["gunicorn", "app.main:app", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--workers", "4", \
     "--bind", "0.0.0.0:8000", \
     "--timeout", "120", \
     "--access-logfile", "-"]
```

---

## 21.2 docker-compose.yml

```yaml
# docker-compose.yml

version: "3.9"

services:
  # ─── API FastAPI ────────────────────────────────────────────────────────
  api:
    build:
      context: .
      target: production
    container_name: buildrapi
    restart: unless-stopped
    env_file:
      - .env.production    # Variables d'environnement de prod
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy  # Attend que PostgreSQL soit prêt
      redis:
        condition: service_healthy
    volumes:
      - uploads:/app/uploads  # Stockage persistant des fichiers uploadés

  # ─── PostgreSQL ─────────────────────────────────────────────────────────
  db:
    image: postgres:15-alpine
    container_name: buildrapi-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: buildrapi
      POSTGRES_PASSWORD: ${DB_PASSWORD}  # Depuis .env
      POSTGRES_DB: buildrapi
    volumes:
      - postgres_data:/var/lib/postgresql/data  # Persistance des données
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "buildrapi"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ─── Redis ──────────────────────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: buildrapi-redis
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s

  # ─── Celery Worker ──────────────────────────────────────────────────────
  celery_worker:
    build:
      context: .
      target: production
    command: celery -A app.celery_app worker --loglevel=info
    depends_on:
      - redis
      - db
    env_file:
      - .env.production

  # ─── Nginx (Reverse Proxy) ───────────────────────────────────────────────
  nginx:
    image: nginx:alpine
    container_name: buildrapi-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro  # Certificats SSL

volumes:
  postgres_data:
  uploads:
```

---

## 21.3 Configuration Nginx

```nginx
# nginx/nginx.conf

events {
    worker_connections 1024;
}

http {
    upstream buildrapi {
        server api:8000;
        # Avec plusieurs instances :
        # server api1:8000;
        # server api2:8000;
    }

    server {
        listen 80;
        server_name buildrapi.com www.buildrapi.com;

        # Redirection HTTP -> HTTPS
        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl;
        server_name buildrapi.com;

        # Certificats SSL (Let's Encrypt recommandé)
        ssl_certificate /etc/nginx/ssl/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/privkey.pem;

        # Sécurité SSL
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512;

        # Taille max des uploads
        client_max_body_size 10M;

        location / {
            proxy_pass http://buildrapi;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # WebSocket support
        location /ws/ {
            proxy_pass http://buildrapi;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
        }
    }
}
```

```bash
# Commandes Docker utiles

# Construire et lancer tout
docker-compose up --build -d

# Voir les logs de l'API
docker-compose logs -f api

# Exécuter les migrations Alembic
docker-compose exec api alembic upgrade head

# Shell interactif dans le container
docker-compose exec api bash

# Arrêter tout
docker-compose down

# Arrêter et supprimer les données
docker-compose down -v
```

---

# Chapitre 22 : CI/CD avec GitHub Actions

## 22.1 Pipeline Complet

```yaml
# .github/workflows/main.yml

name: BuildrAPI CI/CD

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  DOCKER_IMAGE: ghcr.io/${{ github.repository }}/buildrapi

jobs:
  # ─── Job 1 : Tests ────────────────────────────────────────────────────
  test:
    name: Tests & Lint
    runs-on: ubuntu-latest

    services:
      # Service PostgreSQL pour les tests d'intégration
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: buildrapi_test
        ports:
          - 5432:5432
        options: --health-cmd pg_isready --health-interval 10s

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python 3.11
        uses: actions/setup-python@v4
        with:
          python-version: "3.11"
          cache: "pip"  # Cache les dépendances pip

      - name: Install dependencies
        run: pip install -r requirements.txt -r requirements-dev.txt

      - name: Lint avec ruff
        run: ruff check app/

      - name: Format check avec black
        run: black --check app/

      - name: Run tests avec coverage
        env:
          DATABASE_URL: postgresql+asyncpg://test:test@localhost/buildrapi_test
          REDIS_URL: redis://localhost:6379
          SECRET_KEY: test-secret-key-for-ci-only
        run: |
          pytest app/tests/ \
            --cov=app \
            --cov-report=xml \
            --cov-fail-under=80 \
            -v

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage.xml


  # ─── Job 2 : Build Docker ────────────────────────────────────────────
  build:
    name: Build & Push Docker Image
    needs: test  # S'exécute seulement si les tests passent
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'  # Seulement sur la branche main

    steps:
      - uses: actions/checkout@v4

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.DOCKER_IMAGE }}:latest
            ${{ env.DOCKER_IMAGE }}:${{ github.sha }}
          cache-from: type=gha  # Cache entre les runs
          cache-to: type=gha,mode=max


  # ─── Job 3 : Déploiement ─────────────────────────────────────────────
  deploy:
    name: Deploy to Production
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production  # Environnement avec approbation manuelle

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cd /opt/buildrapi
            docker-compose pull
            docker-compose up -d --no-build
            docker-compose exec -T api alembic upgrade head
            echo "[OK] Déploiement terminé !"
```

---

## 22.2 Requirements Files

```
# requirements.txt (production)
fastapi==0.104.1
uvicorn[standard]==0.24.0
gunicorn==21.2.0
sqlalchemy==2.0.23
asyncpg==0.29.0
alembic==1.12.1
pydantic[email]==2.5.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
aiofiles==23.2.1
redis==5.0.1
fastapi-cache2[redis]==0.2.1
celery==5.3.4
fastapi-mail==1.4.1
reportlab==4.0.7
sentry-sdk[fastapi]==1.38.0
```

```
# requirements-dev.txt (développement uniquement)
pytest==7.4.3
pytest-asyncio==0.21.1
httpx==0.25.2
pytest-cov==4.1.0
black==23.11.0
ruff==0.1.6
```

---

## 22.3 [OBJECTIF] Mini-Projet BuildrAPI — Fin de Partie 6

**Checklist production :**

```bash
# 1. Tests
pytest app/tests/ --cov=app --cov-fail-under=80
# -> Doit afficher PASSED avec 80%+ de couverture

# 2. Lint et formatage
black app/
ruff check app/

# 3. Build Docker
docker build -t buildrapi:latest .
docker run -p 8000:8000 --env-file .env.production buildrapi:latest

# 4. Test de charge basique
# (pip install locust)
locust -f locustfile.py --headless -u 100 -r 10 --run-time 60s
```

```python
# locustfile.py — Test de charge minimal

from locust import HttpUser, task, between

class BuildrAPIUser(HttpUser):
    wait_time = between(1, 3)  # Attente entre les requêtes

    def on_start(self):
        """Connexion au démarrage."""
        response = self.client.post("/api/v1/auth/login", data={
            "username": "loadtest@test.com",
            "password": "LoadTest123"
        })
        token = response.json()["access_token"]
        self.headers = {"Authorization": f"Bearer {token}"}

    @task(3)  # 3x plus fréquent que les autres tasks
    def list_projects(self):
        self.client.get("/api/v1/projects/", headers=self.headers)

    @task(1)
    def create_project(self):
        self.client.post("/api/v1/projects/", json={
            "name": "Projet load test",
            "budget": 1000,
            "client_id": 1
        }, headers=self.headers)
```

---

> **-> Prochaine étape : `07_PARTIE7_EXPERT.md`** — Architecture hexagonale, microservices, observabilité, niveau expert.

# [SCIENCE] PARTIE 7 — Niveau Expert : Architecture & Scalabilité
## Chapitres 23 à 26 : Hexagonal, Microservices, Observabilité, Best Practices

> **Objectif :** Maîtriser les concepts d'architecture avancée pour construire des systèmes qui résistent à l'échelle, à l'équipe, et au temps. C'est ici que tu passes du statut de développeur à architecte.

---

# Chapitre 23 : Architecture Avancée

## 23.1 Le Problème de l'Architecture Naïve

Voici l'architecture qu'on a construite jusqu'ici (et qui est déjà très bien pour un projet solo) :

```
Routes -> Services -> ORM -> Base de données
```

Le problème : **tout est couplé à la base de données**. Si tu veux :
- Changer de PostgreSQL vers MongoDB -> tout réécrire
- Tester sans base de données -> difficile
- Réutiliser la logique métier dans un CLI ou une tâche Celery -> couplage HTTP/FastAPI

La solution : **Architecture Hexagonale**.

---

## 23.2 Architecture Hexagonale (Ports & Adapters)

```
┌─────────────────────────────────────────────────────────┐
│                    ADAPTERS PRIMAIRES                    │
│          (comment on appelle le système)                 │
│   [FastAPI Routes]  [Celery Tasks]  [CLI Commands]       │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────[BLACK_DOWN-POINTING_TRIANGLE]────────────────────────────────┐
│                      PORTS PRIMAIRES                     │
│                   (interfaces d'entrée)                  │
│         [ProjectService]  [AuthService]  [EmailPort]     │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────[BLACK_DOWN-POINTING_TRIANGLE]────────────────────────────────┐
│                    DOMAINE (CŒUR)                        │
│      Logique métier pure — pas de dépendances externes   │
│    [Project Entity]  [User Entity]  [Invoice Rules]      │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────[BLACK_DOWN-POINTING_TRIANGLE]────────────────────────────────┐
│                     PORTS SECONDAIRES                    │
│                  (interfaces de sortie)                  │
│    [ProjectRepository]  [EmailSender]  [FileStorage]     │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────[BLACK_DOWN-POINTING_TRIANGLE]────────────────────────────────┐
│                    ADAPTERS SECONDAIRES                  │
│          (implémentations concrètes)                     │
│  [PostgresRepo]  [SMTPEmailSender]  [S3FileStorage]      │
└─────────────────────────────────────────────────────────┘
```

---

## 23.3 Implémentation : Repository Pattern

```python
# app/domain/entities/project.py — L'ENTITÉ (domaine pur, pas de SQLAlchemy)

from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from enum import Enum


class ProjectStatus(str, Enum):
    PENDING = "pending"
    ACTIVE = "active"
    COMPLETED = "completed"


@dataclass
class ProjectEntity:
    """
    Entité de domaine Project.
    PAS d'import SQLAlchemy, PAS d'import FastAPI.
    Logique métier pure, testable sans infrastructure.
    """
    name: str
    budget: float
    client_id: int
    owner_id: int
    id: Optional[int] = None
    status: ProjectStatus = ProjectStatus.PENDING
    description: Optional[str] = None
    created_at: datetime = field(default_factory=datetime.utcnow)

    def activate(self) -> None:
        """Règle métier : on peut activer seulement si budget > 0."""
        if self.budget <= 0:
            raise ValueError("Impossible d'activer un projet sans budget")
        self.status = ProjectStatus.ACTIVE

    def complete(self) -> None:
        """Règle métier : on ne peut compléter que si actif."""
        if self.status != ProjectStatus.ACTIVE:
            raise ValueError(f"Impossible de compléter un projet {self.status.value}")
        self.status = ProjectStatus.COMPLETED

    @property
    def is_overdue(self) -> bool:
        """Calcul métier : le projet est-il en retard ?"""
        if not hasattr(self, "deadline") or not self.deadline:
            return False
        return datetime.utcnow() > self.deadline and self.status != ProjectStatus.COMPLETED
```

```python
# app/domain/ports/project_repository.py — L'INTERFACE (port)

from abc import ABC, abstractmethod
from typing import List, Optional
from app.domain.entities.project import ProjectEntity


class ProjectRepositoryPort(ABC):
    """
    Interface abstraite pour le repository des projets.
    Définit le CONTRAT — pas l'implémentation.
    Le domaine dépend de cette interface, pas de PostgreSQL.
    """

    @abstractmethod
    async def get_by_id(self, project_id: int) -> Optional[ProjectEntity]:
        """Récupère un projet par ID, ou None si introuvable."""
        ...

    @abstractmethod
    async def list_by_owner(
        self, owner_id: int, skip: int = 0, limit: int = 10
    ) -> List[ProjectEntity]:
        """Liste les projets d'un utilisateur."""
        ...

    @abstractmethod
    async def save(self, project: ProjectEntity) -> ProjectEntity:
        """Crée ou met à jour un projet."""
        ...

    @abstractmethod
    async def delete(self, project_id: int) -> None:
        """Supprime un projet."""
        ...
```

```python
# app/infrastructure/repositories/postgres_project_repository.py
# L'ADAPTER (implémentation concrète du port)

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from typing import List, Optional

from app.domain.ports.project_repository import ProjectRepositoryPort
from app.domain.entities.project import ProjectEntity
from app.models.project import Project as ProjectORM


class PostgresProjectRepository(ProjectRepositoryPort):
    """
    Implémentation PostgreSQL du ProjectRepositoryPort.
    Fait la traduction ORM <-> Entity.
    """

    def __init__(self, db: AsyncSession):
        self.db = db

    async def get_by_id(self, project_id: int) -> Optional[ProjectEntity]:
        result = await self.db.execute(
            select(ProjectORM).where(ProjectORM.id == project_id)
        )
        orm_project = result.scalar_one_or_none()
        if not orm_project:
            return None
        return self._to_entity(orm_project)

    async def save(self, project: ProjectEntity) -> ProjectEntity:
        if project.id:
            # Mise à jour
            orm_project = await self.db.get(ProjectORM, project.id)
            for field, value in self._to_dict(project).items():
                setattr(orm_project, field, value)
        else:
            # Création
            orm_project = ProjectORM(**self._to_dict(project))
            self.db.add(orm_project)

        await self.db.commit()
        await self.db.refresh(orm_project)
        return self._to_entity(orm_project)

    def _to_entity(self, orm: ProjectORM) -> ProjectEntity:
        """Convertit un modèle ORM en entité de domaine."""
        return ProjectEntity(
            id=orm.id,
            name=orm.name,
            budget=orm.budget,
            status=orm.status,
            client_id=orm.client_id,
            owner_id=orm.owner_id,
            created_at=orm.created_at
        )

    def _to_dict(self, entity: ProjectEntity) -> dict:
        """Convertit une entité de domaine en dict pour l'ORM."""
        return {
            "name": entity.name,
            "budget": entity.budget,
            "status": entity.status,
            "client_id": entity.client_id,
            "owner_id": entity.owner_id,
        }
```

```python
# app/services/project_service.py — LE SERVICE (logique métier)

from fastapi import HTTPException
from app.domain.ports.project_repository import ProjectRepositoryPort
from app.domain.entities.project import ProjectEntity, ProjectStatus


class ProjectService:
    """
    Service de domaine : orchestre les entités et les ports.
    Ne connaît pas FastAPI, SQLAlchemy, ou PostgreSQL.
    """

    def __init__(self, repository: ProjectRepositoryPort):
        # Injection de dépendance par constructeur (pas de Depends FastAPI ici)
        self.repo = repository

    async def activate_project(self, project_id: int, user_id: int) -> ProjectEntity:
        """
        Logique métier : activer un projet.
        Vérifie les permissions, appelle l'entité, persiste.
        """
        project = await self.repo.get_by_id(project_id)
        if not project:
            raise HTTPException(status_code=404, detail="Projet introuvable")

        if project.owner_id != user_id:
            raise HTTPException(status_code=403, detail="Permission refusée")

        # La règle métier est dans l'entité, pas ici
        try:
            project.activate()
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e))

        return await self.repo.save(project)
```

```python
# Comment relier tout ça à FastAPI

from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.async_session import get_async_db
from app.infrastructure.repositories.postgres_project_repository import PostgresProjectRepository
from app.services.project_service import ProjectService

router = APIRouter()

def get_project_service(db: AsyncSession = Depends(get_async_db)) -> ProjectService:
    """Factory qui crée le service avec l'implémentation concrète."""
    repo = PostgresProjectRepository(db)
    return ProjectService(repo)


@router.post("/{project_id}/activate")
async def activate_project(
    project_id: int,
    service: ProjectService = Depends(get_project_service),
    current_user = Depends(get_current_active_user)
):
    project = await service.activate_project(project_id, current_user.id)
    return project
```

> [LOGIQUE] **Point clé :** Grâce à cette architecture, tu peux tester `ProjectService` avec un **repository mock** (une fausse implémentation) — sans base de données, sans FastAPI, en pur Python. Et si tu veux passer à MongoDB, tu crées juste un `MongoProjectRepository` qui implémente la même interface.

---

# Chapitre 24 : Microservices avec FastAPI

## 24.1 Quand Passer aux Microservices ?

```
Ne commence PAS par les microservices. Commence par un monolithe.
Passe aux microservices quand tu as un VRAI problème de scalabilité.

Signes qu'il est temps :
[OK] Ton équipe grandit (5+ développeurs)
[OK] Certaines parties de l'app ont des besoins de scaling différents
  (ex: le module email est 10x moins sollicité que les projets)
[OK] Tu veux déployer des parties indépendamment
[OK] Tu as besoin de différents langages pour différentes parties
```

---

## 24.2 Architecture Microservices de BuildrAPI

```
┌───────────────────────────────────────────────────────────┐
│                     API GATEWAY (Nginx/Kong)              │
│         Load balancing, Auth centralisée, Rate limiting   │
└───────┬──────────┬──────────┬───────────┬────────────────┘
        │          │          │           │
   ┌────[BLACK_DOWN-POINTING_TRIANGLE]───┐ ┌───[BLACK_DOWN-POINTING_TRIANGLE]────┐ ┌──[BLACK_DOWN-POINTING_TRIANGLE]─────┐ ┌──[BLACK_DOWN-POINTING_TRIANGLE]──────┐
   │ Auth   │ │Project │ │Invoice │ │Notif.   │
   │Service │ │Service │ │Service │ │Service  │
   │:8001   │ │:8002   │ │:8003   │ │:8004    │
   └────┬───┘ └───┬────┘ └──┬─────┘ └──┬──────┘
        │         │          │           │
   ┌────[BLACK_DOWN-POINTING_TRIANGLE]─────────[BLACK_DOWN-POINTING_TRIANGLE]──────────[BLACK_DOWN-POINTING_TRIANGLE]───────────[BLACK_DOWN-POINTING_TRIANGLE]──────┐
   │              Message Broker (RabbitMQ)       │
   │     Events : user_created, project_updated   │
   └───────────────────────────────────────────────┘
```

---

## 24.3 Communication Inter-Services avec httpx

```python
# app/clients/project_client.py — Client HTTP pour le ProjectService

import httpx
from typing import List, Optional
from app.core.config import settings


class ProjectServiceClient:
    """
    Client HTTP pour communiquer avec le Project microservice.
    Encapsule tous les appels HTTP dans une interface Python propre.
    """

    def __init__(self, base_url: str = settings.PROJECT_SERVICE_URL):
        self.base_url = base_url
        # Client httpx async avec timeout et retry
        self._client = httpx.AsyncClient(
            base_url=base_url,
            timeout=10.0,
            headers={"X-Service": "auth-service"}
        )

    async def get_user_projects(self, user_id: int, token: str) -> List[dict]:
        """Récupère les projets d'un utilisateur via le Project Service."""
        try:
            response = await self._client.get(
                f"/api/v1/projects/",
                headers={"Authorization": f"Bearer {token}"}
            )
            response.raise_for_status()
            return response.json()

        except httpx.TimeoutException:
            # Le service est lent -> circuit breaker
            raise HTTPException(
                status_code=503,
                detail="Project Service indisponible (timeout)"
            )
        except httpx.HTTPStatusError as e:
            raise HTTPException(
                status_code=e.response.status_code,
                detail=e.response.json().get("detail")
            )

    async def close(self):
        await self._client.aclose()


# Utilisation dans les routes
@router.get("/dashboard")
async def get_dashboard(
    current_user: User = Depends(get_current_active_user),
    project_client: ProjectServiceClient = Depends(lambda: ProjectServiceClient())
):
    projects = await project_client.get_user_projects(current_user.id, token)
    return {"user": current_user, "projects": projects}
```

---

## 24.4 Event-Driven avec RabbitMQ / Kafka

```python
# app/events/publisher.py — Publie des événements

import aio_pika
import json
from app.core.config import settings


class EventPublisher:
    """Publie des événements dans RabbitMQ pour les autres services."""

    def __init__(self):
        self.connection = None
        self.channel = None

    async def connect(self):
        self.connection = await aio_pika.connect_robust(settings.RABBITMQ_URL)
        self.channel = await self.connection.channel()

    async def publish(self, event_type: str, payload: dict):
        """Publie un événement dans l'exchange."""
        message = aio_pika.Message(
            body=json.dumps({
                "event_type": event_type,
                "payload": payload,
                "timestamp": datetime.utcnow().isoformat()
            }).encode(),
            content_type="application/json",
            delivery_mode=aio_pika.DeliveryMode.PERSISTENT  # Persisté même si broker redémarre
        )
        await self.channel.default_exchange.publish(
            message,
            routing_key=f"buildrapi.{event_type}"
        )


publisher = EventPublisher()

# Exemple d'utilisation : quand un projet est créé, notifier les autres services
@router.post("/projects/")
async def create_project(project_in: ProjectCreate, ...):
    project = await project_service.create_project(...)

    # Publie l'événement (non-bloquant)
    await publisher.publish("project.created", {
        "project_id": project.id,
        "owner_id": project.owner_id,
        "client_id": project.client_id
    })

    return project
```

---

# Chapitre 25 : Observabilité & Performance

## 25.1 OpenTelemetry — Tracing Distribué

```bash
pip install opentelemetry-sdk opentelemetry-instrumentation-fastapi opentelemetry-exporter-jaeger
```

```python
# app/core/tracing.py

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor


def setup_tracing(app):
    """Configure OpenTelemetry pour le tracing distribué."""

    # Exporte vers Jaeger (ou Tempo, Honeycomb, Datadog...)
    jaeger_exporter = JaegerExporter(
        agent_host_name="jaeger",
        agent_port=6831,
    )

    provider = TracerProvider()
    provider.add_span_processor(BatchSpanProcessor(jaeger_exporter))
    trace.set_tracer_provider(provider)

    # Instrumente FastAPI automatiquement (toutes les routes sont tracées)
    FastAPIInstrumentor.instrument_app(app)

    # Instrumente SQLAlchemy (toutes les requêtes SQL sont tracées)
    SQLAlchemyInstrumentor().instrument()

    return trace.get_tracer("buildrapi")


# Tracing manuel dans du code business
tracer = trace.get_tracer("buildrapi")

async def generate_monthly_report(user_id: int):
    with tracer.start_as_current_span("generate_monthly_report") as span:
        span.set_attribute("user.id", user_id)

        # Sous-span pour la requête DB
        with tracer.start_as_current_span("fetch_projects"):
            projects = await fetch_projects(user_id)
            span.set_attribute("projects.count", len(projects))

        # Sous-span pour la génération PDF
        with tracer.start_as_current_span("generate_pdf"):
            pdf = generate_pdf(projects)

        return pdf
```

---

## 25.2 Métriques Prometheus

```bash
pip install prometheus-fastapi-instrumentator
```

```python
# app/main.py

from prometheus_fastapi_instrumentator import Instrumentator

# Ajoute automatiquement les métriques HTTP standards :
# - http_requests_total (par méthode, chemin, code de réponse)
# - http_request_duration_seconds (latence)
# - http_requests_in_progress (requêtes en cours)
Instrumentator().instrument(app).expose(app)

# Métriques custom avec prometheus_client
from prometheus_client import Counter, Histogram, Gauge
import time

projects_created_total = Counter(
    "buildrapi_projects_created_total",
    "Nombre total de projets créés",
    ["status"]  # Label : par statut initial
)

invoice_generation_duration = Histogram(
    "buildrapi_invoice_pdf_duration_seconds",
    "Durée de génération des PDFs de facture"
)

active_websocket_connections = Gauge(
    "buildrapi_active_websocket_connections",
    "Nombre de connexions WebSocket actives"
)

# Utilisation
@router.post("/projects/")
async def create_project(project_in: ProjectCreate, ...):
    project = await project_service.create_project(...)
    projects_created_total.labels(status=project.status.value).inc()
    return project
```

---

# Chapitre 26 : Bonnes Pratiques Finales

## 26.1 Convention de Code — Checklist Pro

```python
# [OK] NOMMAGE
# Routes : noms pluriels pour les collections
# /projects (pas /project), /users (pas /user)

# Fonctions : verbes explicites
async def get_project_by_id(...)   # [OK]
async def project(...)              # [X]

# Variables : snake_case, descriptives
project_id = 42             # [OK]
pid = 42                    # [X]

# [OK] TYPES — TOUJOURS typer
async def create_project(project_in: ProjectCreate) -> ProjectResponse:  # [OK]
async def create_project(project_in, ...):                                # [X]

# [OK] DOCSTRINGS sur toutes les fonctions publiques
async def activate_project(project_id: int, user_id: int) -> ProjectEntity:
    """
    Active un projet en attente.

    Args:
        project_id: ID du projet à activer
        user_id: ID de l'utilisateur faisant la demande

    Returns:
        Le projet mis à jour

    Raises:
        HTTPException 404: Projet introuvable
        HTTPException 403: Pas les permissions
        HTTPException 400: Budget insuffisant pour activer
    """
    ...

# [OK] CONSTANTES en majuscules dans config.py
MAX_UPLOAD_SIZE_MB = 10
MAX_PROJECTS_PER_USER = 50

# [OK] PAS de magic numbers dans le code
if file_size > 10 * 1024 * 1024:  # [X]
if file_size > MAX_UPLOAD_SIZE_MB * 1024 * 1024:  # [OK]
```

---

## 26.2 Revue de Code — Checklist pour les PRs

```markdown
# BuildrAPI PR Checklist

## Fonctionnel
- [ ] Le code fait ce qui est décrit dans la PR
- [ ] Les cas limites sont gérés (None, vide, valeurs max)
- [ ] Les erreurs renvoient les bons codes HTTP

## Sécurité
- [ ] Pas de secrets dans le code
- [ ] Les routes sont correctement protégées (auth/permissions)
- [ ] Les entrées utilisateur sont validées
- [ ] Pas d'exposition de données sensibles dans les réponses

## Tests
- [ ] Tests ajoutés pour les nouvelles fonctionnalités
- [ ] Tests passent localement (`pytest`)
- [ ] Couverture maintenue à 80%+

## Performance
- [ ] Pas de N+1 queries (utilise join/eager loading)
- [ ] Pagination sur les endpoints qui retournent des listes
- [ ] Cache sur les requêtes coûteuses

## Code Quality
- [ ] `black` et `ruff` passent sans erreurs
- [ ] Pas de `print()` (utilise `logging`)
- [ ] Pas de TODO non résolu dans le code mergé
- [ ] Docstrings sur les fonctions publiques
```

---

## 26.3 Structure Finale du Projet BuildrAPI — Expert

```
buildrapi/
├── app/
│   ├── main.py
│   ├── api/
│   │   ├── routes/
│   │   └── dependencies.py
│   ├── core/
│   │   ├── config.py
│   │   ├── security.py
│   │   ├── middleware.py
│   │   ├── logging_config.py
│   │   └── tracing.py
│   ├── domain/               <- Architecture hexagonale
│   │   ├── entities/         <- Entités de domaine pures
│   │   └── ports/            <- Interfaces (abstractions)
│   ├── infrastructure/       <- Implémentations concrètes
│   │   ├── repositories/     <- Adapters DB
│   │   ├── email/            <- Adapter email
│   │   └── storage/          <- Adapter S3/local
│   ├── services/             <- Logique métier
│   ├── events/               <- Publisher/Consumer RabbitMQ
│   ├── models/               <- Modèles ORM
│   ├── schemas/              <- Schémas Pydantic
│   └── tests/
│       ├── unit/             <- Tests du domaine (sans infra)
│       ├── integration/      <- Tests avec DB de test
│       └── e2e/              <- Tests end-to-end
├── alembic/                  <- Migrations DB
├── .github/
│   └── workflows/
│       └── main.yml          <- CI/CD
├── nginx/
│   └── nginx.conf
├── docker-compose.yml
├── Dockerfile
├── .env.example              <- Template sans secrets
├── .env                      <- DANS .gitignore !
└── requirements.txt
```

---

## 26.4 Exercice Final — Projet Expert

**Implémente la feature "Rapport Mensuel" en suivant l'architecture hexagonale complète :**

```python
# TODO COMPLET : Rapport mensuel pour un utilisateur

# ÉTAPE 1 : Entité de domaine
# app/domain/entities/report.py
@dataclass
class MonthlyReport:
    user_id: int
    month: int
    year: int
    # TODO : ajouter les champs (total_projects, total_revenue, completed_tasks, ...)

    def calculate_completion_rate(self) -> float:
        """TODO : règle métier = tasks_completed / tasks_total * 100"""
        pass

    def is_profitable_month(self, target_revenue: float) -> bool:
        """TODO : règle métier = total_revenue >= target_revenue"""
        pass


# ÉTAPE 2 : Port (interface)
# app/domain/ports/report_repository.py
class ReportRepositoryPort(ABC):
    @abstractmethod
    async def generate_monthly_report(
        self, user_id: int, month: int, year: int
    ) -> MonthlyReport:
        ...


# ÉTAPE 3 : Adapter (implémentation PostgreSQL)
# app/infrastructure/repositories/postgres_report_repository.py
class PostgresReportRepository(ReportRepositoryPort):
    async def generate_monthly_report(self, user_id, month, year) -> MonthlyReport:
        # TODO : requêtes SQL agrégées (COUNT, SUM, AVG...)
        pass


# ÉTAPE 4 : Service
# app/services/report_service.py
class ReportService:
    def __init__(self, repo: ReportRepositoryPort, email_port: EmailPort):
        self.repo = repo
        self.email = email_port

    async def generate_and_send_report(self, user_id: int, month: int, year: int):
        # TODO : génère le rapport + PDF + envoie par email
        pass


# ÉTAPE 5 : Route FastAPI
# app/api/routes/reports.py
@router.get("/reports/monthly/{year}/{month}", response_model=MonthlyReportResponse)
async def get_monthly_report(
    year: int = Path(..., ge=2020, le=2030),
    month: int = Path(..., ge=1, le=12),
    send_email: bool = Query(False),
    service: ReportService = Depends(get_report_service),
    current_user: User = Depends(get_current_active_user)
):
    """
    Génère le rapport mensuel de l'utilisateur.
    Optionnellement, l'envoie par email.
    """
    pass


# ÉTAPE 6 : Tests
# app/tests/unit/test_monthly_report.py
class TestMonthlyReport:
    def test_completion_rate_full(self):
        """100% si toutes les tâches sont complétées."""
        pass

    def test_is_profitable_above_target(self):
        """Profitable si revenue >= target."""
        pass
```

---

## 26.5 [TROPHEE] Félicitations — Tu as terminé la Formation BuildrAPI

**Tu maîtrises maintenant :**

```
[VERT] DÉBUTANT
[OK] FastAPI : routes, méthodes HTTP, path/query params
[OK] Pydantic : validation, schémas, contraintes
[OK] Documentation Swagger automatique

[JAUNE] INTERMÉDIAIRE
[OK] Architecture modulaire avec APIRouter
[OK] SQLAlchemy : modèles ORM, CRUD, relations
[OK] Gestion des erreurs et middleware
[OK] JWT : auth, refresh tokens, permissions

[ORANGE] AVANCÉ
[OK] Async/await natif
[OK] Cache Redis
[OK] Upload/Download de fichiers
[OK] WebSockets temps réel
[OK] Celery pour les tâches longues
[OK] Envoi d'emails avec templates
[OK] Tests avec pytest + httpx
[OK] Docker + docker-compose
[OK] CI/CD GitHub Actions

[ROUGE] EXPERT
[OK] Architecture hexagonale (ports & adapters)
[OK] Microservices + communication inter-services
[OK] Event-driven avec RabbitMQ
[OK] OpenTelemetry tracing
[OK] Métriques Prometheus + Grafana
[OK] Best practices, revue de code, conventions
```

**Ton portfolio BuildrAPI est un projet démontrable en entretien.**
Il montre que tu sais construire une API complète, sécurisée, testée, et déployée.

---

> **[IDEE] Prochaines étapes recommandées :**
> - Déploie BuildrAPI sur un vrai serveur (Hetzner, OVH, DigitalOcean)
> - Ajoute un frontend (React/Vue) qui consomme ton API
> - Contribue à des projets open source FastAPI sur GitHub
> - Lis le code source de FastAPI lui-même pour comprendre la magie
> - Rejoins la communauté : Discord FastAPI, r/Python, PyConferences
