# Fichier: python_cheats/cheatsheets/api_avance.txt
# Guide Ultra-Complet sur les APIs - Du Débutant à l'Expert
# Format: POURQUOI? COMMENT? QUAND? pour chaque concept


╔══════════════════════════════════════════════════════════════════════════════╗
║                    TABLE DES MATIÈRES DÉTAILLÉE                              ║
╚══════════════════════════════════════════════════════════════════════════════╝

PARTIE 1: FONDAMENTAUX DES APIs
  1.1  Qu'est-ce qu'une API? (Analogies et Explications Détaillées)
  1.2  Architecture Client-Serveur
  1.3  Protocole HTTP en Profondeur
  1.4  Formats de Données (JSON, XML, YAML)
  1.5  REST vs SOAP vs GraphQL vs gRPC
  1.6  Codes de Statut HTTP Complets
  1.7  Headers HTTP Expliqués
  1.8  Méthodes HTTP (GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD)

PARTIE 2: CRÉATION D'APIs AVEC FLASK
  2.1  Configuration et Structure de Projet
  2.2  Routing et Endpoints
  2.3  Request Handling (Query Params, Path Params, Body)
  2.4  Response Formatting
  2.5  Error Handling et Exceptions
  2.6  Middleware et Hooks
  2.7  Blueprints pour Modularité
  2.8  Configuration Multi-Environnements

PARTIE 3: CRÉATION D'APIs AVEC FASTAPI
  3.1  Pourquoi FastAPI? Comparaison Détaillée
  3.2  Type Hints et Validation Pydantic
  3.3  Documentation Automatique (Swagger/ReDoc)
  3.4  Async/Await et Performance
  3.5  Dependency Injection
  3.6  Background Tasks
  3.7  WebSockets
  3.8  Gestion des Fichiers (Upload/Download)

PARTIE 4: BASES DE DONNÉES
  4.1  SQLAlchemy ORM Complet
  4.2  Modèles et Relations
  4.3  Migrations avec Alembic
  4.4  Transactions et ACID
  4.5  Connection Pooling
  4.6  Query Optimization
  4.7  MongoDB avec PyMongo
  4.8  Redis pour Cache

PARTIE 5: AUTHENTIFICATION & AUTORISATION
  5.1  Types d'Authentification (Basic, Bearer, API Key, OAuth)
  5.2  JWT (JSON Web Tokens) en Détail
  5.3  Session-Based Authentication
  5.4  OAuth 2.0 Flow Complet
  5.5  RBAC (Role-Based Access Control)
  5.6  ABAC (Attribute-Based Access Control)
  5.7  Rate Limiting par Utilisateur
  5.8  Refresh Tokens et Révocation

PARTIE 6: SÉCURITÉ AVANCÉE
  6.1  CORS en Profondeur
  6.2  CSRF Protection
  6.3  XSS Prevention
  6.4  SQL Injection Protection
  6.5  HTTPS et TLS/SSL
  6.6  API Keys Management
  6.7  Secrets Management
  6.8  Input Validation et Sanitization
  6.9  Rate Limiting et Throttling
  6.10 DDoS Protection

PARTIE 7: VALIDATION ET SERIALIZATION
  7.1  Pydantic Models Avancés
  7.2  Marshmallow avec Flask
  7.3  Custom Validators
  7.4  Nested Objects
  7.5  Conditional Validation
  7.6  Serialization Strategies

PARTIE 8: TESTING COMPLET
  8.1  Tests Unitaires avec Pytest
  8.2  Tests d'Intégration
  8.3  Tests E2E (End-to-End)
  8.4  Mocking et Fixtures
  8.5  Test Coverage
  8.6  Load Testing (Locust, JMeter)
  8.7  Contract Testing

PARTIE 9: DOCUMENTATION
  9.1  OpenAPI/Swagger Specification
  9.2  Auto-Documentation FastAPI
  9.3  Postman Collections
  9.4  API Blueprints
  9.5  Versioning Documentation

PARTIE 10: PERFORMANCE & OPTIMISATION
  10.1 Caching Strategies (Redis, Memcached)
  10.2 Database Indexing
  10.3 Query Optimization
  10.4 Async Programming
  10.5 Connection Pooling
  10.6 CDN Usage
  10.7 Pagination Best Practices
  10.8 Compression (gzip)
  10.9 Lazy Loading

PARTIE 11: MONITORING & LOGGING
  11.1 Structured Logging
  11.2 Log Levels et Best Practices
  11.3 Application Performance Monitoring (APM)
  11.4 Error Tracking (Sentry)
  11.5 Metrics et Alerting
  11.6 Health Checks
  11.7 Distributed Tracing

PARTIE 12: DÉPLOIEMENT
  12.1 Docker et Docker Compose
  12.2 Heroku Deployment Complet
  12.3 AWS (EC2, ECS, Lambda)
  12.4 Google Cloud Platform
  12.5 Azure Deployment
  12.6 CI/CD avec GitHub Actions
  12.7 Blue-Green Deployment
  12.8 Canary Releases

PARTIE 13: ARCHITECTURES AVANCÉES
  13.1 Microservices Architecture
  13.2 API Gateway Pattern
  13.3 Service Mesh
  13.4 Event-Driven Architecture
  13.5 CQRS Pattern
  13.6 Saga Pattern
  13.7 Circuit Breaker Pattern

PARTIE 14: APIs EXTERNES
  14.1 Consommer des APIs Tierces
  14.2 Webhooks
  14.3 Server-Sent Events (SSE)
  14.4 Long Polling vs WebSockets
  14.5 API Rate Limits Handling
  14.6 Retry Strategies
  14.7 Circuit Breaking pour APIs Externes

PARTIE 15: BEST PRACTICES & PATTERNS
  15.1 RESTful Design Principles
  15.2 Error Response Formatting
  15.3 API Versioning Strategies
  15.4 Idempotency
  15.5 HATEOAS
  15.6 Richardson Maturity Model
  15.7 API Design Guidelines

PARTIE 16: CAS PRATIQUES COMPLETS
  16.1 API de Blog avec Auth
  16.2 API E-Commerce
  16.3 API de Réservation
  16.4 API de Messagerie en Temps Réel
  16.5 API de Traitement de Fichiers
  16.6 API de Machine Learning


╔══════════════════════════════════════════════════════════════════════════════╗
║                    PARTIE 1: FONDAMENTAUX DES APIs                           ║
╚══════════════════════════════════════════════════════════════════════════════╝


═══════════════════════════════════════════════════════════════════════════════
  1.1 QU'EST-CE QU'UNE API? (ANALOGIES ET EXPLICATIONS DÉTAILLÉES)
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI comprendre ce qu'est une API?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Une API (Application Programming Interface) est le fondement de la communication
moderne entre applications. Sans APIs, il n'y aurait pas:
  • D'applications mobiles (elles communiquent toutes avec des APIs)
  • De sites web dynamiques (React/Vue/Angular appellent des APIs)
  • D'intégrations entre services (Stripe, Google Maps, etc.)
  • De microservices (ils communiquent via APIs)

[?] COMMENT fonctionne une API?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

┌─────────────────────────────────────────────────────────────────────────┐
│                          ANALOGIE DU RESTAURANT                         │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  [UTILISATEUR] CLIENT (Toi)                                                        │
│    v                                                                    │
│    v "Je veux un steak frites"                                          │
│    v                                                                    │
│  [NECKTIE] SERVEUR (API)                                                       │
│    v                                                                    │
│    v Transmet la commande                                               │
│    v                                                                    │
│  [PERSONNE][COOKING] CUISINE (Serveur Backend)                                           │
│    v                                                                    │
│    v Prépare le plat                                                    │
│    v                                                                    │
│  [NECKTIE] SERVEUR (API)                                                       │
│    v                                                                    │
│    v Apporte le plat                                                    │
│    v                                                                    │
│  [UTILISATEUR] CLIENT (Toi)                                                        │
│    [OK] Reçoit le steak frites                                             │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                       TRADUCTION EN TERMES TECHNIQUES                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  [MOBILE] CLIENT (App Mobile, Site Web, Autre Service)                        │
│    v                                                                    │
│    v REQUEST HTTP: GET /api/users/123                                   │
│    v Headers: { "Authorization": "Bearer token..." }                    │
│    v                                                                    │
│  [PLUGIN] API (Endpoint /api/users/123)                                       │
│    v                                                                    │
│    v • Vérifie l'authentification                                       │
│    v • Valide les paramètres                                            │
│    v • Appelle la logique métier                                        │
│    v                                                                    │
│  [SAUVEGARDE] SERVEUR BACKEND (Base de données, Logique)                          │
│    v                                                                    │
│    v SELECT * FROM users WHERE id = 123                                 │
│    v Récupère: { id: 123, name: "Alice", email: "..." }                 │
│    v                                                                    │
│  [PLUGIN] API                                                                 │
│    v                                                                    │
│    v RESPONSE HTTP: 200 OK                                              │
│    v Body: { "id": 123, "name": "Alice", "email": "..." }               │
│    v                                                                    │
│  [MOBILE] CLIENT                                                              │
│    [OK] Affiche les informations de l'utilisateur                        b │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

[?] QUAND utiliser une API?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] TOUJOURS dans ces cas:
  1. Application mobile <-> Backend
     Pourquoi? Les apps mobiles ne peuvent pas accéder directement aux bases de données
     
  2. Frontend (React/Vue/Angular) <-> Backend
     Pourquoi? Séparation des responsabilités, sécurité, réutilisabilité
     
  3. Microservices entre eux
     Pourquoi? Communication standardisée entre services indépendants
     
  4. Intégration de services tiers
     Pourquoi? Stripe, PayPal, Google Maps, AWS, etc.
     
  5. Applications IoT <-> Cloud
     Pourquoi? Capteurs et devices envoient des données au cloud
     
  6. Webhooks et automatisations
     Pourquoi? Notifications en temps réel entre systèmes

[X] NE PAS utiliser d'API quand:
  • Application monolithique simple sans frontend séparé
  • Communication interne directe possible (même processus)
  • Overhead de performance non justifié

[IDEE] EXEMPLE CONCRET: Application Météo
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Scénario: Tu ouvres une app météo sur ton téléphone

1⃣ CLIENT (App Mobile):
   const weather = await fetch('https://api.weather.com/v1/current?city=Paris', {
     headers: { 'API-Key': 'abc123' }
   });

2⃣ API (Endpoint):
   @app.get("/v1/current")
   def get_current_weather(city: str, api_key: str = Header(None)):
       # Vérifier l'API key
       if not validate_api_key(api_key):
           return {"error": "Invalid API key"}, 401
       
       # Interroger la base de données météo
       weather_data = db.query(Weather).filter(Weather.city == city).first()
       
       return {
           "city": city,
           "temperature": weather_data.temp,
           "condition": weather_data.condition,
           "humidity": weather_data.humidity
       }

3⃣ RÉPONSE:
   {
     "city": "Paris",
     "temperature": 18,
     "condition": "Partly Cloudy",
     "humidity": 65
   }

4⃣ CLIENT affiche: "Paris: 18°C, Partly Cloudy [CLOUD]"


═══════════════════════════════════════════════════════════════════════════════
  1.2 ARCHITECTURE CLIENT-SERVEUR
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI une architecture client-serveur?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

AVANTAGES:
  [OK] Séparation des responsabilités
    • Frontend: Interface utilisateur (UI/UX)
    • Backend: Logique métier, base de données, sécurité
    
  [OK] Scalabilité
    • Backend peut servir plusieurs clients (web, mobile, desktop)
    • Chaque composant peut scaler indépendamment
    
  [OK] Sécurité
    • Base de données jamais exposée directement
    • Authentification/autorisation centralisée
    • Validation des données côté serveur
    
  [OK] Maintenance
    • Modifier le frontend sans toucher au backend
    • Modifier le backend sans casser les clients
    • Versioning d'API pour transitions progressives
    
  [OK] Performance
    • Caching possible à plusieurs niveaux
    • Load balancing entre plusieurs serveurs
    • CDN pour les assets statiques

[?] COMMENT fonctionne l'architecture client-serveur?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

┌─────────────────────────────────────────────────────────────────────────┐
│                       ARCHITECTURE COMPLÈTE                             │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────────────┐  ┌───────────────┐  ┌───────────────┐                │
│  │  [MOBILE] Mobile    │  │  [CODE] Web App   │  │  [ECRAN] Desktop   │                │
│  │   (Swift/     │  │  (React/Vue/  │  │  (Electron/   │                │
│  │    Kotlin)    │  │   Angular)    │  │    Qt)        │                │
│  └───────┬───────┘  └───────┬───────┘  └───────┬───────┘                │
│          │                  │                  │                        │
│          └──────────────────┼──────────────────┘                        │
│                             │                                           │
│                    ┌────────[BLACK_DOWN-POINTING_TRIANGLE]────────┐                                  │
│                    │  [WEB] INTERNET     │                                  │
│                    └────────┬────────┘                                  │
│                             │                                           │
│                    ┌────────[BLACK_DOWN-POINTING_TRIANGLE]────────┐                                  │
│                    │  [VERROUILLE] API GATEWAY │ <- Rate Limiting, Auth            │
│                    │  (Nginx/Kong)   │                                  │
│                    └────────┬────────┘                                  │
│                             │                                           │
│          ┌──────────────────┼──────────────────┐                        │
│          │                  │                  │                        │
│  ┌───────[BLACK_DOWN-POINTING_TRIANGLE]───────┐  ┌───────[BLACK_DOWN-POINTING_TRIANGLE]───────┐  ┌──────[BLACK_DOWN-POINTING_TRIANGLE]──────┐                  │
│  │  [PLUGIN] API       │  │  [PLUGIN] API       │  │  [PLUGIN] API     │                  │
│  │  Instance 1   │  │  Instance 2   │  │  Instance 3 │                  │
│  │  (FastAPI/    │  │  (Flask/      │  │  (Django)   │                  │
│  │   Express)    │  │   Express)    │  │             │                  │
│  └───────┬───────┘  └───────┬───────┘  └──────┬──────┘                  │
│          │                  │                  │                        │
│          └──────────────────┼──────────────────┘                        │
│                             │                                           │
│                    ┌────────[BLACK_DOWN-POINTING_TRIANGLE]────────┐                                  │
│                    │  [ARCHIVE] DATABASE    │                                  │
│                    │  (PostgreSQL/   │                                  │
│                    │   MongoDB)      │                                  │
│                    └────────┬────────┘                                  │
│                             │                                           │
│                    ┌────────[BLACK_DOWN-POINTING_TRIANGLE]────────┐                                  │
│                    │  [PACKAGE] CACHE       │                                  │
│                    │  (Redis/        │                                  │
│                    │   Memcached)    │                                  │
│                    └─────────────────┘                                  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

[?] QUAND utiliser différentes architectures?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1⃣ MONOLITHE SIMPLE
   Quand: Petit projet, équipe réduite, pas de scalabilité nécessaire
   
   ┌─────────────────────┐
   │   [PACKAGE] APPLICATION    │
   │  ┌───────────────┐  │
   │  │  Frontend     │  │
   │  ├───────────────┤  │
   │  │  Backend      │  │
   │  ├───────────────┤  │
   │  │  Database     │  │
   │  └───────────────┘  │
   └─────────────────────┘
   
   Exemple: Blog personnel, site vitrine

2⃣ CLIENT-SERVEUR CLASSIQUE
   Quand: Application moyenne, plusieurs clients possibles
   
   ┌─────────┐         ┌──────────┐         ┌──────────┐
   │ Mobile  │────────[BLACK_RIGHT-POINTING_TRIANGLE]│   API    │────────[BLACK_RIGHT-POINTING_TRIANGLE]│ Database │
   └─────────┘         └──────────┘         └──────────┘
   ┌─────────┐               [BLACK_UP-POINTING_TRIANGLE]
   │   Web   │───────────────┘
   └─────────┘
   
   Exemple: Application de gestion, e-commerce standard

3⃣ MICROSERVICES
   Quand: Grande application, équipes multiples, haute disponibilité
   
   ┌────────┐     ┌──────────────┐     ┌──────────────┐
   │ Client │────[BLACK_RIGHT-POINTING_TRIANGLE]│ API Gateway  │────[BLACK_RIGHT-POINTING_TRIANGLE]│  User Service│
   └────────┘     └──────────────┘     └──────────────┘
                          │            ┌──────────────┐
                          ├───────────[BLACK_RIGHT-POINTING_TRIANGLE]│Order Servicen│
                          │            └──────────────┘
                          │             ┌──────────────┐
                          └────────────[BLACK_RIGHT-POINTING_TRIANGLE]│Payment Servic│
                                        └──────────────┘
   
   Exemple: Netflix, Uber, Amazon

4⃣ SERVERLESS
   Quand: Trafic variable, pas de gestion serveur souhaitée
   
   ┌────────┐     ┌─────────────┐     ┌──────────────┐
   │ Client │────[BLACK_RIGHT-POINTING_TRIANGLE]│ API Gateway │────[BLACK_RIGHT-POINTING_TRIANGLE]│AWS Lambda Fn │
   └────────┘     └─────────────┘     └──────────────┘
                                              │
                                      ┌───────[BLACK_DOWN-POINTING_TRIANGLE]──────┐
                                      │  DynamoDB    │
                                      └──────────────┘
   
   Exemple: Webhooks, traitement d'images, tâches ponctuelles


═══════════════════════════════════════════════════════════════════════════════
  1.3 PROTOCOLE HTTP EN PROFONDEUR
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI HTTP?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HTTP (HyperText Transfer Protocol) est LE protocole standard du web car:
  [OK] Universel: Fonctionne partout (navigateurs, mobiles, IoT)
  [OK] Simple: Format texte lisible par humains
  [OK] Stateless: Chaque requête est indépendante
  [OK] Flexible: Extensible via headers
  [OK] Mature: Existe depuis 1991, éprouvé

[?] COMMENT fonctionne une requête HTTP complète?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

┌─────────────────────────────────────────────────────────────────────────┐
│                     ANATOMIE D'UNE REQUÊTE HTTP                         │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  POST /api/v1/users HTTP/1.1                    <- REQUEST LINE          │
│  Host: api.example.com                           <- HEADERS              │
│  Content-Type: application/json                                         │
│  Content-Length: 87                                                     │
│  Authorization: Bearer eyJhbGciOiJIUzI1NiIs...                          │
│  User-Agent: Mozilla/5.0 (Windows NT 10.0)                              │
│  Accept: application/json                                               │
│  Accept-Encoding: gzip, deflate                                         │
│  Connection: keep-alive                                                 │
│                                                  <- BLANK LINE           │
│  {                                               <- BODY                 │
│    "name": "Alice Dupont",                                              │
│    "email": "alice@example.com",                                        │
│    "password": "SecurePass123!"                                         │
│  }                                                                      │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                     ANATOMIE D'UNE RÉPONSE HTTP                         │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  HTTP/1.1 201 Created                            <- STATUS LINE         │
│  Content-Type: application/json                  <- HEADERS             │
│  Content-Length: 156                                                   │
│  Location: /api/v1/users/12345                                         │
│  X-RateLimit-Remaining: 99                                             │
│  X-Request-ID: abc-123-def-456                                         │
│  Date: Sat, 13 Dec 2025 10:30:00 GMT                                   │
│  Server: nginx/1.21.0                                                  │
│                                                  <- BLANK LINE           │
│  {                                               <- BODY                 │
│    "id": 12345,                                                        │
│    "name": "Alice Dupont",                                             │
│    "email": "alice@example.com",                                       │
│    "created_at": "2025-12-13T10:30:00Z",                               │
│    "links": {                                                          │
│      "self": "/api/v1/users/12345",                                    │
│      "posts": "/api/v1/users/12345/posts"                              │
│    }                                                                   │
│  }                                                                     │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

[?] QUAND utiliser chaque méthode HTTP?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

┌───────────────────────────────────────────────────────────────────────────┐
│                         MÉTHODES HTTP DÉTAILLÉES                          │
├─────────┬──────────────┬────────────┬─────────────┬────────────┬─────────┤
│ MÉTHODE │ IDEMPOTENT? │ SAFE?      │ CACHEABLE?  │ BODY?      │ USAGE   │
├─────────┼──────────────┼────────────┼─────────────┼────────────┼─────────┤
│ GET     │ [OK] Oui       │ [OK] Oui      │ [OK] Oui       │ [X] Non      │ Lire    │
│ POST    │ [X] Non       │ [X] Non      │ Rarement    │ [OK] Oui      │ Créer   │
│ PUT     │ [OK] Oui       │ [X] Non      │ [X] Non       │ [OK] Oui      │ Replace │
│ PATCH   │ [X] Non       │ [X] Non      │ [X] Non       │ [OK] Oui      │ Modifier│
│ DELETE  │ [OK] Oui       │ [X] Non      │ [X] Non       │ Parfois    │ Effacer │
│ OPTIONS │ [OK] Oui       │ [OK] Oui      │ [X] Non       │ [X] Non      │ Info    │
│ HEAD    │ [OK] Oui       │ [OK] Oui      │ [OK] Oui       │ [X] Non      │ Metadata│
└─────────┴──────────────┴────────────┴─────────────┴────────────┴─────────┘

[IDEE] Définitions:
  • IDEMPOTENT: Appeler plusieurs fois = même résultat
  • SAFE: N'a aucun effet secondaire (ne modifie rien)
  • CACHEABLE: La réponse peut être mise en cache

1⃣ GET - RÉCUPÉRER DES DONNÉES
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi GET?
     • Lire des données sans les modifier
     • Peut être mis en cache par navigateurs/CDN
     • Peut être bookmarké
     • Safe et idempotent
   
   Quand utiliser?
     [OK] Lister des ressources
     [OK] Récupérer une ressource spécifique
     [OK] Rechercher/filtrer
     [X] NE JAMAIS pour créer/modifier/supprimer
     [X] NE JAMAIS pour envoyer des données sensibles (elles apparaissent dans l'URL)
   
   Exemples:
   # Lister tous les utilisateurs
   GET /api/users
   
   # Récupérer un utilisateur spécifique
   GET /api/users/123
   
   # Avec paramètres de requête (query params)
   GET /api/users?role=admin&active=true&page=1&limit=20
   
   # Recherche
   GET /api/posts?q=python&sort=date&order=desc
   
   # Relations imbriquées
   GET /api/users/123/posts
   GET /api/posts/456/comments
   
   Code Python (FastAPI):
   
   @app.get("/api/users")
   async def get_users(
       role: Optional[str] = None,
       active: bool = True,
       page: int = 1,
       limit: int = 20,
       db: Session = Depends(get_db)
   ):
       """Récupère liste utilisateurs avec filtres"""
       query = db.query(User)
       
       # Filtrage
       if role:
           query = query.filter(User.role == role)
       if active is not None:
           query = query.filter(User.active == active)
       
       # Pagination
       total = query.count()
       users = query.offset((page-1)*limit).limit(limit).all()
       
       return {
           "users": users,
           "pagination": {
               "page": page,
               "limit": limit,
               "total": total,
               "pages": (total + limit - 1) // limit
           }
       }

2⃣ POST - CRÉER UNE RESSOURCE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi POST?
     • Créer une nouvelle ressource
     • Le serveur génère l'ID (auto-increment)
     • Non-idempotent (appeler 2x crée 2 ressources)
     • Peut avoir des effets secondaires
   
   Quand utiliser?
     [OK] Créer un nouvel utilisateur, post, commande
     [OK] Soumettre un formulaire
     [OK] Uploader un fichier
     [OK] Déclencher une action (ex: /api/orders/123/pay)
     [X] NE PAS utiliser pour mettre à jour (utiliser PUT/PATCH)
   
   Exemples:
   
   POST /api/users
   Content-Type: application/json
   
   {
     "name": "Bob Martin",
     "email": "bob@example.com",
     "password": "SecurePass123!",
     "role": "user"
   }
   
   Réponse 201 Created:
   Location: /api/users/124
   
   {
     "id": 124,
     "name": "Bob Martin",
     "email": "bob@example.com",
     "role": "user",
     "created_at": "2025-12-13T10:35:00Z",
     "_links": {
       "self": "/api/users/124"
     }
   }
   
   Code Python (FastAPI):
   
   from pydantic import BaseModel, EmailStr, Field
   
   class UserCreate(BaseModel):
       name: str = Field(..., min_length=2, max_length=100)
       email: EmailStr
       password: str = Field(..., min_length=8)
       role: str = Field(default="user")
       
       @validator('password')
       def validate_password(cls, v):
           if not any(c.isupper() for c in v):
               raise ValueError('Doit contenir une majuscule')
           if not any(c.isdigit() for c in v):
               raise ValueError('Doit contenir un chiffre')
           return v
   
   @app.post("/api/users", status_code=status.HTTP_201_CREATED)
   async def create_user(
       user: UserCreate,
       db: Session = Depends(get_db)
   ):
       """Crée un nouvel utilisateur"""
       
       # Vérifier si email existe déjà
       existing = db.query(User).filter(User.email == user.email).first()
       if existing:
           raise HTTPException(
               status_code=409,
               detail="Email déjà utilisé"
           )
       
       # Hasher le mot de passe
       hashed_pwd = hash_password(user.password)
       
       # Créer l'utilisateur
       db_user = User(
           name=user.name,
           email=user.email,
           hashed_password=hashed_pwd,
           role=user.role
       )
       
       db.add(db_user)
       db.commit()
       db.refresh(db_user)
       
       # Retourner avec header Location
       return db_user

3⃣ PUT - REMPLACER COMPLÈTEMENT UNE RESSOURCE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi PUT?
     • Remplacer INTÉGRALEMENT une ressource
     • Idempotent (appeler plusieurs fois = même résultat)
     • Doit envoyer TOUS les champs
   
   Quand utiliser?
     [OK] Mettre à jour complètement une ressource
     [OK] Quand on connaît l'ID de la ressource
     [OK] Tous les champs sont requis
     [X] PAS pour modification partielle (utiliser PATCH)
   
   Différence PUT vs PATCH:
     PUT  = Remplacer tout      "Je réécris le livre en entier"
     PATCH = Modifier certains  "Je corrige juste une page"
   
   Exemple PUT:
   
   PUT /api/users/124
   Content-Type: application/json
   
   {
     "name": "Bob Martin Updated",
     "email": "bob.new@example.com",
     "role": "admin",
     "active": true,
     "bio": "Nouveau développeur senior"
   }
   
   [ATTENTION] Si tu oublies un champ, il sera NULL ou supprimé!
   
   Code Python (FastAPI):
   
   class UserUpdate(BaseModel):
       name: str
       email: EmailStr
       role: str
       active: bool
       bio: Optional[str] = None
   
   @app.put("/api/users/{user_id}")
   async def replace_user(
       user_id: int,
       user: UserUpdate,
       db: Session = Depends(get_db)
   ):
       """Remplace complètement un utilisateur"""
       
       db_user = db.query(User).filter(User.id == user_id).first()
       if not db_user:
           raise HTTPException(status_code=404, detail="User not found")
       
       # Remplacer TOUS les champs
       db_user.name = user.name
       db_user.email = user.email
       db_user.role = user.role
       db_user.active = user.active
       db_user.bio = user.bio
       db_user.updated_at = datetime.now()
       
       db.commit()
       db.refresh(db_user)
       
       return db_user

4⃣ PATCH - MODIFIER PARTIELLEMENT UNE RESSOURCE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi PATCH?
     • Modifier seulement certains champs
     • Envoyer uniquement ce qui change
     • Plus efficace que PUT
   
   Quand utiliser?
     [OK] Modifier 1 ou quelques champs
     [OK] Toggle un boolean (active/inactive)
     [OK] Mettre à jour partiellement un profil
     [OK] La plupart des mises à jour en production
   
   Exemple PATCH:
   
   PATCH /api/users/124
   Content-Type: application/json
   
   {
     "bio": "Développeur senior passionné par Python"
   }
   
   [OK] Seulement le champ "bio" change
   [OK] Tous les autres champs restent inchangés
   
   Code Python (FastAPI):
   
   class UserPatch(BaseModel):
       name: Optional[str] = None
       email: Optional[EmailStr] = None
       role: Optional[str] = None
       active: Optional[bool] = None
       bio: Optional[str] = None
   
   @app.patch("/api/users/{user_id}")
   async def update_user(
       user_id: int,
       user: UserPatch,
       db: Session = Depends(get_db)
   ):
       """Modifie partiellement un utilisateur"""
       
       db_user = db.query(User).filter(User.id == user_id).first()
       if not db_user:
           raise HTTPException(status_code=404, detail="User not found")
       
       # Mettre à jour SEULEMENT les champs fournis
       update_data = user.dict(exclude_unset=True)
       for field, value in update_data.items():
           setattr(db_user, field, value)
       
       db_user.updated_at = datetime.now()
       
       db.commit()
       db.refresh(db_user)
       
       return db_user

5⃣ DELETE - SUPPRIMER UNE RESSOURCE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi DELETE?
     • Supprimer une ressource
     • Idempotent (supprimer 2x = même résultat)
     • Peut retourner 204 No Content (sans body)
   
   Quand utiliser?
     [OK] Supprimer définitivement une ressource
     [OK] Retourner 204 si pas de contenu
     [OK] Retourner 200 avec info si nécessaire
     [ATTENTION] Attention: Souvent mieux faire un "soft delete" (active=false)
   
   Exemples:
   
   # Suppression simple
   DELETE /api/users/124
   -> 204 No Content (pas de body)
   
   # Suppression avec confirmation
   DELETE /api/users/124
   -> 200 OK
   {
     "message": "User deleted successfully",
     "deleted_id": 124,
     "deleted_at": "2025-12-13T10:40:00Z"
   }
   
   Code Python (FastAPI):
   
   @app.delete("/api/users/{user_id}", status_code=204)
   async def delete_user(
       user_id: int,
       db: Session = Depends(get_db),
       current_user = Depends(get_current_admin)  # Seulement les admins
   ):
       """Supprime un utilisateur (Hard Delete)"""
       
       db_user = db.query(User).filter(User.id == user_id).first()
       if not db_user:
           raise HTTPException(status_code=404, detail="User not found")
       
       # Vérifier les dépendances (ex: ne pas supprimer si a des posts)
       has_posts = db.query(Post).filter(Post.user_id == user_id).first()
       if has_posts:
           raise HTTPException(
               status_code=409,
               detail="Cannot delete user with existing posts"
           )
       
       db.delete(db_user)
       db.commit()
       
       return None  # 204 No Content
   
   # SOFT DELETE (Recommandé en production)
   @app.delete("/api/users/{user_id}")
   async def soft_delete_user(
       user_id: int,
       db: Session = Depends(get_db)
   ):
       """Désactive un utilisateur (Soft Delete)"""
       
       db_user = db.query(User).filter(User.id == user_id).first()
       if not db_user:
           raise HTTPException(status_code=404, detail="User not found")
       
       # Marquer comme supprimé
       db_user.active = False
       db_user.deleted_at = datetime.now()
       
       db.commit()
       
       return {"message": "User deactivated", "id": user_id}

6⃣ OPTIONS - DÉCOUVRIR LES MÉTHODES SUPPORTÉES
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi OPTIONS?
     • Découvrir quelles méthodes HTTP sont supportées
     • Utilisé automatiquement par CORS (preflight)
     • Rarement utilisé manuellement
   
   Quand utiliser?
     [OK] Géré automatiquement par le framework pour CORS
     [OK] Documentation des endpoints
   
   Exemple:
   
   OPTIONS /api/users/124
   
   Réponse:
   Allow: GET, PUT, PATCH, DELETE, OPTIONS
   Access-Control-Allow-Methods: GET, PUT, PATCH, DELETE
   
   Code Python (FastAPI):
   
   # FastAPI gère OPTIONS automatiquement pour CORS
   # Pas besoin de coder explicitement

7⃣ HEAD - OBTENIR SEULEMENT LES HEADERS
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi HEAD?
     • Comme GET mais sans le body
     • Vérifier si une ressource existe
     • Vérifier la taille avant téléchargement
   
   Quand utiliser?
     [OK] Vérifier l'existence d'une ressource
     [OK] Obtenir la taille d'un fichier
     [OK] Vérifier Last-Modified
   
   Exemple:
   
   HEAD /api/files/large-video.mp4
   
   Réponse (sans body):
   HTTP/1.1 200 OK
   Content-Length: 524288000
   Last-Modified: Wed, 12 Dec 2025 15:30:00 GMT
   Content-Type: video/mp4


═══════════════════════════════════════════════════════════════════════════════
  1.4 FORMATS DE DONNÉES - JSON, XML, YAML
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI différents formats?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Chaque format a ses avantages:
  • JSON: Léger, lisible, universellement supporté
  • XML: Structuré, validation forte, legacy
  • YAML: Lisible par humains, configuration
  • Protocol Buffers: Binaire, très rapide, compact


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                           JSON (RECOMMANDÉ)                         ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

[?] POURQUOI JSON?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

AVANTAGES:
  [OK] Léger (moins verbose que XML)
  [OK] Facile à lire pour humains
  [OK] Support natif JavaScript
  [OK] Parse/stringify rapide
  [OK] Support universel (tous langages)
  [OK] Types de données: string, number, boolean, null, array, object

INCONVÉNIENTS:
  [X] Pas de commentaires
  [X] Pas de références
  [X] Verbeux pour données binaires
  [X] Pas de types Date natifs

[?] QUAND utiliser JSON?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] TOUJOURS pour APIs REST modernes
[OK] Communication client-serveur web
[OK] Configuration simple
[OK] Stockage de données structurées

Exemple JSON complet:

{
  "user": {
    "id": 123,
    "name": "Alice Dupont",
    "email": "alice@example.com",
    "age": 28,
    "active": true,
    "roles": ["user", "editor"],
    "profile": {
      "bio": "Développeuse Python",
      "location": "Paris, France",
      "website": "https://alice.dev"
    },
    "preferences": {
      "theme": "dark",
      "language": "fr",
      "notifications": {
        "email": true,
        "push": false
      }
    },
    "created_at": "2025-01-15T10:30:00Z",
    "updated_at": "2025-12-13T14:20:00Z",
    "stats": {
      "posts": 42,
      "followers": 156,
      "following": 89
    }
  },
  "_links": {
    "self": "/api/users/123",
    "posts": "/api/users/123/posts",
    "followers": "/api/users/123/followers"
  },
  "_metadata": {
    "version": "1.0",
    "request_id": "abc-123-def",
    "timestamp": 1702468800
  }
}

Code Python (manipulation JSON):

import json
from datetime import datetime
from typing import Any, Dict

# ═══ SERIALIZATION (Python -> JSON) ═══

# Données Python
user_data = {
    "id": 123,
    "name": "Alice",
    "created_at": datetime.now(),  # [ATTENTION] datetime pas supporté par défaut
    "is_active": True
}

# Custom JSON Encoder pour datetime
class DateTimeEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()
        return super().default(obj)

# Convertir en JSON string
json_string = json.dumps(user_data, cls=DateTimeEncoder, indent=2)
print(json_string)

# ═══ DESERIALIZATION (JSON -> Python) ═══

json_string = '{"name": "Bob", "age": 30}'
user_dict = json.loads(json_string)
print(user_dict["name"])  # "Bob"

# ═══ AVEC FASTAPI (automatique) ═══

from pydantic import BaseModel
from datetime import datetime

class User(BaseModel):
    id: int
    name: str
    email: str
    created_at: datetime
    active: bool = True
    
    class Config:
        # Permet de retourner directement datetime
        # FastAPI le convertira automatiquement en ISO string
        json_encoders = {
            datetime: lambda v: v.isoformat()
        }

@app.get("/api/users/{user_id}", response_model=User)
async def get_user(user_id: int, db: Session = Depends(get_db)):
    user = db.query(UserModel).filter(UserModel.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404)
    
    # FastAPI convertit automatiquement en JSON
    return user

@app.post("/api/users", response_model=User)
async def create_user(user: User):
    # FastAPI parse automatiquement le JSON et valide
    # selon le modèle Pydantic
    return user


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                              XML (LEGACY)                           ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

[?] POURQUOI XML?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

AVANTAGES:
  [OK] Validation forte (XSD schemas)
  [OK] Namespaces pour éviter conflits
  [OK] Support des attributs ET contenu
  [OK] Commentaires possibles
  [OK] Standard d'entreprise (SOAP, etc.)

INCONVÉNIENTS:
  [X] Très verbeux
  [X] Lourd (beaucoup de tags)
  [X] Parse plus lent
  [X] Moins lisible

[?] QUAND utiliser XML?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] Systèmes legacy (banques, gouvernements)
[OK] SOAP web services
[OK] Configuration complexe avec validation
[X] PAS pour nouvelles APIs (utiliser JSON)

Exemple XML:

<?xml version="1.0" encoding="UTF-8"?>
<user id="123">
    <name>Alice Dupont</name>
    <email>alice@example.com</email>
    <age>28</age>
    <active>true</active>
    <roles>
        <role>user</role>
        <role>editor</role>
    </roles>
    <profile>
        <bio>Développeuse Python</bio>
        <location>Paris, France</location>
    </profile>
    <created_at>2025-01-15T10:30:00Z</created_at>
</user>

Code Python (manipulation XML):

import xml.etree.ElementTree as ET
from xml.dom import minidom

# ═══ CRÉATION XML ═══

def create_xml_response(user_data: dict) -> str:
    """Convertir un dict en XML"""
    root = ET.Element("user")
    root.set("id", str(user_data["id"]))
    
    # Ajouter des éléments
    name = ET.SubElement(root, "name")
    name.text = user_data["name"]
    
    email = ET.SubElement(root, "email")
    email.text = user_data["email"]
    
    # Formatter joliment
    xml_string = ET.tostring(root, encoding="unicode")
    dom = minidom.parseString(xml_string)
    return dom.toprettyxml()

# ═══ PARSING XML ═══

def parse_xml_request(xml_string: str) -> dict:
    """Parser XML vers dict"""
    root = ET.fromstring(xml_string)
    
    return {
        "id": int(root.get("id")),
        "name": root.find("name").text,
        "email": root.find("email").text
    }

# ═══ AVEC FASTAPI ═══

from fastapi import Response

@app.get("/api/users/{user_id}")
async def get_user_xml(
    user_id: int,
    accept: str = Header("application/json"),
    db: Session = Depends(get_db)
):
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404)
    
    # Si client demande XML
    if "application/xml" in accept:
        xml_content = create_xml_response(user.dict())
        return Response(
            content=xml_content,
            media_type="application/xml"
        )
    
    # Sinon JSON par défaut
    return user


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                        YAML (CONFIGURATION)                         ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

[?] POURQUOI YAML?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

AVANTAGES:
  [OK] Très lisible (indentation, pas de brackets)
  [OK] Support des commentaires
  [OK] Références et ancres
  [OK] Parfait pour configuration
  [OK] Types de données riches

INCONVÉNIENTS:
  [X] Indentation stricte (peut causer erreurs)
  [X] Parse plus lent que JSON
  [X] Moins supporté par navigateurs
  [X] Risques de sécurité (code execution)

[?] QUAND utiliser YAML?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] Fichiers de configuration (Docker, K8s)
[OK] CI/CD (GitHub Actions, GitLab CI)
[OK] Infrastructure as Code
[X] PAS pour APIs (trop lent, risques sécurité)

Exemple YAML:

user:
  id: 123
  name: Alice Dupont
  email: alice@example.com
  age: 28
  active: true
  roles:
    - user
    - editor
  profile:
    bio: Développeuse Python
    location: Paris, France
    website: https://alice.dev
  preferences:
    theme: dark
    language: fr
    notifications:
      email: true
      push: false
  created_at: 2025-01-15T10:30:00Z

# Commentaires possibles!

# Références avec ancres
defaults: &defaults
  theme: dark
  language: en

user_preferences:
  <<: *defaults  # Hérite de defaults
  language: fr   # Override

Code Python (manipulation YAML):

import yaml
from typing import Any, Dict

# ═══ LECTURE YAML ═══

def load_config(filename: str) -> Dict[str, Any]:
    """Charger un fichier YAML de configuration"""
    with open(filename, 'r') as f:
        config = yaml.safe_load(f)  # [ATTENTION] Utiliser safe_load!
    return config

# Exemple
config = load_config('config.yaml')
print(config['database']['host'])

# ═══ ÉCRITURE YAML ═══

def save_config(data: dict, filename: str):
    """Sauvegarder en YAML"""
    with open(filename, 'w') as f:
        yaml.dump(
            data,
            f,
            default_flow_style=False,  # Style étendu
            sort_keys=False            # Garder l'ordre
        )

# Exemple
config = {
    'database': {
        'host': 'localhost',
        'port': 5432,
        'name': 'mydb'
    },
    'api': {
        'host': '0.0.0.0',
        'port': 8000
    }
}

save_config(config, 'config.yaml')

# [ATTENTION] SÉCURITÉ: NE JAMAIS utiliser yaml.load() sur données non fiables
# Utiliser TOUJOURS yaml.safe_load()


═══════════════════════════════════════════════════════════════════════════════
  1.5 REST vs SOAP vs GraphQL vs gRPC - COMPARAISON DÉTAILLÉE
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI différentes architectures d'API?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Chaque architecture résout des problèmes différents:
  • REST: Simple, standard, cacheable
  • SOAP: Enterprise, sécurité forte, transactions
  • GraphQL: Flexibilité, over/under-fetching
  • gRPC: Performance, microservices, streaming


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         REST (RECOMMANDÉ)                           ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

[?] POURQUOI REST?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

REST (Representational State Transfer) = Style architectural

AVANTAGES:
  [OK] Simple à comprendre et implémenter
  [OK] Utilise HTTP standard (GET, POST, PUT, DELETE)
  [OK] Stateless (chaque requête indépendante)
  [OK] Cacheable (HTTP cache natif)
  [OK] Largement adopté et compris
  [OK] Outils abondants (Postman, Swagger)
  [OK] Scalable

INCONVÉNIENTS:
  [X] Over-fetching (trop de données)
  [X] Under-fetching (pas assez, besoin plusieurs requêtes)
  [X] Pas de schéma strict (contrairement à GraphQL)
  [X] Versioning peut être complexe

[?] QUAND utiliser REST?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] LA PLUPART DES CAS:
  • APIs publiques
  • Applications web/mobile standard
  • Prototypes rapides
  • Équipe peu expérimentée
  • Besoin de cache HTTP
  • CRUD simple

Principes REST:

1. RESSOURCES = URLs
   /users          -> Collection d'utilisateurs
   /users/123      -> Utilisateur spécifique
   /users/123/posts -> Posts de l'utilisateur 123

2. MÉTHODES HTTP = ACTIONS
   GET    /users     -> Lire tous
   GET    /users/123 -> Lire un
   POST   /users     -> Créer
   PUT    /users/123 -> Remplacer
   PATCH  /users/123 -> Modifier
   DELETE /users/123 -> Supprimer

3. STATELESS
   Chaque requête contient TOUTES les infos nécessaires
   Pas de session côté serveur

4. REPRESENTATIONS
   Même ressource, formats différents:
   Accept: application/json -> JSON
   Accept: application/xml  -> XML

5. HATEOAS (Hypermedia)
   Les réponses contiennent des liens vers autres ressources
   
   {
     "id": 123,
     "name": "Alice",
     "_links": {
       "self": "/users/123",
       "posts": "/users/123/posts",
       "followers": "/users/123/followers"
     }
   }

Exemple REST complet:

# ═══ USERS API ═══

# Lister tous les utilisateurs
GET /api/v1/users?page=1&limit=20&sort=created_at&order=desc
Authorization: Bearer <token>

Response 200 OK:
{
  "users": [
    {"id": 1, "name": "Alice"},
    {"id": 2, "name": "Bob"}
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 150,
    "pages": 8
  },
  "_links": {
    "self": "/api/v1/users?page=1",
    "next": "/api/v1/users?page=2",
    "last": "/api/v1/users?page=8"
  }
}

# Récupérer un utilisateur
GET /api/v1/users/123
Authorization: Bearer <token>

Response 200 OK:
{
  "id": 123,
  "name": "Alice Dupont",
  "email": "alice@example.com",
  "created_at": "2025-01-15T10:30:00Z",
  "_links": {
    "self": "/api/v1/users/123",
    "posts": "/api/v1/users/123/posts",
    "edit": "/api/v1/users/123",
    "delete": "/api/v1/users/123"
  }
}

# Créer un utilisateur
POST /api/v1/users
Content-Type: application/json
Authorization: Bearer <token>

{
  "name": "Charlie Martin",
  "email": "charlie@example.com",
  "password": "SecurePass123!"
}

Response 201 Created:
Location: /api/v1/users/124
{
  "id": 124,
  "name": "Charlie Martin",
  "email": "charlie@example.com",
  "created_at": "2025-12-13T15:00:00Z"
}

# Modifier partiellement
PATCH /api/v1/users/123
Content-Type: application/json
Authorization: Bearer <token>

{
  "name": "Alice Dupont Updated"
}

Response 200 OK:
{
  "id": 123,
  "name": "Alice Dupont Updated",
  "email": "alice@example.com",
  "updated_at": "2025-12-13T15:05:00Z"
}

# Supprimer
DELETE /api/v1/users/123
Authorization: Bearer <token>

Response 204 No Content

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                          GRAPHQL (MODERNE)                            ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

[?] POURQUOI GraphQL?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

GraphQL = Langage de requête pour APIs (développé par Facebook)

AVANTAGES:
  [OK] Le client demande EXACTEMENT ce dont il a besoin
  [OK] Pas d'over-fetching ni under-fetching
  [OK] Une seule requête pour données complexes
  [OK] Schéma fortement typé
  [OK] Introspection (documentation auto)
  [OK] Évolution de l'API sans versioning

INCONVÉNIENTS:
  [X] Complexe à implémenter
  [X] Pas de cache HTTP natif
  [X] Peut être lent (N+1 queries)
  [X] Courbe d'apprentissage
  [X] Over-fetching possible côté serveur

[?] QUAND utiliser GraphQL?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] BONS CAS D'USAGE:
  • Applications avec beaucoup de relations
  • Clients multiples avec besoins différents
  • Apps mobiles (économiser bande passante)
  • Prototypage rapide frontend
  • Quand REST nécessite trop de requêtes

[X] ÉVITER SI:
  • API simple CRUD
  • Équipe débutante
  • Besoin cache HTTP natif
  • Uploads de fichiers simples

Exemple GraphQL:

# ═══ SCHEMA ═══

type User {
  id: ID!
  name: String!
  email: String!
  age: Int
  posts: [Post!]!
  followers: [User!]!
  following: [User!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments: [Comment!]!
  likes: Int!
  createdAt: DateTime!
}

type Comment {
  id: ID!
  text: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  post(id: ID!): Post
  posts(limit: Int): [Post!]!
}

type Mutation {
  createUser(name: String!, email: String!, password: String!): User!
  updateUser(id: ID!, name: String, email: String): User!
  deleteUser(id: ID!): Boolean!
  createPost(title: String!, content: String!, authorId: ID!): Post!
}

# ═══ QUERIES ═══

# Requête 1: Juste nom et email
query {
  user(id: "123") {
    name
    email
  }
}

Response:
{
  "data": {
    "user": {
      "name": "Alice Dupont",
      "email": "alice@example.com"
    }
  }
}

# Requête 2: Avec posts et leurs commentaires
query {
  user(id: "123") {
    name
    email
    posts {
      title
      likes
      comments {
        text
        author {
          name
        }
      }
    }
    followers {
      name
    }
  }
}

Response:
{
  "data": {
    "user": {
      "name": "Alice Dupont",
      "email": "alice@example.com",
      "posts": [
        {
          "title": "Mon premier post",
          "likes": 42,
          "comments": [
            {
              "text": "Super article!",
              "author": {
                "name": "Bob Martin"
              }
            }
          ]
        }
      ],
      "followers": [
        {"name": "Bob Martin"},
        {"name": "Charlie Lee"}
      ]
    }
  }
}

# ═══ MUTATIONS ═══

# Créer un utilisateur
mutation {
  createUser(
    name: "David Smith",
    email: "david@example.com",
    password: "SecurePass123!"
  ) {
    id
    name
    email
    createdAt
  }
}

# Modifier un utilisateur
mutation {
  updateUser(
    id: "123",
    name: "Alice Dupont Updated"
  ) {
    id
    name
    email
  }
}

Code Python (avec Strawberry):

import strawberry
from typing import List, Optional
from datetime import datetime

# ═══ TYPES ═══

@strawberry.type
class User:
    id: strawberry.ID
    name: str
    email: str
    age: Optional[int] = None
    created_at: datetime

@strawberry.type
class Post:
    id: strawberry.ID
    title: str
    content: str
    author_id: strawberry.ID
    likes: int = 0
    created_at: datetime

# ═══ QUERIES ═══

@strawberry.type
class Query:
    @strawberry.field
    def user(self, id: strawberry.ID) -> Optional[User]:
        # Récupérer de la DB
        user_data = db.query(UserModel).filter(UserModel.id == id).first()
        if not user_data:
            return None
        return User(**user_data.dict())
    
    @strawberry.field
    def users(self, limit: int = 10, offset: int = 0) -> List[User]:
        users_data = db.query(UserModel).offset(offset).limit(limit).all()
        return [User(**u.dict()) for u in users_data]

# ═══ MUTATIONS ═══

@strawberry.type
class Mutation:
    @strawberry.mutation
    def create_user(
        self,
        name: str,
        email: str,
        password: str
    ) -> User:
        # Validation
        if len(password) < 8:
            raise ValueError("Password too short")
        
        # Créer utilisateur
        hashed_pwd = hash_password(password)
        user = UserModel(
            name=name,
            email=email,
            hashed_password=hashed_pwd
        )
        db.add(user)
        db.commit()
        db.refresh(user)
        
        return User(**user.dict())

# ═══ SCHEMA ═══

schema = strawberry.Schema(query=Query, mutation=Mutation)

# ═══ FASTAPI INTEGRATION ═══

from strawberry.fastapi import GraphQLRouter

graphql_app = GraphQLRouter(schema)

app.include_router(graphql_app, prefix="/graphql")

# Maintenant accessible à: POST /graphql


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         gRPC (PERFORMANCE)                            ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

[?] POURQUOI gRPC?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

gRPC = Google Remote Procedure Call

AVANTAGES:
  [OK] TRÈS RAPIDE (format binaire Protocol Buffers)
  [OK] Streaming bidirectionnel
  [OK] Fortement typé
  [OK] Génération de code automatique
  [OK] Multiplexing (HTTP/2)
  [OK] Petit payload

INCONVÉNIENTS:
  [X] Pas lisible par humains (binaire)
  [X] Support navigateur limité
  [X] Courbe d'apprentissage
  [X] Moins d'outils que REST

[?] QUAND utiliser gRPC?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] PARFAIT POUR:
  • Microservices (communication serveur-serveur)
  • Streaming temps réel
  • Applications nécessitant haute performance
  • IoT (faible bande passante)

[X] ÉVITER SI:
  • API publique (préférer REST)
  • Client = navigateur web
  • Debugging fréquent nécessaire

Exemple gRPC:

# ═══ DÉFINITION .proto ═══

// user.proto
syntax = "proto3";

package user;

service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc ListUsers (ListUsersRequest) returns (stream User);
  rpc CreateUser (CreateUserRequest) returns (User);
  rpc UpdateUser (UpdateUserRequest) returns (User);
  rpc DeleteUser (DeleteUserRequest) returns (Empty);
}

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
  int32 age = 4;
  bool active = 5;
  int64 created_at = 6;
}

message GetUserRequest {
  int32 id = 1;
}

message ListUsersRequest {
  int32 limit = 1;
  int32 offset = 2;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
  string password = 3;
}

message UpdateUserRequest {
  int32 id = 1;
  string name = 2;
  string email = 3;
}

message DeleteUserRequest {
  int32 id = 1;
}

message Empty {}

# ═══ SERVEUR PYTHON ═══

import grpc
from concurrent import futures
import user_pb2
import user_pb2_grpc

class UserServicer(user_pb2_grpc.UserServiceServicer):
    def GetUser(self, request, context):
        # Récupérer de la DB
        user_data = db.query(User).filter(User.id == request.id).first()
        
        if not user_data:
            context.set_code(grpc.StatusCode.NOT_FOUND)
            context.set_details("User not found")
            return user_pb2.User()
        
        return user_pb2.User(
            id=user_data.id,
            name=user_data.name,
            email=user_data.email,
            age=user_data.age,
            active=user_data.active,
            created_at=int(user_data.created_at.timestamp())
        )
    
    def ListUsers(self, request, context):
        # Streaming de users
        users = db.query(User).offset(request.offset).limit(request.limit).all()
        
        for user in users:
            yield user_pb2.User(
                id=user.id,
                name=user.name,
                email=user.email
            )
    
    def CreateUser(self, request, context):
        # Créer user
        user = User(
            name=request.name,
            email=request.email,
            hashed_password=hash_password(request.password)
        )
        db.add(user)
        db.commit()
        db.refresh(user)
        
        return user_pb2.User(id=user.id, name=user.name, email=user.email)

# Démarrer le serveur
def serve():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    user_pb2_grpc.add_UserServiceServicer_to_server(UserServicer(), server)
    server.add_insecure_port('[::]:50051')
    server.start()
    server.wait_for_termination()

# ═══ CLIENT PYTHON ═══

import grpc
import user_pb2
import user_pb2_grpc

def run():
    # Connexion au serveur
    with grpc.insecure_channel('localhost:50051') as channel:
        stub = user_pb2_grpc.UserServiceStub(channel)
        
        # Récupérer un user
        response = stub.GetUser(user_pb2.GetUserRequest(id=123))
        print(f"User: {response.name}, {response.email}")
        
        # Créer un user
        new_user = stub.CreateUser(user_pb2.CreateUserRequest(
            name="Alice",
            email="alice@example.com",
            password="secure123"
        ))
        print(f"Created user: {new_user.id}")
        
        # Streaming de users
        for user in stub.ListUsers(user_pb2.ListUsersRequest(limit=10)):
            print(f"- {user.name}")


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                       TABLEAU COMPARATIF                            ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

┌────────────────┬────────────┬────────────┬────────────┬────────────┬──────────┐
│  CRITÈRE       │   REST     │   SOAP     │ GraphQL    │   gRPC     │  WebSock │
├────────────────┼────────────┼────────────┼────────────┼────────────┼───────────┤
│ Simplicité     │ *****    │ *****    │ *****    │ *****    │ *****    │
│ Performance    │ *****    │ *****    │ *****    │ *****    │ *****    │
│ Flexibilité    │ *****    │ *****    │ *****    │ *****    │ *****    │
│ Caching        │ *****    │ *****    │ *****    │ *****    │ *****    │
│ Type Safety    │ *****    │ *****    │ *****    │ *****    │ *****    │
│ Adoption       │ *****    │ *****    │ *****    │ *****    │ *****    │
│ Temps Réel     │ *****    │ *****    │ *****    │ *****    │ *****    │
│ Browser        │ *****    │ *****    │ *****    │ *****    │ *****    │
│ Outils         │ *****    │ *****    │ *****    │ *****    │ *****    │
│ Learning Curve │ *****    │ *****    │ *****    │ *****    │ *****    │
└────────────────┴────────────┴────────────┴────────────┴────────────┴───────────┘

[GRAPHIQUE] RECOMMANDATIONS PAR CAS D'USAGE:

API Publique / Site Web:            REST      *****
Microservices internes:             gRPC      *****
App mobile données complexes:       GraphQL   *****
Chat / Notifications temps réel:    WebSocket *****
Système bancaire legacy:            SOAP      *****
Prototype rapide:                   REST      *****
IoT faible bande passante:          gRPC      *****
Dashboard temps réel:               WebSocket *****


═══════════════════════════════════════════════════════════════════════════════
  1.6 CODES DE STATUT HTTP - GUIDE EXHAUSTIF
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI les codes de statut HTTP?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les codes de statut HTTP sont ESSENTIELS car ils permettent:
  [OK] Communication standardisée entre client et serveur
  [OK] Gestion d'erreurs appropriée côté client
  [OK] Debugging facilité (savoir exactement ce qui s'est passé)
  [OK] Comportement automatique des clients (retry, cache, etc.)
  [OK] Documentation claire de l'API

[?] COMMENT sont organisés les codes de statut?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les codes HTTP sont organisés en 5 catégories (première chiffre):

┌─────────────────────────────────────────────────────────────────────────┐
│                    CATÉGORIES DES CODES HTTP                            │
├─────────┬───────────────────────────────────────────────────────────────┤
│  1xx    │  INFORMATIONNEL - Requête reçue, traitement en cours         │
│  2xx    │  SUCCÈS - Requête reçue, comprise et acceptée                │
│  3xx    │  REDIRECTION - Actions supplémentaires nécessaires           │
│  4xx    │  ERREUR CLIENT - Problème dans la requête du client          │
│  5xx    │  ERREUR SERVEUR - Le serveur a échoué à traiter la requête   │
└─────────┴───────────────────────────────────────────────────────────────┘

[?] QUAND utiliser chaque code de statut?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    2XX - CODES DE SUCCÈS                            ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

1⃣ 200 OK - SUCCÈS GÉNÉRAL
   ━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Requête réussie avec contenu dans la réponse
     • Le code de succès le plus commun
   
   Quand utiliser?
     [OK] GET réussi (lire des données)
     [OK] PUT réussi (mise à jour complète)
     [OK] PATCH réussi (mise à jour partielle)
     [OK] DELETE réussi avec message de confirmation
     [X] PAS pour POST (utiliser 201 Created)
     [X] PAS pour DELETE sans contenu (utiliser 204)
   
   Exemple:
   
   GET /api/users/123
   
   HTTP/1.1 200 OK
   Content-Type: application/json
   
   {
     "id": 123,
     "name": "Alice",
     "email": "alice@example.com"
   }
   
   Code Python:
   
   @app.get("/api/users/{user_id}")
   async def get_user(user_id: int, db: Session = Depends(get_db)):
       user = db.query(User).filter(User.id == user_id).first()
       if not user:
           raise HTTPException(status_code=404)
       return user  # FastAPI retourne automatiquement 200


2⃣ 201 CREATED - RESSOURCE CRÉÉE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Indique qu'une nouvelle ressource a été créée
     • Devrait inclure le header "Location" avec l'URL de la ressource
   
   Quand utiliser?
     [OK] POST réussi (création d'une ressource)
     [X] PAS pour GET, PUT, PATCH, DELETE
   
   Exemple:
   
   POST /api/users
   Content-Type: application/json
   
   {"name": "Bob", "email": "bob@example.com"}
   
   HTTP/1.1 201 Created
   Location: /api/users/124
   Content-Type: application/json
   
   {
     "id": 124,
     "name": "Bob",
     "email": "bob@example.com",
     "created_at": "2025-12-13T10:30:00Z"
   }
   
   Code Python:
   
   from fastapi import status, Response
   
   @app.post("/api/users", status_code=status.HTTP_201_CREATED)
   async def create_user(
       user: UserCreate,
       response: Response,
       db: Session = Depends(get_db)
   ):
       db_user = User(**user.dict())
       db.add(db_user)
       db.commit()
       db.refresh(db_user)
       
       # Ajouter le header Location
       response.headers["Location"] = f"/api/users/{db_user.id}"
       
       return db_user


3⃣ 202 ACCEPTED - ACCEPTÉ POUR TRAITEMENT
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Requête acceptée mais traitement pas encore terminé
     • Utilisé pour traitements asynchrones/longues tâches
   
   Quand utiliser?
     [OK] Traitement asynchrone (background jobs)
     [OK] Tâches longues (génération de rapports, traitement vidéo)
     [OK] Queue de messages
     [X] PAS pour traitements synchrones immédiats
   
   Exemple:
   
   POST /api/videos/process
   Content-Type: application/json
   
   {"video_id": 456, "quality": "1080p"}
   
   HTTP/1.1 202 Accepted
   Content-Type: application/json
   
   {
     "job_id": "abc-123-def",
     "status": "processing",
     "status_url": "/api/jobs/abc-123-def",
     "estimated_time": 300
   }
   
   Code Python:
   
   from fastapi import BackgroundTasks
   
   def process_video_task(video_id: int):
       """Traitement long en arrière-plan"""
       # Logique de traitement vidéo
       pass
   
   @app.post("/api/videos/process", status_code=202)
   async def process_video(
       video_id: int,
       background_tasks: BackgroundTasks
   ):
       job_id = str(uuid.uuid4())
       
       # Ajouter la tâche en arrière-plan
       background_tasks.add_task(process_video_task, video_id)
       
       return {
           "job_id": job_id,
           "status": "processing",
           "status_url": f"/api/jobs/{job_id}"
       }


4⃣ 204 NO CONTENT - SUCCÈS SANS CONTENU
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Requête réussie mais pas de contenu à retourner
     • Économise de la bande passante
   
   Quand utiliser?
     [OK] DELETE réussi (ressource supprimée)
     [OK] PUT/PATCH réussi sans besoin de retourner la ressource
     [X] PAS si tu veux retourner des données (utiliser 200)
   
   Exemple:
   
   DELETE /api/users/123
   
   HTTP/1.1 204 No Content
   (pas de body)
   
   Code Python:
   
   @app.delete("/api/users/{user_id}", status_code=204)
   async def delete_user(user_id: int, db: Session = Depends(get_db)):
       user = db.query(User).filter(User.id == user_id).first()
       if not user:
           raise HTTPException(status_code=404)
       
       db.delete(user)
       db.commit()
       
       return None  # Pas de contenu


5⃣ 206 PARTIAL CONTENT - CONTENU PARTIEL
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Pour le streaming de gros fichiers
     • Permet de reprendre un téléchargement interrompu
   
   Quand utiliser?
     [OK] Streaming vidéo/audio
     [OK] Téléchargement de gros fichiers
     [OK] Support du header "Range"
   
   Exemple:
   
   GET /api/videos/large.mp4
   Range: bytes=0-1023
   
   HTTP/1.1 206 Partial Content
   Content-Range: bytes 0-1023/524288000
   Content-Length: 1024
   Content-Type: video/mp4
   
   (premiers 1024 bytes du fichier)


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    3XX - CODES DE REDIRECTION                       ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

1⃣ 301 MOVED PERMANENTLY - DÉPLACÉ DÉFINITIVEMENT
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • L'URL a changé définitivement
     • Les clients devraient mettre à jour leurs bookmarks
   
   Quand utiliser?
     [OK] Migration d'API vers nouvelle version
     [OK] Changement de domaine permanent
     [X] PAS pour redirections temporaires (utiliser 302)
   
   Exemple:
   
   GET /api/old-endpoint
   
   HTTP/1.1 301 Moved Permanently
   Location: /api/v2/new-endpoint
   
   Code Python:
   
   from fastapi.responses import RedirectResponse
   
   @app.get("/api/old-endpoint")
   async def old_endpoint():
       return RedirectResponse(
           url="/api/v2/new-endpoint",
           status_code=301
       )


2⃣ 302 FOUND - REDIRECTION TEMPORAIRE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Redirection temporaire
     • L'URL originale peut être réutilisée plus tard
   
   Quand utiliser?
     [OK] Maintenance temporaire
     [OK] A/B testing
     [OK] Load balancing
   
   Exemple:
   
   GET /api/service
   
   HTTP/1.1 302 Found
   Location: /api/service-backup


3⃣ 304 NOT MODIFIED - NON MODIFIÉ
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • La ressource n'a pas changé depuis la dernière requête
     • Le client peut utiliser sa version en cache
     • Économise bande passante
   
   Quand utiliser?
     [OK] Avec headers de cache (If-Modified-Since, If-None-Match)
     [OK] Pour ressources statiques ou peu changeantes
   
   Exemple:
   
   GET /api/users/123
   If-None-Match: "abc123xyz"
   
   HTTP/1.1 304 Not Modified
   ETag: "abc123xyz"
   (pas de body - le client utilise sa version en cache)
   
   Code Python:
   
   @app.get("/api/users/{user_id}")
   async def get_user(
       user_id: int,
       if_none_match: Optional[str] = Header(None),
       db: Session = Depends(get_db)
   ):
       user = db.query(User).filter(User.id == user_id).first()
       if not user:
           raise HTTPException(status_code=404)
       
       # Calculer l'ETag
       etag = hashlib.md5(
           f"{user.id}{user.updated_at}".encode()
       ).hexdigest()
       
       # Si l'ETag correspond, retourner 304
       if if_none_match == etag:
           return Response(status_code=304, headers={"ETag": etag})
       
       return Response(
           content=user.json(),
           headers={"ETag": etag}
       )


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    4XX - ERREURS CLIENT                             ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

1⃣ 400 BAD REQUEST - REQUÊTE MALFORMÉE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • La requête est syntaxiquement incorrecte
     • Le serveur ne peut pas comprendre la requête
   
   Quand utiliser?
     [OK] JSON malformé
     [OK] Paramètres manquants requis
     [OK] Format de données incorrect
     [X] PAS pour validation métier (utiliser 422)
   
   Exemple:
   
   POST /api/users
   Content-Type: application/json
   
   {
     "name": "Alice",
     "email": "pas-un-email"  <- Format invalide
   }
   
   HTTP/1.1 400 Bad Request
   Content-Type: application/json
   
   {
     "error": "Bad Request",
     "message": "Invalid email format",
     "field": "email"
   }
   
   Code Python:
   
   from pydantic import BaseModel, EmailStr, ValidationError
   
   @app.post("/api/users")
   async def create_user(user: UserCreate):
       try:
           # Pydantic valide automatiquement
           # Si invalide, lance ValidationError
           pass
       except ValidationError as e:
           raise HTTPException(
               status_code=400,
               detail={
                   "error": "Bad Request",
                   "message": "Validation failed",
                   "errors": e.errors()
               }
           )


2⃣ 401 UNAUTHORIZED - NON AUTHENTIFIÉ
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • L'utilisateur n'est pas authentifié
     • Token manquant ou invalide
   
   Quand utiliser?
     [OK] Pas de token fourni
     [OK] Token expiré
     [OK] Token invalide
     [X] PAS si authentifié mais pas autorisé (utiliser 403)
   
   [ATTENTION] Note: Le nom est trompeur - "Unauthorized" signifie en fait "Unauthenticated"
   
   Exemple:
   
   GET /api/users/me
   (pas de header Authorization)
   
   HTTP/1.1 401 Unauthorized
   WWW-Authenticate: Bearer realm="api"
   Content-Type: application/json
   
   {
     "error": "Unauthorized",
     "message": "Authentication required",
     "details": "No authentication token provided"
   }
   
   Code Python:
   
   from fastapi import Depends, HTTPException
   from fastapi.security import HTTPBearer
   
   security = HTTPBearer()
   
   async def get_current_user(
       credentials = Depends(security)
   ):
       token = credentials.credentials
       
       try:
           payload = verify_token(token)
       except JWTError:
           raise HTTPException(
               status_code=401,
               detail="Invalid or expired token",
               headers={"WWW-Authenticate": "Bearer"}
           )
       
       return payload
   
   @app.get("/api/users/me")
   async def get_me(current_user = Depends(get_current_user)):
       return current_user


3⃣ 403 FORBIDDEN - ACCÈS INTERDIT
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • L'utilisateur est authentifié mais n'a pas la permission
     • Problème d'autorisation, pas d'authentification
   
   Quand utiliser?
     [OK] Utilisateur connecté mais rôle insuffisant
     [OK] Tentative d'accès à ressource non autorisée
     [OK] Limite de quota dépassée
     [X] PAS si non authentifié (utiliser 401)
   
   Exemple:
   
   DELETE /api/users/456
   Authorization: Bearer <token-user-normal>
   
   HTTP/1.1 403 Forbidden
   Content-Type: application/json
   
   {
     "error": "Forbidden",
     "message": "Admin role required",
     "required_role": "admin",
     "your_role": "user"
   }
   
   Code Python:
   
   def require_admin(current_user = Depends(get_current_user)):
       if current_user.role != "admin":
           raise HTTPException(
               status_code=403,
               detail={
                   "error": "Forbidden",
                   "message": "Admin role required",
                   "your_role": current_user.role
               }
           )
       return current_user
   
   @app.delete("/api/users/{user_id}")
   async def delete_user(
       user_id: int,
       admin = Depends(require_admin),
       db: Session = Depends(get_db)
   ):
       # Seuls les admins peuvent supprimer
       user = db.query(User).filter(User.id == user_id).first()
       if not user:
           raise HTTPException(status_code=404)
       
       db.delete(user)
       db.commit()
       return {"message": "User deleted"}


4⃣ 404 NOT FOUND - RESSOURCE NON TROUVÉE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • La ressource demandée n'existe pas
     • L'endpoint n'existe pas
   
   Quand utiliser?
     [OK] ID de ressource inexistant
     [OK] Endpoint inexistant
     [OK] Ressource supprimée
   
   Exemple:
   
   GET /api/users/99999
   
   HTTP/1.1 404 Not Found
   Content-Type: application/json
   
   {
     "error": "Not Found",
     "message": "User with ID 99999 not found",
     "resource": "user",
     "id": 99999
   }
   
   Code Python:
   
   @app.get("/api/users/{user_id}")
   async def get_user(user_id: int, db: Session = Depends(get_db)):
       user = db.query(User).filter(User.id == user_id).first()
       
       if not user:
           raise HTTPException(
               status_code=404,
               detail={
                   "error": "Not Found",
                   "message": f"User with ID {user_id} not found",
                   "resource": "user",
                   "id": user_id
               }
           )
       
       return user


5⃣ 405 METHOD NOT ALLOWED - MÉTHODE NON AUTORISÉE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • La méthode HTTP n'est pas supportée pour cet endpoint
   
   Quand utiliser?
     [OK] POST sur un endpoint qui n'accepte que GET
     [OK] DELETE sur une ressource en lecture seule
   
   Exemple:
   
   POST /api/users/123  <- Endpoint n'accepte que GET, PUT, DELETE
   
   HTTP/1.1 405 Method Not Allowed
   Allow: GET, PUT, DELETE
   Content-Type: application/json
   
   {
     "error": "Method Not Allowed",
     "message": "POST method not allowed on this endpoint",
     "allowed_methods": ["GET", "PUT", "DELETE"]
   }


6⃣ 409 CONFLICT - CONFLIT
   ━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Conflit avec l'état actuel de la ressource
     • Constraint violation
   
   Quand utiliser?
     [OK] Email/username déjà utilisé
     [OK] Tentative de créer un doublon
     [OK] Violation de contrainte d'unicité
     [OK] Version conflict (optimistic locking)
   
   Exemple:
   
   POST /api/users
   Content-Type: application/json
   
   {
     "name": "Alice",
     "email": "alice@example.com"  <- Email déjà existant
   }
   
   HTTP/1.1 409 Conflict
   Content-Type: application/json
   
   {
     "error": "Conflict",
     "message": "User with this email already exists",
     "field": "email",
     "value": "alice@example.com"
   }
   
   Code Python:
   
   from sqlalchemy.exc import IntegrityError
   
   @app.post("/api/users", status_code=201)
   async def create_user(user: UserCreate, db: Session = Depends(get_db)):
       # Vérifier si email existe
       existing = db.query(User).filter(User.email == user.email).first()
       if existing:
           raise HTTPException(
               status_code=409,
               detail={
                   "error": "Conflict",
                   "message": "User with this email already exists",
                   "field": "email"
               }
           )
       
       db_user = User(**user.dict())
       
       try:
           db.add(db_user)
           db.commit()
           db.refresh(db_user)
       except IntegrityError:
           db.rollback()
           raise HTTPException(
               status_code=409,
               detail="Constraint violation"
           )
       
       return db_user


7⃣ 422 UNPROCESSABLE ENTITY - ENTITÉ NON TRAITABLE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • La syntaxe est correcte mais la validation métier échoue
     • Utilisé par FastAPI par défaut pour erreurs de validation Pydantic
   
   Quand utiliser?
     [OK] Validation métier échouée
     [OK] Règles business non respectées
     [OK] Données valides syntaxiquement mais incorrectes sémantiquement
   
   Exemple:
   
   POST /api/users
   Content-Type: application/json
   
   {
     "name": "A",  <- Trop court (min 2 caractères)
     "email": "valid@example.com",
     "age": 150  <- Âge irréaliste
   }
   
   HTTP/1.1 422 Unprocessable Entity
   Content-Type: application/json
   
   {
     "detail": [
       {
         "loc": ["body", "name"],
         "msg": "ensure this value has at least 2 characters",
         "type": "value_error.any_str.min_length"
       },
       {
         "loc": ["body", "age"],
         "msg": "age must be between 0 and 120",
         "type": "value_error"
       }
     ]
   }
   
   Code Python:
   
   from pydantic import BaseModel, Field, validator
   
   class UserCreate(BaseModel):
       name: str = Field(..., min_length=2, max_length=100)
       email: EmailStr
       age: int = Field(..., ge=0, le=120)
       
       @validator('age')
       def validate_age(cls, v):
           if v > 120:
               raise ValueError('Age must be realistic (0-120)')
           return v
   
   # FastAPI génère automatiquement 422 si validation échoue


8⃣ 429 TOO MANY REQUESTS - TROP DE REQUÊTES
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Rate limit dépassé
     • Protection contre abus/DDoS
   
   Quand utiliser?
     [OK] Nombre de requêtes dépassé
     [OK] Quota API épuisé
   
   Exemple:
   
   GET /api/users
   
   HTTP/1.1 429 Too Many Requests
   Retry-After: 60
   X-RateLimit-Limit: 100
   X-RateLimit-Remaining: 0
   X-RateLimit-Reset: 1702468800
   Content-Type: application/json
   
   {
     "error": "Too Many Requests",
     "message": "Rate limit exceeded",
     "limit": 100,
     "window": "1 hour",
     "retry_after": 60
   }
   
   Code Python:
   
   from slowapi import Limiter, _rate_limit_exceeded_handler
   from slowapi.util import get_remote_address
   from slowapi.errors import RateLimitExceeded
   
   limiter = Limiter(key_func=get_remote_address)
   app.state.limiter = limiter
   app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
   
   @app.get("/api/users")
   @limiter.limit("100/hour")
   async def get_users(request: Request, db: Session = Depends(get_db)):
       users = db.query(User).all()
       return users


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    5XX - ERREURS SERVEUR                            ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

1⃣ 500 INTERNAL SERVER ERROR - ERREUR INTERNE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Erreur inattendue côté serveur
     • Bug dans le code
     • Exception non gérée
   
   Quand utiliser?
     [OK] Exception non catchée
     [OK] Erreur de programmation
     [ATTENTION] À ÉVITER - signale un bug à corriger
   
   Exemple:
   
   GET /api/users/123
   
   HTTP/1.1 500 Internal Server Error
   Content-Type: application/json
   
   {
     "error": "Internal Server Error",
     "message": "An unexpected error occurred",
     "request_id": "abc-123-def",
     "support": "contact support@example.com with request_id"
   }
   
   Code Python:
   
   import logging
   import traceback
   
   logger = logging.getLogger(__name__)
   
   @app.exception_handler(Exception)
   async def global_exception_handler(request: Request, exc: Exception):
       """Gérer toutes les exceptions non catchées"""
       
       # Logger l'erreur avec traceback complet
       logger.error(
           f"Unhandled exception: {str(exc)}",
           exc_info=True,
           extra={
               "path": request.url.path,
               "method": request.method,
               "client_host": request.client.host
           }
       )
       
       # Générer un request_id unique
       request_id = str(uuid.uuid4())
       
       # En production: ne pas exposer les détails de l'erreur
       return JSONResponse(
           status_code=500,
           content={
               "error": "Internal Server Error",
               "message": "An unexpected error occurred",
               "request_id": request_id,
               "support": "contact support@example.com with request_id"
           }
       )


2⃣ 502 BAD GATEWAY - MAUVAISE PASSERELLE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Le serveur agit comme proxy/gateway
     • Le serveur upstream a renvoyé une réponse invalide
   
   Quand utiliser?
     [OK] Erreur de communication avec service upstream
     [OK] Service externe inaccessible
   
   Exemple:
   
   GET /api/external-service
   
   HTTP/1.1 502 Bad Gateway
   Content-Type: application/json
   
   {
     "error": "Bad Gateway",
     "message": "Unable to communicate with upstream service",
     "service": "payment-api"
   }


3⃣ 503 SERVICE UNAVAILABLE - SERVICE INDISPONIBLE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Service temporairement indisponible
     • Maintenance en cours
     • Surcharge temporaire
   
   Quand utiliser?
     [OK] Maintenance planifiée
     [OK] Surcharge serveur
     [OK] Base de données inaccessible
   
   Exemple:
   
   GET /api/users
   
   HTTP/1.1 503 Service Unavailable
   Retry-After: 3600
   Content-Type: application/json
   
   {
     "error": "Service Unavailable",
     "message": "Service is temporarily unavailable",
     "reason": "scheduled_maintenance",
     "retry_after": 3600
   }
   
   Code Python:
   
   # Middleware de maintenance
   MAINTENANCE_MODE = False
   
   @app.middleware("http")
   async def maintenance_middleware(request: Request, call_next):
       if MAINTENANCE_MODE:
           return JSONResponse(
               status_code=503,
               content={
                   "error": "Service Unavailable",
                   "message": "Service is under maintenance",
                   "retry_after": 3600
               },
               headers={"Retry-After": "3600"}
           )
       
       response = await call_next(request)
       return response


4⃣ 504 GATEWAY TIMEOUT - TIMEOUT DE LA PASSERELLE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Le serveur upstream n'a pas répondu à temps
     • Timeout dépassé
   
   Quand utiliser?
     [OK] Service externe trop lent
     [OK] Query base de données timeout
   
   Exemple:
   
   GET /api/slow-operation
   
   HTTP/1.1 504 Gateway Timeout
   Content-Type: application/json
   
   {
     "error": "Gateway Timeout",
     "message": "Upstream service did not respond in time",
     "timeout": 30
   }


═══════════════════════════════════════════════════════════════════════════════
  1.7 HEADERS HTTP - GUIDE COMPLET
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI les headers HTTP?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les headers HTTP sont des métadonnées qui accompagnent requêtes et réponses:
  [OK] Authentification (Authorization)
  [OK] Content negotiation (Accept, Content-Type)
  [OK] Caching (Cache-Control, ETag)
  [OK] Sécurité (CORS, CSP)
  [OK] Informations client (User-Agent)
  [OK] Rate limiting (X-RateLimit-*)

[?] COMMENT utiliser les headers?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    HEADERS DE REQUÊTE ESSENTIELS                    ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

1⃣ Authorization - AUTHENTIFICATION
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Transmettre les credentials d'authentification
     • Support de plusieurs schémas (Bearer, Basic, API Key)
   
   Quand utiliser?
     [OK] JWT tokens
     [OK] API keys
     [OK] Basic auth
   
   Formats:
   
   # Bearer Token (JWT)
   Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
   
   # Basic Auth
   Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
   
   # API Key
   Authorization: ApiKey your-api-key-here
   
   Code Python (extraction):
   
   from fastapi import Header, HTTPException
   
   @app.get("/api/protected")
   async def protected_route(authorization: str = Header(None)):
       if not authorization:
           raise HTTPException(status_code=401, detail="No auth header")
       
       # Extraire le token
       try:
           scheme, token = authorization.split()
           if scheme.lower() != "bearer":
               raise ValueError("Invalid scheme")
       except:
           raise HTTPException(status_code=401, detail="Invalid auth format")
       
       # Vérifier le token
       user = verify_jwt_token(token)
       if not user:
           raise HTTPException(status_code=401, detail="Invalid token")
       
       return {"message": "Access granted", "user": user}


2⃣ Content-Type - TYPE DE CONTENU
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Indique le format du body de la requête
     • Permet au serveur de parser correctement
   
   Quand utiliser?
     [OK] Toujours avec POST, PUT, PATCH ayant un body
   
   Valeurs communes:
   
   # JSON (le plus courant pour APIs)
   Content-Type: application/json
   
   # Form data
   Content-Type: application/x-www-form-urlencoded
   
   # Multipart (upload de fichiers)
   Content-Type: multipart/form-data
   
   # XML
   Content-Type: application/xml
   
   # Texte brut
   Content-Type: text/plain
   
   Code Python:
   
   @app.post("/api/users")
   async def create_user(
       request: Request,
       content_type: str = Header(None)
   ):
       if content_type != "application/json":
           raise HTTPException(
               status_code=415,
               detail="Content-Type must be application/json"
           )
       
       body = await request.json()
       return {"received": body}


3⃣ Accept - TYPES ACCEPTÉS
   ━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Le client indique quels formats il accepte
     • Content negotiation
   
   Quand utiliser?
     [OK] Quand l'API supporte plusieurs formats (JSON, XML)
   
   Exemple:
   
   # Client préfère JSON
   Accept: application/json
   
   # Client accepte plusieurs formats par ordre de préférence
   Accept: application/json, application/xml;q=0.9, */*;q=0.8
   
   Code Python:
   
   from fastapi import Request
   from fastapi.responses import JSONResponse, Response
   
   @app.get("/api/users")
   async def get_users(
       request: Request,
       db: Session = Depends(get_db)
   ):
       users = db.query(User).all()
       
       accept = request.headers.get("accept", "application/json")
       
       if "application/json" in accept:
           return JSONResponse(content=[u.dict() for u in users])
       
       elif "application/xml" in accept:
           xml_content = convert_to_xml(users)
           return Response(content=xml_content, media_type="application/xml")
       
       else:
           raise HTTPException(
               status_code=406,
               detail="Unsupported media type. Use application/json or application/xml"
           )


4⃣ User-Agent - IDENTIFICATION CLIENT
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Identifier le client (navigateur, app mobile, bot)
     • Analytics et debugging
   
   Exemple:
   
   User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
   User-Agent: MyApp/1.0 (iOS 17.0; iPhone14,2)
   User-Agent: curl/7.68.0
   
   Code Python:
   
   @app.get("/api/data")
   async def get_data(user_agent: str = Header(None)):
       # Logger le user agent pour analytics
       logger.info(f"Request from: {user_agent}")
       
       # Adapter la réponse selon le client
       if "mobile" in user_agent.lower():
           return {"data": "mobile_optimized"}
       else:
           return {"data": "desktop_version"}


5⃣ X-Request-ID - TRAÇABILITÉ
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Tracer une requête à travers tous les services
     • Debugging distribué
   
   Exemple:
   
   X-Request-ID: abc-123-def-456
   
   Code Python:
   
   import uuid
   from contextvars import ContextVar
   
   request_id_var: ContextVar[str] = ContextVar("request_id", default=None)
   
   @app.middleware("http")
   async def add_request_id(request: Request, call_next):
       # Utiliser l'ID fourni ou en générer un
       request_id = request.headers.get("x-request-id", str(uuid.uuid4()))
       request_id_var.set(request_id)
       
       response = await call_next(request)
       
       # Ajouter l'ID à la réponse
       response.headers["X-Request-ID"] = request_id
       
       return response
   
   # Utiliser dans les logs
   @app.get("/api/users")
   async def get_users():
       request_id = request_id_var.get()
       logger.info(f"[{request_id}] Fetching users")
       return {"users": []}


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    HEADERS DE RÉPONSE ESSENTIELS                    ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

1⃣ Cache-Control - CONTRÔLE DU CACHE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Contrôler comment et combien de temps les réponses peuvent être cachées
     • Améliorer les performances
   
   Valeurs:
   
   # Ne pas cacher
   Cache-Control: no-cache, no-store, must-revalidate
   
   # Cacher 1 heure
   Cache-Control: max-age=3600
   
   # Cache public (CDN peut cacher)
   Cache-Control: public, max-age=86400
   
   # Cache privé (seulement le navigateur)
   Cache-Control: private, max-age=3600
   
   Code Python:
   
   from fastapi.responses import Response
   
   @app.get("/api/public-data")
   async def get_public_data():
       data = {"info": "This is public data"}
       
       return Response(
           content=json.dumps(data),
           media_type="application/json",
           headers={
               "Cache-Control": "public, max-age=3600",  # 1 heure
               "Vary": "Accept-Encoding"
           }
       )
   
   @app.get("/api/user-data")
   async def get_user_data(user = Depends(get_current_user)):
       data = {"user": user.name, "sensitive": "data"}
       
       return Response(
           content=json.dumps(data),
           media_type="application/json",
           headers={
               "Cache-Control": "private, no-cache, no-store, must-revalidate"
           }
       )


2⃣ ETag - VALIDATION DU CACHE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Identifier une version spécifique d'une ressource
     • Validation de cache efficace
   
   Exemple:
   
   # Réponse initiale
   HTTP/1.1 200 OK
   ETag: "abc123xyz"
   Content-Type: application/json
   
   {"id": 1, "name": "Alice"}
   
   # Requête suivante avec If-None-Match
   GET /api/users/1
   If-None-Match: "abc123xyz"
   
   # Si inchangé
   HTTP/1.1 304 Not Modified
   ETag: "abc123xyz"
   
   # Si modifié
   HTTP/1.1 200 OK
   ETag: "def456uvw"
   {"id": 1, "name": "Alice Updated"}
   
   Code Python:
   
   import hashlib
   
   @app.get("/api/users/{user_id}")
   async def get_user(
       user_id: int,
       if_none_match: Optional[str] = Header(None),
       db: Session = Depends(get_db)
   ):
       user = db.query(User).filter(User.id == user_id).first()
       if not user:
           raise HTTPException(status_code=404)
       
       # Calculer l'ETag basé sur updated_at
       etag = f'"{hashlib.md5(str(user.updated_at).encode()).hexdigest()}"'
       
       # Vérifier si la ressource a changé
       if if_none_match == etag:
           return Response(status_code=304, headers={"ETag": etag})
       
       # Retourner avec ETag
       return JSONResponse(
           content=user.dict(),
           headers={"ETag": etag}
       )


3⃣ Location - EMPLACEMENT DE LA RESSOURCE
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Indiquer l'URL de la ressource créée (201)
     • Indiquer l'URL de redirection (3xx)
   
   Exemple:
   
   POST /api/users
   
   HTTP/1.1 201 Created
   Location: /api/users/124
   Content-Type: application/json
   
   {"id": 124, "name": "Alice"}
   
   Code Python:
   
   @app.post("/api/users", status_code=201)
   async def create_user(
       user: UserCreate,
       response: Response,
       db: Session = Depends(get_db)
   ):
       db_user = User(**user.dict())
       db.add(db_user)
       db.commit()
       db.refresh(db_user)
       
       # Ajouter le header Location
       response.headers["Location"] = f"/api/users/{db_user.id}"
       
       return db_user


4⃣ X-RateLimit-* - INFORMATION SUR LE RATE LIMITING
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Informer le client sur les limites de requêtes
     • Permettre au client de s'adapter
   
   Headers standards:
   
   X-RateLimit-Limit: 100          # Limite totale
   X-RateLimit-Remaining: 85       # Requêtes restantes
   X-RateLimit-Reset: 1702468800   # Timestamp de reset
   Retry-After: 60                 # Secondes avant retry (si 429)
   
   Code Python:
   
   import time
   from collections import defaultdict
   
   # Simple rate limiter en mémoire (utiliser Redis en production)
   rate_limits = defaultdict(lambda: {"count": 0, "reset": 0})
   
   @app.middleware("http")
   async def rate_limit_middleware(request: Request, call_next):
       client_ip = request.client.host
       current_time = int(time.time())
       window = 3600  # 1 heure
       limit = 100
       
       # Reset si la fenêtre est expirée
       if current_time > rate_limits[client_ip]["reset"]:
           rate_limits[client_ip] = {
               "count": 0,
               "reset": current_time + window
           }
       
       # Incrémenter le compteur
       rate_limits[client_ip]["count"] += 1
       count = rate_limits[client_ip]["count"]
       reset_time = rate_limits[client_ip]["reset"]
       
       # Vérifier la limite
       if count > limit:
           return JSONResponse(
               status_code=429,
               content={
                   "error": "Too Many Requests",
                   "message": f"Rate limit of {limit} requests per hour exceeded"
               },
               headers={
                   "X-RateLimit-Limit": str(limit),
                   "X-RateLimit-Remaining": "0",
                   "X-RateLimit-Reset": str(reset_time),
                   "Retry-After": str(reset_time - current_time)
               }
           )
       
       # Continuer avec la requête
       response = await call_next(request)
       
       # Ajouter les headers de rate limit
       response.headers["X-RateLimit-Limit"] = str(limit)
       response.headers["X-RateLimit-Remaining"] = str(limit - count)
       response.headers["X-RateLimit-Reset"] = str(reset_time)
       
       return response


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    HEADERS DE SÉCURITÉ (CORS)                       ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

1⃣ Access-Control-Allow-Origin - ORIGINES AUTORISÉES
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Autoriser des domaines spécifiques à accéder à l'API
     • Protection CORS
   
   Valeurs:
   
   # Autoriser un domaine spécifique
   Access-Control-Allow-Origin: https://monapp.com
   
   # Autoriser tous les domaines (DANGEREUX en production)
   Access-Control-Allow-Origin: *
   
   Code Python:
   
   from fastapi.middleware.cors import CORSMiddleware
   
   # Configuration CORS
   app.add_middleware(
       CORSMiddleware,
       allow_origins=[
           "https://monapp.com",
           "https://www.monapp.com",
           "http://localhost:3000"  # Dev
       ],
       allow_credentials=True,
       allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
       allow_headers=["*"],
       expose_headers=["X-Request-ID", "X-RateLimit-Remaining"],
       max_age=3600  # Cache de la requête preflight
   )


2⃣ Access-Control-Allow-Methods - MÉTHODES AUTORISÉES
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Indiquer quelles méthodes HTTP sont autorisées
     • Réponse à la requête OPTIONS (preflight)
   
   Exemple:
   
   Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS


3⃣ Access-Control-Allow-Headers - HEADERS AUTORISÉS
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Autoriser des headers personnalisés
   
   Exemple:
   
   Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID


4⃣ Access-Control-Allow-Credentials - CREDENTIALS AUTORISÉS
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Autoriser l'envoi de cookies et credentials
     • Nécessaire pour les sessions et auth basée sur cookies
   
   [ATTENTION] Si true, Access-Control-Allow-Origin NE PEUT PAS être *
   
   Exemple:
   
   Access-Control-Allow-Credentials: true
   Access-Control-Allow-Origin: https://monapp.com  # Doit être spécifique
   
   Code Python:
   
   app.add_middleware(
       CORSMiddleware,
       allow_origins=["https://monapp.com"],  # PAS "*"
       allow_credentials=True,  # Autoriser les cookies
       allow_methods=["*"],
       allow_headers=["*"]
   )


5⃣ Access-Control-Max-Age - CACHE PREFLIGHT
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Indiquer combien de temps cacher la réponse OPTIONS
     • Réduire le nombre de requêtes preflight
   
   Exemple:
   
   Access-Control-Max-Age: 86400  # 24 heures
   
   Flux CORS complet:
   
   ┌──────────────────────────────────────────────────────────────────┐
   │                     REQUÊTE CORS COMPLÈTE                        │
   ├──────────────────────────────────────────────────────────────────┤
   │                                                                  │
   │  1⃣ CLIENT (https://monapp.com)                                 │
   │     v                                                            │
   │     v Preflight Request (automatique du navigateur)             │
   │     v                                                            │
   │  OPTIONS /api/users                                              │
   │  Origin: https://monapp.com                                      │
   │  Access-Control-Request-Method: POST                             │
   │  Access-Control-Request-Headers: Content-Type, Authorization     │
   │                                                                  │
   │  2⃣ SERVEUR (api.example.com)                                   │
   │     v                                                            │
   │     v Preflight Response                                         │
   │     v                                                            │
   │  HTTP/1.1 204 No Content                                         │
   │  Access-Control-Allow-Origin: https://monapp.com                 │
   │  Access-Control-Allow-Methods: GET, POST, PUT, DELETE            │
   │  Access-Control-Allow-Headers: Content-Type, Authorization       │
   │  Access-Control-Allow-Credentials: true                          │
   │  Access-Control-Max-Age: 86400                                   │
   │                                                                  │
   │  3⃣ CLIENT                                                       │
   │     v                                                            │
   │     v Requête réelle (si preflight OK)                           │
   │     v                                                            │
   │  POST /api/users                                                 │
   │  Origin: https://monapp.com                                      │
   │  Content-Type: application/json                                  │
   │  Authorization: Bearer token...                                  │
   │                                                                  │
   │  {"name": "Alice", "email": "alice@example.com"}                 │
   │                                                                  │
   │  4⃣ SERVEUR                                                      │
   │     v                                                            │
   │     v Réponse finale                                             │
   │     v                                                            │
   │  HTTP/1.1 201 Created                                            │
   │  Access-Control-Allow-Origin: https://monapp.com                 │
   │  Access-Control-Allow-Credentials: true                          │
   │  Content-Type: application/json                                  │
   │                                                                  │
   │  {"id": 123, "name": "Alice", "email": "alice@example.com"}      │
   │                                                                  │
   └──────────────────────────────────────────────────────────────────┘


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    AUTRES HEADERS DE SÉCURITÉ                       ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

1⃣ Strict-Transport-Security (HSTS)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Forcer HTTPS
     • Protéger contre les attaques downgrade
   
   Exemple:
   
   Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
   
   Code Python:
   
   @app.middleware("http")
   async def add_security_headers(request: Request, call_next):
       response = await call_next(request)
       
       # HSTS - Force HTTPS
       response.headers["Strict-Transport-Security"] = \
           "max-age=31536000; includeSubDomains; preload"
       
       # Content Security Policy
       response.headers["Content-Security-Policy"] = \
           "default-src 'self'; script-src 'self' 'unsafe-inline'"
       
       # Prevent clickjacking
       response.headers["X-Frame-Options"] = "DENY"
       
       # XSS Protection
       response.headers["X-Content-Type-Options"] = "nosniff"
       response.headers["X-XSS-Protection"] = "1; mode=block"
       
       # Referrer Policy
       response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
       
       # Permissions Policy
       response.headers["Permissions-Policy"] = \
           "geolocation=(), microphone=(), camera=()"
       
       return response


2⃣ Content-Security-Policy (CSP)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Protéger contre XSS
     • Contrôler les sources de contenu autorisées
   
   Exemple:
   
   Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com


3⃣ X-Frame-Options
   ━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Protéger contre clickjacking
     • Empêcher l'inclusion dans des iframes
   
   Valeurs:
   
   X-Frame-Options: DENY                    # Pas d'iframe du tout
   X-Frame-Options: SAMEORIGIN              # Seulement même domaine
   X-Frame-Options: ALLOW-FROM https://...  # Domaine spécifique


4⃣ X-Content-Type-Options
   ━━━━━━━━━━━━━━━━━━━━━━━

   Pourquoi?
     • Empêcher MIME type sniffing
     • Forcer le navigateur à respecter Content-Type
   
   Exemple:
   
   X-Content-Type-Options: nosniff


═══════════════════════════════════════════════════════════════════════════════
  PARTIE 2: CRÉATION D'APIs AVEC FLASK
═══════════════════════════════════════════════════════════════════════════════


[?] POURQUOI Flask pour créer des APIs?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Flask est un micro-framework Python PARFAIT pour:
  [OK] Apprendre les bases des APIs (simple et minimal)
  [OK] Prototypes rapides
  [OK] APIs petites/moyennes
  [OK] Flexibilité totale (tu décides de tout)
  [OK] Grande communauté et extensions
  [OK] Léger et rapide à démarrer

COMPARAISON Flask vs FastAPI:
  Flask:   Plus ancien, plus mature, synchrone par défaut
  FastAPI: Plus moderne, async natif, validation auto, plus rapide


═══════════════════════════════════════════════════════════════════════════════
  2.1 CONFIGURATION ET STRUCTURE DE PROJET FLASK
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI une structure de projet organisée?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une bonne structure permet:
  [OK] Code maintenable et scalable
  [OK] Tests faciles
  [OK] Séparation des responsabilités
  [OK] Collaboration en équipe
  [OK] Déploiement simplifié

[?] COMMENT structurer un projet Flask?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

┌─────────────────────────────────────────────────────────────────────────┐
│                    STRUCTURE RECOMMANDÉE - PETITE API                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  my_api/                                                                │
│  ├── app.py              <- Point d'entrée principal                     │
│  ├── config.py           <- Configuration (dev, prod, test)              │
│  ├── requirements.txt    <- Dépendances Python                           │
│  ├── .env                <- Variables d'environnement (secrets)          │
│  ├── .gitignore          <- Fichiers à ignorer par git                   │
│  │                                                                       │
│  ├── models/             <- Modèles de base de données                   │
│  │   ├── __init__.py                                                    │
│  │   ├── user.py                                                        │
│  │   └── post.py                                                        │
│  │                                                                       │
│  ├── routes/             <- Endpoints de l'API                           │
│  │   ├── __init__.py                                                    │
│  │   ├── users.py                                                       │
│  │   └── posts.py                                                       │
│  │                                                                       │
│  ├── schemas/            <- Validation des données (Marshmallow)         │
│  │   ├── __init__.py                                                    │
│  │   ├── user_schema.py                                                 │
│  │   └── post_schema.py                                                 │
│  │                                                                       │
│  ├── utils/              <- Fonctions utilitaires                        │
│  │   ├── __init__.py                                                    │
│  │   ├── auth.py         <- JWT, hashing, etc.                           │
│  │   └── decorators.py   <- Décorateurs personnalisés                    │
│  │                                                                       │
│  └── tests/              <- Tests unitaires et d'intégration             │
│      ├── __init__.py                                                    │
│      ├── test_users.py                                                  │
│      └── test_posts.py                                                  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                    STRUCTURE RECOMMANDÉE - GRANDE API                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  my_api/                                                                │
│  ├── run.py              <- Point d'entrée (lance l'app)                 │
│  ├── config.py           <- Configuration centralisée                    │
│  ├── requirements.txt                                                   │
│  ├── .env                                                               │
│  ├── .gitignore                                                         │
│  ├── README.md                                                          │
│  ├── docker-compose.yml  <- Containerization                             │
│  ├── Dockerfile                                                         │
│  │                                                                       │
│  ├── app/                <- Application principale                       │
│  │   ├── __init__.py     <- Factory pattern (create_app)                 │
│  │   │                                                                   │
│  │   ├── models/         <- Modèles SQLAlchemy                           │
│  │   │   ├── __init__.py                                                │
│  │   │   ├── base.py     <- BaseModel avec timestamps                    │
│  │   │   ├── user.py                                                    │
│  │   │   ├── post.py                                                    │
│  │   │   └── comment.py                                                 │
│  │   │                                                                   │
│  │   ├── api/            <- Routes API (Blueprints)                      │
│  │   │   ├── __init__.py                                                │
│  │   │   ├── v1/         <- Version 1 de l'API                           │
│  │   │   │   ├── __init__.py                                            │
│  │   │   │   ├── users.py                                               │
│  │   │   │   ├── posts.py                                               │
│  │   │   │   └── auth.py                                                │
│  │   │   └── v2/         <- Version 2 de l'API                           │
│  │   │       └── ...                                                    │
│  │   │                                                                   │
│  │   ├── schemas/        <- Validation Marshmallow                       │
│  │   │   ├── __init__.py                                                │
│  │   │   ├── user.py                                                    │
│  │   │   └── post.py                                                    │
│  │   │                                                                   │
│  │   ├── services/       <- Logique métier                               │
│  │   │   ├── __init__.py                                                │
│  │   │   ├── user_service.py                                            │
│  │   │   └── post_service.py                                            │
│  │   │                                                                   │
│  │   ├── middleware/     <- Middleware personnalisés                     │
│  │   │   ├── __init__.py                                                │
│  │   │   ├── auth.py     <- Authentification                             │
│  │   │   └── logging.py  <- Logging des requêtes                         │
│  │   │                                                                   │
│  │   ├── utils/          <- Utilitaires                                  │
│  │   │   ├── __init__.py                                                │
│  │   │   ├── decorators.py                                              │
│  │   │   ├── validators.py                                              │
│  │   │   └── helpers.py                                                 │
│  │   │                                                                   │
│  │   ├── exceptions/     <- Exceptions personnalisées                    │
│  │   │   ├── __init__.py                                                │
│  │   │   └── handlers.py                                                │
│  │   │                                                                   │
│  │   └── extensions.py   <- Initialisation extensions (db, ma, etc.)     │
│  │                                                                       │
│  ├── migrations/         <- Migrations Alembic                           │
│  │   ├── versions/                                                      │
│  │   └── alembic.ini                                                    │
│  │                                                                       │
│  └── tests/              <- Tests                                        │
│      ├── __init__.py                                                    │
│      ├── conftest.py     <- Fixtures pytest                              │
│      ├── unit/           <- Tests unitaires                              │
│      │   ├── test_models.py                                             │
│      │   └── test_services.py                                           │
│      └── integration/    <- Tests d'intégration                          │
│          ├── test_users_api.py                                          │
│          └── test_posts_api.py                                          │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

[?] QUAND utiliser chaque structure?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PETITE STRUCTURE:
  [OK] Prototypes et MVPs
  [OK] APIs avec < 10 endpoints
  [OK] Projets personnels
  [OK] Apprentissage

GRANDE STRUCTURE:
  [OK] APIs de production
  [OK] Équipes multiples
  [OK] APIs avec > 20 endpoints
  [OK] Besoin de versioning
  [OK] Microservices


═══ INSTALLATION ET SETUP ═══

# Installation des dépendances
pip install Flask Flask-SQLAlchemy Flask-Migrate Flask-Marshmallow \
            marshmallow-sqlalchemy Flask-JWT-Extended Flask-CORS \
            python-dotenv

# requirements.txt
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.5
Flask-Marshmallow==0.15.0
marshmallow-sqlalchemy==0.29.0
Flask-JWT-Extended==4.5.3
Flask-CORS==4.0.0
python-dotenv==1.0.0
psycopg2-binary==2.9.9  # PostgreSQL
PyMySQL==1.1.0          # MySQL
gunicorn==21.2.0        # Production server
pytest==7.4.3           # Tests
pytest-cov==4.1.0       # Coverage


═══ FICHIER config.py - CONFIGURATION MULTI-ENVIRONNEMENTS ═══

"""
Configuration de l'application Flask
Support de plusieurs environnements: development, testing, production
"""

import os
from datetime import timedelta
from dotenv import load_dotenv

# Charger les variables d'environnement depuis .env
load_dotenv()


class Config:
    """Configuration de base partagée par tous les environnements"""
    
    # ═══ SECRETS ET CLÉS ═══
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-prod'
    JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or 'jwt-secret-key'
    
    # ═══ BASE DE DONNÉES ═══
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_ECHO = False  # Ne pas logger les queries SQL
    
    # ═══ JWT CONFIGURATION ═══
    JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
    JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
    JWT_ALGORITHM = 'HS256'
    
    # ═══ CORS ═══
    CORS_HEADERS = 'Content-Type'
    
    # ═══ PAGINATION ═══
    ITEMS_PER_PAGE = 20
    MAX_ITEMS_PER_PAGE = 100
    
    # ═══ UPLOAD DE FICHIERS ═══
    MAX_CONTENT_LENGTH = 16 * 1024 * 1024  # 16 MB max
    UPLOAD_FOLDER = 'uploads'
    ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
    
    # ═══ RATE LIMITING ═══
    RATELIMIT_ENABLED = True
    RATELIMIT_STORAGE_URL = os.environ.get('REDIS_URL', 'memory://')
    
    # ═══ LOGGING ═══
    LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT', 'false').lower() == 'true'


class DevelopmentConfig(Config):
    """Configuration pour l'environnement de développement"""
    
    DEBUG = True
    TESTING = False
    
    # Base de données locale
    SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
        'postgresql://localhost/myapi_dev'
    
    SQLALCHEMY_ECHO = True  # Logger toutes les queries SQL
    
    # CORS permissif en dev
    CORS_ORIGINS = ['http://localhost:3000', 'http://localhost:5173']


class TestingConfig(Config):
    """Configuration pour les tests"""
    
    TESTING = True
    DEBUG = True
    
    # Base de données de test (SQLite en mémoire)
    SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
    
    # Désactiver CSRF pour les tests
    WTF_CSRF_ENABLED = False
    
    # JWT avec expiration courte pour tests
    JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=5)


class ProductionConfig(Config):
    """Configuration pour la production"""
    
    DEBUG = False
    TESTING = False
    
    # Base de données de production (depuis variable d'environnement)
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
    
    # Vérifier que la DB URL est définie
    if not SQLALCHEMY_DATABASE_URI:
        raise ValueError("DATABASE_URL environment variable must be set")
    
    # CORS restrictif en production
    CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '').split(',')
    
    # HTTPS uniquement
    SESSION_COOKIE_SECURE = True
    SESSION_COOKIE_HTTPONLY = True
    SESSION_COOKIE_SAMESITE = 'Lax'
    
    # Security headers
    SEND_FILE_MAX_AGE_DEFAULT = 31536000  # 1 an


class StagingConfig(ProductionConfig):
    """Configuration pour staging (pré-production)"""
    
    DEBUG = True  # Un peu de debug en staging
    
    SQLALCHEMY_DATABASE_URI = os.environ.get('STAGING_DATABASE_URL')


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


def get_config(env=None):
    """
    Récupère la configuration selon l'environnement
    
    Args:
        env: Nom de l'environnement (development, testing, production)
             Si None, utilise la variable FLASK_ENV
    
    Returns:
        Classe de configuration appropriée
    """
    if env is None:
        env = os.environ.get('FLASK_ENV', 'development')
    
    return config.get(env, config['default'])


═══ FICHIER .env - VARIABLES D'ENVIRONNEMENT ═══

# .env - NE JAMAIS COMMIT CE FICHIER!

# Environnement
FLASK_ENV=development
FLASK_APP=run.py

# Secrets (générer avec: python -c "import secrets; print(secrets.token_hex(32))")
SECRET_KEY=your-super-secret-key-here
JWT_SECRET_KEY=your-jwt-secret-key-here

# Base de données
DEV_DATABASE_URL=postgresql://user:password@localhost/myapi_dev
TEST_DATABASE_URL=sqlite:///:memory:
DATABASE_URL=postgresql://user:password@prod-host/myapi_prod

# Services externes
REDIS_URL=redis://localhost:6379/0
SENDGRID_API_KEY=your-sendgrid-key
AWS_ACCESS_KEY_ID=your-aws-key
AWS_SECRET_ACCESS_KEY=your-aws-secret

# CORS
CORS_ORIGINS=https://myapp.com,https://www.myapp.com

# Monitoring
SENTRY_DSN=your-sentry-dsn


═══ FICHIER .gitignore ═══

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg

# Flask
instance/
.webassets-cache

# Environment variables
.env
.env.local
.env.*.local

# IDE
.vscode/
.idea/
*.swp
*.swo
*~

# Database
*.db
*.sqlite
*.sqlite3

# Logs
*.log
logs/

# OS
.DS_Store
Thumbs.db

# Testing
.pytest_cache/
.coverage
htmlcov/

# Uploads
uploads/*
!uploads/.gitkeep


═══════════════════════════════════════════════════════════════════════════════
  2.2 APPLICATION FACTORY PATTERN
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI utiliser le Factory Pattern?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le Factory Pattern permet:
  [OK] Créer plusieurs instances de l'app (tests, dev, prod)
  [OK] Configuration différente par environnement
  [OK] Tests plus faciles
  [OK] Éviter les imports circulaires
  [OK] Code plus modulaire et maintenable

[?] COMMENT implémenter le Factory Pattern?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


═══ FICHIER app/__init__.py - APPLICATION FACTORY ═══

"""
Application Factory pour Flask
Crée et configure l'application Flask selon l'environnement
"""

from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_marshmallow import Marshmallow
from flask_jwt_extended import JWTManager
from flask_cors import CORS

from config import get_config

# ═══ INITIALISATION DES EXTENSIONS ═══
# On crée les instances mais ne les lie pas encore à l'app
db = SQLAlchemy()
migrate = Migrate()
ma = Marshmallow()
jwt = JWTManager()
cors = CORS()


def create_app(config_name=None):
    """
    Application Factory
    
    Crée et configure une instance de l'application Flask
    
    Args:
        config_name: Nom de la configuration à utiliser
                     ('development', 'testing', 'production')
    
    Returns:
        app: Instance Flask configurée
    
    Exemple:
        # En développement
        app = create_app('development')
        
        # En production
        app = create_app('production')
        
        # Tests
        app = create_app('testing')
    """
    
    # ═══ 1. CRÉER L'APPLICATION ═══
    app = Flask(__name__)
    
    # ═══ 2. CHARGER LA CONFIGURATION ═══
    config_class = get_config(config_name)
    app.config.from_object(config_class)
    
    # ═══ 3. INITIALISER LES EXTENSIONS ═══
    initialize_extensions(app)
    
    # ═══ 4. ENREGISTRER LES BLUEPRINTS ═══
    register_blueprints(app)
    
    # ═══ 5. ENREGISTRER LES ERROR HANDLERS ═══
    register_error_handlers(app)
    
    # ═══ 6. CONFIGURER LES HOOKS ═══
    register_hooks(app)
    
    # ═══ 7. CONFIGURER LE LOGGING ═══
    configure_logging(app)
    
    # ═══ 8. AJOUTER DES ROUTES DE SANTÉ ═══
    register_health_checks(app)
    
    return app


def initialize_extensions(app):
    """
    Initialise toutes les extensions Flask avec l'app
    
    Cette fonction est appelée par create_app()
    """
    
    # Database
    db.init_app(app)
    
    # Migrations
    migrate.init_app(app, db)
    
    # Marshmallow (serialization)
    ma.init_app(app)
    
    # JWT Authentication
    jwt.init_app(app)
    
    # CORS
    cors.init_app(
        app,
        resources={r"/api/*": {"origins": app.config.get('CORS_ORIGINS', '*')}},
        supports_credentials=True
    )
    
    # ═══ JWT CALLBACKS ═══
    
    @jwt.token_in_blocklist_loader
    def check_if_token_revoked(jwt_header, jwt_payload):
        """Vérifier si le token a été révoqué"""
        # TODO: Implémenter avec Redis
        return False
    
    @jwt.expired_token_loader
    def expired_token_callback(jwt_header, jwt_payload):
        """Token expiré"""
        return jsonify({
            'error': 'Unauthorized',
            'message': 'Token has expired'
        }), 401
    
    @jwt.invalid_token_loader
    def invalid_token_callback(error):
        """Token invalide"""
        return jsonify({
            'error': 'Unauthorized',
            'message': 'Invalid token'
        }), 401
    
    @jwt.unauthorized_loader
    def missing_token_callback(error):
        """Token manquant"""
        return jsonify({
            'error': 'Unauthorized',
            'message': 'No authentication token provided'
        }), 401


def register_blueprints(app):
    """
    Enregistre tous les blueprints (routes) de l'API
    
    Les blueprints permettent de moduler l'application
    """
    
    from app.api.v1 import api_v1_bp
    from app.api.v1.auth import auth_bp
    from app.api.v1.users import users_bp
    from app.api.v1.posts import posts_bp
    
    # API v1
    app.register_blueprint(api_v1_bp)
    
    # Routes spécifiques
    app.register_blueprint(auth_bp, url_prefix='/api/v1/auth')
    app.register_blueprint(users_bp, url_prefix='/api/v1/users')
    app.register_blueprint(posts_bp, url_prefix='/api/v1/posts')


def register_error_handlers(app):
    """
    Enregistre les gestionnaires d'erreurs globaux
    
    Gère toutes les erreurs de manière cohérente
    """
    
    @app.errorhandler(400)
    def bad_request(error):
        """400 Bad Request"""
        return jsonify({
            'error': 'Bad Request',
            'message': str(error)
        }), 400
    
    @app.errorhandler(401)
    def unauthorized(error):
        """401 Unauthorized"""
        return jsonify({
            'error': 'Unauthorized',
            'message': 'Authentication required'
        }), 401
    
    @app.errorhandler(403)
    def forbidden(error):
        """403 Forbidden"""
        return jsonify({
            'error': 'Forbidden',
            'message': 'You do not have permission to access this resource'
        }), 403
    
    @app.errorhandler(404)
    def not_found(error):
        """404 Not Found"""
        return jsonify({
            'error': 'Not Found',
            'message': 'The requested resource was not found'
        }), 404
    
    @app.errorhandler(405)
    def method_not_allowed(error):
        """405 Method Not Allowed"""
        return jsonify({
            'error': 'Method Not Allowed',
            'message': f'The method is not allowed for this endpoint'
        }), 405
    
    @app.errorhandler(409)
    def conflict(error):
        """409 Conflict"""
        return jsonify({
            'error': 'Conflict',
            'message': str(error)
        }), 409
    
    @app.errorhandler(422)
    def unprocessable_entity(error):
        """422 Unprocessable Entity"""
        return jsonify({
            'error': 'Unprocessable Entity',
            'message': 'Validation failed',
            'details': str(error)
        }), 422
    
    @app.errorhandler(429)
    def too_many_requests(error):
        """429 Too Many Requests"""
        return jsonify({
            'error': 'Too Many Requests',
            'message': 'Rate limit exceeded'
        }), 429
    
    @app.errorhandler(500)
    def internal_server_error(error):
        """500 Internal Server Error"""
        app.logger.error(f'Internal error: {error}')
        
        # En production, ne pas exposer les détails
        if app.config['DEBUG']:
            message = str(error)
        else:
            message = 'An unexpected error occurred'
        
        return jsonify({
            'error': 'Internal Server Error',
            'message': message
        }), 500
    
    @app.errorhandler(503)
    def service_unavailable(error):
        """503 Service Unavailable"""
        return jsonify({
            'error': 'Service Unavailable',
            'message': 'Service temporarily unavailable'
        }), 503


def register_hooks(app):
    """
    Enregistre les hooks before_request et after_request
    
    Middleware pour chaque requête
    """
    
    @app.before_request
    def before_request():
        """
        Exécuté avant chaque requête
        Utile pour: logging, authentification, rate limiting
        """
        from flask import request, g
        import time
        
        # Timestamp de début
        g.start_time = time.time()
        
        # Logger la requête
        app.logger.info(f'{request.method} {request.path}')
    
    @app.after_request
    def after_request(response):
        """
        Exécuté après chaque requête
        Utile pour: logging, headers de sécurité, CORS
        """
        from flask import request, g
        import time
        
        # Calculer le temps de réponse
        if hasattr(g, 'start_time'):
            elapsed = time.time() - g.start_time
            response.headers['X-Response-Time'] = f'{elapsed:.3f}s'
        
        # Headers de sécurité
        response.headers['X-Content-Type-Options'] = 'nosniff'
        response.headers['X-Frame-Options'] = 'DENY'
        response.headers['X-XSS-Protection'] = '1; mode=block'
        
        # Logger la réponse
        app.logger.info(
            f'{request.method} {request.path} - '
            f'{response.status_code} - {elapsed:.3f}s'
        )
        
        return response
    
    @app.teardown_appcontext
    def shutdown_session(exception=None):
        """
        Exécuté à la fin de chaque requête
        Ferme la session de base de données
        """
        db.session.remove()


def configure_logging(app):
    """
    Configure le système de logging
    """
    import logging
    from logging.handlers import RotatingFileHandler
    import os
    
    if not app.debug and not app.testing:
        # Créer le dossier logs s'il n'existe pas
        if not os.path.exists('logs'):
            os.mkdir('logs')
        
        # Fichier de log avec rotation
        file_handler = RotatingFileHandler(
            'logs/api.log',
            maxBytes=10240000,  # 10 MB
            backupCount=10
        )
        
        file_handler.setFormatter(logging.Formatter(
            '%(asctime)s %(levelname)s: %(message)s '
            '[in %(pathname)s:%(lineno)d]'
        ))
        
        file_handler.setLevel(logging.INFO)
        app.logger.addHandler(file_handler)
        
        app.logger.setLevel(logging.INFO)
        app.logger.info('API startup')


def register_health_checks(app):
    """
    Ajoute des endpoints de santé pour monitoring
    """
    
    @app.route('/health')
    def health():
        """
        Health check simple
        Utilisé par load balancers et monitoring
        """
        return jsonify({'status': 'healthy'}), 200
    
    @app.route('/health/db')
    def health_db():
        """
        Health check de la base de données
        """
        try:
            # Tenter une query simple
            db.session.execute('SELECT 1')
            return jsonify({
                'status': 'healthy',
                'database': 'connected'
            }), 200
        except Exception as e:
            app.logger.error(f'Database health check failed: {e}')
            return jsonify({
                'status': 'unhealthy',
                'database': 'disconnected',
                'error': str(e)
            }), 503
    
    @app.route('/')
    def index():
        """
        Route racine - Informations sur l'API
        """
        return jsonify({
            'name': 'My API',
            'version': '1.0.0',
            'status': 'running',
            'docs': '/api/v1/docs',
            'health': '/health'
        }), 200


═══ FICHIER run.py - POINT D'ENTRÉE ═══

"""
Point d'entrée de l'application
Lance le serveur Flask
"""

import os
from app import create_app, db
from app.models import User, Post  # Import pour shell context

# Créer l'application
app = create_app(os.environ.get('FLASK_ENV', 'development'))


@app.shell_context_processor
def make_shell_context():
    """
    Ajoute des variables au shell Flask
    Utile pour: flask shell
    
    Usage:
        $ flask shell
        >>> db
        <SQLAlchemy engine=...>
        >>> User.query.all()
        [...]
    """
    return {
        'db': db,
        'User': User,
        'Post': Post
    }


@app.cli.command()
def init_db():
    """
    Initialise la base de données
    
    Usage:
        $ flask init-db
    """
    db.create_all()
    print('Database initialized!')


@app.cli.command()
def seed_db():
    """
    Remplit la base de données avec des données de test
    
    Usage:
        $ flask seed-db
    """
    from app.models import User, Post
    from datetime import datetime
    
    # Créer des utilisateurs de test
    users = [
        User(
            username='alice',
            email='alice@example.com',
            password_hash=User.hash_password('password123')
        ),
        User(
            username='bob',
            email='bob@example.com',
            password_hash=User.hash_password('password123')
        )
    ]
    
    for user in users:
        db.session.add(user)
    
    db.session.commit()
    
    # Créer des posts de test
    posts = [
        Post(
            title='First Post',
            content='This is the first post',
            author_id=users[0].id
        ),
        Post(
            title='Second Post',
            content='This is the second post',
            author_id=users[1].id
        )
    ]
    
    for post in posts:
        db.session.add(post)
    
    db.session.commit()
    
    print('Database seeded!')


if __name__ == '__main__':
    # Lancer le serveur de développement
    # En production, utiliser gunicorn ou uwsgi
    app.run(
        host='0.0.0.0',
        port=5000,
        debug=app.config['DEBUG']
    )


═══ UTILISATION ═══

# Développement
$ export FLASK_ENV=development
$ flask run

# Ou directement
$ python run.py

# Production (avec Gunicorn)
$ gunicorn -w 4 -b 0.0.0.0:5000 "run:app"

# Commandes CLI
$ flask init-db    # Initialiser la DB
$ flask seed-db    # Remplir avec données de test
$ flask shell      # Shell interactif
$ flask routes     # Voir toutes les routes


═══════════════════════════════════════════════════════════════════════════════
  2.3 ROUTING ET ENDPOINTS AVEC BLUEPRINTS
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI utiliser les Blueprints?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les Blueprints permettent:
  [OK] Modulariser l'application (séparer les routes)
  [OK] Réutiliser des routes dans plusieurs apps
  [OK] Organiser par fonctionnalité ou version
  [OK] Préfixer automatiquement les URLs
  [OK] Middleware spécifiques par blueprint

[?] COMMENT créer des Blueprints?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


═══ FICHIER app/api/v1/__init__.py - BLUEPRINT PRINCIPAL V1 ═══

"""
Blueprint principal pour l'API v1
Regroupe tous les sous-blueprints
"""

from flask import Blueprint, jsonify

# Créer le blueprint principal de l'API v1
api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1')


@api_v1_bp.route('/')
def index():
    """
    Index de l'API v1
    Donne des informations sur l'API
    
    GET /api/v1/
    """
    return jsonify({
        'version': '1.0.0',
        'endpoints': {
            'auth': '/api/v1/auth',
            'users': '/api/v1/users',
            'posts': '/api/v1/posts'
        },
        'documentation': '/api/v1/docs'
    }), 200


@api_v1_bp.route('/docs')
def documentation():
    """
    Documentation de l'API
    
    GET /api/v1/docs
    """
    return jsonify({
        'openapi': '3.0.0',
        'info': {
            'title': 'My API',
            'version': '1.0.0',
            'description': 'RESTful API avec Flask'
        },
        'servers': [
            {'url': 'http://localhost:5000/api/v1'}
        ],
        'paths': {
            # Documentation auto-générée ou manuelle
        }
    }), 200


═══ FICHIER app/api/v1/users.py - ROUTES UTILISATEURS ═══

"""
Routes pour la gestion des utilisateurs
CRUD complet sur la ressource User
"""

from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity

from app import db
from app.models.user import User
from app.schemas.user import user_schema, users_schema
from app.utils.decorators import admin_required
from app.utils.pagination import paginate

# Créer le blueprint users
users_bp = Blueprint('users', __name__)


# ═══ GET /api/v1/users - LISTER LES UTILISATEURS ═══

@users_bp.route('', methods=['GET'])
@jwt_required()
def get_users():
    """
    Récupère la liste des utilisateurs avec pagination
    
    Query params:
        - page: Numéro de page (défaut: 1)
        - per_page: Items par page (défaut: 20, max: 100)
        - sort: Champ de tri (défaut: created_at)
        - order: Ordre de tri (asc/desc, défaut: desc)
        - q: Recherche par nom ou email
        - role: Filtrer par rôle
        - active: Filtrer par statut (true/false)
    
    Réponse 200:
        {
            "users": [...],
            "pagination": {
                "page": 1,
                "per_page": 20,
                "total": 150,
                "pages": 8,
                "has_next": true,
                "has_prev": false
            }
        }
    """
    
    # Récupérer les paramètres de query
    page = request.args.get('page', 1, type=int)
    per_page = min(request.args.get('per_page', 20, type=int), 100)
    sort_by = request.args.get('sort', 'created_at')
    order = request.args.get('order', 'desc')
    search = request.args.get('q', '')
    role_filter = request.args.get('role')
    active_filter = request.args.get('active')
    
    # Construire la query
    query = User.query
    
    # Filtre de recherche
    if search:
        query = query.filter(
            db.or_(
                User.username.ilike(f'%{search}%'),
                User.email.ilike(f'%{search}%')
            )
        )
    
    # Filtre par rôle
    if role_filter:
        query = query.filter(User.role == role_filter)
    
    # Filtre par statut actif
    if active_filter is not None:
        is_active = active_filter.lower() == 'true'
        query = query.filter(User.active == is_active)
    
    # Tri
    if hasattr(User, sort_by):
        sort_column = getattr(User, sort_by)
        if order == 'desc':
            query = query.order_by(sort_column.desc())
        else:
            query = query.order_by(sort_column.asc())
    
    # Pagination
    pagination = query.paginate(
        page=page,
        per_page=per_page,
        error_out=False
    )
    
    # Sérialiser les résultats
    users = users_schema.dump(pagination.items)
    
    return jsonify({
        'users': users,
        'pagination': {
            'page': pagination.page,
            'per_page': pagination.per_page,
            'total': pagination.total,
            'pages': pagination.pages,
            'has_next': pagination.has_next,
            'has_prev': pagination.has_prev
        }
    }), 200


# ═══ GET /api/v1/users/:id - RÉCUPÉRER UN UTILISATEUR ═══

@users_bp.route('/<int:user_id>', methods=['GET'])
@jwt_required()
def get_user(user_id):
    """
    Récupère un utilisateur spécifique par son ID
    
    Path params:
        - user_id: ID de l'utilisateur
    
    Réponse 200:
        {
            "id": 123,
            "username": "alice",
            "email": "alice@example.com",
            ...
        }
    
    Réponse 404:
        {
            "error": "Not Found",
            "message": "User with ID 123 not found"
        }
    """
    
    user = User.query.get(user_id)
    
    if not user:
        return jsonify({
            'error': 'Not Found',
            'message': f'User with ID {user_id} not found'
        }), 404
    
    # Sérialiser
    result = user_schema.dump(user)
    
    return jsonify(result), 200


# ═══ POST /api/v1/users - CRÉER UN UTILISATEUR ═══

@users_bp.route('', methods=['POST'])
def create_user():
    """
    Crée un nouvel utilisateur
    
    Body:
        {
            "username": "alice",
            "email": "alice@example.com",
            "password": "SecurePass123!",
            "role": "user"  (optionnel)
        }
    
    Réponse 201:
        {
            "id": 124,
            "username": "alice",
            "email": "alice@example.com",
            "created_at": "2025-12-13T10:30:00Z"
        }
        Header: Location: /api/v1/users/124
    
    Réponse 400:
        {
            "error": "Bad Request",
            "message": "Validation failed",
            "errors": {...}
        }
    
    Réponse 409:
        {
            "error": "Conflict",
            "message": "Username or email already exists"
        }
    """
    
    # Valider les données d'entrée
    try:
        data = user_schema.load(request.json)
    except Exception as e:
        return jsonify({
            'error': 'Bad Request',
            'message': 'Validation failed',
            'errors': e.messages
        }), 400
    
    # Vérifier si username ou email existe déjà
    if User.query.filter_by(username=data['username']).first():
        return jsonify({
            'error': 'Conflict',
            'message': 'Username already exists'
        }), 409
    
    if User.query.filter_by(email=data['email']).first():
        return jsonify({
            'error': 'Conflict',
            'message': 'Email already exists'
        }), 409
    
    # Créer l'utilisateur
    user = User(
        username=data['username'],
        email=data['email'],
        password_hash=User.hash_password(data['password']),
        role=data.get('role', 'user')
    )
    
    db.session.add(user)
    db.session.commit()
    
    # Sérialiser
    result = user_schema.dump(user)
    
    # Créer la réponse avec header Location
    response = jsonify(result)
    response.status_code = 201
    response.headers['Location'] = f'/api/v1/users/{user.id}'
    
    return response


# ═══ PUT /api/v1/users/:id - REMPLACER UN UTILISATEUR ═══

@users_bp.route('/<int:user_id>', methods=['PUT'])
@jwt_required()
def replace_user(user_id):
    """
    Remplace complètement un utilisateur
    
    Nécessite l'authentification JWT
    Un utilisateur peut seulement modifier son propre profil
    Les admins peuvent modifier n'importe quel profil
    
    Body: Tous les champs requis
        {
            "username": "alice_updated",
            "email": "alice.new@example.com",
            "role": "editor"
        }
    
    Réponse 200:
        {
            "id": 123,
            "username": "alice_updated",
            ...
        }
    """
    
    # Récupérer l'utilisateur courant
    current_user_id = get_jwt_identity()
    current_user = User.query.get(current_user_id)
    
    # Vérifier les permissions
    if current_user_id != user_id and current_user.role != 'admin':
        return jsonify({
            'error': 'Forbidden',
            'message': 'You can only modify your own profile'
        }), 403
    
    # Récupérer l'utilisateur à modifier
    user = User.query.get(user_id)
    if not user:
        return jsonify({
            'error': 'Not Found',
            'message': f'User with ID {user_id} not found'
        }), 404
    
    # Valider les données
    try:
        data = user_schema.load(request.json, partial=False)
    except Exception as e:
        return jsonify({
            'error': 'Bad Request',
            'message': 'Validation failed',
            'errors': e.messages
        }), 400
    
    # Vérifier unicité username/email (sauf si inchangés)
    if data['username'] != user.username:
        if User.query.filter_by(username=data['username']).first():
            return jsonify({
                'error': 'Conflict',
                'message': 'Username already exists'
            }), 409
    
    if data['email'] != user.email:
        if User.query.filter_by(email=data['email']).first():
            return jsonify({
                'error': 'Conflict',
                'message': 'Email already exists'
            }), 409
    
    # Mettre à jour TOUS les champs
    user.username = data['username']
    user.email = data['email']
    if 'password' in data:
        user.password_hash = User.hash_password(data['password'])
    if 'role' in data and current_user.role == 'admin':
        user.role = data['role']
    
    db.session.commit()
    
    # Sérialiser
    result = user_schema.dump(user)
    
    return jsonify(result), 200


# ═══ PATCH /api/v1/users/:id - MODIFIER PARTIELLEMENT ═══

@users_bp.route('/<int:user_id>', methods=['PATCH'])
@jwt_required()
def update_user(user_id):
    """
    Modifie partiellement un utilisateur
    
    Body: Seulement les champs à modifier
        {
            "email": "new.email@example.com"
        }
    
    Réponse 200:
        {
            "id": 123,
            "username": "alice",
            "email": "new.email@example.com",
            ...
        }
    """
    
    # Vérifier les permissions
    current_user_id = get_jwt_identity()
    current_user = User.query.get(current_user_id)
    
    if current_user_id != user_id and current_user.role != 'admin':
        return jsonify({
            'error': 'Forbidden',
            'message': 'You can only modify your own profile'
        }), 403
    
    # Récupérer l'utilisateur
    user = User.query.get(user_id)
    if not user:
        return jsonify({
            'error': 'Not Found',
            'message': f'User with ID {user_id} not found'
        }), 404
    
    # Valider les données (partial=True permet champs optionnels)
    try:
        data = user_schema.load(request.json, partial=True)
    except Exception as e:
        return jsonify({
            'error': 'Bad Request',
            'message': 'Validation failed',
            'errors': e.messages
        }), 400
    
    # Mettre à jour SEULEMENT les champs fournis
    if 'username' in data:
        if data['username'] != user.username:
            if User.query.filter_by(username=data['username']).first():
                return jsonify({
                    'error': 'Conflict',
                    'message': 'Username already exists'
                }), 409
        user.username = data['username']
    
    if 'email' in data:
        if data['email'] != user.email:
            if User.query.filter_by(email=data['email']).first():
                return jsonify({
                    'error': 'Conflict',
                    'message': 'Email already exists'
                }), 409
        user.email = data['email']
    
    if 'password' in data:
        user.password_hash = User.hash_password(data['password'])
    
    if 'role' in data and current_user.role == 'admin':
        user.role = data['role']
    
    db.session.commit()
    
    # Sérialiser
    result = user_schema.dump(user)
    
    return jsonify(result), 200


# ═══ DELETE /api/v1/users/:id - SUPPRIMER UN UTILISATEUR ═══

@users_bp.route('/<int:user_id>', methods=['DELETE'])
@jwt_required()
@admin_required  # Seulement les admins peuvent supprimer
def delete_user(user_id):
    """
    Supprime un utilisateur (soft delete recommandé)
    
    Nécessite le rôle admin
    
    Réponse 204:
        (pas de contenu)
    
    Réponse 403:
        {
            "error": "Forbidden",
            "message": "Admin role required"
        }
    
    Réponse 404:
        {
            "error": "Not Found",
            "message": "User with ID 123 not found"
        }
    """
    
    user = User.query.get(user_id)
    
    if not user:
        return jsonify({
            'error': 'Not Found',
            'message': f'User with ID {user_id} not found'
        }), 404
    
    # OPTION 1: Hard delete (supprimer définitivement)
    # db.session.delete(user)
    # db.session.commit()
    # return '', 204
    
    # OPTION 2: Soft delete (recommandé - marquer comme supprimé)
    user.active = False
    user.deleted_at = datetime.utcnow()
    db.session.commit()
    
    return '', 204


# ═══ GET /api/v1/users/me - PROFIL UTILISATEUR COURANT ═══

@users_bp.route('/me', methods=['GET'])
@jwt_required()
def get_current_user_profile():
    """
    Récupère le profil de l'utilisateur authentifié
    
    Réponse 200:
        {
            "id": 123,
            "username": "alice",
            "email": "alice@example.com",
            ...
        }
    """
    
    user_id = get_jwt_identity()
    user = User.query.get(user_id)
    
    if not user:
        return jsonify({
            'error': 'Not Found',
            'message': 'User not found'
        }), 404
    
    result = user_schema.dump(user)
    
    return jsonify(result), 200


# ═══ GET /api/v1/users/:id/posts - POSTS D'UN UTILISATEUR ═══

@users_bp.route('/<int:user_id>/posts', methods=['GET'])
@jwt_required()
def get_user_posts(user_id):
    """
    Récupère tous les posts d'un utilisateur
    
    Query params:
        - page: Numéro de page
        - per_page: Items par page
    
    Réponse 200:
        {
            "posts": [...],
            "pagination": {...}
        }
    """
    
    from app.models.post import Post
    from app.schemas.post import posts_schema
    
    user = User.query.get(user_id)
    if not user:
        return jsonify({
            'error': 'Not Found',
            'message': f'User with ID {user_id} not found'
        }), 404
    
    # Pagination
    page = request.args.get('page', 1, type=int)
    per_page = min(request.args.get('per_page', 20, type=int), 100)
    
    pagination = Post.query.filter_by(author_id=user_id)\
        .order_by(Post.created_at.desc())\
        .paginate(page=page, per_page=per_page, error_out=False)
    
    posts = posts_schema.dump(pagination.items)
    
    return jsonify({
        'posts': posts,
        'pagination': {
            'page': pagination.page,
            'per_page': pagination.per_page,
            'total': pagination.total,
            'pages': pagination.pages
        }
    }), 200


═══ EXEMPLE: Tester les routes avec curl ═══

# Lister les utilisateurs
curl -X GET http://localhost:5000/api/v1/users \
  -H "Authorization: Bearer <token>"

# Avec filtres et pagination
curl -X GET "http://localhost:5000/api/v1/users?page=1&per_page=10&role=admin&q=alice" \
  -H "Authorization: Bearer <token>"

# Récupérer un utilisateur
curl -X GET http://localhost:5000/api/v1/users/123 \
  -H "Authorization: Bearer <token>"

# Créer un utilisateur
curl -X POST http://localhost:5000/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alice",
    "email": "alice@example.com",
    "password": "SecurePass123!"
  }'

# Modifier partiellement
curl -X PATCH http://localhost:5000/api/v1/users/123 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <token>" \
  -d '{
    "email": "alice.new@example.com"
  }'

# Supprimer
curl -X DELETE http://localhost:5000/api/v1/users/123 \
  -H "Authorization: Bearer <token>"

# Profil courant
curl -X GET http://localhost:5000/api/v1/users/me \
  -H "Authorization: Bearer <token>"


═══════════════════════════════════════════════════════════════════════════════
  2.4 MODÈLES SQLAlchemy - BASE DE DONNÉES
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI SQLAlchemy?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SQLAlchemy est l'ORM (Object-Relational Mapping) le plus populaire en Python:
  [OK] Code Python au lieu de SQL brut
  [OK] Protection contre SQL injection
  [OK] Support de multiples bases de données (PostgreSQL, MySQL, SQLite)
  [OK] Relations automatiques entre modèles
  [OK] Migrations avec Alembic
  [OK] Query builder puissant

[?] COMMENT créer des modèles SQLAlchemy?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


═══ FICHIER app/models/base.py - MODÈLE DE BASE ═══

"""
Modèle de base pour tous les modèles
Contient les champs communs et méthodes utilitaires
"""

from datetime import datetime
from app import db


class BaseModel(db.Model):
    """
    Classe abstraite de base pour tous les modèles
    
    Fournit:
    - ID auto-incrémenté
    - Timestamps (created_at, updated_at)
    - Méthodes utilitaires (save, delete, to_dict)
    """
    
    __abstract__ = True  # Ne crée pas de table pour cette classe
    
    # ═══ COLONNES COMMUNES ═══
    
    id = db.Column(
        db.Integer,
        primary_key=True,
        autoincrement=True
    )
    
    created_at = db.Column(
        db.DateTime,
        nullable=False,
        default=datetime.utcnow,
        index=True
    )
    
    updated_at = db.Column(
        db.DateTime,
        nullable=False,
        default=datetime.utcnow,
        onupdate=datetime.utcnow,
        index=True
    )
    
    # ═══ MÉTHODES UTILITAIRES ═══
    
    def save(self):
        """
        Sauvegarde le modèle dans la base de données
        
        Usage:
            user = User(username='alice', email='alice@example.com')
            user.save()
        """
        try:
            db.session.add(self)
            db.session.commit()
            return self
        except Exception as e:
            db.session.rollback()
            raise e
    
    def delete(self):
        """
        Supprime le modèle de la base de données
        
        Usage:
            user.delete()
        """
        try:
            db.session.delete(self)
            db.session.commit()
            return True
        except Exception as e:
            db.session.rollback()
            raise e
    
    def update(self, **kwargs):
        """
        Met à jour plusieurs champs à la fois
        
        Usage:
            user.update(username='alice2', email='alice2@example.com')
        """
        for key, value in kwargs.items():
            if hasattr(self, key):
                setattr(self, key, value)
        
        return self.save()
    
    def to_dict(self):
        """
        Convertit le modèle en dictionnaire
        
        Returns:
            dict: Représentation du modèle en dictionnaire
        
        Usage:
            user_dict = user.to_dict()
        """
        data = {}
        for column in self.__table__.columns:
            value = getattr(self, column.name)
            
            # Convertir datetime en ISO format
            if isinstance(value, datetime):
                value = value.isoformat() + 'Z'
            
            data[column.name] = value
        
        return data
    
    def __repr__(self):
        """Représentation string du modèle"""
        return f'<{self.__class__.__name__} {self.id}>'


═══ FICHIER app/models/user.py - MODÈLE USER ═══

"""
Modèle User - Représente un utilisateur dans la base de données
"""

from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
from app.models.base import BaseModel


class User(BaseModel):
    """
    Modèle User
    
    Représente un utilisateur de l'application
    """
    
    __tablename__ = 'users'
    
    # ═══ COLONNES ═══
    
    username = db.Column(
        db.String(80),
        unique=True,
        nullable=False,
        index=True
    )
    
    email = db.Column(
        db.String(120),
        unique=True,
        nullable=False,
        index=True
    )
    
    password_hash = db.Column(
        db.String(255),
        nullable=False
    )
    
    first_name = db.Column(
        db.String(50),
        nullable=True
    )
    
    last_name = db.Column(
        db.String(50),
        nullable=True
    )
    
    bio = db.Column(
        db.Text,
        nullable=True
    )
    
    avatar_url = db.Column(
        db.String(255),
        nullable=True
    )
    
    role = db.Column(
        db.String(20),
        nullable=False,
        default='user',
        index=True
    )
    # Rôles possibles: 'user', 'editor', 'admin'
    
    active = db.Column(
        db.Boolean,
        nullable=False,
        default=True,
        index=True
    )
    
    email_verified = db.Column(
        db.Boolean,
        nullable=False,
        default=False
    )
    
    last_login_at = db.Column(
        db.DateTime,
        nullable=True
    )
    
    deleted_at = db.Column(
        db.DateTime,
        nullable=True
    )
    
    # ═══ RELATIONS ═══
    
    # Un utilisateur a plusieurs posts
    posts = db.relationship(
        'Post',
        backref='author',
        lazy='dynamic',  # Ne charge pas automatiquement
        cascade='all, delete-orphan'  # Supprimer posts si user supprimé
    )
    
    # Un utilisateur a plusieurs commentaires
    comments = db.relationship(
        'Comment',
        backref='author',
        lazy='dynamic',
        cascade='all, delete-orphan'
    )
    
    # ═══ MÉTHODES DE CLASSE ═══
    
    @classmethod
    def hash_password(cls, password):
        """
        Hash un mot de passe
        
        Args:
            password (str): Mot de passe en clair
        
        Returns:
            str: Mot de passe hashé
        
        Usage:
            hashed = User.hash_password('mypassword123')
        """
        return generate_password_hash(password, method='pbkdf2:sha256')
    
    @classmethod
    def find_by_username(cls, username):
        """
        Trouve un utilisateur par son username
        
        Args:
            username (str): Username à rechercher
        
        Returns:
            User|None: Utilisateur trouvé ou None
        """
        return cls.query.filter_by(username=username).first()
    
    @classmethod
    def find_by_email(cls, email):
        """
        Trouve un utilisateur par son email
        
        Args:
            email (str): Email à rechercher
        
        Returns:
            User|None: Utilisateur trouvé ou None
        """
        return cls.query.filter_by(email=email).first()
    
    @classmethod
    def find_active_users(cls):
        """
        Récupère tous les utilisateurs actifs
        
        Returns:
            Query: Query SQLAlchemy des utilisateurs actifs
        """
        return cls.query.filter_by(active=True)
    
    # ═══ MÉTHODES D'INSTANCE ═══
    
    def check_password(self, password):
        """
        Vérifie si le mot de passe est correct
        
        Args:
            password (str): Mot de passe à vérifier
        
        Returns:
            bool: True si le mot de passe est correct
        
        Usage:
            if user.check_password('mypassword123'):
                print('Password correct!')
        """
        return check_password_hash(self.password_hash, password)
    
    def set_password(self, password):
        """
        Définit un nouveau mot de passe
        
        Args:
            password (str): Nouveau mot de passe
        
        Usage:
            user.set_password('newpassword123')
            user.save()
        """
        self.password_hash = self.hash_password(password)
    
    def is_admin(self):
        """Vérifie si l'utilisateur est admin"""
        return self.role == 'admin'
    
    def is_editor(self):
        """Vérifie si l'utilisateur est editor"""
        return self.role in ['editor', 'admin']
    
    def can_edit_post(self, post):
        """
        Vérifie si l'utilisateur peut éditer un post
        
        Args:
            post (Post): Post à vérifier
        
        Returns:
            bool: True si l'utilisateur peut éditer
        """
        return self.is_admin() or post.author_id == self.id
    
    def update_last_login(self):
        """Met à jour la date de dernière connexion"""
        self.last_login_at = datetime.utcnow()
        db.session.commit()
    
    def deactivate(self):
        """Désactive l'utilisateur (soft delete)"""
        self.active = False
        self.deleted_at = datetime.utcnow()
        db.session.commit()
    
    def activate(self):
        """Réactive l'utilisateur"""
        self.active = True
        self.deleted_at = None
        db.session.commit()
    
    def get_posts_count(self):
        """Retourne le nombre de posts de l'utilisateur"""
        return self.posts.count()
    
    def get_recent_posts(self, limit=5):
        """
        Retourne les posts récents de l'utilisateur
        
        Args:
            limit (int): Nombre de posts à retourner
        
        Returns:
            list[Post]: Liste des posts récents
        """
        return self.posts.order_by(Post.created_at.desc()).limit(limit).all()
    
    # ═══ MÉTHODES SPÉCIALES ═══
    
    def to_dict(self, include_email=False):
        """
        Convertit en dictionnaire
        
        Args:
            include_email (bool): Inclure l'email (sensible)
        
        Returns:
            dict: Représentation du user
        """
        data = {
            'id': self.id,
            'username': self.username,
            'first_name': self.first_name,
            'last_name': self.last_name,
            'bio': self.bio,
            'avatar_url': self.avatar_url,
            'role': self.role,
            'active': self.active,
            'created_at': self.created_at.isoformat() + 'Z' if self.created_at else None,
            'last_login_at': self.last_login_at.isoformat() + 'Z' if self.last_login_at else None
        }
        
        # Email seulement si demandé (données sensibles)
        if include_email:
            data['email'] = self.email
            data['email_verified'] = self.email_verified
        
        return data
    
    def __repr__(self):
        return f'<User {self.username}>'


═══ FICHIER app/models/post.py - MODÈLE POST ═══

"""
Modèle Post - Représente un article/post
"""

from app import db
from app.models.base import BaseModel
from sqlalchemy import event


class Post(BaseModel):
    """
    Modèle Post
    
    Représente un article publié par un utilisateur
    """
    
    __tablename__ = 'posts'
    
    # ═══ COLONNES ═══
    
    title = db.Column(
        db.String(200),
        nullable=False,
        index=True
    )
    
    slug = db.Column(
        db.String(250),
        unique=True,
        nullable=False,
        index=True
    )
    # Slug: URL-friendly version du titre (ex: "my-first-post")
    
    content = db.Column(
        db.Text,
        nullable=False
    )
    
    excerpt = db.Column(
        db.String(500),
        nullable=True
    )
    # Court résumé du post
    
    cover_image_url = db.Column(
        db.String(255),
        nullable=True
    )
    
    published = db.Column(
        db.Boolean,
        nullable=False,
        default=False,
        index=True
    )
    
    published_at = db.Column(
        db.DateTime,
        nullable=True,
        index=True
    )
    
    views_count = db.Column(
        db.Integer,
        nullable=False,
        default=0
    )
    
    likes_count = db.Column(
        db.Integer,
        nullable=False,
        default=0
    )
    
    # ═══ FOREIGN KEYS ═══
    
    author_id = db.Column(
        db.Integer,
        db.ForeignKey('users.id', ondelete='CASCADE'),
        nullable=False,
        index=True
    )
    
    # ═══ RELATIONS ═══
    
    # Relation vers User (définie dans User avec backref='author')
    # Accessible via: post.author
    
    # Un post a plusieurs commentaires
    comments = db.relationship(
        'Comment',
        backref='post',
        lazy='dynamic',
        cascade='all, delete-orphan',
        order_by='Comment.created_at.desc()'
    )
    
    # Un post a plusieurs tags (many-to-many)
    tags = db.relationship(
        'Tag',
        secondary='post_tags',  # Table de liaison
        backref=db.backref('posts', lazy='dynamic'),
        lazy='dynamic'
    )
    
    # ═══ MÉTHODES DE CLASSE ═══
    
    @classmethod
    def find_published(cls):
        """Retourne tous les posts publiés"""
        return cls.query.filter_by(published=True)\
            .order_by(cls.published_at.desc())
    
    @classmethod
    def find_by_slug(cls, slug):
        """Trouve un post par son slug"""
        return cls.query.filter_by(slug=slug).first()
    
    @classmethod
    def search(cls, query_string):
        """
        Recherche des posts par titre ou contenu
        
        Args:
            query_string (str): Texte à rechercher
        
        Returns:
            Query: Query SQLAlchemy des posts trouvés
        """
        search = f'%{query_string}%'
        return cls.query.filter(
            db.or_(
                cls.title.ilike(search),
                cls.content.ilike(search),
                cls.excerpt.ilike(search)
            )
        )
    
    # ═══ MÉTHODES D'INSTANCE ═══
    
    def publish(self):
        """Publie le post"""
        from datetime import datetime
        self.published = True
        self.published_at = datetime.utcnow()
        db.session.commit()
    
    def unpublish(self):
        """Dépublie le post"""
        self.published = False
        self.published_at = None
        db.session.commit()
    
    def increment_views(self):
        """Incrémente le compteur de vues"""
        self.views_count += 1
        db.session.commit()
    
    def increment_likes(self):
        """Incrémente le compteur de likes"""
        self.likes_count += 1
        db.session.commit()
    
    def get_comments_count(self):
        """Retourne le nombre de commentaires"""
        return self.comments.count()
    
    def get_recent_comments(self, limit=5):
        """Retourne les commentaires récents"""
        return self.comments.limit(limit).all()
    
    def add_tag(self, tag):
        """
        Ajoute un tag au post
        
        Args:
            tag (Tag): Tag à ajouter
        """
        if not self.has_tag(tag):
            self.tags.append(tag)
            db.session.commit()
    
    def remove_tag(self, tag):
        """Retire un tag du post"""
        if self.has_tag(tag):
            self.tags.remove(tag)
            db.session.commit()
    
    def has_tag(self, tag):
        """Vérifie si le post a un tag"""
        return self.tags.filter_by(id=tag.id).count() > 0
    
    @staticmethod
    def generate_slug(title):
        """
        Génère un slug à partir du titre
        
        Args:
            title (str): Titre du post
        
        Returns:
            str: Slug généré
        
        Usage:
            slug = Post.generate_slug("Mon Premier Post")
            # Retourne: "mon-premier-post"
        """
        import re
        from unidecode import unidecode
        
        # Convertir en minuscules et enlever accents
        slug = unidecode(title.lower())
        
        # Remplacer espaces et caractères spéciaux par des tirets
        slug = re.sub(r'[^\w\s-]', '', slug)
        slug = re.sub(r'[-\s]+', '-', slug)
        
        # Enlever tirets au début/fin
        slug = slug.strip('-')
        
        # S'assurer que le slug est unique
        original_slug = slug
        counter = 1
        while Post.query.filter_by(slug=slug).first() is not None:
            slug = f'{original_slug}-{counter}'
            counter += 1
        
        return slug
    
    def to_dict(self, include_content=True, include_author=True):
        """
        Convertit en dictionnaire
        
        Args:
            include_content (bool): Inclure le contenu complet
            include_author (bool): Inclure les infos de l'auteur
        
        Returns:
            dict: Représentation du post
        """
        data = {
            'id': self.id,
            'title': self.title,
            'slug': self.slug,
            'excerpt': self.excerpt,
            'cover_image_url': self.cover_image_url,
            'published': self.published,
            'published_at': self.published_at.isoformat() + 'Z' if self.published_at else None,
            'views_count': self.views_count,
            'likes_count': self.likes_count,
            'comments_count': self.get_comments_count(),
            'created_at': self.created_at.isoformat() + 'Z',
            'updated_at': self.updated_at.isoformat() + 'Z'
        }
        
        if include_content:
            data['content'] = self.content
        
        if include_author:
            data['author'] = {
                'id': self.author.id,
                'username': self.author.username,
                'avatar_url': self.author.avatar_url
            }
        
        return data
    
    def __repr__(self):
        return f'<Post {self.title}>'


# ═══ EVENT LISTENER - Auto-générer slug ═══

@event.listens_for(Post, 'before_insert')
def generate_slug_on_insert(mapper, connection, target):
    """
    Génère automatiquement un slug avant insertion
    
    SQLAlchemy event listener
    """
    if not target.slug:
        target.slug = Post.generate_slug(target.title)


═══ FICHIER app/models/comment.py - MODÈLE COMMENT ═══

"""
Modèle Comment - Commentaires sur les posts
"""

from app import db
from app.models.base import BaseModel


class Comment(BaseModel):
    """
    Modèle Comment
    
    Représente un commentaire sur un post
    """
    
    __tablename__ = 'comments'
    
    # ═══ COLONNES ═══
    
    content = db.Column(
        db.Text,
        nullable=False
    )
    
    # ═══ FOREIGN KEYS ═══
    
    author_id = db.Column(
        db.Integer,
        db.ForeignKey('users.id', ondelete='CASCADE'),
        nullable=False,
        index=True
    )
    
    post_id = db.Column(
        db.Integer,
        db.ForeignKey('posts.id', ondelete='CASCADE'),
        nullable=False,
        index=True
    )
    
    # Commentaires imbriqués (réponses aux commentaires)
    parent_id = db.Column(
        db.Integer,
        db.ForeignKey('comments.id', ondelete='CASCADE'),
        nullable=True,
        index=True
    )
    
    # ═══ RELATIONS ═══
    
    # Relations définies avec backref dans User et Post
    # Accessible via: comment.author, comment.post
    
    # Réponses au commentaire (commentaires enfants)
    replies = db.relationship(
        'Comment',
        backref=db.backref('parent', remote_side='Comment.id'),
        lazy='dynamic',
        cascade='all, delete-orphan'
    )
    
    # ═══ MÉTHODES ═══
    
    def is_reply(self):
        """Vérifie si c'est une réponse à un autre commentaire"""
        return self.parent_id is not None
    
    def get_replies_count(self):
        """Retourne le nombre de réponses"""
        return self.replies.count()
    
    def to_dict(self, include_replies=False):
        """Convertit en dictionnaire"""
        data = {
            'id': self.id,
            'content': self.content,
            'author': {
                'id': self.author.id,
                'username': self.author.username,
                'avatar_url': self.author.avatar_url
            },
            'post_id': self.post_id,
            'parent_id': self.parent_id,
            'created_at': self.created_at.isoformat() + 'Z',
            'replies_count': self.get_replies_count()
        }
        
        if include_replies:
            data['replies'] = [reply.to_dict() for reply in self.replies.all()]
        
        return data
    
    def __repr__(self):
        return f'<Comment {self.id} by {self.author.username}>'


═══ FICHIER app/models/tag.py - MODÈLE TAG ═══

"""
Modèle Tag - Tags pour catégoriser les posts
"""

from app import db
from app.models.base import BaseModel


# ═══ TABLE DE LIAISON MANY-TO-MANY ═══
post_tags = db.Table('post_tags',
    db.Column('post_id', db.Integer, db.ForeignKey('posts.id', ondelete='CASCADE'), primary_key=True),
    db.Column('tag_id', db.Integer, db.ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True),
    db.Column('created_at', db.DateTime, nullable=False, default=db.func.now())
)


class Tag(BaseModel):
    """
    Modèle Tag
    
    Représente un tag/catégorie pour les posts
    """
    
    __tablename__ = 'tags'
    
    # ═══ COLONNES ═══
    
    name = db.Column(
        db.String(50),
        unique=True,
        nullable=False,
        index=True
    )
    
    slug = db.Column(
        db.String(60),
        unique=True,
        nullable=False,
        index=True
    )
    
    description = db.Column(
        db.String(255),
        nullable=True
    )
    
    # ═══ RELATIONS ═══
    # Relation vers Post définie dans Post avec backref
    # Accessible via: tag.posts
    
    # ═══ MÉTHODES ═══
    
    @classmethod
    def find_by_name(cls, name):
        """Trouve un tag par son nom"""
        return cls.query.filter_by(name=name).first()
    
    @classmethod
    def find_by_slug(cls, slug):
        """Trouve un tag par son slug"""
        return cls.query.filter_by(slug=slug).first()
    
    @classmethod
    def get_or_create(cls, name):
        """
        Trouve ou crée un tag
        
        Args:
            name (str): Nom du tag
        
        Returns:
            Tag: Tag trouvé ou créé
        """
        tag = cls.find_by_name(name)
        if not tag:
            slug = Post.generate_slug(name)  # Réutiliser la fonction de génération de slug
            tag = cls(name=name, slug=slug)
            tag.save()
        return tag
    
    def get_posts_count(self):
        """Retourne le nombre de posts avec ce tag"""
        return self.posts.count()
    
    def to_dict(self):
        """Convertit en dictionnaire"""
        return {
            'id': self.id,
            'name': self.name,
            'slug': self.slug,
            'description': self.description,
            'posts_count': self.get_posts_count(),
            'created_at': self.created_at.isoformat() + 'Z'
        }
    
    def __repr__(self):
        return f'<Tag {self.name}>'


═══════════════════════════════════════════════════════════════════════════════
  2.5 SCHÉMAS MARSHMALLOW - VALIDATION ET SÉRIALISATION
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI Marshmallow?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Marshmallow est une bibliothèque de sérialisation/validation:
  [OK] Valider les données d'entrée (request body)
  [OK] Sérialiser les modèles en JSON (response)
  [OK] Filtrer les champs sensibles (password)
  [OK] Nested objects (relations)
  [OK] Custom validators

[?] COMMENT utiliser Marshmallow?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


═══ FICHIER app/schemas/user.py - SCHÉMA USER ═══

"""
Schémas Marshmallow pour le modèle User
"""

from marshmallow import Schema, fields, validate, validates, ValidationError, post_load
from app import ma
from app.models.user import User


class UserSchema(ma.SQLAlchemySchema):
    """
    Schéma pour sérialiser/valider User
    
    Utilisé pour:
    - Valider les données lors de la création/modification
    - Sérialiser les users en JSON pour les réponses
    """
    
    class Meta:
        model = User
        load_instance = False  # Ne pas créer automatiquement une instance
    
    # ═══ CHAMPS ═══
    
    id = fields.Int(dump_only=True)
    # dump_only = seulement en sortie (lecture seule)
    
    username = fields.Str(
        required=True,
        validate=validate.Length(min=3, max=80),
        error_messages={
            'required': 'Username is required',
            'invalid': 'Invalid username format'
        }
    )
    
    email = fields.Email(
        required=True,
        validate=validate.Length(max=120),
        error_messages={
            'required': 'Email is required',
            'invalid': 'Invalid email format'
        }
    )
    
    password = fields.Str(
        required=True,
        load_only=True,  # Seulement en entrée (jamais en sortie)
        validate=validate.Length(min=8, max=128),
        error_messages={
            'required': 'Password is required',
            'invalid': 'Password must be at least 8 characters'
        }
    )
    
    first_name = fields.Str(
        validate=validate.Length(max=50),
        allow_none=True
    )
    
    last_name = fields.Str(
        validate=validate.Length(max=50),
        allow_none=True
    )
    
    bio = fields.Str(
        validate=validate.Length(max=500),
        allow_none=True
    )
    
    avatar_url = fields.Url(
        allow_none=True
    )
    
    role = fields.Str(
        validate=validate.OneOf(['user', 'editor', 'admin']),
        missing='user'  # Valeur par défaut
    )
    
    active = fields.Bool(dump_only=True)
    email_verified = fields.Bool(dump_only=True)
    
    created_at = fields.DateTime(dump_only=True, format='iso')
    updated_at = fields.DateTime(dump_only=True, format='iso')
    last_login_at = fields.DateTime(dump_only=True, format='iso', allow_none=True)
    
    # ═══ CHAMPS CALCULÉS ═══
    
    posts_count = fields.Method('get_posts_count', dump_only=True)
    
    def get_posts_count(self, obj):
        """Compte le nombre de posts de l'utilisateur"""
        return obj.posts.count() if obj.posts else 0
    
    # ═══ NESTED OBJECTS ═══
    
    # Inclure les posts de l'utilisateur (optionnel)
    # posts = fields.Nested('PostSchema', many=True, exclude=('author',))
    
    # ═══ VALIDATEURS PERSONNALISÉS ═══
    
    @validates('username')
    def validate_username(self, value):
        """
        Validation personnalisée du username
        
        Règles:
        - Seulement lettres, chiffres, tirets et underscores
        - Ne peut pas commencer par un chiffre
        """
        import re
        
        if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', value):
            raise ValidationError(
                'Username must start with a letter and contain only letters, '
                'numbers, hyphens and underscores'
            )
    
    @validates('password')
    def validate_password(self, value):
        """
        Validation du mot de passe
        
        Règles:
        - Au moins 8 caractères
        - Au moins une majuscule
        - Au moins un chiffre
        - Au moins un caractère spécial
        """
        if len(value) < 8:
            raise ValidationError('Password must be at least 8 characters')
        
        if not any(c.isupper() for c in value):
            raise ValidationError('Password must contain at least one uppercase letter')
        
        if not any(c.isdigit() for c in value):
            raise ValidationError('Password must contain at least one digit')
        
        if not any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in value):
            raise ValidationError('Password must contain at least one special character')
    
    @validates('email')
    def validate_email_unique(self, value):
        """Vérifie que l'email n'est pas déjà utilisé"""
        # Cette validation est faite côté route pour éviter les problèmes
        # lors de la désérialisation
        pass


class UserCreateSchema(UserSchema):
    """
    Schéma pour la création d'utilisateur
    
    Hérite de UserSchema mais nécessite le password
    """
    pass


class UserUpdateSchema(UserSchema):
    """
    Schéma pour la mise à jour d'utilisateur
    
    Tous les champs sont optionnels sauf contraintes spécifiques
    """
    
    username = fields.Str(
        validate=validate.Length(min=3, max=80),
        required=False
    )
    
    email = fields.Email(
        validate=validate.Length(max=120),
        required=False
    )
    
    password = fields.Str(
        load_only=True,
        validate=validate.Length(min=8, max=128),
        required=False
    )


class UserPublicSchema(ma.SQLAlchemySchema):
    """
    Schéma public pour User
    
    Version allégée sans informations sensibles
    Utilisé pour afficher les auteurs de posts, commentaires, etc.
    """
    
    class Meta:
        model = User
    
    id = fields.Int()
    username = fields.Str()
    first_name = fields.Str()
    last_name = fields.Str()
    bio = fields.Str()
    avatar_url = fields.Url()
    role = fields.Str()
    created_at = fields.DateTime(format='iso')


# ═══ INSTANCIATION DES SCHÉMAS ═══

user_schema = UserSchema()
users_schema = UserSchema(many=True)

user_create_schema = UserCreateSchema()
user_update_schema = UserUpdateSchema()

user_public_schema = UserPublicSchema()
users_public_schema = UserPublicSchema(many=True)


═══ FICHIER app/schemas/post.py - SCHÉMA POST ═══

"""
Schémas Marshmallow pour le modèle Post
"""

from marshmallow import fields, validate, validates, ValidationError, post_load
from app import ma
from app.models.post import Post
from app.schemas.user import UserPublicSchema


class PostSchema(ma.SQLAlchemySchema):
    """
    Schéma pour sérialiser/valider Post
    """
    
    class Meta:
        model = Post
        load_instance = False
    
    # ═══ CHAMPS ═══
    
    id = fields.Int(dump_only=True)
    
    title = fields.Str(
        required=True,
        validate=validate.Length(min=5, max=200),
        error_messages={
            'required': 'Title is required',
            'invalid': 'Title must be between 5 and 200 characters'
        }
    )
    
    slug = fields.Str(dump_only=True)  # Auto-généré
    
    content = fields.Str(
        required=True,
        validate=validate.Length(min=50),
        error_messages={
            'required': 'Content is required',
            'invalid': 'Content must be at least 50 characters'
        }
    )
    
    excerpt = fields.Str(
        validate=validate.Length(max=500),
        allow_none=True
    )
    
    cover_image_url = fields.Url(allow_none=True)
    
    published = fields.Bool(missing=False)
    published_at = fields.DateTime(dump_only=True, format='iso', allow_none=True)
    
    views_count = fields.Int(dump_only=True)
    likes_count = fields.Int(dump_only=True)
    
    author_id = fields.Int(load_only=True)
    
    created_at = fields.DateTime(dump_only=True, format='iso')
    updated_at = fields.DateTime(dump_only=True, format='iso')
    
    # ═══ NESTED OBJECTS ═══
    
    # Inclure l'auteur
    author = fields.Nested(UserPublicSchema, dump_only=True)
    
    # Inclure les tags
    tags = fields.List(fields.Str(), dump_only=True)
    
    # Champs calculés
    comments_count = fields.Method('get_comments_count', dump_only=True)
    
    def get_comments_count(self, obj):
        """Compte le nombre de commentaires"""
        return obj.comments.count() if obj.comments else 0
    
    # ═══ VALIDATEURS ═══
    
    @validates('title')
    def validate_title(self, value):
        """Valide que le titre n'est pas trop générique"""
        generic_titles = ['untitled', 'new post', 'my post', 'post']
        if value.lower().strip() in generic_titles:
            raise ValidationError('Please provide a more specific title')


class PostCreateSchema(PostSchema):
    """Schéma pour création de post"""
    pass


class PostUpdateSchema(PostSchema):
    """Schéma pour mise à jour de post"""
    
    title = fields.Str(
        validate=validate.Length(min=5, max=200),
        required=False
    )
    
    content = fields.Str(
        validate=validate.Length(min=50),
        required=False
    )


class PostListSchema(ma.SQLAlchemySchema):
    """
    Schéma allégé pour liste de posts
    
    Ne contient pas le contenu complet (pour performance)
    """
    
    class Meta:
        model = Post
    
    id = fields.Int()
    title = fields.Str()
    slug = fields.Str()
    excerpt = fields.Str()
    cover_image_url = fields.Url()
    published = fields.Bool()
    published_at = fields.DateTime(format='iso')
    views_count = fields.Int()
    likes_count = fields.Int()
    author = fields.Nested(UserPublicSchema)
    created_at = fields.DateTime(format='iso')
    comments_count = fields.Method('get_comments_count')
    
    def get_comments_count(self, obj):
        return obj.comments.count() if obj.comments else 0


# ═══ INSTANCIATION ═══

post_schema = PostSchema()
posts_schema = PostSchema(many=True)

post_create_schema = PostCreateSchema()
post_update_schema = PostUpdateSchema()

post_list_schema = PostListSchema()
posts_list_schema = PostListSchema(many=True)


═══ UTILISATION DES SCHÉMAS DANS LES ROUTES ═══

"""
Exemples d'utilisation des schémas Marshmallow
"""

from flask import request, jsonify
from marshmallow import ValidationError
from app.schemas.user import user_schema, user_create_schema, users_schema


# ═══ DÉSÉRIALISATION (JSON -> Python) avec VALIDATION ═══

@app.route('/api/users', methods=['POST'])
def create_user():
    """Créer un utilisateur avec validation"""
    
    try:
        # Charger et valider les données JSON
        data = user_create_schema.load(request.json)
    except ValidationError as err:
        # Retourner les erreurs de validation
        return jsonify({
            'error': 'Validation Failed',
            'messages': err.messages
        }), 400
    
    # Créer l'utilisateur
    user = User(
        username=data['username'],
        email=data['email'],
        password_hash=User.hash_password(data['password'])
    )
    user.save()
    
    # Sérialiser la réponse
    result = user_schema.dump(user)
    return jsonify(result), 201


# ═══ SÉRIALISATION (Python -> JSON) ═══

@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    """Récupérer un utilisateur"""
    
    user = User.query.get_or_404(user_id)
    
    # Sérialiser en JSON
    result = user_schema.dump(user)
    return jsonify(result), 200


# ═══ SÉRIALISATION DE LISTE ═══

@app.route('/api/users', methods=['GET'])
def get_users():
    """Récupérer tous les utilisateurs"""
    
    users = User.query.all()
    
    # Sérialiser la liste
    result = users_schema.dump(users)
    return jsonify({'users': result}), 200


# ═══ UPDATE PARTIEL avec partial=True ═══

@app.route('/api/users/<int:user_id>', methods=['PATCH'])
def update_user(user_id):
    """Mettre à jour partiellement un utilisateur"""
    
    user = User.query.get_or_404(user_id)
    
    try:
        # partial=True permet des champs optionnels
        data = user_update_schema.load(request.json, partial=True)
    except ValidationError as err:
        return jsonify({
            'error': 'Validation Failed',
            'messages': err.messages
        }), 400
    
    # Mettre à jour les champs fournis
    for key, value in data.items():
        if key == 'password':
            user.password_hash = User.hash_password(value)
        else:
            setattr(user, key, value)
    
    user.save()
    
    result = user_schema.dump(user)
    return jsonify(result), 200


═══════════════════════════════════════════════════════════════════════════════
  PARTIE 5: AUTHENTIFICATION & AUTORISATION
═══════════════════════════════════════════════════════════════════════════════


[?] POURQUOI l'authentification et l'autorisation?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

AUTHENTIFICATION = Vérifier QUI tu es (login)
AUTORISATION = Vérifier CE QUE tu peux faire (permissions)

Pourquoi c'est ESSENTIEL:
  [OK] Protéger les données sensibles
  [OK] Contrôler l'accès aux ressources
  [OK] Tracer les actions des utilisateurs
  [OK] Respecter la confidentialité
  [OK] Conformité légale (RGPD, etc.)


═══════════════════════════════════════════════════════════════════════════════
  5.1 TYPES D'AUTHENTIFICATION
═══════════════════════════════════════════════════════════════════════════════

[?] QUAND utiliser chaque type?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

┌─────────────────────────────────────────────────────────────────────────┐
│                    COMPARAISON DES MÉTHODES D'AUTH                      │
├──────────────┬────────────┬────────────┬────────────┬──────────────────┤
│ MÉTHODE      │ STATELESS? │ SÉCURITÉ   │ COMPLEXITÉ │ CAS D'USAGE      │
├──────────────┼────────────┼────────────┼────────────┼──────────────────┤
│ Basic Auth   │ [OK] Oui      │ *****    │ *****    │ Dev, scripts     │
│ Session      │ [X] Non      │ *****    │ *****    │ Apps web monos   │
│ JWT          │ [OK] Oui      │ *****    │ *****    │ APIs RESTful     │
│ OAuth 2.0    │ [OK] Oui      │ *****    │ *****    │ Apps tierces     │
│ API Key      │ [OK] Oui      │ *****    │ *****    │ M2M, services    │
└──────────────┴────────────┴────────────┴────────────┴──────────────────┘


1⃣ BASIC AUTHENTICATION
   ━━━━━━━━━━━━━━━━━━━━━━

   [?] QUOI?
   Username:password encodé en Base64 dans le header
   
   [?] POURQUOI?
   • Simple à implémenter
   • Pas de configuration complexe
   
   [?] QUAND utiliser?
   [OK] Développement et tests
   [OK] Scripts internes
   [OK] APIs non-critiques
   [X] JAMAIS en production sans HTTPS
   [X] PAS pour apps publiques
   
   Format du header:
   Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
   
   Exemple Python:
   
   import base64
   from functools import wraps
   from flask import request, jsonify
   
   def check_basic_auth(username, password):
       """Vérifie les credentials Basic Auth"""
       user = User.find_by_username(username)
       if user and user.check_password(password):
           return user
       return None
   
   def requires_basic_auth(f):
       """Décorateur pour Basic Auth"""
       @wraps(f)
       def decorated(*args, **kwargs):
           auth = request.authorization
           
           if not auth:
               return jsonify({
                   'error': 'Unauthorized',
                   'message': 'Basic authentication required'
               }), 401
           
           user = check_basic_auth(auth.username, auth.password)
           if not user:
               return jsonify({
                   'error': 'Unauthorized',
                   'message': 'Invalid credentials'
               }), 401
           
           return f(user, *args, **kwargs)
       
       return decorated
   
   # Usage
   @app.route('/api/protected')
   @requires_basic_auth
   def protected_route(current_user):
       return jsonify({'message': f'Hello {current_user.username}'})
   
   [ATTENTION] LIMITATIONS:
   • Credentials envoyés à chaque requête
   • Facilement intercepté sans HTTPS
   • Pas de logout (cache navigateur)
   • Pas de granularité des permissions


2⃣ SESSION-BASED AUTHENTICATION
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

   [?] QUOI?
   Session stockée côté serveur, cookie côté client
   
   [?] POURQUOI?
   • Contrôle total côté serveur
   • Révocation immédiate possible
   • Familier pour apps web traditionnelles
   
   [?] QUAND utiliser?
   [OK] Applications web monolithiques
   [OK] Quand vous contrôlez le frontend
   [OK] Besoin de révocation instantanée
   [X] PAS pour APIs RESTful (stateless)
   [X] PAS pour apps mobiles
   [X] PAS pour microservices
   
   Flux:
   ┌──────────────────────────────────────────────────────┐
   │ 1. Login -> Serveur crée session -> Retourne cookie   │
   │ 2. Requêtes suivantes -> Cookie envoyé auto           │
   │ 3. Serveur vérifie session dans store (Redis/DB)    │
   │ 4. Logout -> Serveur détruit session                 │
   └──────────────────────────────────────────────────────┘
   
   Exemple Python:
   
   from flask import session
   from datetime import timedelta
   
   # Configuration
   app.config['SECRET_KEY'] = 'your-secret-key'
   app.config['SESSION_TYPE'] = 'redis'
   app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24)
   
   @app.route('/login', methods=['POST'])
   def login():
       data = request.json
       user = User.find_by_username(data['username'])
       
       if user and user.check_password(data['password']):
           # Créer la session
           session['user_id'] = user.id
           session['username'] = user.username
           session.permanent = True
           
           return jsonify({'message': 'Logged in successfully'}), 200
       
       return jsonify({'error': 'Invalid credentials'}), 401
   
   @app.route('/logout', methods=['POST'])
   def logout():
       session.clear()
       return jsonify({'message': 'Logged out'}), 200
   
   def login_required(f):
       @wraps(f)
       def decorated(*args, **kwargs):
           if 'user_id' not in session:
               return jsonify({'error': 'Authentication required'}), 401
           return f(*args, **kwargs)
       return decorated
   
   [ATTENTION] LIMITATIONS:
   • Pas stateless (viole principe REST)
   • Difficile à scaler horizontalement
   • Problèmes avec CORS
   • Pas adapté aux apps mobiles


3⃣ API KEY AUTHENTICATION
   ━━━━━━━━━━━━━━━━━━━━━━━

   [?] QUOI?
   Clé unique et secrète identifiant un client/service
   
   [?] POURQUOI?
   • Simple à implémenter
   • Bon pour machine-to-machine
   • Rate limiting par clé
   
   [?] QUAND utiliser?
   [OK] APIs publiques (Stripe, Google Maps)
   [OK] Communication service-to-service
   [OK] Webhooks
   [OK] CLI tools
   [X] PAS pour authentification utilisateur
   
   Formats courants:
   # Dans header
   Authorization: ApiKey your-api-key-here
   X-API-Key: your-api-key-here
   
   # Dans query param (moins sécurisé)
   /api/data?api_key=your-api-key-here
   
   Exemple Python:
   
   import secrets
   from functools import wraps
   
   class APIKey(db.Model):
       """Modèle pour stocker les API keys"""
       id = db.Column(db.Integer, primary_key=True)
       key = db.Column(db.String(64), unique=True, nullable=False, index=True)
       name = db.Column(db.String(100))  # Nom descriptif
       user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
       active = db.Column(db.Boolean, default=True)
       last_used_at = db.Column(db.DateTime)
       created_at = db.Column(db.DateTime, default=datetime.utcnow)
       
       # Rate limiting
       requests_count = db.Column(db.Integer, default=0)
       rate_limit = db.Column(db.Integer, default=1000)  # Requêtes/jour
       
       @classmethod
       def generate_key(cls):
           """Génère une API key sécurisée"""
           return secrets.token_urlsafe(48)
       
       @classmethod
       def create_for_user(cls, user_id, name):
           """Crée une nouvelle API key pour un user"""
           api_key = cls(
               key=cls.generate_key(),
               name=name,
               user_id=user_id
           )
           db.session.add(api_key)
           db.session.commit()
           return api_key
   
   def require_api_key(f):
       """Décorateur pour valider l'API key"""
       @wraps(f)
       def decorated(*args, **kwargs):
           # Récupérer la clé du header
           api_key = request.headers.get('X-API-Key')
           
           if not api_key:
               return jsonify({
                   'error': 'Unauthorized',
                   'message': 'API key required'
               }), 401
           
           # Vérifier la clé
           key_obj = APIKey.query.filter_by(key=api_key, active=True).first()
           
           if not key_obj:
               return jsonify({
                   'error': 'Unauthorized',
                   'message': 'Invalid API key'
               }), 401
           
           # Vérifier le rate limit
           if key_obj.requests_count >= key_obj.rate_limit:
               return jsonify({
                   'error': 'Too Many Requests',
                   'message': 'API key rate limit exceeded'
               }), 429
           
           # Incrémenter le compteur et mettre à jour last_used
           key_obj.requests_count += 1
           key_obj.last_used_at = datetime.utcnow()
           db.session.commit()
           
           # Passer la clé à la route
           return f(key_obj, *args, **kwargs)
       
       return decorated
   
   # Usage
   @app.route('/api/data')
   @require_api_key
   def get_data(api_key):
       return jsonify({
           'data': 'some data',
           'api_key_name': api_key.name,
           'requests_remaining': api_key.rate_limit - api_key.requests_count
       })


═══════════════════════════════════════════════════════════════════════════════
  5.2 JWT (JSON WEB TOKENS) - EN DÉTAIL
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI JWT pour les APIs RESTful?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

JWT est LE standard pour l'authentification d'APIs modernes car:
  [OK] STATELESS - Pas de stockage côté serveur
  [OK] SCALABLE - Fonctionne avec load balancing
  [OK] CROSS-DOMAIN - CORS-friendly
  [OK] MOBILE-FRIENDLY - Apps mobiles natives
  [OK] MICROSERVICES - Authentification distribuée
  [OK] SELF-CONTAINED - Contient toutes les infos nécessaires

[?] COMMENT fonctionne un JWT?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                      ANATOMIE D'UN JWT                              ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

Un JWT est composé de 3 parties séparées par des points:

HEADER.PAYLOAD.SIGNATURE

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

┌─────────────────────────────────────────────────────────────────────────┐
│                          1. HEADER (Base64)                             │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  {                                                                      │
│    "alg": "HS256",      <- Algorithme de signature                      │
│    "typ": "JWT"         <- Type de token                                │
│  }                                                                      │
│                                                                         │
│  Encodé en Base64:                                                      │
│  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9                                   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                          2. PAYLOAD (Base64)                            │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  {                                                                      │
│    // ═══ CLAIMS STANDARDS ═══                                         │
│    "sub": "123",          <- Subject (user ID)                          │
│    "iat": 1702468800,     <- Issued At (timestamp création)             │
│    "exp": 1702472400,     <- Expiration (timestamp expiration)          │
│    "nbf": 1702468800,     <- Not Before (pas valide avant)              │
│    "iss": "myapi.com",    <- Issuer (qui a émis le token)               │
│    "aud": "myapp.com",    <- Audience (pour qui)                        │
│    "jti": "abc-123",      <- JWT ID (identifiant unique)                │
│                                                                        │
│    // ═══ CLAIMS PERSONNALISÉS ═══                                     │
│    "username": "alice",                                                │
│    "email": "alice@example.com",                                       │
│    "role": "admin",                                                    │
│    "permissions": ["read", "write", "delete"]                          │
│  }                                                                     │
│                                                                        │
│  Encodé en Base64:                                                     │
│  eyJzdWIiOiIxMjMiLCJpYXQiOjE3MDI0Njg4MDAsImV4cCI6MTcwMjQ3MjQwMH0       │
│                                                                        │
└────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                          3. SIGNATURE                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  HMACSHA256(                                                            │
│    base64UrlEncode(header) + "." +                                      │
│    base64UrlEncode(payload),                                            │
│    secret_key                                                           │
│  )                                                                      │
│                                                                         │
│  Résultat:                                                              │
│  SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c                            │
│                                                                         │
│  [ATTENTION] La signature garantit:                                              │
│     • Le token n'a pas été modifié                                      │
│     • Le token a bien été émis par le serveur                           │
│     • L'intégrité des données                                           │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                       FLUX COMPLET JWT                              ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  1⃣ LOGIN - OBTENIR LE TOKEN                                          │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━                                        │
│                                                                         │
│  CLIENT                           SERVEUR                              │
│    │                                 │                                 │
│    │  POST /auth/login               │                                 │
│    │  {username, password}           │                                 │
│    │────────────────────────────────>│                                 │
│    │                                 │                                 │
│    │                                 │ 1. Vérifier credentials         │
│    │                                 │ 2. Générer JWT                  │
│    │                                 │    - sub: user_id               │
│    │                                 │    - exp: now + 1h              │
│    │                                 │    - role: admin                │
│    │                                 │ 3. Signer avec secret           │
│    │                                 │                                 │
│    │  200 OK                         │                                 │
│    │  {                              │                                 │
│    │    "access_token": "eyJ...",    │                                 │
│    │    "token_type": "Bearer",      │                                 │
│    │    "expires_in": 3600           │                                 │
│    │  }                              │                                 │
│    │<────────────────────────────────│                                 │
│    │                                 │                                 │
│  [Stocke le token localement]       │                                 │
│                                                                         │
│                                                                         │
│  2⃣ UTILISER LE TOKEN                                                 │
│  ━━━━━━━━━━━━━━━━━━━━━━━                                              │
│                                                                         │
│  CLIENT                           SERVEUR                              │
│    │                                 │                                 │
│    │  GET /api/users/me              │                                 │
│    │  Authorization: Bearer eyJ...   │                                 │
│    │────────────────────────────────>│                                 │
│    │                                 │                                 │
│    │                                 │ 1. Extraire le token            │
│    │                                 │ 2. Vérifier signature           │
│    │                                 │ 3. Vérifier expiration          │
│    │                                 │ 4. Décoder payload              │
│    │                                 │ 5. Extraire user_id             │
│    │                                 │ 6. Récupérer user de DB         │
│    │                                 │ 7. Vérifier permissions         │
│    │                                 │                                 │
│    │  200 OK                         │                                 │
│    │  {user data}                    │                                 │
│    │<────────────────────────────────│                                 │
│    │                                                                   │
│                                                                         │
│  3⃣ TOKEN EXPIRÉ - REFRESH                                            │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━                                        │
│                                                                         │
│  CLIENT                           SERVEUR                              │
│    │                                 │                                 │
│    │  GET /api/users/me              │                                 │
│    │  Authorization: Bearer eyJ...   │                                 │
│    │────────────────────────────────>│                                 │
│    │                                 │                                 │
│    │                                 │ Token expiré!                   │
│    │                                 │                                 │
│    │  401 Unauthorized               │                                 │
│    │  {error: "Token expired"}       │                                 │
│    │<────────────────────────────────│                                 │
│    │                                 │                                 │
│    │  POST /auth/refresh             │                                 │
│    │  {refresh_token}                │                                 │
│    │────────────────────────────────>│                                 │
│    │                                 │                                 │
│    │                                 │ 1. Valider refresh token        │
│    │                                 │ 2. Générer nouveau access token │
│    │                                 │                                 │
│    │  200 OK                         │                                 │
│    │  {                              │                                 │
│    │    "access_token": "eyJ...",    │                                 │
│    │    "token_type": "Bearer",      │                                 │
│    │    "expires_in": 3600           │                                 │
│    │  }                              │                                 │
│    │<────────────────────────────────│                                 │
│    │                                 │                                 │
│  [Stocke le nouveau token]          │                                  │
│                                                                        │
└────────────────────────────────────────────────────────────────────────┘


═══════════════════════════════════════════════════════════════════════════════
  5.3 IMPLÉMENTATION JWT AVEC FLASK
═══════════════════════════════════════════════════════════════════════════════


═══ FICHIER app/utils/auth.py - UTILITAIRES JWT ═══

"""
Utilitaires pour l'authentification JWT
"""

from datetime import datetime, timedelta
from functools import wraps
from flask import jsonify, request
from flask_jwt_extended import (
    create_access_token,
    create_refresh_token,
    get_jwt_identity,
    get_jwt,
    verify_jwt_in_request
)
from app import db
from app.models.user import User


# ═══ TOKEN BLACKLIST (Révocation) ═══

class TokenBlocklist(db.Model):
    """
    Liste des tokens révoqués
    
    Utilisé pour le logout et la révocation de tokens
    """
    __tablename__ = 'token_blocklist'
    
    id = db.Column(db.Integer, primary_key=True)
    jti = db.Column(db.String(36), nullable=False, unique=True, index=True)
    # jti = JWT ID (identifiant unique du token)
    
    token_type = db.Column(db.String(10), nullable=False)
    # 'access' ou 'refresh'
    
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    
    revoked_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
    
    expires_at = db.Column(db.DateTime, nullable=False)
    
    @classmethod
    def is_jti_blacklisted(cls, jti):
        """Vérifie si un token est révoqué"""
        query = cls.query.filter_by(jti=jti).first()
        return query is not None
    
    @classmethod
    def add_to_blacklist(cls, jti, token_type, user_id, expires_at):
        """Ajoute un token à la blacklist"""
        blocked_token = cls(
            jti=jti,
            token_type=token_type,
            user_id=user_id,
            expires_at=expires_at
        )
        db.session.add(blocked_token)
        db.session.commit()
    
    @classmethod
    def cleanup_expired(cls):
        """
        Nettoie les tokens expirés de la blacklist
        À exécuter périodiquement (cron job)
        """
        cls.query.filter(cls.expires_at < datetime.utcnow()).delete()
        db.session.commit()


# ═══ CRÉATION DE TOKENS ═══

def generate_tokens(user):
    """
    Génère access et refresh tokens pour un utilisateur
    
    Args:
        user (User): Utilisateur pour qui générer les tokens
    
    Returns:
        dict: {
            'access_token': '...',
            'refresh_token': '...',
            'token_type': 'Bearer',
            'expires_in': 3600
        }
    """
    
    # Claims supplémentaires à inclure dans le JWT
    additional_claims = {
        'username': user.username,
        'email': user.email,
        'role': user.role,
        'email_verified': user.email_verified
    }
    
    # Créer l'access token (courte durée)
    access_token = create_access_token(
        identity=user.id,
        additional_claims=additional_claims,
        fresh=True  # Token "fresh" = vient d'un login
    )
    
    # Créer le refresh token (longue durée)
    refresh_token = create_refresh_token(
        identity=user.id,
        additional_claims={'username': user.username}
    )
    
    return {
        'access_token': access_token,
        'refresh_token': refresh_token,
        'token_type': 'Bearer',
        'expires_in': 3600  # 1 heure en secondes
    }


# ═══ RÉCUPÉRATION DE L'UTILISATEUR COURANT ═══

def get_current_user():
    """
    Récupère l'utilisateur courant depuis le JWT
    
    Returns:
        User: Utilisateur courant
    
    Raises:
        HTTPException: Si le token est invalide ou l'utilisateur n'existe plus
    
    Usage:
        @app.route('/api/profile')
        @jwt_required()
        def get_profile():
            user = get_current_user()
            return jsonify(user.to_dict())
    """
    user_id = get_jwt_identity()
    user = User.query.get(user_id)
    
    if not user:
        return None
    
    if not user.active:
        return None
    
    return user


# ═══ DÉCORATEURS PERSONNALISÉS ═══

def admin_required():
    """
    Décorateur pour routes nécessitant le rôle admin
    
    Usage:
        @app.route('/api/admin/users')
        @jwt_required()
        @admin_required()
        def admin_users():
            return jsonify({'users': []})
    """
    def wrapper(fn):
        @wraps(fn)
        def decorator(*args, **kwargs):
            verify_jwt_in_request()
            
            user = get_current_user()
            if not user:
                return jsonify({
                    'error': 'Unauthorized',
                    'message': 'User not found'
                }), 401
            
            if user.role != 'admin':
                return jsonify({
                    'error': 'Forbidden',
                    'message': 'Admin role required',
                    'your_role': user.role
                }), 403
            
            return fn(*args, **kwargs)
        return decorator
    return wrapper


def role_required(allowed_roles):
    """
    Décorateur pour routes nécessitant certains rôles
    
    Args:
        allowed_roles (list): Liste des rôles autorisés
    
    Usage:
        @app.route('/api/posts/publish')
        @jwt_required()
        @role_required(['editor', 'admin'])
        def publish_post():
            return jsonify({'message': 'Published'})
    """
    def wrapper(fn):
        @wraps(fn)
        def decorator(*args, **kwargs):
            verify_jwt_in_request()
            
            user = get_current_user()
            if not user:
                return jsonify({
                    'error': 'Unauthorized',
                    'message': 'User not found'
                }), 401
            
            if user.role not in allowed_roles:
                return jsonify({
                    'error': 'Forbidden',
                    'message': f'One of these roles required: {", ".join(allowed_roles)}',
                    'your_role': user.role
                }), 403
            
            return fn(*args, **kwargs)
        return decorator
    return wrapper


def permission_required(permission):
    """
    Décorateur pour routes nécessitant une permission spécifique
    
    Args:
        permission (str): Permission requise
    
    Usage:
        @app.route('/api/users/<int:user_id>')
        @jwt_required()
        @permission_required('users:delete')
        def delete_user(user_id):
            return jsonify({'message': 'Deleted'})
    """
    def wrapper(fn):
        @wraps(fn)
        def decorator(*args, **kwargs):
            verify_jwt_in_request()
            
            user = get_current_user()
            if not user:
                return jsonify({
                    'error': 'Unauthorized',
                    'message': 'User not found'
                }), 401
            
            # Vérifier si l'utilisateur a la permission
            # (À implémenter selon votre système de permissions)
            if not user.has_permission(permission):
                return jsonify({
                    'error': 'Forbidden',
                    'message': f'Permission required: {permission}'
                }), 403
            
            return fn(*args, **kwargs)
        return decorator
    return wrapper


def fresh_jwt_required():
    """
    Décorateur pour routes nécessitant un token "fresh"
    (obtenu récemment via login)
    
    Utilisé pour opérations sensibles (changement mot de passe, etc.)
    
    Usage:
        @app.route('/api/users/change-password')
        @fresh_jwt_required()
        def change_password():
            return jsonify({'message': 'Password changed'})
    """
    def wrapper(fn):
        @wraps(fn)
        def decorator(*args, **kwargs):
            from flask_jwt_extended import verify_jwt_in_request
            
            verify_jwt_in_request(fresh=True)
            return fn(*args, **kwargs)
        return decorator
    return wrapper


# ═══ VALIDATION DE TOKEN ═══

def validate_token():
    """
    Valide le token JWT de la requête courante
    
    Returns:
        dict: Payload du JWT si valide
        None: Si invalide
    """
    try:
        verify_jwt_in_request()
        claims = get_jwt()
        return claims
    except Exception:
        return None


═══ FICHIER app/api/v1/auth.py - ROUTES D'AUTHENTIFICATION ═══

"""
Routes pour l'authentification JWT
"""

from flask import Blueprint, request, jsonify
from flask_jwt_extended import (
    jwt_required,
    get_jwt_identity,
    get_jwt,
    create_access_token
)
from marshmallow import Schema, fields, validate, ValidationError
from datetime import datetime

from app import db
from app.models.user import User
from app.models.auth import TokenBlocklist
from app.utils.auth import generate_tokens, get_current_user
from app.schemas.user import user_schema


# Créer le blueprint
auth_bp = Blueprint('auth', __name__)


# ═══ SCHÉMAS DE VALIDATION ═══

class LoginSchema(Schema):
    """Schéma pour la requête de login"""
    username = fields.Str(required=True)
    password = fields.Str(required=True)

class RegisterSchema(Schema):
    """Schéma pour la requête de register"""
    username = fields.Str(
        required=True,
        validate=validate.Length(min=3, max=80)
    )
    email = fields.Email(required=True)
    password = fields.Str(
        required=True,
        validate=validate.Length(min=8)
    )
    first_name = fields.Str(validate=validate.Length(max=50))
    last_name = fields.Str(validate=validate.Length(max=50))

login_schema = LoginSchema()
register_schema = RegisterSchema()


# ═══ POST /auth/register - INSCRIPTION ═══

@auth_bp.route('/register', methods=['POST'])
def register():
    """
    Inscription d'un nouvel utilisateur
    
    Body:
        {
            "username": "alice",
            "email": "alice@example.com",
            "password": "SecurePass123!",
            "first_name": "Alice",  (optionnel)
            "last_name": "Dupont"   (optionnel)
        }
    
    Réponse 201:
        {
            "message": "User created successfully",
            "user": {...},
            "tokens": {
                "access_token": "eyJ...",
                "refresh_token": "eyJ...",
                "token_type": "Bearer",
                "expires_in": 3600
            }
        }
    
    Réponse 400: Validation échouée
    Réponse 409: Username ou email déjà utilisé
    """
    
    # Valider les données
    try:
        data = register_schema.load(request.json)
    except ValidationError as err:
        return jsonify({
            'error': 'Validation Failed',
            'messages': err.messages
        }), 400
    
    # Vérifier si username existe déjà
    if User.find_by_username(data['username']):
        return jsonify({
            'error': 'Conflict',
            'message': 'Username already exists'
        }), 409
    
    # Vérifier si email existe déjà
    if User.find_by_email(data['email']):
        return jsonify({
            'error': 'Conflict',
            'message': 'Email already exists'
        }), 409
    
    # Créer l'utilisateur
    user = User(
        username=data['username'],
        email=data['email'],
        password_hash=User.hash_password(data['password']),
        first_name=data.get('first_name'),
        last_name=data.get('last_name'),
        role='user'
    )
    
    user.save()
    
    # Générer les tokens
    tokens = generate_tokens(user)
    
    # TODO: Envoyer email de vérification
    
    return jsonify({
        'message': 'User created successfully',
        'user': user_schema.dump(user),
        'tokens': tokens
    }), 201


# ═══ POST /auth/login - CONNEXION ═══

@auth_bp.route('/login', methods=['POST'])
def login():
    """
    Connexion d'un utilisateur
    
    Body:
        {
            "username": "alice",
            "password": "SecurePass123!"
        }
    
    Réponse 200:
        {
            "message": "Logged in successfully",
            "user": {...},
            "tokens": {
                "access_token": "eyJ...",
                "refresh_token": "eyJ...",
                "token_type": "Bearer",
                "expires_in": 3600
            }
        }
    
    Réponse 400: Validation échouée
    Réponse 401: Credentials invalides
    Réponse 403: Compte désactivé
    """
    
    # Valider les données
    try:
        data = login_schema.load(request.json)
    except ValidationError as err:
        return jsonify({
            'error': 'Validation Failed',
            'messages': err.messages
        }), 400
    
    # Trouver l'utilisateur
    user = User.find_by_username(data['username'])
    
    if not user or not user.check_password(data['password']):
        return jsonify({
            'error': 'Unauthorized',
            'message': 'Invalid username or password'
        }), 401
    
    # Vérifier si le compte est actif
    if not user.active:
        return jsonify({
            'error': 'Forbidden',
            'message': 'Account is disabled'
        }), 403
    
    # Mettre à jour last_login
    user.update_last_login()
    
    # Générer les tokens
    tokens = generate_tokens(user)
    
    return jsonify({
        'message': 'Logged in successfully',
        'user': user_schema.dump(user),
        'tokens': tokens
    }), 200


# ═══ POST /auth/logout - DÉCONNEXION ═══

@auth_bp.route('/logout', methods=['POST'])
@jwt_required()
def logout():
    """
    Déconnexion - Révoque le token courant
    
    Header:
        Authorization: Bearer <access_token>
    
    Réponse 200:
        {
            "message": "Logged out successfully"
        }
    """
    
    # Récupérer les infos du token
    jwt_data = get_jwt()
    jti = jwt_data['jti']  # JWT ID
    token_type = jwt_data['type']  # 'access' ou 'refresh'
    user_id = get_jwt_identity()
    exp_timestamp = jwt_data['exp']
    expires_at = datetime.fromtimestamp(exp_timestamp)
    
    # Ajouter à la blacklist
    TokenBlocklist.add_to_blacklist(
        jti=jti,
        token_type=token_type,
        user_id=user_id,
        expires_at=expires_at
    )
    
    return jsonify({
        'message': 'Logged out successfully'
    }), 200


# ═══ POST /auth/refresh - RAFRAÎCHIR LE TOKEN ═══

@auth_bp.route('/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh():
    """
    Rafraîchit l'access token en utilisant le refresh token
    
    Header:
        Authorization: Bearer <refresh_token>
    
    Réponse 200:
        {
            "access_token": "eyJ...",
            "token_type": "Bearer",
            "expires_in": 3600
        }
    """
    
    user_id = get_jwt_identity()
    user = User.query.get(user_id)
    
    if not user or not user.active:
        return jsonify({
            'error': 'Unauthorized',
            'message': 'User not found or inactive'
        }), 401
    
    # Créer un nouveau access token (non-fresh)
    additional_claims = {
        'username': user.username,
        'email': user.email,
        'role': user.role
    }
    
    access_token = create_access_token(
        identity=user.id,
        additional_claims=additional_claims,
        fresh=False  # Token pas "fresh"
    )
    
    return jsonify({
        'access_token': access_token,
        'token_type': 'Bearer',
        'expires_in': 3600
    }), 200


# ═══ GET /auth/me - PROFIL UTILISATEUR COURANT ═══

@auth_bp.route('/me', methods=['GET'])
@jwt_required()
def get_me():
    """
    Récupère le profil de l'utilisateur courant
    
    Header:
        Authorization: Bearer <access_token>
    
    Réponse 200:
        {
            "id": 123,
            "username": "alice",
            "email": "alice@example.com",
            ...
        }
    """
    
    user = get_current_user()
    
    if not user:
        return jsonify({
            'error': 'Unauthorized',
            'message': 'User not found'
        }), 401
    
    return jsonify(user_schema.dump(user)), 200


# ═══ POST /auth/change-password - CHANGER MOT DE PASSE ═══

@auth_bp.route('/change-password', methods=['POST'])
@jwt_required(fresh=True)  # Nécessite un token "fresh"
def change_password():
    """
    Change le mot de passe de l'utilisateur courant
    
    Nécessite un token "fresh" (obtenu récemment via login)
    
    Header:
        Authorization: Bearer <fresh_access_token>
    
    Body:
        {
            "current_password": "OldPass123!",
            "new_password": "NewPass456!"
        }
    
    Réponse 200:
        {
            "message": "Password changed successfully"
        }
    
    Réponse 400: Mot de passe actuel incorrect
    Réponse 401: Token pas assez récent (pas fresh)
    """
    
    data = request.json
    
    if not data.get('current_password') or not data.get('new_password'):
        return jsonify({
            'error': 'Bad Request',
            'message': 'current_password and new_password required'
        }), 400
    
    user = get_current_user()
    
    # Vérifier le mot de passe actuel
    if not user.check_password(data['current_password']):
        return jsonify({
            'error': 'Bad Request',
            'message': 'Current password is incorrect'
        }), 400
    
    # Valider le nouveau mot de passe
    if len(data['new_password']) < 8:
        return jsonify({
            'error': 'Bad Request',
            'message': 'New password must be at least 8 characters'
        }), 400
    
    # Changer le mot de passe
    user.set_password(data['new_password'])
    user.save()
    
    return jsonify({
        'message': 'Password changed successfully'
    }), 200


# ═══ POST /auth/verify-email - VÉRIFIER EMAIL ═══

@auth_bp.route('/verify-email/<token>', methods=['GET'])
def verify_email(token):
    """
    Vérifie l'email d'un utilisateur
    
    Path param:
        token: Token de vérification envoyé par email
    
    Réponse 200:
        {
            "message": "Email verified successfully"
        }
    """
    
    # TODO: Implémenter la vérification d'email
    # 1. Vérifier le token
    # 2. Trouver l'utilisateur
    # 3. Marquer email comme vérifié
    
    return jsonify({
        'message': 'Email verified successfully'
    }), 200


# ═══ POST /auth/forgot-password - MOT DE PASSE OUBLIÉ ═══

@auth_bp.route('/forgot-password', methods=['POST'])
def forgot_password():
    """
    Demande de réinitialisation de mot de passe
    
    Body:
        {
            "email": "alice@example.com"
        }
    
    Réponse 200:
        {
            "message": "Password reset email sent"
        }
    """
    
    data = request.json
    email = data.get('email')
    
    if not email:
        return jsonify({
            'error': 'Bad Request',
            'message': 'Email required'
        }), 400
    
    user = User.find_by_email(email)
    
    # Ne pas révéler si l'email existe ou non (sécurité)
    # Toujours retourner 200
    
    if user:
        # TODO: Envoyer email de réinitialisation
        pass
    
    return jsonify({
        'message': 'If an account with that email exists, a password reset link has been sent'
    }), 200


# ═══ POST /auth/reset-password - RÉINITIALISER MOT DE PASSE ═══

@auth_bp.route('/reset-password/<token>', methods=['POST'])
def reset_password(token):
    """
    Réinitialise le mot de passe avec un token
    
    Path param:
        token: Token de réinitialisation reçu par email
    
    Body:
        {
            "new_password": "NewPass123!"
        }
    
    Réponse 200:
        {
            "message": "Password reset successfully"
        }
    """
    
    # TODO: Implémenter la réinitialisation
    # 1. Vérifier le token
    # 2. Trouver l'utilisateur
    # 3. Changer le mot de passe
    
    return jsonify({
        'message': 'Password reset successfully'
    }), 200


═══ EXEMPLES D'UTILISATION AVEC CURL ═══

# ═══ 1. REGISTER ═══
curl -X POST http://localhost:5000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alice",
    "email": "alice@example.com",
    "password": "SecurePass123!",
    "first_name": "Alice",
    "last_name": "Dupont"
  }'

# Réponse:
# {
#   "message": "User created successfully",
#   "user": {...},
#   "tokens": {
#     "access_token": "eyJhbGc...",
#     "refresh_token": "eyJhbGc...",
#     "token_type": "Bearer",
#     "expires_in": 3600
#   }
# }


# ═══ 2. LOGIN ═══
curl -X POST http://localhost:5000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alice",
    "password": "SecurePass123!"
  }'

# Sauvegarder le token
export TOKEN="eyJhbGc..."


# ═══ 3. ACCÉDER À UNE ROUTE PROTÉGÉE ═══
curl -X GET http://localhost:5000/api/v1/auth/me \
  -H "Authorization: Bearer $TOKEN"


# ═══ 4. REFRESH TOKEN ═══
export REFRESH_TOKEN="eyJhbGc..."

curl -X POST http://localhost:5000/api/v1/auth/refresh \
  -H "Authorization: Bearer $REFRESH_TOKEN"


# ═══ 5. LOGOUT ═══
curl -X POST http://localhost:5000/api/v1/auth/logout \
  -H "Authorization: Bearer $TOKEN"


# ═══ 6. CHANGER MOT DE PASSE ═══
curl -X POST http://localhost:5000/api/v1/auth/change-password \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "current_password": "SecurePass123!",
    "new_password": "NewPass456!"
  }'


═══════════════════════════════════════════════════════════════════════════════
  5.4 OAuth 2.0 - AUTHENTIFICATION TIERCE
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI OAuth 2.0?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

OAuth 2.0 permet aux utilisateurs de se connecter avec leurs comptes existants:
  [OK] Pas besoin de créer un nouveau compte
  [OK] Pas besoin de mémoriser un nouveau mot de passe
  [OK] Confiance (Google, GitHub, etc.)
  [OK] Accès aux données du provider (email, profil)
  [OK] Meilleure UX (connexion en 1 clic)

Providers populaires:
  • Google (Gmail, Google Workspace)
  • GitHub (développeurs)
  • Facebook/Meta
  • Microsoft (Azure AD, Office 365)
  • Twitter/X
  • LinkedIn

[?] COMMENT fonctionne OAuth 2.0?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    FLUX OAUTH 2.0 (Authorization Code)              ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  ACTEURS:                                                               │
│    • USER (Utilisateur)                                                 │
│    • CLIENT (Votre application)                                         │
│    • AUTHORIZATION SERVER (Google, GitHub, etc.)                        │
│    • RESOURCE SERVER (API du provider)                                  │
│                                                                         │
│  ═════════════════════════════════════════════════════════════════      │
│                                                                         │
│  1⃣ INITIATION - L'utilisateur clique "Se connecter avec Google"        │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━       │
│                                                                         │
│  USER                    CLIENT                    GOOGLE               │
│   │                        │                         │                  │
│   │  Clic "Login Google"   │                         │                  │
│   │───────────────────────>│                         │                  │
│   │                        │                         │                  │
│   │                        │ 1. Redirection vers     │                  │
│   │                        │    Google OAuth         │                  │
│   │                        │                         │                  │
│   │                        │  URL: https://accounts.google.com/o/oauth2/│
│   │                        │       v2/auth?                             │
│   │                        │       client_id=YOUR_CLIENT_ID&            │
│   │                        │       redirect_uri=https://yourapp.com/auth│
│   │                        │       /google/callback&                    │
│   │                        │       response_type=code&                  │
│   │                        │       scope=openid email profile           │
│   │<───────────────────────────────────────────────│                    │
│   │                                                                     │
│                                                                         │
│  2⃣ AUTORISATION - L'utilisateur autorise l'application                 │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━       │
│                                                                         │
│  USER                                              GOOGLE               │
│   │                                                  │                  │
│   │  2. Affichage écran de consentement             │                   │
│   │<─────────────────────────────────────────────────│                  │
│   │                                                  │                  │
│   │  "YourApp veut accéder à:"                       │                  │
│   │  • Votre email                                   │                  │
│   │  • Votre profil public                           │                  │
│   │  [Autoriser] [Refuser]                           │                  │
│   │                                                  │                  │
│   │  3. User clique "Autoriser"                      │                  │
│   │──────────────────────────────────────────────────>│                 │
│   │                                                  │                  │
│                                                                         │
│  3⃣ AUTHORIZATION CODE - Google redirige avec un code                   │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━       │
│                                                                         │
│  USER                    CLIENT                    GOOGLE               │
│   │                        │                         │                  │
│   │  4. Redirection avec   │                         │                  │
│   │     authorization code │                         │                  │
│   │<───────────────────────────────────────────────│                    │
│   │                        │                         │                  │
│   │  URL: https://yourapp.com/auth/google/callback  │                   │
│   │       ?code=AUTH_CODE_HERE                       │                  │
│   │                        │                         │                  │
│   │───────────────────────>│                         │                  │
│   │                        │                         │                  │
│                                                                         │
│  4⃣ EXCHANGE CODE FOR TOKEN - Backend échange le code                   │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━       │
│                                                                         │
│                          CLIENT                    GOOGLE               │
│                            │                         │                  │
│                            │  5. POST /token         │                  │
│                            │     code=AUTH_CODE      │                  │
│                            │     client_id=...       │                  │
│                            │     client_secret=...   │                  │
│                            │     redirect_uri=...    │                  │
│                            │─────────────────────────>│                 │
│                            │                         │                  │
│                            │  6. Retourne tokens     │                  │
│                            │  {                      │                  │
│                            │    "access_token": "ya29...",              │
│                            │    "refresh_token": "1//...",              │
│                            │    "expires_in": 3600,  │                  │
│                            │    "token_type": "Bearer"                  │
│                            │  }                      │                  │
│                            │<─────────────────────────│                 │
│                            │                         │                  │
│                                                                         │
│  5⃣ RÉCUPÉRER LES INFOS USER - Backend appelle l'API Google             │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━       │
│                                                                         │
│                          CLIENT                    GOOGLE API           │
│                            │                         │                  │
│                            │  7. GET /userinfo       │                  │
│                            │     Authorization:      │                  │
│                            │     Bearer ya29...      │                  │
│                            │─────────────────────────>│                 │
│                            │                         │                  │
│                            │  8. Retourne profil     │                  │
│                            │  {                      │                  │
│                            │    "sub": "1234567890", │                  │
│                            │    "email": "user@...", │                  │
│                            │    "name": "John Doe",  │                  │
│                            │    "picture": "https:// │                  │
│                            │  }                      │                  │
│                            │<─────────────────────────│                 │
│                            │                         │                  │
│                                                                         │
│  6⃣ CRÉER/CONNECTER USER - Backend crée ou connecte l'utilisateur       │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━       │
│                                                                         │
│  USER                    CLIENT                                         │
│   │                        │                                            │
│   │                        │  9. Créer/trouver user dans DB             │
│   │                        │     basé sur email ou provider_id          │
│   │                        │                                            │
│   │                        │  10. Générer JWT token pour le user        │
│   │                        │                                            │
│   │  11. Redirection       │                                            │
│   │      avec JWT          │                                            │
│   │<───────────────────────│                                            │
│   │                        │                                            │
│   │  User est connecté! [OK]  │                                            │
│   │                        │                                            │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘


═══ INSTALLATION ═══

pip install authlib requests


═══ FICHIER config.py - CONFIGURATION OAUTH ═══

# Ajouter à la configuration

class Config:
    # ... autres configs ...
    
    # ═══ OAUTH GOOGLE ═══
    GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID')
    GOOGLE_CLIENT_SECRET = os.environ.get('GOOGLE_CLIENT_SECRET')
    GOOGLE_DISCOVERY_URL = "https://accounts.google.com/.well-known/openid-configuration"
    
    # ═══ OAUTH GITHUB ═══
    GITHUB_CLIENT_ID = os.environ.get('GITHUB_CLIENT_ID')
    GITHUB_CLIENT_SECRET = os.environ.get('GITHUB_CLIENT_SECRET')
    
    # ═══ REDIRECT URIs ═══
    # En dev
    OAUTH_REDIRECT_URI = 'http://localhost:5000/auth/callback'
    
    # En production
    # OAUTH_REDIRECT_URI = 'https://yourdomain.com/auth/callback'


═══ FICHIER .env - SECRETS OAUTH ═══

# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret

# GitHub OAuth
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret


═══ FICHIER app/models/user.py - AJOUTER CHAMPS OAUTH ═══

# Ajouter à la classe User

class User(BaseModel):
    # ... champs existants ...
    
    # ═══ OAUTH FIELDS ═══
    
    # Provider OAuth utilisé ('local', 'google', 'github', etc.)
    oauth_provider = db.Column(
        db.String(20),
        nullable=True,
        default='local'
    )
    
    # ID de l'utilisateur chez le provider OAuth
    oauth_provider_id = db.Column(
        db.String(255),
        nullable=True,
        index=True
    )
    
    # Token OAuth (pour refresh si nécessaire)
    oauth_access_token = db.Column(
        db.Text,
        nullable=True
    )
    
    oauth_refresh_token = db.Column(
        db.Text,
        nullable=True
    )
    
    # Photo de profil du provider
    avatar_url = db.Column(
        db.String(500),
        nullable=True
    )
    
    @classmethod
    def find_by_oauth(cls, provider, provider_id):
        """
        Trouve un utilisateur par son provider OAuth
        
        Args:
            provider (str): 'google', 'github', etc.
            provider_id (str): ID chez le provider
        
        Returns:
            User|None: Utilisateur trouvé ou None
        """
        return cls.query.filter_by(
            oauth_provider=provider,
            oauth_provider_id=provider_id
        ).first()
    
    @classmethod
    def create_from_oauth(cls, provider, provider_id, email, profile_data):
        """
        Crée un utilisateur depuis les données OAuth
        
        Args:
            provider (str): 'google', 'github', etc.
            provider_id (str): ID chez le provider
            email (str): Email de l'utilisateur
            profile_data (dict): Données du profil
        
        Returns:
            User: Nouvel utilisateur créé
        """
        # Générer un username unique
        base_username = email.split('@')[0]
        username = base_username
        counter = 1
        
        while cls.find_by_username(username):
            username = f"{base_username}{counter}"
            counter += 1
        
        user = cls(
            username=username,
            email=email,
            oauth_provider=provider,
            oauth_provider_id=provider_id,
            email_verified=True,  # Email vérifié par le provider
            first_name=profile_data.get('given_name'),
            last_name=profile_data.get('family_name'),
            avatar_url=profile_data.get('picture'),
            # Pas de password_hash pour OAuth users
            password_hash=None
        )
        
        return user


═══ FICHIER app/api/v1/oauth.py - ROUTES OAUTH ═══

"""
Routes pour l'authentification OAuth 2.0
Supporte Google, GitHub, etc.
"""

from flask import Blueprint, request, redirect, url_for, jsonify, session
from authlib.integrations.requests_client import OAuth2Session
import requests

from app import db
from app.models.user import User
from app.utils.auth import generate_tokens
from config import Config


oauth_bp = Blueprint('oauth', __name__)


# ═══ GOOGLE OAUTH ═══

@oauth_bp.route('/google/login')
def google_login():
    """
    Initie le flux OAuth Google
    
    Redirige l'utilisateur vers la page de consentement Google
    
    GET /auth/google/login
    """
    
    # Créer le client OAuth
    google = OAuth2Session(
        Config.GOOGLE_CLIENT_ID,
        Config.GOOGLE_CLIENT_SECRET,
        scope='openid email profile',
        redirect_uri=f"{Config.OAUTH_REDIRECT_URI}/google"
    )
    
    # Récupérer l'URL d'autorisation
    authorization_url, state = google.create_authorization_url(
        'https://accounts.google.com/o/oauth2/v2/auth'
    )
    
    # Sauvegarder le state en session pour vérification
    session['oauth_state'] = state
    
    # Rediriger vers Google
    return redirect(authorization_url)


@oauth_bp.route('/google/callback')
def google_callback():
    """
    Callback après autorisation Google
    
    Google redirige ici avec le code d'autorisation
    
    GET /auth/google/callback?code=...&state=...
    """
    
    # Vérifier le state (protection CSRF)
    if request.args.get('state') != session.get('oauth_state'):
        return jsonify({
            'error': 'Invalid state',
            'message': 'State mismatch - possible CSRF attack'
        }), 400
    
    # Récupérer le code d'autorisation
    code = request.args.get('code')
    
    if not code:
        return jsonify({
            'error': 'Authorization failed',
            'message': 'No authorization code received'
        }), 400
    
    # Échanger le code contre un access token
    try:
        google = OAuth2Session(
            Config.GOOGLE_CLIENT_ID,
            Config.GOOGLE_CLIENT_SECRET,
            redirect_uri=f"{Config.OAUTH_REDIRECT_URI}/google"
        )
        
        token = google.fetch_token(
            'https://oauth2.googleapis.com/token',
            code=code
        )
        
    except Exception as e:
        return jsonify({
            'error': 'Token exchange failed',
            'message': str(e)
        }), 400
    
    # Récupérer les informations du profil
    try:
        google = OAuth2Session(
            Config.GOOGLE_CLIENT_ID,
            Config.GOOGLE_CLIENT_SECRET,
            token=token
        )
        
        resp = google.get('https://www.googleapis.com/oauth2/v3/userinfo')
        profile = resp.json()
        
    except Exception as e:
        return jsonify({
            'error': 'Failed to fetch profile',
            'message': str(e)
        }), 400
    
    # Extraire les infos
    google_id = profile.get('sub')
    email = profile.get('email')
    
    if not google_id or not email:
        return jsonify({
            'error': 'Invalid profile',
            'message': 'Missing required profile information'
        }), 400
    
    # Trouver ou créer l'utilisateur
    user = User.find_by_oauth('google', google_id)
    
    if not user:
        # Vérifier si l'email existe déjà (compte local)
        user = User.find_by_email(email)
        
        if user:
            # Lier le compte OAuth existant
            user.oauth_provider = 'google'
            user.oauth_provider_id = google_id
            user.email_verified = True
            user.avatar_url = profile.get('picture')
            db.session.commit()
        else:
            # Créer un nouveau compte
            user = User.create_from_oauth(
                provider='google',
                provider_id=google_id,
                email=email,
                profile_data=profile
            )
            user.save()
    
    # Mettre à jour les tokens OAuth
    user.oauth_access_token = token.get('access_token')
    user.oauth_refresh_token = token.get('refresh_token')
    user.update_last_login()
    db.session.commit()
    
    # Générer JWT tokens
    tokens = generate_tokens(user)
    
    # Rediriger vers le frontend avec le token
    # Option 1: Query param (moins sécurisé)
    # return redirect(f"http://localhost:3000/auth/callback?token={tokens['access_token']}")
    
    # Option 2: Retourner JSON (pour SPA)
    return jsonify({
        'message': 'Logged in with Google successfully',
        'user': user.to_dict(include_email=True),
        'tokens': tokens
    }), 200


# ═══ GITHUB OAUTH ═══

@oauth_bp.route('/github/login')
def github_login():
    """
    Initie le flux OAuth GitHub
    
    GET /auth/github/login
    """
    
    github = OAuth2Session(
        Config.GITHUB_CLIENT_ID,
        Config.GITHUB_CLIENT_SECRET,
        scope='user:email',
        redirect_uri=f"{Config.OAUTH_REDIRECT_URI}/github"
    )
    
    authorization_url, state = github.create_authorization_url(
        'https://github.com/login/oauth/authorize'
    )
    
    session['oauth_state'] = state
    
    return redirect(authorization_url)


@oauth_bp.route('/github/callback')
def github_callback():
    """
    Callback après autorisation GitHub
    
    GET /auth/github/callback?code=...&state=...
    """
    
    # Vérifier le state
    if request.args.get('state') != session.get('oauth_state'):
        return jsonify({
            'error': 'Invalid state'
        }), 400
    
    code = request.args.get('code')
    
    if not code:
        return jsonify({
            'error': 'No authorization code'
        }), 400
    
    # Échanger le code
    try:
        github = OAuth2Session(
            Config.GITHUB_CLIENT_ID,
            Config.GITHUB_CLIENT_SECRET,
            redirect_uri=f"{Config.OAUTH_REDIRECT_URI}/github"
        )
        
        token = github.fetch_token(
            'https://github.com/login/oauth/access_token',
            code=code
        )
        
    except Exception as e:
        return jsonify({
            'error': 'Token exchange failed',
            'message': str(e)
        }), 400
    
    # Récupérer le profil
    try:
        github = OAuth2Session(
            Config.GITHUB_CLIENT_ID,
            Config.GITHUB_CLIENT_SECRET,
            token=token
        )
        
        # Profil principal
        resp = github.get('https://api.github.com/user')
        profile = resp.json()
        
        # Emails (séparé sur GitHub)
        resp_emails = github.get('https://api.github.com/user/emails')
        emails = resp_emails.json()
        
        # Prendre l'email primary et verified
        primary_email = next(
            (e['email'] for e in emails if e['primary'] and e['verified']),
            None
        )
        
    except Exception as e:
        return jsonify({
            'error': 'Failed to fetch profile',
            'message': str(e)
        }), 400
    
    github_id = str(profile.get('id'))
    email = primary_email or profile.get('email')
    
    if not github_id or not email:
        return jsonify({
            'error': 'Missing profile information'
        }), 400
    
    # Trouver ou créer user
    user = User.find_by_oauth('github', github_id)
    
    if not user:
        user = User.find_by_email(email)
        
        if user:
            user.oauth_provider = 'github'
            user.oauth_provider_id = github_id
            user.email_verified = True
            user.avatar_url = profile.get('avatar_url')
            db.session.commit()
        else:
            profile_data = {
                'given_name': profile.get('name', '').split()[0] if profile.get('name') else None,
                'family_name': ' '.join(profile.get('name', '').split()[1:]) if profile.get('name') and len(profile.get('name').split()) > 1 else None,
                'picture': profile.get('avatar_url')
            }
            
            user = User.create_from_oauth(
                provider='github',
                provider_id=github_id,
                email=email,
                profile_data=profile_data
            )
            user.bio = profile.get('bio')
            user.save()
    
    user.oauth_access_token = token.get('access_token')
    user.update_last_login()
    db.session.commit()
    
    tokens = generate_tokens(user)
    
    return jsonify({
        'message': 'Logged in with GitHub successfully',
        'user': user.to_dict(include_email=True),
        'tokens': tokens
    }), 200


═══ ENREGISTRER LE BLUEPRINT ═══

# Dans app/__init__.py

def register_blueprints(app):
    # ... autres blueprints ...
    
    from app.api.v1.oauth import oauth_bp
    app.register_blueprint(oauth_bp, url_prefix='/api/v1/auth')


═══ FRONTEND - BOUTONS OAUTH ═══

<!-- HTML -->
<button onclick="loginWithGoogle()">
  <img src="google-logo.svg" alt="Google" />
  Se connecter avec Google
</button>

<button onclick="loginWithGitHub()">
  <img src="github-logo.svg" alt="GitHub" />
  Se connecter avec GitHub
</button>

<script>
function loginWithGoogle() {
  // Rediriger vers l'endpoint OAuth
  window.location.href = 'http://localhost:5000/api/v1/auth/google/login';
}

function loginWithGitHub() {
  window.location.href = 'http://localhost:5000/api/v1/auth/github/login';
}

// Récupérer le token après callback (si query param)
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');

if (token) {
  // Stocker le token
  localStorage.setItem('access_token', token);
  
  // Rediriger vers dashboard
  window.location.href = '/dashboard';
}
</script>


═══════════════════════════════════════════════════════════════════════════════
  5.5 RBAC (ROLE-BASED ACCESS CONTROL) - SYSTÈME AVANCÉ
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI RBAC?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RBAC = Contrôle d'accès basé sur les rôles

Simple RBAC (ce qu'on a fait jusqu'ici):
  • Utilisateur a UN rôle (user, editor, admin)
  • Rôle détermine les permissions
  • Limité et rigide

RBAC Avancé (ce qu'on va faire):
  • Rôles multiples par utilisateur
  • Permissions granulaires
  • Hiérarchie de rôles
  • Permissions dynamiques
  • Flexible et scalable


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                      ARCHITECTURE RBAC AVANCÉ                       ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  USER                                                                   │
│   │                                                                     │
│   │ has many                                                            │
│   │                                                                     │
│   [BLACK_DOWN-POINTING_TRIANGLE]                                                                     │
│  USER_ROLES (table de liaison)                                          │
│   │                                                                     │
│   │ belongs to                                                          │
│   │                                                                     │
│   [BLACK_DOWN-POINTING_TRIANGLE]                                                                     │
│  ROLE (Admin, Editor, Moderator, ...)                                   │
│   │                                                                     │
│   │ has many                                                            │
│   │                                                                     │
│   [BLACK_DOWN-POINTING_TRIANGLE]                                                                     │
│  ROLE_PERMISSIONS (table de liaison)                                    │
│   │                                                                     │
│   │ belongs to                                                          │
│   │                                                                     │
│   [BLACK_DOWN-POINTING_TRIANGLE]                                                                     │
│  PERMISSION (users:read, users:write, posts:delete, ...)                │
│                                                                         │
│  ═══════════════════════════════════════════════════════════════════   │
│                                                                         │
│  EXEMPLE:                                                               │
│                                                                         │
│  User "Alice"                                                           │
│    ├─ Role "Editor"                                                     │
│    │    ├─ Permission "posts:read"                                      │
│    │    ├─ Permission "posts:write"                                     │
│    │    └─ Permission "posts:publish"                                   │
│    │                                                                     │
│    └─ Role "Moderator"                                                  │
│         ├─ Permission "comments:read"                                   │
│         ├─ Permission "comments:delete"                                 │
│         └─ Permission "users:ban"                                       │
│                                                                         │
│  Donc Alice peut:                                                       │
│    [OK] Créer et publier des posts                                        │
│    [OK] Modérer les commentaires                                          │
│    [OK] Bannir des utilisateurs                                           │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘


═══ FICHIER app/models/rbac.py - MODÈLES RBAC ═══

"""
Modèles pour RBAC (Role-Based Access Control)
"""

from app import db
from app.models.base import BaseModel


# ═══ TABLES DE LIAISON ═══

user_roles = db.Table('user_roles',
    db.Column('user_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
    db.Column('role_id', db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
    db.Column('created_at', db.DateTime, nullable=False, default=db.func.now())
)

role_permissions = db.Table('role_permissions',
    db.Column('role_id', db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
    db.Column('permission_id', db.Integer, db.ForeignKey('permissions.id', ondelete='CASCADE'), primary_key=True),
    db.Column('created_at', db.DateTime, nullable=False, default=db.func.now())
)


# ═══ MODÈLE ROLE ═══

class Role(BaseModel):
    """
    Modèle Role
    
    Représente un rôle dans le système (Admin, Editor, etc.)
    """
    
    __tablename__ = 'roles'
    
    name = db.Column(
        db.String(50),
        unique=True,
        nullable=False,
        index=True
    )
    # Exemples: 'admin', 'editor', 'moderator', 'premium_user'
    
    description = db.Column(
        db.String(255),
        nullable=True
    )
    
    # Hiérarchie de rôles (optionnel)
    level = db.Column(
        db.Integer,
        nullable=False,
        default=0
    )
    # level 0 = user, 1 = moderator, 2 = editor, 3 = admin
    
    # ═══ RELATIONS ═══
    
    # Many-to-many avec User
    users = db.relationship(
        'User',
        secondary=user_roles,
        backref=db.backref('roles', lazy='dynamic')
    )
    
    # Many-to-many avec Permission
    permissions = db.relationship(
        'Permission',
        secondary=role_permissions,
        backref=db.backref('roles', lazy='dynamic'),
        lazy='dynamic'
    )
    
    # ═══ MÉTHODES ═══
    
    @classmethod
    def find_by_name(cls, name):
        """Trouve un rôle par son nom"""
        return cls.query.filter_by(name=name).first()
    
    @classmethod
    def get_or_create(cls, name, description=None, level=0):
        """Trouve ou crée un rôle"""
        role = cls.find_by_name(name)
        if not role:
            role = cls(name=name, description=description, level=level)
            role.save()
        return role
    
    def add_permission(self, permission):
        """Ajoute une permission au rôle"""
        if not self.has_permission(permission):
            self.permissions.append(permission)
            db.session.commit()
    
    def remove_permission(self, permission):
        """Retire une permission du rôle"""
        if self.has_permission(permission):
            self.permissions.remove(permission)
            db.session.commit()
    
    def has_permission(self, permission):
        """Vérifie si le rôle a une permission"""
        if isinstance(permission, str):
            permission = Permission.find_by_name(permission)
        
        if not permission:
            return False
        
        return self.permissions.filter_by(id=permission.id).count() > 0
    
    def get_all_permissions(self):
        """Retourne toutes les permissions du rôle"""
        return self.permissions.all()
    
    def to_dict(self):
        return {
            'id': self.id,
            'name': self.name,
            'description': self.description,
            'level': self.level,
            'permissions': [p.name for p in self.get_all_permissions()],
            'users_count': len(self.users),
            'created_at': self.created_at.isoformat() + 'Z'
        }
    
    def __repr__(self):
        return f'<Role {self.name}>'


# ═══ MODÈLE PERMISSION ═══

class Permission(BaseModel):
    """
    Modèle Permission
    
    Représente une permission granulaire (users:read, posts:delete, etc.)
    """
    
    __tablename__ = 'permissions'
    
    name = db.Column(
        db.String(100),
        unique=True,
        nullable=False,
        index=True
    )
    # Format: "resource:action"
    # Exemples: 'users:read', 'users:write', 'posts:delete', 'comments:moderate'
    
    description = db.Column(
        db.String(255),
        nullable=True
    )
    
    # Catégorie de la permission (pour organisation)
    category = db.Column(
        db.String(50),
        nullable=True,
        index=True
    )
    # Exemples: 'users', 'posts', 'comments', 'system'
    
    # ═══ MÉTHODES ═══
    
    @classmethod
    def find_by_name(cls, name):
        """Trouve une permission par son nom"""
        return cls.query.filter_by(name=name).first()
    
    @classmethod
    def get_or_create(cls, name, description=None, category=None):
        """Trouve ou crée une permission"""
        permission = cls.find_by_name(name)
        if not permission:
            permission = cls(
                name=name,
                description=description,
                category=category or name.split(':')[0]
            )
            permission.save()
        return permission
    
    @classmethod
    def get_by_category(cls, category):
        """Récupère toutes les permissions d'une catégorie"""
        return cls.query.filter_by(category=category).all()
    
    def to_dict(self):
        return {
            'id': self.id,
            'name': self.name,
            'description': self.description,
            'category': self.category,
            'created_at': self.created_at.isoformat() + 'Z'
        }
    
    def __repr__(self):
        return f'<Permission {self.name}>'


═══ FICHIER app/models/user.py - MÉTHODES RBAC ═══

# Ajouter à la classe User

class User(BaseModel):
    # ... champs existants ...
    
    # ═══ RELATIONS RBAC ═══
    # Relation many-to-many avec Role définie dans Role avec backref
    # Accessible via: user.roles
    
    # ═══ MÉTHODES RBAC ═══
    
    def add_role(self, role):
        """
        Ajoute un rôle à l'utilisateur
        
        Args:
            role (Role|str): Rôle à ajouter (objet ou nom)
        """
        if isinstance(role, str):
            role = Role.find_by_name(role)
        
        if role and not self.has_role(role):
            self.roles.append(role)
            db.session.commit()
    
    def remove_role(self, role):
        """Retire un rôle de l'utilisateur"""
        if isinstance(role, str):
            role = Role.find_by_name(role)
        
        if role and self.has_role(role):
            self.roles.remove(role)
            db.session.commit()
    
    def has_role(self, role):
        """
        Vérifie si l'utilisateur a un rôle
        
        Args:
            role (Role|str): Rôle à vérifier
        
        Returns:
            bool: True si l'utilisateur a le rôle
        """
        if isinstance(role, str):
            role = Role.find_by_name(role)
        
        if not role:
            return False
        
        return self.roles.filter_by(id=role.id).count() > 0
    
    def has_any_role(self, *roles):
        """
        Vérifie si l'utilisateur a au moins un des rôles
        
        Args:
            *roles: Liste de rôles (str ou Role)
        
        Returns:
            bool: True si l'utilisateur a au moins un rôle
        
        Usage:
            if user.has_any_role('admin', 'editor'):
                # User is admin OR editor
        """
        return any(self.has_role(role) for role in roles)
    
    def has_all_roles(self, *roles):
        """
        Vérifie si l'utilisateur a tous les rôles
        
        Args:
            *roles: Liste de rôles
        
        Returns:
            bool: True si l'utilisateur a tous les rôles
        """
        return all(self.has_role(role) for role in roles)
    
    def has_permission(self, permission):
        """
        Vérifie si l'utilisateur a une permission
        
        Args:
            permission (Permission|str): Permission à vérifier
        
        Returns:
            bool: True si l'utilisateur a la permission
        
        Usage:
            if user.has_permission('posts:delete'):
                # User can delete posts
        """
        if isinstance(permission, str):
            permission = Permission.find_by_name(permission)
        
        if not permission:
            return False
        
        # Vérifier si un des rôles de l'utilisateur a la permission
        for role in self.roles.all():
            if role.has_permission(permission):
                return True
        
        return False
    
    def has_any_permission(self, *permissions):
        """
        Vérifie si l'utilisateur a au moins une des permissions
        
        Usage:
            if user.has_any_permission('posts:write', 'posts:publish'):
                # User can write OR publish
        """
        return any(self.has_permission(perm) for perm in permissions)
    
    def has_all_permissions(self, *permissions):
        """
        Vérifie si l'utilisateur a toutes les permissions
        
        Usage:
            if user.has_all_permissions('posts:write', 'posts:publish'):
                # User can write AND publish
        """
        return all(self.has_permission(perm) for perm in permissions)
    
    def get_all_permissions(self):
        """
        Récupère toutes les permissions de l'utilisateur
        
        Returns:
            set: Ensemble de noms de permissions
        """
        permissions = set()
        
        for role in self.roles.all():
            for permission in role.get_all_permissions():
                permissions.add(permission.name)
        
        return permissions
    
    def get_all_roles(self):
        """Récupère tous les rôles de l'utilisateur"""
        return self.roles.all()
    
    def get_highest_role_level(self):
        """Récupère le niveau du rôle le plus élevé"""
        roles = self.get_all_roles()
        if not roles:
            return 0
        return max(role.level for role in roles)


═══ FICHIER app/utils/decorators.py - DÉCORATEURS RBAC ═══

"""
Décorateurs pour RBAC
"""

from functools import wraps
from flask import jsonify
from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity

from app.models.user import User
from app.models.rbac import Role, Permission


def require_role(*roles):
    """
    Décorateur pour vérifier que l'utilisateur a au moins un des rôles
    
    Args:
        *roles: Liste de noms de rôles
    
    Usage:
        @app.route('/api/admin/dashboard')
        @jwt_required()
        @require_role('admin')
        def admin_dashboard():
            return jsonify({'message': 'Admin dashboard'})
        
        @app.route('/api/content/publish')
        @jwt_required()
        @require_role('admin', 'editor')
        def publish_content():
            return jsonify({'message': 'Content published'})
    """
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            verify_jwt_in_request()
            
            user_id = get_jwt_identity()
            user = User.query.get(user_id)
            
            if not user or not user.active:
                return jsonify({
                    'error': 'Unauthorized',
                    'message': 'User not found or inactive'
                }), 401
            
            if not user.has_any_role(*roles):
                return jsonify({
                    'error': 'Forbidden',
                    'message': f'One of these roles required: {", ".join(roles)}',
                    'your_roles': [r.name for r in user.get_all_roles()]
                }), 403
            
            return fn(*args, **kwargs)
        
        return wrapper
    return decorator


def require_permission(*permissions):
    """
    Décorateur pour vérifier que l'utilisateur a au moins une des permissions
    
    Args:
        *permissions: Liste de noms de permissions
    
    Usage:
        @app.route('/api/users/<int:user_id>', methods=['DELETE'])
        @jwt_required()
        @require_permission('users:delete')
        def delete_user(user_id):
            return jsonify({'message': 'User deleted'})
        
        @app.route('/api/posts/<int:post_id>/publish', methods=['POST'])
        @jwt_required()
        @require_permission('posts:publish', 'posts:admin')
        def publish_post(post_id):
            return jsonify({'message': 'Post published'})
    """
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            verify_jwt_in_request()
            
            user_id = get_jwt_identity()
            user = User.query.get(user_id)
            
            if not user or not user.active:
                return jsonify({
                    'error': 'Unauthorized',
                    'message': 'User not found or inactive'
                }), 401
            
            if not user.has_any_permission(*permissions):
                return jsonify({
                    'error': 'Forbidden',
                    'message': f'One of these permissions required: {", ".join(permissions)}',
                    'your_permissions': list(user.get_all_permissions())
                }), 403
            
            return fn(*args, **kwargs)
        
        return wrapper
    return decorator


def require_all_permissions(*permissions):
    """
    Décorateur pour vérifier que l'utilisateur a TOUTES les permissions
    
    Usage:
        @app.route('/api/system/reset', methods=['POST'])
        @jwt_required()
        @require_all_permissions('system:admin', 'system:reset')
        def reset_system():
            return jsonify({'message': 'System reset'})
    """
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            verify_jwt_in_request()
            
            user_id = get_jwt_identity()
            user = User.query.get(user_id)
            
            if not user or not user.active:
                return jsonify({
                    'error': 'Unauthorized',
                    'message': 'User not found or inactive'
                }), 401
            
            if not user.has_all_permissions(*permissions):
                missing = [p for p in permissions if not user.has_permission(p)]
                return jsonify({
                    'error': 'Forbidden',
                    'message': f'All of these permissions required: {", ".join(permissions)}',
                    'missing_permissions': missing
                }), 403
            
            return fn(*args, **kwargs)
        
        return wrapper
    return decorator


def require_role_level(min_level):
    """
    Décorateur pour vérifier le niveau de rôle minimum
    
    Args:
        min_level (int): Niveau minimum requis
    
    Usage:
        @app.route('/api/moderation/actions')
        @jwt_required()
        @require_role_level(2)  # Niveau 2 minimum
        def moderation_actions():
            return jsonify({'message': 'Moderation panel'})
    """
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            verify_jwt_in_request()
            
            user_id = get_jwt_identity()
            user = User.query.get(user_id)
            
            if not user or not user.active:
                return jsonify({
                    'error': 'Unauthorized',
                    'message': 'User not found or inactive'
                }), 401
            
            user_level = user.get_highest_role_level()
            
            if user_level < min_level:
                return jsonify({
                    'error': 'Forbidden',
                    'message': f'Role level {min_level} or higher required',
                    'your_level': user_level
                }), 403
            
            return fn(*args, **kwargs)
        
        return wrapper
    return decorator


═══════════════════════════════════════════════════════════════════════════════
  PARTIE 6: SÉCURITÉ AVANCÉE
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI la sécurité est-elle CRITIQUE?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une seule vulnérabilité peut entraîner:
  [X] Vol de données utilisateurs (emails, mots de passe)
  [X] Attaques par déni de service (DDoS)
  [X] Injection de code malveillant
  [X] Perte de confiance des utilisateurs
  [X] Amendes légales (RGPD)
  [X] Fermeture du service

La sécurité doit être pensée DÈS LE DÉBUT, pas ajoutée après coup.


═══════════════════════════════════════════════════════════════════════════════
  6.1 CORS (CROSS-ORIGIN RESOURCE SHARING) - EN PROFONDEUR
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI CORS?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORS = Mécanisme de sécurité des navigateurs

Sans CORS:
  • Un site malveillant pourrait appeler votre API
  • Voler les données de vos utilisateurs
  • Effectuer des actions en leur nom

Avec CORS:
  [OK] Seulement les domaines autorisés peuvent appeler l'API
  [OK] Protection contre les attaques CSRF
  [OK] Contrôle granulaire des permissions

[?] COMMENT fonctionne CORS?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         REQUÊTE CORS SIMPLE                         ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

Une requête est "simple" si:
  • Méthode: GET, HEAD, ou POST
  • Headers: Accept, Accept-Language, Content-Language, Content-Type
  • Content-Type: application/x-www-form-urlencoded, multipart/form-data, text/plain

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  BROWSER                           API SERVER                           │
│  (myapp.com)                      (api.example.com)                     │
│     │                                   │                               │
│     │  GET /api/users                   │                               │
│     │  Origin: https://myapp.com        │                               │
│     │──────────────────────────────────>│                               │
│     │                                   │                               │
│     │                                   │ Vérifier si origine autorisée │
│     │                                   │                               │
│     │  200 OK                           │                               │
│     │  Access-Control-Allow-Origin:     │                               │
│     │    https://myapp.com              │                               │
│     │<──────────────────────────────────│                               │
│     │                                   │                               │
│  [OK] Requête autorisée                   │                               │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                   REQUÊTE CORS AVEC PREFLIGHT                       ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

Une requête nécessite preflight si:
  • Méthode: PUT, DELETE, PATCH
  • Headers personnalisés (Authorization, X-Custom-Header)
  • Content-Type: application/json

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  BROWSER                           API SERVER                           │
│  (myapp.com)                      (api.example.com)                     │
│     │                                   │                               │
│     │  1⃣ PREFLIGHT REQUEST             │                               │
│     │                                   │                               │
│     │  OPTIONS /api/users               │                               │
│     │  Origin: https://myapp.com        │                               │
│     │  Access-Control-Request-Method: DELETE                            │
│     │  Access-Control-Request-Headers: Authorization                    │
│     │──────────────────────────────────>│                               │
│     │                                   │                               │
│     │                                   │ Vérifier permissions          │
│     │                                   │                               │
│     │  204 No Content                   │                               │
│     │  Access-Control-Allow-Origin:     │                               │
│     │    https://myapp.com              │                               │
│     │  Access-Control-Allow-Methods:    │                               │
│     │    GET, POST, DELETE              │                               │
│     │  Access-Control-Allow-Headers:    │                               │
│     │    Authorization                  │                               │
│     │  Access-Control-Max-Age: 86400    │ <- Cache 24h                   │
│     │<──────────────────────────────────│                               │
│     │                                   │                               │
│  [OK] Preflight OK                        │                               │
│                                                                         │
│     │  2⃣ REQUÊTE RÉELLE                 │                               │
│     │                                   │                               │
│     │  DELETE /api/users/123            │                               │
│     │  Origin: https://myapp.com        │                               │
│     │  Authorization: Bearer token...   │                               │
│     │──────────────────────────────────>│                               │
│     │                                   │                               │
│     │  200 OK                           │                               │
│     │  Access-Control-Allow-Origin:     │                               │
│     │    https://myapp.com              │                               │
│     │<──────────────────────────────────│                               │
│     │                                   │                               │
│  [OK] Utilisateur supprimé                │                               │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘


═══ IMPLÉMENTATION CORS SÉCURISÉE ═══

"""
Configuration CORS sécurisée pour Flask
"""

from flask_cors import CORS
from flask import request, jsonify


# ═══ OPTION 1: Configuration Simple (Développement) ═══

def configure_cors_dev(app):
    """
    Configuration CORS permissive pour développement
    
    [ATTENTION] NE JAMAIS UTILISER EN PRODUCTION
    """
    CORS(
        app,
        origins='*',  # [ATTENTION] Accepte toutes les origines
        methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
        allow_headers='*',
        supports_credentials=False
    )


# ═══ OPTION 2: Configuration Sécurisée (Production) ═══

def configure_cors_production(app):
    """
    Configuration CORS sécurisée pour production
    
    [OK] Origines spécifiques uniquement
    [OK] Méthodes limitées
    [OK] Headers contrôlés
    [OK] Credentials autorisés
    """
    
    # Liste blanche des origines autorisées
    allowed_origins = [
        'https://myapp.com',
        'https://www.myapp.com',
        'https://app.myapp.com',
        # Staging
        'https://staging.myapp.com',
    ]
    
    # En développement local, ajouter localhost
    if app.config['DEBUG']:
        allowed_origins.extend([
            'http://localhost:3000',
            'http://localhost:5173',
            'http://localhost:8080',
            'http://127.0.0.1:3000'
        ])
    
    CORS(
        app,
        origins=allowed_origins,
        methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'],
        allow_headers=[
            'Content-Type',
            'Authorization',
            'X-Requested-With',
            'X-Request-ID'
        ],
        expose_headers=[
            'X-Total-Count',
            'X-Page',
            'X-Per-Page',
            'X-RateLimit-Limit',
            'X-RateLimit-Remaining',
            'X-RateLimit-Reset'
        ],
        supports_credentials=True,  # Autoriser cookies/credentials
        max_age=86400  # Cache preflight 24h
    )


# ═══ OPTION 3: Configuration Dynamique (Avancée) ═══

def configure_cors_dynamic(app):
    """
    Configuration CORS dynamique avec validation personnalisée
    
    Permet un contrôle fin par route
    """
    
    def check_origin(origin):
        """
        Valide l'origine de la requête
        
        Returns:
            bool: True si origine autorisée
        """
        # Liste des origines autorisées
        allowed = [
            'https://myapp.com',
            'https://www.myapp.com',
        ]
        
        # En dev, autoriser localhost
        if app.config['DEBUG'] and origin and origin.startswith('http://localhost'):
            return True
        
        # Vérifier si origine exactement dans la liste
        if origin in allowed:
            return True
        
        # Pattern matching pour sous-domaines
        # Exemple: *.myapp.com
        if origin and origin.endswith('.myapp.com'):
            return True
        
        return False
    
    @app.after_request
    def add_cors_headers(response):
        """
        Ajoute les headers CORS après chaque requête
        """
        origin = request.headers.get('Origin')
        
        # Vérifier si l'origine est autorisée
        if check_origin(origin):
            response.headers['Access-Control-Allow-Origin'] = origin
            response.headers['Access-Control-Allow-Credentials'] = 'true'
            response.headers['Access-Control-Allow-Methods'] = \
                'GET, POST, PUT, PATCH, DELETE, OPTIONS'
            response.headers['Access-Control-Allow-Headers'] = \
                'Content-Type, Authorization, X-Requested-With'
            response.headers['Access-Control-Max-Age'] = '86400'
        
        return response
    
    @app.route('/cors-test', methods=['OPTIONS'])
    def cors_preflight():
        """
        Endpoint pour tester CORS
        """
        return '', 204


# ═══ CORS PAR ROUTE (Granulaire) ═══

from flask_cors import cross_origin

@app.route('/api/public/data')
@cross_origin(origins='*')  # Public, toutes origines
def public_data():
    return jsonify({'data': 'public'})


@app.route('/api/private/data')
@cross_origin(
    origins=['https://myapp.com'],
    supports_credentials=True
)
def private_data():
    return jsonify({'data': 'private'})


# ═══ VÉRIFICATION MANUELLE DE L'ORIGINE ═══

def verify_origin():
    """
    Vérifie manuellement l'origine de la requête
    
    Usage dans une route:
        if not verify_origin():
            return jsonify({'error': 'Forbidden origin'}), 403
    """
    origin = request.headers.get('Origin')
    
    allowed_origins = [
        'https://myapp.com',
        'https://www.myapp.com'
    ]
    
    return origin in allowed_origins


═══════════════════════════════════════════════════════════════════════════════
  6.2 CSRF (CROSS-SITE REQUEST FORGERY) PROTECTION
═══════════════════════════════════════════════════════════════════════════════

[?] QU'EST-CE QUE CSRF?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CSRF = Attaque qui force un utilisateur à exécuter des actions non désirées

Exemple d'attaque:
  1. Tu es connecté à mybank.com
  2. Tu visites evilsite.com
  3. evilsite.com contient: <img src="https://mybank.com/transfer?to=hacker&amount=1000">
  4. Ton navigateur envoie automatiquement tes cookies de session
  5. L'argent est transféré! [ARGENT]

[?] COMMENT se protéger contre CSRF?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                     PROTECTION CSRF AVEC TOKENS                     ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

Méthode 1: Double Submit Cookie
Méthode 2: Synchronizer Token Pattern
Méthode 3: SameSite Cookie Attribute


═══ MÉTHODE 1: APIs RESTful avec JWT (PAS DE CSRF!) ═══

"""
Les APIs RESTful utilisant JWT ne sont PAS vulnérables à CSRF

POURQUOI?
  • JWT stocké dans localStorage/sessionStorage (pas dans cookies)
  • JWT envoyé manuellement via header Authorization
  • Pas d'envoi automatique par le navigateur
  • Les sites malveillants ne peuvent pas lire localStorage d'un autre domaine

DONC: Si vous utilisez JWT comme on l'a fait, vous êtes protégé! [OK]
"""


═══ MÉTHODE 2: Session-Based Auth (NÉCESSITE CSRF PROTECTION) ═══

from flask_wtf.csrf import CSRFProtect, generate_csrf
from flask import jsonify

# Initialiser CSRF protection
csrf = CSRFProtect()

def init_csrf_protection(app):
    """
    Active la protection CSRF pour les sessions
    """
    csrf.init_app(app)
    
    # Configuration
    app.config['WTF_CSRF_ENABLED'] = True
    app.config['WTF_CSRF_TIME_LIMIT'] = 3600  # 1 heure
    app.config['WTF_CSRF_SSL_STRICT'] = True  # HTTPS uniquement en prod
    
    @app.route('/csrf-token', methods=['GET'])
    def get_csrf_token():
        """
        Endpoint pour obtenir un token CSRF
        
        Le frontend doit appeler cet endpoint avant de faire
        des requêtes POST/PUT/DELETE
        """
        token = generate_csrf()
        return jsonify({'csrf_token': token})
    
    @app.errorhandler(400)
    def csrf_error(reason):
        """Gérer les erreurs CSRF"""
        return jsonify({
            'error': 'CSRF Validation Failed',
            'message': str(reason)
        }), 400


# Frontend (JavaScript):
"""
// 1. Obtenir le token CSRF
const response = await fetch('/csrf-token');
const data = await response.json();
const csrfToken = data.csrf_token;

// 2. L'inclure dans chaque requête POST/PUT/DELETE
await fetch('/api/users', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrfToken
    },
    body: JSON.stringify(userData)
});
"""


═══ MÉTHODE 3: SameSite Cookie Attribute ═══

"""
Attribut SameSite pour les cookies de session

Configure le navigateur à ne PAS envoyer les cookies
dans les requêtes cross-site
"""

app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# Valeurs possibles:
#   'Strict': Cookies jamais envoyés en cross-site (plus sécurisé)
#   'Lax': Cookies envoyés seulement pour navigation GET (recommandé)
#   'None': Cookies toujours envoyés (nécessite Secure=True)

app.config['SESSION_COOKIE_SECURE'] = True  # HTTPS uniquement
app.config['SESSION_COOKIE_HTTPONLY'] = True  # Pas accessible via JS


═══════════════════════════════════════════════════════════════════════════════
  6.3 XSS (CROSS-SITE SCRIPTING) PREVENTION
═══════════════════════════════════════════════════════════════════════════════

[?] QU'EST-CE QUE XSS?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

XSS = Injection de JavaScript malveillant dans votre site

Types de XSS:
  1. Stored XSS: Script stocké dans la DB (commentaires, profils)
  2. Reflected XSS: Script dans l'URL (paramètres de recherche)
  3. DOM-based XSS: Script modifie le DOM côté client

Exemple d'attaque Stored XSS:
  1. Attaquant poste un commentaire: <script>steal_cookies()</script>
  2. Commentaire stocké dans la DB
  3. Tous les visiteurs exécutent le script
  4. Cookies/tokens volés! [COOKIE]

[?] COMMENT se protéger contre XSS?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


═══ PROTECTION 1: OUTPUT ENCODING (Automatique avec Flask) ═══

"""
Flask/Jinja2 échappe automatiquement les variables

SÉCURISÉ:
  {{ user.name }}
  -> Si user.name = "<script>alert('XSS')</script>"
  -> Affiché comme: &lt;script&gt;alert('XSS')&lt;/script&gt;
  -> Pas exécuté! [OK]

DANGEREUX (à éviter):
  {{ user.name|safe }}
  -> Désactive l'échappement
  -> Script exécuté! [X]
"""


═══ PROTECTION 2: INPUT VALIDATION & SANITIZATION ═══

import bleach
from markupsafe import escape

def sanitize_html(text, allowed_tags=None, allowed_attributes=None):
    """
    Nettoie le HTML d'entrée utilisateur
    
    Args:
        text (str): Texte à nettoyer
        allowed_tags (list): Tags HTML autorisés
        allowed_attributes (dict): Attributs autorisés par tag
    
    Returns:
        str: Texte nettoyé et sécurisé
    
    Usage:
        # Texte brut uniquement (pas de HTML)
        clean = sanitize_html(user_input, allowed_tags=[])
        
        # HTML simple (gras, italique, liens)
        clean = sanitize_html(
            user_input,
            allowed_tags=['p', 'b', 'i', 'a', 'br'],
            allowed_attributes={'a': ['href', 'title']}
        )
    """
    
    if allowed_tags is None:
        # Par défaut: texte brut seulement
        allowed_tags = []
    
    if allowed_attributes is None:
        allowed_attributes = {}
    
    # Nettoyer avec bleach
    cleaned = bleach.clean(
        text,
        tags=allowed_tags,
        attributes=allowed_attributes,
        strip=True  # Supprimer les tags non autorisés
    )
    
    return cleaned


def escape_user_input(text):
    """
    Échappe tous les caractères HTML spéciaux
    
    Usage:
        safe_text = escape_user_input(user_input)
    """
    return escape(text)


# Exemple dans une route
@app.route('/api/comments', methods=['POST'])
@jwt_required()
def create_comment():
    data = request.json
    content = data.get('content', '')
    
    # OPTION 1: Nettoyer avec tags autorisés
    clean_content = sanitize_html(
        content,
        allowed_tags=['p', 'b', 'i', 'a', 'ul', 'ol', 'li'],
        allowed_attributes={
            'a': ['href', 'title'],
            'img': ['src', 'alt']
        }
    )
    
    # OPTION 2: Texte brut uniquement (plus sécurisé)
    # clean_content = escape_user_input(content)
    
    comment = Comment(
        content=clean_content,
        user_id=get_jwt_identity()
    )
    comment.save()
    
    return jsonify(comment.to_dict()), 201


═══ PROTECTION 3: Content Security Policy (CSP) ═══

@app.after_request
def set_csp_header(response):
    """
    Configure Content Security Policy
    
    CSP indique au navigateur quelles sources sont autorisées
    pour scripts, styles, images, etc.
    """
    
    csp_policy = "; ".join([
        "default-src 'self'",  # Par défaut: seulement même origine
        "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
        "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
        "img-src 'self' data: https:",
        "font-src 'self' https://fonts.gstatic.com",
        "connect-src 'self' https://api.example.com",
        "frame-ancestors 'none'",  # Empêche iframes
        "base-uri 'self'",
        "form-action 'self'"
    ])
    
    response.headers['Content-Security-Policy'] = csp_policy
    
    return response


═══ PROTECTION 4: HttpOnly & Secure Cookies ═══

# Si vous utilisez des cookies (sessions)
app.config['SESSION_COOKIE_HTTPONLY'] = True  # JS ne peut pas lire
app.config['SESSION_COOKIE_SECURE'] = True    # HTTPS uniquement
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'  # Protection CSRF


═══ VALIDATION PYDANTIC CONTRE XSS ═══

from pydantic import BaseModel, validator
import re

class CommentCreate(BaseModel):
    content: str
    
    @validator('content')
    def validate_no_scripts(cls, v):
        """
        Valide qu'il n'y a pas de tags script
        """
        # Détecter les tags script
        if re.search(r'<script[^>]*>.*?</script>', v, re.IGNORECASE | re.DOTALL):
            raise ValueError('Script tags are not allowed')
        
        # Détecter les événements JavaScript inline
        if re.search(r'on\w+\s*=', v, re.IGNORECASE):
            raise ValueError('JavaScript event handlers are not allowed')
        
        return v


═══════════════════════════════════════════════════════════════════════════════
  6.4 SQL INJECTION PREVENTION
═══════════════════════════════════════════════════════════════════════════════

[?] QU'EST-CE QUE SQL INJECTION?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SQL Injection = Injection de code SQL malveillant

Exemple d'attaque:
  # Code vulnérable
  username = request.args.get('username')
  query = f"SELECT * FROM users WHERE username = '{username}'"
  
  # Attaque
  username = "admin' OR '1'='1"
  query = "SELECT * FROM users WHERE username = 'admin' OR '1'='1'"
  # Retourne TOUS les utilisateurs! [X]

[?] COMMENT se protéger contre SQL Injection?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


═══ PROTECTION: UTILISER UN ORM (SQLAlchemy) ═══

"""
SQLAlchemy utilise des paramètres préparés (prepared statements)
qui séparent le code SQL des données

SÉCURISÉ [OK] (ce qu'on utilise)
"""

# [OK] CORRECT: SQLAlchemy protège automatiquement
username = request.args.get('username')
user = User.query.filter_by(username=username).first()

# [OK] CORRECT: Paramètres liés
user_id = request.args.get('user_id')
user = User.query.filter(User.id == user_id).first()

# [OK] CORRECT: Requêtes complexes avec paramètres
search = request.args.get('q')
users = User.query.filter(
    db.or_(
        User.username.ilike(f'%{search}%'),
        User.email.ilike(f'%{search}%')
    )
).all()


"""
DANGEREUX [X] (à ÉVITER)
"""

# [X] DANGEREUX: SQL brut avec f-string
username = request.args.get('username')
query = f"SELECT * FROM users WHERE username = '{username}'"
result = db.session.execute(query)

# [X] DANGEREUX: Concaténation de strings
query = "SELECT * FROM users WHERE id = " + user_id
result = db.session.execute(query)


═══ SI VOUS DEVEZ utiliser du SQL brut (rare) ═══

from sqlalchemy import text

# [OK] CORRECT: Utiliser des paramètres bindés
user_id = request.args.get('user_id')

query = text("SELECT * FROM users WHERE id = :user_id")
result = db.session.execute(query, {'user_id': user_id})

# [OK] CORRECT: Avec plusieurs paramètres
query = text("""
    SELECT * FROM users 
    WHERE username = :username 
    AND email = :email
""")
result = db.session.execute(query, {
    'username': username,
    'email': email
})


═══ VALIDATION SUPPLÉMENTAIRE ═══

def validate_sql_safe(value):
    """
    Valide qu'une valeur ne contient pas de caractères SQL dangereux
    
    Usage:
        user_id = request.args.get('id')
        if not validate_sql_safe(user_id):
            return jsonify({'error': 'Invalid input'}), 400
    """
    # Caractères SQL dangereux
    dangerous = ["'", '"', ';', '--', '/*', '*/', 'xp_', 'sp_']
    
    for char in dangerous:
        if char in str(value):
            return False
    
    return True


═══════════════════════════════════════════════════════════════════════════════
  6.5 RATE LIMITING - PROTECTION CONTRE ABUS
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI Rate Limiting?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Rate Limiting = Limiter le nombre de requêtes par unité de temps

Protège contre:
  [OK] Brute force (tentatives de login)
  [OK] DDoS (déni de service)
  [OK] Scraping abusif
  [OK] Abus d'API
  [OK] Coûts serveur excessifs

[?] COMMENT implémenter Rate Limiting?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


═══ INSTALLATION ═══

pip install Flask-Limiter redis


═══ CONFIGURATION ═══

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

# Initialiser le limiter
limiter = Limiter(
    app=app,
    key_func=get_remote_address,  # Limite par IP
    storage_uri="redis://localhost:6379",  # Stockage Redis
    storage_options={"socket_connect_timeout": 30},
    strategy="fixed-window",  # Stratégie de comptage
    default_limits=["1000 per day", "100 per hour"]  # Limites par défaut
)


═══ STRATÉGIES DE RATE LIMITING ═══

"""
1. FIXED WINDOW
   • Fenêtre fixe (ex: 100 req/heure de 10h à 11h)
   • Simple mais peut avoir des pics
   
2. SLIDING WINDOW
   • Fenêtre glissante (ex: 100 req dans les 60 dernières minutes)
   • Plus lisse mais plus complexe
   
3. TOKEN BUCKET
   • Seau de tokens qui se remplit graduellement
   • Permet des bursts contrôlés
"""

limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    storage_uri="redis://localhost:6379",
    strategy="moving-window",  # Fenêtre glissante (recommandé)
    default_limits=["200 per day", "50 per hour"]
)


═══ UTILISATION PAR ROUTE ═══

# ═══ LIMITE GLOBALE (toutes les routes) ═══
@app.route('/api/data')
def get_data():
    # Limite par défaut: 200/jour, 50/heure
    return jsonify({'data': 'some data'})


# ═══ LIMITE PERSONNALISÉE PAR ROUTE ═══

@app.route('/api/search')
@limiter.limit("30 per minute")  # 30 requêtes par minute
def search():
    query = request.args.get('q')
    results = perform_search(query)
    return jsonify(results)


# ═══ LIMITES MULTIPLES ═══

@app.route('/api/expensive-operation')
@limiter.limit("10 per hour")
@limiter.limit("100 per day")
def expensive_operation():
    # Maximum 10 par heure ET 100 par jour
    result = perform_expensive_operation()
    return jsonify(result)


# ═══ ROUTES SENSIBLES (Login, Register) ═══

@app.route('/api/auth/login', methods=['POST'])
@limiter.limit("5 per minute")  # Très strict contre brute force
@limiter.limit("20 per hour")
def login():
    # Limite stricte pour éviter brute force
    data = request.json
    # ... logique de login
    return jsonify({'token': 'abc123'})


@app.route('/api/auth/register', methods=['POST'])
@limiter.limit("3 per hour")  # Encore plus strict
def register():
    # Éviter création de comptes spam
    data = request.json
    # ... logique de register
    return jsonify({'message': 'User created'})


# ═══ EXEMPTER CERTAINES ROUTES ═══

@app.route('/api/public/info')
@limiter.exempt  # Pas de limite
def public_info():
    return jsonify({'info': 'public data'})


═══ RATE LIMITING PAR UTILISATEUR (JWT) ═══

from flask_jwt_extended import get_jwt_identity

def get_user_id():
    """
    Fonction pour obtenir l'ID utilisateur depuis JWT
    Utilisée comme key_func pour limiter par user au lieu d'IP
    """
    try:
        return get_jwt_identity()
    except:
        return get_remote_address()  # Fallback sur IP


# Créer un limiter séparé pour les users authentifiés
user_limiter = Limiter(
    app=app,
    key_func=get_user_id,
    storage_uri="redis://localhost:6379"
)

@app.route('/api/users/posts', methods=['POST'])
@jwt_required()
@user_limiter.limit("50 per day")  # 50 posts par jour par user
def create_post():
    user_id = get_jwt_identity()
    data = request.json
    # ... créer le post
    return jsonify({'message': 'Post created'}), 201


═══ RATE LIMITING PAR API KEY ═══

def get_api_key():
    """Limite par API key"""
    api_key = request.headers.get('X-API-Key')
    return api_key or get_remote_address()

api_limiter = Limiter(
    app=app,
    key_func=get_api_key,
    storage_uri="redis://localhost:6379"
)

@app.route('/api/v1/data')
@api_limiter.limit("1000 per hour")  # Limite par API key
def get_api_data():
    return jsonify({'data': 'API data'})


═══ HEADERS DE RÉPONSE ═══

"""
Flask-Limiter ajoute automatiquement des headers:

X-RateLimit-Limit: 100        # Limite totale
X-RateLimit-Remaining: 85     # Requêtes restantes
X-RateLimit-Reset: 1702468800 # Timestamp de reset
Retry-After: 3600             # Secondes avant retry (si 429)
"""

@app.after_request
def add_rate_limit_headers(response):
    """
    Ajouter des headers de rate limit personnalisés
    """
    # Les headers sont déjà ajoutés par Flask-Limiter
    # Mais on peut en ajouter d'autres si nécessaire
    return response


═══ GESTION DES ERREURS 429 ═══

@app.errorhandler(429)
def ratelimit_handler(e):
    """
    Gestionnaire personnalisé pour erreurs de rate limit
    """
    return jsonify({
        'error': 'Too Many Requests',
        'message': 'Rate limit exceeded. Please try again later.',
        'retry_after': e.description  # Temps avant retry
    }), 429


═══ RATE LIMITING AVANCÉ: PAR ENDPOINT ET USER ═══

from functools import wraps

def rate_limit_per_user_per_endpoint(limit):
    """
    Décorateur pour limiter par user ET endpoint
    
    Usage:
        @app.route('/api/posts')
        @jwt_required()
        @rate_limit_per_user_per_endpoint("10 per minute")
        def get_posts():
            return jsonify([])
    """
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            try:
                user_id = get_jwt_identity()
                endpoint = request.endpoint
                
                # Clé unique: user_id + endpoint
                key = f"ratelimit:{user_id}:{endpoint}"
                
                # Vérifier et incrémenter avec Redis
                # (implémentation simplifiée)
                
            except Exception as e:
                pass  # Laisser passer si erreur
            
            return f(*args, **kwargs)
        
        return decorated_function
    return decorator


═══ MONITORING DES RATE LIMITS ═══

import redis

redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)

@app.route('/api/admin/rate-limits')
@jwt_required()
@require_role('admin')
def get_rate_limit_stats():
    """
    Dashboard admin pour voir les rate limits
    """
    # Récupérer toutes les clés de rate limit
    keys = redis_client.keys('LIMITER:*')
    
    stats = []
    for key in keys:
        value = redis_client.get(key)
        ttl = redis_client.ttl(key)
        
        stats.append({
            'key': key,
            'count': value,
            'expires_in': ttl
        })
    
    return jsonify({
        'total_keys': len(keys),
        'stats': stats
    }), 200


═══ BYPASS RATE LIMITING POUR TESTS ═══

# Configuration
app.config['RATELIMIT_ENABLED'] = not app.config['TESTING']

# Ou désactiver pour certaines IPs (localhost, tests)
@limiter.request_filter
def ip_whitelist():
    """
    Bypass rate limiting pour certaines IPs
    """
    whitelisted_ips = [
        '127.0.0.1',
        '::1',
        # IPs de test
    ]
    
    return request.remote_addr in whitelisted_ips


═══════════════════════════════════════════════════════════════════════════════
  PARTIE 7: VALIDATION ET SERIALIZATION AVANCÉE
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI la validation est-elle CRITIQUE?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

La validation des données est la PREMIÈRE ligne de défense:
  [OK] Prévient les injections (SQL, XSS)
  [OK] Garantit l'intégrité des données
  [OK] Améliore l'UX (erreurs claires)
  [OK] Réduit les bugs
  [OK] Documentation automatique
  [OK] Type safety

Sans validation:
  [X] Données corrompues dans la DB
  [X] Bugs imprévisibles
  [X] Failles de sécurité
  [X] Mauvaise UX

"Garbage In, Garbage Out" - Validez TOUJOURS les entrées!


═══════════════════════════════════════════════════════════════════════════════
  7.1 PYDANTIC MODELS AVANCÉS
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI Pydantic?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Pydantic est LA référence pour validation en Python moderne:
  [OK] Type hints natifs Python
  [OK] Validation automatique ultra-rapide
  [OK] Excellente intégration FastAPI
  [OK] Messages d'erreur détaillés
  [OK] Génération de JSON Schema
  [OK] Performance (écrit en Rust pour Pydantic v2)


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    PYDANTIC V2 - MODÈLES DE BASE                    ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

from pydantic import BaseModel, Field, EmailStr, HttpUrl, validator, root_validator
from pydantic import constr, conint, confloat, conlist
from typing import Optional, List, Dict, Any
from datetime import datetime, date
from enum import Enum


# ═══ MODÈLE BASIQUE ═══

class UserBase(BaseModel):
    """
    Modèle de base pour un utilisateur
    
    Démontre les types de base et validations simples
    """
    
    # Types de base
    username: str
    email: EmailStr  # Validation email automatique
    age: int
    is_active: bool = True  # Valeur par défaut
    
    # Types optionnels
    bio: Optional[str] = None
    website: Optional[HttpUrl] = None  # Validation URL
    
    # Dates
    birth_date: Optional[date] = None
    created_at: datetime = Field(default_factory=datetime.now)


# ═══ FIELD - CONFIGURATION AVANCÉE ═══

class UserAdvanced(BaseModel):
    """
    Utilisation avancée de Field pour configuration détaillée
    """
    
    username: str = Field(
        ...,  # Required (équivalent à required=True)
        min_length=3,
        max_length=50,
        pattern=r'^[a-zA-Z0-9_-]+$',  # Regex
        description="Nom d'utilisateur unique",
        examples=["john_doe", "alice123"]
    )
    
    email: EmailStr = Field(
        ...,
        description="Adresse email valide",
        examples=["user@example.com"]
    )
    
    password: str = Field(
        ...,
        min_length=8,
        max_length=128,
        description="Mot de passe (min 8 caractères)"
    )
    
    age: int = Field(
        ...,
        ge=13,  # Greater or Equal (>=)
        le=120,  # Less or Equal (<=)
        description="Âge entre 13 et 120 ans"
    )
    
    score: float = Field(
        default=0.0,
        ge=0.0,
        le=100.0,
        description="Score entre 0 et 100"
    )
    
    tags: List[str] = Field(
        default_factory=list,
        max_items=10,
        description="Maximum 10 tags"
    )
    
    # Types contraints (Pydantic v2)
    phone: Optional[str] = Field(
        None,
        pattern=r'^\+?1?\d{9,15}$',
        description="Numéro de téléphone international"
    )


# ═══ TYPES CONTRAINTS ═══

class ProductCreate(BaseModel):
    """
    Démontre l'utilisation de types contraints
    """
    
    # String avec contraintes
    name: constr(min_length=2, max_length=200)
    
    # Integer avec contraintes
    stock: conint(ge=0, le=10000)
    
    # Float avec contraintes
    price: confloat(gt=0.0, le=999999.99)  # gt = Greater Than (>)
    
    # List avec contraintes
    images: conlist(HttpUrl, min_items=1, max_items=10)
    
    # Dict
    metadata: Dict[str, Any] = Field(default_factory=dict)


# ═══ ENUMS POUR CHOIX LIMITÉS ═══

class UserRole(str, Enum):
    """
    Énumération pour les rôles utilisateur
    """
    USER = "user"
    EDITOR = "editor"
    ADMIN = "admin"
    MODERATOR = "moderator"


class PostStatus(str, Enum):
    """
    Statuts possibles d'un post
    """
    DRAFT = "draft"
    PUBLISHED = "published"
    ARCHIVED = "archived"


class UserWithRole(BaseModel):
    """
    Utilisateur avec rôle (enum)
    """
    username: str
    email: EmailStr
    role: UserRole = UserRole.USER  # Valeur par défaut
    
    # Validation automatique: seules les valeurs de l'enum sont acceptées
    # "user", "editor", "admin", "moderator"


class Post(BaseModel):
    """
    Post avec statut
    """
    title: str
    content: str
    status: PostStatus = PostStatus.DRAFT
    author_id: int


# ═══ VALIDATORS PERSONNALISÉS ═══

class UserRegistration(BaseModel):
    """
    Modèle d'inscription avec validations personnalisées
    """
    
    username: str = Field(..., min_length=3, max_length=50)
    email: EmailStr
    password: str = Field(..., min_length=8)
    password_confirm: str
    age: int = Field(..., ge=13)
    terms_accepted: bool
    
    # ═══ FIELD VALIDATOR ═══
    
    @validator('username')
    def username_alphanumeric(cls, v):
        """
        Valide que le username est alphanumérique
        
        Appelé automatiquement lors de la validation
        """
        if not v.replace('_', '').replace('-', '').isalnum():
            raise ValueError(
                'Username must contain only letters, numbers, '
                'underscores and hyphens'
            )
        return v
    
    @validator('password')
    def password_strength(cls, v):
        """
        Valide la force du mot de passe
        """
        if not any(c.isupper() for c in v):
            raise ValueError('Password must contain at least one uppercase letter')
        
        if not any(c.islower() for c in v):
            raise ValueError('Password must contain at least one lowercase letter')
        
        if not any(c.isdigit() for c in v):
            raise ValueError('Password must contain at least one digit')
        
        if not any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in v):
            raise ValueError('Password must contain at least one special character')
        
        return v
    
    @validator('email')
    def email_not_disposable(cls, v):
        """
        Bloque les emails jetables
        """
        disposable_domains = [
            'tempmail.com',
            'throwaway.email',
            '10minutemail.com',
            'guerrillamail.com'
        ]
        
        domain = v.split('@')[1].lower()
        if domain in disposable_domains:
            raise ValueError('Disposable email addresses are not allowed')
        
        return v
    
    # ═══ ROOT VALIDATOR ═══
    
    @root_validator
    def check_passwords_match(cls, values):
        """
        Valide que les mots de passe correspondent
        
        Root validator a accès à TOUS les champs
        """
        password = values.get('password')
        password_confirm = values.get('password_confirm')
        
        if password and password_confirm and password != password_confirm:
            raise ValueError('Passwords do not match')
        
        return values
    
    @root_validator
    def check_terms_accepted(cls, values):
        """
        Valide que les conditions sont acceptées
        """
        if not values.get('terms_accepted'):
            raise ValueError('You must accept the terms and conditions')
        
        return values


# ═══ VALIDATORS AVEC PARAMÈTRES ═══

from typing import Set

class BlogPost(BaseModel):
    """
    Post de blog avec validations avancées
    """
    
    title: str = Field(..., min_length=5, max_length=200)
    content: str = Field(..., min_length=100)
    tags: List[str] = Field(default_factory=list)
    category: str
    
    @validator('title')
    def title_capitalized(cls, v):
        """
        S'assure que le titre commence par une majuscule
        """
        if not v[0].isupper():
            raise ValueError('Title must start with a capital letter')
        return v
    
    @validator('tags')
    def tags_lowercase(cls, v):
        """
        Convertit tous les tags en minuscules
        """
        return [tag.lower().strip() for tag in v]
    
    @validator('tags')
    def tags_unique(cls, v):
        """
        S'assure que les tags sont uniques
        """
        if len(v) != len(set(v)):
            raise ValueError('Tags must be unique')
        return v
    
    @validator('category')
    def category_valid(cls, v):
        """
        Valide que la catégorie existe
        """
        valid_categories = {
            'technology', 'science', 'business',
            'health', 'entertainment', 'sports'
        }
        
        if v.lower() not in valid_categories:
            raise ValueError(
                f'Category must be one of: {", ".join(valid_categories)}'
            )
        
        return v.lower()


# ═══ VALIDATORS PRE vs POST ═══

class UserProfile(BaseModel):
    """
    Démontre les validators pre (avant validation type)
    et post (après validation type)
    """
    
    username: str
    email: str
    age: Optional[int] = None
    
    @validator('username', pre=True)
    def username_strip_whitespace(cls, v):
        """
        PRE validator: s'exécute AVANT la validation de type
        
        Utile pour nettoyer/transformer les données brutes
        """
        if isinstance(v, str):
            return v.strip().lower()
        return v
    
    @validator('email', pre=True)
    def email_normalize(cls, v):
        """
        Normalise l'email avant validation
        """
        if isinstance(v, str):
            return v.strip().lower()
        return v
    
    @validator('age')
    def age_realistic(cls, v):
        """
        POST validator: s'exécute APRÈS la validation de type
        
        v est déjà validé comme int (ou None)
        """
        if v is not None and (v < 0 or v > 150):
            raise ValueError('Age must be between 0 and 150')
        return v


# ═══ VALIDATORS AVEC DÉPENDANCES ═══

class DateRange(BaseModel):
    """
    Démontre les validators avec dépendances entre champs
    """
    
    start_date: date
    end_date: date
    
    @validator('end_date')
    def end_after_start(cls, v, values):
        """
        Validator avec accès aux valeurs précédentes
        
        Args:
            v: Valeur du champ courant (end_date)
            values: Dict des champs déjà validés
        """
        start_date = values.get('start_date')
        
        if start_date and v < start_date:
            raise ValueError('end_date must be after start_date')
        
        return v


class PriceRange(BaseModel):
    """
    Range de prix avec validation
    """
    
    min_price: float = Field(..., ge=0)
    max_price: float = Field(..., ge=0)
    
    @validator('max_price')
    def max_greater_than_min(cls, v, values):
        """
        Valide que max > min
        """
        min_price = values.get('min_price')
        
        if min_price is not None and v <= min_price:
            raise ValueError('max_price must be greater than min_price')
        
        return v


# ═══ VALIDATORS RÉUTILISABLES ═══

def validate_not_empty(v: str) -> str:
    """
    Validator réutilisable pour strings non vides
    """
    if not v or not v.strip():
        raise ValueError('Field cannot be empty')
    return v.strip()


def validate_no_profanity(v: str) -> str:
    """
    Validator réutilisable pour bloquer profanités
    """
    profanity_list = ['badword1', 'badword2']  # Liste réelle plus longue
    
    if any(word in v.lower() for word in profanity_list):
        raise ValueError('Content contains inappropriate language')
    
    return v


class Comment(BaseModel):
    """
    Utilise des validators réutilisables
    """
    
    content: str
    author_name: str
    
    # Appliquer les validators réutilisables
    _validate_content = validator('content', allow_reuse=True)(validate_not_empty)
    _validate_content_profanity = validator('content', allow_reuse=True)(validate_no_profanity)
    _validate_author = validator('author_name', allow_reuse=True)(validate_not_empty)


# ═══ MODEL CONFIG ═══

class User(BaseModel):
    """
    Configuration du modèle avec Config
    """
    
    id: int
    username: str
    email: EmailStr
    created_at: datetime
    
    class Config:
        """
        Configuration Pydantic du modèle
        """
        
        # Permettre la création depuis ORM (SQLAlchemy)
        from_attributes = True  # Pydantic v2 (anciennement orm_mode)
        
        # Valider lors de l'assignation
        validate_assignment = True
        
        # Utiliser enum values
        use_enum_values = True
        
        # Encoder les types personnalisés
        json_encoders = {
            datetime: lambda v: v.isoformat()
        }
        
        # Schema extra pour documentation
        json_schema_extra = {
            "example": {
                "id": 123,
                "username": "john_doe",
                "email": "john@example.com",
                "created_at": "2025-12-13T10:30:00Z"
            }
        }


# ═══ UTILISATION DANS FASTAPI ═══

from fastapi import FastAPI, HTTPException
from pydantic import ValidationError

app = FastAPI()

@app.post("/users", response_model=User, status_code=201)
async def create_user(user_data: UserRegistration):
    """
    Crée un utilisateur avec validation Pydantic
    
    FastAPI valide automatiquement avec le modèle Pydantic
    Si validation échoue -> 422 avec détails des erreurs
    """
    
    # user_data est déjà validé!
    # FastAPI a automatiquement:
    # 1. Parsé le JSON
    # 2. Validé avec UserRegistration
    # 3. Exécuté tous les validators
    
    # Créer l'utilisateur
    user = User(
        id=123,
        username=user_data.username,
        email=user_data.email,
        created_at=datetime.now()
    )
    
    return user


# ═══ VALIDATION MANUELLE ═══

def validate_data_manually():
    """
    Valider manuellement des données
    """
    
    # Données à valider
    data = {
        "username": "john_doe",
        "email": "john@example.com",
        "password": "SecurePass123!",
        "password_confirm": "SecurePass123!",
        "age": 25,
        "terms_accepted": True
    }
    
    try:
        # Valider
        user = UserRegistration(**data)
        print("[OK] Validation réussie:", user)
        
        # Accéder aux données validées
        print(f"Username: {user.username}")
        print(f"Email: {user.email}")
        
        # Convertir en dict
        user_dict = user.model_dump()  # Pydantic v2 (anciennement .dict())
        print("Dict:", user_dict)
        
        # Convertir en JSON
        user_json = user.model_dump_json()  # Pydantic v2 (anciennement .json())
        print("JSON:", user_json)
        
    except ValidationError as e:
        print("[X] Validation échouée:")
        print(e.json())  # Erreurs au format JSON
        
        # Erreurs détaillées
        for error in e.errors():
            print(f"  Field: {error['loc']}")
            print(f"  Error: {error['msg']}")
            print(f"  Type: {error['type']}")


# ═══ VALIDATION INCRÉMENTALE ═══

from pydantic import parse_obj_as

def validate_list_of_users():
    """
    Valider une liste d'objets
    """
    
    users_data = [
        {"username": "alice", "email": "alice@example.com"},
        {"username": "bob", "email": "bob@example.com"},
        {"username": "charlie", "email": "invalid-email"}  # [X] Invalid
    ]
    
    try:
        users = parse_obj_as(List[UserBase], users_data)
        print(f"[OK] {len(users)} users validés")
    except ValidationError as e:
        print("[X] Validation échouée:", e)


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                      NESTED MODELS (OBJETS IMBRIQUÉS)               ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ MODÈLES IMBRIQUÉS SIMPLES ═══

class Address(BaseModel):
    """
    Adresse (modèle imbriqué)
    """
    street: str
    city: str
    state: str
    postal_code: str
    country: str = "France"
    
    @validator('postal_code')
    def validate_postal_code(cls, v, values):
        """
        Valide le code postal selon le pays
        """
        country = values.get('country', 'France')
        
        if country == "France":
            if not v.isdigit() or len(v) != 5:
                raise ValueError('French postal code must be 5 digits')
        elif country == "USA":
            if not v.isdigit() or len(v) != 5:
                raise ValueError('US ZIP code must be 5 digits')
        
        return v


class ContactInfo(BaseModel):
    """
    Informations de contact
    """
    phone: Optional[str] = None
    mobile: Optional[str] = None
    email: EmailStr
    
    @root_validator
    def at_least_one_phone(cls, values):
        """
        Au moins un numéro de téléphone requis
        """
        if not values.get('phone') and not values.get('mobile'):
            raise ValueError('At least one phone number is required')
        return values


class UserComplete(BaseModel):
    """
    Utilisateur avec modèles imbriqués
    """
    username: str
    email: EmailStr
    
    # Objet imbriqué
    address: Address
    
    # Objet imbriqué optionnel
    contact: Optional[ContactInfo] = None
    
    # Liste d'objets imbriqués
    addresses: List[Address] = Field(default_factory=list)


# Exemple d'utilisation
user_data = {
    "username": "john_doe",
    "email": "john@example.com",
    "address": {
        "street": "123 Main St",
        "city": "Paris",
        "state": "Île-de-France",
        "postal_code": "75001",
        "country": "France"
    },
    "contact": {
        "phone": "+33123456789",
        "email": "john@example.com"
    },
    "addresses": [
        {
            "street": "Home address",
            "city": "Paris",
            "state": "Île-de-France",
            "postal_code": "75001",
            "country": "France"
        },
        {
            "street": "Work address",
            "city": "Lyon",
            "state": "Auvergne-Rhône-Alpes",
            "postal_code": "69001",
            "country": "France"
        }
    ]
}

user = UserComplete(**user_data)  # [OK] Validé


# ═══ NESTED MODELS AVEC RELATIONS ═══

class Author(BaseModel):
    """
    Auteur simple (pour éviter circular imports)
    """
    id: int
    username: str
    email: EmailStr


class PostWithAuthor(BaseModel):
    """
    Post avec auteur imbriqué
    """
    id: int
    title: str
    content: str
    author: Author  # Objet imbriqué
    tags: List[str]
    created_at: datetime
    
    class Config:
        from_attributes = True


# ═══ CIRCULAR REFERENCES (Références circulaires) ═══

from typing import ForwardRef

# Forward reference pour éviter circular import
AuthorRef = ForwardRef('AuthorWithPosts')

class PostMinimal(BaseModel):
    """
    Post minimal pour éviter récursion infinie
    """
    id: int
    title: str
    created_at: datetime


class AuthorWithPosts(BaseModel):
    """
    Auteur avec ses posts
    """
    id: int
    username: str
    email: EmailStr
    posts: List[PostMinimal] = Field(default_factory=list)
    
    class Config:
        from_attributes = True


# ═══ NESTED MODELS AVEC DIFFÉRENTS NIVEAUX DE DÉTAILS ═══

class PostSummary(BaseModel):
    """
    Résumé de post (pour listes)
    """
    id: int
    title: str
    excerpt: str
    author_name: str
    created_at: datetime


class PostDetail(BaseModel):
    """
    Détail complet de post (pour page individuelle)
    """
    id: int
    title: str
    content: str
    excerpt: str
    author: Author  # Objet complet
    tags: List[str]
    comments_count: int
    likes_count: int
    created_at: datetime
    updated_at: datetime


# ═══ UTILISATION DANS FASTAPI ═══

@app.get("/posts", response_model=List[PostSummary])
async def list_posts():
    """
    Liste des posts (résumé)
    """
    # Retourne liste de PostSummary
    pass


@app.get("/posts/{post_id}", response_model=PostDetail)
async def get_post(post_id: int):
    """
    Détail d'un post (complet)
    """
    # Retourne PostDetail avec author imbriqué
    pass


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    CONDITIONAL VALIDATION                           ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

from typing import Union, Literal

# ═══ VALIDATION CONDITIONNELLE AVEC ROOT_VALIDATOR ═══

class PaymentMethod(str, Enum):
    """
    Méthodes de paiement
    """
    CREDIT_CARD = "credit_card"
    PAYPAL = "paypal"
    BANK_TRANSFER = "bank_transfer"


class Payment(BaseModel):
    """
    Paiement avec validation conditionnelle
    
    Selon la méthode de paiement, différents champs sont requis
    """
    
    amount: float = Field(..., gt=0)
    currency: str = "EUR"
    method: PaymentMethod
    
    # Champs conditionnels
    card_number: Optional[str] = None
    card_expiry: Optional[str] = None
    card_cvv: Optional[str] = None
    
    paypal_email: Optional[EmailStr] = None
    
    bank_account: Optional[str] = None
    bank_code: Optional[str] = None
    
    @root_validator
    def validate_payment_method(cls, values):
        """
        Valide les champs selon la méthode de paiement
        """
        method = values.get('method')
        
        if method == PaymentMethod.CREDIT_CARD:
            # Carte de crédit: numéro, expiration et CVV requis
            if not values.get('card_number'):
                raise ValueError('card_number is required for credit card payments')
            if not values.get('card_expiry'):
                raise ValueError('card_expiry is required for credit card payments')
            if not values.get('card_cvv'):
                raise ValueError('card_cvv is required for credit card payments')
            
            # Valider le numéro de carte
            card_number = values['card_number'].replace(' ', '')
            if not card_number.isdigit() or len(card_number) != 16:
                raise ValueError('card_number must be 16 digits')
            
            # Valider CVV
            if not values['card_cvv'].isdigit() or len(values['card_cvv']) not in [3, 4]:
                raise ValueError('card_cvv must be 3 or 4 digits')
        
        elif method == PaymentMethod.PAYPAL:
            # PayPal: email requis
            if not values.get('paypal_email'):
                raise ValueError('paypal_email is required for PayPal payments')
        
        elif method == PaymentMethod.BANK_TRANSFER:
            # Virement: compte et code bancaire requis
            if not values.get('bank_account'):
                raise ValueError('bank_account is required for bank transfers')
            if not values.get('bank_code'):
                raise ValueError('bank_code is required for bank transfers')
        
        return values


# ═══ VALIDATION CONDITIONNELLE AVEC DISCRIMINATED UNIONS ═══

class CreditCardPayment(BaseModel):
    """
    Paiement par carte de crédit
    """
    method: Literal["credit_card"]
    card_number: str = Field(..., min_length=16, max_length=16)
    card_expiry: str = Field(..., pattern=r'^\d{2}/\d{2}$')  # MM/YY
    card_cvv: str = Field(..., min_length=3, max_length=4)


class PayPalPayment(BaseModel):
    """
    Paiement par PayPal
    """
    method: Literal["paypal"]
    paypal_email: EmailStr


class BankTransferPayment(BaseModel):
    """
    Paiement par virement bancaire
    """
    method: Literal["bank_transfer"]
    bank_account: str
    bank_code: str


# Union discriminée par le champ "method"
PaymentUnion = Union[CreditCardPayment, PayPalPayment, BankTransferPayment]


class Order(BaseModel):
    """
    Commande avec paiement discriminé
    """
    order_id: int
    amount: float
    payment: PaymentUnion  # Type union


# Exemple d'utilisation
order_data_card = {
    "order_id": 123,
    "amount": 99.99,
    "payment": {
        "method": "credit_card",
        "card_number": "1234567890123456",
        "card_expiry": "12/25",
        "card_cvv": "123"
    }
}

order_data_paypal = {
    "order_id": 124,
    "amount": 49.99,
    "payment": {
        "method": "paypal",
        "paypal_email": "user@example.com"
    }
}

order1 = Order(**order_data_card)  # [OK] Valide CreditCardPayment
order2 = Order(**order_data_paypal)  # [OK] Valide PayPalPayment


# ═══ VALIDATION CONDITIONNELLE PAR TYPE D'UTILISATEUR ═══

class UserType(str, Enum):
    """
    Types d'utilisateurs
    """
    INDIVIDUAL = "individual"
    COMPANY = "company"


class UserRegistrationBase(BaseModel):
    """
    Base pour inscription
    """
    email: EmailStr
    password: str
    user_type: UserType


class IndividualRegistration(UserRegistrationBase):
    """
    Inscription individuelle
    """
    user_type: Literal[UserType.INDIVIDUAL]
    first_name: str
    last_name: str
    birth_date: date
    
    @validator('birth_date')
    def must_be_adult(cls, v):
        """Doit avoir 18 ans"""
        today = date.today()
        age = today.year - v.year - ((today.month, today.day) < (v.month, v.day))
        
        if age < 18:
            raise ValueError('Must be at least 18 years old')
        
        return v


class CompanyRegistration(UserRegistrationBase):
    """
    Inscription entreprise
    """
    user_type: Literal[UserType.COMPANY]
    company_name: str
    company_registration: str  # SIRET, etc.
    tax_id: str
    legal_representative: str


# Union discriminée
RegistrationUnion = Union[IndividualRegistration, CompanyRegistration]


@app.post("/register")
async def register_user(registration: RegistrationUnion):
    """
    Endpoint d'inscription avec validation conditionnelle
    
    FastAPI choisit automatiquement le bon modèle selon user_type
    """
    if isinstance(registration, IndividualRegistration):
        # Traiter inscription individuelle
        print(f"Individual: {registration.first_name} {registration.last_name}")
    else:
        # Traiter inscription entreprise
        print(f"Company: {registration.company_name}")
    
    return {"message": "Registration successful"}



═══════════════════════════════════════════════════════════════════════════════
  7.2 MARSHMALLOW AVEC FLASK - AVANCÉ
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI Marshmallow avec Flask?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Marshmallow est conçu spécifiquement pour Flask:
  [OK] Intégration parfaite avec SQLAlchemy
  [OK] Flask-Marshmallow ajoute des helpers
  [OK] Sérialisation/Désérialisation bidirectionnelle
  [OK] Nested relationships automatiques
  [OK] Validation puissante
  [OK] Écosystème mature


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                  MARSHMALLOW - SCHÉMAS AVANCÉS                      ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

from marshmallow import Schema, fields, validate, validates, validates_schema
from marshmallow import ValidationError, post_load, pre_load, post_dump, pre_dump
from marshmallow import EXCLUDE, INCLUDE, RAISE
from flask_marshmallow import Marshmallow

ma = Marshmallow(app)


# ═══ SCHÉMA DE BASE AVEC VALIDATIONS ═══

class UserSchema(ma.Schema):
    """
    Schéma utilisateur avec validations avancées
    """
    
    # ═══ CHAMPS AVEC VALIDATIONS ═══
    
    id = fields.Int(dump_only=True)
    
    username = fields.Str(
        required=True,
        validate=[
            validate.Length(min=3, max=50),
            validate.Regexp(
                r'^[a-zA-Z0-9_-]+$',
                error='Username can only contain letters, numbers, hyphens and underscores'
            )
        ],
        error_messages={
            'required': 'Username is required',
            'null': 'Username cannot be null',
            'invalid': 'Invalid username format'
        }
    )
    
    email = fields.Email(
        required=True,
        validate=validate.Length(max=120),
        error_messages={
            'required': 'Email is required',
            'invalid': 'Invalid email address'
        }
    )
    
    password = fields.Str(
        required=True,
        load_only=True,  # Jamais en sortie (sérialisation)
        validate=validate.Length(min=8, max=128)
    )
    
    age = fields.Int(
        validate=validate.Range(min=13, max=120),
        allow_none=True
    )
    
    bio = fields.Str(
        validate=validate.Length(max=500),
        allow_none=True
    )
    
    role = fields.Str(
        validate=validate.OneOf(['user', 'editor', 'admin']),
        missing='user'  # Valeur par défaut
    )
    
    is_active = fields.Bool(dump_only=True)
    
    created_at = fields.DateTime(dump_only=True, format='iso')
    updated_at = fields.DateTime(dump_only=True, format='iso')
    
    # ═══ URL FIELDS ═══
    
    avatar_url = fields.Url(
        allow_none=True,
        schemes=['http', 'https'],
        require_tld=True  # Nécessite un TLD (.com, .fr, etc.)
    )
    
    # ═══ CHAMPS CALCULÉS (METHOD FIELDS) ═══
    
    full_name = fields.Method('get_full_name', dump_only=True)
    posts_count = fields.Method('get_posts_count', dump_only=True)
    
    def get_full_name(self, obj):
        """
        Calcule le nom complet
        
        Args:
            obj: L'objet User source
        """
        if obj.first_name and obj.last_name:
            return f"{obj.first_name} {obj.last_name}"
        return obj.username
    
    def get_posts_count(self, obj):
        """Compte les posts de l'utilisateur"""
        return obj.posts.count() if hasattr(obj, 'posts') else 0
    
    # ═══ FUNCTION FIELDS ═══
    
    account_age_days = fields.Function(
        lambda obj: (datetime.now() - obj.created_at).days
    )
    
    # ═══ META CONFIGURATION ═══
    
    class Meta:
        """Configuration du schéma"""
        
        # Ordre des champs dans la sortie
        fields = (
            'id', 'username', 'email', 'full_name',
            'age', 'bio', 'avatar_url', 'role',
            'is_active', 'posts_count', 'account_age_days',
            'created_at', 'updated_at'
        )
        
        # Ou utiliser ordered=True pour garder l'ordre de définition
        ordered = True
        
        # Gestion des champs inconnus lors du load
        unknown = EXCLUDE  # EXCLUDE, INCLUDE, ou RAISE
        
        # Date format par défaut
        datetimeformat = '%Y-%m-%dT%H:%M:%S%z'


# ═══ VALIDATEURS PERSONNALISÉS ═══

class UserRegistrationSchema(ma.Schema):
    """
    Schéma d'inscription avec validateurs personnalisés
    """
    
    username = fields.Str(required=True, validate=validate.Length(min=3, max=50))
    email = fields.Email(required=True)
    password = fields.Str(required=True, load_only=True)
    password_confirm = fields.Str(required=True, load_only=True)
    
    # ═══ FIELD-LEVEL VALIDATOR ═══
    
    @validates('username')
    def validate_username(self, value):
        """
        Valide le username
        
        Lève ValidationError si invalide
        """
        # Vérifier format
        if not value.replace('_', '').replace('-', '').isalnum():
            raise ValidationError(
                'Username can only contain letters, numbers, hyphens and underscores'
            )
        
        # Vérifier unicité (DB)
        if User.query.filter_by(username=value).first():
            raise ValidationError('Username already exists')
    
    @validates('email')
    def validate_email(self, value):
        """Valide l'email"""
        # Bloquer emails jetables
        disposable_domains = ['tempmail.com', '10minutemail.com']
        domain = value.split('@')[1].lower()
        
        if domain in disposable_domains:
            raise ValidationError('Disposable email addresses are not allowed')
        
        # Vérifier unicité
        if User.query.filter_by(email=value).first():
            raise ValidationError('Email already exists')
    
    @validates('password')
    def validate_password(self, value):
        """Valide la force du mot de passe"""
        if not any(c.isupper() for c in value):
            raise ValidationError('Password must contain at least one uppercase letter')
        
        if not any(c.islower() for c in value):
            raise ValidationError('Password must contain at least one lowercase letter')
        
        if not any(c.isdigit() for c in value):
            raise ValidationError('Password must contain at least one digit')
        
        if not any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in value):
            raise ValidationError('Password must contain at least one special character')
    
    # ═══ SCHEMA-LEVEL VALIDATOR ═══
    
    @validates_schema
    def validate_passwords_match(self, data, **kwargs):
        """
        Valide que les mots de passe correspondent
        
        Accès à tous les champs validés
        """
        if data.get('password') != data.get('password_confirm'):
            raise ValidationError(
                'Passwords do not match',
                field_name='password_confirm'
            )


# ═══ PRE/POST PROCESSING ═══

class UserProcessingSchema(ma.Schema):
    """
    Démontre les hooks de pre/post processing
    """
    
    username = fields.Str(required=True)
    email = fields.Email(required=True)
    tags = fields.List(fields.Str())
    
    # ═══ PRE_LOAD: Avant désérialisation ═══
    
    @pre_load
    def preprocess_input(self, data, **kwargs):
        """
        Traite les données brutes avant validation
        
        Utile pour:
        - Nettoyer les données
        - Normaliser les formats
        - Transformer les structures
        """
        # Normaliser username (strip, lowercase)
        if 'username' in data:
            data['username'] = data['username'].strip().lower()
        
        # Normaliser email
        if 'email' in data:
            data['email'] = data['email'].strip().lower()
        
        # Nettoyer tags
        if 'tags' in data:
            data['tags'] = [tag.strip().lower() for tag in data['tags'] if tag.strip()]
        
        return data
    
    # ═══ POST_LOAD: Après désérialisation ═══
    
    @post_load
    def make_user(self, data, **kwargs):
        """
        Crée un objet User après validation
        
        Optionnel: transformer le dict en objet
        """
        # Retourner tel quel (dict)
        return data
        
        # Ou créer un objet User
        # return User(**data)
    
    # ═══ PRE_DUMP: Avant sérialisation ═══
    
    @pre_dump
    def prepare_output(self, obj, **kwargs):
        """
        Prépare l'objet avant sérialisation
        
        Utile pour:
        - Charger des données lazy-loaded
        - Calculer des champs dérivés
        - Filtrer selon permissions
        """
        # Si obj est un User SQLAlchemy, on peut charger les relations
        # obj.posts  # Charge les posts si lazy
        
        return obj
    
    # ═══ POST_DUMP: Après sérialisation ═══
    
    @post_dump
    def postprocess_output(self, data, **kwargs):
        """
        Traite les données sérialisées
        
        Utile pour:
        - Ajouter des métadonnées
        - Filtrer des champs sensibles
        - Transformer le format final
        """
        # Ajouter des métadonnées
        data['_links'] = {
            'self': f"/api/users/{data.get('id')}",
            'posts': f"/api/users/{data.get('id')}/posts"
        }
        
        return data


# ═══ NESTED SCHEMAS (Relations) ═══

class PostSchema(ma.Schema):
    """Schéma pour Post"""
    
    id = fields.Int(dump_only=True)
    title = fields.Str(required=True)
    content = fields.Str(required=True)
    author_id = fields.Int(load_only=True)
    created_at = fields.DateTime(dump_only=True)


class UserWithPostsSchema(ma.Schema):
    """
    Utilisateur avec posts imbriqués
    """
    
    id = fields.Int(dump_only=True)
    username = fields.Str()
    email = fields.Email()
    
    # ═══ NESTED SIMPLE ═══
    
    # Inclure les posts (many=True pour liste)
    posts = fields.Nested(PostSchema, many=True, dump_only=True)
    
    # ═══ NESTED AVEC EXCLUDE ═══
    
    # Exclure certains champs du nested
    # posts = fields.Nested(PostSchema, many=True, exclude=('author_id',))
    
    # ═══ NESTED AVEC ONLY ═══
    
    # Inclure seulement certains champs
    # posts = fields.Nested(PostSchema, many=True, only=('id', 'title'))


class PostWithAuthorSchema(ma.Schema):
    """
    Post avec auteur imbriqué
    """
    
    id = fields.Int(dump_only=True)
    title = fields.Str()
    content = fields.Str()
    
    # Auteur imbriqué (seulement certains champs)
    author = fields.Nested(
        UserSchema,
        only=('id', 'username', 'avatar_url'),
        dump_only=True
    )


# ═══ SELF-REFERENCING NESTED (Recursion) ═══

class CommentSchema(ma.Schema):
    """
    Commentaire avec réponses imbriquées (récursif)
    """
    
    id = fields.Int(dump_only=True)
    content = fields.Str(required=True)
    author = fields.Nested(UserSchema, only=('id', 'username'))
    parent_id = fields.Int(allow_none=True)
    
    # Réponses (self-referencing)
    replies = fields.Nested(
        'self',  # Référence au schéma lui-même
        many=True,
        dump_only=True
    )
    
    created_at = fields.DateTime(dump_only=True)


# ═══ PLUCK - NESTED SIMPLIFIÉ ═══

from marshmallow import fields

class BlogPostSchema(ma.Schema):
    """
    Post de blog avec tags pluckés
    """
    
    id = fields.Int(dump_only=True)
    title = fields.Str()
    content = fields.Str()
    
    # Pluck: extraire seulement un champ des relations
    # Au lieu d'objets complets, retourne liste de strings
    tag_names = fields.Pluck('TagSchema', 'name', many=True)
    # Retourne: ["python", "flask", "api"] au lieu d'objets Tag complets


# ═══ POLYMORPHIC SCHEMAS (Union types) ═══

class ImageMediaSchema(ma.Schema):
    """Média de type image"""
    type = fields.Str(dump_default='image')
    url = fields.Url(required=True)
    width = fields.Int()
    height = fields.Int()
    alt_text = fields.Str()


class VideoMediaSchema(ma.Schema):
    """Média de type vidéo"""
    type = fields.Str(dump_default='video')
    url = fields.Url(required=True)
    duration = fields.Int()
    thumbnail_url = fields.Url()


class DocumentMediaSchema(ma.Schema):
    """Média de type document"""
    type = fields.Str(dump_default='document')
    url = fields.Url(required=True)
    filename = fields.Str()
    size_bytes = fields.Int()


# Fonction pour choisir le schéma selon le type
def media_schema_serialization_disambiguation(obj, parent_obj):
    """
    Choisit le schéma selon le type de média
    """
    type_to_schema = {
        'image': ImageMediaSchema,
        'video': VideoMediaSchema,
        'document': DocumentMediaSchema
    }
    
    try:
        return type_to_schema[obj.type]()
    except KeyError:
        raise ValueError(f"Unknown media type: {obj.type}")


class PostWithMediaSchema(ma.Schema):
    """
    Post avec médias polymorphiques
    """
    
    id = fields.Int(dump_only=True)
    title = fields.Str()
    
    # Utilisation d'un resolver pour choisir le schéma
    media = fields.List(
        fields.Nested(
            lambda: media_schema_serialization_disambiguation,
        )
    )


# ═══ UTILISATION DANS FLASK ═══

from flask import request, jsonify
from marshmallow import ValidationError

@app.route('/api/users', methods=['POST'])
def create_user():
    """
    Crée un utilisateur avec validation Marshmallow
    """
    
    # Créer le schéma
    schema = UserRegistrationSchema()
    
    try:
        # Valider et désérialiser
        data = schema.load(request.json)
        
        # Données validées!
        user = User(
            username=data['username'],
            email=data['email'],
            password_hash=User.hash_password(data['password'])
        )
        user.save()
        
        # Sérialiser la réponse
        result = UserSchema().dump(user)
        
        return jsonify(result), 201
        
    except ValidationError as err:
        # Erreurs de validation
        return jsonify({
            'error': 'Validation Failed',
            'messages': err.messages
        }), 400


@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    """
    Récupère un utilisateur
    """
    user = User.query.get_or_404(user_id)
    
    # Sérialiser
    schema = UserSchema()
    result = schema.dump(user)
    
    return jsonify(result), 200


@app.route('/api/users', methods=['GET'])
def get_users():
    """
    Liste des utilisateurs
    """
    users = User.query.all()
    
    # Sérialiser une liste (many=True)
    schema = UserSchema(many=True)
    result = schema.dump(users)
    
    return jsonify({'users': result}), 200


@app.route('/api/users/<int:user_id>', methods=['PATCH'])
def update_user(user_id):
    """
    Met à jour un utilisateur (PATCH = partiel)
    """
    user = User.query.get_or_404(user_id)
    
    # Schéma avec partial=True (champs optionnels)
    schema = UserSchema(partial=True)
    
    try:
        data = schema.load(request.json)
        
        # Mettre à jour les champs fournis
        for key, value in data.items():
            if hasattr(user, key):
                setattr(user, key, value)
        
        user.save()
        
        result = schema.dump(user)
        return jsonify(result), 200
        
    except ValidationError as err:
        return jsonify({
            'error': 'Validation Failed',
            'messages': err.messages
        }), 400


═══════════════════════════════════════════════════════════════════════════════
  7.3 CUSTOM VALIDATORS - RÉUTILISABLES
═══════════════════════════════════════════════════════════════════════════════

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    VALIDATEURS PERSONNALISÉS                        ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER app/validators.py ═══

"""
Validateurs personnalisés réutilisables
"""

from marshmallow import ValidationError
import re
from datetime import datetime, date


# ═══ VALIDATEURS SIMPLES ═══

def validate_not_empty(value):
    """
    Valide qu'une string n'est pas vide
    
    Usage:
        name = fields.Str(validate=validate_not_empty)
    """
    if not value or not value.strip():
        raise ValidationError('Field cannot be empty')


def validate_no_spaces(value):
    """
    Valide qu'une string ne contient pas d'espaces
    
    Usage:
        username = fields.Str(validate=validate_no_spaces)
    """
    if ' ' in value:
        raise ValidationError('Field cannot contain spaces')


def validate_alphanumeric(value):
    """
    Valide qu'une string est alphanumérique
    """
    if not value.isalnum():
        raise ValidationError('Field must be alphanumeric')


def validate_lowercase(value):
    """
    Valide qu'une string est en minuscules
    """
    if value != value.lower():
        raise ValidationError('Field must be lowercase')


# ═══ VALIDATEURS AVEC PARAMÈTRES (Classes) ═══

class ValidatePasswordStrength:
    """
    Validateur de force de mot de passe configurable
    
    Usage:
        password = fields.Str(
            validate=ValidatePasswordStrength(
                min_length=8,
                require_uppercase=True,
                require_lowercase=True,
                require_digit=True,
                require_special=True
            )
        )
    """
    
    def __init__(
        self,
        min_length=8,
        require_uppercase=True,
        require_lowercase=True,
        require_digit=True,
        require_special=True
    ):
        self.min_length = min_length
        self.require_uppercase = require_uppercase
        self.require_lowercase = require_lowercase
        self.require_digit = require_digit
        self.require_special = require_special
    
    def __call__(self, value):
        """Exécute la validation"""
        errors = []
        
        if len(value) < self.min_length:
            errors.append(f'Password must be at least {self.min_length} characters')
        
        if self.require_uppercase and not any(c.isupper() for c in value):
            errors.append('Password must contain at least one uppercase letter')
        
        if self.require_lowercase and not any(c.islower() for c in value):
            errors.append('Password must contain at least one lowercase letter')
        
        if self.require_digit and not any(c.isdigit() for c in value):
            errors.append('Password must contain at least one digit')
        
        if self.require_special:
            special_chars = '!@#$%^&*()_+-=[]{}|;:,.<>?'
            if not any(c in special_chars for c in value):
                errors.append('Password must contain at least one special character')
        
        if errors:
            raise ValidationError(errors)


class ValidatePhoneNumber:
    """
    Validateur de numéro de téléphone international
    
    Usage:
        phone = fields.Str(validate=ValidatePhoneNumber(countries=['FR', 'US']))
    """
    
    # Patterns par pays
    PATTERNS = {
        'FR': r'^(\+33|0)[1-9](\d{8})$',
        'US': r'^(\+1)?[2-9]\d{9}$',
        'UK': r'^(\+44|0)[1-9]\d{9}$',
        'DE': r'^(\+49|0)[1-9]\d{9,10}$',
    }
    
    def __init__(self, countries=None):
        """
        Args:
            countries (list): Liste des codes pays acceptés (None = tous)
        """
        self.countries = countries
    
    def __call__(self, value):
        """Valide le numéro de téléphone"""
        # Nettoyer le numéro
        cleaned = value.replace(' ', '').replace('-', '').replace('.', '')
        
        # Si pas de restriction de pays, pattern générique
        if not self.countries:
            if not re.match(r'^\+?[\d\s-]{10,15}$', cleaned):
                raise ValidationError('Invalid phone number format')
            return
        
        # Vérifier contre les patterns spécifiques
        for country in self.countries:
            pattern = self.PATTERNS.get(country)
            if pattern and re.match(pattern, cleaned):
                return
        
        raise ValidationError(
            f'Phone number must be valid for: {", ".join(self.countries)}'
        )


class ValidateDateRange:
    """
    Valide qu'une date est dans un range
    
    Usage:
        birth_date = fields.Date(
            validate=ValidateDateRange(
                min_date=date(1900, 1, 1),
                max_date=date.today()
            )
        )
    """
    
    def __init__(self, min_date=None, max_date=None):
        self.min_date = min_date
        self.max_date = max_date
    
    def __call__(self, value):
        """Valide la date"""
        if self.min_date and value < self.min_date:
            raise ValidationError(
                f'Date must be after {self.min_date.isoformat()}'
            )
        
        if self.max_date and value > self.max_date:
            raise ValidationError(
                f'Date must be before {self.max_date.isoformat()}'
            )


class ValidateFileExtension:
    """
    Valide l'extension d'un fichier
    
    Usage:
        filename = fields.Str(
            validate=ValidateFileExtension(['.jpg', '.png', '.gif'])
        )
    """
    
    def __init__(self, allowed_extensions):
        """
        Args:
            allowed_extensions (list): Extensions autorisées (avec le point)
        """
        self.allowed_extensions = [ext.lower() for ext in allowed_extensions]
    
    def __call__(self, value):
        """Valide l'extension"""
        ext = value.lower().split('.')[-1]
        if f'.{ext}' not in self.allowed_extensions:
            raise ValidationError(
                f'File must have one of these extensions: '
                f'{", ".join(self.allowed_extensions)}'
            )


class ValidateUnique:
    """
    Valide qu'une valeur est unique dans la DB
    
    Usage:
        username = fields.Str(
            validate=ValidateUnique(User, 'username')
        )
    """
    
    def __init__(self, model, field_name, case_sensitive=True):
        """
        Args:
            model: Modèle SQLAlchemy
            field_name (str): Nom du champ à vérifier
            case_sensitive (bool): Sensible à la casse
        """
        self.model = model
        self.field_name = field_name
        self.case_sensitive = case_sensitive
    
    def __call__(self, value):
        """Valide l'unicité"""
        field = getattr(self.model, self.field_name)
        
        if self.case_sensitive:
            query = self.model.query.filter(field == value)
        else:
            query = self.model.query.filter(field.ilike(value))
        
        if query.first():
            raise ValidationError(
                f'{self.field_name.capitalize()} already exists'
            )


# ═══ VALIDATEURS DE CONTENU ═══

class ValidateNoProfanity:
    """
    Valide qu'un texte ne contient pas de profanités
    
    Usage:
        comment = fields.Str(validate=ValidateNoProfanity())
    """
    
    # Liste de mots interdits (exemple simplifié)
    PROFANITY_LIST = [
        'badword1', 'badword2', 'badword3'
        # Liste réelle serait beaucoup plus longue
    ]
    
    def __init__(self, custom_list=None):
        """
        Args:
            custom_list (list): Liste personnalisée de mots interdits
        """
        self.profanity_list = custom_list or self.PROFANITY_LIST
    
    def __call__(self, value):
        """Valide le contenu"""
        value_lower = value.lower()
        
        for word in self.profanity_list:
            if word in value_lower:
                raise ValidationError('Content contains inappropriate language')


class ValidateNoHTML:
    """
    Valide qu'un texte ne contient pas de HTML/JavaScript
    
    Usage:
        comment = fields.Str(validate=ValidateNoHTML())
    """
    
    def __call__(self, value):
        """Valide le contenu"""
        # Détecter tags HTML
        if re.search(r'<[^>]+>', value):
            raise ValidationError('HTML tags are not allowed')
        
        # Détecter événements JavaScript
        if re.search(r'on\w+\s*=', value, re.IGNORECASE):
            raise ValidationError('JavaScript event handlers are not allowed')


class ValidateURLAccessible:
    """
    Valide qu'une URL est accessible (HEAD request)
    
    Usage:
        avatar_url = fields.Url(validate=ValidateURLAccessible())
    """
    
    def __init__(self, timeout=5):
        self.timeout = timeout
    
    def __call__(self, value):
        """Valide l'URL"""
        import requests
        
        try:
            response = requests.head(value, timeout=self.timeout, allow_redirects=True)
            if response.status_code >= 400:
                raise ValidationError(f'URL returned status code {response.status_code}')
        except requests.RequestException as e:
            raise ValidationError(f'URL is not accessible: {str(e)}')


# ═══ VALIDATEURS COMPOSITES ═══

class ValidateUsernameStrict:
    """
    Validation stricte de username
    
    Combine plusieurs règles:
    - Longueur 3-50
    - Alphanumérique + _ -
    - Commence par une lettre
    - Ne se termine pas par _ ou -
    - Pas de caractères consécutifs spéciaux
    """
    
    def __call__(self, value):
        """Valide le username"""
        errors = []
        
        # Longueur
        if len(value) < 3 or len(value) > 50:
            errors.append('Username must be between 3 and 50 characters')
        
        # Commence par une lettre
        if not value[0].isalpha():
            errors.append('Username must start with a letter')
        
        # Se termine par alphanumérique
        if value[-1] in '_-':
            errors.append('Username cannot end with underscore or hyphen')
        
        # Caractères autorisés
        if not re.match(r'^[a-zA-Z0-9_-]+$', value):
            errors.append(
                'Username can only contain letters, numbers, underscores and hyphens'
            )
        
        # Pas de caractères spéciaux consécutifs
        if re.search(r'[_-]{2,}', value):
            errors.append('Username cannot contain consecutive underscores or hyphens')
        
        if errors:
            raise ValidationError(errors)


# ═══ UTILISATION DES VALIDATEURS ═══

class UserCreateSchema(ma.Schema):
    """
    Schéma d'inscription avec validateurs personnalisés
    """
    
    username = fields.Str(
        required=True,
        validate=[
            validate.Length(min=3, max=50),
            ValidateUsernameStrict(),
            ValidateUnique(User, 'username', case_sensitive=False)
        ]
    )
    
    email = fields.Email(
        required=True,
        validate=ValidateUnique(User, 'email', case_sensitive=False)
    )
    
    password = fields.Str(
        required=True,
        load_only=True,
        validate=ValidatePasswordStrength(
            min_length=8,
            require_uppercase=True,
            require_lowercase=True,
            require_digit=True,
            require_special=True
        )
    )
    
    phone = fields.Str(
        required=True,
        validate=ValidatePhoneNumber(countries=['FR', 'US', 'UK'])
    )
    
    birth_date = fields.Date(
        required=True,
        validate=ValidateDateRange(
            min_date=date(1900, 1, 1),
            max_date=date.today()
        )
    )
    
    avatar_filename = fields.Str(
        allow_none=True,
        validate=ValidateFileExtension(['.jpg', '.jpeg', '.png', '.gif'])
    )


class CommentCreateSchema(ma.Schema):
    """
    Schéma de commentaire avec validation de contenu
    """
    
    content = fields.Str(
        required=True,
        validate=[
            validate.Length(min=1, max=2000),
            validate_not_empty,
            ValidateNoProfanity(),
            ValidateNoHTML()
        ]
    )
    
    author_name = fields.Str(
        required=True,
        validate=[
            validate.Length(min=2, max=100),
            validate_not_empty
        ]
    )


═══════════════════════════════════════════════════════════════════════════════
  7.6 SERIALIZATION STRATEGIES
═══════════════════════════════════════════════════════════════════════════════

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    STRATÉGIES DE SÉRIALISATION                      ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ STRATÉGIE 1: SCHÉMAS MULTIPLES SELON LE CONTEXTE ═══

class UserMinimalSchema(ma.Schema):
    """
    Schéma minimal pour listes/aperçus
    
    Seulement les champs essentiels
    """
    id = fields.Int()
    username = fields.Str()
    avatar_url = fields.Url()


class UserSummarySchema(ma.Schema):
    """
    Schéma résumé pour cards/previews
    """
    id = fields.Int()
    username = fields.Str()
    email = fields.Email()
    avatar_url = fields.Url()
    role = fields.Str()
    is_active = fields.Bool()
    posts_count = fields.Method('get_posts_count')
    
    def get_posts_count(self, obj):
        return obj.posts.count()


class UserDetailSchema(ma.Schema):
    """
    Schéma détaillé pour page profil
    
    Tous les champs + relations
    """
    id = fields.Int()
    username = fields.Str()
    email = fields.Email()
    first_name = fields.Str()
    last_name = fields.Str()
    bio = fields.Str()
    avatar_url = fields.Url()
    role = fields.Str()
    is_active = fields.Bool()
    email_verified = fields.Bool()
    created_at = fields.DateTime()
    updated_at = fields.DateTime()
    last_login_at = fields.DateTime()
    
    # Relations
    posts = fields.Nested('PostSummarySchema', many=True)
    comments_count = fields.Method('get_comments_count')
    
    def get_comments_count(self, obj):
        return obj.comments.count()


# Utilisation selon l'endpoint
@app.route('/api/users')
def list_users():
    """Liste: schéma minimal"""
    users = User.query.all()
    schema = UserMinimalSchema(many=True)
    return jsonify(schema.dump(users))


@app.route('/api/users/<int:user_id>')
def get_user(user_id):
    """Détail: schéma complet"""
    user = User.query.get_or_404(user_id)
    schema = UserDetailSchema()
    return jsonify(schema.dump(user))


# ═══ STRATÉGIE 2: SÉRIALISATION CONDITIONNELLE ═══

class UserAdaptiveSchema(ma.Schema):
    """
    Schéma adaptatif selon les permissions
    """
    
    id = fields.Int()
    username = fields.Str()
    email = fields.Email()
    avatar_url = fields.Url()
    
    # Champs sensibles (seulement pour owner/admin)
    phone = fields.Method('serialize_phone')
    address = fields.Method('serialize_address')
    
    # Stats (seulement pour owner/admin)
    total_posts = fields.Method('serialize_total_posts')
    total_likes = fields.Method('serialize_total_likes')
    
    def __init__(self, current_user=None, *args, **kwargs):
        """
        Args:
            current_user: Utilisateur courant (pour permissions)
        """
        super().__init__(*args, **kwargs)
        self.current_user = current_user
    
    def serialize_phone(self, obj):
        """Téléphone seulement si owner/admin"""
        if self._can_view_private(obj):
            return obj.phone
        return None
    
    def serialize_address(self, obj):
        """Adresse seulement si owner/admin"""
        if self._can_view_private(obj):
            return obj.address
        return None
    
    def serialize_total_posts(self, obj):
        """Stats seulement si owner/admin"""
        if self._can_view_private(obj):
            return obj.posts.count()
        return None
    
    def serialize_total_likes(self, obj):
        """Stats seulement si owner/admin"""
        if self._can_view_private(obj):
            return obj.get_total_likes()
        return None
    
    def _can_view_private(self, obj):
        """Vérifie si l'utilisateur peut voir les données privées"""
        if not self.current_user:
            return False
        
        # Owner ou admin
        return (
            self.current_user.id == obj.id or
            self.current_user.role == 'admin'
        )


# Utilisation
@app.route('/api/users/<int:user_id>')
@jwt_required()
def get_user_adaptive(user_id):
    """Sérialisation adaptative selon permissions"""
    user = User.query.get_or_404(user_id)
    current_user = get_current_user()
    
    schema = UserAdaptiveSchema(current_user=current_user)
    return jsonify(schema.dump(user))


# ═══ STRATÉGIE 3: FIELDS DYNAMIQUES (Query Params) ═══

from flask import request

class UserFlexibleSchema(ma.Schema):
    """
    Schéma avec champs sélectionnables via query params
    
    GET /api/users/123?fields=id,username,email
    GET /api/users/123?fields=id,username,posts
    """
    
    id = fields.Int()
    username = fields.Str()
    email = fields.Email()
    first_name = fields.Str()
    last_name = fields.Str()
    bio = fields.Str()
    avatar_url = fields.Url()
    created_at = fields.DateTime()
    
    # Relations (coûteuses)
    posts = fields.Nested('PostSummarySchema', many=True)
    comments = fields.Nested('CommentSummarySchema', many=True)


@app.route('/api/users/<int:user_id>')
def get_user_flexible(user_id):
    """
    Utilisateur avec champs sélectionnables
    
    Query params:
        fields: Liste de champs à inclure (comma-separated)
    
    Exemples:
        /api/users/123?fields=id,username,email
        /api/users/123?fields=id,username,posts
    """
    user = User.query.get_or_404(user_id)
    
    # Récupérer les champs demandés
    fields_param = request.args.get('fields')
    
    if fields_param:
        # Parser les champs
        requested_fields = [f.strip() for f in fields_param.split(',')]
        
        # Créer schéma avec seulement ces champs
        schema = UserFlexibleSchema(only=requested_fields)
    else:
        # Schéma par défaut (champs de base)
        schema = UserFlexibleSchema(only=('id', 'username', 'email', 'avatar_url'))
    
    return jsonify(schema.dump(user))


# ═══ STRATÉGIE 4: PAGINATION AVEC MÉTADONNÉES ═══

class PaginatedSchema(ma.Schema):
    """
    Schéma pour réponses paginées
    
    Wrapper générique pour ajouter métadonnées de pagination
    """
    
    # Données
    items = fields.List(fields.Raw())
    
    # Métadonnées de pagination
    page = fields.Int()
    per_page = fields.Int()
    total_items = fields.Int()
    total_pages = fields.Int()
    has_next = fields.Bool()
    has_prev = fields.Bool()
    
    # Links de navigation
    next_url = fields.Str(allow_none=True)
    prev_url = fields.Str(allow_none=True)


def paginate_query(query, page, per_page, schema):
    """
    Helper pour paginer une query et sérialiser
    
    Args:
        query: SQLAlchemy query
        page (int): Numéro de page
        per_page (int): Items par page
        schema: Schéma Marshmallow pour les items
    
    Returns:
        dict: Réponse paginée avec métadonnées
    """
    # Exécuter la pagination
    pagination = query.paginate(
        page=page,
        per_page=per_page,
        error_out=False
    )
    
    # Sérialiser les items
    items_schema = schema(many=True)
    items = items_schema.dump(pagination.items)
    
    # Construire URLs de navigation
    def build_url(page_num):
        """Construit l'URL pour une page"""
        if not pagination.has_next and page_num > pagination.page:
            return None
        if not pagination.has_prev and page_num < pagination.page:
            return None
        
        from flask import request
        args = request.args.copy()
        args['page'] = page_num
        
        from urllib.parse import urlencode
        return f"{request.base_url}?{urlencode(args)}"
    
    # Retourner réponse complète
    return {
        'items': items,
        'page': pagination.page,
        'per_page': pagination.per_page,
        'total_items': pagination.total,
        'total_pages': pagination.pages,
        'has_next': pagination.has_next,
        'has_prev': pagination.has_prev,
        'next_url': build_url(pagination.page + 1) if pagination.has_next else None,
        'prev_url': build_url(pagination.page - 1) if pagination.has_prev else None
    }


@app.route('/api/users')
def list_users_paginated():
    """
    Liste paginée d'utilisateurs
    
    Query params:
        page (int): Numéro de page (défaut: 1)
        per_page (int): Items par page (défaut: 20, max: 100)
    """
    page = request.args.get('page', 1, type=int)
    per_page = min(request.args.get('per_page', 20, type=int), 100)
    
    query = User.query.filter_by(is_active=True)
    
    result = paginate_query(
        query=query,
        page=page,
        per_page=per_page,
        schema=UserSummarySchema
    )
    
    return jsonify(result), 200


# ═══ STRATÉGIE 5: HATEOAS (Hypermedia) ═══

class UserHATEOASSchema(ma.Schema):
    """
    Schéma avec liens HATEOAS
    
    HATEOAS = Hypermedia As The Engine Of Application State
    Inclut les liens vers les actions possibles
    """
    
    id = fields.Int()
    username = fields.Str()
    email = fields.Email()
    
    # Links HATEOAS
    _links = fields.Method('get_links')
    
    def get_links(self, obj):
        """
        Génère les liens HATEOAS
        
        Retourne les actions possibles pour cette ressource
        """
        links = {
            'self': f'/api/users/{obj.id}',
            'posts': f'/api/users/{obj.id}/posts',
            'comments': f'/api/users/{obj.id}/comments',
        }
        
        # Liens conditionnels selon permissions
        if self.context.get('current_user'):
            current_user = self.context['current_user']
            
            if current_user.id == obj.id or current_user.is_admin():
                links['update'] = f'/api/users/{obj.id}'
                links['delete'] = f'/api/users/{obj.id}'
        
        return links


@app.route('/api/users/<int:user_id>')
@jwt_required()
def get_user_hateoas(user_id):
    """Utilisateur avec liens HATEOAS"""
    user = User.query.get_or_404(user_id)
    current_user = get_current_user()
    
    schema = UserHATEOASSchema(context={'current_user': current_user})
    return jsonify(schema.dump(user))


# Exemple de réponse:
"""
{
    "id": 123,
    "username": "john_doe",
    "email": "john@example.com",
    "_links": {
        "self": "/api/users/123",
        "posts": "/api/users/123/posts",
        "comments": "/api/users/123/comments",
        "update": "/api/users/123",
        "delete": "/api/users/123"
    }
}
"""


# ═══ STRATÉGIE 6: VERSIONING DES SCHÉMAS ═══

# Version 1
class UserSchemaV1(ma.Schema):
    """Version 1 de l'API (legacy)"""
    id = fields.Int()
    username = fields.Str()
    email = fields.Email()


# Version 2
class UserSchemaV2(ma.Schema):
    """
    Version 2 de l'API
    
    Changements:
    - Ajout de first_name, last_name
    - email_verified
    """
    id = fields.Int()
    username = fields.Str()
    email = fields.Email()
    first_name = fields.Str()
    last_name = fields.Str()
    email_verified = fields.Bool()


@app.route('/api/v1/users/<int:user_id>')
def get_user_v1(user_id):
    """API v1"""
    user = User.query.get_or_404(user_id)
    schema = UserSchemaV1()
    return jsonify(schema.dump(user))


@app.route('/api/v2/users/<int:user_id>')
def get_user_v2(user_id):
    """API v2"""
    user = User.query.get_or_404(user_id)
    schema = UserSchemaV2()
    return jsonify(schema.dump(user))


# Ou via header Accept-Version
@app.route('/api/users/<int:user_id>')
def get_user_versioned(user_id):
    """Versioning via header"""
    user = User.query.get_or_404(user_id)
    
    version = request.headers.get('Accept-Version', 'v1')
    
    if version == 'v2':
        schema = UserSchemaV2()
    else:
        schema = UserSchemaV1()
    
    return jsonify(schema.dump(user))


═══════════════════════════════════════════════════════════════════════════════
  RÉSUMÉ PARTIE 7
═══════════════════════════════════════════════════════════════════════════════

[OK] PYDANTIC: Validation moderne avec type hints Python
[OK] MARSHMALLOW: Sérialisation/validation pour Flask + SQLAlchemy
[OK] CUSTOM VALIDATORS: Validateurs réutilisables et composables
[OK] NESTED OBJECTS: Relations et objets imbriqués
[OK] CONDITIONAL VALIDATION: Validation selon contexte
[OK] SERIALIZATION STRATEGIES: Multiples approches de sérialisation

MEILLEURES PRATIQUES:
  • Toujours valider les entrées utilisateur
  • Utiliser des schémas différents selon le contexte (liste vs détail)
  • Créer des validateurs réutilisables
  • Implémenter la pagination avec métadonnées
  • Ajouter des liens HATEOAS pour APIs hypermedia
  • Versionner vos schémas pour évolution de l'API


═══════════════════════════════════════════════════════════════════════════════
  PARTIE 8: TESTING, DEBUGGING & MONITORING
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI Testing est CRITIQUE?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Sans tests:
  [X] Bugs découverts en production
  [X] Régressions à chaque changement
  [X] Peur de modifier le code
  [X] Temps de debug colossal
  [X] Perte de confiance des utilisateurs

Avec tests:
  [OK] Bugs détectés tôt
  [OK] Refactoring en confiance
  [OK] Documentation vivante
  [OK] Déploiements sereins
  [OK] Qualité du code


═══════════════════════════════════════════════════════════════════════════════
  8.1 TESTING AVEC PYTEST
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI Pytest?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Pytest est LE framework de test Python moderne:
  [OK] Syntaxe simple et pythonique
  [OK] Fixtures puissantes
  [OK] Parametrization facile
  [OK] Plugins riches
  [OK] Rapports détaillés
  [OK] Parallel execution


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         CONFIGURATION PYTEST                        ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ INSTALLATION ═══

pip install pytest pytest-cov pytest-flask pytest-mock pytest-env


# ═══ FICHIER pytest.ini ═══

"""
Configuration Pytest à la racine du projet
"""

[pytest]
# Chemins de recherche des tests
testpaths = tests

# Patterns de fichiers de test
python_files = test_*.py *_test.py

# Patterns de classes de test
python_classes = Test*

# Patterns de fonctions de test
python_functions = test_*

# Options par défaut
addopts =
    -v                          # Verbose
    --strict-markers           # Erreur si marker inconnu
    --tb=short                 # Traceback court
    --cov=app                  # Coverage sur app/
    --cov-report=term-missing  # Afficher lignes manquantes
    --cov-report=html          # Rapport HTML
    --disable-warnings         # Masquer warnings

# Markers personnalisés
markers =
    unit: Unit tests (fast, no DB)
    integration: Integration tests (with DB)
    slow: Slow tests (> 1s)
    auth: Authentication tests
    api: API endpoint tests

# Variables d'environnement pour tests
env =
    FLASK_ENV=testing
    DATABASE_URL=sqlite:///:memory:
    JWT_SECRET_KEY=test-secret-key
    TESTING=1


# ═══ FICHIER conftest.py ═══

"""
Fixtures globales pour tous les tests

Placé à la racine du dossier tests/
"""

import pytest
from app import create_app, db
from app.models.user import User
from app.models.post import Post
from datetime import datetime


# ═══ FIXTURES DE BASE ═══

@pytest.fixture(scope='session')
def app():
    """
    Crée l'application Flask pour les tests
    
    Scope 'session': créé une seule fois pour toute la session de test
    """
    app = create_app('testing')
    
    # Configuration spécifique aux tests
    app.config.update({
        'TESTING': True,
        'WTF_CSRF_ENABLED': False,  # Désactiver CSRF pour tests
        'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',  # DB en mémoire
        'SERVER_NAME': 'localhost:5000',  # Pour url_for
    })
    
    # Setup
    with app.app_context():
        db.create_all()
        yield app  # Retourner l'app aux tests
        
        # Teardown
        db.session.remove()
        db.drop_all()


@pytest.fixture(scope='function')
def client(app):
    """
    Client de test Flask
    
    Scope 'function': nouvelle instance pour chaque test
    """
    return app.test_client()


@pytest.fixture(scope='function')
def runner(app):
    """
    CLI runner pour tester les commandes Flask
    """
    return app.test_cli_runner()


@pytest.fixture(scope='function')
def db_session(app):
    """
    Session de base de données pour les tests
    
    Rollback automatique après chaque test
    """
    with app.app_context():
        # Créer les tables
        db.create_all()
        
        # Retourner la session
        yield db.session
        
        # Rollback et cleanup
        db.session.rollback()
        db.session.remove()
        
        # Recréer les tables pour le prochain test
        db.drop_all()
        db.create_all()


# ═══ FIXTURES DE DONNÉES ═══

@pytest.fixture
def user_data():
    """
    Données de test pour créer un utilisateur
    """
    return {
        'username': 'testuser',
        'email': 'test@example.com',
        'password': 'TestPass123!',
        'first_name': 'Test',
        'last_name': 'User'
    }


@pytest.fixture
def user(db_session, user_data):
    """
    Crée un utilisateur de test dans la DB
    """
    user = User(
        username=user_data['username'],
        email=user_data['email'],
        password_hash=User.hash_password(user_data['password']),
        first_name=user_data['first_name'],
        last_name=user_data['last_name'],
        role='user',
        active=True
    )
    db_session.add(user)
    db_session.commit()
    
    return user


@pytest.fixture
def admin_user(db_session):
    """
    Crée un utilisateur admin de test
    """
    admin = User(
        username='admin',
        email='admin@example.com',
        password_hash=User.hash_password('AdminPass123!'),
        role='admin',
        active=True
    )
    db_session.add(admin)
    db_session.commit()
    
    return admin


@pytest.fixture
def multiple_users(db_session):
    """
    Crée plusieurs utilisateurs pour les tests de liste
    """
    users = []
    for i in range(5):
        user = User(
            username=f'user{i}',
            email=f'user{i}@example.com',
            password_hash=User.hash_password('Pass123!'),
            role='user',
            active=True
        )
        db_session.add(user)
        users.append(user)
    
    db_session.commit()
    return users


@pytest.fixture
def post(db_session, user):
    """
    Crée un post de test
    """
    post = Post(
        title='Test Post',
        content='This is a test post content.',
        slug='test-post',
        author_id=user.id,
        published=True,
        published_at=datetime.utcnow()
    )
    db_session.add(post)
    db_session.commit()
    
    return post


# ═══ FIXTURES D'AUTHENTIFICATION ═══

@pytest.fixture
def auth_headers(client, user, user_data):
    """
    Headers d'authentification avec JWT valide
    """
    # Login pour obtenir le token
    response = client.post('/api/v1/auth/login', json={
        'username': user_data['username'],
        'password': user_data['password']
    })
    
    data = response.get_json()
    access_token = data['tokens']['access_token']
    
    return {
        'Authorization': f'Bearer {access_token}',
        'Content-Type': 'application/json'
    }


@pytest.fixture
def admin_auth_headers(client, admin_user):
    """
    Headers d'authentification admin
    """
    response = client.post('/api/v1/auth/login', json={
        'username': 'admin',
        'password': 'AdminPass123!'
    })
    
    data = response.get_json()
    access_token = data['tokens']['access_token']
    
    return {
        'Authorization': f'Bearer {access_token}',
        'Content-Type': 'application/json'
    }


# ═══ FIXTURES DE MOCKING ═══

@pytest.fixture
def mock_email_send(mocker):
    """
    Mock l'envoi d'emails
    """
    return mocker.patch('app.utils.email.send_email')


@pytest.fixture
def mock_redis(mocker):
    """
    Mock Redis
    """
    mock = mocker.patch('app.extensions.redis_client')
    mock.get.return_value = None
    mock.set.return_value = True
    return mock


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                           TESTS UNITAIRES                           ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER tests/unit/test_models.py ═══

"""
Tests unitaires des modèles
"""

import pytest
from app.models.user import User
from app.models.post import Post


class TestUserModel:
    """Tests du modèle User"""
    
    def test_create_user(self, db_session, user_data):
        """Test création d'un utilisateur"""
        user = User(
            username=user_data['username'],
            email=user_data['email'],
            password_hash=User.hash_password(user_data['password'])
        )
        db_session.add(user)
        db_session.commit()
        
        # Assertions
        assert user.id is not None
        assert user.username == user_data['username']
        assert user.email == user_data['email']
        assert user.password_hash != user_data['password']  # Hashé
        assert user.active is True  # Valeur par défaut
        assert user.created_at is not None
    
    def test_user_password_hashing(self):
        """Test du hashing de mot de passe"""
        password = 'TestPass123!'
        hashed = User.hash_password(password)
        
        # Le hash est différent du password
        assert hashed != password
        
        # On peut vérifier le password
        user = User(
            username='test',
            email='test@example.com',
            password_hash=hashed
        )
        assert user.check_password(password) is True
        assert user.check_password('WrongPass') is False
    
    def test_user_repr(self, user):
        """Test de la représentation string"""
        assert repr(user) == f'<User {user.username}>'
    
    def test_user_to_dict(self, user):
        """Test de la sérialisation en dict"""
        user_dict = user.to_dict()
        
        assert user_dict['id'] == user.id
        assert user_dict['username'] == user.username
        assert 'password_hash' not in user_dict  # Sensible, pas inclus
        assert 'created_at' in user_dict
    
    def test_find_by_username(self, db_session, user):
        """Test de la recherche par username"""
        found = User.find_by_username(user.username)
        
        assert found is not None
        assert found.id == user.id
        assert found.username == user.username
    
    def test_find_by_username_not_found(self, db_session):
        """Test recherche username inexistant"""
        found = User.find_by_username('nonexistent')
        
        assert found is None
    
    def test_find_by_email(self, db_session, user):
        """Test de la recherche par email"""
        found = User.find_by_email(user.email)
        
        assert found is not None
        assert found.id == user.id


class TestPostModel:
    """Tests du modèle Post"""
    
    def test_create_post(self, db_session, user):
        """Test création d'un post"""
        post = Post(
            title='Test Post',
            content='Content here',
            slug='test-post',
            author_id=user.id
        )
        db_session.add(post)
        db_session.commit()
        
        assert post.id is not None
        assert post.title == 'Test Post'
        assert post.author_id == user.id
        assert post.published is False  # Valeur par défaut
    
    def test_post_slug_generation(self):
        """Test génération automatique du slug"""
        slug = Post.generate_slug("Mon Premier Post !")
        
        assert slug == "mon-premier-post"
        assert slug.islower()
        assert ' ' not in slug
    
    def test_post_publish(self, db_session, post):
        """Test publication d'un post"""
        assert post.published is False
        assert post.published_at is None
        
        post.publish()
        
        assert post.published is True
        assert post.published_at is not None


# ═══ FICHIER tests/unit/test_validators.py ═══

"""
Tests des validateurs personnalisés
"""

import pytest
from marshmallow import ValidationError
from app.validators import (
    ValidatePasswordStrength,
    ValidatePhoneNumber,
    ValidateUnique
)
from app.models.user import User


class TestPasswordValidator:
    """Tests du validateur de mot de passe"""
    
    def test_valid_password(self):
        """Test mot de passe valide"""
        validator = ValidatePasswordStrength()
        
        # Ne lève pas d'exception
        validator('ValidPass123!')
    
    def test_too_short(self):
        """Test mot de passe trop court"""
        validator = ValidatePasswordStrength(min_length=10)
        
        with pytest.raises(ValidationError) as exc_info:
            validator('Short1!')
        
        assert 'at least 10 characters' in str(exc_info.value)
    
    def test_no_uppercase(self):
        """Test sans majuscule"""
        validator = ValidatePasswordStrength()
        
        with pytest.raises(ValidationError) as exc_info:
            validator('password123!')
        
        assert 'uppercase' in str(exc_info.value)
    
    def test_no_digit(self):
        """Test sans chiffre"""
        validator = ValidatePasswordStrength()
        
        with pytest.raises(ValidationError) as exc_info:
            validator('Password!')
        
        assert 'digit' in str(exc_info.value)


class TestPhoneValidator:
    """Tests du validateur de téléphone"""
    
    def test_valid_french_phone(self):
        """Test numéro français valide"""
        validator = ValidatePhoneNumber(countries=['FR'])
        
        validator('+33123456789')
        validator('0123456789')
    
    def test_invalid_phone(self):
        """Test numéro invalide"""
        validator = ValidatePhoneNumber(countries=['FR'])
        
        with pytest.raises(ValidationError):
            validator('123')  # Trop court


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         TESTS D'INTÉGRATION                         ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER tests/integration/test_auth_endpoints.py ═══

"""
Tests d'intégration des endpoints d'authentification
"""

import pytest


class TestAuthRegistration:
    """Tests de l'endpoint de registration"""
    
    def test_register_success(self, client, user_data):
        """Test inscription réussie"""
        response = client.post('/api/v1/auth/register', json=user_data)
        
        assert response.status_code == 201
        
        data = response.get_json()
        assert 'user' in data
        assert 'tokens' in data
        assert data['user']['username'] == user_data['username']
        assert data['tokens']['access_token'] is not None
    
    def test_register_duplicate_username(self, client, user, user_data):
        """Test inscription avec username existant"""
        response = client.post('/api/v1/auth/register', json=user_data)
        
        assert response.status_code == 409
        
        data = response.get_json()
        assert 'error' in data
        assert 'Username already exists' in data['message']
    
    def test_register_invalid_email(self, client, user_data):
        """Test inscription avec email invalide"""
        user_data['email'] = 'invalid-email'
        
        response = client.post('/api/v1/auth/register', json=user_data)
        
        assert response.status_code == 400
        
        data = response.get_json()
        assert 'validation' in data['error'].lower()
    
    def test_register_weak_password(self, client, user_data):
        """Test inscription avec mot de passe faible"""
        user_data['password'] = 'weak'
        
        response = client.post('/api/v1/auth/register', json=user_data)
        
        assert response.status_code == 400


class TestAuthLogin:
    """Tests de l'endpoint de login"""
    
    def test_login_success(self, client, user, user_data):
        """Test login réussi"""
        response = client.post('/api/v1/auth/login', json={
            'username': user_data['username'],
            'password': user_data['password']
        })
        
        assert response.status_code == 200
        
        data = response.get_json()
        assert 'tokens' in data
        assert data['tokens']['access_token'] is not None
        assert data['tokens']['refresh_token'] is not None
        assert data['tokens']['token_type'] == 'Bearer'
    
    def test_login_wrong_password(self, client, user):
        """Test login avec mauvais mot de passe"""
        response = client.post('/api/v1/auth/login', json={
            'username': user.username,
            'password': 'WrongPassword123!'
        })
        
        assert response.status_code == 401
        
        data = response.get_json()
        assert 'error' in data
    
    def test_login_nonexistent_user(self, client):
        """Test login avec utilisateur inexistant"""
        response = client.post('/api/v1/auth/login', json={
            'username': 'nonexistent',
            'password': 'Pass123!'
        })
        
        assert response.status_code == 401
    
    def test_login_inactive_user(self, client, user, user_data):
        """Test login avec compte désactivé"""
        user.active = False
        user.save()
        
        response = client.post('/api/v1/auth/login', json={
            'username': user_data['username'],
            'password': user_data['password']
        })
        
        assert response.status_code == 403
        assert 'disabled' in response.get_json()['message'].lower()


class TestAuthProtected:
    """Tests des routes protégées"""
    
    def test_protected_route_without_token(self, client):
        """Test route protégée sans token"""
        response = client.get('/api/v1/auth/me')
        
        assert response.status_code == 401
    
    def test_protected_route_with_valid_token(self, client, auth_headers):
        """Test route protégée avec token valide"""
        response = client.get('/api/v1/auth/me', headers=auth_headers)
        
        assert response.status_code == 200
        
        data = response.get_json()
        assert 'id' in data
        assert 'username' in data
    
    def test_protected_route_with_invalid_token(self, client):
        """Test route protégée avec token invalide"""
        headers = {
            'Authorization': 'Bearer invalid-token-here',
            'Content-Type': 'application/json'
        }
        
        response = client.get('/api/v1/auth/me', headers=headers)
        
        assert response.status_code == 422  # Unprocessable Entity


# ═══ FICHIER tests/integration/test_user_endpoints.py ═══

"""
Tests d'intégration des endpoints users
"""


class TestUserCRUD:
    """Tests CRUD des utilisateurs"""
    
    def test_get_users_list(self, client, multiple_users, auth_headers):
        """Test récupération liste d'utilisateurs"""
        response = client.get('/api/v1/users', headers=auth_headers)
        
        assert response.status_code == 200
        
        data = response.get_json()
        assert 'users' in data
        assert len(data['users']) == 5
    
    def test_get_user_by_id(self, client, user, auth_headers):
        """Test récupération utilisateur par ID"""
        response = client.get(f'/api/v1/users/{user.id}', headers=auth_headers)
        
        assert response.status_code == 200
        
        data = response.get_json()
        assert data['id'] == user.id
        assert data['username'] == user.username
    
    def test_get_user_not_found(self, client, auth_headers):
        """Test récupération utilisateur inexistant"""
        response = client.get('/api/v1/users/99999', headers=auth_headers)
        
        assert response.status_code == 404
    
    def test_update_own_profile(self, client, user, auth_headers):
        """Test mise à jour de son propre profil"""
        update_data = {
            'first_name': 'Updated',
            'bio': 'New bio'
        }
        
        response = client.patch(
            f'/api/v1/users/{user.id}',
            json=update_data,
            headers=auth_headers
        )
        
        assert response.status_code == 200
        
        data = response.get_json()
        assert data['first_name'] == 'Updated'
        assert data['bio'] == 'New bio'
    
    def test_update_other_user_forbidden(self, client, multiple_users, auth_headers):
        """Test mise à jour d'un autre utilisateur (interdit)"""
        other_user = multiple_users[0]
        
        response = client.patch(
            f'/api/v1/users/{other_user.id}',
            json={'bio': 'Hacked!'},
            headers=auth_headers
        )
        
        assert response.status_code == 403
    
    def test_delete_user_as_admin(self, client, user, admin_auth_headers):
        """Test suppression utilisateur par admin"""
        response = client.delete(
            f'/api/v1/users/{user.id}',
            headers=admin_auth_headers
        )
        
        assert response.status_code == 204


class TestUserPagination:
    """Tests de pagination"""
    
    def test_pagination_first_page(self, client, multiple_users, auth_headers):
        """Test première page"""
        response = client.get(
            '/api/v1/users?page=1&per_page=2',
            headers=auth_headers
        )
        
        assert response.status_code == 200
        
        data = response.get_json()
        assert data['page'] == 1
        assert data['per_page'] == 2
        assert len(data['items']) == 2
        assert data['total_items'] == 5
        assert data['total_pages'] == 3
        assert data['has_next'] is True
        assert data['has_prev'] is False
    
    def test_pagination_middle_page(self, client, multiple_users, auth_headers):
        """Test page du milieu"""
        response = client.get(
            '/api/v1/users?page=2&per_page=2',
            headers=auth_headers
        )
        
        data = response.get_json()
        assert data['has_next'] is True
        assert data['has_prev'] is True


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                          TESTS AVANCÉS                              ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ PARAMETRIZED TESTS ═══

@pytest.mark.parametrize('username,email,password,expected_status', [
    ('valid', 'valid@example.com', 'ValidPass123!', 201),  # Valide
    ('ab', 'valid@example.com', 'ValidPass123!', 400),     # Username trop court
    ('valid', 'invalid-email', 'ValidPass123!', 400),      # Email invalide
    ('valid', 'valid@example.com', 'weak', 400),           # Password faible
])
def test_register_various_inputs(client, username, email, password, expected_status):
    """
    Test inscription avec différentes combinaisons d'entrées
    
    Parametrized test = un seul test, plusieurs jeux de données
    """
    response = client.post('/api/v1/auth/register', json={
        'username': username,
        'email': email,
        'password': password
    })
    
    assert response.status_code == expected_status


# ═══ TESTS DE PERMISSIONS RBAC ═══

@pytest.mark.auth
class TestRBACPermissions:
    """Tests des permissions RBAC"""
    
    def test_admin_can_delete_any_user(self, client, user, admin_auth_headers):
        """Admin peut supprimer n'importe quel utilisateur"""
        response = client.delete(
            f'/api/v1/users/{user.id}',
            headers=admin_auth_headers
        )
        
        assert response.status_code == 204
    
    def test_user_cannot_delete_other_user(self, client, multiple_users, auth_headers):
        """User ne peut pas supprimer un autre utilisateur"""
        other_user = multiple_users[0]
        
        response = client.delete(
            f'/api/v1/users/{other_user.id}',
            headers=auth_headers
        )
        
        assert response.status_code == 403
    
    @pytest.mark.parametrize('role,can_publish', [
        ('user', False),
        ('editor', True),
        ('admin', True),
    ])
    def test_post_publish_permissions(self, db_session, role, can_publish):
        """Test permissions de publication selon le rôle"""
        user = User(
            username=f'test_{role}',
            email=f'{role}@example.com',
            password_hash=User.hash_password('Pass123!'),
            role=role
        )
        db_session.add(user)
        db_session.commit()
        
        # Vérifier permission
        has_permission = user.has_permission('posts:publish')
        assert has_permission == can_publish


# ═══ TESTS DE RATE LIMITING ═══

@pytest.mark.slow
class TestRateLimiting:
    """Tests du rate limiting"""
    
    def test_rate_limit_exceeded(self, client):
        """Test dépassement de limite de requêtes"""
        # Faire beaucoup de requêtes rapidement
        for i in range(10):
            response = client.post('/api/v1/auth/login', json={
                'username': 'test',
                'password': 'test'
            })
        
        # La 11ème devrait être rejetée
        response = client.post('/api/v1/auth/login', json={
            'username': 'test',
            'password': 'test'
        })
        
        assert response.status_code == 429  # Too Many Requests
        
        data = response.get_json()
        assert 'rate limit' in data['message'].lower()


# ═══ TESTS DE MOCKING ═══

def test_email_sending_on_registration(client, user_data, mock_email_send):
    """Test que l'email est envoyé lors de l'inscription"""
    response = client.post('/api/v1/auth/register', json=user_data)
    
    assert response.status_code == 201
    
    # Vérifier que send_email a été appelé
    mock_email_send.assert_called_once()
    
    # Vérifier les arguments
    call_args = mock_email_send.call_args
    assert user_data['email'] in call_args[0]


# ═══ TESTS D'ERREURS ═══

def test_500_error_handling(client, mocker):
    """Test gestion des erreurs 500"""
    # Mocker une fonction pour qu'elle lève une exception
    mocker.patch(
        'app.models.user.User.query',
        side_effect=Exception('Database error')
    )
    
    response = client.get('/api/v1/users')
    
    assert response.status_code == 500
    
    data = response.get_json()
    assert 'error' in data


# ═══ TESTS DE FIXTURES ═══

def test_fixture_user_is_persisted(user, db_session):
    """Test que la fixture user persiste bien en DB"""
    # Requête directe à la DB
    found = db_session.query(User).filter_by(id=user.id).first()
    
    assert found is not None
    assert found.id == user.id


# ═══ COMMANDES PYTEST ═══

"""
# Lancer tous les tests
pytest

# Tests avec coverage
pytest --cov=app --cov-report=html

# Tests spécifiques
pytest tests/unit/
pytest tests/integration/

# Tests avec markers
pytest -m unit          # Seulement tests unitaires
pytest -m integration   # Seulement tests d'intégration
pytest -m "not slow"    # Exclure tests lents

# Tests en parallèle (avec pytest-xdist)
pytest -n 4             # 4 workers

# Tests avec output détaillé
pytest -v -s            # -s affiche les prints

# Tests qui échouent en premier
pytest -x               # Stop au premier échec
pytest --maxfail=3      # Stop après 3 échecs

# Re-run des tests qui ont échoué
pytest --lf             # Last failed

# Tests spécifiques
pytest tests/unit/test_models.py::TestUserModel::test_create_user
"""


═══════════════════════════════════════════════════════════════════════════════
  8.2 DEBUGGING & LOGGING
═══════════════════════════════════════════════════════════════════════════════

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         LOGGING CONFIGURATION                       ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER app/logging_config.py ═══

"""
Configuration du logging pour l'application
"""

import logging
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
import os
from pythonjsonlogger import jsonlogger


def setup_logging(app):
    """
    Configure le logging pour l'application Flask
    
    Logs:
    - Console (dev): INFO+
    - Fichier application.log: INFO+
    - Fichier error.log: ERROR+
    - Format JSON pour parsing facile
    """
    
    # Créer dossier logs
    log_dir = 'logs'
    if not os.path.exists(log_dir):
        os.makedirs(log_dir)
    
    # ═══ FORMATTERS ═══
    
    # Format simple pour console (dev)
    console_formatter = logging.Formatter(
        '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    
    # Format JSON pour fichiers (production)
    json_formatter = jsonlogger.JsonFormatter(
        '%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d',
        timestamp=True
    )
    
    # ═══ HANDLERS ═══
    
    # Console handler (dev uniquement)
    if app.config['DEBUG']:
        console_handler = logging.StreamHandler()
        console_handler.setLevel(logging.INFO)
        console_handler.setFormatter(console_formatter)
        app.logger.addHandler(console_handler)
    
    # File handler - Application logs (rotation par taille)
    app_handler = RotatingFileHandler(
        os.path.join(log_dir, 'application.log'),
        maxBytes=10 * 1024 * 1024,  # 10MB
        backupCount=10
    )
    app_handler.setLevel(logging.INFO)
    app_handler.setFormatter(json_formatter)
    app.logger.addHandler(app_handler)
    
    # File handler - Error logs (rotation par jour)
    error_handler = TimedRotatingFileHandler(
        os.path.join(log_dir, 'error.log'),
        when='midnight',
        interval=1,
        backupCount=30  # Garder 30 jours
    )
    error_handler.setLevel(logging.ERROR)
    error_handler.setFormatter(json_formatter)
    app.logger.addHandler(error_handler)
    
    # Niveau global
    app.logger.setLevel(logging.INFO)
    
    # Désactiver propagation
    app.logger.propagate = False
    
    # Log de démarrage
    app.logger.info('Application started', extra={
        'environment': app.config['ENV'],
        'debug': app.config['DEBUG']
    })


# ═══ UTILISATION DANS LES ROUTES ═══

from flask import current_app

@app.route('/api/users', methods=['POST'])
def create_user():
    """Crée un utilisateur avec logging"""
    
    # Log de début
    current_app.logger.info('Creating new user', extra={
        'remote_addr': request.remote_addr,
        'user_agent': request.user_agent.string
    })
    
    try:
        data = request.json
        
        # Log des données (sans password!)
        current_app.logger.debug('User data received', extra={
            'username': data.get('username'),
            'email': data.get('email')
        })
        
        # Créer user
        user = User(**data)
        user.save()
        
        # Log de succès
        current_app.logger.info('User created successfully', extra={
            'user_id': user.id,
            'username': user.username
        })
        
        return jsonify(user.to_dict()), 201
        
    except ValidationError as e:
        # Log d'erreur de validation
        current_app.logger.warning('User creation validation failed', extra={
            'errors': e.messages,
            'data': data
        })
        return jsonify({'error': 'Validation failed'}), 400
        
    except Exception as e:
        # Log d'erreur critique
        current_app.logger.error('User creation failed', extra={
            'error': str(e),
            'data': data
        }, exc_info=True)  # Inclut le traceback
        
        return jsonify({'error': 'Internal server error'}), 500


# ═══ LOGGING DECORATOR ═══

from functools import wraps
import time

def log_execution_time(func):
    """
    Décorateur pour logger le temps d'exécution
    
    Usage:
        @app.route('/api/slow-endpoint')
        @log_execution_time
        def slow_endpoint():
            pass
    """
    @wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        
        try:
            result = func(*args, **kwargs)
            execution_time = time.time() - start_time
            
            current_app.logger.info(
                f'{func.__name__} executed',
                extra={
                    'function': func.__name__,
                    'execution_time': execution_time,
                    'status': 'success'
                }
            )
            
            return result
            
        except Exception as e:
            execution_time = time.time() - start_time
            
            current_app.logger.error(
                f'{func.__name__} failed',
                extra={
                    'function': func.__name__,
                    'execution_time': execution_time,
                    'error': str(e),
                    'status': 'error'
                },
                exc_info=True
            )
            
            raise
    
    return wrapper


# ═══ REQUEST LOGGING MIDDLEWARE ═══

@app.before_request
def log_request():
    """Log chaque requête entrante"""
    current_app.logger.info('Request received', extra={
        'method': request.method,
        'path': request.path,
        'remote_addr': request.remote_addr,
        'user_agent': request.user_agent.string,
        'request_id': g.get('request_id')  # Si implémenté
    })


@app.after_request
def log_response(response):
    """Log chaque réponse sortante"""
    current_app.logger.info('Request completed', extra={
        'method': request.method,
        'path': request.path,
        'status_code': response.status_code,
        'response_time': g.get('request_start_time', 0),
        'request_id': g.get('request_id')
    })
    
    return response


═══════════════════════════════════════════════════════════════════════════════
  8.3 MONITORING & HEALTH CHECKS
═══════════════════════════════════════════════════════════════════════════════

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                            HEALTH CHECKS                            ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER app/api/health.py ═══

"""
Endpoints de health check pour monitoring
"""

from flask import Blueprint, jsonify
from app import db
from datetime import datetime
import psutil  # Pour stats système

health_bp = Blueprint('health', __name__)


@health_bp.route('/health', methods=['GET'])
def health_check():
    """
    Health check basique
    
    Retourne 200 si l'application est up
    Utilisé par load balancers
    """
    return jsonify({
        'status': 'healthy',
        'timestamp': datetime.utcnow().isoformat()
    }), 200


@health_bp.route('/health/detailed', methods=['GET'])
def detailed_health():
    """
    Health check détaillé
    
    Vérifie:
    - Application
    - Base de données
    - Cache (Redis)
    - Services externes
    """
    
    health_status = {
        'status': 'healthy',
        'timestamp': datetime.utcnow().isoformat(),
        'checks': {}
    }
    
    # ═══ CHECK DATABASE ═══
    try:
        # Simple query pour vérifier connexion
        db.session.execute('SELECT 1')
        health_status['checks']['database'] = {
            'status': 'healthy',
            'message': 'Database connection OK'
        }
    except Exception as e:
        health_status['checks']['database'] = {
            'status': 'unhealthy',
            'message': str(e)
        }
        health_status['status'] = 'unhealthy'
    
    # ═══ CHECK REDIS (si utilisé) ═══
    try:
        from app.extensions import redis_client
        redis_client.ping()
        health_status['checks']['redis'] = {
            'status': 'healthy',
            'message': 'Redis connection OK'
        }
    except Exception as e:
        health_status['checks']['redis'] = {
            'status': 'unhealthy',
            'message': str(e)
        }
        health_status['status'] = 'degraded'  # Non-critique
    
    # ═══ SYSTEM STATS ═══
    health_status['system'] = {
        'cpu_percent': psutil.cpu_percent(interval=1),
        'memory_percent': psutil.virtual_memory().percent,
        'disk_percent': psutil.disk_usage('/').percent
    }
    
    # Code de statut HTTP selon health
    status_code = 200 if health_status['status'] == 'healthy' else 503
    
    return jsonify(health_status), status_code


@health_bp.route('/health/ready', methods=['GET'])
def readiness_check():
    """
    Readiness check (Kubernetes)
    
    Vérifie si l'app est prête à recevoir du trafic
    """
    try:
        # Vérifier DB
        db.session.execute('SELECT 1')
        
        return jsonify({
            'status': 'ready',
            'timestamp': datetime.utcnow().isoformat()
        }), 200
        
    except Exception as e:
        return jsonify({
            'status': 'not ready',
            'error': str(e),
            'timestamp': datetime.utcnow().isoformat()
        }), 503


@health_bp.route('/health/live', methods=['GET'])
def liveness_check():
    """
    Liveness check (Kubernetes)
    
    Vérifie si l'app est vivante (pas crashée/deadlock)
    """
    return jsonify({
        'status': 'alive',
        'timestamp': datetime.utcnow().isoformat()
    }), 200


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                           MÉTRIQUES (PROMETHEUS)                    ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ INSTALLATION ═══

pip install prometheus-flask-exporter


# ═══ CONFIGURATION ═══

from prometheus_flask_exporter import PrometheusMetrics

# Dans app/__init__.py
def create_app(config_name):
    app = Flask(__name__)
    
    # Initialiser Prometheus
    metrics = PrometheusMetrics(app)
    
    # Métriques personnalisées
    metrics.info('app_info', 'Application info', version='1.0.0')
    
    # Endpoint metrics automatiquement disponible sur /metrics
    
    return app


# ═══ MÉTRIQUES PERSONNALISÉES ═══

from prometheus_client import Counter, Histogram, Gauge

# Compteur de requêtes
request_count = Counter(
    'http_requests_total',
    'Total HTTP requests',
    ['method', 'endpoint', 'status']
)

# Histogramme de latence
request_latency = Histogram(
    'http_request_duration_seconds',
    'HTTP request latency',
    ['method', 'endpoint']
)

# Gauge pour métrique instantanée
active_users = Gauge(
    'active_users_total',
    'Number of active users'
)


# Utilisation dans les routes
@app.route('/api/users', methods=['POST'])
def create_user():
    with request_latency.labels(method='POST', endpoint='/api/users').time():
        try:
            # ... logique
            request_count.labels(method='POST', endpoint='/api/users', status=201).inc()
            return jsonify(result), 201
        except Exception as e:
            request_count.labels(method='POST', endpoint='/api/users', status=500).inc()
            raise



┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         APM (APPLICATION PERFORMANCE MONITORING)    ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

[?] POURQUOI APM?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

APM = Application Performance Monitoring

Permet de:
  [OK] Tracer les requêtes de bout en bout
  [OK] Identifier les bottlenecks
  [OK] Mesurer les temps de réponse
  [OK] Détecter les requêtes lentes
  [OK] Analyser les dépendances (DB, APIs externes)
  [OK] Profiler le code en production


# ═══ OPTION 1: NEW RELIC ═══

"""
New Relic est un APM complet et populaire
"""

# Installation
pip install newrelic

# Configuration: newrelic.ini
"""
[newrelic]
license_key = YOUR_LICENSE_KEY
app_name = My Flask API
monitor_mode = true
log_level = info

# Transaction tracer
transaction_tracer.enabled = true
transaction_tracer.transaction_threshold = apdex_f
transaction_tracer.record_sql = obfuscated
transaction_tracer.stack_trace_threshold = 0.5

# Error collector
error_collector.enabled = true
error_collector.ignore_status_codes = 404

# Browser monitoring
browser_monitoring.auto_instrument = true

# Database tracer
datastore_tracer.instance_reporting.enabled = true
datastore_tracer.database_name_reporting.enabled = true
"""

# Initialisation dans app
import newrelic.agent
newrelic.agent.initialize('newrelic.ini')

# Wrapper l'application
from newrelic.agent import WSGIApplicationWrapper
app = WSGIApplicationWrapper(app)

# Lancer avec newrelic-admin
# newrelic-admin run-program gunicorn app:app


# ═══ OPTION 2: DATADOG APM ═══

"""
Datadog offre un APM avec tracing distribué
"""

# Installation
pip install ddtrace

# Initialisation
from ddtrace import patch_all
patch_all()

# Ou sélectif
from ddtrace import patch
patch(logging=True, requests=True, sqlalchemy=True)

# Configuration via variables d'environnement
"""
DD_AGENT_HOST=localhost
DD_TRACE_AGENT_PORT=8126
DD_SERVICE=my-flask-api
DD_ENV=production
DD_VERSION=1.0.0
DD_TAGS=team:backend,component:api
"""

# Lancer avec ddtrace
# ddtrace-run gunicorn app:app

# Traces personnalisées
from ddtrace import tracer

@app.route('/api/complex-operation')
def complex_operation():
    with tracer.trace('custom.operation', service='api'):
        # Votre code
        result = do_something()
    
    return jsonify(result)


# ═══ OPTION 3: ELASTIC APM ═══

"""
Elastic APM s'intègre avec ELK Stack
"""

# Installation
pip install elastic-apm[flask]

# Configuration
from elasticapm.contrib.flask import ElasticAPM

app.config['ELASTIC_APM'] = {
    'SERVICE_NAME': 'my-flask-api',
    'SERVER_URL': 'http://localhost:8200',
    'ENVIRONMENT': 'production',
    'SECRET_TOKEN': 'your-secret-token',
    'CAPTURE_BODY': 'all',
    'TRANSACTION_SAMPLE_RATE': 1.0,
    'SPAN_FRAMES_MIN_DURATION': '5ms',
}

apm = ElasticAPM(app)

# Transactions personnalisées
from elasticapm import capture_span

@capture_span('database.query')
def slow_query():
    # Votre requête
    pass


# ═══ OPTION 4: JAEGER (Open Source) ═══

"""
Jaeger est un système de tracing distribué open source
"""

# Installation
pip install jaeger-client opentracing-instrumentation

# Configuration
from jaeger_client import Config
from flask_opentracing import FlaskTracing

def init_tracer(service_name='my-api'):
    config = Config(
        config={
            'sampler': {
                'type': 'const',
                'param': 1,
            },
            'local_agent': {
                'reporting_host': 'localhost',
                'reporting_port': 6831,
            },
            'logging': True,
        },
        service_name=service_name,
    )
    return config.initialize_tracer()

# Initialiser
tracer = init_tracer()
tracing = FlaskTracing(tracer, True, app)

# Spans personnalisés
from opentracing import tags

@app.route('/api/users/<int:user_id>')
def get_user(user_id):
    with tracer.start_active_span('get_user') as scope:
        scope.span.set_tag('user.id', user_id)
        
        # Span pour DB query
        with tracer.start_active_span('db.query.user') as db_scope:
            user = User.query.get(user_id)
        
        return jsonify(user.to_dict())


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         ERROR TRACKING                              ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

[?] POURQUOI Error Tracking?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Error Tracking permet de:
  [OK] Capturer toutes les exceptions
  [OK] Recevoir des alertes en temps réel
  [OK] Voir le contexte complet (stack trace, request, user)
  [OK] Grouper les erreurs similaires
  [OK] Tracker la résolution des bugs


# ═══ OPTION 1: SENTRY (Recommandé) ═══

"""
Sentry est LA référence pour error tracking
"""

# Installation
pip install sentry-sdk[flask]

# Configuration
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration

sentry_sdk.init(
    dsn="https://your-dsn@sentry.io/project-id",
    
    # Intégrations
    integrations=[
        FlaskIntegration(),
        SqlalchemyIntegration(),
    ],
    
    # Environnement
    environment='production',
    
    # Release tracking
    release='my-app@1.0.0',
    
    # Sample rate (100% en prod, moins en dev)
    traces_sample_rate=1.0,
    
    # Profiling
    profiles_sample_rate=1.0,
    
    # Options avancées
    attach_stacktrace=True,
    send_default_pii=False,  # Ne pas envoyer PII (emails, IPs)
    max_breadcrumbs=50,
    
    # Filtrer les erreurs
    before_send=lambda event, hint: event if should_send_event(event) else None,
)


def should_send_event(event):
    """
    Filtre les événements avant envoi à Sentry
    
    Évite d'envoyer:
    - 404 (pas des erreurs)
    - Erreurs de dev (localhost)
    """
    # Ignorer 404
    if event.get('exception', {}).get('values', [{}])[0].get('type') == 'NotFound':
        return False
    
    # Ignorer localhost en dev
    if app.config['DEBUG']:
        return False
    
    return True


# Capturer exceptions manuellement
from sentry_sdk import capture_exception, capture_message

try:
    risky_operation()
except Exception as e:
    # Capturer avec contexte
    capture_exception(e)


# Ajouter du contexte aux erreurs
from sentry_sdk import set_user, set_tag, set_context

@app.before_request
def add_sentry_context():
    """Ajoute contexte utilisateur à chaque requête"""
    user = get_current_user()
    
    if user:
        set_user({
            'id': user.id,
            'username': user.username,
            'email': user.email
        })
    
    # Tags personnalisés
    set_tag('request_id', g.get('request_id'))
    set_tag('endpoint', request.endpoint)
    
    # Contexte additionnel
    set_context('request', {
        'method': request.method,
        'url': request.url,
        'headers': dict(request.headers),
        'data': request.get_json(silent=True)
    })


# Breadcrumbs (fil d'Ariane)
from sentry_sdk import add_breadcrumb

def perform_operation():
    add_breadcrumb(
        category='operation',
        message='Starting database query',
        level='info'
    )
    
    result = db.session.query(User).all()
    
    add_breadcrumb(
        category='operation',
        message=f'Query returned {len(result)} users',
        level='info'
    )
    
    return result


# Performance monitoring
from sentry_sdk import start_transaction

@app.route('/api/checkout')
def checkout():
    with start_transaction(op='http.server', name='POST /api/checkout') as transaction:
        
        # Span pour validation
        with transaction.start_child(op='validation') as span:
            validate_cart()
        
        # Span pour paiement
        with transaction.start_child(op='payment') as span:
            span.set_tag('payment.method', 'credit_card')
            process_payment()
        
        # Span pour email
        with transaction.start_child(op='email') as span:
            send_confirmation_email()
        
        return jsonify({'status': 'success'})


# ═══ OPTION 2: ROLLBAR ═══

"""
Alternative à Sentry
"""

# Installation
pip install rollbar

# Configuration
import rollbar
import rollbar.contrib.flask

rollbar.init(
    access_token='your-access-token',
    environment='production',
    code_version='1.0.0',
    root=os.path.dirname(os.path.realpath(__file__)),
)

# Intégration Flask
rollbar.contrib.flask.report_exception(app)

# Utilisation
@app.route('/api/endpoint')
def endpoint():
    try:
        risky_operation()
    except Exception as e:
        rollbar.report_exc_info()
        raise


# ═══ OPTION 3: BUGSNAG ═══

"""
Autre alternative populaire
"""

# Installation
pip install bugsnag

# Configuration
import bugsnag
from bugsnag.flask import handle_exceptions

bugsnag.configure(
    api_key='your-api-key',
    project_root='/path/to/app',
    app_version='1.0.0',
)

handle_exceptions(app)


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                      PERFORMANCE MONITORING                         ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ PROFILING EN PRODUCTION ═══

"""
Profiling pour identifier les bottlenecks
"""

# Installation
pip install py-spy

# Profiling en production (sans modifier le code)
# py-spy record -o profile.svg --pid <PID>
# py-spy top --pid <PID>


# ═══ FLASK-PROFILER ═══

"""
Profiler intégré à Flask
"""

# Installation
pip install flask_profiler

# Configuration
app.config['flask_profiler'] = {
    'enabled': app.config['DEBUG'],
    'storage': {
        'engine': 'sqlite',
        'FILE': 'profiler.db'
    },
    'basicAuth': {
        'enabled': True,
        'username': 'admin',
        'password': 'admin'
    },
    'ignore': [
        '^/static/.*'
    ]
}

from flask_profiler import Profiler
profiler = Profiler()
profiler.init_app(app)

# Dashboard accessible sur /flask-profiler


# ═══ MONITORING DES REQUÊTES LENTES ═══

"""
Logger automatiquement les requêtes lentes
"""

from functools import wraps
import time

SLOW_REQUEST_THRESHOLD = 1.0  # secondes

def monitor_slow_requests(f):
    """Décorateur pour monitorer les requêtes lentes"""
    @wraps(f)
    def decorated_function(*args, **kwargs):
        start_time = time.time()
        
        try:
            result = f(*args, **kwargs)
            return result
        finally:
            duration = time.time() - start_time
            
            if duration > SLOW_REQUEST_THRESHOLD:
                current_app.logger.warning(
                    'Slow request detected',
                    extra={
                        'endpoint': request.endpoint,
                        'method': request.method,
                        'path': request.path,
                        'duration': duration,
                        'args': request.args,
                        'user_id': g.get('user_id')
                    }
                )
    
    return decorated_function


# Appliquer globalement
@app.before_request
def start_timer():
    g.start_time = time.time()


@app.after_request
def log_request_time(response):
    if hasattr(g, 'start_time'):
        duration = time.time() - g.start_time
        
        if duration > SLOW_REQUEST_THRESHOLD:
            current_app.logger.warning(
                'Slow request',
                extra={
                    'endpoint': request.endpoint,
                    'duration': duration,
                    'status_code': response.status_code
                }
            )
    
    return response


# ═══ MONITORING DES REQUÊTES SQL ═══

"""
Détecter les requêtes SQL lentes (N+1 problem)
"""

from sqlalchemy import event
from sqlalchemy.engine import Engine

@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
    """Enregistre le début de la requête"""
    conn.info.setdefault('query_start_time', []).append(time.time())


@event.listens_for(Engine, "after_cursor_execute")
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
    """Log les requêtes lentes"""
    total_time = time.time() - conn.info['query_start_time'].pop(-1)
    
    if total_time > 0.1:  # 100ms
        current_app.logger.warning(
            'Slow SQL query',
            extra={
                'duration': total_time,
                'statement': statement,
                'parameters': parameters
            }
        )


# ═══ DÉTECTION DU N+1 PROBLEM ═══

"""
Le N+1 problem survient quand on fait N requêtes en boucle
"""

# [X] MAUVAIS (N+1)
users = User.query.all()
for user in users:
    # Requête SQL pour CHAQUE user!
    posts_count = user.posts.count()


# [OK] BON (1 requête)
from sqlalchemy.orm import joinedload

users = User.query.options(
    joinedload(User.posts)
).all()

for user in users:
    # Pas de requête, déjà chargé!
    posts_count = len(user.posts)


# Ou avec func.count
from sqlalchemy import func

users_with_counts = db.session.query(
    User,
    func.count(Post.id).label('posts_count')
).outerjoin(Post).group_by(User.id).all()


# ═══ CACHING POUR PERFORMANCE ═══

"""
Cache les réponses coûteuses
"""

# Installation
pip install Flask-Caching

# Configuration
from flask_caching import Cache

cache = Cache(app, config={
    'CACHE_TYPE': 'redis',
    'CACHE_REDIS_URL': 'redis://localhost:6379/0',
    'CACHE_DEFAULT_TIMEOUT': 300
})

# Usage simple
@app.route('/api/stats')
@cache.cached(timeout=3600)  # Cache 1 heure
def get_stats():
    # Calcul coûteux
    stats = compute_expensive_stats()
    return jsonify(stats)


# Cache avec clé dynamique
@app.route('/api/users/<int:user_id>')
@cache.cached(timeout=300, key_prefix=lambda: f'user_{user_id}')
def get_user(user_id):
    user = User.query.get_or_404(user_id)
    return jsonify(user.to_dict())


# Invalidation manuelle
@app.route('/api/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
    user = User.query.get_or_404(user_id)
    user.update(**request.json)
    
    # Invalider le cache
    cache.delete(f'user_{user_id}')
    
    return jsonify(user.to_dict())


# Cache de fonction
@cache.memoize(timeout=3600)
def get_user_posts_count(user_id):
    """Cache le résultat de cette fonction"""
    return Post.query.filter_by(author_id=user_id).count()


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         ALERTING & NOTIFICATIONS                    ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ ALERTES PERSONNALISÉES ═══

"""
Envoyer des alertes pour événements critiques
"""

def send_alert(message, severity='error', context=None):
    """
    Envoie une alerte multi-canal
    
    Args:
        message (str): Message d'alerte
        severity (str): 'info', 'warning', 'error', 'critical'
        context (dict): Contexte additionnel
    """
    
    # 1. Log
    logger = current_app.logger
    log_method = getattr(logger, severity, logger.error)
    log_method(message, extra=context or {})
    
    # 2. Sentry (si erreur/critique)
    if severity in ['error', 'critical']:
        from sentry_sdk import capture_message
        capture_message(message, level=severity)
    
    # 3. Slack
    if app.config.get('SLACK_WEBHOOK_URL'):
        send_slack_alert(message, severity, context)
    
    # 4. Email (si critique)
    if severity == 'critical' and app.config.get('ALERT_EMAIL'):
        send_email_alert(message, context)
    
    # 5. PagerDuty (si production + critique)
    if app.config['ENV'] == 'production' and severity == 'critical':
        trigger_pagerduty(message, context)


def send_slack_alert(message, severity, context):
    """Envoie une alerte Slack"""
    import requests
    
    color_map = {
        'info': '#36a64f',
        'warning': '#ff9900',
        'error': '#ff0000',
        'critical': '#8b0000'
    }
    
    payload = {
        'attachments': [{
            'color': color_map.get(severity, '#ff0000'),
            'title': f'{severity.upper()}: {message}',
            'text': json.dumps(context, indent=2) if context else '',
            'footer': f'{app.config["APP_NAME"]} - {app.config["ENV"]}',
            'ts': int(time.time())
        }]
    }
    
    try:
        requests.post(
            app.config['SLACK_WEBHOOK_URL'],
            json=payload,
            timeout=5
        )
    except Exception as e:
        current_app.logger.error(f'Failed to send Slack alert: {e}')


# Usage
@app.route('/api/critical-operation', methods=['POST'])
def critical_operation():
    try:
        result = perform_critical_task()
        return jsonify(result)
    except Exception as e:
        send_alert(
            'Critical operation failed',
            severity='critical',
            context={
                'error': str(e),
                'user_id': g.get('user_id'),
                'request_id': g.get('request_id')
            }
        )
        raise


# ═══ HEALTH CHECK ALERTING ═══

"""
Alerter si les health checks échouent
"""

def monitor_health_checks():
    """
    Fonction à exécuter périodiquement (cron)
    pour vérifier la santé de l'application
    """
    
    try:
        # Vérifier DB
        db.session.execute('SELECT 1')
    except Exception as e:
        send_alert(
            'Database connection failed',
            severity='critical',
            context={'error': str(e)}
        )
    
    try:
        # Vérifier Redis
        from app.extensions import redis_client
        redis_client.ping()
    except Exception as e:
        send_alert(
            'Redis connection failed',
            severity='error',
            context={'error': str(e)}
        )
    
    # Vérifier métriques système
    cpu_percent = psutil.cpu_percent(interval=1)
    if cpu_percent > 90:
        send_alert(
            f'High CPU usage: {cpu_percent}%',
            severity='warning',
            context={'cpu_percent': cpu_percent}
        )
    
    memory_percent = psutil.virtual_memory().percent
    if memory_percent > 90:
        send_alert(
            f'High memory usage: {memory_percent}%',
            severity='warning',
            context={'memory_percent': memory_percent}
        )


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    OBSERVABILITY COMPLÈTE (3 PILLIERS)              ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

"""
Les 3 piliers de l'observabilité:
  1. LOGS (événements)
  2. METRICS (mesures)
  3. TRACES (requêtes distribuées)
"""

# ═══ ARCHITECTURE D'OBSERVABILITÉ COMPLÈTE ═══

"""
Stack recommandée:

1. LOGS:
   - Collection: Fluentd / Logstash
   - Stockage: Elasticsearch
   - Visualisation: Kibana
   
2. METRICS:
   - Collection: Prometheus
   - Alerting: Alertmanager
   - Visualisation: Grafana
   
3. TRACES:
   - Collection: Jaeger / Zipkin
   - Visualisation: Jaeger UI

4. ERREURS:
   - Tracking: Sentry
   
5. APM:
   - Monitoring: New Relic / Datadog
"""


# ═══ CORRÉLATION DES 3 PILIERS ═══

"""
Request ID pour corréler logs, traces et métriques
"""

import uuid
from flask import g

@app.before_request
def add_request_id():
    """Génère un ID unique pour chaque requête"""
    request_id = request.headers.get('X-Request-ID', str(uuid.uuid4()))
    g.request_id = request_id
    
    # Ajouter à Sentry
    from sentry_sdk import set_tag
    set_tag('request_id', request_id)
    
    # Ajouter aux logs
    current_app.logger.info('Request started', extra={
        'request_id': request_id,
        'method': request.method,
        'path': request.path
    })


@app.after_request
def add_request_id_header(response):
    """Retourne le request ID dans les headers"""
    if hasattr(g, 'request_id'):
        response.headers['X-Request-ID'] = g.request_id
    return response


# ═══ DASHBOARD GRAFANA ═══

"""
Requêtes Prometheus pour dashboard Grafana:

1. Taux de requêtes (RPS):
   rate(http_requests_total[5m])

2. Latence p95:
   histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))

3. Taux d'erreurs:
   rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m])

4. Utilisateurs actifs:
   active_users_total

5. Requêtes par endpoint:
   sum by (endpoint) (rate(http_requests_total[5m]))
"""


═══════════════════════════════════════════════════════════════════════════════
  8.4 DEBUGGING AVANCÉ
═══════════════════════════════════════════════════════════════════════════════

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         DEBUGGER INTERACTIF                         ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ PDB - Python Debugger ═══

"""
Debugger natif Python
"""

# Ajouter un breakpoint
import pdb

@app.route('/api/debug')
def debug_endpoint():
    data = request.json
    
    # Breakpoint ici
    pdb.set_trace()
    
    result = process_data(data)
    return jsonify(result)

# Commandes PDB:
# n (next): ligne suivante
# s (step): entre dans la fonction
# c (continue): continue jusqu'au prochain breakpoint
# p variable: affiche la valeur d'une variable
# l (list): affiche le code autour
# q (quit): quitte le debugger


# ═══ IPDB - IPython Debugger (Meilleur) ═══

"""
Version améliorée de pdb avec autocomplétion
"""

# Installation
pip install ipdb

# Usage
import ipdb

@app.route('/api/debug')
def debug_endpoint():
    data = request.json
    
    # Breakpoint avec ipdb
    ipdb.set_trace()
    
    result = process_data(data)
    return jsonify(result)


# ═══ FLASK DEBUG TOOLBAR ═══

"""
Toolbar de debug dans le navigateur
"""

# Installation
pip install flask-debugtoolbar

# Configuration
from flask_debugtoolbar import DebugToolbarExtension

app.config['DEBUG_TB_ENABLED'] = app.config['DEBUG']
app.config['DEBUG_TB_INTERCEPT_REDIRECTS'] = False

toolbar = DebugToolbarExtension(app)

# Affiche automatiquement:
# - Requêtes SQL
# - Templates rendus
# - Variables de config
# - Profiling
# - Logs


# ═══ FLASK SHELL PLUS ═══

"""
Shell interactif amélioré
"""

# Installation
pip install flask-shell-ipython

# Configuration dans run.py
@app.shell_context_processor
def make_shell_context():
    """Injecte automatiquement dans le shell"""
    return {
        'db': db,
        'User': User,
        'Post': Post,
        'Comment': Comment,
        # Helpers
        'query': db.session.query,
        'add': db.session.add,
        'commit': db.session.commit,
    }

# Lancer le shell
# flask shell

# Usage dans le shell:
"""
>>> users = User.query.all()
>>> user = User.query.first()
>>> user.username
'alice'
>>> user.posts.count()
5
"""


# ═══ REQUEST CONTEXT DEBUGGING ═══

"""
Debugger avec contexte de requête
"""

def debug_in_request_context():
    """Helper pour debugger avec contexte"""
    with app.test_request_context():
        # Code avec accès à request, session, g, etc.
        user = User.query.first()
        print(user.to_dict())


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                      DEBUGGING EN PRODUCTION                        ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ LOGGING CONTEXTUEL ═══

"""
Ajouter du contexte aux logs pour faciliter le debug
"""

import logging
from contextvars import ContextVar

# Context var pour le request ID
request_id_var: ContextVar[str] = ContextVar('request_id', default='')

class ContextFilter(logging.Filter):
    """
    Filtre pour ajouter le context aux logs
    """
    def filter(self, record):
        record.request_id = request_id_var.get('')
        return True


# Ajouter le filtre
handler = logging.StreamHandler()
handler.addFilter(ContextFilter())


@app.before_request
def set_request_context():
    """Set context variables"""
    request_id = str(uuid.uuid4())
    request_id_var.set(request_id)
    g.request_id = request_id


# ═══ FEATURE FLAGS POUR DEBUG ═══

"""
Activer/désactiver des features en production
"""

class FeatureFlags:
    """Gestionnaire de feature flags"""
    
    FLAGS = {
        'debug_mode': False,
        'verbose_logging': False,
        'slow_query_logging': True,
        'profiling_enabled': False,
    }
    
    @classmethod
    def is_enabled(cls, flag_name):
        """Vérifie si un flag est activé"""
        return cls.FLAGS.get(flag_name, False)
    
    @classmethod
    def enable(cls, flag_name):
        """Active un flag"""
        cls.FLAGS[flag_name] = True
    
    @classmethod
    def disable(cls, flag_name):
        """Désactive un flag"""
        cls.FLAGS[flag_name] = False


# Usage
@app.route('/api/users')
def get_users():
    if FeatureFlags.is_enabled('verbose_logging'):
        current_app.logger.info('Fetching all users', extra={
            'filters': request.args,
            'user_id': g.get('user_id')
        })
    
    users = User.query.all()
    return jsonify([u.to_dict() for u in users])


# Endpoint pour toggle flags (admin only)
@app.route('/api/admin/feature-flags/<flag_name>', methods=['POST'])
@jwt_required()
@admin_required()
def toggle_feature_flag(flag_name):
    """Active/désactive un feature flag"""
    action = request.json.get('action')  # 'enable' or 'disable'
    
    if action == 'enable':
        FeatureFlags.enable(flag_name)
    elif action == 'disable':
        FeatureFlags.disable(flag_name)
    
    return jsonify({
        'flag': flag_name,
        'enabled': FeatureFlags.is_enabled(flag_name)
    })


═══════════════════════════════════════════════════════════════════════════════
  RÉSUMÉ PARTIE 8 - TESTING, DEBUGGING & MONITORING
═══════════════════════════════════════════════════════════════════════════════

[OK] TESTING
  • Pytest configuration complète
  • Fixtures réutilisables
  • Tests unitaires et d'intégration
  • Parametrized tests
  • Mocking
  • Coverage

[OK] DEBUGGING
  • Logging structuré (JSON)
  • Rotation des logs
  • Niveaux appropriés
  • Context logging
  • Debug toolbar
  • Interactive debugger

[OK] MONITORING
  • Health checks (basic, detailed, ready, live)
  • Métriques Prometheus
  • Métriques personnalisées

[OK] APM
  • New Relic, Datadog, Elastic APM, Jaeger
  • Tracing distribué
  • Performance monitoring
  • Profiling

[OK] ERROR TRACKING
  • Sentry (recommandé)
  • Context et breadcrumbs
  • Alerting multi-canal

[OK] PERFORMANCE
  • Détection requêtes lentes
  • N+1 problem
  • Caching stratégique
  • Profiling production

[OK] ALERTING
  • Alertes multi-canal (Slack, Email, PagerDuty)
  • Health check monitoring
  • Alertes contextuelles

[OK] OBSERVABILITY
  • 3 piliers (Logs, Metrics, Traces)
  • Corrélation via Request ID
  • Dashboard Grafana

BEST PRACTICES:
  • Toujours tester avant de déployer
  • Logger avec contexte
  • Monitor en production
  • Alerter sur les événements critiques
  • Tracer les requêtes distribuées
  • Profiler régulièrement
  • Feature flags pour debug en prod

(FIN DE LA PARTIE 8)


═══════════════════════════════════════════════════════════════════════════════
  PARTIE 9: DÉPLOIEMENT & DEVOPS
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI DevOps est ESSENTIEL?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Sans DevOps:
  [X] Déploiements manuels (erreurs humaines)
  [X] "Ça marche sur ma machine"
  [X] Downtime lors des déploiements
  [X] Pas de rollback facile
  [X] Infrastructure fragile

Avec DevOps:
  [OK] Déploiements automatisés
  [OK] Environnements reproductibles
  [OK] Zero-downtime deployments
  [OK] Rollback instantané
  [OK] Infrastructure as Code
  [OK] Scalabilité automatique


═══════════════════════════════════════════════════════════════════════════════
  9.1 DOCKER & CONTAINERISATION
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI Docker?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Docker résout le problème "ça marche sur ma machine":
  [OK] Environnement identique partout (dev, staging, prod)
  [OK] Isolation des dépendances
  [OK] Déploiement facile
  [OK] Scalabilité horizontale
  [OK] Portabilité


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         DOCKERFILE OPTIMISÉ                         ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER Dockerfile ═══

# Image de base Python slim (plus légère)
FROM python:3.11-slim as base

# Variables d'environnement
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Répertoire de travail
WORKDIR /app

# ═══ STAGE 1: BUILDER (pour les dépendances) ═══
FROM base as builder

# Installer les dépendances système nécessaires pour compiler
RUN apt-get update && apt-get install -y \
    gcc \
    g++ \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Copier requirements
COPY requirements.txt .

# Installer les dépendances Python dans un venv
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --upgrade pip && \
    pip install -r requirements.txt


# ═══ STAGE 2: RUNTIME (image finale légère) ═══
FROM base as runtime

# Installer seulement les dépendances runtime nécessaires
RUN apt-get update && apt-get install -y \
    libpq5 \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Copier le venv depuis le builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Créer un utilisateur non-root (sécurité)
RUN useradd -m -u 1000 appuser && \
    chown -R appuser:appuser /app

# Copier le code de l'application
COPY --chown=appuser:appuser . .

# Changer vers l'utilisateur non-root
USER appuser

# Exposer le port
EXPOSE 5000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:5000/health || exit 1

# Commande de démarrage
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", \
     "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", \
     "run:app"]


# ═══ FICHIER .dockerignore ═══

"""
Ignorer les fichiers inutiles pour réduire la taille de l'image
"""

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/

# Tests
.pytest_cache/
.coverage
htmlcov/
*.log

# IDE
.vscode/
.idea/
*.swp
*.swo

# Git
.git/
.gitignore

# Documentation
README.md
docs/

# CI/CD
.github/
.gitlab-ci.yml

# Environment
.env
.env.local

# Database
*.db
*.sqlite

# Logs
logs/
*.log


# ═══ BUILD ET RUN ═══

# Build l'image
docker build -t my-flask-api:latest .

# Build avec cache busting
docker build --no-cache -t my-flask-api:latest .

# Build multi-platform (ARM + AMD)
docker buildx build --platform linux/amd64,linux/arm64 -t my-flask-api:latest .

# Run le container
docker run -d \
  --name my-api \
  -p 5000:5000 \
  -e DATABASE_URL=postgresql://user:pass@db:5432/mydb \
  -e JWT_SECRET_KEY=your-secret \
  --restart unless-stopped \
  my-flask-api:latest

# Logs
docker logs -f my-api

# Shell dans le container
docker exec -it my-api /bin/bash

# Stop et remove
docker stop my-api
docker rm my-api


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         DOCKER COMPOSE                              ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER docker-compose.yml ═══

"""
Configuration complète multi-services
"""

version: '3.8'

services:
  # ═══ API APPLICATION ═══
  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: flask-api
    restart: unless-stopped
    ports:
      - "5000:5000"
    environment:
      - FLASK_ENV=production
      - DATABASE_URL=postgresql://postgres:password@db:5432/mydb
      - REDIS_URL=redis://redis:6379/0
      - JWT_SECRET_KEY=${JWT_SECRET_KEY}
      - SENTRY_DSN=${SENTRY_DSN}
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ./logs:/app/logs
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 40s
  
  # ═══ POSTGRESQL DATABASE ═══
  db:
    image: postgres:15-alpine
    container_name: postgres-db
    restart: unless-stopped
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=mydb
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
  
  # ═══ REDIS CACHE ═══
  redis:
    image: redis:7-alpine
    container_name: redis-cache
    restart: unless-stopped
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
  
  # ═══ NGINX (Reverse Proxy) ═══
  nginx:
    image: nginx:alpine
    container_name: nginx-proxy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./nginx/logs:/var/log/nginx
    depends_on:
      - api
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "nginx", "-t"]
      interval: 30s
      timeout: 3s
      retries: 3
  
  # ═══ CELERY WORKER (Tâches asynchrones) ═══
  celery-worker:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: celery-worker
    restart: unless-stopped
    command: celery -A app.celery worker --loglevel=info --concurrency=4
    environment:
      - FLASK_ENV=production
      - DATABASE_URL=postgresql://postgres:password@db:5432/mydb
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - db
      - redis
    networks:
      - app-network
  
  # ═══ CELERY BEAT (Scheduler) ═══
  celery-beat:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: celery-beat
    restart: unless-stopped
    command: celery -A app.celery beat --loglevel=info
    environment:
      - FLASK_ENV=production
      - DATABASE_URL=postgresql://postgres:password@db:5432/mydb
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - db
      - redis
    networks:
      - app-network
  
  # ═══ MONITORING: PROMETHEUS ═══
  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    restart: unless-stopped
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
    networks:
      - app-network
  
  # ═══ MONITORING: GRAFANA ═══
  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
      - GF_INSTALL_PLUGINS=grafana-piechart-panel
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning
    depends_on:
      - prometheus
    networks:
      - app-network

# ═══ VOLUMES ═══
volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local
  prometheus_data:
    driver: local
  grafana_data:
    driver: local

# ═══ NETWORKS ═══
networks:
  app-network:
    driver: bridge


# ═══ FICHIER .env ═══

"""
Variables d'environnement sensibles
"""

# Database
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-secure-password
POSTGRES_DB=mydb

# Redis
REDIS_PASSWORD=your-redis-password

# Application
JWT_SECRET_KEY=your-jwt-secret-key
SECRET_KEY=your-app-secret-key

# Monitoring
SENTRY_DSN=https://your-sentry-dsn
GRAFANA_PASSWORD=admin

# Email
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password


# ═══ COMMANDES DOCKER COMPOSE ═══

# Démarrer tous les services
docker-compose up -d

# Voir les logs
docker-compose logs -f

# Logs d'un service spécifique
docker-compose logs -f api

# Arrêter tous les services
docker-compose down

# Arrêter et supprimer les volumes
docker-compose down -v

# Rebuild et redémarrer
docker-compose up -d --build

# Scale un service
docker-compose up -d --scale api=3

# Exécuter une commande dans un service
docker-compose exec api flask db upgrade
docker-compose exec api flask shell

# Voir l'état des services
docker-compose ps

# Stats en temps réel
docker-compose stats


# ═══ FICHIER nginx/nginx.conf ═══

"""
Configuration NGINX pour reverse proxy
"""

events {
    worker_connections 1024;
}

http {
    # Rate limiting
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
    
    # Upstream API servers (pour load balancing)
    upstream api_backend {
        least_conn;  # Algorithme de load balancing
        server api:5000 max_fails=3 fail_timeout=30s;
        # Pour scale horizontal:
        # server api-2:5000 max_fails=3 fail_timeout=30s;
        # server api-3:5000 max_fails=3 fail_timeout=30s;
    }
    
    # Cache zone
    proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m 
                     max_size=100m inactive=60m use_temp_path=off;
    
    # Server block
    server {
        listen 80;
        server_name api.example.com;
        
        # Redirect to HTTPS
        return 301 https://$server_name$request_uri;
    }
    
    server {
        listen 443 ssl http2;
        server_name api.example.com;
        
        # SSL Configuration
        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;
        
        # Security headers
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-XSS-Protection "1; mode=block" always;
        
        # Logging
        access_log /var/log/nginx/access.log;
        error_log /var/log/nginx/error.log;
        
        # Gzip compression
        gzip on;
        gzip_vary on;
        gzip_proxied any;
        gzip_comp_level 6;
        gzip_types text/plain text/css text/xml text/javascript 
                   application/json application/javascript application/xml+rss;
        
        # Client body size limit
        client_max_body_size 10M;
        
        # Timeouts
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
        
        # API routes
        location /api/ {
            # Rate limiting
            limit_req zone=api_limit burst=20 nodelay;
            
            # Proxy settings
            proxy_pass http://api_backend;
            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;
            
            # Cache configuration (pour GET uniquement)
            proxy_cache api_cache;
            proxy_cache_key "$scheme$request_method$host$request_uri";
            proxy_cache_valid 200 5m;
            proxy_cache_valid 404 1m;
            proxy_cache_methods GET HEAD;
            proxy_cache_bypass $http_cache_control;
            add_header X-Cache-Status $upstream_cache_status;
        }
        
        # Static files (si servis par NGINX)
        location /static/ {
            alias /app/static/;
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
        
        # Health check (pas de rate limit)
        location /health {
            proxy_pass http://api_backend;
            access_log off;
        }
        
        # Metrics (protégé)
        location /metrics {
            proxy_pass http://api_backend;
            
            # Restriction IP (seulement Prometheus)
            allow 172.18.0.0/16;  # Docker network
            deny all;
        }
    }
}


═══════════════════════════════════════════════════════════════════════════════
  9.2 CI/CD PIPELINES
═══════════════════════════════════════════════════════════════════════════════

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         GITHUB ACTIONS                              ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER .github/workflows/ci.yml ═══

"""
Pipeline CI/CD complet avec GitHub Actions
"""

name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
  release:
    types: [published]

env:
  PYTHON_VERSION: '3.11'
  DOCKER_IMAGE: myorg/my-flask-api

jobs:
  # ═══ JOB 1: TESTS ═══
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    
    services:
      # PostgreSQL pour tests
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test_db
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
      
      # Redis pour tests
      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: 'pip'
      
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install -r requirements-dev.txt
      
      - name: Lint with flake8
        run: |
          flake8 app --count --select=E9,F63,F7,F82 --show-source --statistics
          flake8 app --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
      
      - name: Type check with mypy
        run: |
          mypy app
        continue-on-error: true
      
      - name: Security check with bandit
        run: |
          bandit -r app -ll
      
      - name: Run tests with pytest
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/test_db
          REDIS_URL: redis://localhost:6379/0
          JWT_SECRET_KEY: test-secret-key
          TESTING: 1
        run: |
          pytest --cov=app --cov-report=xml --cov-report=html --cov-report=term
      
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage.xml
          flags: unittests
          name: codecov-umbrella
      
      - name: Archive code coverage results
        uses: actions/upload-artifact@v3
        with:
          name: code-coverage-report
          path: htmlcov/
  
  # ═══ JOB 2: BUILD DOCKER IMAGE ═══
  build:
    name: Build Docker Image
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name != 'pull_request'
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.DOCKER_IMAGE }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix={{branch}}-
      
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64
  
  # ═══ JOB 3: DEPLOY TO STAGING ═══
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/develop'
    environment:
      name: staging
      url: https://staging.api.example.com
    
    steps:
      - name: Deploy to staging server
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.STAGING_SSH_KEY }}
          script: |
            cd /app
            docker-compose pull
            docker-compose up -d
            docker-compose exec -T api flask db upgrade
  
  # ═══ JOB 4: DEPLOY TO PRODUCTION ═══
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    environment:
      name: production
      url: https://api.example.com
    
    steps:
      - name: Deploy to production via kubectl
        uses: azure/setup-kubectl@v3
      
      - name: Set Kubernetes context
        uses: azure/k8s-set-context@v3
        with:
          method: kubeconfig
          kubeconfig: ${{ secrets.KUBE_CONFIG }}
      
      - name: Deploy to Kubernetes
        run: |
          kubectl set image deployment/api api=${{ env.DOCKER_IMAGE }}:${{ github.sha }} -n production
          kubectl rollout status deployment/api -n production
      
      - name: Notify Slack
        uses: slackapi/slack-github-action@v1
        with:
          webhook-url: ${{ secrets.SLACK_WEBHOOK }}
          payload: |
            {
              "text": "[RAPIDE] Deployed to production: ${{ github.sha }}"
            }


# ═══ FICHIER .github/workflows/security.yml ═══

"""
Pipeline de sécurité séparé
"""

name: Security Scan

on:
  schedule:
    - cron: '0 0 * * *'  # Tous les jours à minuit
  push:
    branches: [main]

jobs:
  security:
    name: Security Scan
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          scan-ref: '.'
          format: 'sarif'
          output: 'trivy-results.sarif'
      
      - name: Upload Trivy results to GitHub Security
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: 'trivy-results.sarif'
      
      - name: Dependency Review
        uses: actions/dependency-review-action@v3


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                         GITLAB CI/CD                                ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER .gitlab-ci.yml ═══

"""
Pipeline CI/CD GitLab complet
"""

stages:
  - lint
  - test
  - build
  - deploy

variables:
  DOCKER_IMAGE: registry.gitlab.com/$CI_PROJECT_PATH
  PYTHON_VERSION: "3.11"

# ═══ TEMPLATES ═══

.python_template:
  image: python:${PYTHON_VERSION}
  before_script:
    - pip install --upgrade pip
    - pip install -r requirements.txt
    - pip install -r requirements-dev.txt

.docker_template:
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

# ═══ STAGE 1: LINT ═══

lint:flake8:
  extends: .python_template
  stage: lint
  script:
    - flake8 app --count --select=E9,F63,F7,F82 --show-source --statistics
    - flake8 app --count --exit-zero --max-complexity=10 --max-line-length=127
  only:
    - branches

lint:black:
  extends: .python_template
  stage: lint
  script:
    - black --check app
  only:
    - branches

security:bandit:
  extends: .python_template
  stage: lint
  script:
    - bandit -r app -ll -f json -o bandit-report.json
  artifacts:
    reports:
      sast: bandit-report.json
  only:
    - branches

# ═══ STAGE 2: TEST ═══

test:pytest:
  extends: .python_template
  stage: test
  services:
    - postgres:15-alpine
    - redis:7-alpine
  variables:
    POSTGRES_DB: test_db
    POSTGRES_USER: test
    POSTGRES_PASSWORD: test
    DATABASE_URL: postgresql://test:test@postgres:5432/test_db
    REDIS_URL: redis://redis:6379/0
    TESTING: "1"
  script:
    - pytest --cov=app --cov-report=xml --cov-report=html --cov-report=term
  coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
    paths:
      - htmlcov/
    expire_in: 30 days
  only:
    - branches

# ═══ STAGE 3: BUILD ═══

build:docker:
  extends: .docker_template
  stage: build
  script:
    - docker build -t $DOCKER_IMAGE:$CI_COMMIT_SHA .
    - docker build -t $DOCKER_IMAGE:latest .
    - docker push $DOCKER_IMAGE:$CI_COMMIT_SHA
    - docker push $DOCKER_IMAGE:latest
  only:
    - main
    - develop

# ═══ STAGE 4: DEPLOY ═══

deploy:staging:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$STAGING_SSH_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
  script:
    - ssh -o StrictHostKeyChecking=no $STAGING_USER@$STAGING_HOST "
        cd /app &&
        docker-compose pull &&
        docker-compose up -d &&
        docker-compose exec -T api flask db upgrade
      "
  environment:
    name: staging
    url: https://staging.api.example.com
  only:
    - develop

deploy:production:
  stage: deploy
  image: bitnami/kubectl:latest
  before_script:
    - echo "$KUBE_CONFIG" | base64 -d > ~/.kube/config
  script:
    - kubectl set image deployment/api api=$DOCKER_IMAGE:$CI_COMMIT_SHA -n production
    - kubectl rollout status deployment/api -n production
  environment:
    name: production
    url: https://api.example.com
  when: manual  # Déploiement manuel en prod
  only:
    - main


═══════════════════════════════════════════════════════════════════════════════
  9.3 KUBERNETES
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI Kubernetes?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Kubernetes (K8s) orchestre les containers:
  [OK] Auto-scaling (horizontal et vertical)
  [OK] Self-healing (redémarre les pods crashés)
  [OK] Load balancing automatique
  [OK] Rolling updates sans downtime
  [OK] Rollback facile
  [OK] Service discovery
  [OK] Secret et config management


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    MANIFESTS KUBERNETES                             ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER k8s/namespace.yaml ═══

"""
Namespace pour isoler les ressources
"""

apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    name: production
    environment: production


# ═══ FICHIER k8s/configmap.yaml ═══

"""
Configuration non-sensible
"""

apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
  namespace: production
data:
  FLASK_ENV: "production"
  LOG_LEVEL: "INFO"
  DATABASE_HOST: "postgres-service"
  DATABASE_PORT: "5432"
  DATABASE_NAME: "mydb"
  REDIS_HOST: "redis-service"
  REDIS_PORT: "6379"


# ═══ FICHIER k8s/secret.yaml ═══

"""
Données sensibles (encodées en base64)
"""

apiVersion: v1
kind: Secret
metadata:
  name: api-secrets
  namespace: production
type: Opaque
data:
  # echo -n 'your-secret' | base64
  JWT_SECRET_KEY: eW91ci1qd3Qtc2VjcmV0LWtleQ==
  DATABASE_PASSWORD: eW91ci1kYi1wYXNzd29yZA==
  REDIS_PASSWORD: eW91ci1yZWRpcy1wYXNzd29yZA==
  SENTRY_DSN: aHR0cHM6Ly95b3VyLXNlbnRyeS1kc24=


# ═══ FICHIER k8s/deployment.yaml ═══

"""
Deployment principal de l'API
"""

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: production
  labels:
    app: api
    version: v1
spec:
  # Nombre de réplicas
  replicas: 3
  
  # Stratégie de déploiement
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1        # Pods supplémentaires pendant l'update
      maxUnavailable: 0  # Garantit zero-downtime
  
  # Sélecteur de pods
  selector:
    matchLabels:
      app: api
  
  # Template des pods
  template:
    metadata:
      labels:
        app: api
        version: v1
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "5000"
        prometheus.io/path: "/metrics"
    spec:
      # Anti-affinity (distribuer sur différents nodes)
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchExpressions:
                    - key: app
                      operator: In
                      values:
                        - api
                topologyKey: kubernetes.io/hostname
      
      # Service account
      serviceAccountName: api-service-account
      
      # Init containers (migrations DB)
      initContainers:
        - name: db-migration
          image: myorg/my-flask-api:latest
          command: ['flask', 'db', 'upgrade']
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: api-secrets
                  key: DATABASE_URL
      
      # Containers principaux
      containers:
        - name: api
          image: myorg/my-flask-api:latest
          imagePullPolicy: Always
          
          ports:
            - name: http
              containerPort: 5000
              protocol: TCP
          
          # Variables d'environnement depuis ConfigMap
          envFrom:
            - configMapRef:
                name: api-config
          
          # Variables d'environnement depuis Secret
          env:
            - name: JWT_SECRET_KEY
              valueFrom:
                secretKeyRef:
                  name: api-secrets
                  key: JWT_SECRET_KEY
            - name: DATABASE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: api-secrets
                  key: DATABASE_PASSWORD
          
          # Resource limits
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          
          # Liveness probe (est-ce que le pod est vivant?)
          livenessProbe:
            httpGet:
              path: /health/live
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          
          # Readiness probe (est-ce que le pod est prêt?)
          readinessProbe:
            httpGet:
              path: /health/ready
              port: http
            initialDelaySeconds: 10
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 3
          
          # Startup probe (pour les apps qui démarrent lentement)
          startupProbe:
            httpGet:
              path: /health/live
              port: http
            initialDelaySeconds: 0
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 30
          
          # Volume mounts
          volumeMounts:
            - name: logs
              mountPath: /app/logs
      
      # Volumes
      volumes:
        - name: logs
          emptyDir: {}


# ═══ FICHIER k8s/service.yaml ═══

"""
Service pour exposer les pods
"""

apiVersion: v1
kind: Service
metadata:
  name: api-service
  namespace: production
  labels:
    app: api
spec:
  type: ClusterIP  # Internal service
  selector:
    app: api
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 5000
  sessionAffinity: ClientIP  # Sticky sessions


# ═══ FICHIER k8s/ingress.yaml ═══

"""
Ingress pour exposer l'API à l'extérieur
"""

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  namespace: production
  annotations:
    # NGINX Ingress
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/rate-limit: "100"
    
    # Cert Manager (SSL automatique)
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    
    # CORS
    nginx.ingress.kubernetes.io/enable-cors: "true"
    nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS"
    nginx.ingress.kubernetes.io/cors-allow-origin: "https://myapp.com"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - api.example.com
      secretName: api-tls-cert
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-service
                port:
                  number: 80


# ═══ FICHIER k8s/hpa.yaml ═══

"""
Horizontal Pod Autoscaler
Auto-scale basé sur CPU/Memory
"""

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  
  # Limites de scaling
  minReplicas: 3
  maxReplicas: 10
  
  # Métriques pour scaling
  metrics:
    # Scale basé sur CPU
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70  # Scale si CPU > 70%
    
    # Scale basé sur Memory
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80  # Scale si Memory > 80%
    
    # Scale basé sur métriques custom (requêtes/sec)
    - type: Pods
      pods:
        metric:
          name: http_requests_per_second
        target:
          type: AverageValue
          averageValue: "1000"
  
  # Comportement de scaling
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300  # Attendre 5min avant de scale down
      policies:
        - type: Percent
          value: 50  # Scale down max 50% à la fois
          periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0  # Scale up immédiatement
      policies:
        - type: Percent
          value: 100  # Scale up max 100% à la fois
          periodSeconds: 15


# ═══ FICHIER k8s/pdb.yaml ═══

"""
Pod Disruption Budget
Garantit disponibilité pendant les maintenances
"""

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-pdb
  namespace: production
spec:
  minAvailable: 2  # Au moins 2 pods toujours disponibles
  selector:
    matchLabels:
      app: api


# ═══ COMMANDES KUBECTL ═══

# Appliquer les manifests
kubectl apply -f k8s/

# Voir les pods
kubectl get pods -n production

# Voir les logs
kubectl logs -f deployment/api -n production

# Logs d'un pod spécifique
kubectl logs -f <pod-name> -n production

# Shell dans un pod
kubectl exec -it <pod-name> -n production -- /bin/bash

# Port-forward pour debug
kubectl port-forward deployment/api 5000:5000 -n production

# Voir les événements
kubectl get events -n production --sort-by='.lastTimestamp'

# Décrire une ressource
kubectl describe deployment api -n production

# Scale manuellement
kubectl scale deployment/api --replicas=5 -n production

# Rollout (deployment)
kubectl rollout status deployment/api -n production
kubectl rollout history deployment/api -n production
kubectl rollout undo deployment/api -n production

# Voir les métriques
kubectl top pods -n production
kubectl top nodes

# Configurer context
kubectl config get-contexts
kubectl config use-context production




═══════════════════════════════════════════════════════════════════════════════
  9.4 SECRETS MANAGEMENT
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI gérer les secrets correctement?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Sans gestion appropriée:
  [X] Secrets en clair dans le code (GIT!)
  [X] Secrets dans les logs
  [X] Difficile de rotate les secrets
  [X] Pas d'audit trail
  [X] Risque de fuite massive

Avec bonne gestion:
  [OK] Secrets chiffrés au repos
  [OK] Rotation automatique
  [OK] Accès audité
  [OK] Principe du moindre privilège
  [OK] Secrets jamais en clair


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    OPTION 1: HASHICORP VAULT                        ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

"""
Vault est la solution enterprise-grade pour secrets
"""

# ═══ INSTALLATION VAULT ═══

# Docker Compose avec Vault
services:
  vault:
    image: vault:latest
    container_name: vault
    restart: unless-stopped
    ports:
      - "8200:8200"
    environment:
      VAULT_DEV_ROOT_TOKEN_ID: root-token
      VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200
    cap_add:
      - IPC_LOCK
    volumes:
      - vault_data:/vault/data
      - vault_logs:/vault/logs
    networks:
      - app-network


# ═══ CONFIGURATION VAULT ═══

"""
Initialiser et configurer Vault
"""

# Initialiser Vault (une seule fois)
vault operator init

# Unseal Vault (nécessaire après chaque redémarrage)
vault operator unseal <unseal-key-1>
vault operator unseal <unseal-key-2>
vault operator unseal <unseal-key-3>

# Login
vault login <root-token>

# Activer le moteur KV v2
vault secrets enable -path=secret kv-v2

# Créer une policy pour l'application
vault policy write api-policy - <<EOF
path "secret/data/api/*" {
  capabilities = ["read"]
}
EOF

# Créer un app role
vault auth enable approle

vault write auth/approle/role/api \
  token_policies="api-policy" \
  token_ttl=1h \
  token_max_ttl=4h

# Récupérer role-id et secret-id
vault read auth/approle/role/api/role-id
vault write -f auth/approle/role/api/secret-id


# ═══ STOCKER DES SECRETS ═══

# Via CLI
vault kv put secret/api/database \
  username=postgres \
  password=super-secret-password \
  host=db.example.com \
  port=5432

vault kv put secret/api/jwt \
  secret_key=my-jwt-secret-key

vault kv put secret/api/external-services \
  stripe_api_key=sk_live_xxx \
  sendgrid_api_key=SG.xxx


# ═══ INTÉGRATION PYTHON ═══

# Installation
pip install hvac

# Fichier app/vault.py
"""
Client Vault pour l'application
"""

import hvac
import os
from functools import lru_cache


class VaultClient:
    """
    Client pour interagir avec Vault
    """
    
    def __init__(self):
        self.client = hvac.Client(
            url=os.getenv('VAULT_ADDR', 'http://localhost:8200')
        )
        self._authenticate()
    
    def _authenticate(self):
        """Authentification via AppRole"""
        role_id = os.getenv('VAULT_ROLE_ID')
        secret_id = os.getenv('VAULT_SECRET_ID')
        
        if not role_id or not secret_id:
            raise ValueError('VAULT_ROLE_ID and VAULT_SECRET_ID required')
        
        # Login avec AppRole
        response = self.client.auth.approle.login(
            role_id=role_id,
            secret_id=secret_id
        )
        
        # Le token est automatiquement stocké dans le client
        print('[OK] Authenticated with Vault')
    
    @lru_cache(maxsize=128)
    def get_secret(self, path):
        """
        Récupère un secret depuis Vault
        
        Args:
            path (str): Chemin du secret (ex: 'api/database')
        
        Returns:
            dict: Données du secret
        
        Usage:
            secrets = vault.get_secret('api/database')
            db_password = secrets['password']
        """
        try:
            response = self.client.secrets.kv.v2.read_secret_version(
                path=path,
                mount_point='secret'
            )
            return response['data']['data']
        except Exception as e:
            print(f'Error fetching secret {path}: {e}')
            raise
    
    def renew_token(self):
        """Renouvelle le token avant expiration"""
        try:
            self.client.auth.token.renew_self()
            print('[OK] Token renewed')
        except Exception as e:
            print(f'Error renewing token: {e}')
            self._authenticate()  # Re-authenticate si échec


# Singleton
vault_client = VaultClient()


# ═══ UTILISATION DANS FLASK ═══

from app.vault import vault_client

# Initialisation de l'app
def create_app():
    app = Flask(__name__)
    
    # Charger les secrets depuis Vault
    try:
        # Database secrets
        db_secrets = vault_client.get_secret('api/database')
        app.config['SQLALCHEMY_DATABASE_URI'] = (
            f"postgresql://{db_secrets['username']}:"
            f"{db_secrets['password']}@{db_secrets['host']}:"
            f"{db_secrets['port']}/mydb"
        )
        
        # JWT secrets
        jwt_secrets = vault_client.get_secret('api/jwt')
        app.config['JWT_SECRET_KEY'] = jwt_secrets['secret_key']
        
        # External services
        external = vault_client.get_secret('api/external-services')
        app.config['STRIPE_API_KEY'] = external['stripe_api_key']
        app.config['SENDGRID_API_KEY'] = external['sendgrid_api_key']
        
    except Exception as e:
        print(f'Failed to load secrets from Vault: {e}')
        raise
    
    return app


# ═══ TOKEN RENEWAL BACKGROUND TASK ═══

import threading
import time

def renew_vault_token_periodically():
    """Renouvelle le token Vault périodiquement"""
    while True:
        time.sleep(3000)  # Renouveler toutes les 50 minutes
        try:
            vault_client.renew_token()
        except Exception as e:
            print(f'Failed to renew Vault token: {e}')

# Démarrer le thread de renouvellement
renewal_thread = threading.Thread(
    target=renew_vault_token_periodically,
    daemon=True
)
renewal_thread.start()


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                OPTION 2: KUBERNETES SEALED SECRETS                  ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

"""
Sealed Secrets permet de versionner les secrets chiffrés dans Git
"""

# ═══ INSTALLATION ═══

# Installer le controller dans Kubernetes
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml

# Installer kubeseal CLI
brew install kubeseal  # macOS
# ou télécharger depuis GitHub releases


# ═══ CRÉER UN SEALED SECRET ═══

# 1. Créer un secret normal
kubectl create secret generic api-secrets \
  --from-literal=JWT_SECRET_KEY=my-jwt-secret \
  --from-literal=DATABASE_PASSWORD=my-db-password \
  --dry-run=client -o yaml > secret.yaml

# 2. Sceller le secret
kubeseal --format yaml < secret.yaml > sealed-secret.yaml

# 3. Le sealed-secret peut être versionné dans Git
cat sealed-secret.yaml

# Exemple de sortie:
"""
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: api-secrets
  namespace: production
spec:
  encryptedData:
    JWT_SECRET_KEY: AgBg8F7T...
    DATABASE_PASSWORD: AgCK9mP...
  template:
    metadata:
      name: api-secrets
      namespace: production
"""

# 4. Appliquer le sealed secret
kubectl apply -f sealed-secret.yaml

# Le controller déchiffre automatiquement et crée le Secret normal


# ═══ ROTATION DES SECRETS ═══

# 1. Mettre à jour le secret
kubectl create secret generic api-secrets \
  --from-literal=JWT_SECRET_KEY=new-jwt-secret \
  --from-literal=DATABASE_PASSWORD=new-db-password \
  --dry-run=client -o yaml | kubeseal --format yaml > sealed-secret.yaml

# 2. Appliquer
kubectl apply -f sealed-secret.yaml

# 3. Redémarrer les pods pour charger les nouveaux secrets
kubectl rollout restart deployment/api -n production


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                   OPTION 3: AWS SECRETS MANAGER                     ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

"""
AWS Secrets Manager pour applications sur AWS
"""

# Installation
pip install boto3

# Fichier app/aws_secrets.py
"""
Client AWS Secrets Manager
"""

import boto3
import json
from botocore.exceptions import ClientError
from functools import lru_cache


class AWSSecretsManager:
    """Client pour AWS Secrets Manager"""
    
    def __init__(self, region_name='us-east-1'):
        self.client = boto3.client(
            service_name='secretsmanager',
            region_name=region_name
        )
    
    @lru_cache(maxsize=128)
    def get_secret(self, secret_name):
        """
        Récupère un secret depuis AWS Secrets Manager
        
        Args:
            secret_name (str): Nom du secret
        
        Returns:
            dict: Secret décodé
        """
        try:
            response = self.client.get_secret_value(SecretId=secret_name)
            
            if 'SecretString' in response:
                return json.loads(response['SecretString'])
            else:
                # Binary secret
                import base64
                return base64.b64decode(response['SecretBinary'])
        
        except ClientError as e:
            if e.response['Error']['Code'] == 'ResourceNotFoundException':
                print(f"Secret {secret_name} not found")
            elif e.response['Error']['Code'] == 'InvalidRequestException':
                print(f"Invalid request for secret {secret_name}")
            elif e.response['Error']['Code'] == 'InvalidParameterException':
                print(f"Invalid parameter for secret {secret_name}")
            raise


# Utilisation
secrets_manager = AWSSecretsManager()

db_secrets = secrets_manager.get_secret('production/api/database')
jwt_secrets = secrets_manager.get_secret('production/api/jwt')


═══════════════════════════════════════════════════════════════════════════════
  9.5 BLUE-GREEN DEPLOYMENT
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI Blue-Green Deployment?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Blue-Green = Deux environnements identiques (Blue et Green)

Avantages:
  [OK] Zero-downtime deployment
  [OK] Rollback instantané (switch back)
  [OK] Testing en production (Green) avant switch
  [OK] Réduction des risques

Flux:
  1. Blue (v1) en production, Green (v2) idle
  2. Déployer v2 sur Green
  3. Tester Green
  4. Switch traffic Blue -> Green
  5. Green (v2) en production, Blue (v1) idle


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                  BLUE-GREEN AVEC KUBERNETES                         ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ FICHIER k8s/blue-green/deployment-blue.yaml ═══

"""
Deployment Blue (version actuelle)
"""

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-blue
  namespace: production
  labels:
    app: api
    version: blue
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api
      version: blue
  template:
    metadata:
      labels:
        app: api
        version: blue
    spec:
      containers:
        - name: api
          image: myorg/my-flask-api:v1.0.0
          ports:
            - containerPort: 5000
          # ... autres configs


# ═══ FICHIER k8s/blue-green/deployment-green.yaml ═══

"""
Deployment Green (nouvelle version)
"""

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-green
  namespace: production
  labels:
    app: api
    version: green
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api
      version: green
  template:
    metadata:
      labels:
        app: api
        version: green
    spec:
      containers:
        - name: api
          image: myorg/my-flask-api:v2.0.0
          ports:
            - containerPort: 5000
          # ... autres configs


# ═══ FICHIER k8s/blue-green/service.yaml ═══

"""
Service qui pointe vers Blue OU Green
"""

apiVersion: v1
kind: Service
metadata:
  name: api-service
  namespace: production
spec:
  selector:
    app: api
    version: blue  # <- Change to 'green' pour switch
  ports:
    - protocol: TCP
      port: 80
      targetPort: 5000


# ═══ SCRIPT DE DÉPLOIEMENT BLUE-GREEN ═══

#!/bin/bash
# deploy-blue-green.sh

set -e

NAMESPACE="production"
CURRENT_VERSION=$(kubectl get service api-service -n $NAMESPACE -o jsonpath='{.spec.selector.version}')
NEW_VERSION=""

# Déterminer la nouvelle version
if [ "$CURRENT_VERSION" == "blue" ]; then
    NEW_VERSION="green"
    NEW_IMAGE="myorg/my-flask-api:v2.0.0"
else
    NEW_VERSION="blue"
    NEW_IMAGE="myorg/my-flask-api:v2.0.0"
fi

echo "Current version: $CURRENT_VERSION"
echo "Deploying to: $NEW_VERSION"

# 1. Déployer la nouvelle version
echo "-> Deploying new version..."
kubectl set image deployment/api-$NEW_VERSION api=$NEW_IMAGE -n $NAMESPACE

# 2. Attendre que le déploiement soit prêt
echo "-> Waiting for rollout..."
kubectl rollout status deployment/api-$NEW_VERSION -n $NAMESPACE

# 3. Vérifier la santé de la nouvelle version
echo "-> Health checking new version..."
NEW_POD=$(kubectl get pod -n $NAMESPACE -l version=$NEW_VERSION -o jsonpath='{.items[0].metadata.name}')
kubectl exec $NEW_POD -n $NAMESPACE -- curl -f http://localhost:5000/health

# 4. Demander confirmation avant switch
read -p "Switch traffic to $NEW_VERSION? (yes/no) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    echo "Deployment aborted"
    exit 1
fi

# 5. Switch le traffic
echo "-> Switching traffic to $NEW_VERSION..."
kubectl patch service api-service -n $NAMESPACE -p "{\"spec\":{\"selector\":{\"version\":\"$NEW_VERSION\"}}}"

echo "[OK] Successfully switched to $NEW_VERSION"

# 6. Garder l'ancienne version pendant un moment (pour rollback rapide)
echo "-> Old version ($CURRENT_VERSION) still running for quick rollback"
echo "To rollback: kubectl patch service api-service -n $NAMESPACE -p '{\"spec\":{\"selector\":{\"version\":\"$CURRENT_VERSION\"}}}'"


# ═══ ROLLBACK INSTANTANÉ ═══

#!/bin/bash
# rollback.sh

NAMESPACE="production"
CURRENT_VERSION=$(kubectl get service api-service -n $NAMESPACE -o jsonpath='{.spec.selector.version}')

if [ "$CURRENT_VERSION" == "blue" ]; then
    PREVIOUS_VERSION="green"
else
    PREVIOUS_VERSION="blue"
fi

echo "Rolling back from $CURRENT_VERSION to $PREVIOUS_VERSION..."
kubectl patch service api-service -n $NAMESPACE -p "{\"spec\":{\"selector\":{\"version\":\"$PREVIOUS_VERSION\"}}}"
echo "[OK] Rolled back to $PREVIOUS_VERSION"


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                       CANARY DEPLOYMENT                             ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

"""
Canary = Déployer progressivement la nouvelle version

Exemple: 
  - 95% traffic -> v1
  - 5% traffic -> v2 (canary)
  - Si OK, augmenter progressivement
"""

# ═══ AVEC ISTIO ═══

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: api-virtual-service
  namespace: production
spec:
  hosts:
    - api-service
  http:
    - match:
        - headers:
            canary:
              exact: "true"
      route:
        - destination:
            host: api-service
            subset: v2
    - route:
        - destination:
            host: api-service
            subset: v1
          weight: 95
        - destination:
            host: api-service
            subset: v2
          weight: 5  # 5% traffic vers v2


═══════════════════════════════════════════════════════════════════════════════
  9.6 INFRASTRUCTURE AS CODE (IaC)
═══════════════════════════════════════════════════════════════════════════════

[?] POURQUOI Infrastructure as Code?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

IaC = Infrastructure définie en code (versionnable, reproductible)

Avantages:
  [OK] Infrastructure reproductible
  [OK] Versionning (Git)
  [OK] Review process (Pull Requests)
  [OK] Rollback facile
  [OK] Documentation vivante
  [OK] Automation complète


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                          TERRAFORM                                  ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

"""
Terraform = IaC multi-cloud
"""

# ═══ FICHIER terraform/main.tf ═══

# Provider configuration
terraform {
  required_version = ">= 1.0"
  
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.23"
    }
  }
  
  # Remote state (S3 backend)
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-lock"
  }
}

provider "aws" {
  region = var.aws_region
}

# ═══ VPC & NETWORKING ═══

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  
  tags = {
    Name        = "production-vpc"
    Environment = "production"
  }
}

resource "aws_subnet" "public" {
  count             = 3
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index + 1}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]
  
  map_public_ip_on_launch = true
  
  tags = {
    Name = "public-subnet-${count.index + 1}"
  }
}

resource "aws_subnet" "private" {
  count             = 3
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index + 10}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]
  
  tags = {
    Name = "private-subnet-${count.index + 1}"
  }
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  
  tags = {
    Name = "main-igw"
  }
}

# NAT Gateway
resource "aws_eip" "nat" {
  count  = 3
  domain = "vpc"
}

resource "aws_nat_gateway" "main" {
  count         = 3
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
}

# ═══ EKS CLUSTER ═══

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 19.0"
  
  cluster_name    = "production-cluster"
  cluster_version = "1.28"
  
  vpc_id     = aws_vpc.main.id
  subnet_ids = aws_subnet.private[*].id
  
  # Node groups
  eks_managed_node_groups = {
    general = {
      min_size     = 2
      max_size     = 10
      desired_size = 3
      
      instance_types = ["t3.large"]
      capacity_type  = "ON_DEMAND"
      
      labels = {
        role = "general"
      }
      
      tags = {
        Environment = "production"
      }
    }
  }
  
  # Cluster access
  cluster_endpoint_public_access = true
  
  tags = {
    Environment = "production"
  }
}

# ═══ RDS DATABASE ═══

resource "aws_db_instance" "postgres" {
  identifier           = "production-db"
  engine              = "postgres"
  engine_version      = "15.3"
  instance_class      = "db.t3.large"
  allocated_storage   = 100
  storage_encrypted   = true
  
  db_name  = "mydb"
  username = "postgres"
  password = var.db_password  # From variable
  
  vpc_security_group_ids = [aws_security_group.rds.id]
  db_subnet_group_name   = aws_db_subnet_group.main.name
  
  backup_retention_period = 7
  backup_window          = "03:00-04:00"
  maintenance_window     = "sun:04:00-sun:05:00"
  
  skip_final_snapshot = false
  final_snapshot_identifier = "production-db-final-snapshot"
  
  tags = {
    Name        = "production-db"
    Environment = "production"
  }
}

# ═══ ELASTICACHE REDIS ═══

resource "aws_elasticache_cluster" "redis" {
  cluster_id           = "production-redis"
  engine              = "redis"
  engine_version      = "7.0"
  node_type           = "cache.t3.micro"
  num_cache_nodes     = 1
  parameter_group_name = "default.redis7"
  port                = 6379
  
  subnet_group_name  = aws_elasticache_subnet_group.main.name
  security_group_ids = [aws_security_group.redis.id]
  
  tags = {
    Name = "production-redis"
  }
}

# ═══ LOAD BALANCER ═══

resource "aws_lb" "main" {
  name               = "production-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets           = aws_subnet.public[*].id
  
  enable_deletion_protection = true
  
  tags = {
    Name = "production-alb"
  }
}

# ═══ OUTPUTS ═══

output "eks_cluster_endpoint" {
  value = module.eks.cluster_endpoint
}

output "rds_endpoint" {
  value = aws_db_instance.postgres.endpoint
}

output "redis_endpoint" {
  value = aws_elasticache_cluster.redis.cache_nodes[0].address
}


# ═══ FICHIER terraform/variables.tf ═══

variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "db_password" {
  description = "Database password"
  type        = string
  sensitive   = true
}


# ═══ COMMANDES TERRAFORM ═══

# Initialiser
terraform init

# Valider la configuration
terraform validate

# Voir les changements
terraform plan

# Appliquer les changements
terraform apply

# Détruire l'infrastructure
terraform destroy

# Format le code
terraform fmt -recursive


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                            HELM CHARTS                              ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

"""
Helm = Package manager pour Kubernetes
"""

# ═══ STRUCTURE HELM CHART ═══

my-api-chart/
├── Chart.yaml
├── values.yaml
├── values-production.yaml
├── values-staging.yaml
└── templates/
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── configmap.yaml
    ├── secret.yaml
    ├── hpa.yaml
    └── _helpers.tpl


# ═══ FICHIER Chart.yaml ═══

apiVersion: v2
name: my-api
description: Flask API Helm Chart
type: application
version: 1.0.0
appVersion: "1.0.0"

maintainers:
  - name: DevOps Team
    email: devops@example.com

keywords:
  - flask
  - api
  - python


# ═══ FICHIER values.yaml ═══

# Default values
replicaCount: 3

image:
  repository: myorg/my-flask-api
  tag: latest
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80
  targetPort: 5000

ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: api.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: api-tls
      hosts:
        - api.example.com

resources:
  requests:
    memory: "256Mi"
    cpu: "250m"
  limits:
    memory: "512Mi"
    cpu: "500m"

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

env:
  - name: FLASK_ENV
    value: production
  - name: LOG_LEVEL
    value: INFO

envFrom:
  - configMapRef:
      name: api-config
  - secretRef:
      name: api-secrets


# ═══ FICHIER templates/deployment.yaml ═══

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-api.fullname" . }}
  labels:
    {{- include "my-api.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "my-api.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-api.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
          env:
            {{- toYaml .Values.env | nindent 12 }}
          envFrom:
            {{- toYaml .Values.envFrom | nindent 12 }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          livenessProbe:
            httpGet:
              path: /health/live
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /health/ready
              port: http
            initialDelaySeconds: 10
            periodSeconds: 5


# ═══ COMMANDES HELM ═══

# Installer un chart
helm install my-api ./my-api-chart

# Avec values spécifiques
helm install my-api ./my-api-chart -f values-production.yaml

# Upgrade
helm upgrade my-api ./my-api-chart

# Rollback
helm rollback my-api 1

# Liste des releases
helm list

# Historique
helm history my-api

# Uninstall
helm uninstall my-api

# Dry-run (voir ce qui serait appliqué)
helm install my-api ./my-api-chart --dry-run --debug


═══════════════════════════════════════════════════════════════════════════════
  9.7 LOAD BALANCING & SCALING
═══════════════════════════════════════════════════════════════════════════════

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                    LOAD BALANCING STRATEGIES                        ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

"""
Stratégies de load balancing
"""

# 1. ROUND ROBIN (par défaut)
#    Distribue les requêtes de manière circulaire
#    Request 1 -> Server 1
#    Request 2 -> Server 2
#    Request 3 -> Server 3
#    Request 4 -> Server 1 (recommence)

# 2. LEAST CONNECTIONS
#    Envoie vers le serveur avec le moins de connexions
#    Bon pour requêtes de durée variable

upstream api_backend {
    least_conn;
    server api-1:5000;
    server api-2:5000;
    server api-3:5000;
}

# 3. IP HASH (Sticky sessions)
#    Même client -> toujours même serveur
#    Utile pour sessions

upstream api_backend {
    ip_hash;
    server api-1:5000;
    server api-2:5000;
    server api-3:5000;
}

# 4. WEIGHTED
#    Distribue selon poids (capacité des serveurs)

upstream api_backend {
    server api-1:5000 weight=3;  # Reçoit 3x plus
    server api-2:5000 weight=2;
    server api-3:5000 weight=1;
}


┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                      AUTO-SCALING KUBERNETES                        ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

# ═══ HORIZONTAL POD AUTOSCALER (HPA) ═══

"""
Scale le nombre de pods basé sur métriques
"""

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3
  maxReplicas: 20
  metrics:
    # CPU
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    
    # Memory
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80
    
    # Requêtes par seconde (custom metric)
    - type: Pods
      pods:
        metric:
          name: http_requests_per_second
        target:
          type: AverageValue
          averageValue: "1000"


# ═══ VERTICAL POD AUTOSCALER (VPA) ═══

"""
Ajuste automatiquement les resource requests/limits
"""

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  updatePolicy:
    updateMode: "Auto"  # Recreate, Initial, Off
  resourcePolicy:
    containerPolicies:
      - containerName: api
        minAllowed:
          cpu: 100m
          memory: 128Mi
        maxAllowed:
          cpu: 2
          memory: 2Gi


# ═══ CLUSTER AUTOSCALER ═══

"""
Scale les nodes du cluster selon la demande
"""

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cluster-autoscaler
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cluster-autoscaler
  template:
    metadata:
      labels:
        app: cluster-autoscaler
    spec:
      serviceAccountName: cluster-autoscaler
      containers:
        - image: k8s.gcr.io/autoscaling/cluster-autoscaler:v1.28.0
          name: cluster-autoscaler
          command:
            - ./cluster-autoscaler
            - --cloud-provider=aws
            - --namespace=kube-system
            - --nodes=2:10:production-node-group
            - --scale-down-enabled=true
            - --scale-down-delay-after-add=10m
            - --scale-down-unneeded-time=10m


═══════════════════════════════════════════════════════════════════════════════
  9.8 MONITORING KUBERNETES
═══════════════════════════════════════════════════════════════════════════════

# ═══ PROMETHEUS OPERATOR ═══

"""
Installation via Helm
"""

# Ajouter le repo
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

# Installer kube-prometheus-stack
helm install prometheus prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  --create-namespace \
  --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false


# ═══ SERVICE MONITOR ═══

"""
Monitorer l'API avec Prometheus
"""

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: api-metrics
  namespace: production
spec:
  selector:
    matchLabels:
      app: api
  endpoints:
    - port: http
      path: /metrics
      interval: 30s


# ═══ PROMETHEUS RULES (ALERTES) ═══

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: api-alerts
  namespace: production
spec:
  groups:
    - name: api
      interval: 30s
      rules:
        # High error rate
        - alert: HighErrorRate
          expr: |
            rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
          for: 5m
          labels:
            severity: critical
          annotations:
            summary: "High error rate detected"
            description: "Error rate is {{ $value | humanizePercentage }}"
        
        # High latency
        - alert: HighLatency
          expr: |
            histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 1
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "High latency detected"
            description: "P95 latency is {{ $value }}s"
        
        # Pod restarts
        - alert: PodRestarting
          expr: |
            rate(kube_pod_container_status_restarts_total[15m]) > 0
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "Pod is restarting"


═══════════════════════════════════════════════════════════════════════════════
  RÉSUMÉ PARTIE 9 - DÉPLOIEMENT & DEVOPS
═══════════════════════════════════════════════════════════════════════════════

[OK] DOCKER & CONTAINERISATION
  • Dockerfile optimisé multi-stage
  • Docker Compose complet
  • Nginx reverse proxy
  • Services multiples

[OK] CI/CD
  • GitHub Actions (tests, build, deploy)
  • GitLab CI (pipeline complet)
  • Security scanning
  • Multi-environment deployment

[OK] KUBERNETES
  • Manifests complets
  • Deployments, Services, Ingress
  • HPA, PDB
  • Probes (liveness, readiness, startup)

[OK] SECRETS MANAGEMENT
  • HashiCorp Vault
  • Sealed Secrets
  • AWS Secrets Manager
  • Best practices

[OK] BLUE-GREEN DEPLOYMENT
  • Zero-downtime deployments
  • Rollback instantané
  • Canary deployments

[OK] INFRASTRUCTURE AS CODE
  • Terraform (AWS, EKS, RDS)
  • Helm Charts
  • Reproductibilité

[OK] LOAD BALANCING & SCALING
  • Stratégies load balancing
  • HPA, VPA, Cluster Autoscaler
  • Auto-scaling avancé

[OK] MONITORING
  • Prometheus + Grafana
  • Service Monitors
  • Alerting rules

BEST PRACTICES:
  • Infrastructure as Code (IaC)
  • Secrets jamais en clair
  • Zero-downtime deployments
  • Auto-scaling configuré
  • Monitoring complet
  • Rollback strategy
  • Multi-environment (dev, staging, prod)

(FIN DE LA PARTIE 9)


