# [COURS] Formation Django — Maîtriser Django de Zéro à Expert
## Fichier 1/7 — Introduction, Projet Fil Rouge & Fondations

---

> **Navigation des fichiers :**
> - [FICHIER] **Fichier 1** — Introduction & Fondations *(vous êtes ici)*
> - [FICHIER] Fichier 2 — Modèles & Base de données
> - [FICHIER] Fichier 3 — Vues & Templates
> - [FICHIER] Fichier 4 — URLs, Middleware & Authentification
> - [FICHIER] Fichier 5 — Django REST Framework
> - [FICHIER] Fichier 6 — Tests & Déploiement
> - [FICHIER] Fichier 7 — Expert : Optimisation, Projets & Architecture

---

# [RAPIDE] Le Projet Fil Rouge : **SkillHub**

Tout au long de cette formation, tu vas construire **SkillHub**, une plateforme communautaire de partage de compétences. C'est un projet ambitieux, réaliste, et progressif qui te permettra d'appliquer chaque concept au fur et à mesure que tu l'apprends.

---

## * Vision du Projet

**SkillHub** est une plateforme web où :

- Les utilisateurs créent un profil public mettant en avant leurs compétences
- Ils peuvent **proposer des ateliers** (workshops) dans leur domaine
- D'autres utilisateurs peuvent **s'inscrire** à ces ateliers
- Un système de **reviews et évaluations** permet de noter les ateliers
- Un **tableau de bord** personnel affiche les ateliers suivis, proposés, et les statistiques
- Un **moteur de recherche** permet de trouver des ateliers par compétence, niveau, ou localisation
- Une **API REST** expose toutes les données pour une future application mobile

C'est intentionnellement large et vague : tu découvriras de nouvelles fonctionnalités à implémenter à chaque chapitre. Comme dans la vraie vie professionnelle, le produit évolue au fil du temps.

---

## [MESURE] Architecture Globale de SkillHub

```
skillhub/                        <- Dossier racine du projet
│
├── skillhub/                    <- Configuration principale Django
│   ├── settings/
│   │   ├── base.py
│   │   ├── dev.py
│   │   └── prod.py
│   ├── urls.py
│   ├── wsgi.py
│   └── asgi.py
│
├── apps/
│   ├── accounts/                <- Gestion des utilisateurs
│   ├── workshops/               <- Ateliers / compétences
│   ├── registrations/           <- Inscriptions aux ateliers
│   ├── reviews/                 <- Système d'évaluation
│   └── api/                     <- API REST (DRF)
│
├── templates/                   <- Templates HTML globaux
├── static/                      <- Fichiers CSS, JS, images
├── media/                       <- Fichiers uploadés par les users
├── requirements/
│   ├── base.txt
│   ├── dev.txt
│   └── prod.txt
├── manage.py
├── Dockerfile
├── docker-compose.yml
└── README.md
```

---

## [CALENDRIER] Roadmap de construction

| Phase | Chapitres | Ce qu'on construit |
|---|---|---|
| 1 | 1-3 | Projet Django, structure, premier "Hello World" |
| 2 | 4-7 | Modèles User, Workshop, migrations, admin |
| 3 | 8-10 | Pages HTML : liste ateliers, détail, création |
| 4 | 11-13 | Routing, login/logout, permissions |
| 5 | 14-16 | API REST avec DRF |
| 6 | 17-23 | Tests, Docker, CI/CD |
| 7 | 24-34 | Caching, observabilité, architecture pro |

---

# [MODULE] PARTIE I — Introduction et Fondations

---

## Chapitre 1 — Introduction à Django

### 1.1 Qu'est-ce que Django ?

Django est un **framework web** écrit en Python. Un framework, c'est un ensemble d'outils, de conventions et de code préécrit qui t'évite de repartir de zéro à chaque projet. Sans framework, tu devrais toi-même :

- Gérer les connexions HTTP entrantes
- Parser les URLs pour savoir quelle page afficher
- Construire des requêtes SQL pour lire/écrire en base de données
- Gérer la sécurité (injections SQL, XSS, CSRF...)
- Créer un système d'authentification

Django fait tout cela pour toi, et bien plus encore. C'est pour ça que son slogan est "**batteries included**" (piles incluses) : tout ce dont tu as besoin est déjà là dès l'installation.

### 1.2 Pourquoi Django et pas autre chose ?

Il existe plusieurs frameworks Python populaires :

**Flask** est minimaliste. Il te donne le strict minimum et tu ajoutes toi-même ce dont tu as besoin. C'est excellent pour les petits projets ou quand tu veux un contrôle total sur chaque composant. Mais pour un projet comme SkillHub, tu devrais assembler des dizaines de librairies tierces.

**FastAPI** est moderne et très rapide. Il est pensé principalement pour créer des APIs. Il a une syntaxe élégante avec les type hints Python. Mais il ne gère pas les templates HTML, ni l'admin, ni les formulaires HTML.

**Django** est le bon choix quand tu veux :
- Un projet web complet avec interface HTML ET API
- Un panneau d'administration fonctionnel dès le départ
- Un ORM puissant pour parler à la base de données
- Un système d'authentification intégré
- Une communauté et documentation immenses

### 1.3 Le patron MTV

Django suit le patron **MTV** : **Model – Template – View**. C'est une variante du célèbre MVC (Model-View-Controller).

```
Requête HTTP
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
  [URLs]  <- Django regarde l'URL pour décider quoi faire
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
  [View]  <- Logique métier : récupère les données, les traite
     │  ^v
  [Model] <- Parle à la base de données
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
[Template] <- Génère le HTML avec les données
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
Réponse HTTP (page HTML)
```

**Le Model** décrit la structure de tes données et comment elles sont stockées en base. Par exemple, un Workshop (atelier) a un titre, une description, une date, un nombre de places...

**La View** contient la logique de traitement. Quand un utilisateur demande la liste des ateliers, la View récupère tous les ateliers en base, les passe au template, et retourne la page HTML.

**Le Template** est le fichier HTML (avec des balises spéciales Django) qui affiche les données de manière jolie.

---

## Chapitre 2 — Installation et Configuration

### 2.1 Prérequis

Avant de commencer, assure-toi d'avoir :

- **Python 3.10+** installé (`python --version`)
- **pip** (gestionnaire de paquets Python)
- Un éditeur de code : **VS Code** est recommandé

### 2.2 Environnement virtuel

Un environnement virtuel isole les dépendances de ton projet. Sans ça, toutes tes librairies Python se mélangent au niveau système, et tu auras des conflits entre projets.

```bash
# 1. Crée un dossier pour ton projet
mkdir skillhub
cd skillhub

# 2. Crée un environnement virtuel
python -m venv venv

# 3. Active-le
# Sur Linux/Mac :
source venv/bin/activate

# Sur Windows :
venv\Scripts\activate

# Ton terminal doit maintenant afficher (venv) au début
# (venv) $ _
```

> [ATTENTION] **Règle absolue :** Toujours activer ton environnement virtuel avant de travailler sur ton projet. Si tu installes Django sans l'avoir activé, tu l'installes pour tout le système et pas pour ton projet.

### 2.3 Installation de Django

```bash
# Dans ton environnement virtuel activé
pip install django

# Vérifie l'installation
python -m django --version
# Doit afficher : 5.x.x
```

### 2.4 Création du projet SkillHub

```bash
# Cette commande crée la structure de base du projet
django-admin startproject skillhub .

# Le point (.) à la fin dit à Django de créer le projet
# dans le dossier actuel (évite un dossier skillhub/skillhub/)
```

Tu dois maintenant avoir :

```
skillhub/
├── manage.py          <- Le couteau suisse de Django
├── skillhub/
│   ├── __init__.py    <- Marque ce dossier comme module Python
│   ├── settings.py    <- TOUTE la configuration de ton projet
│   ├── urls.py        <- Le routeur principal (URLs -> Views)
│   ├── asgi.py        <- Pour les connexions asynchrones
│   └── wsgi.py        <- Interface avec le serveur web en prod
└── venv/
```

### 2.5 Comprendre `manage.py`

`manage.py` est ton outil principal. Tout passe par lui :

```bash
python manage.py runserver        # Lance le serveur de développement
python manage.py makemigrations   # Prépare les changements de base de données
python manage.py migrate          # Applique ces changements
python manage.py createsuperuser  # Crée un compte administrateur
python manage.py shell            # Ouvre une console Python avec Django chargé
python manage.py test             # Lance les tests
python manage.py collectstatic    # Collecte les fichiers statiques pour la prod
```

### 2.6 Comprendre `settings.py`

C'est le cerveau de la configuration. Voici les sections les plus importantes :

```python
# skillhub/settings.py

# [ATTENTION] JAMAIS en production ! Active le mode debug avec page d'erreur détaillée
DEBUG = True

# Liste des domaines autorisés à servir ce site (vide en dev avec DEBUG=True)
ALLOWED_HOSTS = []

# Liste des applications installées dans ce projet
INSTALLED_APPS = [
    'django.contrib.admin',        # L'interface d'administration
    'django.contrib.auth',         # Système d'authentification
    'django.contrib.contenttypes', # Système de types de contenu
    'django.contrib.sessions',     # Gestion des sessions
    'django.contrib.messages',     # Système de messages flash
    'django.contrib.staticfiles',  # Gestion des fichiers statiques
]

# Middlewares : des "couches" qui traitent chaque requête/réponse
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',    # Protection CSRF
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

# Base de données (SQLite par défaut, parfait pour le développement)
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

# Dossier des templates HTML
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'],  # Ajoute ceci !
        'APP_DIRS': True,
        # ...
    },
]

# Clé secrète (NE JAMAIS la partager ni la mettre sur GitHub !)
SECRET_KEY = 'django-insecure-...'

# Langue et fuseau horaire
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'Europe/Paris'
USE_I18N = True
USE_TZ = True
```

### 2.7 Premier lancement

```bash
python manage.py runserver
```

Ouvre ton navigateur à `http://127.0.0.1:8000/`. Tu devrais voir la page de bienvenue Django avec la fusée [RAPIDE].

### 2.8 Configuration modulaire (Bonne pratique Pro)

Au lieu d'un seul `settings.py`, les projets professionnels utilisent plusieurs fichiers :

```bash
# Crée la structure
mkdir skillhub/settings
touch skillhub/settings/__init__.py
touch skillhub/settings/base.py
touch skillhub/settings/dev.py
touch skillhub/settings/prod.py
```

```python
# skillhub/settings/base.py — Paramètres communs à tous les environnements
from pathlib import Path

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

SECRET_KEY = 'à-remplacer-par-variable-environnement'

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # Nos apps (on les ajoutera au fur et à mesure)
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'skillhub.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'Europe/Paris'
USE_I18N = True
USE_TZ = True

STATIC_URL = '/static/'
STATICFILES_DIRS = [BASE_DIR / 'static']
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
```

```python
# skillhub/settings/dev.py — Paramètres de développement uniquement
from .base import *

DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1']

# En développement, on peut afficher les emails dans la console
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
```

```python
# skillhub/settings/prod.py — Paramètres de production
from .base import *
import os

DEBUG = False
ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOST', '')]

SECRET_KEY = os.environ.get('SECRET_KEY')

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DB_NAME'),
        'USER': os.environ.get('DB_USER'),
        'PASSWORD': os.environ.get('DB_PASSWORD'),
        'HOST': os.environ.get('DB_HOST', 'localhost'),
        'PORT': os.environ.get('DB_PORT', '5432'),
    }
}
```

Puis modifie `manage.py` pour pointer vers le bon settings :

```python
# manage.py
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'skillhub.settings.dev')
```

---

## Chapitre 3 — Concepts de Base

### 3.1 Apps vs Projet

Un **projet Django** est le conteneur global. Il a une configuration (`settings.py`), un routeur d'URLs (`urls.py`), et coordonne tout.

Une **app Django** est un module autonome qui gère une fonctionnalité précise. L'idée est que chaque app devrait pouvoir être réutilisée dans d'autres projets. Par exemple :

- L'app `accounts` gère tout ce qui concerne les utilisateurs
- L'app `workshops` gère les ateliers
- L'app `reviews` gère les avis et évaluations

```bash
# Crée la première app de SkillHub
python manage.py startapp workshops
```

Structure créée :

```
workshops/
├── __init__.py
├── admin.py       <- Enregistre tes modèles dans l'admin
├── apps.py        <- Configuration de l'app
├── migrations/    <- Historique des changements de BDD
│   └── __init__.py
├── models.py      <- Définition des données (tables BDD)
├── tests.py       <- Tests unitaires
├── urls.py        <- À créer manuellement
└── views.py       <- Logique des pages
```

Enregistre l'app dans `settings.py` (base.py) :

```python
INSTALLED_APPS = [
    # ...
    'workshops',  # Ajoute ceci
]
```

> [IDEE] **Bonne pratique :** Pour SkillHub, on va organiser nos apps dans un dossier `apps/` pour plus de clarté. Il faudra alors les nommer `apps.workshops`, `apps.accounts`, etc.

### 3.2 URL Routing — Comment Django sait quoi afficher

Quand un utilisateur tape `http://skillhub.com/workshops/`, Django suit ce processus :

1. Il reçoit la requête avec le chemin `/workshops/`
2. Il consulte `skillhub/urls.py` (routeur principal)
3. Ce fichier le redirige vers `workshops/urls.py`
4. Ce fichier associe `/workshops/` à la View `workshop_list`
5. La View traite la requête et retourne une réponse

```python
# skillhub/urls.py — Routeur principal
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('workshops/', include('workshops.urls')),  # Délègue à workshops/urls.py
]
```

```python
# workshops/urls.py — Routeur de l'app workshops
from django.urls import path
from . import views

app_name = 'workshops'  # Namespace pour éviter les conflits de noms

urlpatterns = [
    path('', views.workshop_list, name='list'),
    path('<int:pk>/', views.workshop_detail, name='detail'),
    path('create/', views.workshop_create, name='create'),
]
```

### 3.3 Ta Première View

```python
# workshops/views.py
from django.http import HttpResponse
from django.shortcuts import render

def workshop_list(request):
    """Affiche la liste de tous les ateliers."""
    # Pour l'instant, on retourne juste du texte brut
    return HttpResponse("<h1>Liste des ateliers</h1>")
```

### 3.4 Templates — Générer du HTML proprement

Les templates évitent de mélanger du HTML dans ton code Python. Crée ce fichier :

```
templates/
└── workshops/
    └── list.html
```

```html
<!-- templates/workshops/list.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>SkillHub — Ateliers</title>
</head>
<body>
    <h1>[COURS] Ateliers disponibles</h1>

    <!-- On affichera ici la liste des ateliers avec Django Template Language -->
    <p>Aucun atelier pour l'instant. Sois le premier à en créer un !</p>
</body>
</html>
```

Maintenant utilise ce template dans ta view :

```python
# workshops/views.py
from django.shortcuts import render

def workshop_list(request):
    """Affiche la liste de tous les ateliers."""
    context = {
        'page_title': 'Ateliers disponibles',
    }
    return render(request, 'workshops/list.html', context)
```

La fonction `render(request, template, context)` :
- `request` : L'objet de la requête HTTP
- `'workshops/list.html'` : Le chemin vers le template
- `context` : Un dictionnaire Python dont les clés deviennent des variables dans le template

### 3.5 Fichiers Statiques et Médias

**Fichiers statiques** = CSS, JavaScript, images du site (ne changent pas selon les utilisateurs)

**Fichiers médias** = Images uploadées par les utilisateurs (photos de profil, etc.)

```bash
mkdir static
mkdir static/css
mkdir static/js
mkdir static/images
mkdir media
```

```css
/* static/css/main.css */
:root {
    --primary: #6C63FF;
    --secondary: #FF6584;
    --bg: #F8F9FA;
    --text: #2D3436;
}

body {
    font-family: 'Segoe UI', sans-serif;
    background: var(--bg);
    color: var(--text);
    margin: 0;
    padding: 0;
}

.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 0 20px;
}
```

Dans le template :

```html
<!-- En haut du template, charge les tags statiques -->
{% load static %}

<!DOCTYPE html>
<html lang="fr">
<head>
    <link rel="stylesheet" href="{% static 'css/main.css' %}">
</head>
```

---

## [OK] Exercices de Complétion — Partie I

Ces exercices te permettent de consolider ce que tu viens d'apprendre. Chaque exercice a un niveau de difficulté.

---

### Exercice 1.1 — Hello SkillHub *

**Objectif :** Créer ta première page Django fonctionnelle.

**Instructions :**
1. Crée le projet SkillHub avec la structure vue dans ce chapitre
2. Crée une app `core` qui gérera la page d'accueil
3. Crée une view `home` qui retourne une page HTML simple avec un message de bienvenue
4. Configure l'URL `/` pour pointer vers cette view
5. Lance le serveur et vérifie que ça fonctionne

**Critères de réussite :**
- La page s'affiche à `http://127.0.0.1:8000/`
- Le titre de la page est "SkillHub — Partagez vos compétences"
- Aucune erreur dans la console

---

### Exercice 1.2 — Configuration Modulaire **

**Objectif :** Mettre en place la structure de settings professionnelle.

**Instructions :**
1. Crée les fichiers `settings/base.py`, `settings/dev.py`, `settings/prod.py`
2. Déplace le contenu actuel de `settings.py` vers `base.py`
3. Ajoute dans `dev.py` : `DEBUG = True`, `ALLOWED_HOSTS = ['localhost']`
4. Modifie `manage.py` pour utiliser `skillhub.settings.dev`
5. Relance le serveur et confirme que tout fonctionne

**Question de réflexion :** Pourquoi est-il dangereux de mettre `DEBUG = True` en production ?

---

### Exercice 1.3 — Navigation Multi-Pages ***

**Objectif :** Créer plusieurs pages avec navigation entre elles.

**Instructions :**
1. Crée une app `pages` avec trois vues : `home`, `about`, `contact`
2. Crée un template `base.html` avec une barre de navigation
3. Les trois pages héritent de `base.html` (tu auras besoin de `{% extends %}` et `{% block %}`)
4. Les liens de navigation fonctionnent entre les pages
5. Chaque page a un titre unique dans l'onglet du navigateur

**Bonus :** Ajoute du CSS dans `static/css/main.css` pour styliser la barre de navigation.

---

### Exercice 1.4 — Première App SkillHub ***

**Objectif :** Créer l'app `workshops` avec une page de liste vide.

**Instructions :**
1. Crée l'app `workshops`
2. Crée une view `workshop_list` qui affiche un template
3. Le template affiche "Aucun atelier disponible pour l'instant" si la liste est vide
4. Configure l'URL `/workshops/` pour pointer vers cette view
5. Ajoute un lien "Ateliers" dans la barre de navigation globale

**Piège à éviter :** N'oublie pas d'ajouter l'app dans `INSTALLED_APPS` !

---

### [NOTE] Journal de Bord — À remplir après chaque exercice

Après chaque exercice, réponds à ces questions dans un fichier `NOTES.md` :

1. Qu'est-ce que j'ai appris ?
2. Qu'est-ce qui m'a posé problème ?
3. Comment j'ai résolu ce problème ?

Ce journal est une pratique de développeur professionnel. Il t'aidera à réviser et à progresser.

---

## [IMPORTANT] Récapitulatif Partie I

| Concept | Ce que c'est | Fichier concerné |
|---|---|---|
| Projet Django | Conteneur global | `skillhub/` |
| App Django | Module fonctionnel | `workshops/`, `accounts/`... |
| settings.py | Configuration | `skillhub/settings/` |
| urls.py | Routeur HTTP | `skillhub/urls.py`, `workshops/urls.py` |
| views.py | Logique de traitement | `workshops/views.py` |
| templates/ | HTML dynamique | `templates/workshops/list.html` |
| static/ | CSS, JS, images | `static/css/main.css` |

---

> -> **Suite : Fichier 2 — Modèles & Base de données**
> Tu vas maintenant apprendre à définir tes données, créer les tables SQL automatiquement, et utiliser l'ORM Django pour interagir avec la base de données.

# [COURS] Formation Django — Maîtriser Django de Zéro à Expert
## Fichier 2/7 — Modèles & Base de Données (Parties II)

---

> **Navigation des fichiers :**
> - [FICHIER] Fichier 1 — Introduction & Fondations
> - [FICHIER] **Fichier 2** — Modèles & Base de données *(vous êtes ici)*
> - [FICHIER] Fichier 3 — Vues & Templates
> - [FICHIER] Fichier 4 — URLs, Middleware & Authentification
> - [FICHIER] Fichier 5 — Django REST Framework
> - [FICHIER] Fichier 6 — Tests & Déploiement
> - [FICHIER] Fichier 7 — Expert : Optimisation, Projets & Architecture

---

# [BRICK] PARTIE II — Modèles et Base de Données

Les modèles sont le **cœur de ton application**. Ils définissent la structure de tes données et servent de pont entre ton code Python et ta base de données. Avec Django, tu n'écriras jamais une ligne de SQL brut pour les opérations courantes — l'ORM (Object-Relational Mapper) s'en charge pour toi.

---

## Chapitre 4 — Modèles Django

### 4.1 Qu'est-ce qu'un modèle ?

Un modèle est une **classe Python** qui représente une table dans ta base de données. Chaque **attribut** de la classe correspond à une **colonne** de la table. Chaque **instance** de la classe correspond à une **ligne** dans la table.

```
Classe Python       ->    Table SQL
Attribut Python     ->    Colonne SQL
Instance Python     ->    Ligne SQL
```

### 4.2 Ton premier modèle : Workshop

Pour SkillHub, un atelier (workshop) a les caractéristiques suivantes :
- Un titre
- Une description détaillée
- Une date et heure de début
- Une durée (en minutes)
- Un niveau de difficulté (débutant, intermédiaire, avancé)
- Un nombre maximum de participants
- Une localisation (en ligne ou présentiel)
- Un statut (brouillon, publié, annulé, terminé)
- Un prix (gratuit ou payant)
- Des photos

Voici comment le modéliser :

```python
# workshops/models.py
from django.db import models
from django.contrib.auth.models import User
from django.utils.text import slugify
from django.urls import reverse


class Category(models.Model):
    """Catégorie d'un atelier : Programmation, Design, Cuisine, etc."""
    name = models.CharField(max_length=100, verbose_name="Nom")
    slug = models.SlugField(unique=True, verbose_name="Slug")
    description = models.TextField(blank=True, verbose_name="Description")
    icon = models.CharField(max_length=50, blank=True, verbose_name="Icône (emoji)")

    class Meta:
        verbose_name = "Catégorie"
        verbose_name_plural = "Catégories"
        ordering = ['name']

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        # Génère automatiquement le slug depuis le nom
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)


class Workshop(models.Model):
    """Un atelier proposé par un utilisateur de SkillHub."""

    # === Choix pour les champs à options ===
    class DifficultyLevel(models.TextChoices):
        BEGINNER = 'beginner', '[VERT] Débutant'
        INTERMEDIATE = 'intermediate', '[JAUNE] Intermédiaire'
        ADVANCED = 'advanced', '[ROUGE] Avancé'
        ALL_LEVELS = 'all', '[BLANC] Tous niveaux'

    class Status(models.TextChoices):
        DRAFT = 'draft', 'Brouillon'
        PUBLISHED = 'published', 'Publié'
        CANCELLED = 'cancelled', 'Annulé'
        COMPLETED = 'completed', 'Terminé'

    class Format(models.TextChoices):
        ONLINE = 'online', '[CODE] En ligne'
        ONSITE = 'onsite', '[IMPORTANT] Présentiel'
        HYBRID = 'hybrid', '[MELANGE] Hybride'

    # === Champs principaux ===
    title = models.CharField(
        max_length=200,
        verbose_name="Titre",
        help_text="Sois précis et attrayant ! Ex: 'Initiation au Python avec des jeux vidéo'"
    )
    slug = models.SlugField(
        unique=True,
        max_length=250,
        verbose_name="Slug URL",
        help_text="Identifiant unique dans l'URL (généré automatiquement)"
    )
    description = models.TextField(
        verbose_name="Description",
        help_text="Décris ce que les participants vont apprendre, ce dont ils ont besoin..."
    )
    short_description = models.CharField(
        max_length=300,
        verbose_name="Description courte",
        help_text="Résumé pour les cartes et aperçus (300 caractères max)"
    )

    # === Relations ===
    instructor = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name='workshops_taught',
        verbose_name="Formateur"
    )
    category = models.ForeignKey(
        Category,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name='workshops',
        verbose_name="Catégorie"
    )

    # === Dates et durée ===
    starts_at = models.DateTimeField(verbose_name="Date et heure de début")
    duration_minutes = models.PositiveIntegerField(
        verbose_name="Durée (minutes)",
        help_text="Durée en minutes. Ex: 90 pour 1h30"
    )

    # === Format et lieu ===
    format = models.CharField(
        max_length=20,
        choices=Format.choices,
        default=Format.ONLINE,
        verbose_name="Format"
    )
    location = models.CharField(
        max_length=200,
        blank=True,
        verbose_name="Lieu",
        help_text="Adresse si présentiel, lien si en ligne"
    )

    # === Participants ===
    max_participants = models.PositiveIntegerField(
        verbose_name="Nombre maximum de participants",
        default=20
    )

    # === Niveau et prix ===
    difficulty = models.CharField(
        max_length=20,
        choices=DifficultyLevel.choices,
        default=DifficultyLevel.ALL_LEVELS,
        verbose_name="Niveau"
    )
    price = models.DecimalField(
        max_digits=8,
        decimal_places=2,
        default=0.00,
        verbose_name="Prix (€)"
    )
    is_free = models.BooleanField(
        default=True,
        verbose_name="Gratuit"
    )

    # === Statut ===
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.DRAFT,
        verbose_name="Statut"
    )

    # === Image ===
    cover_image = models.ImageField(
        upload_to='workshops/covers/',
        null=True,
        blank=True,
        verbose_name="Image de couverture"
    )

    # === Métadonnées ===
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="Créé le")
    updated_at = models.DateTimeField(auto_now=True, verbose_name="Modifié le")

    class Meta:
        verbose_name = "Atelier"
        verbose_name_plural = "Ateliers"
        ordering = ['-starts_at']  # Les plus récents en premier

    def __str__(self):
        return f"{self.title} — {self.instructor.get_full_name()}"

    def save(self, *args, **kwargs):
        # Génère le slug automatiquement si pas encore défini
        if not self.slug:
            self.slug = slugify(self.title)
        # Si le prix est 0, marquer comme gratuit
        if self.price == 0:
            self.is_free = True
        super().save(*args, **kwargs)

    def get_absolute_url(self):
        """URL canonique de cet atelier."""
        return reverse('workshops:detail', kwargs={'slug': self.slug})

    # === Propriétés calculées ===
    @property
    def duration_display(self):
        """Affiche la durée en heures et minutes."""
        hours = self.duration_minutes // 60
        minutes = self.duration_minutes % 60
        if hours and minutes:
            return f"{hours}h{minutes:02d}"
        elif hours:
            return f"{hours}h"
        return f"{minutes}min"

    @property
    def spots_remaining(self):
        """Nombre de places restantes."""
        registered = self.registrations.filter(status='confirmed').count()
        return self.max_participants - registered

    @property
    def is_full(self):
        """L'atelier est-il complet ?"""
        return self.spots_remaining <= 0

    @property
    def average_rating(self):
        """Note moyenne calculée depuis les avis."""
        reviews = self.reviews.all()
        if not reviews:
            return None
        return sum(r.rating for r in reviews) / len(reviews)
```

### 4.3 Décryptage des Types de Champs

Voici les champs les plus utilisés et quand les employer :

| Champ | Usage | Options importantes |
|---|---|---|
| `CharField` | Texte court (< 500 chars) | `max_length` obligatoire |
| `TextField` | Texte long sans limite | `blank=True` si optionnel |
| `IntegerField` | Entier | `default`, `null` |
| `PositiveIntegerField` | Entier positif uniquement | Idéal pour quantités |
| `DecimalField` | Nombre décimal précis (prix!) | `max_digits`, `decimal_places` |
| `FloatField` | Nombre flottant (approximatif) | Évite pour les prix |
| `BooleanField` | Vrai/Faux | `default` recommandé |
| `DateField` | Date seule (sans heure) | `auto_now_add`, `auto_now` |
| `DateTimeField` | Date + heure | `auto_now_add`, `auto_now` |
| `SlugField` | Chaîne URL-friendly | `unique=True` souvent |
| `ImageField` | Image uploadée | `upload_to` pour le dossier |
| `FileField` | Fichier quelconque | `upload_to` pour le dossier |
| `EmailField` | Email (avec validation) | Hérite de CharField |
| `URLField` | URL (avec validation) | Hérite de CharField |
| `JSONField` | Données JSON | Pratique pour données flexibles |

**Options communes à presque tous les champs :**

```python
# null=True -> La colonne peut contenir NULL en base de données
# blank=True -> Le champ peut être vide dans un formulaire Django
# default=... -> Valeur par défaut
# verbose_name="..." -> Nom affiché dans l'admin et les formulaires
# help_text="..." -> Texte d'aide sous le champ dans les formulaires
# unique=True -> La valeur doit être unique dans la table
# choices=... -> Restreint les valeurs possibles à une liste

# [ATTENTION] Règle importante :
# null=True : pour les champs non-texte (DateField, IntegerField, ForeignKey)
# blank=True : pour tous les champs optionnels dans les formulaires
# Pour CharField et TextField : utilise SEULEMENT blank=True (pas null=True)
# car Django distingue "" (chaîne vide) de NULL
```

### 4.4 La Classe `Meta`

```python
class Meta:
    verbose_name = "Atelier"            # Nom singulier dans l'admin
    verbose_name_plural = "Ateliers"    # Nom pluriel dans l'admin
    ordering = ['-created_at']          # Ordre par défaut des requêtes
                                        # Le - signifie ordre décroissant
    unique_together = [['instructor', 'title']]  # Contrainte d'unicité combinée
    indexes = [
        models.Index(fields=['status', 'starts_at']),  # Index pour performance
    ]
```

### 4.5 La méthode `__str__`

```python
def __str__(self):
    return self.title
```

Cette méthode définit comment un objet Workshop est représenté en texte. Elle est utilisée dans l'admin, dans la console Python, et dans les listes déroulantes de formulaires. Toujours la définir !

---

## Chapitre 5 — Migrations

### 5.1 Comprendre les Migrations

Les migrations sont le système de **versionning de ta base de données**. Quand tu modifies un modèle, Django génère automatiquement le SQL nécessaire pour mettre à jour la structure de la base.

Sans migrations, tu devrais écrire toi-même du SQL comme :
```sql
ALTER TABLE workshops ADD COLUMN price DECIMAL(8,2);
```

Avec Django, tu modifies juste ton modèle Python, et Django génère ce SQL.

### 5.2 Le Cycle des Migrations

```
1. Tu modifies models.py
          v
2. python manage.py makemigrations
   (Django détecte les changements et génère un fichier de migration)
          v
3. python manage.py migrate
   (Django exécute le SQL et met à jour la base de données)
```

### 5.3 Créer et Appliquer les Migrations

```bash
# Génère les fichiers de migration pour les changements détectés
python manage.py makemigrations

# Ou pour une app spécifique
python manage.py makemigrations workshops

# Affiche le SQL qui va être exécuté (sans l'exécuter)
python manage.py sqlmigrate workshops 0001

# Applique toutes les migrations en attente
python manage.py migrate

# Voir l'état des migrations
python manage.py showmigrations
```

### 5.4 Anatomie d'un Fichier de Migration

```python
# workshops/migrations/0001_initial.py
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

    initial = True  # Première migration de cette app

    dependencies = [
        # Cette migration dépend d'autres migrations
        ('auth', '0012_alter_user_first_name_max_length'),
    ]

    operations = [
        # Chaque opération correspond à un changement
        migrations.CreateModel(
            name='Workshop',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True)),
                ('title', models.CharField(max_length=200, verbose_name='Titre')),
                ('slug', models.SlugField(max_length=250, unique=True)),
                # ... tous les autres champs
            ],
        ),
    ]
```

> [ATTENTION] **Règle d'or :** Ne jamais modifier manuellement un fichier de migration existant et déjà appliqué. Si tu as une erreur, crée une nouvelle migration corrective. Les migrations sont comme un historique Git de ta base de données.

### 5.5 Rollback (Annuler une Migration)

```bash
# Revenir à l'état après la migration 0002 (annule 0003, 0004, etc.)
python manage.py migrate workshops 0002

# Revenir avant toutes les migrations de cette app (table supprimée !)
python manage.py migrate workshops zero
```

### 5.6 Données Initiales avec DataMigration

Une DataMigration permet d'insérer des données lors d'une migration :

```python
# workshops/migrations/0002_initial_categories.py
from django.db import migrations


def create_initial_categories(apps, schema_editor):
    """Crée les catégories initiales."""
    Category = apps.get_model('workshops', 'Category')
    categories = [
        {'name': 'Programmation', 'slug': 'programmation', 'icon': '[CODE]'},
        {'name': 'Design', 'slug': 'design', 'icon': '[DESIGN]'},
        {'name': 'Langues', 'slug': 'langues', 'icon': '[MONDE]'},
        {'name': 'Musique', 'slug': 'musique', 'icon': '[SON]'},
        {'name': 'Cuisine', 'slug': 'cuisine', 'icon': '[COOKING]'},
        {'name': 'Sport', 'slug': 'sport', 'icon': '[SOCCER_BALL]'},
    ]
    for cat_data in categories:
        Category.objects.create(**cat_data)


def reverse_categories(apps, schema_editor):
    """Annule la création des catégories."""
    Category = apps.get_model('workshops', 'Category')
    Category.objects.all().delete()


class Migration(migrations.Migration):

    dependencies = [
        ('workshops', '0001_initial'),
    ]

    operations = [
        migrations.RunPython(create_initial_categories, reverse_categories),
    ]
```

---

## Chapitre 6 — Relations entre Modèles

Les données réelles ont des relations. Dans SkillHub, un Workshop a un Instructor (User), un Instructor peut avoir plusieurs Workshops, etc. Django propose trois types de relations.

### 6.1 ForeignKey — Relation Plusieurs-à-Un

Un Workshop a **un** Instructor, mais un Instructor peut avoir **plusieurs** Workshops.

```python
class Workshop(models.Model):
    instructor = models.ForeignKey(
        User,                          # Le modèle cible
        on_delete=models.CASCADE,      # Que faire si l'user est supprimé ?
        related_name='workshops_taught' # Nom de la relation inverse
    )
```

**Options `on_delete` :**

| Option | Comportement |
|---|---|
| `CASCADE` | Supprime le Workshop si l'Instructor est supprimé |
| `PROTECT` | Empêche la suppression de l'Instructor s'il a des Workshops |
| `SET_NULL` | Met `instructor` à NULL (nécessite `null=True`) |
| `SET_DEFAULT` | Remet la valeur par défaut |
| `DO_NOTHING` | Ne fait rien (dangereux, peut créer des orphelins) |

**Accès via le code Python :**

```python
workshop = Workshop.objects.get(id=1)

# Accès à l'instructor depuis le workshop (relation directe)
instructor = workshop.instructor
print(instructor.username)

# Accès aux workshops depuis l'instructor (relation inverse via related_name)
user = User.objects.get(username='alice')
mes_ateliers = user.workshops_taught.all()
mes_ateliers_publies = user.workshops_taught.filter(status='published')
```

### 6.2 OneToOneField — Relation Un-à-Un

Un User a **un** profil, et ce profil appartient à **un seul** User.

```python
# accounts/models.py
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver


class UserProfile(models.Model):
    """Profil étendu pour chaque utilisateur de SkillHub."""

    user = models.OneToOneField(
        User,
        on_delete=models.CASCADE,
        related_name='profile'
    )

    # Informations de profil
    bio = models.TextField(blank=True, verbose_name="Biographie")
    avatar = models.ImageField(
        upload_to='avatars/',
        null=True,
        blank=True,
        verbose_name="Photo de profil"
    )
    website = models.URLField(blank=True, verbose_name="Site web")
    location = models.CharField(max_length=100, blank=True, verbose_name="Ville")

    # Compétences : texte libre séparé par des virgules (on verra M2M après)
    skills = models.TextField(
        blank=True,
        verbose_name="Compétences",
        help_text="Sépare tes compétences par des virgules"
    )

    # Statistiques
    is_instructor = models.BooleanField(default=False, verbose_name="Formateur")
    rating_average = models.FloatField(default=0.0, verbose_name="Note moyenne")

    # Réseaux sociaux
    linkedin_url = models.URLField(blank=True)
    github_url = models.URLField(blank=True)
    twitter_url = models.URLField(blank=True)

    class Meta:
        verbose_name = "Profil utilisateur"
        verbose_name_plural = "Profils utilisateurs"

    def __str__(self):
        return f"Profil de {self.user.get_full_name() or self.user.username}"

    @property
    def skills_list(self):
        """Retourne les compétences sous forme de liste."""
        return [s.strip() for s in self.skills.split(',') if s.strip()]


# Signal : crée automatiquement le profil quand un User est créé
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    if created:
        UserProfile.objects.create(user=instance)


@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
    instance.profile.save()
```

**Utilisation :**

```python
user = User.objects.get(username='alice')
profile = user.profile  # Accès direct grâce à related_name='profile'
print(profile.bio)
```

### 6.3 ManyToManyField — Relation Plusieurs-à-Plusieurs

Un Workshop peut avoir **plusieurs** Tags, et un Tag peut appartenir à **plusieurs** Workshops.

```python
class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)
    slug = models.SlugField(unique=True)

    def __str__(self):
        return self.name


class Workshop(models.Model):
    # ... autres champs ...

    tags = models.ManyToManyField(
        Tag,
        blank=True,
        related_name='workshops',
        verbose_name="Tags"
    )
```

**Utilisation :**

```python
workshop = Workshop.objects.get(id=1)

# Ajouter des tags
python_tag = Tag.objects.get(name='Python')
workshop.tags.add(python_tag)
workshop.tags.add(Tag.objects.get(name='Débutant'))

# Lister les tags
for tag in workshop.tags.all():
    print(tag.name)

# Retirer un tag
workshop.tags.remove(python_tag)

# Trouver tous les workshops avec le tag Python
python_workshops = python_tag.workshops.all()
```

### 6.4 ManyToMany avec Table Intermédiaire (Through)

Pour le modèle d'inscription (Registration), on a besoin d'une relation ManyToMany entre User et Workshop, mais avec des données supplémentaires (statut, date d'inscription, etc.) :

```python
class Registration(models.Model):
    """Inscription d'un utilisateur à un atelier."""

    class Status(models.TextChoices):
        PENDING = 'pending', 'En attente'
        CONFIRMED = 'confirmed', 'Confirmée'
        CANCELLED = 'cancelled', 'Annulée'
        WAITLISTED = 'waitlisted', 'En liste d\'attente'
        ATTENDED = 'attended', 'A participé'

    user = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name='registrations'
    )
    workshop = models.ForeignKey(
        Workshop,
        on_delete=models.CASCADE,
        related_name='registrations'
    )
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.PENDING
    )
    registered_at = models.DateTimeField(auto_now_add=True)
    notes = models.TextField(blank=True, verbose_name="Notes du participant")

    class Meta:
        verbose_name = "Inscription"
        verbose_name_plural = "Inscriptions"
        unique_together = [['user', 'workshop']]  # Un user ne peut s'inscrire qu'une fois

    def __str__(self):
        return f"{self.user.username} -> {self.workshop.title} ({self.get_status_display()})"
```

### 6.5 select_related et prefetch_related

Ces deux méthodes sont **cruciales pour la performance**. Comprends-les dès maintenant.

**Le problème N+1 :**

```python
# [X] MAUVAIS — Génère N+1 requêtes SQL
workshops = Workshop.objects.all()  # 1 requête
for workshop in workshops:
    print(workshop.instructor.username)  # 1 requête par workshop !
# Si tu as 100 ateliers -> 101 requêtes SQL !
```

**select_related (pour ForeignKey et OneToOne) :**

```python
# [OK] BON — 1 seule requête SQL avec JOIN
workshops = Workshop.objects.select_related('instructor', 'category').all()
for workshop in workshops:
    print(workshop.instructor.username)  # Déjà chargé, 0 requête !
```

**prefetch_related (pour ManyToMany et relations inverses) :**

```python
# [OK] BON — 2 requêtes SQL (ateliers + tags en une fois)
workshops = Workshop.objects.prefetch_related('tags', 'registrations').all()
for workshop in workshops:
    for tag in workshop.tags.all():  # Déjà chargé, 0 requête !
        print(tag.name)
```

**Combinaison :**

```python
# Requête optimale pour la page de liste des ateliers
workshops = (
    Workshop.objects
    .select_related('instructor', 'instructor__profile', 'category')
    .prefetch_related('tags')
    .filter(status='published')
    .order_by('-starts_at')
)
```

---

## Chapitre 7 — Admin Django

L'admin Django est une interface d'administration automatique générée depuis tes modèles. C'est l'un des points forts de Django : en quelques lignes de code, tu as une interface CRUD complète.

### 7.1 Activer l'Admin et Créer un Superuser

```bash
# Crée un compte admin
python manage.py createsuperuser
# Saisir : username, email, password

# Lance le serveur et va sur http://127.0.0.1:8000/admin/
```

### 7.2 Enregistrer les Modèles

```python
# workshops/admin.py
from django.contrib import admin
from .models import Category, Workshop, Tag


# Version simple
admin.site.register(Category)

# Version personnalisée
@admin.register(Workshop)
class WorkshopAdmin(admin.ModelAdmin):

    # Colonnes affichées dans la liste
    list_display = [
        'title', 'instructor', 'category',
        'status', 'starts_at', 'max_participants',
        'spots_remaining', 'is_free'
    ]

    # Filtres dans la barre latérale droite
    list_filter = ['status', 'format', 'difficulty', 'category', 'is_free']

    # Champs de recherche (utilise LIKE en SQL)
    search_fields = ['title', 'description', 'instructor__username', 'instructor__email']

    # Champs modifiables directement dans la liste (sans ouvrir le détail)
    list_editable = ['status']

    # Champs qui génèrent des liens dans la liste
    list_display_links = ['title']

    # Liens avec la date de début
    date_hierarchy = 'starts_at'

    # Actions personnalisées dans le menu déroulant
    actions = ['publish_workshops', 'cancel_workshops']

    # Organisation des champs dans le formulaire de détail
    fieldsets = [
        ('Informations principales', {
            'fields': ['title', 'slug', 'short_description', 'description', 'cover_image']
        }),
        ('Organisation', {
            'fields': ['instructor', 'category', 'tags'],
        }),
        ('Logistique', {
            'fields': ['starts_at', 'duration_minutes', 'format', 'location', 'max_participants'],
            'classes': ['collapse'],  # Section rétractable
        }),
        ('Niveau et tarif', {
            'fields': ['difficulty', 'price', 'is_free'],
        }),
        ('Publication', {
            'fields': ['status'],
        }),
    ]

    # Auto-génération du slug depuis le titre
    prepopulated_fields = {'slug': ('title',)}

    # Champs en lecture seule
    readonly_fields = ['created_at', 'updated_at', 'spots_remaining']

    # Nombre d'objets par page
    list_per_page = 25

    # Actions personnalisées
    @admin.action(description='[OK] Publier les ateliers sélectionnés')
    def publish_workshops(self, request, queryset):
        updated = queryset.update(status='published')
        self.message_user(request, f"{updated} atelier(s) publié(s) avec succès.")

    @admin.action(description='[X] Annuler les ateliers sélectionnés')
    def cancel_workshops(self, request, queryset):
        updated = queryset.update(status='cancelled')
        self.message_user(request, f"{updated} atelier(s) annulé(s).")

    def spots_remaining(self, obj):
        """Colonne calculée dans la liste."""
        remaining = obj.spots_remaining
        if remaining == 0:
            return "Complet [X]"
        return f"{remaining} place(s)"
    spots_remaining.short_description = "Places restantes"
```

### 7.3 Inline Admin (Données liées dans le même formulaire)

```python
class RegistrationInline(admin.TabularInline):
    """Affiche les inscriptions directement dans la page de l'atelier."""
    model = Registration
    extra = 0  # Pas de lignes vides par défaut
    readonly_fields = ['registered_at']
    fields = ['user', 'status', 'registered_at']
    can_delete = True


@admin.register(Workshop)
class WorkshopAdmin(admin.ModelAdmin):
    # ...
    inlines = [RegistrationInline]
```

### 7.4 Personnaliser l'Admin Global

```python
# skillhub/urls.py ou dans un fichier admin_config.py
from django.contrib import admin

admin.site.site_header = "SkillHub Administration"
admin.site.site_title = "SkillHub Admin"
admin.site.index_title = "Tableau de bord administrateur"
```

### 7.5 Sécuriser l'Admin

L'URL `/admin/` est connue de tous les hackers. Meilleures pratiques :

```python
# skillhub/urls.py
urlpatterns = [
    # [OK] Utilise une URL non standard
    path('dashboard-secret-42/', admin.site.urls),
    # ...
]
```

```python
# settings/prod.py
# Forcer HTTPS pour l'admin
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
```

---

## [OUTIL] L'ORM Django en Pratique

L'ORM (Object-Relational Mapper) te permet d'interagir avec la base de données en Python pur. Voici les opérations essentielles :

### Créer des objets

```python
# Méthode 1 : create() — crée et sauvegarde en une ligne
workshop = Workshop.objects.create(
    title="Introduction à Python",
    description="Apprenez Python from scratch",
    instructor=user,
    starts_at="2024-03-15 14:00:00",
    duration_minutes=120,
)

# Méthode 2 : instancier puis save()
workshop = Workshop()
workshop.title = "Introduction à Python"
workshop.instructor = user
workshop.save()  # INSERT SQL
```

### Lire des objets

```python
# Tous les workshops
workshops = Workshop.objects.all()

# Filtrer
published = Workshop.objects.filter(status='published')
free_published = Workshop.objects.filter(status='published', is_free=True)

# Lookups avancés (__ = double underscore)
recent = Workshop.objects.filter(starts_at__year=2024)
long_title = Workshop.objects.filter(title__contains='Python')  # LIKE %Python%
long_title_ci = Workshop.objects.filter(title__icontains='python')  # Insensible à la casse

# Exclure
not_draft = Workshop.objects.exclude(status='draft')

# Obtenir un seul objet (lève une exception si 0 ou plusieurs résultats)
workshop = Workshop.objects.get(slug='introduction-python')

# get_or_create : obtient ou crée
tag, created = Tag.objects.get_or_create(name='Python', defaults={'slug': 'python'})

# Premier/Dernier
first = Workshop.objects.order_by('starts_at').first()
last = Workshop.objects.order_by('-starts_at').first()
```

### Mettre à jour

```python
# Un seul objet
workshop = Workshop.objects.get(id=1)
workshop.status = 'published'
workshop.save()  # UPDATE SQL

# Plusieurs objets en une requête (plus efficace)
Workshop.objects.filter(status='draft').update(status='published')
```

### Supprimer

```python
# Un seul objet
workshop.delete()

# Plusieurs objets
Workshop.objects.filter(status='cancelled').delete()
```

### Agréger

```python
from django.db.models import Count, Avg, Sum, Max, Min

# Compter
nb_workshops = Workshop.objects.filter(status='published').count()

# Agréger
stats = Workshop.objects.aggregate(
    total=Count('id'),
    avg_duration=Avg('duration_minutes'),
    max_participants=Max('max_participants'),
)
print(stats)  # {'total': 42, 'avg_duration': 95.5, 'max_participants': 100}

# Annoter (ajouter une valeur calculée à chaque objet)
workshops = Workshop.objects.annotate(
    nb_registrations=Count('registrations')
).order_by('-nb_registrations')

for w in workshops:
    print(f"{w.title}: {w.nb_registrations} inscrits")
```

---

## [OK] Exercices de Complétion — Partie II

### Exercice 2.1 — Modèle Review **

**Contexte :** SkillHub a besoin d'un système d'avis pour les ateliers.

**Instructions :**
1. Dans une nouvelle app `reviews`, crée un modèle `Review` avec :
   - `workshop` (ForeignKey vers Workshop)
   - `author` (ForeignKey vers User)
   - `rating` (entier de 1 à 5, utilise `MinValueValidator` et `MaxValueValidator`)
   - `comment` (TextField, optionnel)
   - `created_at` (DateTimeField auto)
   - `is_verified` (BooleanField, True si l'auteur a bien participé)
2. Un utilisateur ne peut écrire qu'un avis par atelier
3. La note moyenne est recalculée et sauvegardée dans `UserProfile.rating_average` à chaque nouvel avis (utilise les signaux post_save)

**Question :** Comment gérer le cas où un utilisateur supprime son avis ? La note moyenne doit-elle être recalculée ?

---

### Exercice 2.2 — QuerySet Complexe ***

**Instructions :** Dans le shell Django (`python manage.py shell`), écris les requêtes ORM pour :

1. Trouver tous les ateliers publiés qui commencent dans les 7 prochains jours
2. Trouver les 5 formateurs ayant proposé le plus d'ateliers (utilise `annotate` et `Count`)
3. Calculer le revenu total généré par les ateliers terminés et payants
4. Trouver les ateliers complets (tous les participants inscrits)
5. Trouver les ateliers ayant une note moyenne supérieure à 4/5

**Indice pour le point 1 :**
```python
from django.utils import timezone
from datetime import timedelta
now = timezone.now()
in_7_days = now + timedelta(days=7)
```

---

### Exercice 2.3 — Admin Avancé ***

**Instructions :**
1. Enregistre le modèle `UserProfile` dans l'admin avec une vue inline dans l'admin User
2. Ajoute une action `send_reminder` qui simule l'envoi d'un email de rappel aux inscrits 24h avant un atelier (affiche juste un message dans l'admin pour l'instant)
3. Ajoute un `list_display` avec une colonne colorée montrant le statut (vert=publié, rouge=annulé, gris=brouillon) en utilisant `format_html`

**Indice :**
```python
from django.utils.html import format_html

def status_badge(self, obj):
    colors = {'published': 'green', 'cancelled': 'red', 'draft': 'gray'}
    color = colors.get(obj.status, 'black')
    return format_html(
        '<span style="color: {}; font-weight: bold;">{}</span>',
        color,
        obj.get_status_display()
    )
status_badge.short_description = "Statut"
```

---

### Exercice 2.4 — Migration avec Données **

**Instructions :**
1. Crée une migration qui ajoute un champ `prerequisite_skills` (TextField, blank=True) au modèle Workshop
2. Crée une DataMigration qui remplit ce champ avec la valeur "Aucun prérequis" pour tous les ateliers existants de niveau 'beginner'
3. Vérifie que tu peux rollback cette migration sans erreur

---

### Exercice 2.5 — Modèle Complet SkillHub ****

**Défi :** En te basant sur ce que tu as appris, conçois et implémente le modèle `Certificate` qui représente un certificat de participation émis après un atelier :
- Il est lié à une Registration (donc à un User et un Workshop)
- Il a un numéro unique généré automatiquement (utilise `uuid`)
- Il a une date d'émission
- Il a un statut (valide/révoqué)
- La méthode `__str__` retourne quelque chose de lisible
- Crée et applique la migration correspondante

---

## [IMPORTANT] Récapitulatif Partie II

| Concept | Syntaxe clé | À retenir |
|---|---|---|
| Définir un modèle | `class Workshop(models.Model)` | Hérite toujours de `models.Model` |
| Champ texte court | `models.CharField(max_length=200)` | `max_length` obligatoire |
| Champ texte long | `models.TextField()` | Pas de `max_length` |
| Relation N-1 | `models.ForeignKey(Model, on_delete=CASCADE)` | Toujours définir `on_delete` |
| Relation 1-1 | `models.OneToOneField(Model, ...)` | Parfait pour les profils |
| Relation N-N | `models.ManyToManyField(Model)` | Génère une table de jointure |
| Créer migration | `python manage.py makemigrations` | Après chaque modif de modèle |
| Appliquer migration | `python manage.py migrate` | Exécute le SQL |
| Admin simple | `admin.site.register(Model)` | Ou decorator `@admin.register` |
| Optimiser requêtes | `select_related()`, `prefetch_related()` | TOUJOURS pour les relations |

---

> -> **Suite : Fichier 3 — Vues & Templates**
> Tu vas maintenant apprendre à créer les pages web de SkillHub : liste des ateliers, page de détail, formulaire de création, en utilisant les vues Django et le système de templates.

# [COURS] Formation Django — Maîtriser Django de Zéro à Expert
## Fichier 3/7 — Vues & Templates (Partie III)

---

> **Navigation des fichiers :**
> - [FICHIER] Fichier 1 — Introduction & Fondations
> - [FICHIER] Fichier 2 — Modèles & Base de données
> - [FICHIER] **Fichier 3** — Vues & Templates *(vous êtes ici)*
> - [FICHIER] Fichier 4 — URLs, Middleware & Authentification
> - [FICHIER] Fichier 5 — Django REST Framework
> - [FICHIER] Fichier 6 — Tests & Déploiement
> - [FICHIER] Fichier 7 — Expert : Optimisation, Projets & Architecture

---

# [CONFIG] PARTIE III — Vues et Templates

Les vues sont la **logique de ton application** : elles reçoivent les requêtes HTTP, interagissent avec les modèles, et retournent des réponses. Les templates sont le **rendu visuel** de ces données.

---

## Chapitre 8 — Vues Django

### 8.1 Function-Based Views (FBV)

Une FBV est simplement une fonction Python qui :
- Prend en paramètre un objet `request`
- Retourne un objet `HttpResponse`

```python
# workshops/views.py
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import Http404
from django.core.paginator import Paginator

from .models import Workshop, Category, Tag
from .forms import WorkshopForm


def workshop_list(request):
    """
    Page d'accueil des ateliers avec filtres et pagination.
    URL : /workshops/
    """
    # Base queryset : seulement les ateliers publiés
    workshops = Workshop.objects.filter(
        status='published'
    ).select_related(
        'instructor', 'instructor__profile', 'category'
    ).prefetch_related('tags')

    # === Filtres depuis les paramètres GET ===
    # Ex: /workshops/?category=programmation&level=beginner&format=online

    category_slug = request.GET.get('category')
    if category_slug:
        workshops = workshops.filter(category__slug=category_slug)

    level = request.GET.get('level')
    if level:
        workshops = workshops.filter(difficulty=level)

    format_type = request.GET.get('format')
    if format_type:
        workshops = workshops.filter(format=format_type)

    is_free = request.GET.get('free')
    if is_free == 'true':
        workshops = workshops.filter(is_free=True)

    # Recherche textuelle
    search_query = request.GET.get('q')
    if search_query:
        workshops = workshops.filter(
            title__icontains=search_query
        ) | workshops.filter(
            description__icontains=search_query
        )

    # === Tri ===
    sort = request.GET.get('sort', '-starts_at')
    allowed_sorts = ['-starts_at', 'starts_at', '-price', 'price', 'title']
    if sort in allowed_sorts:
        workshops = workshops.order_by(sort)

    # === Pagination ===
    paginator = Paginator(workshops, per_page=12)  # 12 ateliers par page
    page_number = request.GET.get('page', 1)
    page_obj = paginator.get_page(page_number)

    # === Contexte ===
    context = {
        'workshops': page_obj,
        'page_obj': page_obj,
        'categories': Category.objects.all(),
        'search_query': search_query or '',
        'active_category': category_slug,
        'active_level': level,
        'active_format': format_type,
        'total_count': paginator.count,
    }

    return render(request, 'workshops/list.html', context)


def workshop_detail(request, slug):
    """
    Page de détail d'un atelier.
    URL : /workshops/<slug>/
    """
    workshop = get_object_or_404(
        Workshop.objects.select_related(
            'instructor', 'instructor__profile', 'category'
        ).prefetch_related('tags', 'reviews__author'),
        slug=slug,
        status='published'
    )

    # Vérifie si l'utilisateur connecté est déjà inscrit
    user_registration = None
    if request.user.is_authenticated:
        user_registration = workshop.registrations.filter(
            user=request.user
        ).first()

    # Ateliers similaires (même catégorie, sauf celui-ci)
    similar_workshops = Workshop.objects.filter(
        category=workshop.category,
        status='published'
    ).exclude(id=workshop.id).select_related('instructor')[:3]

    context = {
        'workshop': workshop,
        'user_registration': user_registration,
        'similar_workshops': similar_workshops,
        'reviews': workshop.reviews.all().select_related('author'),
        'can_register': (
            request.user.is_authenticated
            and not user_registration
            and not workshop.is_full
        ),
    }

    return render(request, 'workshops/detail.html', context)


@login_required  # Redirige vers /login/ si non connecté
def workshop_create(request):
    """
    Formulaire de création d'un atelier.
    URL : /workshops/create/
    """
    if request.method == 'POST':
        form = WorkshopForm(request.POST, request.FILES)
        if form.is_valid():
            workshop = form.save(commit=False)  # Ne sauvegarde pas encore
            workshop.instructor = request.user   # Assigne l'instructeur
            workshop.save()
            form.save_m2m()  # Sauvegarde les relations ManyToMany (tags)

            messages.success(
                request,
                f'[OK] L\'atelier "{workshop.title}" a été créé avec succès !'
            )
            return redirect('workshops:detail', slug=workshop.slug)
    else:
        form = WorkshopForm()

    return render(request, 'workshops/form.html', {
        'form': form,
        'action': 'Créer un atelier',
        'submit_label': 'Publier l\'atelier'
    })


@login_required
def workshop_edit(request, slug):
    """
    Formulaire de modification d'un atelier.
    URL : /workshops/<slug>/edit/
    """
    workshop = get_object_or_404(Workshop, slug=slug)

    # Vérification des permissions : seul le formateur peut modifier
    if workshop.instructor != request.user and not request.user.is_staff:
        messages.error(request, "Vous n'avez pas la permission de modifier cet atelier.")
        return redirect('workshops:detail', slug=slug)

    if request.method == 'POST':
        form = WorkshopForm(request.POST, request.FILES, instance=workshop)
        if form.is_valid():
            form.save()
            messages.success(request, '[OK] Atelier mis à jour avec succès !')
            return redirect('workshops:detail', slug=workshop.slug)
    else:
        form = WorkshopForm(instance=workshop)

    return render(request, 'workshops/form.html', {
        'form': form,
        'workshop': workshop,
        'action': 'Modifier l\'atelier',
        'submit_label': 'Enregistrer les modifications'
    })


@login_required
def workshop_delete(request, slug):
    """
    Suppression d'un atelier (confirmation POST requise).
    URL : /workshops/<slug>/delete/
    """
    workshop = get_object_or_404(Workshop, slug=slug, instructor=request.user)

    if request.method == 'POST':
        title = workshop.title
        workshop.delete()
        messages.success(request, f'L\'atelier "{title}" a été supprimé.')
        return redirect('workshops:list')

    return render(request, 'workshops/confirm_delete.html', {'workshop': workshop})
```

### 8.2 Class-Based Views (CBV)

Les CBV utilisent des classes Python au lieu de fonctions. Elles offrent plus de réutilisabilité via l'héritage, mais sont plus complexes à comprendre au début.

Django fournit des vues génériques prêtes à l'emploi :

```python
# workshops/views_cbv.py — Version CBV des mêmes vues
from django.views.generic import (
    ListView, DetailView, CreateView, UpdateView, DeleteView
)
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.urls import reverse_lazy
from django.contrib import messages

from .models import Workshop
from .forms import WorkshopForm


class WorkshopListView(ListView):
    """
    Équivalent CBV de workshop_list().
    Automatiquement : récupère Workshop.objects.all(), pagine, passe au template
    """
    model = Workshop
    template_name = 'workshops/list.html'
    context_object_name = 'workshops'  # Nom de la variable dans le template
    paginate_by = 12

    def get_queryset(self):
        """Surcharge le queryset de base pour filtrer."""
        queryset = Workshop.objects.filter(
            status='published'
        ).select_related('instructor', 'category')

        category = self.request.GET.get('category')
        if category:
            queryset = queryset.filter(category__slug=category)

        return queryset

    def get_context_data(self, **kwargs):
        """Ajoute des données supplémentaires au contexte."""
        context = super().get_context_data(**kwargs)
        context['categories'] = Category.objects.all()
        return context


class WorkshopDetailView(DetailView):
    model = Workshop
    template_name = 'workshops/detail.html'
    context_object_name = 'workshop'
    slug_field = 'slug'  # Le champ utilisé pour identifier l'objet dans l'URL


class WorkshopCreateView(LoginRequiredMixin, CreateView):
    """LoginRequiredMixin redirige si non connecté."""
    model = Workshop
    form_class = WorkshopForm
    template_name = 'workshops/form.html'

    def form_valid(self, form):
        """Appelé si le formulaire est valide."""
        form.instance.instructor = self.request.user
        messages.success(self.request, "Atelier créé avec succès !")
        return super().form_valid(form)


class WorkshopUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
    model = Workshop
    form_class = WorkshopForm
    template_name = 'workshops/form.html'

    def test_func(self):
        """UserPassesTestMixin : vérifie si l'user a le droit."""
        workshop = self.get_object()
        return self.request.user == workshop.instructor

    def handle_no_permission(self):
        messages.error(self.request, "Permission refusée.")
        return redirect('workshops:list')


class WorkshopDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
    model = Workshop
    template_name = 'workshops/confirm_delete.html'
    success_url = reverse_lazy('workshops:list')

    def test_func(self):
        return self.request.user == self.get_object().instructor
```

### 8.3 Quand utiliser FBV vs CBV ?

| Situation | Recommandation |
|---|---|
| Logique simple et directe | FBV — plus lisible |
| CRUD standard (list/detail/create/update/delete) | CBV — moins de code |
| Logique complexe avec beaucoup de conditions | FBV — plus explicite |
| Réutilisation de logique entre vues | CBV avec Mixins |
| Débutant | Commence par les FBV |

> [IDEE] **Conseil :** Les deux approches coexistent souvent dans un même projet. Comprends les FBV d'abord, puis les CBV viendront naturellement.

---

## Chapitre 9 — Templates et Context

### 9.1 Le Django Template Language (DTL)

Le DTL est un langage de template qui te permet d'insérer de la logique dans tes fichiers HTML.

**Variables :**

```html
<!-- Affiche la valeur de title -->
<h1>{{ workshop.title }}</h1>

<!-- Accès aux attributs et méthodes -->
<p>{{ workshop.instructor.get_full_name }}</p>
<p>{{ workshop.duration_display }}</p>  <!-- Propriété @property -->

<!-- Valeur par défaut si variable vide -->
<p>{{ workshop.description|default:"Aucune description" }}</p>
```

**Tags de contrôle :**

```html
<!-- Condition if/elif/else -->
{% if workshop.is_full %}
    <span class="badge badge-danger">Complet</span>
{% elif workshop.spots_remaining <= 3 %}
    <span class="badge badge-warning">{{ workshop.spots_remaining }} place(s) restante(s) !</span>
{% else %}
    <span class="badge badge-success">Places disponibles</span>
{% endif %}

<!-- Boucle for -->
{% for workshop in workshops %}
    <div class="workshop-card">
        <h2>{{ workshop.title }}</h2>
        <p>{{ workshop.short_description }}</p>
    </div>
{% empty %}
    <!-- Affiché si la liste est vide -->
    <p>Aucun atelier disponible pour l'instant.</p>
{% endfor %}

<!-- Variables de boucle spéciales -->
{% for tag in workshop.tags.all %}
    <span class="tag">{{ tag.name }}</span>
    {% if not forloop.last %}, {% endif %}  <!-- Pas de virgule après le dernier -->
{% endfor %}

<!-- forloop.counter : numéro de l'itération (commence à 1) -->
<!-- forloop.counter0 : numéro de l'itération (commence à 0) -->
<!-- forloop.first : True pour le premier élément -->
<!-- forloop.last : True pour le dernier élément -->
```

**Filtres :**

```html
<!-- Formater les dates -->
{{ workshop.starts_at|date:"d/m/Y à H:i" }}
{{ workshop.starts_at|date:"l j F Y" }}  <!-- Lundi 15 mars 2024 -->

<!-- Manipuler le texte -->
{{ workshop.title|upper }}              <!-- MAJUSCULES -->
{{ workshop.title|lower }}              <!-- minuscules -->
{{ workshop.title|capfirst }}           <!-- Première lettre en majuscule -->
{{ workshop.title|truncatechars:50 }}   <!-- Tronque à 50 caractères -->
{{ workshop.description|truncatewords:20 }}  <!-- Tronque à 20 mots -->

<!-- Nombres -->
{{ workshop.price|floatformat:2 }}      <!-- 25.00 -->
{{ workshop.max_participants|add:"0" }} <!-- Convertit en entier -->

<!-- Listes -->
{{ workshop.tags.all|join:", " }}       <!-- "Python, Django, Web" -->
{{ workshops|length }}                  <!-- Nombre d'éléments -->

<!-- Sécurité -->
{{ user_comment|escape }}              <!-- Échappe le HTML (automatique) -->
{{ safe_html_content|safe }}           <!-- Marque comme sûr ([ATTENTION] à utiliser avec précaution) -->
{{ workshop.description|linebreaks }}  <!-- Convertit \n en <br> et <p> -->
```

### 9.2 Héritage de Templates

Le système d'héritage évite de répéter le HTML commun (header, footer, navigation) dans chaque page.

**Le template de base :**

```html
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <!-- Le block title permet aux enfants de définir leur propre titre -->
    <title>{% block title %}SkillHub{% endblock %} — Partagez vos compétences</title>

    {% load static %}
    <link rel="stylesheet" href="{% static 'css/main.css' %}">

    <!-- Block pour du CSS spécifique à une page -->
    {% block extra_css %}{% endblock %}
</head>
<body>
    <!-- Navigation globale -->
    <nav class="navbar">
        <div class="container">
            <a href="{% url 'home' %}" class="navbar-brand">
                [COURS] SkillHub
            </a>

            <ul class="navbar-menu">
                <li><a href="{% url 'workshops:list' %}">Ateliers</a></li>

                {% if user.is_authenticated %}
                    <li><a href="{% url 'dashboard' %}">Mon tableau de bord</a></li>
                    <li><a href="{% url 'workshops:create' %}">+ Proposer un atelier</a></li>
                    <li>
                        <a href="{% url 'accounts:profile' user.username %}">
                            {{ user.get_full_name|default:user.username }}
                        </a>
                    </li>
                    <li><a href="{% url 'accounts:logout' %}">Déconnexion</a></li>
                {% else %}
                    <li><a href="{% url 'accounts:login' %}">Connexion</a></li>
                    <li><a href="{% url 'accounts:register' %}" class="btn-primary">Inscription</a></li>
                {% endif %}
            </ul>
        </div>
    </nav>

    <!-- Messages Flash (succès, erreurs, avertissements) -->
    {% if messages %}
        <div class="messages-container">
            {% for message in messages %}
                <div class="alert alert-{{ message.tags }}">
                    {{ message }}
                    <button class="close-btn">[X]</button>
                </div>
            {% endfor %}
        </div>
    {% endif %}

    <!-- Contenu principal — les pages enfants remplissent ce block -->
    <main class="main-content">
        {% block content %}{% endblock %}
    </main>

    <!-- Footer -->
    <footer class="footer">
        <div class="container">
            <p>&copy; 2024 SkillHub — Partagez, Apprenez, Grandissez</p>
            <p>
                <a href="{% url 'pages:about' %}">À propos</a> |
                <a href="{% url 'pages:contact' %}">Contact</a> |
                <a href="{% url 'pages:terms' %}">CGU</a>
            </p>
        </div>
    </footer>

    <script src="{% static 'js/main.js' %}"></script>
    {% block extra_js %}{% endblock %}
</body>
</html>
```

**Une page enfant :**

```html
<!-- templates/workshops/list.html -->
{% extends 'base.html' %}
{% load static %}

{% block title %}Ateliers disponibles{% endblock %}

{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/workshops.css' %}">
{% endblock %}

{% block content %}
<div class="container">
    <!-- En-tête de page -->
    <div class="page-header">
        <h1>[OBJECTIF] Ateliers disponibles</h1>
        <p>{{ total_count }} atelier{{ total_count|pluralize }} disponible{{ total_count|pluralize }}</p>
    </div>

    <!-- Filtres -->
    <aside class="filters">
        <form method="get" action="{% url 'workshops:list' %}">
            <div class="filter-group">
                <label>Rechercher</label>
                <input type="text" name="q" value="{{ search_query }}" placeholder="Ex: Python, Design...">
            </div>

            <div class="filter-group">
                <label>Catégorie</label>
                <select name="category">
                    <option value="">Toutes</option>
                    {% for category in categories %}
                        <option value="{{ category.slug }}"
                            {% if active_category == category.slug %}selected{% endif %}>
                            {{ category.icon }} {{ category.name }}
                        </option>
                    {% endfor %}
                </select>
            </div>

            <div class="filter-group">
                <label>Niveau</label>
                <select name="level">
                    <option value="">Tous</option>
                    <option value="beginner" {% if active_level == 'beginner' %}selected{% endif %}>[VERT] Débutant</option>
                    <option value="intermediate" {% if active_level == 'intermediate' %}selected{% endif %}>[JAUNE] Intermédiaire</option>
                    <option value="advanced" {% if active_level == 'advanced' %}selected{% endif %}>[ROUGE] Avancé</option>
                </select>
            </div>

            <button type="submit" class="btn-primary">[RECHERCHE] Filtrer</button>
            <a href="{% url 'workshops:list' %}" class="btn-secondary">Réinitialiser</a>
        </form>
    </aside>

    <!-- Liste des ateliers -->
    <div class="workshops-grid">
        {% for workshop in workshops %}
            <article class="workshop-card">
                {% if workshop.cover_image %}
                    <img src="{{ workshop.cover_image.url }}" alt="{{ workshop.title }}" class="card-image">
                {% else %}
                    <div class="card-placeholder">
                        {{ workshop.category.icon|default:"[COURS]" }}
                    </div>
                {% endif %}

                <div class="card-body">
                    <div class="card-meta">
                        <span class="badge badge-{{ workshop.difficulty }}">
                            {{ workshop.get_difficulty_display }}
                        </span>
                        {% if workshop.is_free %}
                            <span class="badge badge-free">Gratuit</span>
                        {% else %}
                            <span class="badge badge-paid">{{ workshop.price }}€</span>
                        {% endif %}
                    </div>

                    <h2 class="card-title">
                        <a href="{{ workshop.get_absolute_url }}">{{ workshop.title }}</a>
                    </h2>

                    <p class="card-description">{{ workshop.short_description }}</p>

                    <div class="card-instructor">
                        {% if workshop.instructor.profile.avatar %}
                            <img src="{{ workshop.instructor.profile.avatar.url }}"
                                 alt="{{ workshop.instructor.get_full_name }}" class="avatar-sm">
                        {% endif %}
                        <span>{{ workshop.instructor.get_full_name|default:workshop.instructor.username }}</span>
                    </div>

                    <div class="card-footer">
                        <span class="card-date">[CALENDRIER] {{ workshop.starts_at|date:"d/m à H:i" }}</span>
                        <span class="card-duration">[TEMPS] {{ workshop.duration_display }}</span>
                        {% if workshop.is_full %}
                            <span class="badge badge-full">Complet</span>
                        {% else %}
                            <span class="badge badge-spots">{{ workshop.spots_remaining }} place(s)</span>
                        {% endif %}
                    </div>
                </div>
            </article>
        {% empty %}
            <div class="empty-state">
                <span class="empty-icon">[RECHERCHE]</span>
                <h3>Aucun atelier trouvé</h3>
                <p>Essaie d'autres filtres ou
                    <a href="{% url 'workshops:create' %}">propose ton propre atelier</a> !
                </p>
            </div>
        {% endfor %}
    </div>

    <!-- Pagination -->
    {% if page_obj.has_other_pages %}
        <nav class="pagination">
            {% if page_obj.has_previous %}
                <a href="?page={{ page_obj.previous_page_number }}&{{ request.GET.urlencode }}"
                   class="page-link"><- Précédent</a>
            {% endif %}

            <span class="page-info">
                Page {{ page_obj.number }} sur {{ page_obj.paginator.num_pages }}
            </span>

            {% if page_obj.has_next %}
                <a href="?page={{ page_obj.next_page_number }}&{{ request.GET.urlencode }}"
                   class="page-link">Suivant -></a>
            {% endif %}
        </nav>
    {% endif %}
</div>
{% endblock %}
```

### 9.3 Inclusion de Templates Partiels

Pour les éléments réutilisables, utilise `{% include %}` :

```html
<!-- templates/components/workshop_card.html -->
<!-- Ce template attend une variable 'workshop' dans son contexte -->
<article class="workshop-card">
    <h2><a href="{{ workshop.get_absolute_url }}">{{ workshop.title }}</a></h2>
    <p>{{ workshop.short_description }}</p>
    <p>Par {{ workshop.instructor.get_full_name }}</p>
</article>
```

```html
<!-- Utilisation dans n'importe quel template -->
{% for workshop in workshops %}
    {% include 'components/workshop_card.html' with workshop=workshop %}
{% endfor %}
```

### 9.4 Template Tags Personnalisés

Quand les filtres et tags built-in ne suffisent pas, crée les tiens :

```python
# workshops/templatetags/__init__.py  (fichier vide)
# workshops/templatetags/workshop_tags.py

from django import template
from django.utils.html import format_html

register = template.Library()


@register.filter
def rating_stars(value):
    """
    Convertit une note numérique en étoiles HTML.
    Usage: {{ workshop.average_rating|rating_stars }}
    """
    if value is None:
        return "Pas encore noté"
    full_stars = int(value)
    empty_stars = 5 - full_stars
    html = '*' * full_stars + '*' * empty_stars
    return format_html(f'<span class="stars" title="{value:.1f}/5">{html}</span>')


@register.simple_tag(takes_context=True)
def active_url(context, url_name):
    """
    Retourne 'active' si l'URL courante correspond.
    Usage: <a class="{% active_url 'workshops:list' %}">Ateliers</a>
    """
    request = context['request']
    from django.urls import reverse
    try:
        target_url = reverse(url_name)
        if request.path == target_url:
            return 'active'
    except Exception:
        pass
    return ''


@register.inclusion_tag('components/pagination.html', takes_context=True)
def pagination(context, page_obj):
    """
    Affiche une pagination complète.
    Usage: {% pagination page_obj %}
    """
    return {
        'page_obj': page_obj,
        'request': context['request'],
    }
```

Utilisation dans les templates :

```html
{% load workshop_tags %}

<div class="rating">{{ workshop.average_rating|rating_stars }}</div>

<a class="nav-link {% active_url 'workshops:list' %}" href="{% url 'workshops:list' %}">
    Ateliers
</a>

{% pagination page_obj %}
```

---

## Chapitre 10 — Formulaires

### 10.1 Les Formulaires Django

Django fournit un système de formulaires puissant qui gère :
- Le rendu HTML du formulaire
- La validation des données
- Le nettoyage des données
- Les messages d'erreur

Il existe deux types principaux :

**`forms.Form`** : Pour les formulaires non liés à un modèle (ex: formulaire de contact, de recherche)

**`forms.ModelForm`** : Pour les formulaires directement liés à un modèle — c'est ce qu'on utilise le plus souvent.

### 10.2 ModelForm pour Workshop

```python
# workshops/forms.py
from django import forms
from django.utils import timezone

from .models import Workshop, Category, Tag


class WorkshopForm(forms.ModelForm):
    """Formulaire de création et modification d'un atelier."""

    # Personnalisation du champ tags pour un widget plus convivial
    tags = forms.ModelMultipleChoiceField(
        queryset=Tag.objects.all(),
        widget=forms.CheckboxSelectMultiple,
        required=False,
        label="Tags"
    )

    class Meta:
        model = Workshop
        fields = [
            'title', 'short_description', 'description',
            'category', 'tags',
            'starts_at', 'duration_minutes',
            'format', 'location',
            'max_participants', 'difficulty',
            'price', 'is_free',
            'cover_image',
            'status'
        ]

        # Personnalisation des widgets HTML
        widgets = {
            'title': forms.TextInput(attrs={
                'class': 'form-control',
                'placeholder': 'Ex: Initiation au dessin vectoriel en 2h'
            }),
            'short_description': forms.Textarea(attrs={
                'class': 'form-control',
                'rows': 3,
                'placeholder': 'Résumé accrocheur de 1-2 phrases...',
                'maxlength': 300,
            }),
            'description': forms.Textarea(attrs={
                'class': 'form-control form-textarea',
                'rows': 8,
                'placeholder': 'Décris l\'atelier en détail...'
            }),
            'starts_at': forms.DateTimeInput(attrs={
                'class': 'form-control',
                'type': 'datetime-local'  # Input de type datetime dans le navigateur
            }),
            'duration_minutes': forms.NumberInput(attrs={
                'class': 'form-control',
                'min': 15,
                'max': 480,
                'placeholder': 'Ex: 90 pour 1h30'
            }),
            'max_participants': forms.NumberInput(attrs={
                'class': 'form-control',
                'min': 1,
                'max': 500
            }),
            'price': forms.NumberInput(attrs={
                'class': 'form-control',
                'min': 0,
                'step': '0.01'
            }),
            'format': forms.Select(attrs={'class': 'form-select'}),
            'difficulty': forms.Select(attrs={'class': 'form-select'}),
            'category': forms.Select(attrs={'class': 'form-select'}),
            'status': forms.Select(attrs={'class': 'form-select'}),
        }

        labels = {
            'starts_at': 'Date et heure de début',
            'duration_minutes': 'Durée (en minutes)',
            'max_participants': 'Nombre maximum de participants',
            'is_free': 'Atelier gratuit',
        }

        help_texts = {
            'title': 'Sois précis et accrocheur. Le titre est le premier contact avec les participants.',
            'format': 'En ligne = via visio, Présentiel = à une adresse physique',
            'status': 'Garde "Brouillon" pour finaliser plus tard, "Publié" pour le rendre visible.',
        }

    def clean_starts_at(self):
        """
        Validation personnalisée : la date doit être dans le futur.
        Les méthodes clean_<fieldname> sont appelées automatiquement.
        """
        starts_at = self.cleaned_data.get('starts_at')
        if starts_at and starts_at <= timezone.now():
            raise forms.ValidationError(
                "La date de début doit être dans le futur."
            )
        return starts_at

    def clean_price(self):
        """Le prix ne peut pas être négatif."""
        price = self.cleaned_data.get('price')
        if price is not None and price < 0:
            raise forms.ValidationError("Le prix ne peut pas être négatif.")
        return price

    def clean(self):
        """
        Validation croisée entre plusieurs champs.
        La méthode clean() (sans nom de champ) est appelée après toutes les autres.
        """
        cleaned_data = super().clean()
        is_free = cleaned_data.get('is_free')
        price = cleaned_data.get('price')
        format_type = cleaned_data.get('format')
        location = cleaned_data.get('location')

        # Si payant, le prix doit être > 0
        if not is_free and price is not None and price == 0:
            self.add_error('price', "Un atelier payant doit avoir un prix supérieur à 0€.")

        # Si présentiel, une adresse est obligatoire
        if format_type in ['onsite', 'hybrid'] and not location:
            self.add_error('location', "Merci d'indiquer l'adresse pour un atelier en présentiel.")

        return cleaned_data
```

### 10.3 Template du Formulaire

```html
<!-- templates/workshops/form.html -->
{% extends 'base.html' %}

{% block title %}{{ action }}{% endblock %}

{% block content %}
<div class="container">
    <div class="form-container">
        <h1>{{ action }}</h1>

        <form method="post" enctype="multipart/form-data" novalidate>
            {% csrf_token %}  <!-- Protection CSRF obligatoire ! -->

            <!-- Affichage des erreurs globales (non liées à un champ) -->
            {% if form.non_field_errors %}
                <div class="alert alert-danger">
                    {% for error in form.non_field_errors %}
                        <p>{{ error }}</p>
                    {% endfor %}
                </div>
            {% endif %}

            <!-- Rendu manuel des champs pour un meilleur contrôle -->
            <div class="form-section">
                <h2>Informations principales</h2>

                <div class="form-group {% if form.title.errors %}has-error{% endif %}">
                    <label for="{{ form.title.id_for_label }}">
                        {{ form.title.label }}
                        <span class="required">*</span>
                    </label>
                    {{ form.title }}
                    {% if form.title.help_text %}
                        <small class="help-text">{{ form.title.help_text }}</small>
                    {% endif %}
                    {% for error in form.title.errors %}
                        <span class="error-message">[ATTENTION] {{ error }}</span>
                    {% endfor %}
                </div>

                <div class="form-group {% if form.short_description.errors %}has-error{% endif %}">
                    <label for="{{ form.short_description.id_for_label }}">
                        {{ form.short_description.label }}
                    </label>
                    {{ form.short_description }}
                    <span class="char-counter">
                        <span id="desc-count">0</span>/300 caractères
                    </span>
                    {% for error in form.short_description.errors %}
                        <span class="error-message">[ATTENTION] {{ error }}</span>
                    {% endfor %}
                </div>

                <!-- Raccourci : {{ form.as_p }} génère tous les champs -->
                <!-- Mais le rendu manuel donne plus de contrôle -->
            </div>

            <div class="form-section">
                <h2>Logistique</h2>

                <div class="form-row">
                    <div class="form-group">
                        <label>{{ form.starts_at.label }} *</label>
                        {{ form.starts_at }}
                        {% for error in form.starts_at.errors %}
                            <span class="error-message">[ATTENTION] {{ error }}</span>
                        {% endfor %}
                    </div>

                    <div class="form-group">
                        <label>{{ form.duration_minutes.label }} *</label>
                        {{ form.duration_minutes }}
                        {% for error in form.duration_minutes.errors %}
                            <span class="error-message">[ATTENTION] {{ error }}</span>
                        {% endfor %}
                    </div>
                </div>

                <div class="form-group">
                    <label>{{ form.format.label }}</label>
                    {{ form.format }}
                </div>

                <div class="form-group" id="location-group">
                    <label>{{ form.location.label }}</label>
                    {{ form.location }}
                    {% for error in form.location.errors %}
                        <span class="error-message">[ATTENTION] {{ error }}</span>
                    {% endfor %}
                </div>
            </div>

            <div class="form-actions">
                <button type="submit" class="btn-primary">{{ submit_label }}</button>
                <a href="{% url 'workshops:list' %}" class="btn-secondary">Annuler</a>
            </div>
        </form>
    </div>
</div>
{% endblock %}

{% block extra_js %}
<script>
// Compteur de caractères pour la description courte
const descInput = document.querySelector('#id_short_description');
const counter = document.querySelector('#desc-count');
if (descInput && counter) {
    const updateCount = () => counter.textContent = descInput.value.length;
    descInput.addEventListener('input', updateCount);
    updateCount();
}

// Afficher/masquer le champ Location selon le format
const formatSelect = document.querySelector('#id_format');
const locationGroup = document.querySelector('#location-group');
if (formatSelect && locationGroup) {
    const toggleLocation = () => {
        const val = formatSelect.value;
        locationGroup.style.display = (val === 'onsite' || val === 'hybrid') ? 'block' : 'none';
    };
    formatSelect.addEventListener('change', toggleLocation);
    toggleLocation();
}
</script>
{% endblock %}
```

### 10.4 Formulaire de Contact (Form pur, sans modèle)

```python
# pages/forms.py
from django import forms


class ContactForm(forms.Form):
    """Formulaire de contact simple sans modèle associé."""

    name = forms.CharField(
        max_length=100,
        label="Ton nom",
        widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Jean Dupont'})
    )
    email = forms.EmailField(
        label="Ton email",
        widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'jean@exemple.com'})
    )
    subject = forms.ChoiceField(
        choices=[
            ('', 'Sélectionne un sujet'),
            ('question', 'Question générale'),
            ('bug', 'Signaler un bug'),
            ('partnership', 'Partenariat'),
            ('other', 'Autre'),
        ],
        widget=forms.Select(attrs={'class': 'form-select'})
    )
    message = forms.CharField(
        widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 5}),
        label="Ton message",
        min_length=20,
        max_length=2000
    )
    accept_terms = forms.BooleanField(
        required=True,
        label="J'accepte que mes données soient utilisées pour répondre à ma demande"
    )
```

```python
# pages/views.py
from django.core.mail import send_mail
from django.conf import settings

def contact_view(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)
        if form.is_valid():
            # Les données sont validées et nettoyées
            name = form.cleaned_data['name']
            email = form.cleaned_data['email']
            subject = form.cleaned_data['subject']
            message = form.cleaned_data['message']

            # Envoyer l'email (on verra ça en détail plus tard)
            send_mail(
                subject=f"[SkillHub Contact] {subject} — {name}",
                message=f"De : {name} ({email})\n\n{message}",
                from_email=settings.DEFAULT_FROM_EMAIL,
                recipient_list=['contact@skillhub.com'],
            )

            messages.success(request, "[OK] Ton message a bien été envoyé ! On te répondra sous 48h.")
            return redirect('pages:contact')
    else:
        form = ContactForm()

    return render(request, 'pages/contact.html', {'form': form})
```

---

## [OK] Exercices de Complétion — Partie III

### Exercice 3.1 — Page Détail Complète **

**Contexte :** La page de détail d'un atelier doit afficher toutes les informations.

**Instructions :**
1. Crée la view `workshop_detail` et son template `workshops/detail.html`
2. Le template doit afficher : titre, description, informations de l'instructeur, date/heure/durée, format, niveau, prix, tags, places restantes, bouton d'inscription
3. Affiche les avis existants avec la note en étoiles
4. Affiche 3 ateliers similaires en bas de page
5. Si l'utilisateur est l'instructeur de l'atelier, affiche les boutons Modifier et Supprimer

---

### Exercice 3.2 — Tableau de Bord Utilisateur ***

**Contexte :** Chaque utilisateur a un tableau de bord personnel.

**Instructions :**
1. Crée une view `dashboard` qui nécessite d'être connecté
2. Le tableau de bord affiche :
   - Les ateliers que l'utilisateur propose (onglet "Mes ateliers")
   - Les ateliers auxquels il est inscrit (onglet "Mes inscriptions")
   - Des statistiques : nombre total d'ateliers, inscrits, avis reçus
3. Utilise des tabs HTML/CSS pour basculer entre les sections
4. Les ateliers en cours et à venir sont distingués des passés

---

### Exercice 3.3 — Formulaire d'Inscription **

**Contexte :** Implémenter l'inscription à un atelier.

**Instructions :**
1. Crée une view `workshop_register` qui prend le slug de l'atelier
2. Si l'atelier est complet -> affiche un message d'erreur et propose la liste d'attente
3. Si l'utilisateur est déjà inscrit -> affiche son statut d'inscription
4. Si l'inscription réussit -> envoie un email de confirmation (avec `console.EmailBackend`)
5. Crée une view `workshop_unregister` pour se désinscrire (avec confirmation)

---

### Exercice 3.4 — Template Tag Personnalisé ***

**Instructions :**
1. Crée un template tag `{% workshop_card workshop %}` qui affiche la carte d'un atelier
2. Ce tag doit être une `inclusion_tag` utilisant le template `components/workshop_card.html`
3. Crée un filtre `duration_human` qui convertit 95 minutes en "1h35"
4. Crée un filtre `price_display` qui affiche "Gratuit" si le prix est 0, sinon "25,00€"

---

### Exercice 3.5 — Page de Recherche Avancée ****

**Contexte :** Les utilisateurs veulent chercher des ateliers avec des critères précis.

**Instructions :**
1. Crée une page `/search/` avec un formulaire de recherche avancée
2. Critères : mots-clés, catégorie, niveau, format, date entre X et Y, prix max, note minimale
3. Les résultats sont paginés (10 par page)
4. Un résumé des filtres actifs est affiché au-dessus des résultats avec des boutons pour les supprimer individuellement
5. Les paramètres de filtres persistent dans l'URL (GET parameters)
6. Le titre de la page change selon les filtres actifs (ex: "15 ateliers Python à Paris")

**Bonus :** Ajoute un tri des résultats (par date, par prix, par note) qui maintient les autres filtres.

---

## [IMPORTANT] Récapitulatif Partie III

| Concept | Ce que c'est | Exemple |
|---|---|---|
| FBV | Fonction qui retourne HttpResponse | `def workshop_list(request):` |
| CBV | Classe héritant d'une vue générique | `class WorkshopListView(ListView):` |
| render() | Génère une réponse HTML avec template | `return render(request, 'list.html', ctx)` |
| get_object_or_404() | Récupère un objet ou retourne 404 | `get_object_or_404(Workshop, slug=slug)` |
| redirect() | Redirige vers une autre URL | `return redirect('workshops:list')` |
| @login_required | Décore une vue pour forcer la connexion | `@login_required` |
| Context | Données passées au template | `{'workshops': queryset}` |
| Template héritage | Une page enfant étend une base | `{% extends 'base.html' %}` |
| ModelForm | Formulaire lié à un modèle | `class WorkshopForm(forms.ModelForm):` |
| clean_<field>() | Validation personnalisée d'un champ | `def clean_starts_at(self):` |
| {% csrf_token %} | Protection contre CSRF | Obligatoire dans tous les formulaires POST |

---

> -> **Suite : Fichier 4 — URLs, Middleware & Authentification**
> Tu vas apprendre le routing avancé, les middlewares, et implémenter le système complet d'authentification (inscription, connexion, déconnexion, réinitialisation de mot de passe) pour SkillHub.

# [COURS] Formation Django — Maîtriser Django de Zéro à Expert
## Fichier 4/7 — URLs, Middleware & Authentification (Partie IV)

---

> **Navigation des fichiers :**
> - [FICHIER] Fichier 1 — Introduction & Fondations
> - [FICHIER] Fichier 2 — Modèles & Base de données
> - [FICHIER] Fichier 3 — Vues & Templates
> - [FICHIER] **Fichier 4** — URLs, Middleware & Authentification *(vous êtes ici)*
> - [FICHIER] Fichier 5 — Django REST Framework
> - [FICHIER] Fichier 6 — Tests & Déploiement
> - [FICHIER] Fichier 7 — Expert : Optimisation, Projets & Architecture

---

# [WEB] PARTIE IV — URL, Routes, Middleware & Authentification

---

## Chapitre 11 — Routing Avancé

### 11.1 Comment Django traite une URL

Quand Django reçoit une requête pour `/workshops/mon-atelier-python/`, voici exactement ce qui se passe :

```
1. Django reçoit la requête
2. Il lit ROOT_URLCONF = 'skillhub.urls' dans settings.py
3. Il parcourt skillhub/urls.py de haut en bas
4. Il trouve : path('workshops/', include('workshops.urls'))
5. Il retire '/workshops/' et cherche 'mon-atelier-python/' dans workshops/urls.py
6. Il trouve : path('<slug:slug>/', views.workshop_detail, name='detail')
7. Il appelle views.workshop_detail(request, slug='mon-atelier-python')
8. Il retourne la réponse de cette fonction
```

### 11.2 Structure d'URL du Projet SkillHub

```python
# skillhub/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    # Admin (URL personnalisée pour la sécurité)
    path('admin/', admin.site.urls),

    # Pages statiques (accueil, à propos, contact...)
    path('', include('apps.pages.urls', namespace='pages')),

    # Comptes utilisateurs
    path('accounts/', include('apps.accounts.urls', namespace='accounts')),

    # Ateliers
    path('workshops/', include('apps.workshops.urls', namespace='workshops')),

    # Inscriptions
    path('registrations/', include('apps.registrations.urls', namespace='registrations')),

    # Avis
    path('reviews/', include('apps.reviews.urls', namespace='reviews')),

    # API REST (Django REST Framework)
    path('api/v1/', include('apps.api.urls', namespace='api')),
]

# En développement seulement : servir les fichiers médias uploadés
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
    urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

    # Debug Toolbar (si installé)
    try:
        import debug_toolbar
        urlpatterns += [path('__debug__/', include(debug_toolbar.urls))]
    except ImportError:
        pass
```

### 11.3 URL Converters

Les "converters" définissent le type de données capturé dans l'URL :

```python
# workshops/urls.py
from django.urls import path
from . import views

app_name = 'workshops'

urlpatterns = [
    # <slug:slug> — Chaîne de caractères URL-friendly (lettres, chiffres, tirets)
    path('', views.workshop_list, name='list'),
    path('<slug:slug>/', views.workshop_detail, name='detail'),
    path('<slug:slug>/edit/', views.workshop_edit, name='edit'),
    path('<slug:slug>/delete/', views.workshop_delete, name='delete'),
    path('<slug:slug>/register/', views.workshop_register, name='register'),
    path('<slug:slug>/unregister/', views.workshop_unregister, name='unregister'),
    path('create/', views.workshop_create, name='create'),

    # <int:pk> — Entier positif
    # path('<int:pk>/', views.workshop_detail, name='detail'),  # Alternatif avec ID

    # <str:username> — Chaîne quelconque (sans slash)
    # path('by/<str:username>/', views.workshops_by_user, name='by_user'),

    # <uuid:pk> — UUID format
    # path('<uuid:pk>/', views.workshop_detail, name='detail'),
]
```

### 11.4 Named URLs et Reverse

**Toujours** utiliser des URLs nommées plutôt que des URLs en dur :

```python
# [OK] CORRECT — utilise le nom de l'URL
from django.urls import reverse

url = reverse('workshops:detail', kwargs={'slug': 'mon-atelier'})
# Retourne : '/workshops/mon-atelier/'

url = reverse('workshops:list')
# Retourne : '/workshops/'

# Dans les vues, pour rediriger
return redirect('workshops:detail', slug=workshop.slug)
```

```html
<!-- [OK] CORRECT dans les templates -->
<a href="{% url 'workshops:detail' slug=workshop.slug %}">Voir l'atelier</a>
<a href="{% url 'workshops:list' %}">Tous les ateliers</a>

<!-- [X] MAUVAIS — URL en dur -->
<a href="/workshops/mon-atelier/">Voir l'atelier</a>
```

**Pourquoi ?** Si tu changes la structure d'URL (ex: `/workshops/` -> `/ateliers/`), tu n'as qu'un seul endroit à modifier. Toutes les URLs nommées se mettent à jour automatiquement.

### 11.5 Converter Personnalisé

```python
# workshops/converters.py
class FourDigitYearConverter:
    """Capturer une année sur 4 chiffres dans l'URL."""
    regex = '[0-9]{4}'

    def to_python(self, value):
        return int(value)

    def to_url(self, value):
        return str(value)


# skillhub/urls.py
from django.urls import register_converter
from apps.workshops.converters import FourDigitYearConverter

register_converter(FourDigitYearConverter, 'yyyy')

urlpatterns = [
    # URL : /workshops/2024/
    path('workshops/<yyyy:year>/', views.workshops_by_year, name='by_year'),
]
```

---

## Chapitre 12 — Middleware

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

Un middleware est une **couche de traitement** qui s'exécute pour chaque requête, avant et/ou après la vue. C'est comme un filtre ou un intercepteur.

```
Requête HTTP
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
[Middleware 1] -> processus de la requête
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
[Middleware 2] -> processus de la requête
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
[Middleware 3] -> processus de la requête
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
    [Vue] -> traitement métier
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
[Middleware 3] -> processus de la réponse
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
[Middleware 2] -> processus de la réponse
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
[Middleware 1] -> processus de la réponse
     │
     [BLACK_DOWN-POINTING_TRIANGLE]
Réponse HTTP
```

Les middlewares Django intégrés gèrent la sécurité (CSRF, XSS), les sessions, l'authentification, etc.

### 12.2 Middleware Personnalisé — Logging des Requêtes

```python
# skillhub/middleware.py
import time
import logging

logger = logging.getLogger(__name__)


class RequestLoggingMiddleware:
    """
    Middleware qui logue le temps de traitement de chaque requête.
    Très utile pour identifier les requêtes lentes.
    """

    def __init__(self, get_response):
        """
        __init__ est appelé une seule fois au démarrage du serveur.
        get_response est une fonction qui appelle la vue.
        """
        self.get_response = get_response

    def __call__(self, request):
        """
        __call__ est appelé pour chaque requête.
        """
        # === Traitement AVANT la vue ===
        start_time = time.time()

        # Appelle la vue (et les middlewares suivants)
        response = self.get_response(request)

        # === Traitement APRÈS la vue ===
        duration = time.time() - start_time
        duration_ms = round(duration * 1000, 2)

        # Log les requêtes lentes (> 1 seconde)
        if duration > 1.0:
            logger.warning(
                f"[ATTENTION] REQUÊTE LENTE: {request.method} {request.path} "
                f"— {duration_ms}ms — User: {request.user}"
            )
        else:
            logger.debug(
                f"{request.method} {request.path} — {duration_ms}ms"
            )

        # Ajoute un header custom dans la réponse
        response['X-Response-Time'] = f"{duration_ms}ms"

        return response


class MaintenanceModeMiddleware:
    """
    Middleware de mode maintenance.
    Affiche une page de maintenance si activé dans les settings.
    """
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        from django.conf import settings
        from django.shortcuts import render

        # Si le mode maintenance est activé
        if getattr(settings, 'MAINTENANCE_MODE', False):
            # Sauf pour les URLs admin et les IPs autorisées
            admin_urls = ['/admin/']
            allowed_ips = getattr(settings, 'MAINTENANCE_ALLOWED_IPS', [])

            is_admin_url = any(request.path.startswith(url) for url in admin_urls)
            is_allowed_ip = request.META.get('REMOTE_ADDR') in allowed_ips

            if not is_admin_url and not is_allowed_ip:
                return render(request, '503.html', status=503)

        return self.get_response(request)


class UserActivityMiddleware:
    """
    Middleware qui met à jour la dernière activité de l'utilisateur connecté.
    """
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)

        # Après la réponse, met à jour l'activité si connecté
        if request.user.is_authenticated:
            from django.utils import timezone
            # Mise à jour toutes les 5 minutes seulement pour éviter trop d'écritures
            last_activity = request.session.get('last_activity')
            now = timezone.now()

            if not last_activity or (now - timezone.datetime.fromisoformat(last_activity)).seconds > 300:
                request.session['last_activity'] = now.isoformat()
                # Met à jour un champ last_seen sur le profil
                try:
                    request.user.profile.last_seen = now
                    request.user.profile.save(update_fields=['last_seen'])
                except Exception:
                    pass

        return response
```

Ajoute les middlewares dans `settings/base.py` :

```python
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',

    # Nos middlewares personnalisés
    'skillhub.middleware.RequestLoggingMiddleware',
    'skillhub.middleware.UserActivityMiddleware',
]
```

---

## Chapitre 13 — Sessions et Authentification

### 13.1 Sessions

Une session est un mécanisme pour stocker des données côté serveur entre plusieurs requêtes du même utilisateur. Django génère un identifiant de session unique stocké dans un cookie côté client.

```python
# Utilisation des sessions dans une vue
def workshop_view(request):
    # Lire une valeur de session
    last_search = request.session.get('last_search', '')

    # Écrire une valeur de session
    request.session['last_search'] = request.GET.get('q', '')

    # Supprimer une valeur
    if 'last_search' in request.session:
        del request.session['last_search']

    # Données de session comme un dictionnaire Python
    # Elles persistent tant que la session n'expire pas (2 semaines par défaut)
```

### 13.2 Modèle Utilisateur Personnalisé

**Règle d'or :** Toujours créer un modèle utilisateur personnalisé **au début** du projet. Il est très difficile de le faire plus tard !

```python
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    """
    Modèle utilisateur personnalisé de SkillHub.
    On hérite de AbstractUser pour garder toutes les fonctionnalités Django
    (login, password, groupes, permissions...) et on ajoute nos champs.
    """

    # Champ email unique (par défaut Django l'autorise en double)
    email = models.EmailField(unique=True)

    # Utiliser l'email comme identifiant de connexion
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username', 'first_name', 'last_name']

    class Meta:
        verbose_name = "Utilisateur"
        verbose_name_plural = "Utilisateurs"

    def get_absolute_url(self):
        from django.urls import reverse
        return reverse('accounts:profile', kwargs={'username': self.username})

    @property
    def full_name(self):
        return self.get_full_name() or self.username

    @property
    def initials(self):
        """Initiales pour l'avatar par défaut."""
        name = self.get_full_name()
        if name:
            parts = name.split()
            return ''.join(p[0].upper() for p in parts[:2])
        return self.username[:2].upper()
```

Dans `settings/base.py` :

```python
# Dis à Django d'utiliser notre modèle User personnalisé
AUTH_USER_MODEL = 'accounts.User'
```

### 13.3 Système d'Authentification Complet

```python
# accounts/views.py
from django.shortcuts import render, redirect
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes, force_str
from django.contrib.auth.tokens import default_token_generator
from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string

from .models import User
from .forms import (
    RegisterForm, LoginForm, ProfileEditForm,
    PasswordChangeCustomForm, PasswordResetRequestForm
)


def register_view(request):
    """
    Inscription d'un nouvel utilisateur.
    """
    if request.user.is_authenticated:
        return redirect('workshops:list')  # Déjà connecté

    if request.method == 'POST':
        form = RegisterForm(request.POST)
        if form.is_valid():
            user = form.save(commit=False)
            user.is_active = False  # Inactif jusqu'à validation email
            user.save()

            # Envoie un email de vérification
            send_verification_email(request, user)

            messages.info(
                request,
                "[EMAIL] Un email de vérification a été envoyé. "
                "Consulte ta boîte mail pour activer ton compte."
            )
            return redirect('accounts:login')
    else:
        form = RegisterForm()

    return render(request, 'accounts/register.html', {'form': form})


def send_verification_email(request, user):
    """Génère et envoie le lien de vérification d'email."""
    token = default_token_generator.make_token(user)
    uid = urlsafe_base64_encode(force_bytes(user.pk))

    verification_url = request.build_absolute_uri(
        f'/accounts/verify-email/{uid}/{token}/'
    )

    subject = "[COURS] SkillHub — Vérifie ton adresse email"
    message = render_to_string('accounts/emails/verification.txt', {
        'user': user,
        'verification_url': verification_url,
    })

    send_mail(
        subject=subject,
        message=message,
        from_email=settings.DEFAULT_FROM_EMAIL,
        recipient_list=[user.email],
    )


def verify_email_view(request, uidb64, token):
    """Vérifie le token et active le compte."""
    try:
        uid = force_str(urlsafe_base64_decode(uidb64))
        user = User.objects.get(pk=uid)
    except (TypeError, ValueError, OverflowError, User.DoesNotExist):
        user = None

    if user and default_token_generator.check_token(user, token):
        user.is_active = True
        user.save()
        login(request, user)
        messages.success(request, "[OK] Ton compte est activé ! Bienvenue sur SkillHub [BRAVO]")
        return redirect('workshops:list')
    else:
        messages.error(request, "[X] Le lien de vérification est invalide ou expiré.")
        return redirect('accounts:register')


def login_view(request):
    """
    Connexion utilisateur.
    """
    if request.user.is_authenticated:
        return redirect('workshops:list')

    if request.method == 'POST':
        form = LoginForm(request.POST)
        if form.is_valid():
            email = form.cleaned_data['email']
            password = form.cleaned_data['password']

            user = authenticate(request, username=email, password=password)

            if user:
                login(request, user)
                # Redirige vers la page demandée initialement si elle existe
                next_url = request.GET.get('next', 'workshops:list')
                messages.success(request, f"Bienvenue, {user.first_name or user.username} ! [WAVING_HAND_SIGN]")
                return redirect(next_url)
            else:
                messages.error(request, "[X] Email ou mot de passe incorrect.")
    else:
        form = LoginForm()

    return render(request, 'accounts/login.html', {'form': form})


def logout_view(request):
    """Déconnexion (doit être un POST pour la sécurité)."""
    if request.method == 'POST':
        logout(request)
        messages.success(request, "Tu es déconnecté. À bientôt ! [WAVING_HAND_SIGN]")
    return redirect('workshops:list')


@login_required
def profile_view(request, username):
    """Profil public d'un utilisateur."""
    from django.shortcuts import get_object_or_404
    profile_user = get_object_or_404(User, username=username)

    workshops = profile_user.workshops_taught.filter(
        status='published'
    ).select_related('category')[:6]

    return render(request, 'accounts/profile.html', {
        'profile_user': profile_user,
        'workshops': workshops,
        'is_own_profile': request.user == profile_user,
    })


@login_required
def profile_edit_view(request):
    """Modification du profil de l'utilisateur connecté."""
    profile = request.user.profile

    if request.method == 'POST':
        form = ProfileEditForm(
            request.POST,
            request.FILES,
            instance=profile,
            user=request.user
        )
        if form.is_valid():
            form.save()
            # Mise à jour du user (prénom, nom)
            request.user.first_name = form.cleaned_data['first_name']
            request.user.last_name = form.cleaned_data['last_name']
            request.user.save()
            messages.success(request, "[OK] Profil mis à jour avec succès !")
            return redirect('accounts:profile', username=request.user.username)
    else:
        form = ProfileEditForm(
            instance=profile,
            user=request.user,
            initial={
                'first_name': request.user.first_name,
                'last_name': request.user.last_name,
            }
        )

    return render(request, 'accounts/profile_edit.html', {'form': form})
```

### 13.4 Formulaires d'Authentification

```python
# accounts/forms.py
from django import forms
from django.contrib.auth.password_validation import validate_password

from .models import User
from .models import UserProfile


class RegisterForm(forms.ModelForm):
    """Formulaire d'inscription."""

    first_name = forms.CharField(
        max_length=50,
        label="Prénom",
        widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Marie'})
    )
    last_name = forms.CharField(
        max_length=50,
        label="Nom",
        widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Dupont'})
    )
    email = forms.EmailField(
        label="Adresse email",
        widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'marie@exemple.com'})
    )
    password = forms.CharField(
        label="Mot de passe",
        widget=forms.PasswordInput(attrs={'class': 'form-control'}),
        validators=[validate_password]
    )
    password_confirm = forms.CharField(
        label="Confirmer le mot de passe",
        widget=forms.PasswordInput(attrs={'class': 'form-control'})
    )
    accept_terms = forms.BooleanField(
        required=True,
        label="J'accepte les <a href='/legal/cgu/' target='_blank'>Conditions Générales d'Utilisation</a>"
    )

    class Meta:
        model = User
        fields = ['first_name', 'last_name', 'email', 'username']
        widgets = {
            'username': forms.TextInput(attrs={
                'class': 'form-control',
                'placeholder': 'marie_dupont'
            })
        }

    def clean_email(self):
        email = self.cleaned_data['email']
        if User.objects.filter(email=email).exists():
            raise forms.ValidationError("Cette adresse email est déjà utilisée.")
        return email

    def clean_username(self):
        username = self.cleaned_data['username']
        if User.objects.filter(username=username).exists():
            raise forms.ValidationError("Ce nom d'utilisateur est déjà pris.")
        if len(username) < 3:
            raise forms.ValidationError("Le nom d'utilisateur doit faire au moins 3 caractères.")
        return username

    def clean(self):
        cleaned_data = super().clean()
        password = cleaned_data.get('password')
        password_confirm = cleaned_data.get('password_confirm')

        if password and password_confirm and password != password_confirm:
            self.add_error('password_confirm', "Les deux mots de passe ne correspondent pas.")

        return cleaned_data

    def save(self, commit=True):
        user = super().save(commit=False)
        user.set_password(self.cleaned_data['password'])
        if commit:
            user.save()
        return user


class LoginForm(forms.Form):
    """Formulaire de connexion."""
    email = forms.EmailField(
        label="Email",
        widget=forms.EmailInput(attrs={
            'class': 'form-control',
            'placeholder': 'ton@email.com',
            'autofocus': True
        })
    )
    password = forms.CharField(
        label="Mot de passe",
        widget=forms.PasswordInput(attrs={'class': 'form-control'})
    )
    remember_me = forms.BooleanField(
        required=False,
        label="Se souvenir de moi"
    )
```

### 13.5 Permissions et Groupes

```python
# Créer des groupes et permissions programmatiquement
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from apps.workshops.models import Workshop

# Crée le groupe "Modérateurs"
moderators_group, _ = Group.objects.get_or_create(name='Modérateurs')

# Ajoute des permissions
content_type = ContentType.objects.get_for_model(Workshop)
permission = Permission.objects.get(
    codename='change_workshop',
    content_type=content_type,
)
moderators_group.permissions.add(permission)

# Ajoute un user au groupe
user.groups.add(moderators_group)
```

```python
# Vérification dans les vues
def workshop_moderate(request, slug):
    # Méthode 1 : vérifier la permission directement
    if not request.user.has_perm('workshops.change_workshop'):
        raise PermissionDenied

    # Méthode 2 : vérifier l'appartenance à un groupe
    if not request.user.groups.filter(name='Modérateurs').exists():
        return redirect('workshops:list')
```

```python
# Décorateurs de permission
from django.contrib.auth.decorators import permission_required

@permission_required('workshops.add_workshop', raise_exception=True)
def workshop_create(request):
    ...
```

```html
<!-- Dans les templates -->
{% if user.has_perm('workshops.change_workshop') %}
    <a href="{% url 'workshops:edit' workshop.slug %}">Modifier</a>
{% endif %}

{% if user.groups.all|join:"," contains "Modérateurs" %}
    <span class="badge">Modérateur</span>
{% endif %}
```

### 13.6 URLs Authentification

```python
# accounts/urls.py
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views

app_name = 'accounts'

urlpatterns = [
    # Inscription et vérification
    path('register/', views.register_view, name='register'),
    path('verify-email/<uidb64>/<token>/', views.verify_email_view, name='verify_email'),

    # Connexion / Déconnexion
    path('login/', views.login_view, name='login'),
    path('logout/', views.logout_view, name='logout'),

    # Profil
    path('profile/<str:username>/', views.profile_view, name='profile'),
    path('profile/edit/', views.profile_edit_view, name='profile_edit'),

    # Réinitialisation mot de passe (vues Django intégrées)
    path('password-reset/',
        auth_views.PasswordResetView.as_view(
            template_name='accounts/password_reset.html',
            email_template_name='accounts/emails/password_reset.txt',
            success_url='/accounts/password-reset/sent/'
        ),
        name='password_reset'
    ),
    path('password-reset/sent/',
        auth_views.PasswordResetDoneView.as_view(
            template_name='accounts/password_reset_done.html'
        ),
        name='password_reset_done'
    ),
    path('password-reset/<uidb64>/<token>/',
        auth_views.PasswordResetConfirmView.as_view(
            template_name='accounts/password_reset_confirm.html',
            success_url='/accounts/login/'
        ),
        name='password_reset_confirm'
    ),
]
```

### 13.7 Template de Connexion

```html
<!-- templates/accounts/login.html -->
{% extends 'base.html' %}

{% block title %}Connexion{% endblock %}

{% block content %}
<div class="auth-container">
    <div class="auth-card">
        <div class="auth-header">
            <h1>[SECURISE] Connexion</h1>
            <p>Pas encore de compte ?
                <a href="{% url 'accounts:register' %}">Inscris-toi gratuitement</a>
            </p>
        </div>

        <form method="post" novalidate>
            {% csrf_token %}

            {% if form.non_field_errors %}
                <div class="alert alert-danger">
                    {{ form.non_field_errors }}
                </div>
            {% endif %}

            <div class="form-group">
                <label for="{{ form.email.id_for_label }}">Email</label>
                {{ form.email }}
                {% for error in form.email.errors %}
                    <span class="error">{{ error }}</span>
                {% endfor %}
            </div>

            <div class="form-group">
                <label for="{{ form.password.id_for_label }}">
                    Mot de passe
                    <a href="{% url 'accounts:password_reset' %}" class="forgot-link">
                        Mot de passe oublié ?
                    </a>
                </label>
                {{ form.password }}
            </div>

            <div class="form-group form-check">
                {{ form.remember_me }}
                <label for="{{ form.remember_me.id_for_label }}">Se souvenir de moi</label>
            </div>

            <button type="submit" class="btn-primary btn-block">
                Se connecter ->
            </button>
        </form>

        <!-- Connexion sociale (optionnel) -->
        <div class="auth-separator">
            <span>ou</span>
        </div>

        <div class="social-login">
            <a href="#" class="btn-social btn-google">
                Continuer avec Google
            </a>
        </div>
    </div>
</div>
{% endblock %}
```

---

## [OK] Exercices de Complétion — Partie IV

### Exercice 4.1 — Middleware de Statistiques **

**Instructions :**
1. Crée un middleware `AnalyticsMiddleware` qui enregistre dans un modèle `PageView` :
   - L'URL visitée
   - La méthode HTTP
   - Le code de réponse
   - Le temps de traitement
   - L'utilisateur (s'il est connecté)
   - L'adresse IP
   - Le User-Agent (navigateur)
2. Exclure les URLs admin et les fichiers statiques du tracking
3. Crée une vue admin simple qui affiche les 100 dernières pages vues

---

### Exercice 4.2 — Système d'Invitation ***

**Contexte :** Un formateur peut inviter des personnes à son atelier par email.

**Instructions :**
1. Crée un modèle `Invitation` avec : workshop, email, token (UUID), expiration (48h), statut
2. Crée une vue pour envoyer des invitations depuis la page de détail d'un atelier
3. Crée une vue publique `/invite/<token>/` qui permet à la personne invitée de s'inscrire directement sans remplir de recherche
4. Le token doit expirer après 48h et être à usage unique

---

### Exercice 4.3 — Profil Public Complet ***

**Instructions :**
1. Crée la page de profil public d'un utilisateur
2. Elle affiche : photo, nom, bio, compétences (tags), localisation, ateliers proposés, évaluations reçues
3. Si c'est ton propre profil, tu vois le bouton "Modifier"
4. Si c'est le profil de quelqu'un d'autre, tu vois un bouton "Contacter" qui ouvre un formulaire de message
5. Crée un modèle `DirectMessage` pour stocker ces messages

---

### Exercice 4.4 — Router Les Permissions **

**Instructions :**
1. Crée trois groupes Django : "Utilisateurs", "Formateurs certifiés", "Modérateurs"
2. Implémente la logique : seuls les "Formateurs certifiés" peuvent créer des ateliers payants
3. Les "Modérateurs" peuvent modifier/supprimer n'importe quel atelier
4. Crée un décorateur personnalisé `@instructor_required` qui vérifie le statut de formateur
5. Crée une vue pour demander la certification de formateur

---

### Exercice 4.5 — Authentification Sociale (Bonus) ****

**Instructions :**
1. Installe `django-allauth`
2. Configure l'authentification Google OAuth2
3. Assure-toi qu'un UserProfile est créé lors de la connexion sociale
4. Gère le cas où un utilisateur existant se connecte avec Google avec le même email

---

## [IMPORTANT] Récapitulatif Partie IV

| Concept | Syntaxe / Outil | À retenir |
|---|---|---|
| URL Converter | `<slug:slug>`, `<int:pk>` | Choisir selon le type de données |
| Namespace URL | `app_name = 'workshops'` dans urls.py | Évite les conflits de noms |
| Reverse URL | `reverse('workshops:list')` | Jamais d'URL en dur |
| URL dans template | `{% url 'workshops:detail' slug=workshop.slug %}` | Toujours utiliser url tag |
| Middleware | Classe avec `__init__` et `__call__` | Traite requêtes et réponses |
| Session | `request.session['key'] = value` | Données persistantes par user |
| Custom User | `AbstractUser` dans `AUTH_USER_MODEL` | À faire dès le début ! |
| Login | `login(request, user)` | Après authenticate() |
| Logout | `logout(request)` | Via POST pour la sécurité |
| Permission | `@permission_required('app.action_model')` | Basé sur les modèles |
| Groupe | `user.groups.filter(name='X').exists()` | Rôles métier |

---

> -> **Suite : Fichier 5 — Django REST Framework**
> Tu vas construire l'API REST complète de SkillHub avec authentification JWT, sérialiseurs, ViewSets, filtres et pagination — pour permettre l'accès aux données depuis une application mobile ou un frontend React/Vue.

# [COURS] Formation Django — Maîtriser Django de Zéro à Expert
## Fichier 5/7 — Django REST Framework (Partie V)

---

> **Navigation des fichiers :**
> - [FICHIER] Fichier 1 — Introduction & Fondations
> - [FICHIER] Fichier 2 — Modèles & Base de données
> - [FICHIER] Fichier 3 — Vues & Templates
> - [FICHIER] Fichier 4 — URLs, Middleware & Authentification
> - [FICHIER] **Fichier 5** — Django REST Framework *(vous êtes ici)*
> - [FICHIER] Fichier 6 — Tests & Déploiement
> - [FICHIER] Fichier 7 — Expert : Optimisation, Projets & Architecture

---

# [BOITE_A_OUTILS] PARTIE V — Django REST Framework (DRF)

Une API REST (Representational State Transfer) permet à des applications externes (apps mobiles, frontends React/Vue, autres serveurs) de communiquer avec ton backend Django via des requêtes HTTP qui échangent des données en JSON.

---

## Chapitre 14 — Introduction à DRF

### 14.1 Installation et Configuration

```bash
pip install djangorestframework
pip install djangorestframework-simplejwt  # Pour l'auth JWT
pip install django-filter                   # Pour les filtres avancés
pip install drf-spectacular                 # Pour la documentation OpenAPI/Swagger
```

```python
# settings/base.py
INSTALLED_APPS = [
    # ... apps Django
    'rest_framework',
    'django_filters',
    'drf_spectacular',
]

# Configuration globale de DRF
REST_FRAMEWORK = {
    # Authentification par défaut
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
        'rest_framework.authentication.SessionAuthentication',  # Pour le browser API
    ],

    # Permissions par défaut
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticatedOrReadOnly',
    ],

    # Pagination par défaut
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 12,

    # Filtre par défaut
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
        'rest_framework.filters.SearchFilter',
        'rest_framework.filters.OrderingFilter',
    ],

    # Format de réponse par défaut
    'DEFAULT_RENDERER_CLASSES': [
        'rest_framework.renderers.JSONRenderer',
        'rest_framework.renderers.BrowsableAPIRenderer',  # Interface web de test
    ],

    # Schéma OpenAPI
    'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',

    # Throttling (limitation du débit)
    'DEFAULT_THROTTLE_CLASSES': [
        'rest_framework.throttling.AnonRateThrottle',
        'rest_framework.throttling.UserRateThrottle',
    ],
    'DEFAULT_THROTTLE_RATES': {
        'anon': '100/hour',
        'user': '1000/hour',
    },
}

# Configuration JWT
from datetime import timedelta
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
    'AUTH_HEADER_TYPES': ('Bearer',),
}
```

### 14.2 Comprendre les Serializers

Un serializer fait deux choses :
1. **Sérialisation** : Convertit un objet Python/Django en JSON (pour les réponses API)
2. **Désérialisation** : Valide et convertit du JSON en objet Python (pour les requêtes API)

```
Objet Django Workshop -> Serializer -> JSON {"id": 1, "title": "...", ...}
JSON {"title": "..."} -> Serializer -> Objet Django Workshop
```

### 14.3 Serializers pour SkillHub

```python
# api/serializers.py
from rest_framework import serializers
from django.contrib.auth import get_user_model

from apps.workshops.models import Workshop, Category, Tag
from apps.accounts.models import UserProfile
from apps.reviews.models import Review
from apps.registrations.models import Registration

User = get_user_model()


# === Serializers simples (pour les relations imbriquées) ===

class CategorySerializer(serializers.ModelSerializer):
    """Serializer simple pour les catégories."""
    workshops_count = serializers.SerializerMethodField()

    class Meta:
        model = Category
        fields = ['id', 'name', 'slug', 'icon', 'workshops_count']

    def get_workshops_count(self, obj):
        return obj.workshops.filter(status='published').count()


class TagSerializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = ['id', 'name', 'slug']


class UserPublicSerializer(serializers.ModelSerializer):
    """Informations publiques d'un utilisateur."""
    full_name = serializers.CharField(source='get_full_name')
    avatar_url = serializers.SerializerMethodField()
    workshops_count = serializers.SerializerMethodField()
    rating_average = serializers.FloatField(source='profile.rating_average', default=0)

    class Meta:
        model = User
        fields = ['id', 'username', 'full_name', 'avatar_url', 'workshops_count', 'rating_average']

    def get_avatar_url(self, obj):
        request = self.context.get('request')
        try:
            if obj.profile.avatar and request:
                return request.build_absolute_uri(obj.profile.avatar.url)
        except Exception:
            pass
        return None

    def get_workshops_count(self, obj):
        return obj.workshops_taught.filter(status='published').count()


class ReviewSerializer(serializers.ModelSerializer):
    author = UserPublicSerializer(read_only=True)

    class Meta:
        model = Review
        fields = ['id', 'author', 'rating', 'comment', 'is_verified', 'created_at']
        read_only_fields = ['author', 'is_verified', 'created_at']


# === Serializer principal pour Workshop ===

class WorkshopListSerializer(serializers.ModelSerializer):
    """
    Serializer allégé pour la liste des ateliers.
    N'inclut pas les champs lourds comme la description complète.
    """
    instructor = UserPublicSerializer(read_only=True)
    category = CategorySerializer(read_only=True)
    tags = TagSerializer(many=True, read_only=True)

    # Champs calculés
    duration_display = serializers.CharField(read_only=True)
    spots_remaining = serializers.IntegerField(read_only=True)
    is_full = serializers.BooleanField(read_only=True)
    average_rating = serializers.FloatField(read_only=True)
    cover_image_url = serializers.SerializerMethodField()

    class Meta:
        model = Workshop
        fields = [
            'id', 'title', 'slug', 'short_description',
            'instructor', 'category', 'tags',
            'starts_at', 'duration_display',
            'format', 'difficulty', 'price', 'is_free',
            'max_participants', 'spots_remaining', 'is_full',
            'cover_image_url', 'average_rating',
            'status', 'created_at',
        ]

    def get_cover_image_url(self, obj):
        request = self.context.get('request')
        if obj.cover_image and request:
            return request.build_absolute_uri(obj.cover_image.url)
        return None


class WorkshopDetailSerializer(WorkshopListSerializer):
    """
    Serializer complet pour le détail d'un atelier.
    Hérite du ListSerializer et ajoute les champs lourds.
    """
    reviews = ReviewSerializer(many=True, read_only=True)
    reviews_count = serializers.SerializerMethodField()

    class Meta(WorkshopListSerializer.Meta):
        fields = WorkshopListSerializer.Meta.fields + [
            'description', 'location', 'reviews', 'reviews_count'
        ]

    def get_reviews_count(self, obj):
        return obj.reviews.count()


class WorkshopCreateSerializer(serializers.ModelSerializer):
    """
    Serializer pour la création et modification d'un atelier.
    Gère les données entrantes (POST/PUT/PATCH).
    """
    tags = serializers.PrimaryKeyRelatedField(
        queryset=Tag.objects.all(),
        many=True,
        required=False
    )
    category = serializers.PrimaryKeyRelatedField(
        queryset=Category.objects.all(),
        required=False,
        allow_null=True
    )

    class Meta:
        model = Workshop
        fields = [
            'title', 'short_description', 'description',
            'category', 'tags',
            'starts_at', 'duration_minutes',
            'format', 'location', 'max_participants',
            'difficulty', 'price', 'is_free',
            'status',
        ]

    def validate_starts_at(self, value):
        from django.utils import timezone
        if value <= timezone.now():
            raise serializers.ValidationError("La date doit être dans le futur.")
        return value

    def validate(self, data):
        is_free = data.get('is_free', True)
        price = data.get('price', 0)
        format_type = data.get('format')
        location = data.get('location', '')

        if not is_free and price == 0:
            raise serializers.ValidationError({
                'price': "Un atelier payant doit avoir un prix > 0€."
            })

        if format_type in ['onsite', 'hybrid'] and not location:
            raise serializers.ValidationError({
                'location': "L'adresse est obligatoire pour un atelier en présentiel."
            })

        return data

    def create(self, validated_data):
        tags = validated_data.pop('tags', [])
        workshop = Workshop.objects.create(**validated_data)
        workshop.tags.set(tags)
        return workshop

    def update(self, instance, validated_data):
        tags = validated_data.pop('tags', None)
        for attr, value in validated_data.items():
            setattr(instance, attr, value)
        instance.save()
        if tags is not None:
            instance.tags.set(tags)
        return instance


# === Serializer pour l'authentification ===

class UserRegisterSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, min_length=8)
    password_confirm = serializers.CharField(write_only=True)

    class Meta:
        model = User
        fields = ['email', 'username', 'first_name', 'last_name', 'password', 'password_confirm']

    def validate(self, data):
        if data['password'] != data['password_confirm']:
            raise serializers.ValidationError({'password': "Les mots de passe ne correspondent pas."})
        return data

    def create(self, validated_data):
        validated_data.pop('password_confirm')
        user = User.objects.create_user(**validated_data)
        return user


class UserProfileSerializer(serializers.ModelSerializer):
    user = UserPublicSerializer(read_only=True)
    skills_list = serializers.ListField(source='skills_list', read_only=True)

    class Meta:
        model = UserProfile
        fields = [
            'user', 'bio', 'avatar', 'website', 'location',
            'skills', 'skills_list', 'is_instructor', 'rating_average',
            'linkedin_url', 'github_url',
        ]
```

---

## Chapitre 15 — ViewSets et APIViews

### 15.1 APIView (FBV Équivalent pour les APIs)

```python
# api/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated


class WorkshopRegisterAPIView(APIView):
    """
    API pour s'inscrire ou se désinscrire d'un atelier.
    POST /api/v1/workshops/<slug>/register/
    DELETE /api/v1/workshops/<slug>/register/
    """
    permission_classes = [IsAuthenticated]

    def post(self, request, slug):
        from apps.workshops.models import Workshop
        from apps.registrations.models import Registration

        try:
            workshop = Workshop.objects.get(slug=slug, status='published')
        except Workshop.DoesNotExist:
            return Response(
                {'error': 'Atelier non trouvé'},
                status=status.HTTP_404_NOT_FOUND
            )

        if workshop.is_full:
            return Response(
                {'error': 'L\'atelier est complet', 'can_waitlist': True},
                status=status.HTTP_400_BAD_REQUEST
            )

        registration, created = Registration.objects.get_or_create(
            user=request.user,
            workshop=workshop,
            defaults={'status': 'confirmed'}
        )

        if not created:
            return Response(
                {'error': 'Vous êtes déjà inscrit à cet atelier'},
                status=status.HTTP_400_BAD_REQUEST
            )

        return Response(
            {'message': 'Inscription confirmée !', 'registration_id': registration.id},
            status=status.HTTP_201_CREATED
        )

    def delete(self, request, slug):
        from apps.workshops.models import Workshop
        from apps.registrations.models import Registration

        try:
            workshop = Workshop.objects.get(slug=slug)
            registration = Registration.objects.get(user=request.user, workshop=workshop)
            registration.delete()
            return Response({'message': 'Désinscription effectuée'}, status=status.HTTP_204_NO_CONTENT)
        except (Workshop.DoesNotExist, Registration.DoesNotExist):
            return Response({'error': 'Inscription non trouvée'}, status=status.HTTP_404_NOT_FOUND)
```

### 15.2 ViewSets (La Puissance de DRF)

Les ViewSets regroupent toutes les actions CRUD en une seule classe :

```python
# api/viewsets.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly, AllowAny

from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter

from apps.workshops.models import Workshop, Category, Tag
from .serializers import (
    WorkshopListSerializer, WorkshopDetailSerializer,
    WorkshopCreateSerializer, CategorySerializer, TagSerializer,
    ReviewSerializer
)
from .permissions import IsInstructorOrReadOnly
from .filters import WorkshopFilter
from .pagination import WorkshopPagination


class WorkshopViewSet(viewsets.ModelViewSet):
    """
    ViewSet complet pour les ateliers.

    Génère automatiquement :
    GET    /api/v1/workshops/            -> list()
    POST   /api/v1/workshops/            -> create()
    GET    /api/v1/workshops/<slug>/     -> retrieve()
    PUT    /api/v1/workshops/<slug>/     -> update()
    PATCH  /api/v1/workshops/<slug>/     -> partial_update()
    DELETE /api/v1/workshops/<slug>/     -> destroy()

    + Actions personnalisées :
    POST /api/v1/workshops/<slug>/register/
    GET  /api/v1/workshops/<slug>/reviews/
    GET  /api/v1/workshops/featured/
    """

    lookup_field = 'slug'  # Utilise le slug dans l'URL au lieu de l'ID

    permission_classes = [IsInstructorOrReadOnly]
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_class = WorkshopFilter
    search_fields = ['title', 'description', 'instructor__username']
    ordering_fields = ['starts_at', 'price', 'created_at', 'title']
    ordering = ['-starts_at']
    pagination_class = WorkshopPagination

    def get_queryset(self):
        """Filtre selon le statut et l'utilisateur."""
        queryset = Workshop.objects.select_related(
            'instructor', 'instructor__profile', 'category'
        ).prefetch_related('tags')

        # Les non-connectés voient seulement les publiés
        if not self.request.user.is_authenticated:
            return queryset.filter(status='published')

        # Un instructor voit ses propres brouillons
        if self.action in ['list', 'retrieve']:
            return queryset.filter(
                status='published'
            ) | queryset.filter(
                instructor=self.request.user
            )

        return queryset

    def get_serializer_class(self):
        """Utilise un serializer différent selon l'action."""
        if self.action == 'list':
            return WorkshopListSerializer
        elif self.action == 'retrieve':
            return WorkshopDetailSerializer
        elif self.action in ['create', 'update', 'partial_update']:
            return WorkshopCreateSerializer
        return WorkshopListSerializer

    def perform_create(self, serializer):
        """Assigne automatiquement l'instructeur."""
        serializer.save(instructor=self.request.user)

    # === Actions personnalisées ===

    @action(
        detail=False,        # False = action de liste (pas de slug dans l'URL)
        methods=['get'],
        url_path='featured', # URL : /api/v1/workshops/featured/
        permission_classes=[AllowAny]
    )
    def featured(self, request):
        """Retourne les ateliers mis en avant (les mieux notés)."""
        from django.db.models import Avg
        workshops = Workshop.objects.filter(
            status='published'
        ).annotate(
            avg_rating=Avg('reviews__rating')
        ).order_by('-avg_rating')[:6]

        serializer = WorkshopListSerializer(
            workshops, many=True, context={'request': request}
        )
        return Response(serializer.data)

    @action(
        detail=True,         # True = action de détail (avec slug dans l'URL)
        methods=['post', 'delete'],
        url_path='register',
        permission_classes=[IsAuthenticated]
    )
    def register(self, request, slug=None):
        """S'inscrire (POST) ou se désinscrire (DELETE) d'un atelier."""
        workshop = self.get_object()

        if request.method == 'POST':
            if workshop.is_full:
                return Response(
                    {'error': 'Atelier complet'},
                    status=status.HTTP_400_BAD_REQUEST
                )

            from apps.registrations.models import Registration
            reg, created = Registration.objects.get_or_create(
                user=request.user,
                workshop=workshop,
                defaults={'status': 'confirmed'}
            )

            if not created:
                return Response(
                    {'error': 'Déjà inscrit'},
                    status=status.HTTP_400_BAD_REQUEST
                )

            return Response(
                {'message': 'Inscrit !', 'spots_remaining': workshop.spots_remaining},
                status=status.HTTP_201_CREATED
            )

        elif request.method == 'DELETE':
            from apps.registrations.models import Registration
            deleted, _ = Registration.objects.filter(
                user=request.user, workshop=workshop
            ).delete()

            if deleted:
                return Response(status=status.HTTP_204_NO_CONTENT)
            return Response({'error': 'Non inscrit'}, status=status.HTTP_400_BAD_REQUEST)

    @action(
        detail=True,
        methods=['get', 'post'],
        url_path='reviews',
        permission_classes=[IsAuthenticatedOrReadOnly]
    )
    def reviews(self, request, slug=None):
        """Lister (GET) ou ajouter (POST) un avis."""
        workshop = self.get_object()

        if request.method == 'GET':
            reviews = workshop.reviews.select_related('author').all()
            serializer = ReviewSerializer(reviews, many=True, context={'request': request})
            return Response(serializer.data)

        elif request.method == 'POST':
            from apps.reviews.models import Review
            if Review.objects.filter(author=request.user, workshop=workshop).exists():
                return Response(
                    {'error': 'Tu as déjà laissé un avis'},
                    status=status.HTTP_400_BAD_REQUEST
                )

            serializer = ReviewSerializer(data=request.data, context={'request': request})
            if serializer.is_valid():
                serializer.save(author=request.user, workshop=workshop)
                return Response(serializer.data, status=status.HTTP_201_CREATED)
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
    """
    ViewSet en lecture seule pour les catégories.
    ReadOnlyModelViewSet génère seulement list() et retrieve().
    """
    queryset = Category.objects.all()
    serializer_class = CategorySerializer
    lookup_field = 'slug'
    permission_classes = [AllowAny]
```

### 15.3 Permissions Personnalisées

```python
# api/permissions.py
from rest_framework.permissions import BasePermission, SAFE_METHODS


class IsInstructorOrReadOnly(BasePermission):
    """
    Lecture autorisée pour tous.
    Écriture autorisée seulement pour les utilisateurs connectés.
    Modification/suppression : seulement le créateur ou un admin.
    """

    def has_permission(self, request, view):
        # Lecture = toujours autorisé
        if request.method in SAFE_METHODS:
            return True
        # Écriture = connecté uniquement
        return request.user and request.user.is_authenticated

    def has_object_permission(self, request, view, obj):
        # Lecture = toujours autorisé
        if request.method in SAFE_METHODS:
            return True
        # Modification/suppression = créateur ou admin
        return obj.instructor == request.user or request.user.is_staff


class IsOwnerOrAdmin(BasePermission):
    """L'utilisateur doit être le propriétaire de l'objet ou admin."""

    def has_object_permission(self, request, view, obj):
        if request.user.is_staff:
            return True
        # Fonctionne avec des objets ayant un attribut 'user' ou 'author'
        owner = getattr(obj, 'user', None) or getattr(obj, 'author', None)
        return owner == request.user
```

---

## Chapitre 16 — Filtrage, Pagination et Recherche

### 16.1 Filtre Avancé avec django-filter

```python
# api/filters.py
import django_filters
from apps.workshops.models import Workshop


class WorkshopFilter(django_filters.FilterSet):
    """Filtre avancé pour les ateliers."""

    # Filtre texte insensible à la casse
    title = django_filters.CharFilter(lookup_expr='icontains')

    # Filtre sur une fourchette de prix
    price_min = django_filters.NumberFilter(field_name='price', lookup_expr='gte')
    price_max = django_filters.NumberFilter(field_name='price', lookup_expr='lte')

    # Filtre sur la date
    starts_after = django_filters.DateTimeFilter(field_name='starts_at', lookup_expr='gte')
    starts_before = django_filters.DateTimeFilter(field_name='starts_at', lookup_expr='lte')

    # Filtre sur une relation (catégorie par slug)
    category = django_filters.CharFilter(field_name='category__slug')

    # Filtre sur les tags
    tags = django_filters.CharFilter(field_name='tags__slug')

    # Filtre booléen simple
    is_free = django_filters.BooleanFilter()

    class Meta:
        model = Workshop
        fields = ['format', 'difficulty', 'status']
```

Utilisation de l'API avec filtres :

```
GET /api/v1/workshops/?category=programmation
GET /api/v1/workshops/?price_min=0&price_max=50
GET /api/v1/workshops/?starts_after=2024-03-01&difficulty=beginner
GET /api/v1/workshops/?search=python&ordering=-starts_at
GET /api/v1/workshops/?is_free=true&format=online
```

### 16.2 Pagination Personnalisée

```python
# api/pagination.py
from rest_framework.pagination import PageNumberPagination, CursorPagination
from rest_framework.response import Response


class WorkshopPagination(PageNumberPagination):
    """
    Pagination standard par numéro de page.
    """
    page_size = 12
    page_size_query_param = 'page_size'  # Permet de changer la taille via ?page_size=6
    max_page_size = 100

    def get_paginated_response(self, data):
        """Personnalise la structure de la réponse paginée."""
        return Response({
            'pagination': {
                'count': self.page.paginator.count,
                'total_pages': self.page.paginator.num_pages,
                'current_page': self.page.number,
                'page_size': self.get_page_size(self.request),
                'next': self.get_next_link(),
                'previous': self.get_previous_link(),
            },
            'results': data
        })


class InfiniteScrollPagination(CursorPagination):
    """
    Pagination par curseur — parfaite pour le scroll infini.
    Plus performante sur de grandes tables.
    """
    page_size = 20
    ordering = '-created_at'
    cursor_query_param = 'cursor'
```

### 16.3 URLs de l'API avec Router

```python
# api/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView
)
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView

from . import viewsets, views

# Router génère automatiquement toutes les URLs CRUD
router = DefaultRouter()
router.register('workshops', viewsets.WorkshopViewSet, basename='workshop')
router.register('categories', viewsets.CategoryViewSet, basename='category')
router.register('tags', viewsets.TagViewSet, basename='tag')
router.register('users', viewsets.UserViewSet, basename='user')

app_name = 'api'

urlpatterns = [
    # Routes générées par le router
    path('', include(router.urls)),

    # Authentification JWT
    path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain'),
    path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('auth/token/verify/', TokenVerifyView.as_view(), name='token_verify'),
    path('auth/register/', views.RegisterAPIView.as_view(), name='register'),
    path('auth/me/', views.CurrentUserAPIView.as_view(), name='me'),

    # Documentation OpenAPI
    path('schema/', SpectacularAPIView.as_view(), name='schema'),
    path('docs/', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger'),
]
```

### 16.4 Vues d'Authentification

```python
# api/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework_simplejwt.tokens import RefreshToken

from .serializers import UserRegisterSerializer, UserProfileSerializer


class RegisterAPIView(APIView):
    """
    POST /api/v1/auth/register/
    Crée un nouvel utilisateur et retourne les tokens JWT.
    """
    permission_classes = [AllowAny]

    def post(self, request):
        serializer = UserRegisterSerializer(data=request.data)
        if serializer.is_valid():
            user = serializer.save()

            # Génère les tokens JWT
            refresh = RefreshToken.for_user(user)

            return Response({
                'message': 'Compte créé avec succès !',
                'user': {
                    'id': user.id,
                    'username': user.username,
                    'email': user.email,
                    'full_name': user.get_full_name(),
                },
                'tokens': {
                    'access': str(refresh.access_token),
                    'refresh': str(refresh),
                }
            }, status=status.HTTP_201_CREATED)

        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class CurrentUserAPIView(APIView):
    """
    GET /api/v1/auth/me/
    Retourne les informations de l'utilisateur connecté.
    """
    permission_classes = [IsAuthenticated]

    def get(self, request):
        from .serializers import UserProfileSerializer
        user = request.user
        return Response({
            'id': user.id,
            'username': user.username,
            'email': user.email,
            'full_name': user.get_full_name(),
            'is_instructor': user.profile.is_instructor,
            'avatar_url': (
                request.build_absolute_uri(user.profile.avatar.url)
                if user.profile.avatar else None
            ),
        })

    def patch(self, request):
        """Mise à jour partielle du profil."""
        serializer = UserProfileSerializer(
            request.user.profile,
            data=request.data,
            partial=True,
            context={'request': request}
        )
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
```

### 16.5 Documentation Swagger Automatique

```python
# settings/base.py
SPECTACULAR_SETTINGS = {
    'TITLE': 'SkillHub API',
    'DESCRIPTION': '''
    API REST de la plateforme SkillHub — Partagez vos compétences.

    ## Authentification
    Cette API utilise JWT (JSON Web Tokens).

    1. Obtiens un token via `POST /api/v1/auth/token/`
    2. Inclus le token dans le header : `Authorization: Bearer <token>`
    3. Rafraîchis le token via `POST /api/v1/auth/token/refresh/`

    ## Ressources principales
    - **Workshops** : Gestion des ateliers
    - **Categories** : Catégories d'ateliers
    - **Users** : Profils utilisateurs
    ''',
    'VERSION': '1.0.0',
    'SERVE_INCLUDE_SCHEMA': False,
    'COMPONENT_SPLIT_REQUEST': True,
    'TAGS': [
        {'name': 'workshops', 'description': 'Gestion des ateliers'},
        {'name': 'auth', 'description': 'Authentification JWT'},
        {'name': 'users', 'description': 'Profils utilisateurs'},
    ],
}
```

La documentation sera disponible à `http://localhost:8000/api/v1/docs/` [BRAVO]

---

## [ANALYSE] Utilisation de l'API — Exemples Concrets

### Avec curl

```bash
# 1. Obtenir un token JWT
curl -X POST http://localhost:8000/api/v1/auth/token/ \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@example.com", "password": "monmotdepasse"}'

# Réponse :
# {"access": "eyJ...", "refresh": "eyJ..."}

# 2. Lister les ateliers
curl http://localhost:8000/api/v1/workshops/?category=programmation&is_free=true

# 3. Créer un atelier (avec token)
curl -X POST http://localhost:8000/api/v1/workshops/ \
  -H "Authorization: Bearer eyJ..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Python pour les nuls",
    "short_description": "Apprenez Python en 2h",
    "description": "Description complète...",
    "starts_at": "2024-06-15T14:00:00Z",
    "duration_minutes": 120,
    "format": "online",
    "max_participants": 15,
    "difficulty": "beginner",
    "price": 0,
    "is_free": true,
    "status": "published"
  }'

# 4. S'inscrire à un atelier
curl -X POST http://localhost:8000/api/v1/workshops/python-pour-les-nuls/register/ \
  -H "Authorization: Bearer eyJ..."
```

### Avec JavaScript (fetch)

```javascript
// Fichier api.js - Client API pour SkillHub

const API_BASE = 'http://localhost:8000/api/v1';

class SkillHubAPI {
  constructor() {
    this.accessToken = localStorage.getItem('access_token');
  }

  getHeaders(withAuth = true) {
    const headers = {
      'Content-Type': 'application/json',
    };
    if (withAuth && this.accessToken) {
      headers['Authorization'] = `Bearer ${this.accessToken}`;
    }
    return headers;
  }

  async login(email, password) {
    const response = await fetch(`${API_BASE}/auth/token/`, {
      method: 'POST',
      headers: this.getHeaders(false),
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) {
      throw new Error('Identifiants incorrects');
    }

    const data = await response.json();
    this.accessToken = data.access;
    localStorage.setItem('access_token', data.access);
    localStorage.setItem('refresh_token', data.refresh);
    return data;
  }

  async getWorkshops(params = {}) {
    const queryString = new URLSearchParams(params).toString();
    const response = await fetch(`${API_BASE}/workshops/?${queryString}`, {
      headers: this.getHeaders(),
    });
    return response.json();
  }

  async registerToWorkshop(slug) {
    const response = await fetch(`${API_BASE}/workshops/${slug}/register/`, {
      method: 'POST',
      headers: this.getHeaders(),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.error || 'Erreur lors de l\'inscription');
    }

    return response.json();
  }
}

// Utilisation
const api = new SkillHubAPI();

// Login
await api.login('alice@example.com', 'password');

// Récupérer les ateliers Python gratuits
const workshops = await api.getWorkshops({
  category: 'programmation',
  is_free: true,
  search: 'python',
});
console.log(workshops.results);

// S'inscrire
await api.registerToWorkshop('python-pour-les-nuls');
```

---

## [OK] Exercices de Complétion — Partie V

### Exercice 5.1 — API Complète des Inscriptions **

**Instructions :**
1. Crée un `RegistrationViewSet` avec :
   - `list` : liste des inscriptions de l'utilisateur connecté
   - `retrieve` : détail d'une inscription
   - Action `cancel` : annuler une inscription
   - Action `attend` : marquer comme "a participé" (admin seulement)
2. Un utilisateur ne peut voir que ses propres inscriptions
3. Ajoute la pagination et les filtres (par statut, par date)

---

### Exercice 5.2 — Serializer Imbriqué ***

**Contexte :** Quand on récupère le profil d'un utilisateur via l'API, on veut voir ses ateliers et ses stats.

**Instructions :**
1. Crée un `UserDetailSerializer` qui imbrique :
   - Les informations de profil
   - Les 5 derniers ateliers proposés
   - Les statistiques : nb ateliers, note moyenne, nb participants total
2. Les ateliers imbriqués utilisent `WorkshopListSerializer`
3. Les statistiques sont calculées dans des `SerializerMethodField`

---

### Exercice 5.3 — Rate Limiting Personnalisé **

**Contexte :** L'inscription à un atelier ne doit pas être spammée.

**Instructions :**
1. Crée un throttle `WorkshopRegistrationThrottle` qui limite à 10 inscriptions par heure par utilisateur
2. Crée un throttle `WorkshopCreationThrottle` qui limite à 5 créations d'atelier par jour
3. Applique-les aux bonnes actions dans le ViewSet

---

### Exercice 5.4 — API de Statistiques ***

**Instructions :**
1. Crée une vue `/api/v1/stats/` qui retourne des statistiques globales :
   - Nombre total d'ateliers, utilisateurs, inscriptions
   - Répartition des ateliers par catégorie
   - Les 10 formateurs les mieux notés
   - Nombre d'ateliers par format (en ligne/présentiel)
2. Cette vue est en cache pendant 5 minutes (utilise `django.core.cache`)
3. Elle ne nécessite pas d'authentification

---

### Exercice 5.5 — Versioning de l'API ****

**Contexte :** Tu veux publier une v2 de l'API sans casser la v1.

**Instructions :**
1. Configure le versioning dans DRF (URL versioning : `/api/v1/` et `/api/v2/`)
2. Crée une v2 du serializer Workshop qui ajoute un champ `requirements` et `learning_objectives`
3. Ajoute ces champs au modèle Workshop avec une migration
4. La v1 reste compatible avec l'ancienne structure

---

## [IMPORTANT] Récapitulatif Partie V

| Concept | Ce que c'est | Exemple |
|---|---|---|
| Serializer | Convertit objet <-> JSON | `class WorkshopSerializer(ModelSerializer):` |
| ModelSerializer | Serializer lié à un modèle | `class Meta: model = Workshop` |
| SerializerMethodField | Champ calculé en lecture seule | `def get_xxx(self, obj):` |
| ViewSet | Classe CRUD complète | `class WorkshopViewSet(ModelViewSet):` |
| Router | Génère les URLs automatiquement | `router.register('workshops', WorkshopViewSet)` |
| @action | Action personnalisée dans un ViewSet | `@action(detail=True, methods=['post'])` |
| JWT | Authentification sans état | `Authorization: Bearer <token>` |
| DjangoFilterBackend | Filtres par champs | `?category=programmation` |
| SearchFilter | Recherche textuelle | `?search=python` |
| OrderingFilter | Tri des résultats | `?ordering=-starts_at` |
| Pagination | Découpe les résultats | `?page=2&page_size=12` |
| Throttling | Limite les requêtes | `'user': '1000/hour'` |

---

> -> **Suite : Fichier 6 — Tests & Déploiement**
> Tu vas apprendre à écrire des tests unitaires et d'intégration pour SkillHub, puis à déployer l'application avec Docker, Gunicorn, Nginx et un pipeline CI/CD automatisé.

# [COURS] Formation Django — Maîtriser Django de Zéro à Expert
## Fichier 6/7 — Tests & Déploiement (Parties VI & VII)

---

> **Navigation des fichiers :**
> - [FICHIER] Fichier 1 — Introduction & Fondations
> - [FICHIER] Fichier 2 — Modèles & Base de données
> - [FICHIER] Fichier 3 — Vues & Templates
> - [FICHIER] Fichier 4 — URLs, Middleware & Authentification
> - [FICHIER] Fichier 5 — Django REST Framework
> - [FICHIER] **Fichier 6** — Tests & Déploiement *(vous êtes ici)*
> - [FICHIER] Fichier 7 — Expert : Optimisation, Projets & Architecture

---

# [PACKAGE] PARTIE VI — Tests et Qualité

Écrire des tests n'est pas une option dans un projet professionnel — c'est une nécessité. Les tests te permettent de :
- Détecter les régressions (quand une modification casse quelque chose d'autre)
- Refactoriser en confiance
- Documenter le comportement attendu du code
- Collaborer sereinement en équipe

---

## Chapitre 17 — Tests Unitaires

### 17.1 Configuration des Tests

```bash
# Installe les dépendances de test
pip install pytest
pip install pytest-django
pip install pytest-cov       # Coverage
pip install factory_boy      # Génération de données de test
pip install faker             # Données fakes réalistes
```

```ini
# pytest.ini (à la racine du projet)
[pytest]
DJANGO_SETTINGS_MODULE = skillhub.settings.test
python_files = tests.py test_*.py *_tests.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short --strict-markers
markers =
    unit: Tests unitaires
    integration: Tests d'intégration
    api: Tests d'API
    slow: Tests lents
```

```python
# skillhub/settings/test.py
from .base import *

DEBUG = False
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': ':memory:',  # Base de données en mémoire, plus rapide pour les tests
    }
}

# Désactive les emails en test
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'

# Stockage des médias en mémoire
DEFAULT_FILE_STORAGE = 'django.core.files.storage.InMemoryStorage'

# Mot de passe hashé plus vite en test
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
```

### 17.2 Factories avec factory_boy

Les factories génèrent des données de test réalistes et cohérentes :

```python
# tests/factories.py
import factory
from factory.django import DjangoModelFactory
from factory import Faker, LazyAttribute, SubFactory, RelatedFactory
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta

from apps.accounts.models import UserProfile
from apps.workshops.models import Workshop, Category, Tag
from apps.registrations.models import Registration
from apps.reviews.models import Review

User = get_user_model()


class UserFactory(DjangoModelFactory):
    class Meta:
        model = User
        skip_postgeneration_save = True

    username = Faker('user_name', locale='fr_FR')
    email = LazyAttribute(lambda obj: f"{obj.username}@example.com")
    first_name = Faker('first_name', locale='fr_FR')
    last_name = Faker('last_name', locale='fr_FR')
    password = factory.PostGenerationMethodCall('set_password', 'testpassword123')
    is_active = True

    @factory.post_generation
    def profile(self, create, extracted, **kwargs):
        if not create:
            return
        # Le profil est créé par le signal, on peut le personnaliser ici
        if extracted:
            self.profile.bio = extracted.get('bio', '')
            self.profile.save()


class CategoryFactory(DjangoModelFactory):
    class Meta:
        model = Category

    name = Faker('word', locale='fr_FR')
    slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-'))
    description = Faker('sentence', locale='fr_FR')
    icon = '[CODE]'


class TagFactory(DjangoModelFactory):
    class Meta:
        model = Tag
        django_get_or_create = ('name',)  # Évite les doublons sur le champ unique

    name = Faker('word', locale='fr_FR')
    slug = factory.LazyAttribute(lambda obj: obj.name.lower())


class WorkshopFactory(DjangoModelFactory):
    class Meta:
        model = Workshop

    title = Faker('sentence', nb_words=5, locale='fr_FR')
    slug = factory.LazyAttribute(
        lambda obj: obj.title.lower().replace(' ', '-')[:50] + str(factory.Faker('random_int', min=1, max=9999))
    )
    short_description = Faker('sentence', locale='fr_FR')
    description = Faker('paragraph', nb_sentences=5, locale='fr_FR')
    instructor = SubFactory(UserFactory)
    category = SubFactory(CategoryFactory)
    starts_at = LazyAttribute(lambda _: timezone.now() + timedelta(days=7))
    duration_minutes = factory.Iterator([60, 90, 120, 180, 240])
    format = factory.Iterator(['online', 'onsite', 'hybrid'])
    max_participants = factory.Iterator([10, 15, 20, 30])
    difficulty = factory.Iterator(['beginner', 'intermediate', 'advanced'])
    price = factory.LazyAttribute(lambda obj: 0 if obj.is_free else 29.99)
    is_free = factory.Faker('boolean', chance_of_getting_true=60)
    status = 'published'

    @factory.post_generation
    def tags(self, create, extracted, **kwargs):
        if not create:
            return
        if extracted:
            for tag in extracted:
                self.tags.add(tag)
        else:
            # Ajoute 2-3 tags aléatoires
            num_tags = factory.Faker('random_int', min=1, max=3).generate()
            for _ in range(num_tags):
                self.tags.add(TagFactory())


class RegistrationFactory(DjangoModelFactory):
    class Meta:
        model = Registration

    user = SubFactory(UserFactory)
    workshop = SubFactory(WorkshopFactory)
    status = 'confirmed'


class ReviewFactory(DjangoModelFactory):
    class Meta:
        model = Review

    workshop = SubFactory(WorkshopFactory)
    author = SubFactory(UserFactory)
    rating = factory.Faker('random_int', min=1, max=5)
    comment = Faker('paragraph', locale='fr_FR')
    is_verified = True
```

### 17.3 Tests Unitaires des Modèles

```python
# workshops/tests/test_models.py
import pytest
from django.utils import timezone
from datetime import timedelta

from tests.factories import WorkshopFactory, UserFactory, RegistrationFactory, ReviewFactory


@pytest.mark.django_db  # Permet l'accès à la base de données
class TestWorkshopModel:
    """Tests unitaires pour le modèle Workshop."""

    def test_workshop_creation(self):
        """Un workshop peut être créé avec les champs requis."""
        workshop = WorkshopFactory()
        assert workshop.id is not None
        assert workshop.title
        assert workshop.slug

    def test_str_representation(self):
        """La méthode __str__ retourne le bon format."""
        workshop = WorkshopFactory()
        expected = f"{workshop.title} — {workshop.instructor.get_full_name()}"
        assert str(workshop) == expected

    def test_slug_auto_generated(self):
        """Le slug est généré automatiquement depuis le titre."""
        workshop = WorkshopFactory(title="Introduction au Python")
        assert 'python' in workshop.slug.lower() or 'introduction' in workshop.slug.lower()

    def test_duration_display_hours_and_minutes(self):
        """duration_display formate correctement 90 minutes."""
        workshop = WorkshopFactory(duration_minutes=90)
        assert workshop.duration_display == "1h30"

    def test_duration_display_hours_only(self):
        """duration_display formate correctement 120 minutes."""
        workshop = WorkshopFactory(duration_minutes=120)
        assert workshop.duration_display == "2h"

    def test_duration_display_minutes_only(self):
        """duration_display formate correctement 45 minutes."""
        workshop = WorkshopFactory(duration_minutes=45)
        assert workshop.duration_display == "45min"

    def test_spots_remaining_all_available(self):
        """Toutes les places sont disponibles sans inscription."""
        workshop = WorkshopFactory(max_participants=20)
        assert workshop.spots_remaining == 20

    def test_spots_remaining_with_registrations(self):
        """Les places diminuent avec les inscriptions confirmées."""
        workshop = WorkshopFactory(max_participants=20)
        RegistrationFactory.create_batch(5, workshop=workshop, status='confirmed')
        assert workshop.spots_remaining == 15

    def test_is_full_when_max_reached(self):
        """is_full est True quand toutes les places sont prises."""
        workshop = WorkshopFactory(max_participants=2)
        RegistrationFactory(workshop=workshop, status='confirmed')
        RegistrationFactory(workshop=workshop, status='confirmed')
        assert workshop.is_full is True

    def test_is_full_cancelled_not_counted(self):
        """Les inscriptions annulées ne comptent pas dans les places prises."""
        workshop = WorkshopFactory(max_participants=2)
        RegistrationFactory(workshop=workshop, status='confirmed')
        RegistrationFactory(workshop=workshop, status='cancelled')
        assert workshop.is_full is False

    def test_average_rating_with_reviews(self):
        """La note moyenne est correctement calculée."""
        workshop = WorkshopFactory()
        ReviewFactory(workshop=workshop, rating=4)
        ReviewFactory(workshop=workshop, rating=5)
        ReviewFactory(workshop=workshop, rating=3)
        assert workshop.average_rating == pytest.approx(4.0)

    def test_average_rating_no_reviews(self):
        """La note moyenne est None s'il n'y a pas d'avis."""
        workshop = WorkshopFactory()
        assert workshop.average_rating is None

    def test_is_free_set_when_price_zero(self):
        """is_free est mis à True automatiquement si price = 0."""
        workshop = WorkshopFactory(price=0, is_free=False)
        workshop.save()
        assert workshop.is_free is True

    def test_get_absolute_url(self):
        """L'URL canonique est correctement générée."""
        workshop = WorkshopFactory(slug='mon-atelier-test')
        assert '/workshops/mon-atelier-test/' in workshop.get_absolute_url()
```

### 17.4 Tests des Vues

```python
# workshops/tests/test_views.py
import pytest
from django.urls import reverse
from django.test import Client

from tests.factories import WorkshopFactory, UserFactory, RegistrationFactory


@pytest.mark.django_db
class TestWorkshopListView:
    """Tests pour la vue de liste des ateliers."""

    def test_list_view_returns_200(self, client):
        """La page de liste s'affiche correctement."""
        response = client.get(reverse('workshops:list'))
        assert response.status_code == 200

    def test_list_view_uses_correct_template(self, client):
        """La vue utilise le bon template."""
        response = client.get(reverse('workshops:list'))
        assert 'workshops/list.html' in [t.name for t in response.templates]

    def test_only_published_workshops_shown(self, client):
        """Seuls les ateliers publiés sont affichés."""
        WorkshopFactory(status='published', title='Atelier public')
        WorkshopFactory(status='draft', title='Atelier privé')
        WorkshopFactory(status='cancelled', title='Atelier annulé')

        response = client.get(reverse('workshops:list'))
        workshops = response.context['workshops']

        titles = [w.title for w in workshops]
        assert 'Atelier public' in titles
        assert 'Atelier privé' not in titles
        assert 'Atelier annulé' not in titles

    def test_filter_by_category(self, client):
        """Le filtre par catégorie fonctionne."""
        from tests.factories import CategoryFactory
        cat_python = CategoryFactory(name='Python', slug='python')
        cat_design = CategoryFactory(name='Design', slug='design')

        WorkshopFactory(category=cat_python, status='published')
        WorkshopFactory(category=cat_design, status='published')

        response = client.get(reverse('workshops:list') + '?category=python')
        workshops = list(response.context['workshops'])
        assert all(w.category.slug == 'python' for w in workshops)

    def test_search_by_title(self, client):
        """La recherche par titre fonctionne."""
        WorkshopFactory(title='Introduction au Python', status='published')
        WorkshopFactory(title='Design Thinking', status='published')

        response = client.get(reverse('workshops:list') + '?q=python')
        workshops = list(response.context['workshops'])
        assert len(workshops) == 1
        assert 'Python' in workshops[0].title

    def test_pagination(self, client):
        """La pagination fonctionne correctement."""
        WorkshopFactory.create_batch(15, status='published')

        response = client.get(reverse('workshops:list'))
        assert response.context['page_obj'].has_next()

        response_page2 = client.get(reverse('workshops:list') + '?page=2')
        assert response_page2.status_code == 200


@pytest.mark.django_db
class TestWorkshopCreateView:
    """Tests pour la création d'un atelier."""

    def test_create_requires_login(self, client):
        """La création nécessite d'être connecté."""
        response = client.get(reverse('workshops:create'))
        assert response.status_code == 302
        assert '/login/' in response.url

    def test_create_form_displayed(self, client):
        """Le formulaire de création s'affiche pour un user connecté."""
        user = UserFactory()
        client.force_login(user)

        response = client.get(reverse('workshops:create'))
        assert response.status_code == 200
        assert 'form' in response.context

    def test_create_workshop_valid_data(self, client):
        """Un atelier est créé avec des données valides."""
        from tests.factories import CategoryFactory
        user = UserFactory()
        client.force_login(user)

        from django.utils import timezone
        from datetime import timedelta
        future_date = timezone.now() + timedelta(days=10)

        data = {
            'title': 'Mon super atelier',
            'short_description': 'Un atelier génial',
            'description': 'Description complète de l\'atelier...',
            'starts_at': future_date.strftime('%Y-%m-%dT%H:%M'),
            'duration_minutes': 90,
            'format': 'online',
            'max_participants': 20,
            'difficulty': 'beginner',
            'price': 0,
            'is_free': True,
            'status': 'published',
        }

        response = client.post(reverse('workshops:create'), data)

        from apps.workshops.models import Workshop
        workshop = Workshop.objects.filter(title='Mon super atelier').first()
        assert workshop is not None
        assert workshop.instructor == user
        assert response.status_code == 302  # Redirection après succès

    def test_create_assigns_instructor(self, client):
        """L'instructeur est automatiquement assigné à l'utilisateur connecté."""
        user = UserFactory()
        client.force_login(user)

        from django.utils import timezone
        future_date = timezone.now() + timedelta(days=10)

        data = {
            'title': 'Test instructeur',
            'short_description': 'Test',
            'description': 'Description...',
            'starts_at': future_date.strftime('%Y-%m-%dT%H:%M'),
            'duration_minutes': 60,
            'format': 'online',
            'max_participants': 10,
            'difficulty': 'all',
            'price': 0,
            'is_free': True,
            'status': 'draft',
        }

        client.post(reverse('workshops:create'), data)

        from apps.workshops.models import Workshop
        workshop = Workshop.objects.filter(title='Test instructeur').first()
        if workshop:
            assert workshop.instructor == user
```

### 17.5 Tests de l'API (DRF)

```python
# api/tests/test_workshop_api.py
import pytest
from django.urls import reverse
from rest_framework.test import APIClient
from rest_framework import status

from tests.factories import WorkshopFactory, UserFactory, RegistrationFactory


@pytest.fixture
def api_client():
    return APIClient()


@pytest.fixture
def authenticated_client():
    """Client API avec un utilisateur authentifié."""
    client = APIClient()
    user = UserFactory()
    client.force_authenticate(user=user)
    return client, user


@pytest.mark.django_db
class TestWorkshopAPI:

    def test_list_workshops_public(self, api_client):
        """La liste des ateliers est accessible sans authentification."""
        WorkshopFactory.create_batch(5, status='published')
        response = api_client.get('/api/v1/workshops/')
        assert response.status_code == status.HTTP_200_OK
        assert 'results' in response.data

    def test_list_only_published_for_anon(self, api_client):
        """Les non-connectés ne voient que les ateliers publiés."""
        WorkshopFactory(status='published')
        WorkshopFactory(status='draft')
        response = api_client.get('/api/v1/workshops/')
        assert response.data['pagination']['count'] == 1

    def test_create_workshop_requires_auth(self, api_client):
        """La création d'un atelier nécessite une authentification."""
        response = api_client.post('/api/v1/workshops/', {})
        assert response.status_code == status.HTTP_401_UNAUTHORIZED

    def test_create_workshop_success(self, authenticated_client):
        """Un atelier est créé avec succès."""
        client, user = authenticated_client

        from django.utils import timezone
        future_date = (timezone.now() + timezone.timedelta(days=10)).isoformat()

        data = {
            'title': 'API Test Workshop',
            'short_description': 'Test via API',
            'description': 'Description complète',
            'starts_at': future_date,
            'duration_minutes': 60,
            'format': 'online',
            'max_participants': 10,
            'difficulty': 'beginner',
            'price': 0,
            'is_free': True,
            'status': 'published',
        }

        response = client.post('/api/v1/workshops/', data, format='json')
        assert response.status_code == status.HTTP_201_CREATED
        assert response.data['title'] == 'API Test Workshop'

    def test_register_to_workshop(self, authenticated_client):
        """Un utilisateur peut s'inscrire à un atelier."""
        client, user = authenticated_client
        workshop = WorkshopFactory(status='published', max_participants=10)

        response = client.post(f'/api/v1/workshops/{workshop.slug}/register/')
        assert response.status_code == status.HTTP_201_CREATED

    def test_cannot_register_twice(self, authenticated_client):
        """Un utilisateur ne peut pas s'inscrire deux fois."""
        client, user = authenticated_client
        workshop = WorkshopFactory(status='published')
        RegistrationFactory(user=user, workshop=workshop, status='confirmed')

        response = client.post(f'/api/v1/workshops/{workshop.slug}/register/')
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_cannot_register_to_full_workshop(self, authenticated_client):
        """Impossible de s'inscrire à un atelier complet."""
        client, user = authenticated_client
        workshop = WorkshopFactory(status='published', max_participants=1)
        other_user = UserFactory()
        RegistrationFactory(user=other_user, workshop=workshop, status='confirmed')

        response = client.post(f'/api/v1/workshops/{workshop.slug}/register/')
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_cannot_delete_other_users_workshop(self, authenticated_client):
        """Un utilisateur ne peut pas supprimer l'atelier d'un autre."""
        client, user = authenticated_client
        other_workshop = WorkshopFactory()  # Appartient à un autre user

        response = client.delete(f'/api/v1/workshops/{other_workshop.slug}/')
        assert response.status_code == status.HTTP_403_FORBIDDEN

    def test_filter_by_category(self, api_client):
        """Le filtre par catégorie fonctionne."""
        from tests.factories import CategoryFactory
        cat = CategoryFactory(slug='programmation')
        WorkshopFactory(category=cat, status='published')
        WorkshopFactory(status='published')  # Sans catégorie

        response = api_client.get('/api/v1/workshops/?category=programmation')
        assert response.data['pagination']['count'] == 1

    def test_jwt_authentication(self, api_client):
        """L'authentification JWT fonctionne correctement."""
        user = UserFactory()

        # Obtenir les tokens
        response = api_client.post('/api/v1/auth/token/', {
            'email': user.email,
            'password': 'testpassword123',
        })
        assert response.status_code == status.HTTP_200_OK
        assert 'access' in response.data

        # Utiliser le token
        access_token = response.data['access']
        api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}')

        response = api_client.get('/api/v1/auth/me/')
        assert response.status_code == status.HTTP_200_OK
        assert response.data['email'] == user.email
```

### 17.6 Couverture de Code

```bash
# Lancer les tests avec coverage
pytest --cov=apps --cov-report=html --cov-report=term-missing

# Le rapport HTML est dans htmlcov/index.html
# Objectif : > 80% de couverture
```

---

## Chapitre 18 — Linting et Qualité de Code

### 18.1 Outils de Qualité

```bash
pip install black          # Formatter automatique
pip install flake8         # Linter (détecte les erreurs)
pip install isort          # Trie les imports
pip install mypy           # Vérification des types
pip install pre-commit     # Hooks Git automatiques
```

```ini
# setup.cfg
[flake8]
max-line-length = 120
exclude = migrations, venv, .git
per-file-ignores =
    __init__.py:F401  # Import non utilisé (souvent intentionnel dans __init__)

[isort]
profile = black
multi_line_output = 3
include_trailing_comma = True
```

```toml
# pyproject.toml
[tool.black]
line-length = 120
target-version = ['py311']
exclude = '''
/(
    \.git
  | \.venv
  | migrations
)/
'''

[tool.mypy]
python_version = "3.11"
django_settings_module = "skillhub.settings.dev"
plugins = ["mypy_django_plugin.main"]

[mypy-mypy_django_plugin.main]
django_settings_module = "skillhub.settings.dev"
```

```yaml
# .pre-commit-config.yaml — Vérifie automatiquement avant chaque commit
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace    # Supprime les espaces en fin de ligne
      - id: end-of-file-fixer      # Ajoute un saut de ligne en fin de fichier
      - id: check-yaml             # Vérifie les fichiers YAML
      - id: check-merge-conflict   # Vérifie les marqueurs de conflit Git
      - id: no-commit-to-branch    # Empêche les commits directs sur main
        args: ['--branch', 'main']

  - repo: https://github.com/psf/black
    rev: 23.12.1
    hooks:
      - id: black

  - repo: https://github.com/pycqa/isort
    rev: 5.13.2
    hooks:
      - id: isort

  - repo: https://github.com/pycqa/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
```

```bash
# Installation des hooks
pre-commit install

# Test manuel
pre-commit run --all-files
```

---

# [CLOUD] PARTIE VII — Déploiement et Production

---

## Chapitre 19 — Configuration Production

### 19.1 Variables d'Environnement

Ne mets JAMAIS de secrets dans ton code. Utilise les variables d'environnement :

```bash
pip install python-decouple  # Gestion des variables d'environnement
```

```python
# settings/prod.py
from decouple import config, Csv
from .base import *

# Sécurité
DEBUG = False
SECRET_KEY = config('SECRET_KEY')
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv())

# Base de données
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': config('DB_NAME'),
        'USER': config('DB_USER'),
        'PASSWORD': config('DB_PASSWORD'),
        'HOST': config('DB_HOST', default='localhost'),
        'PORT': config('DB_PORT', default='5432'),
        'CONN_MAX_AGE': 60,  # Connexions persistantes
    }
}

# Sécurité HTTPS
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'

# Stockage des fichiers (AWS S3 ou similaire)
DEFAULT_FILE_STORAGE = config('DEFAULT_FILE_STORAGE', default='django.core.files.storage.FileSystemStorage')

# Email
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = config('EMAIL_HOST')
EMAIL_PORT = config('EMAIL_PORT', cast=int, default=587)
EMAIL_HOST_USER = config('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD')
EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@skillhub.com')

# Cache (Redis en production)
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': config('REDIS_URL', default='redis://localhost:6379/0'),
    }
}

# Logging
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'formatter': 'verbose',
        },
        'file': {
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': '/var/log/skillhub/django.log',
            'maxBytes': 1024 * 1024 * 10,  # 10 MB
            'backupCount': 5,
            'formatter': 'verbose',
        },
    },
    'root': {
        'handlers': ['console', 'file'],
        'level': 'WARNING',
    },
    'loggers': {
        'django': {
            'handlers': ['console', 'file'],
            'level': 'WARNING',
            'propagate': False,
        },
        'apps': {
            'handlers': ['console', 'file'],
            'level': 'INFO',
            'propagate': False,
        },
    },
}
```

Fichier `.env` (à ne jamais commit dans Git !) :

```env
# .env — Variables d'environnement locales
SECRET_KEY=ma-super-cle-secrete-longue-et-aleatoire
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1

DB_NAME=skillhub_db
DB_USER=skillhub_user
DB_PASSWORD=motdepasse_securise
DB_HOST=localhost
DB_PORT=5432

REDIS_URL=redis://localhost:6379/0

EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_HOST_USER=noreply@skillhub.com
EMAIL_HOST_PASSWORD=mon_app_password_gmail
```

```gitignore
# .gitignore
.env
*.pyc
__pycache__/
db.sqlite3
media/
staticfiles/
.venv/
venv/
htmlcov/
.pytest_cache/
```

---

## Chapitre 20 — Base de Données Production

### 20.1 Passer à PostgreSQL

```bash
# Installation locale de PostgreSQL
sudo apt-get install postgresql postgresql-contrib

# Créer la base et l'utilisateur
sudo -u postgres psql
CREATE DATABASE skillhub_db;
CREATE USER skillhub_user WITH PASSWORD 'motdepasse_securise';
GRANT ALL PRIVILEGES ON DATABASE skillhub_db TO skillhub_user;
\q

# Dépendance Python
pip install psycopg2-binary
```

---

## Chapitre 21 — Dockerisation

### 21.1 Dockerfile

```dockerfile
# Dockerfile
FROM python:3.12-slim

# Variables d'environnement
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    DJANGO_SETTINGS_MODULE=skillhub.settings.prod

# Dossier de travail
WORKDIR /app

# Installe les dépendances système
RUN apt-get update && apt-get install -y \
    postgresql-client \
    libpq-dev \
    gcc \
    && rm -rf /var/lib/apt/lists/*

# Installe les dépendances Python
COPY requirements/prod.txt .
RUN pip install --no-cache-dir -r prod.txt

# Copie le code source
COPY . .

# Collecte les fichiers statiques
RUN python manage.py collectstatic --noinput

# Port exposé
EXPOSE 8000

# Script de démarrage
COPY scripts/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "skillhub.wsgi:application", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "4", \
     "--timeout", "120", \
     "--access-logfile", "-", \
     "--error-logfile", "-"]
```

### 21.2 Script d'Entrypoint

```bash
#!/bin/bash
# scripts/entrypoint.sh

set -e

echo "[HOURGLASS_WITH_FLOWING_SAND] En attente de la base de données..."

# Attend que PostgreSQL soit prêt
while ! pg_isready -h $DB_HOST -p $DB_PORT -U $DB_USER; do
    sleep 1
done

echo "[OK] Base de données prête!"

echo "[SYNC] Application des migrations..."
python manage.py migrate --noinput

echo "[DOSSIER] Collecte des fichiers statiques..."
python manage.py collectstatic --noinput

echo "[RAPIDE] Démarrage du serveur..."
exec "$@"
```

### 21.3 Docker Compose

```yaml
# docker-compose.yml
version: '3.9'

services:
  # Base de données PostgreSQL
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data/
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 5s
      timeout: 5s
      retries: 5

  # Cache Redis
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s

  # Application Django
  web:
    build: .
    command: gunicorn skillhub.wsgi:application --bind 0.0.0.0:8000 --workers 4
    volumes:
      - ./media:/app/media
      - static_volume:/app/staticfiles
    expose:
      - 8000
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  # Worker Celery (tâches asynchrones)
  celery_worker:
    build: .
    command: celery -A skillhub worker -l info
    env_file:
      - .env
    depends_on:
      - redis
      - db
    restart: unless-stopped

  # Scheduler Celery Beat
  celery_beat:
    build: .
    command: celery -A skillhub beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
    env_file:
      - .env
    depends_on:
      - redis
      - db
    restart: unless-stopped

  # Nginx reverse proxy
  nginx:
    image: nginx:alpine
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
      - static_volume:/app/staticfiles
      - ./media:/app/media
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - web
    restart: unless-stopped

volumes:
  postgres_data:
  static_volume:
```

### 21.4 Configuration Nginx

```nginx
# nginx/nginx.conf
upstream skillhub {
    server web:8000;
}

server {
    listen 80;
    server_name skillhub.com www.skillhub.com;
    client_max_body_size 20M;

    # Fichiers statiques servis directement par Nginx (ultra rapide)
    location /static/ {
        alias /app/staticfiles/;
        expires 30d;
        add_header Cache-Control "public, no-transform";
    }

    location /media/ {
        alias /app/media/;
        expires 7d;
    }

    # Tout le reste -> Django
    location / {
        proxy_pass http://skillhub;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
        proxy_read_timeout 300;
    }
}
```

---

## Chapitre 22 — CI/CD avec GitHub Actions

```yaml
# .github/workflows/ci.yml
name: SkillHub CI/CD

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

jobs:
  # ===== Tests =====
  test:
    name: [TEST] Tests & Linting
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: skillhub_test
          POSTGRES_USER: skillhub
          POSTGRES_PASSWORD: password
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s

    env:
      DJANGO_SETTINGS_MODULE: skillhub.settings.test
      DATABASE_URL: postgres://skillhub:password@localhost/skillhub_test

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

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.12'
          cache: 'pip'

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

      - name: Run linting (Black)
        run: black --check .

      - name: Run linting (Flake8)
        run: flake8 .

      - name: Run tests with coverage
        run: |
          pytest --cov=apps --cov-report=xml -x

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

  # ===== Build Docker Image =====
  build:
    name: [DOCKER] Build Docker Image
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            skillhub/app:latest
            skillhub/app:${{ github.sha }}

  # ===== Déploiement =====
  deploy:
    name: [RAPIDE] Deploy to Production
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/skillhub
            docker-compose pull
            docker-compose up -d --no-deps web
            docker-compose exec -T web python manage.py migrate --noinput
            echo "[OK] Déploiement terminé!"
```

---

## [OK] Exercices de Complétion — Parties VI & VII

### Exercice 6.1 — Tests Complets ***

**Instructions :**
1. Atteins 80% de couverture de code sur l'app `workshops`
2. Écris des tests pour tous les cas d'erreur (workshop complet, inscription double, permission refusée)
3. Écris des tests d'intégration qui testent le flux complet : créer un compte -> créer un atelier -> s'inscrire -> laisser un avis
4. Configure pytest pour que les tests lents (marqués `@pytest.mark.slow`) ne s'exécutent pas par défaut

---

### Exercice 6.2 — Dockerisation Complète ***

**Instructions :**
1. Crée le Dockerfile et le docker-compose.yml complets pour SkillHub
2. Le `docker-compose up` doit démarrer l'app complète (Django + PostgreSQL + Redis + Nginx)
3. Crée un fichier `Makefile` avec les commandes courantes :
   - `make start` : démarre les containers
   - `make test` : lance les tests
   - `make migrate` : applique les migrations
   - `make superuser` : crée un superuser
   - `make shell` : ouvre le shell Django

---

### Exercice 6.3 — Pipeline CI/CD ****

**Instructions :**
1. Configure GitHub Actions avec au minimum 3 jobs : test, lint, build
2. Les tests doivent s'exécuter sur une vraie base PostgreSQL (service Docker dans CI)
3. Si les tests échouent, le build ne se déclenche pas
4. Configure les secrets GitHub pour le déploiement
5. Ajoute un badge de statut CI dans le README

---

## [IMPORTANT] Récapitulatif Parties VI & VII

| Concept | Outil | À retenir |
|---|---|---|
| Tests unitaires | `pytest` + `factory_boy` | Tester un comportement isolé |
| Tests d'intégration | `pytest-django` | Tester plusieurs composants ensemble |
| Tests API | `APIClient` (DRF) | Simuler des requêtes HTTP |
| Coverage | `pytest-cov` | Objectif > 80% |
| Linting | `black` + `flake8` | Code cohérent et lisible |
| Hooks Git | `pre-commit` | Qualité automatique avant chaque commit |
| Variables d'env | `python-decouple` + `.env` | Jamais de secrets dans le code |
| Docker | `Dockerfile` + `docker-compose` | Environnement reproductible |
| Nginx | Reverse proxy | Sert les statiques, forward à Django |
| CI/CD | `GitHub Actions` | Automatise tests + déploiement |

---

> -> **Suite : Fichier 7 — Expert : Optimisation, Projets & Architecture**
> Le niveau final : caching, Celery, observabilité avec Prometheus/Grafana, architecture modulaire, et projets complets pour consolider toutes tes compétences.


# [COURS] Formation Django — Maîtriser Django de Zéro à Expert
## Fichier 7/7 — Expert : Optimisation, Projets & Architecture (Parties VIII, IX, X)

---

> **Navigation des fichiers :**
> - [FICHIER] Fichier 1 — Introduction & Fondations
> - [FICHIER] Fichier 2 — Modèles & Base de données
> - [FICHIER] Fichier 3 — Vues & Templates
> - [FICHIER] Fichier 4 — URLs, Middleware & Authentification
> - [FICHIER] Fichier 5 — Django REST Framework
> - [FICHIER] Fichier 6 — Tests & Déploiement
> - [FICHIER] **Fichier 7** — Expert : Optimisation, Projets & Architecture *(vous êtes ici)*

---

# [LOGIQUE] PARTIE VIII — Optimisation et Bonnes Pratiques

---

## Chapitre 24 — Performance

### 24.1 Optimisation des Requêtes SQL

Le problème de performance le plus fréquent en Django est le **problème N+1**.
La solution : `select_related` et `prefetch_related`.

Pour diagnostiquer, utilise la **Django Debug Toolbar** :

```bash
pip install django-debug-toolbar
```

```python
# settings/dev.py
INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE
INTERNAL_IPS = ['127.0.0.1']
```

Elle affiche le nombre et le détail de toutes les requêtes SQL exécutées sur chaque page.

**Techniques d'optimisation :**

```python
# 1. defer() — Ne charge pas les champs lourds
workshops = Workshop.objects.defer('description').all()

# 2. only() — Ne charge QUE ces champs
workshops = Workshop.objects.only('id', 'title', 'slug', 'starts_at')

# 3. values() — Retourne des dicts, plus léger
workshops = Workshop.objects.values('id', 'title', 'category__name')

# 4. iterator() — Pour les très gros querysets
for workshop in Workshop.objects.iterator(chunk_size=100):
    process(workshop)

# 5. bulk_create() — Insertion en masse (1 requête SQL au lieu de N)
Workshop.objects.bulk_create([
    Workshop(title=f"Atelier {i}", instructor=user)
    for i in range(100)
])

# 6. update() en masse — Pas besoin de charger les objets
Workshop.objects.filter(
    starts_at__lt=timezone.now(),
    status='published'
).update(status='completed')

# 7. Index pour les champs souvent filtrés
class Workshop(models.Model):
    class Meta:
        indexes = [
            models.Index(fields=['status', 'starts_at']),
            models.Index(fields=['category', 'difficulty']),
        ]
```

### 24.2 Caching avec Redis

```python
# settings/prod.py
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': 'redis://127.0.0.1:6379/1',
        'KEY_PREFIX': 'skillhub',
        'TIMEOUT': 300,
    }
}
```

**Utilisation dans les vues :**

```python
from django.core.cache import cache
from django.views.decorators.cache import cache_page


def get_categories():
    """Retourne les catégories depuis le cache ou la base."""
    categories = cache.get('all_categories')
    if categories is None:
        categories = list(Category.objects.all())
        cache.set('all_categories', categories, timeout=3600)
    return categories


def workshop_stats():
    """Statistiques globales avec cache 5 minutes."""
    stats = cache.get('global_stats')
    if not stats:
        stats = {
            'total_workshops': Workshop.objects.filter(status='published').count(),
            'total_users': User.objects.count(),
            'total_registrations': Registration.objects.count(),
        }
        cache.set('global_stats', stats, timeout=300)
    return stats


# Cache de la vue entière pendant 15 minutes
@cache_page(60 * 15)
def workshop_list(request):
    ...
```

**Invalidation intelligente du cache :**

```python
# workshops/signals.py
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
from .models import Workshop


@receiver([post_save, post_delete], sender=Workshop)
def invalidate_workshop_cache(sender, instance, **kwargs):
    """Invalide le cache à chaque modification d'atelier."""
    cache.delete('all_categories')
    cache.delete('global_stats')
    cache.delete(f'workshop_detail_{instance.slug}')
```

### 24.3 Tâches Asynchrones avec Celery

Certaines opérations ne doivent pas bloquer la réponse HTTP :
envoyer un email, redimensionner une image, générer un PDF, notifier des utilisateurs...

```bash
pip install celery django-celery-beat django-celery-results
```

```python
# skillhub/celery.py
import os
from celery import Celery

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'skillhub.settings.dev')

app = Celery('skillhub')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
```

```python
# skillhub/__init__.py
from .celery import app as celery_app
__all__ = ('celery_app',)
```

```python
# settings/base.py
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
```

```python
# workshops/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from django.template.loader import render_to_string
import logging

logger = logging.getLogger(__name__)


@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_registration_confirmation(self, registration_id):
    """
    Envoie un email de confirmation d'inscription.
    Réessaie jusqu'à 3 fois si ça échoue.
    """
    try:
        from apps.registrations.models import Registration
        registration = Registration.objects.select_related(
            'user', 'workshop', 'workshop__instructor'
        ).get(id=registration_id)

        subject = f"[OK] Inscription confirmée — {registration.workshop.title}"
        message = render_to_string(
            'emails/registration_confirmation.txt',
            {'registration': registration}
        )

        send_mail(
            subject=subject,
            message=message,
            from_email='noreply@skillhub.com',
            recipient_list=[registration.user.email],
        )

        logger.info(f"Email de confirmation envoyé à {registration.user.email}")

    except Exception as exc:
        logger.error(f"Erreur envoi email confirmation: {exc}")
        # Réessaie après 60 secondes, puis 120, puis 240 (backoff exponentiel)
        raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))


@shared_task
def send_workshop_reminder():
    """
    Tâche planifiée : envoie un rappel 24h avant chaque atelier.
    Lancée automatiquement par Celery Beat chaque heure.
    """
    from django.utils import timezone
    from datetime import timedelta
    from apps.registrations.models import Registration

    tomorrow = timezone.now() + timedelta(hours=24)
    one_hour_later = tomorrow + timedelta(hours=1)

    upcoming_workshops = Workshop.objects.filter(
        starts_at__gte=tomorrow,
        starts_at__lt=one_hour_later,
        status='published'
    )

    for workshop in upcoming_workshops:
        registrations = Registration.objects.filter(
            workshop=workshop,
            status='confirmed'
        ).select_related('user')

        for reg in registrations:
            send_mail(
                subject=f"[ALARM_CLOCK] Rappel — {workshop.title} commence demain !",
                message=f"N'oublie pas ton atelier '{workshop.title}' demain à {workshop.starts_at.strftime('%H:%M')}.",
                from_email='noreply@skillhub.com',
                recipient_list=[reg.user.email],
            )

    logger.info(f"Rappels envoyés pour {upcoming_workshops.count()} atelier(s)")


@shared_task
def generate_workshop_certificate(registration_id):
    """Génère un certificat PDF après participation."""
    from apps.registrations.models import Registration
    registration = Registration.objects.get(id=registration_id)

    # Génération du PDF (ex: avec ReportLab ou WeasyPrint)
    # ... logique de génération ...
    logger.info(f"Certificat généré pour la registration {registration_id}")
```

**Lancer Celery :**

```bash
# Dans un terminal séparé
celery -A skillhub worker -l info

# Pour les tâches planifiées (rappels automatiques)
celery -A skillhub beat -l info

# Utilisation dans une vue
from .tasks import send_registration_confirmation

def workshop_register(request, slug):
    # ... logique d'inscription ...
    registration = Registration.objects.create(...)

    # Lance la tâche en arrière-plan (ne bloque pas la réponse)
    send_registration_confirmation.delay(registration.id)

    return redirect('workshops:detail', slug=slug)
```

---

## Chapitre 25 — Sécurité

### 25.1 Protections Natives de Django

Django protège automatiquement contre les failles les plus courantes :

**Protection CSRF (Cross-Site Request Forgery) :**

```html
<!-- Toujours ajouter dans les formulaires POST -->
<form method="post">
    {% csrf_token %}
    ...
</form>
```

**Protection XSS (Cross-Site Scripting) :**

```html
<!-- Django échappe automatiquement les variables -->
{{ user_input }}  <!-- Sûr : <script>alert()</script> -> affichage textuel -->

<!-- [ATTENTION] Le filtre |safe désactive cette protection — n'utilise que sur du HTML contrôlé -->
{{ trusted_html_content|safe }}
```

**Protection SQL Injection :**

```python
# [OK] CORRECT — Django protège automatiquement via l'ORM
Workshop.objects.filter(title=user_input)

# [OK] CORRECT — Paramètres liés même en SQL brut
Workshop.objects.raw("SELECT * FROM workshops WHERE title = %s", [user_input])

# [X] DANGEREUX — Interpolation directe de chaîne
Workshop.objects.raw(f"SELECT * FROM workshops WHERE title = '{user_input}'")
```

### 25.2 Rate Limiting

```python
pip install django-ratelimit
```

```python
from django_ratelimit.decorators import ratelimit


@ratelimit(key='ip', rate='5/m', method='POST', block=True)
def login_view(request):
    """Limite à 5 tentatives de connexion par minute par IP."""
    ...


@ratelimit(key='user', rate='10/h', method='POST', block=True)
@login_required
def workshop_create(request):
    """Limite à 10 créations d'atelier par heure par utilisateur."""
    ...
```

### 25.3 Checklist Sécurité Production

Exécute cette commande avant chaque déploiement :

```bash
python manage.py check --deploy
```

Elle vérifie automatiquement :
- `DEBUG = False`
- `SECRET_KEY` longue et aléatoire
- `ALLOWED_HOSTS` configuré
- HTTPS forcé
- Cookies sécurisés
- En-têtes de sécurité

---

## Chapitre 26 — Observabilité

### 26.1 Logging Structuré

```python
# apps/workshops/views.py
import logging

logger = logging.getLogger(__name__)

def workshop_create(request):
    ...
    if form.is_valid():
        workshop = form.save()
        logger.info(
            "Workshop créé",
            extra={
                'workshop_id': workshop.id,
                'instructor_id': request.user.id,
                'workshop_title': workshop.title,
            }
        )
```

### 26.2 Sentry pour le Suivi des Erreurs

```bash
pip install sentry-sdk
```

```python
# settings/prod.py
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.redis import RedisIntegration

sentry_sdk.init(
    dsn=config('SENTRY_DSN'),
    integrations=[
        DjangoIntegration(transaction_style='url'),
        CeleryIntegration(),
        RedisIntegration(),
    ],
    traces_sample_rate=0.1,       # 10% des transactions tracées
    send_default_pii=False,        # Pas de données personnelles
    environment=config('ENVIRONMENT', default='production'),
)
```

Sentry capture automatiquement toutes les exceptions non gérées, avec le contexte complet (URL, utilisateur, stack trace, variables).

### 26.3 Métriques avec Prometheus

```bash
pip install django-prometheus
```

```python
# settings/base.py
INSTALLED_APPS += ['django_prometheus']
MIDDLEWARE = [
    'django_prometheus.middleware.PrometheusBeforeMiddleware',
    *MIDDLEWARE,
    'django_prometheus.middleware.PrometheusAfterMiddleware',
]
```

```python
# skillhub/urls.py
urlpatterns += [
    path('metrics/', include('django_prometheus.urls')),
]
```

Les métriques sont exposées sur `/metrics/` et consommées par Prometheus + Grafana.

---

# [MODULE] PARTIE IX — Projets Pratiques

Ces projets sont les jalons de ta formation. Chacun réutilise et enrichit le précédent.

---

## Chapitre 27 — Projet 1 : SkillHub MVP (Mini Produit)

**Objectif :** Finaliser SkillHub avec les fonctionnalités core.

**Fonctionnalités à implémenter :**

```
[OK] Authentification complète (inscription, connexion, reset MDP)
[OK] CRUD Workshops (créer, lire, modifier, supprimer)
[OK] Système d'inscription avec gestion des places
[OK] Page profil utilisateur
[OK] Recherche et filtres
[OK] Système d'avis et notes
[OK] Tableau de bord personnel
[OK] Emails transactionnels (confirmation inscription, rappel 24h)
[OK] Admin Django complet
[OK] Tests couvrant > 75% du code
[OK] Déployé sur un serveur (Heroku, Railway, ou VPS)
```

**Architecture minimale :**

```
apps/
├── accounts/     <- User, UserProfile
├── workshops/    <- Workshop, Category, Tag
├── registrations/ <- Registration
└── reviews/       <- Review
```

---

## Chapitre 28 — Projet 2 : API REST Complète

**Objectif :** Exposer toute la logique de SkillHub via une API consommable par n'importe quel client.

**Endpoints à implémenter :**

```
Authentication
  POST   /api/v1/auth/register/
  POST   /api/v1/auth/token/
  POST   /api/v1/auth/token/refresh/
  GET    /api/v1/auth/me/
  PATCH  /api/v1/auth/me/

Workshops
  GET    /api/v1/workshops/                 (liste paginée + filtres)
  POST   /api/v1/workshops/                 (créer)
  GET    /api/v1/workshops/featured/        (ateliers mis en avant)
  GET    /api/v1/workshops/<slug>/          (détail)
  PUT    /api/v1/workshops/<slug>/          (modifier)
  DELETE /api/v1/workshops/<slug>/          (supprimer)
  POST   /api/v1/workshops/<slug>/register/ (s'inscrire)
  DELETE /api/v1/workshops/<slug>/register/ (se désinscrire)
  GET    /api/v1/workshops/<slug>/reviews/  (lister les avis)
  POST   /api/v1/workshops/<slug>/reviews/  (ajouter un avis)

Users
  GET    /api/v1/users/<username>/          (profil public)
  GET    /api/v1/users/<username>/workshops/ (ateliers d'un user)

Stats
  GET    /api/v1/stats/                     (statistiques globales)

Docs
  GET    /api/v1/docs/                      (Swagger UI)
  GET    /api/v1/schema/                    (OpenAPI Schema)
```

**Exercice de complétion :** Construis un mini client JavaScript (HTML + fetch API) qui consomme ton API pour :
1. Afficher la liste des ateliers avec filtres
2. Permettre la connexion et stocker le token JWT en mémoire
3. S'inscrire à un atelier depuis l'interface

---

## Chapitre 29 — Projet 3 : Tâches Asynchrones & Notifications

**Objectif :** Rendre SkillHub réactif avec des tâches en arrière-plan.

**Fonctionnalités :**

```python
# workshops/tasks.py — Toutes les tâches Celery de SkillHub

# [ALARM_CLOCK] Tâches planifiées (Celery Beat)
@shared_task
def send_daily_workshop_reminders():
    """Rappels quotidiens 24h avant chaque atelier."""

@shared_task
def update_workshop_statuses():
    """Met à jour le statut des ateliers passés (published -> completed)."""

@shared_task
def generate_weekly_instructor_report():
    """Rapport hebdomadaire pour les formateurs : vues, inscriptions, revenus."""

@shared_task
def cleanup_expired_invitations():
    """Supprime les invitations expirées."""


# [EMAIL] Tâches événementielles
@shared_task
def send_registration_confirmation(registration_id):
    """Email de confirmation d'inscription."""

@shared_task
def send_workshop_cancellation_notice(workshop_id):
    """Notifie tous les inscrits si l'atelier est annulé."""

@shared_task
def send_review_notification(review_id):
    """Notifie le formateur quand il reçoit un nouvel avis."""

@shared_task
def generate_participation_certificate(registration_id):
    """Génère le certificat PDF après l'atelier."""
```

**Configuration Celery Beat :**

```python
# settings/base.py
from celery.schedules import crontab

CELERY_BEAT_SCHEDULE = {
    'send-daily-reminders': {
        'task': 'apps.workshops.tasks.send_daily_workshop_reminders',
        'schedule': crontab(hour=9, minute=0),  # Tous les jours à 9h
    },
    'update-workshop-statuses': {
        'task': 'apps.workshops.tasks.update_workshop_statuses',
        'schedule': crontab(minute=0),  # Toutes les heures
    },
    'weekly-instructor-report': {
        'task': 'apps.workshops.tasks.generate_weekly_instructor_report',
        'schedule': crontab(day_of_week='monday', hour=8),  # Chaque lundi à 8h
    },
}
```

---

## Chapitre 30 — Projet 4 : Frontend React + API Django

**Objectif :** Découpler frontend et backend. Django = API pure, React = interface utilisateur.

**Architecture :**

```
skillhub-backend/     <- Django API (ce qu'on a construit)
skillhub-frontend/    <- Application React
    src/
    ├── api/          <- Client API (axios/fetch)
    ├── components/   <- Composants réutilisables
    ├── pages/        <- Pages de l'app
    └── context/      <- État global (auth, panier...)
```

**Configuration CORS pour Django :**

```bash
pip install django-cors-headers
```

```python
# settings/base.py
INSTALLED_APPS += ['corsheaders']
MIDDLEWARE = ['corsheaders.middleware.CorsMiddleware'] + MIDDLEWARE

# settings/dev.py
CORS_ALLOWED_ORIGINS = [
    'http://localhost:3000',   # React dev server
    'http://127.0.0.1:3000',
]

# settings/prod.py
CORS_ALLOWED_ORIGINS = [
    'https://skillhub.com',
    'https://www.skillhub.com',
]
```

---

# [RAPIDE] PARTIE X — Expertise et Architecture

---

## Chapitre 31 — Architecture Modulaire

### 31.1 Structure de Projet Expert

```
skillhub/
│
├── apps/                           <- Toutes les apps Django
│   ├── accounts/
│   │   ├── models.py               <- Modèles
│   │   ├── views.py                <- Vues HTML
│   │   ├── urls.py                 <- Routes
│   │   ├── forms.py                <- Formulaires
│   │   ├── serializers.py          <- Serializers DRF
│   │   ├── admin.py                <- Config admin
│   │   ├── signals.py              <- Signaux Django
│   │   ├── tasks.py                <- Tâches Celery
│   │   ├── services.py             <- Logique métier réutilisable
│   │   ├── selectors.py            <- Requêtes ORM (pattern Django Styleguide)
│   │   ├── permissions.py          <- Permissions DRF
│   │   ├── filters.py              <- Filtres django-filter
│   │   └── tests/
│   │       ├── test_models.py
│   │       ├── test_views.py
│   │       ├── test_api.py
│   │       └── test_services.py
│   └── workshops/
│       └── ... (même structure)
│
├── skillhub/
│   ├── settings/
│   │   ├── base.py
│   │   ├── dev.py
│   │   ├── test.py
│   │   └── prod.py
│   ├── urls.py
│   ├── celery.py
│   ├── middleware.py
│   └── wsgi.py
│
├── templates/
│   ├── base.html
│   ├── accounts/
│   ├── workshops/
│   └── emails/
│
├── static/
│   ├── css/
│   ├── js/
│   └── images/
│
├── requirements/
│   ├── base.txt
│   ├── dev.txt
│   ├── test.txt
│   └── prod.txt
│
├── tests/
│   └── factories.py
│
├── scripts/
│   └── entrypoint.sh
│
├── nginx/
│   └── nginx.conf
│
├── .github/
│   └── workflows/
│       └── ci.yml
│
├── docker-compose.yml
├── Dockerfile
├── .env.example
├── .gitignore
├── .pre-commit-config.yaml
├── Makefile
├── manage.py
└── README.md
```

### 31.2 Le Pattern Services/Selectors

Pour garder les vues propres, extrais la logique métier dans des services :

```python
# apps/workshops/selectors.py
# Les selectors = requêtes ORM réutilisables

def get_published_workshops(*, category_slug=None, level=None, is_free=None):
    """Retourne les ateliers publiés avec filtres optionnels."""
    queryset = Workshop.objects.filter(
        status='published'
    ).select_related('instructor', 'category').prefetch_related('tags')

    if category_slug:
        queryset = queryset.filter(category__slug=category_slug)
    if level:
        queryset = queryset.filter(difficulty=level)
    if is_free is not None:
        queryset = queryset.filter(is_free=is_free)

    return queryset


def get_workshop_by_slug(slug):
    """Retourne un atelier publié par son slug ou lève 404."""
    from django.shortcuts import get_object_or_404
    return get_object_or_404(Workshop, slug=slug, status='published')


def get_upcoming_workshops_for_user(user):
    """Retourne les prochains ateliers d'un utilisateur inscrit."""
    from django.utils import timezone
    return Workshop.objects.filter(
        registrations__user=user,
        registrations__status='confirmed',
        starts_at__gte=timezone.now()
    ).select_related('category').order_by('starts_at')
```

```python
# apps/workshops/services.py
# Les services = logique métier avec effets de bord

from django.db import transaction
from .models import Workshop
from apps.registrations.models import Registration
from .tasks import send_registration_confirmation


def register_user_to_workshop(*, user, workshop):
    """
    Inscrit un utilisateur à un atelier.
    Gère toutes les règles métier.
    Lève des exceptions explicites en cas d'erreur.
    """
    if workshop.status != 'published':
        raise ValueError("Impossible de s'inscrire à un atelier non publié.")

    if Registration.objects.filter(user=user, workshop=workshop).exists():
        raise ValueError("Vous êtes déjà inscrit à cet atelier.")

    if workshop.instructor == user:
        raise ValueError("Vous ne pouvez pas vous inscrire à votre propre atelier.")

    if workshop.is_full:
        # Offrir la liste d'attente
        raise WorkshopFullError("Cet atelier est complet.", can_waitlist=True)

    with transaction.atomic():  # Tout ou rien
        registration = Registration.objects.create(
            user=user,
            workshop=workshop,
            status='confirmed'
        )

        # Lance l'email de confirmation en arrière-plan
        send_registration_confirmation.delay(registration.id)

    return registration


class WorkshopFullError(Exception):
    def __init__(self, message, can_waitlist=False):
        super().__init__(message)
        self.can_waitlist = can_waitlist


def cancel_workshop(*, workshop, cancelled_by):
    """Annule un atelier et notifie tous les inscrits."""
    from .tasks import send_workshop_cancellation_notice

    if workshop.instructor != cancelled_by and not cancelled_by.is_staff:
        raise PermissionError("Vous n'êtes pas autorisé à annuler cet atelier.")

    with transaction.atomic():
        workshop.status = 'cancelled'
        workshop.save(update_fields=['status', 'updated_at'])

        # Annule toutes les inscriptions
        Registration.objects.filter(
            workshop=workshop,
            status='confirmed'
        ).update(status='cancelled')

    # Notifie les inscrits en arrière-plan
    send_workshop_cancellation_notice.delay(workshop.id)

    return workshop
```

```python
# apps/workshops/views.py — Vue simple qui délègue aux services
from .services import register_user_to_workshop, WorkshopFullError

@login_required
def workshop_register(request, slug):
    workshop = get_workshop_by_slug(slug)

    if request.method == 'POST':
        try:
            registration = register_user_to_workshop(
                user=request.user,
                workshop=workshop
            )
            messages.success(request, "[OK] Inscription confirmée !")
        except WorkshopFullError as e:
            if e.can_waitlist:
                messages.warning(request, "L'atelier est complet. Veux-tu rejoindre la liste d'attente ?")
            else:
                messages.error(request, str(e))
        except ValueError as e:
            messages.error(request, str(e))

        return redirect('workshops:detail', slug=slug)

    return render(request, 'workshops/confirm_register.html', {'workshop': workshop})
```

### 31.3 Versionning de l'API

```python
# api/v2/serializers.py — Nouvelle version avec champs supplémentaires
class WorkshopV2Serializer(WorkshopListSerializer):
    requirements = serializers.CharField(read_only=True)
    learning_objectives = serializers.ListField(read_only=True)
    language = serializers.CharField(read_only=True, default='fr')


# skillhub/urls.py
urlpatterns += [
    path('api/v1/', include('apps.api.v1.urls', namespace='api_v1')),
    path('api/v2/', include('apps.api.v2.urls', namespace='api_v2')),
]
```

---

## Chapitre 32 — Scalabilité

### 32.1 Multiple Workers Gunicorn

```bash
# Règle : 2 * nb_coeurs + 1 workers
gunicorn skillhub.wsgi:application \
    --workers 9 \
    --worker-class gthread \
    --threads 4 \
    --bind 0.0.0.0:8000 \
    --timeout 120 \
    --keep-alive 5 \
    --access-logfile -
```

### 32.2 Connexion Pool avec pgBouncer

Pour des milliers de connexions simultanées, utilise pgBouncer comme pooler de connexions PostgreSQL :

```ini
# pgbouncer.ini
[databases]
skillhub = host=127.0.0.1 port=5432 dbname=skillhub_db

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
```

---

## Chapitre 33 — Documentation OpenAPI Avancée

```python
# Décorer les viewsets pour une meilleure doc
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiExample


@extend_schema(tags=['workshops'])
class WorkshopViewSet(viewsets.ModelViewSet):

    @extend_schema(
        summary="Liste des ateliers publiés",
        description="Retourne la liste paginée des ateliers avec filtres.",
        parameters=[
            OpenApiParameter('category', str, description='Slug de la catégorie'),
            OpenApiParameter('is_free', bool, description='Ateliers gratuits seulement'),
            OpenApiParameter('search', str, description='Recherche textuelle'),
        ],
        responses={200: WorkshopListSerializer(many=True)},
        examples=[
            OpenApiExample(
                'Exemple de réponse',
                value={
                    'pagination': {'count': 42, 'total_pages': 4},
                    'results': [{'id': 1, 'title': 'Intro Python', '...': '...'}]
                }
            )
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)
```

---

## [LISTE] Chapitre 34 — Checklist de l'Étudiant Expert

Avant de te considérer expert Django, vérifie que tu peux répondre OUI à toutes ces questions :

### Architecture & Modèles

- [ ] Je sais concevoir un schéma de base de données normalisé
- [ ] Je comprends et utilise correctement `select_related` et `prefetch_related`
- [ ] Je sais écrire des DataMigrations
- [ ] Je crée systématiquement un `CustomUser` dès le début d'un projet
- [ ] Je sépare la logique métier dans des services

### Vues & Templates

- [ ] Je suis à l'aise avec les FBV et les CBV
- [ ] Je comprends le système d'héritage de templates
- [ ] Je sais créer des template tags et filtres personnalisés
- [ ] Je gère correctement la pagination

### API & DRF

- [ ] Je sais choisir entre `Serializer`, `ModelSerializer`, et `HyperlinkedModelSerializer`
- [ ] Je comprends la différence entre `APIView` et `ViewSet`
- [ ] Je sais implémenter l'authentification JWT
- [ ] Je sais créer des permissions personnalisées
- [ ] Je documente mon API avec Swagger

### Tests

- [ ] J'écris des tests avant ou en même temps que le code (TDD)
- [ ] J'utilise `factory_boy` pour générer des données de test réalistes
- [ ] Je maintiens une couverture > 80%
- [ ] Je teste les cas d'erreur, pas seulement les cas nominaux

### Déploiement & DevOps

- [ ] Je ne mets jamais de secrets dans le code
- [ ] Je sais dockeriser une application Django
- [ ] Je comprends le rôle de Nginx comme reverse proxy
- [ ] J'ai configuré un pipeline CI/CD
- [ ] Je sais lire et interpréter les logs de production

### Performance & Sécurité

- [ ] Je peux identifier et corriger un problème N+1
- [ ] Je sais utiliser Redis comme cache
- [ ] Je sais configurer Celery pour les tâches asynchrones
- [ ] J'exécute `python manage.py check --deploy` avant chaque mise en prod
- [ ] J'utilise Sentry pour le monitoring des erreurs

---

## [OK] Exercices Finaux — Partie X

### Exercice 10.1 — Refactoring Architecture ***

**Instructions :**
1. Identifie dans le code de SkillHub toute la logique métier qui se trouve dans les vues
2. Extrais-la dans des fonctions `services.py` et `selectors.py`
3. Mets à jour les vues pour qu'elles délèguent aux services
4. Mets à jour les tests pour tester les services directement (sans HTTP)

---

### Exercice 10.2 — Dashboard Analytics ****

**Instructions :**
1. Crée une page `/dashboard/analytics/` réservée aux formateurs
2. Elle affiche pour leurs ateliers :
   - Un graphique des inscriptions dans le temps (données JSON pour Chart.js)
   - La répartition des participants par niveau
   - Le taux de complétion (inscrits qui ont réellement participé)
   - L'évolution de la note moyenne
3. Ces données sont calculées par des tâches Celery et mises en cache

---

### Exercice 10.3 — Système de Notifications ****

**Instructions :**
1. Crée un modèle `Notification` avec : destinataire, type, message, lien, lu/non-lu, date
2. Crée un système qui génère des notifications pour :
   - Nouvelle inscription à ton atelier
   - Nouvel avis reçu
   - Atelier bientôt complet (< 3 places)
   - Rappel 24h avant un atelier
3. Affiche le nombre de notifications non lues dans la navbar (sans rafraîchissement de page — utilise JavaScript)
4. Crée une API endpoint `/api/v1/notifications/` pour les récupérer et les marquer comme lues

---

### Exercice 10.4 — Projet Final : SkillHub v1.0 *****

**Le défi ultime :** Déploie SkillHub en production avec toutes les fonctionnalités.

**Checklist de livraison :**

```
[ ] Toutes les fonctionnalités core implémentées et testées
[ ] API REST documentée sur /api/v1/docs/
[ ] Couverture de tests > 80%
[ ] Docker Compose fonctionnel (web + db + redis + nginx)
[ ] Pipeline CI/CD configuré (GitHub Actions)
[ ] Application déployée sur un serveur public
[ ] Domaine configuré avec HTTPS (Let's Encrypt)
[ ] Monitoring Sentry configuré
[ ] README complet avec installation, utilisation, architecture
[ ] Variables d'environnement documentées dans .env.example
```

---

# [GRAPHIQUE] Récapitulatif Global de la Formation

## Ta Progression SkillHub

```
Fichier 1 -> Hello World Django
Fichier 2 -> Modèles : Workshop, User, Registration, Review
Fichier 3 -> Pages web : liste, détail, formulaires
Fichier 4 -> Routing avancé, Middleware, Auth complète
Fichier 5 -> API REST : JWT, ViewSets, Filtres, Swagger
Fichier 6 -> Tests 80%+, Docker, CI/CD GitHub Actions
Fichier 7 -> Celery, Cache Redis, Services, Monitoring
```

## Tableau de Bord des Compétences

| Domaine | Débutant | Intermédiaire | Expert |
|---|---|---|---|
| **Modèles & ORM** | Champs basiques | Relations, migrations | Optimisation, index, DataMigrations |
| **Vues** | FBV simples | CBV, Mixins | Services, selectors, patterns |
| **Templates** | Variables, boucles | Héritage, filtres | Custom tags, composants |
| **Formulaires** | ModelForm basique | Validation custom | Formsets, widgets custom |
| **API DRF** | Serializer simple | ViewSets, JWT | Versionning, throttling |
| **Tests** | TestCase basique | Factories, fixtures | TDD, 90%+ coverage |
| **Déploiement** | `runserver` | Docker basique | CI/CD, monitoring, scaling |
| **Performance** | Aucune optimisation | select_related | Cache, Celery, profiling |
| **Sécurité** | Defaults Django | HTTPS, CSRF | Rate limiting, audit, SAST |

## Ressources pour Aller Plus Loin

**Documentation officielle :**
- docs.djangoproject.com — La référence absolue
- www.django-rest-framework.org — DRF complet

**Livres recommandés :**
- "Two Scoops of Django" — Best practices par les créateurs de Cookiecutter Django
- "Django for APIs" — DRF de A à Z

**Communautés :**
- Django Forum (forum.djangoproject.com)
- Django Discord
- r/django sur Reddit

**Projets Open Source à étudier :**
- Taiga (gestion de projet) — Architecture Django avancée
- Saleor (e-commerce) — DRF + GraphQL
- Zulip (messagerie) — Celery + WebSockets

---

> [COURS] **Félicitations !** Tu as parcouru l'intégralité de la formation Django, de zéro à expert. Le projet SkillHub que tu as construit représente des compétences directement applicables en entreprise. Continue à pratiquer, contribue à des projets open source, et n'arrête jamais d'apprendre !
