1. Django vs FastAPI

CritèreDjango (+ DRF)FastAPI
PhilosophieBatteries included — tout fourniMicro-framework — assemblez vos outils
ORMDjango ORM intégréSQLAlchemy (à installer)
AdminInterface admin auto-généréeNon intégré
AuthSystème auth complet (users, groups, perms)JWT custom ou auth0
AsyncPartiel (Django 4.1+)Natif (ASGI)
PerformanceBonneTrès haute
Doc APIDRF Browsable APISwagger / ReDoc auto
Idéal pourProjets complexes, backoffice, CMSMicroservices, API haute perf

2. Démarrer un projet Django

pip install django==5.0.4 djangorestframework==3.15.1

# Créer le projet
django-admin startproject taskapi .

# Créer une application
python manage.py startapp blog

# Structure générée
# taskapi/
# ├── manage.py
# ├── taskapi/
# │   ├── settings.py
# │   ├── urls.py
# │   └── wsgi.py
# └── blog/
#     ├── models.py
#     ├── views.py
#     ├── serializers.py   ← à créer
#     └── urls.py          ← à créer
# taskapi/settings.py — configuration minimale
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "rest_framework",      # ← DRF
    "rest_framework.authtoken",  # ← Token auth
    "django_filters",      # ← django-filter
    "blog",                # ← notre app
]

# Configuration DRF globale
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticatedOrReadOnly",
    ],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
    "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}

3. models.Model

from __future__ import annotations

from django.contrib.auth.models import User
from django.db import models

class Post(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Brouillon"
        PUBLISHED = "published", "Publié"
        ARCHIVED = "archived", "Archivé"

    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    content = models.TextField()
    author = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name="posts",
    )
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.DRAFT,
    )
    published_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]       # ordre par défaut
        indexes = [
            models.Index(fields=["status", "created_at"]),
        ]

    def __str__(self) -> str:
        return self.title

class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at"]
# Migrations
python manage.py makemigrations blog
python manage.py migrate

# Créer un superuser pour l'admin
python manage.py createsuperuser

4. Django Admin

# blog/admin.py
from __future__ import annotations

from django.contrib import admin
from django.db.models import Count

from .models import Comment, Post

class CommentInline(admin.TabularInline):
    model = Comment
    extra = 0
    readonly_fields = ["created_at"]

@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ["title", "author", "status", "comment_count", "created_at"]
    list_filter = ["status", "author"]
    search_fields = ["title", "content"]
    prepopulated_fields = {"slug": ("title",)}  # auto-génère le slug
    inlines = [CommentInline]
    actions = ["publish_posts"]

    def get_queryset(self, request):
        return super().get_queryset(request).annotate(
            comment_count=Count("comments")
        )

    @admin.display(description="Commentaires", ordering="comment_count")
    def comment_count(self, obj):
        return obj.comment_count

    @admin.action(description="Publier les posts sélectionnés")
    def publish_posts(self, request, queryset):
        from django.utils import timezone
        count = queryset.filter(status="draft").update(
            status="published", published_at=timezone.now()
        )
        self.message_user(request, f"{count} post(s) publiés.")

5. DRF Serializer

from __future__ import annotations

from rest_framework import serializers
from django.contrib.auth.models import User

from .models import Comment, Post

class CommentSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source="author.username", read_only=True)

    class Meta:
        model = Comment
        fields = ["id", "content", "author_name", "created_at"]
        read_only_fields = ["created_at"]

class PostSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source="author.username", read_only=True)
    comment_count = serializers.IntegerField(read_only=True)
    comments = CommentSerializer(many=True, read_only=True)

    class Meta:
        model = Post
        fields = [
            "id", "title", "slug", "content", "status",
            "author_name", "comment_count", "comments",
            "published_at", "created_at",
        ]
        read_only_fields = ["slug", "created_at", "published_at"]

    # Validator personnalisé
    def validate_title(self, value: str) -> str:
        if len(value) < 5:
            raise serializers.ValidationError("Le titre doit faire au moins 5 caractères")
        return value.strip()

    # Validation cross-fields (équivalent model_validator)
    def validate(self, attrs: dict) -> dict:
        if attrs.get("status") == "published" and not attrs.get("content"):
            raise serializers.ValidationError("Un post publié doit avoir un contenu")
        return attrs

    def create(self, validated_data: dict) -> Post:
        # Injecter l'auteur depuis la requête
        validated_data["author"] = self.context["request"].user
        from django.utils.text import slugify
        validated_data["slug"] = slugify(validated_data["title"])
        return super().create(validated_data)

6. ViewSet & Router

from __future__ import annotations

from django.db.models import Count
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.request import Request
from rest_framework.response import Response

from .models import Comment, Post
from .serializers import CommentSerializer, PostSerializer

class PostViewSet(viewsets.ModelViewSet):
    """CRUD complet pour les posts du blog."""
    serializer_class = PostSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]

    def get_queryset(self):
        # Optimisation : annotate + select_related en une requête
        return (
            Post.objects
            .select_related("author")
            .prefetch_related("comments")
            .annotate(comment_count=Count("comments"))
            .filter(status="published")    # n'expose que les posts publiés
        )

    # ── Action personnalisée : POST /posts/{id}/publish/
    @action(detail=True, methods=["post"])
    def publish(self, request: Request, pk: int = None) -> Response:
        post = self.get_object()
        if post.status == "published":
            return Response({"detail": "Déjà publié"}, status=400)
        post.status = "published"
        from django.utils import timezone
        post.published_at = timezone.now()
        post.save()
        return Response(PostSerializer(post).data)

    # ── Action liste : GET /posts/drafts/
    @action(detail=False, methods=["get"])
    def drafts(self, request: Request) -> Response:
        drafts = Post.objects.filter(author=request.user, status="draft")
        serializer = self.get_serializer(drafts, many=True)
        return Response(serializer.data)

class CommentViewSet(viewsets.ModelViewSet):
    serializer_class = CommentSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]

    def get_queryset(self):
        return Comment.objects.filter(post_id=self.kwargs["post_pk"])

    def perform_create(self, serializer: CommentSerializer) -> None:
        serializer.save(
            author=self.request.user,
            post_id=self.kwargs["post_pk"],
        )
# blog/urls.py — Router DRF
from __future__ import annotations

from rest_framework.routers import DefaultRouter
from rest_framework_nested import routers as nested_routers

from . import views

router = DefaultRouter()
router.register(r"posts", views.PostViewSet, basename="post")

# Routes générées automatiquement :
# GET    /posts/             → list
# POST   /posts/             → create
# GET    /posts/{id}/        → retrieve
# PUT    /posts/{id}/        → update
# PATCH  /posts/{id}/        → partial_update
# DELETE /posts/{id}/        → destroy
# POST   /posts/{id}/publish/ → publish (action)
# GET    /posts/drafts/       → drafts (action)

urlpatterns = router.urls

7. TokenAuthentication

from __future__ import annotations

from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response

@api_view(["POST"])
@permission_classes([AllowAny])
def register(request: Request) -> Response:
    username = request.data.get("username")
    password = request.data.get("password")
    email = request.data.get("email", "")

    if not username or not password:
        return Response({"error": "username et password requis"}, status=400)
    if User.objects.filter(username=username).exists():
        return Response({"error": "Username déjà pris"}, status=409)

    user = User.objects.create_user(username=username, password=password, email=email)
    token, _ = Token.objects.get_or_create(user=user)
    return Response({"token": token.key, "user_id": user.id}, status=201)

@api_view(["POST"])
@permission_classes([AllowAny])
def login_view(request: Request) -> Response:
    from django.contrib.auth import authenticate
    user = authenticate(
        username=request.data.get("username"),
        password=request.data.get("password"),
    )
    if user is None:
        return Response({"error": "Identifiants incorrects"}, status=401)
    token, _ = Token.objects.get_or_create(user=user)
    return Response({"token": token.key})

# ── Usage côté client
# curl -X POST /api/login -d '{"username":"alice","password":"xxx"}'
# → {"token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"}
# curl /api/posts -H "Authorization: Token 9944b09..."

8. Filtres & Pagination DRF

from __future__ import annotations

import django_filters
from rest_framework import filters, pagination

from .models import Post

# ── Filtre personnalisé
class PostFilter(django_filters.FilterSet):
    status = django_filters.ChoiceFilter(choices=Post.Status.choices)
    author = django_filters.CharFilter(field_name="author__username", lookup_expr="iexact")
    created_after = django_filters.DateFilter(field_name="created_at", lookup_expr="gte")
    title_contains = django_filters.CharFilter(field_name="title", lookup_expr="icontains")

    class Meta:
        model = Post
        fields = ["status", "author", "created_after", "title_contains"]

# ── Pagination personnalisée
class StandardPagination(pagination.PageNumberPagination):
    page_size = 20
    page_size_query_param = "limit"
    max_page_size = 100
    page_query_param = "page"

    def get_paginated_response(self, data):
        return Response({
            "count": self.page.paginator.count,
            "pages": self.page.paginator.num_pages,
            "next": self.get_next_link(),
            "previous": self.get_previous_link(),
            "results": data,
        })

# ── Dans le ViewSet
class PostViewSet(viewsets.ModelViewSet):
    filterset_class = PostFilter
    pagination_class = StandardPagination
    filter_backends = [
        django_filters.rest_framework.DjangoFilterBackend,
        filters.SearchFilter,
        filters.OrderingFilter,
    ]
    search_fields = ["title", "content"]
    ordering_fields = ["created_at", "title"]
    ordering = ["-created_at"]

9. ORM Django avancé (annotate, Q)

from __future__ import annotations

from django.db.models import Avg, Count, ExpressionWrapper, F, FloatField, Q, Sum
from django.db.models.functions import TruncMonth
from django.utils import timezone

from .models import Post

# ── annotate : ajouter des champs calculés
posts_with_stats = Post.objects.annotate(
    comment_count=Count("comments"),
    # Sous-requête / expression
    days_since_published=ExpressionWrapper(
        timezone.now() - F("published_at"),
        output_field=FloatField(),
    ),
)

# ── Q objects : requêtes complexes
from django.db.models import Q

# Posts publiés OU brouillons de l'utilisateur courant
my_posts = Post.objects.filter(
    Q(status="published") | Q(author=request.user, status="draft")
)

# Posts avec titre contenant "python" ET au moins 1 commentaire
popular_python = Post.objects.filter(
    Q(title__icontains="python") &
    Q(comment_count__gte=1)
).annotate(comment_count=Count("comments"))

# ── Agrégation globale
stats = Post.objects.aggregate(
    total=Count("id"),
    published=Count("id", filter=Q(status="published")),
    avg_comments=Avg("comments"),
)

# ── Grouper par mois
monthly = (
    Post.objects
    .filter(status="published")
    .annotate(month=TruncMonth("published_at"))
    .values("month")
    .annotate(count=Count("id"))
    .order_by("month")
)
← Module 06 Module 08 — Déploiement →