# Fichier: python_cheats/cheatsheets/api_flask.txt
# Guide Ultra-Complet sur les APIs


═══════════════════════════════════════════════════════════════════════════════
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 = JWT Manager()
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>"


(Suite dans la partie 3...)
# Fichier: python_cheats/cheatsheets/api_avance_partie3.txt
# Guide Ultra-Complet sur les APIs - PARTIE 3
# Continuation de la PARTIE 2


═══════════════════════════════════════════════════════════════════════════════
  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


(Suite dans la partie 4 avec Authentification JWT...)
# Fichier: python_cheats/cheatsheets/api_avance_partie4.txt
# Guide Ultra-Complet sur les APIs - PARTIE 4
# Continuation de la PARTIE 3


═══════════════════════════════════════════════════════════════════════════════
  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!"
  }'


(Suite dans la partie 5 avec OAuth 2.0, RBAC avancé, et sécurité...)
# Fichier: python_cheats/cheatsheets/api_avance_partie5.txt
# Guide Ultra-Complet sur les APIs - PARTIE 5
# Continuation de la PARTIE 4


═══════════════════════════════════════════════════════════════════════════════
  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


(Suite dans la partie 6 avec Sécurité avancée, CORS, Rate Limiting...)
# Fichier: python_cheats/cheatsheets/api_avance_partie6.txt
# Guide Ultra-Complet sur les APIs - PARTIE 6
# Continuation de la PARTIE 5


═══════════════════════════════════════════════════════════════════════════════
  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


(Suite dans la partie 7 avec Input Validation avancée, Secrets Management, HTTPS...)
# Fichier: python_cheats/cheatsheets/api_avance_partie7.txt
# Guide Ultra-Complet sur les APIs - PARTIE 7
# Continuation de la PARTIE 6


═══════════════════════════════════════════════════════════════════════════════
  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

(Suite dans la partie 8 avec Testing, Debugging et Monitoring...)
# Fichier: python_cheats/cheatsheets/api_avance_partie8.txt
# Guide Ultra-Complet sur les APIs - PARTIE 8
# Continuation de la PARTIE 7


═══════════════════════════════════════════════════════════════════════════════
  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)

La suite: PARTIE 9 - DÉPLOIEMENT & DEVOPS
# Fichier: python_cheats/cheatsheets/api_avance_partie9.txt
# Guide Ultra-Complet sur les APIs - PARTIE 9
# Continuation 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)

═══════════════════════════════════════════════════════════════════════════════
  GUIDE COMPLET TERMINÉ !
═══════════════════════════════════════════════════════════════════════════════

Toutes les parties sont maintenant disponibles:
  [OK] PARTIE 1: Introduction et concepts de base
  [OK] PARTIE 2: Architecture Flask (structure, factory pattern, blueprints)
  [OK] PARTIE 3: Modèles SQLAlchemy
  [OK] PARTIE 4: Authentification JWT complète
  [OK] PARTIE 5: OAuth 2.0 et RBAC avancé
  [OK] PARTIE 6: Sécurité (CORS, CSRF, XSS, Rate Limiting)
  [OK] PARTIE 7: Validation et Sérialisation (Pydantic, Marshmallow)
  [OK] PARTIE 8: Testing, Debugging & Monitoring
  [OK] PARTIE 9: Déploiement & DevOps