Gérer l’upload de fichiers et les médias dans Django

Gérer l’upload de fichiers et les médias dans Django

Dès qu’un projet Django commence à devenir un véritable produit, une question finit presque toujours par apparaître : comment gérer correctement les fichiers envoyés par les utilisateurs ? Au début, cela semble très simple. On ajoute un FileField ou un ImageField dans un modèle, on crée un formulaire, on met enctype="multipart/form-data" dans le HTML et, quelques minutes plus tard, une image apparaît dans le dossier media. Tout fonctionne. On se dit alors que le problème est réglé.

Puis le projet grandit.

Les utilisateurs commencent à envoyer des avatars, des photos de produits, des documents PDF, des fichiers Word, des vidéos, des pièces jointes, des galeries d’images et parfois plusieurs fichiers à la fois. On veut afficher les médias dans les templates, supprimer automatiquement les anciens fichiers, empêcher l’envoi de fichiers dangereux, limiter la taille des uploads, stocker les fichiers sur Amazon S3 ou un autre stockage compatible, utiliser Django REST Framework pour accepter des fichiers depuis une application React ou Next.js, générer des miniatures, organiser les fichiers par utilisateur ou par date et faire fonctionner tout cela correctement en production.

C’est à ce moment-là que l’on comprend que l’upload de fichiers n’est pas simplement une question de formulaire HTML.

Dans Django, la gestion des fichiers repose sur plusieurs concepts qui doivent être compris ensemble : FileField, ImageField, MEDIA_ROOT, MEDIA_URL, les objets UploadedFile, les formulaires Django, les modèles, les backends de stockage, les fichiers statiques, la validation, les permissions, la sécurité et éventuellement les services de stockage externes. Une bonne architecture permet de garder cette partie du projet propre et prévisible. Une mauvaise architecture peut au contraire créer des problèmes difficiles à diagnostiquer : fichiers orphelins, noms de fichiers dangereux, répertoires qui grossissent sans contrôle, erreurs de permissions, médias qui fonctionnent en développement mais pas en production, ou encore vulnérabilités liées à des fichiers malveillants.

Ce guide propose donc une approche complète et progressive. L’objectif n’est pas seulement de montrer comment faire fonctionner un upload, mais de comprendre comment concevoir une véritable stratégie de gestion des médias dans Django.


Comprendre la différence entre fichiers statiques et fichiers médias

Avant même d’écrire une ligne de code, il est indispensable de comprendre une distinction fondamentale de Django : les fichiers statiques ne sont pas les fichiers médias.

Les fichiers statiques correspondent aux ressources nécessaires au fonctionnement ou à la présentation de l’application. On peut citer les fichiers CSS, JavaScript, icônes, images utilisées par l’interface, polices ou autres ressources qui font partie du projet.

Les fichiers médias, eux, sont généralement produits ou envoyés pendant l’utilisation de l’application. Une photo de profil envoyée par un utilisateur est un fichier média. Une facture PDF générée pour un client peut être considérée comme un média. Une image ajoutée à un article depuis une interface d’administration est également un média.

Cette distinction est importante parce que les deux catégories n’ont pas la même durée de vie ni la même responsabilité.

Un fichier statique appartient généralement au code ou au déploiement de l’application. Un fichier média appartient aux données de l’application.

Dans un projet Django classique, on retrouve donc souvent une structure conceptuelle comme celle-ci :

project/
├── manage.py
├── config/
│   ├── settings.py
│   ├── urls.py
│   └── ...
├── app/
│   ├── models.py
│   ├── views.py
│   ├── forms.py
│   └── ...
├── static/
│   ├── css/
│   ├── js/
│   └── images/
└── media/
    ├── images/
    ├── documents/
    └── avatars/

Le dossier static contient les ressources statiques tandis que media contient les fichiers envoyés ou générés dynamiquement.

Cette organisation n’est pas obligatoire sous cette forme exacte, mais elle représente une séparation saine.


Configurer les médias dans Django

Commençons par une configuration simple.

Dans settings.py, on peut définir :

from pathlib import Path

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

MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

MEDIA_ROOT représente le chemin physique dans lequel Django stockera les fichiers.

MEDIA_URL représente l’URL utilisée pour accéder aux fichiers depuis le navigateur.

Il faut bien comprendre qu’il s’agit de deux concepts différents.

Par exemple, si Django stocke physiquement un fichier dans :

/home/project/media/uploads/photo.jpg

son URL publique pourrait être :

/media/uploads/photo.jpg

Le navigateur ne connaît pas nécessairement le chemin physique du serveur. Il utilise l’URL.

Cette séparation devient particulièrement importante lorsque l’on passe à un stockage externe comme Amazon S3, Google Cloud Storage, Azure Blob Storage ou un autre backend compatible avec Django.


Configurer les URLs de développement

En développement, Django peut servir les fichiers médias lui-même.

Dans le urls.py principal :

from django.conf import settings
from django.conf.urls.static import static
from django.urls import path

urlpatterns = [
    # vos URLs
]

if settings.DEBUG:
    urlpatterns += static(
        settings.MEDIA_URL,
        document_root=settings.MEDIA_ROOT
    )

Cela permet par exemple d’accéder à :

http://127.0.0.1:8000/media/uploads/photo.jpg

Cette solution est pratique pour le développement.

Il faut cependant retenir un point essentiel : ce mécanisme n’est pas destiné à devenir une stratégie complète de diffusion des fichiers en production.

En production, les fichiers médias sont généralement servis par un serveur web ou un service de stockage dédié.


Utiliser FileField dans un modèle Django

La manière la plus simple de stocker un fichier associé à un objet Django consiste à utiliser FileField.

Imaginons une application de gestion de documents :

from django.db import models


class Document(models.Model):
    title = models.CharField(max_length=200)
    file = models.FileField(upload_to="documents/")
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title

Lorsque l’utilisateur envoie un fichier, Django va l’enregistrer dans le répertoire correspondant à MEDIA_ROOT.

Avec :

upload_to="documents/"

un fichier pourrait être enregistré sous :

media/documents/rapport.pdf

La base de données, elle, ne contient pas le contenu binaire du fichier.

C’est un point extrêmement important.

Dans la plupart des configurations Django classiques, la base de données contient le chemin ou le nom du fichier, tandis que le fichier lui-même se trouve dans le système de stockage.

Par exemple, la colonne de la base pourrait contenir :

documents/rapport.pdf

et Django pourra ensuite construire son URL grâce au système de stockage.


Utiliser ImageField pour les images

Pour les images, Django fournit ImageField.

from django.db import models


class Product(models.Model):
    name = models.CharField(max_length=200)
    image = models.ImageField(
        upload_to="products/"
    )

    def __str__(self):
        return self.name

L’avantage d’ImageField est qu’il s’appuie sur Pillow pour vérifier qu’il s’agit bien d’une image exploitable.

Il faut donc installer Pillow :

pip install Pillow

Puis ajouter l’application ou les migrations nécessaires selon la configuration du projet.

Un fichier peut alors être envoyé dans :

media/products/

et accessible depuis le modèle :

product.image.url

Par exemple :

{% if product.image %}
    <img
        src="{{ product.image.url }}"
        alt="{{ product.name }}"
    >
{% endif %}

Cette syntaxe est beaucoup plus propre que de construire manuellement les chemins.


Pourquoi upload_to est important

Beaucoup de développeurs commencent avec :

image = models.ImageField(upload_to="images/")

et s’arrêtent là.

Pour un petit projet, cela peut être suffisant.

Mais dans une application réelle, il est souvent préférable d’organiser les fichiers.

On peut par exemple utiliser :

class Article(models.Model):
    title = models.CharField(max_length=255)
    cover = models.ImageField(
        upload_to="articles/covers/"
    )

Cela donnera une structure comme :

media/
└── articles/
    └── covers/
        ├── image1.jpg
        ├── image2.jpg
        └── image3.webp

Cette organisation devient très utile lorsque le nombre de fichiers augmente.


Générer dynamiquement le chemin des fichiers

Django permet également de définir upload_to avec une fonction.

C’est extrêmement intéressant pour organiser les fichiers par identifiant, date ou autre information métier.

Par exemple :

from django.db import models


def product_image_path(instance, filename):
    return f"products/{instance.id}/{filename}"


class Product(models.Model):
    name = models.CharField(max_length=200)
    image = models.ImageField(
        upload_to=product_image_path
    )

Un fichier pourrait être enregistré comme :

products/42/photo.jpg

42 correspond à l’identifiant du produit.

On peut aussi organiser par année et mois :

def media_path(instance, filename):
    return f"uploads/{instance.created_at:%Y/%m}/{filename}"

Il faut cependant faire attention aux champs qui ne sont pas encore disponibles au moment où le chemin est calculé. Dans certains scénarios, l’objet peut ne pas encore avoir d’identifiant.

Une solution robuste consiste souvent à utiliser la date actuelle ou des informations déjà disponibles.

Par exemple :

from datetime import date


def upload_path(instance, filename):
    today = date.today()

    return (
        f"uploads/"
        f"{today.year}/"
        f"{today.month:02d}/"
        f"{filename}"
    )

On obtient alors une organisation comme :

media/
└── uploads/
    └── 2026/
        └── 08/
            ├── photo1.jpg
            └── document.pdf

Créer un formulaire Django pour l’upload

Le modèle seul ne suffit pas.

Créons un formulaire :

from django import forms
from .models import Document


class DocumentForm(forms.ModelForm):

    class Meta:
        model = Document
        fields = ["title", "file"]

Dans la vue :

from django.shortcuts import redirect, render

from .forms import DocumentForm


def upload_document(request):

    if request.method == "POST":
        form = DocumentForm(request.POST, request.FILES)

        if form.is_valid():
            form.save()
            return redirect("documents")

    else:
        form = DocumentForm()

    return render(
        request,
        "documents/upload.html",
        {"form": form},
    )

L’élément particulièrement important ici est :

request.FILES

Sans request.FILES, Django ne recevra pas correctement les fichiers envoyés par le formulaire.


Le rôle de multipart/form-data

Le formulaire HTML doit utiliser :

<form
    method="post"
    enctype="multipart/form-data"
>
    {% csrf_token %}

    {{ form.as_p }}

    <button type="submit">
        Envoyer
    </button>
</form>

L’attribut :

enctype="multipart/form-data"

est indispensable pour envoyer des fichiers.

C’est une erreur très classique chez les débutants.

Le formulaire semble correct, le champ fichier apparaît dans le navigateur, mais le serveur reçoit uniquement les champs texte. La raison est généralement l’absence de multipart/form-data.


Comprendre request.FILES

Lorsque Django reçoit un formulaire contenant un fichier, les données textuelles sont généralement disponibles dans :

request.POST

alors que les fichiers sont disponibles dans :

request.FILES

On peut par exemple inspecter :

def upload(request):

    if request.method == "POST":
        print(request.POST)
        print(request.FILES)

    ...

Si le champ s’appelle file, on peut récupérer :

uploaded_file = request.FILES.get("file")

L’objet reçu est un objet UploadedFile.

Django fournit plusieurs classes selon la manière dont le fichier est traité, mais dans la pratique on peut utiliser l’interface commune de l’objet fichier.

Par exemple :

uploaded_file.name
uploaded_file.size
uploaded_file.content_type

On peut également lire son contenu par morceaux.


Ne jamais faire confiance au nom du fichier

Supposons qu’un utilisateur envoie :

photo.jpg

Il serait tentant de croire que le fichier est forcément une image.

Mais le nom du fichier est fourni par le client.

Un utilisateur malveillant peut envoyer un fichier nommé :

malware.jpg

alors que son contenu n’est pas une image.

Il est donc important de ne pas baser la sécurité uniquement sur l’extension.

Une validation sérieuse doit prendre en compte plusieurs éléments :

  • la taille du fichier ;

  • le type attendu ;

  • le contenu réel ;

  • l’extension ;

  • les capacités du serveur à traiter ce type de fichier ;

  • éventuellement une analyse antivirus ;

  • les permissions ;

  • l’emplacement de stockage ;

  • la manière dont le fichier sera servi au navigateur.


Valider la taille d’un fichier

Une validation simple peut être réalisée dans un formulaire.

Par exemple, pour limiter un fichier à 5 Mo :

from django import forms
from .models import Document


class DocumentForm(forms.ModelForm):

    class Meta:
        model = Document
        fields = ["title", "file"]

    def clean_file(self):
        file = self.cleaned_data.get("file")

        if file and file.size > 5 * 1024 * 1024:
            raise forms.ValidationError(
                "Le fichier ne doit pas dépasser 5 Mo."
            )

        return file

Cette approche est simple et efficace pour beaucoup de projets.

On peut naturellement créer une constante :

MAX_FILE_SIZE = 5 * 1024 * 1024

puis la réutiliser.


Valider les extensions

On peut utiliser un validateur Django :

from django.core.validators import FileExtensionValidator


class Document(models.Model):
    title = models.CharField(max_length=200)

    file = models.FileField(
        upload_to="documents/",
        validators=[
            FileExtensionValidator(
                allowed_extensions=[
                    "pdf",
                    "docx",
                    "txt",
                ]
            )
        ],
    )

Cette validation est utile, mais elle ne doit pas être considérée comme une protection absolue.

Une extension ne garantit pas le contenu réel du fichier.

Elle constitue une couche de validation parmi d’autres.


Valider une image

Pour les images, ImageField constitue déjà une bonne base.

On peut cependant ajouter des contraintes.

from django import forms
from PIL import Image


class ImageUploadForm(forms.Form):

    image = forms.ImageField()

    def clean_image(self):
        image = self.cleaned_data["image"]

        if image.size > 5 * 1024 * 1024:
            raise forms.ValidationError(
                "L'image est trop volumineuse."
            )

        return image

On peut également contrôler les dimensions.

class ImageUploadForm(forms.Form):

    image = forms.ImageField()

    def clean_image(self):
        image = self.cleaned_data["image"]

        width, height = image.image.size

        if width > 5000 or height > 5000:
            raise forms.ValidationError(
                "Les dimensions de l'image sont trop grandes."
            )

        return image

Cette validation est particulièrement intéressante pour empêcher des utilisateurs d’envoyer accidentellement des images gigantesques provenant directement d’un smartphone récent ou d’un appareil photo.


Gérer les avatars utilisateurs

Un cas très fréquent consiste à ajouter un avatar au modèle utilisateur ou à un profil.

Par exemple :

from django.conf import settings
from django.db import models


class Profile(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    avatar = models.ImageField(
        upload_to="avatars/",
        blank=True,
        null=True,
    )

Dans un template :

{% if profile.avatar %}
    <img
        src="{{ profile.avatar.url }}"
        alt="Avatar de {{ profile.user.username }}"
    >
{% else %}
    <img
        src="/static/images/default-avatar.png"
        alt="Avatar par défaut"
    >
{% endif %}

Dans une vraie application, on peut ensuite améliorer cette architecture avec des chemins organisés par utilisateur, des images redimensionnées et une suppression de l’ancien avatar.


Supprimer un ancien fichier

Un problème très fréquent est celui des fichiers orphelins.

Supposons qu’un utilisateur possède :

avatars/avatar-old.jpg

Puis il téléverse :

avatars/avatar-new.jpg

Le champ du modèle pointe maintenant vers avatar-new.jpg.

Mais qu’est devenu avatar-old.jpg ?

Selon la manière dont l’opération est effectuée, l’ancien fichier peut rester sur le disque.

Au bout de plusieurs mois, une application qui change régulièrement les images peut accumuler énormément de fichiers inutiles.

Il faut donc penser au cycle de vie du fichier, et pas seulement à son upload.


Supprimer un fichier lorsqu’un objet est supprimé

Une solution consiste à utiliser un signal.

from django.db.models.signals import post_delete
from django.dispatch import receiver

from .models import Document


@receiver(post_delete, sender=Document)
def delete_document_file(sender, instance, **kwargs):

    if instance.file:
        instance.file.delete(save=False)

Le paramètre :

save=False

indique que l’on souhaite supprimer le fichier sans demander une nouvelle sauvegarde du modèle.

Il faut néanmoins faire attention à cette technique dans les architectures complexes. Les signaux sont puissants, mais ils peuvent rendre le comportement du projet moins évident si leur utilisation devient excessive.


Supprimer l’ancien fichier lors d’un remplacement

Pour détecter un changement, on peut comparer l’ancien objet avec la nouvelle version.

Une approche simple :

from django.db import models


class Product(models.Model):
    name = models.CharField(max_length=200)
    image = models.ImageField(
        upload_to="products/"
    )

    def save(self, *args, **kwargs):

        old_image = None

        if self.pk:
            old = Product.objects.filter(pk=self.pk).first()

            if old and old.image != self.image:
                old_image = old.image

        super().save(*args, **kwargs)

        if old_image:
            old_image.delete(save=False)

Cette méthode peut convenir dans certaines applications, mais il faut réfléchir à ses conséquences.

Dans un système utilisant du stockage cloud, des transactions complexes ou des traitements asynchrones, une stratégie dédiée peut être préférable.

L’idée importante est simplement de comprendre que modifier une référence de fichier en base de données ne supprime pas nécessairement l’ancien fichier physique.


Les fichiers ne doivent pas être considérés comme de simples chaînes

Un piège courant consiste à considérer :

document.file

comme une simple chaîne.

En réalité, Django utilise une abstraction de fichier.

On peut accéder au nom :

document.file.name

à l’URL :

document.file.url

à la taille :

document.file.size

et à certaines opérations du fichier :

document.file.open()

Par exemple :

document.file.open("rb")

content = document.file.read()

document.file.close()

Pour les gros fichiers, il faut éviter de charger inutilement tout le contenu en mémoire.


Traiter les fichiers par morceaux

Lorsqu’un fichier est volumineux, il est préférable d’utiliser chunks().

uploaded_file = request.FILES["file"]

with open("/tmp/uploaded_file", "wb+") as destination:

    for chunk in uploaded_file.chunks():
        destination.write(chunk)

Cette méthode permet de traiter les fichiers progressivement.

Elle devient particulièrement importante pour les gros fichiers parce qu’une application web doit éviter de consommer toute la mémoire disponible pour une seule requête.


Upload de plusieurs fichiers

Django permet également de gérer plusieurs fichiers.

Côté HTML :

<form
    method="post"
    enctype="multipart/form-data"
>
    {% csrf_token %}

    <input
        type="file"
        name="files"
        multiple
    >

    <button type="submit">
        Envoyer
    </button>
</form>

Dans la vue :

def upload_multiple(request):

    if request.method == "POST":

        files = request.FILES.getlist("files")

        for uploaded_file in files:
            print(
                uploaded_file.name,
                uploaded_file.size
            )

    return render(
        request,
        "upload_multiple.html"
    )

Cependant, accepter plusieurs fichiers signifie aussi qu’il faut réfléchir à des limites.

Par exemple :

MAX_FILES = 10

Puis :

if len(files) > MAX_FILES:
    ...

Il faut également contrôler la taille totale :

total_size = sum(file.size for file in files)

if total_size > 50 * 1024 * 1024:
    ...

Créer un modèle pour une galerie d’images

Pour une galerie, il est généralement préférable de créer un modèle séparé.

class Gallery(models.Model):
    title = models.CharField(max_length=200)


class GalleryImage(models.Model):
    gallery = models.ForeignKey(
        Gallery,
        on_delete=models.CASCADE,
        related_name="images",
    )

    image = models.ImageField(
        upload_to="galleries/"
    )

    caption = models.CharField(
        max_length=255,
        blank=True,
    )

    created_at = models.DateTimeField(
        auto_now_add=True
    )

Cela donne une architecture beaucoup plus flexible.

Une galerie peut contenir zéro, une ou plusieurs images.

On peut ensuite afficher :

gallery.images.all()

Dans le template :

{% for image in gallery.images.all %}
    <figure>
        <img
            src="{{ image.image.url }}"
            alt="{{ image.caption }}"
        >

        {% if image.caption %}
            <figcaption>
                {{ image.caption }}
            </figcaption>
        {% endif %}
    </figure>
{% endfor %}

Stocker des documents associés à un utilisateur

Pour une plateforme documentaire :

class UserDocument(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    title = models.CharField(max_length=255)

    file = models.FileField(
        upload_to="user-documents/"
    )

    uploaded_at = models.DateTimeField(
        auto_now_add=True
    )

La relation utilisateur est essentielle parce qu’elle permet ensuite de contrôler l’accès.

Par exemple :

document = get_object_or_404(
    UserDocument,
    pk=pk,
    user=request.user,
)

Cette petite contrainte évite qu’un utilisateur puisse simplement modifier un identifiant dans l’URL pour accéder au document d’un autre utilisateur.


L’upload et les permissions

La sécurité d’un système de fichiers ne repose pas uniquement sur le fichier lui-même.

Il faut également se demander :

Qui a le droit d’envoyer ce fichier ?

Puis :

Qui a le droit de le télécharger ?

Et enfin :

Qui a le droit de le supprimer ou de le remplacer ?

Prenons une vue :

from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect


@login_required
def delete_document(request, pk):

    document = get_object_or_404(
        UserDocument,
        pk=pk,
        user=request.user,
    )

    if request.method == "POST":
        document.file.delete(save=False)
        document.delete()

        return redirect("documents")

Le filtre :

user=request.user

est essentiel.

Une URL comme :

/documents/42/delete/

ne doit pas permettre à n’importe quel utilisateur connecté de supprimer le document 42.


Ne jamais utiliser le nom fourni par l’utilisateur comme chemin complet

Imaginez que vous construisiez manuellement :

path = f"/uploads/{uploaded_file.name}"

C’est une mauvaise idée.

Le nom du fichier peut contenir des caractères inattendus.

Dans certains contextes, les attaques de type path traversal cherchent à exploiter des noms contenant des séquences comme :

../../

Django et ses systèmes de stockage fournissent des mécanismes permettant de mieux gérer les noms, mais il ne faut pas contourner ces mécanismes en construisant soi-même des chemins physiques à partir d’une entrée utilisateur.

Préférez :

file = models.FileField(
    upload_to="documents/"
)

et laissez le backend de stockage gérer le chemin.


Renommer les fichiers uploadés

Dans beaucoup d’applications, on ne veut pas conserver le nom original.

Par exemple, dix utilisateurs peuvent envoyer :

photo.jpg

Même si Django sait gérer les collisions de noms, une stratégie basée sur des noms uniques peut être préférable.

On peut générer un UUID :

import uuid


def unique_filename(instance, filename):
    extension = filename.split(".")[-1]

    return (
        f"uploads/"
        f"{uuid.uuid4().hex}."
        f"{extension}"
    )

Puis :

class FileUpload(models.Model):
    file = models.FileField(
        upload_to=unique_filename
    )

Cela produira par exemple :

uploads/8d3b6c0b7f0f4f58b0f7f0e7b0a5f3f2.jpg

Cette stratégie présente plusieurs avantages.

Elle évite les collisions, rend les noms moins prévisibles et permet de séparer le nom affiché à l’utilisateur du nom physique du fichier.


Utiliser pathlib pour gérer les extensions

Une version plus propre :

from pathlib import Path
import uuid


def unique_filename(instance, filename):
    extension = Path(filename).suffix.lower()

    return (
        f"uploads/"
        f"{uuid.uuid4().hex}"
        f"{extension}"
    )

Si le fichier s’appelle :

PHOTO.JPG

on obtient :

uploads/....jpg

Cela permet également d’éviter de faire des manipulations fragiles avec split(".").


Attention à la confiance accordée à l’extension

Même une extension normalisée n’est pas une preuve du contenu.

Pour une image, Pillow permet de vérifier et de traiter l’image.

Pour des documents sensibles, on peut mettre en place une analyse antivirus ou un service spécialisé.

Dans une application qui accepte des fichiers provenant d’utilisateurs inconnus, il est préférable d’adopter une approche de défense en profondeur :

Utilisateur
   ↓
Authentification
   ↓
Autorisation
   ↓
Limite de taille
   ↓
Validation extension
   ↓
Validation contenu
   ↓
Analyse antivirus éventuelle
   ↓
Stockage isolé
   ↓
Diffusion contrôlée

Cette architecture est beaucoup plus robuste que :

Utilisateur → fichier → disque

Configurer les limites d’upload

Il existe plusieurs niveaux auxquels les limites peuvent être définies.

Django possède des paramètres liés aux uploads et au traitement des requêtes, tandis que le serveur web ou le reverse proxy peut également imposer des limites.

Par exemple, avec Nginx, on peut avoir une limite :

client_max_body_size 20M;

Cela signifie que même si Django autorise un fichier de 50 Mo, Nginx peut refuser la requête avant qu’elle n’atteigne Django.

Il est donc important de considérer toute la chaîne :

Navigateur
   ↓
Nginx / Proxy
   ↓
Gunicorn / Uvicorn
   ↓
Django
   ↓
Application
   ↓
Stockage

Une limite mal alignée peut créer des erreurs frustrantes.


Pourquoi un upload fonctionne en local mais échoue en production

C’est une situation extrêmement fréquente.

En local :

MEDIA_ROOT = /projet/media

Django écrit directement sur le disque.

En production, plusieurs choses peuvent changer.

L’application peut être exécutée dans un conteneur Docker. Plusieurs instances Django peuvent être lancées derrière un load balancer. Le système de fichiers du conteneur peut être temporaire. Le serveur web peut être séparé de l’application.

Dans ce contexte, écrire dans :

/media/

sur le disque local de l’instance peut être une mauvaise stratégie.

Imaginez deux serveurs :

Server A
media/avatar.jpg

Server B
media/

Un utilisateur envoie une image sur Server A.

Puis la requête suivante est envoyée à Server B.

Le fichier n’existe pas sur Server B.

Voilà pourquoi les applications modernes utilisent souvent un stockage partagé ou un stockage objet.


Comprendre le système de stockage Django

Django abstrait le stockage des fichiers grâce au système de Storage.

C’est l’une des fonctionnalités les plus importantes pour comprendre les médias.

Le modèle :

image = models.ImageField(
    upload_to="products/"
)

ne dit pas explicitement :

écris ce fichier dans le disque local.

Il dit plutôt :

utilise le système de stockage configuré pour enregistrer ce fichier.

Cette abstraction permet de changer de backend sans réécrire tous les modèles.


Stockage local

En développement, le stockage local est extrêmement pratique.

On peut conserver les fichiers dans :

MEDIA_ROOT

C’est généralement la meilleure option pour commencer un projet.

Il n’est pas nécessaire de mettre immédiatement S3 ou un autre service cloud pour une petite application en développement.

Une architecture simple est souvent une bonne architecture.


Stockage cloud

Lorsque l’application grandit, un stockage objet devient intéressant.

Les solutions courantes incluent notamment :

  • Amazon S3 ;

  • Google Cloud Storage ;

  • Azure Blob Storage ;

  • Cloudflare R2 ;

  • MinIO ;

  • d’autres services compatibles S3.

L’idée est de déplacer le stockage des fichiers hors du serveur applicatif.

On obtient alors une architecture :

Django
   │
   ├── Base de données
   │
   └── Stockage objet
          │
          ├── images
          ├── documents
          └── vidéos

Cette architecture est particulièrement adaptée aux environnements avec plusieurs instances Django.


Utiliser django-storages

Pour plusieurs fournisseurs cloud, le package django-storages est une solution populaire.

Installation :

pip install django-storages

Pour AWS S3, on ajoute généralement :

pip install boto3

Puis :

INSTALLED_APPS = [
    # ...
    "storages",
]

La configuration exacte dépend du fournisseur et de la version de Django utilisée, mais l’idée générale reste la même : Django utilise un backend de stockage distant au lieu du filesystem local.


Ne jamais mettre les clés cloud directement dans settings.py

Évitez :

AWS_ACCESS_KEY_ID = "ma-cle-secrete"
AWS_SECRET_ACCESS_KEY = "super-secret"

dans un fichier versionné.

Utilisez plutôt des variables d’environnement :

import os

AWS_ACCESS_KEY_ID = os.environ.get(
    "AWS_ACCESS_KEY_ID"
)

AWS_SECRET_ACCESS_KEY = os.environ.get(
    "AWS_SECRET_ACCESS_KEY"
)

Encore mieux, dans un environnement de production, utilisez le mécanisme de gestion des secrets fourni par votre infrastructure.

Une règle simple à retenir :

Les secrets ne doivent pas être committés dans Git.


Les variables d’environnement pour les médias

On peut organiser la configuration :

import os

MEDIA_URL = os.environ.get(
    "MEDIA_URL",
    "/media/"
)

MEDIA_ROOT = os.environ.get(
    "MEDIA_ROOT",
    str(BASE_DIR / "media")
)

Cette approche permet d’utiliser une configuration différente selon l’environnement.

Développement :

MEDIA_URL=/media/

Production avec stockage externe :

MEDIA_URL=https://cdn.example.com/

Le code métier n’a pas nécessairement besoin de changer.


Utiliser une CDN pour les images

Pour une application avec beaucoup d’images, servir toutes les ressources directement depuis le serveur Django n’est pas toujours optimal.

Une architecture moderne peut ressembler à :

Utilisateur
     ↓
CDN
     ↓
Object Storage

Django gère les données et les permissions tandis que le CDN diffuse les fichiers.

Cela permet notamment de réduire la charge sur l’application.

Pour des images publiques, cette stratégie est particulièrement intéressante.

Pour des documents privés, il faut ajouter une couche de contrôle d’accès, par exemple avec des URLs temporaires ou une vue qui vérifie les permissions avant de transmettre le fichier.


Fichiers publics et fichiers privés

Tous les médias ne doivent pas être publics.

Une image d’article peut être publique :

/articles/python.jpg

Mais un document administratif peut être privé :

/users/42/private/document.pdf

Il serait dangereux de rendre automatiquement tout le dossier MEDIA_ROOT accessible au public.

Pour les fichiers privés, on peut créer une vue dédiée :

from django.contrib.auth.decorators import login_required
from django.http import FileResponse
from django.shortcuts import get_object_or_404


@login_required
def download_document(request, pk):

    document = get_object_or_404(
        UserDocument,
        pk=pk,
        user=request.user,
    )

    return FileResponse(
        document.file.open("rb"),
        as_attachment=True,
        filename=document.title,
    )

Ici, le contrôle d’accès est effectué avant l’envoi.


Différence entre afficher et télécharger

Pour une image publique :

return FileResponse(
    image.file.open("rb")
)

on peut laisser le navigateur l’afficher.

Pour un document à télécharger :

return FileResponse(
    document.file.open("rb"),
    as_attachment=True,
    filename="document.pdf",
)

Le navigateur est alors invité à télécharger le fichier.

Le comportement dépend également des headers HTTP.


Les fichiers privés avec S3

Dans un stockage objet, il existe généralement deux grandes approches.

La première consiste à rendre le fichier public.

La seconde consiste à garder le fichier privé et générer une URL temporaire.

La deuxième solution est préférable pour les documents confidentiels.

Le principe est :

Utilisateur
    ↓
Django vérifie les permissions
    ↓
Django génère une URL temporaire
    ↓
Utilisateur accède au fichier
    ↓
Le stockage sert directement le fichier

Cette architecture évite de faire transiter des fichiers lourds à travers le serveur Django.


Upload depuis React ou Next.js vers Django

Django est souvent utilisé comme backend d’une application moderne avec React, Vue ou Next.js.

Dans ce cas, le fichier est généralement envoyé via FormData.

Côté JavaScript :

const formData = new FormData();

formData.append("title", title);
formData.append("file", file);

const response = await fetch(
    "http://localhost:8000/api/documents/",
    {
        method: "POST",
        body: formData,
        headers: {
            Authorization: `Bearer ${token}`,
        },
    }
);

Il est important de ne pas définir manuellement :

Content-Type: multipart/form-data

dans la plupart des cas lorsque vous utilisez FormData.

Le navigateur ajoute automatiquement le boundary nécessaire.


Upload avec Django REST Framework

Pour une API Django REST Framework, on peut créer un serializer :

from rest_framework import serializers

from .models import Document


class DocumentSerializer(serializers.ModelSerializer):

    class Meta:
        model = Document
        fields = [
            "id",
            "title",
            "file",
            "created_at",
        ]

Puis une vue :

from rest_framework import viewsets

from .models import Document
from .serializers import DocumentSerializer


class DocumentViewSet(viewsets.ModelViewSet):

    serializer_class = DocumentSerializer

    def get_queryset(self):
        return Document.objects.filter(
            user=self.request.user
        )

    def perform_create(self, serializer):
        serializer.save(
            user=self.request.user
        )

Le frontend peut alors envoyer une requête multipart.


Configurer les parsers DRF

Django REST Framework fournit notamment :

from rest_framework.parsers import (
    MultiPartParser,
    FormParser,
)

Dans une vue :

class DocumentUploadView(APIView):

    parser_classes = [
        MultiPartParser,
        FormParser,
    ]

Cela permet à l’API de traiter les données multipart.

Pour les APIs qui reçoivent des fichiers, comprendre les parsers est important.


Serializer avec validation de taille

On peut également placer une validation au niveau du serializer :

class DocumentSerializer(serializers.ModelSerializer):

    class Meta:
        model = Document
        fields = [
            "id",
            "title",
            "file",
        ]

    def validate_file(self, value):

        max_size = 5 * 1024 * 1024

        if value.size > max_size:
            raise serializers.ValidationError(
                "Le fichier ne doit pas dépasser 5 Mo."
            )

        return value

Cela permet à l’API de retourner une réponse structurée.

Par exemple :

{
    "file": [
        "Le fichier ne doit pas dépasser 5 Mo."
    ]
}

Progression d’un upload

Pour les petits fichiers, un simple formulaire suffit.

Mais pour les gros fichiers, l’expérience utilisateur devient importante.

Imaginez un utilisateur qui envoie une vidéo de 500 Mo.

S’il clique sur « Envoyer » et voit uniquement un spinner pendant plusieurs minutes, il peut penser que l’application est bloquée.

Une interface moderne peut afficher :

Upload en cours...
████████████████░░░░ 82%

Côté navigateur, XMLHttpRequest ou certaines bibliothèques peuvent fournir des informations de progression.

Pour des applications nécessitant des uploads très importants, une architecture d’upload direct vers un stockage objet peut être encore meilleure.


Upload direct vers S3

Une architecture avancée consiste à éviter :

Navigateur
    ↓
Django
    ↓
S3

et à utiliser :

Navigateur
    ↓
S3

avec Django qui autorise l’opération.

Le flux devient :

1. Frontend demande une autorisation à Django.
2. Django vérifie l'utilisateur.
3. Django génère une URL ou une politique temporaire.
4. Frontend envoie directement le fichier au stockage.
5. Frontend informe Django que l'upload est terminé.
6. Django enregistre les métadonnées.

Cette approche est très intéressante pour les fichiers volumineux.


Générer des miniatures

Pour les sites qui contiennent beaucoup d’images, afficher directement une image originale de 8 Mo dans une liste de 50 articles est une mauvaise expérience.

Il est préférable de générer plusieurs versions :

original.jpg
large.jpg
medium.jpg
thumbnail.jpg

On peut utiliser Pillow :

from PIL import Image


def create_thumbnail(path):

    image = Image.open(path)

    image.thumbnail((300, 300))

    image.save(path)

Dans une application réelle, le traitement doit être pensé avec soin.

Pour des milliers d’images, effectuer toutes les conversions directement dans la requête HTTP peut ralentir l’application.

Une meilleure solution consiste souvent à utiliser une tâche asynchrone.


Utiliser Celery pour les traitements médias

Supposons qu’un utilisateur envoie une image.

Django peut enregistrer le fichier puis lancer une tâche :

generate_thumbnails.delay(image.id)

La tâche :

from celery import shared_task


@shared_task
def generate_thumbnails(image_id):

    image = GalleryImage.objects.get(
        pk=image_id
    )

    # traitement de l'image
    # génération des différentes tailles

    return True

Cela permet de séparer :

Upload
   ↓
Réponse rapide
   ↓
Traitement asynchrone
   ↓
Miniatures
   ↓
Optimisation

C’est particulièrement utile pour les vidéos, les images haute résolution, les PDF nécessitant une conversion ou les traitements antivirus.


Optimiser les images

Une bonne gestion des médias ne consiste pas uniquement à stocker les fichiers.

Il faut également réfléchir à leur poids.

Une image :

4000 × 3000

peut être beaucoup plus grande que nécessaire pour une miniature affichée :

300 × 225

Il est donc préférable de générer des tailles adaptées à chaque usage.

Par exemple :

thumbnail: 150px
small: 400px
medium: 800px
large: 1600px
original: taille originale

Cela permet au frontend de choisir la ressource adaptée.


Utiliser WebP ou AVIF

Les formats modernes comme WebP et AVIF peuvent réduire considérablement le poids des images.

Une application peut conserver l’original mais produire des versions optimisées.

Par exemple :

original.jpg
optimized.webp
optimized.avif

Le frontend peut ensuite utiliser :

<picture>
    <source
        srcset="{{ image.avif_url }}"
        type="image/avif"
    >

    <source
        srcset="{{ image.webp_url }}"
        type="image/webp"
    >

    <img
        src="{{ image.original_url }}"
        alt="{{ image.alt }}"
    >
</picture>

La stratégie exacte dépend du besoin, mais l’idée est de ne pas envoyer systématiquement la version la plus lourde.


Les métadonnées EXIF

Les photographies peuvent contenir des métadonnées EXIF.

Elles peuvent notamment révéler :

  • le modèle de l’appareil ;

  • la date ;

  • certains paramètres de prise de vue ;

  • parfois les coordonnées GPS.

Pour des applications publiques, il peut être pertinent de supprimer certaines métadonnées avant de publier les images.

Pillow permet de manipuler les images, mais la stratégie exacte doit être adaptée au type de fichier et aux exigences de confidentialité.


Sécuriser l’affichage des noms de fichiers

Le nom original d’un fichier peut contenir des caractères inhabituels.

Il est généralement préférable d’utiliser le nom original uniquement comme métadonnée destinée à l’utilisateur, et de générer un nom physique contrôlé.

Par exemple :

class Document(models.Model):
    original_filename = models.CharField(
        max_length=255
    )

    file = models.FileField(
        upload_to="documents/"
    )

Lors de l’upload :

document.original_filename = uploaded_file.name

Le stockage peut utiliser un nom UUID tandis que l’interface affiche le nom fourni par l’utilisateur.

Cette séparation est propre :

Nom utilisateur :
rapport-annuel-2026.pdf

Nom physique :
a6f4e91c....pdf

Éviter les fichiers exécutables

Si votre application n’a aucune raison d’accepter des exécutables, ne les acceptez pas.

Par exemple, pour une plateforme de documents administratifs, vous pouvez limiter les types à :

pdf
docx
xlsx
jpg
png

et refuser :

exe
bat
cmd
sh
php
js
html

Attention toutefois : même une liste blanche d’extensions n’est qu’une couche de sécurité.

Il faut également contrôler la manière dont les fichiers sont servis.


Le risque des fichiers HTML et SVG

Les fichiers SVG sont particulièrement intéressants parce qu’ils sont techniquement des images mais peuvent contenir du contenu XML et potentiellement des éléments actifs selon le contexte.

De même, permettre à des utilisateurs d’envoyer du HTML qui sera ensuite servi directement depuis votre domaine peut présenter des risques.

Dans les applications sensibles, il est donc préférable de traiter les formats pouvant contenir du code ou du contenu actif avec prudence.


Les fichiers uploadés et XSS

Une mauvaise configuration peut transformer un simple upload en vecteur d’attaque.

Imaginez que votre serveur permette d’envoyer :

malicious.html

puis de le rendre accessible depuis :

https://example.com/media/malicious.html

Le navigateur pourrait interpréter ce fichier comme une page.

Selon l’architecture, cela peut créer des problèmes sérieux.

Il faut donc réfléchir à la manière dont les fichiers sont servis, à leur Content-Type, aux headers HTTP, au domaine utilisé et à la possibilité d’isoler les fichiers utilisateurs sur un domaine différent.


Isoler les fichiers utilisateurs

Pour des applications particulièrement sensibles, une stratégie intéressante consiste à utiliser un domaine séparé pour les uploads.

Par exemple :

www.example.com

pour l’application,

et :

media.exampleusercontent.com

pour les fichiers utilisateurs.

Cela permet de réduire certains risques liés au contenu actif.

Ce type d’architecture doit évidemment être étudié selon les besoins du projet.


Afficher un fichier dans un template Django

Une fois le fichier enregistré :

document.file.url

permet d’obtenir son URL.

Exemple :

<a
    href="{{ document.file.url }}"
    target="_blank"
    rel="noopener"
>
    Ouvrir le document
</a>

Pour une image :

<img
    src="{{ product.image.url }}"
    alt="{{ product.name }}"
>

Il est préférable de tester l’existence du fichier :

{% if product.image %}
    <img
        src="{{ product.image.url }}"
        alt="{{ product.name }}"
    >
{% endif %}

Utiliser get_media_prefix

Dans certains templates, on peut utiliser :

{{ MEDIA_URL }}

mais il est généralement plus propre de s’appuyer sur le champ fichier lui-même :

{{ object.file.url }}

Ainsi, le stockage reste abstrait.

Si demain le fichier passe du stockage local à S3, le modèle peut continuer à fournir :

object.file.url

sans que le template ait besoin de connaître les détails du stockage.


Éviter de construire les URLs manuellement

Évitez :

<img
    src="/media/products/{{ product.image.name }}"
>

Préférez :

<img
    src="{{ product.image.url }}"
>

Pourquoi ?

Parce que le deuxième exemple respecte l’abstraction de stockage de Django.

Si le fichier est sur :

https://cdn.example.com/

le champ peut générer l’URL correspondante.

Le template reste donc indépendant du backend.


Utiliser des propriétés pour les médias

Dans un modèle, on peut ajouter des propriétés utiles :

class Product(models.Model):
    name = models.CharField(max_length=200)

    image = models.ImageField(
        upload_to="products/"
    )

    @property
    def has_image(self):
        return bool(self.image)

Dans le template :

{% if product.has_image %}
    <img
        src="{{ product.image.url }}"
        alt="{{ product.name }}"
    >
{% endif %}

Il faut cependant éviter de multiplier les propriétés triviales lorsque le template peut déjà gérer le cas simplement.


Gérer les champs optionnels

Pour une image facultative :

image = models.ImageField(
    upload_to="products/",
    blank=True,
    null=True,
)

Dans beaucoup de projets Django, on préfère souvent :

blank=True

sans null=True pour certains champs de fichiers, selon la convention choisie.

Par exemple :

image = models.ImageField(
    upload_to="products/",
    blank=True,
)

Le comportement exact doit rester cohérent dans tout le projet.


Upload de PDF

Un modèle simple :

from django.core.validators import FileExtensionValidator


class Invoice(models.Model):
    number = models.CharField(
        max_length=100
    )

    pdf = models.FileField(
        upload_to="invoices/",
        validators=[
            FileExtensionValidator(
                allowed_extensions=["pdf"]
            )
        ],
    )

    created_at = models.DateTimeField(
        auto_now_add=True
    )

Formulaire :

class InvoiceForm(forms.ModelForm):

    class Meta:
        model = Invoice
        fields = [
            "number",
            "pdf",
        ]

    def clean_pdf(self):
        pdf = self.cleaned_data["pdf"]

        if pdf.size > 10 * 1024 * 1024:
            raise forms.ValidationError(
                "Le PDF ne doit pas dépasser 10 Mo."
            )

        return pdf

Upload de fichiers CSV

Un CSV peut sembler inoffensif, mais il faut également le valider.

Par exemple :

class ImportForm(forms.Form):

    file = forms.FileField()

    def clean_file(self):

        file = self.cleaned_data["file"]

        if file.size > 20 * 1024 * 1024:
            raise forms.ValidationError(
                "Fichier trop volumineux."
            )

        if not file.name.lower().endswith(".csv"):
            raise forms.ValidationError(
                "Veuillez envoyer un fichier CSV."
            )

        return file

Ensuite, on peut traiter le fichier :

import csv
import io


def process_csv(uploaded_file):

    decoded = uploaded_file.read().decode(
        "utf-8"
    )

    reader = csv.DictReader(
        io.StringIO(decoded)
    )

    for row in reader:
        print(row)

Pour de gros CSV, il faut évidemment éviter de charger tout le contenu en mémoire.


Upload de fichiers Excel

Pour les fichiers Excel, on peut utiliser openpyxl pour les .xlsx.

from openpyxl import load_workbook


def process_excel(uploaded_file):

    workbook = load_workbook(
        uploaded_file,
        read_only=True,
    )

    worksheet = workbook.active

    for row in worksheet.iter_rows(
        values_only=True
    ):
        print(row)

Là encore, le traitement peut être déplacé dans une tâche asynchrone si le fichier est volumineux.


Upload de vidéos

Les vidéos changent complètement l’échelle du problème.

Une vidéo peut faire :

50 Mo
200 Mo
1 Go
5 Go

Il n’est généralement pas souhaitable de faire passer de très gros fichiers par le processus Django classique.

Une architecture adaptée peut être :

Client
  ↓
URL d'upload temporaire
  ↓
Stockage objet
  ↓
Traitement vidéo asynchrone
  ↓
Transcodage
  ↓
CDN

Django devient alors le gestionnaire de métadonnées et d’autorisation plutôt que le tuyau par lequel passent plusieurs gigaoctets.


Stocker les métadonnées séparément

Pour un média complexe, le modèle peut contenir :

class Media(models.Model):

    file = models.FileField(
        upload_to="media/"
    )

    original_name = models.CharField(
        max_length=255
    )

    mime_type = models.CharField(
        max_length=100
    )

    size = models.PositiveBigIntegerField()

    created_at = models.DateTimeField(
        auto_now_add=True
    )

On peut alors afficher :

video.mp4
Taille : 184 Mo
Type : video/mp4

sans devoir ouvrir le fichier à chaque affichage.


Attention à la cohérence entre base de données et stockage

Une opération d’upload peut échouer à différents moments.

Par exemple :

1. Création du fichier
2. Sauvegarde du modèle
3. Erreur réseau

ou :

1. Sauvegarde du modèle
2. Échec du stockage

Cela peut créer une incohérence.

C’est pourquoi les systèmes de production complexes doivent réfléchir à la gestion des erreurs et à la cohérence entre la base de données et le stockage.

Pour les opérations simples, Django gère une grande partie de la logique de manière satisfaisante. Pour des workflows distribués, il peut être nécessaire d’introduire des statuts.

Par exemple :

class Media(models.Model):

    STATUS_CHOICES = [
        ("pending", "En attente"),
        ("processing", "Traitement"),
        ("ready", "Prêt"),
        ("failed", "Échec"),
    ]

    status = models.CharField(
        max_length=20,
        choices=STATUS_CHOICES,
        default="pending",
    )

Les fichiers orphelins : un problème sous-estimé

Imaginez une application qui fonctionne depuis trois ans.

La base de données contient :

1 000 000 objets

Le stockage contient :

1 700 000 fichiers

Pourquoi ?

Parce que des fichiers ont été remplacés, des objets ont été supprimés, des uploads ont été interrompus, des traitements ont échoué ou des migrations ont laissé des anciennes versions.

Sans stratégie de nettoyage, le stockage peut continuer à augmenter.

Il peut donc être intéressant de créer des tâches périodiques de maintenance.

Par exemple :

Chaque nuit :
    rechercher les fichiers temporaires expirés
    supprimer les fichiers temporaires
    vérifier certains objets orphelins
    nettoyer les anciennes versions

Fichiers temporaires

Pour les gros traitements, il est parfois préférable d’utiliser un répertoire temporaire.

Python fournit :

import tempfile

Par exemple :

with tempfile.NamedTemporaryFile(
    suffix=".jpg"
) as temp_file:

    temp_file.write(data)
    temp_file.flush()

    process_image(temp_file.name)

Cela évite de créer manuellement des chemins temporaires difficiles à gérer.


Tester les uploads

Les uploads doivent être testés comme n’importe quelle fonctionnalité importante.

Django fournit SimpleUploadedFile.

from django.core.files.uploadedfile import (
    SimpleUploadedFile
)


def test_document_upload(client):

    file = SimpleUploadedFile(
        "test.txt",
        b"Contenu du document",
        content_type="text/plain",
    )

    response = client.post(
        "/documents/upload/",
        {
            "title": "Document test",
            "file": file,
        },
    )

    assert response.status_code == 302

Cette technique est extrêmement utile pour les tests automatisés.


Tester une image

Pour une image réelle, on peut générer une petite image avec Pillow dans les tests :

from io import BytesIO

from PIL import Image
from django.core.files.uploadedfile import (
    SimpleUploadedFile
)


def create_test_image():

    image = Image.new(
        "RGB",
        (100, 100),
        color="white",
    )

    buffer = BytesIO()

    image.save(
        buffer,
        format="JPEG",
    )

    buffer.seek(0)

    return SimpleUploadedFile(
        "test.jpg",
        buffer.read(),
        content_type="image/jpeg",
    )

Puis :

def test_image_upload(client):

    image = create_test_image()

    response = client.post(
        "/products/create/",
        {
            "name": "Produit test",
            "image": image,
        },
    )

    assert response.status_code == 302

Tester les limites de taille

Un bon test doit également vérifier le refus d’un fichier trop volumineux.

On peut créer un fichier de test :

large_file = SimpleUploadedFile(
    "large.txt",
    b"x" * (6 * 1024 * 1024),
    content_type="text/plain",
)

Puis vérifier que le formulaire est invalide :

form = DocumentForm(
    data={
        "title": "Test",
    },
    files={
        "file": large_file,
    },
)

assert not form.is_valid()

Cette approche permet d’éviter qu’une modification future du code supprime accidentellement une validation importante.


Tester les permissions

Un test important est de vérifier qu’un utilisateur ne peut pas supprimer le fichier d’un autre utilisateur.

def test_user_cannot_delete_other_user_document(
    client,
    user_a,
    user_b,
    document,
):

    client.force_login(user_b)

    response = client.post(
        f"/documents/{document.pk}/delete/"
    )

    assert response.status_code == 404

Ce type de test est souvent plus important que l’apparence du formulaire.


Gérer les erreurs d’upload

Un upload peut échouer.

Le disque peut être plein.

Le stockage cloud peut être temporairement indisponible.

Le fichier peut dépasser une limite.

L’utilisateur peut perdre sa connexion.

Le traitement peut échouer.

Il est donc préférable de prévoir des messages clairs :

try:
    form.save()

except Exception:
    form.add_error(
        "file",
        "Impossible d'enregistrer le fichier."
    )

Il faut cependant éviter de capturer Exception partout sans stratégie de logging.

Dans un environnement réel, les erreurs inattendues doivent être enregistrées pour permettre leur diagnostic.


Logging des opérations médias

Pour les systèmes sensibles, il peut être utile de journaliser :

user_id
action
file_id
filename
size
timestamp
status

Par exemple :

import logging

logger = logging.getLogger(__name__)


logger.info(
    "File uploaded",
    extra={
        "user_id": request.user.id,
        "filename": uploaded_file.name,
        "size": uploaded_file.size,
    },
)

Attention toutefois à ne pas enregistrer de données sensibles inutilement.


Nettoyer les noms de fichiers

Même si Django fournit une gestion sûre des noms dans son système de stockage, il est souvent intéressant de normaliser les noms.

Par exemple :

from pathlib import Path
import unicodedata


def normalize_filename(filename):

    path = Path(filename)

    stem = unicodedata.normalize(
        "NFKD",
        path.stem,
    )

    stem = "".join(
        char
        for char in stem
        if not unicodedata.combining(char)
    )

    return f"{stem}{path.suffix.lower()}"

Dans de nombreux cas, cependant, une stratégie UUID est encore plus simple.


Architecture recommandée pour une petite application

Pour un petit projet Django, inutile de compliquer les choses.

Une architecture raisonnable :

Django
├── Base de données
└── MEDIA_ROOT
    ├── avatars/
    ├── products/
    ├── documents/
    └── galleries/

Avec :

MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

Cette architecture est facile à comprendre, facile à sauvegarder et suffisante pour beaucoup de projets.


Architecture recommandée pour une application moyenne

Lorsque le trafic augmente :

Django
    ↓
Object Storage
    ↓
CDN

Django conserve les références.

Le stockage conserve les fichiers.

Le CDN les distribue.

Les traitements lourds passent par des workers.

On peut avoir :

Django
   │
   ├── PostgreSQL
   │
   ├── Redis
   │
   └── Object Storage
          │
          └── CDN

Et pour les traitements :

Django
   ↓
Celery
   ↓
Image processing
   ↓
Storage

Cette architecture évolue beaucoup mieux.


Organiser les fichiers par domaine métier

Au lieu de mettre tout dans :

media/uploads/

préférez une organisation métier :

media/
├── users/
├── products/
├── articles/
├── invoices/
├── galleries/
└── messages/

Cela rend les opérations de maintenance beaucoup plus faciles.

On peut également aller plus loin :

media/
├── users/42/avatar/
├── users/42/documents/
├── products/150/images/
└── articles/90/covers/

Cette organisation reflète la logique métier.


Ne pas exposer inutilement les IDs

Organiser les fichiers par identifiant n’est pas forcément un problème :

products/42/image.jpg

Mais il ne faut pas considérer ce chemin comme un mécanisme de sécurité.

La sécurité doit venir des permissions.

Un utilisateur ne doit pas obtenir un accès à un fichier simplement parce qu’il devine :

products/43/image.jpg

Les permissions et le stockage privé sont là pour cela.


Gestion des médias dans l’administration Django

Django Admin facilite énormément la gestion des fichiers.

Avec :

@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = [
        "name",
        "image",
    ]

On peut gérer les images depuis l’interface.

Pour une meilleure expérience, on peut créer un affichage :

from django.utils.html import format_html


@admin.display(description="Image")
def image_preview(self, obj):

    if not obj.image:
        return "-"

    return format_html(
        '<img src="{}" width="100" />',
        obj.image.url,
    )

Puis :

class ProductAdmin(admin.ModelAdmin):

    list_display = [
        "name",
        "image_preview",
    ]

Il faut naturellement être prudent lorsqu’on construit du HTML dans l’administration.


Ajouter un texte alternatif aux images

Un bon système média ne doit pas oublier l’accessibilité.

Le modèle peut contenir :

class Product(models.Model):

    name = models.CharField(
        max_length=200
    )

    image = models.ImageField(
        upload_to="products/"
    )

    image_alt = models.CharField(
        max_length=255,
        blank=True,
    )

Puis :

<img
    src="{{ product.image.url }}"
    alt="{{ product.image_alt }}"
>

Cela permet de fournir une description adaptée.


Images responsives

Pour les sites web modernes, il est intéressant de générer plusieurs tailles.

Par exemple :

product-small.webp
product-medium.webp
product-large.webp

Puis utiliser :

<img
    src="{{ product.image_medium.url }}"
    srcset="
        {{ product.image_small.url }} 400w,
        {{ product.image_medium.url }} 800w,
        {{ product.image_large.url }} 1600w
    "
    sizes="
        (max-width: 600px) 100vw,
        (max-width: 1200px) 50vw,
        33vw
    "
    alt="{{ product.image_alt }}"
>

Le navigateur peut alors choisir une ressource adaptée.


Médias et SEO

Les images ont également un rôle SEO.

Une bonne gestion comprend :

nom pertinent
alt descriptif
dimensions adaptées
poids réduit
format moderne
lazy loading
URL stable

Dans un template :

<img
    src="{{ article.cover.url }}"
    alt="{{ article.cover_alt }}"
    loading="lazy"
    width="1200"
    height="630"
>

Les dimensions explicites permettent également de réduire certains problèmes de mise en page lors du chargement.


Lazy loading

Pour les longues galeries :

<img
    src="{{ image.image.url }}"
    alt="{{ image.caption }}"
    loading="lazy"
>

Le navigateur peut différer le chargement des images qui ne sont pas immédiatement visibles.

Cela améliore souvent les performances des pages riches en médias.


Ne pas générer les miniatures à chaque requête

Une erreur classique serait :

def product_detail(request, pk):

    product = get_object_or_404(
        Product,
        pk=pk,
    )

    create_thumbnail(product.image)

    return render(...)

Cela signifie que chaque visite peut provoquer un traitement d’image.

Pour un site populaire, cela devient catastrophique.

Il vaut mieux générer les variantes :

  • au moment de l’upload ;

  • après l’upload avec une tâche asynchrone ;

  • ou à la demande avec un système de cache adapté.


Cache des médias

Les fichiers médias sont généralement très bien adaptés au cache.

Un CDN peut conserver :

image.webp

pendant une longue durée.

Cela réduit les requêtes vers le stockage.

Mais il faut réfléchir à la stratégie lorsque le fichier est remplacé.

Si l’URL reste :

products/image.jpg

un cache peut continuer à servir l’ancienne version.

Une stratégie consiste à utiliser des noms de fichiers uniques ou versionnés :

products/image-v2.jpg

ou :

products/8f3d....jpg

Ainsi, chaque nouvelle version possède une nouvelle URL.


Pourquoi les UUID sont particulièrement utiles

Les UUID ne servent pas uniquement à éviter les collisions.

Ils permettent également de rendre les ressources moins prévisibles.

Au lieu de :

uploads/1.jpg
uploads/2.jpg
uploads/3.jpg

on peut avoir :

uploads/
a2f4...jpg
b81c...jpg
d99a...jpg

Attention cependant : un nom difficile à deviner n’est pas une permission.

Si le fichier est privé, il faut toujours appliquer un contrôle d’accès.


Stratégie de stockage pour Docker

Avec Docker, un problème courant est de stocker les médias dans le filesystem interne du conteneur.

Par exemple :

Container
└── /app/media

Si le conteneur est supprimé, les fichiers peuvent disparaître.

Une solution de développement consiste à utiliser un volume :

services:

  web:
    build: .
    volumes:
      - media_data:/app/media

volumes:
  media_data:

En production, un stockage objet est souvent plus adapté.


Sauvegarder les fichiers médias

Sauvegarder uniquement PostgreSQL ou MySQL ne suffit pas.

Si la base contient :

documents/rapport.pdf

mais que le fichier est perdu, la référence en base devient inutile.

Une stratégie de sauvegarde doit donc couvrir :

Base de données
+
Fichiers médias

Pour un stockage objet, on peut utiliser les mécanismes de réplication, versioning ou sauvegarde du fournisseur.


Versioning des fichiers

Certains systèmes de stockage permettent de conserver plusieurs versions d’un même objet.

C’est intéressant pour les documents importants.

Par exemple :

contrat.pdf
version 1
version 2
version 3

Mais cela augmente également le coût du stockage.

Il faut donc définir une politique :

conserver 30 jours
conserver les 5 dernières versions
supprimer les anciennes versions

Upload et quotas utilisateurs

Une application SaaS peut imposer un quota.

Par exemple :

Plan gratuit :
100 Mo

Plan Pro :
10 Go

Plan Business :
100 Go

Le modèle utilisateur peut avoir :

class StorageQuota(models.Model):

    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    limit = models.PositiveBigIntegerField(
        default=100 * 1024 * 1024
    )

    used = models.PositiveBigIntegerField(
        default=0
    )

Lorsqu’un fichier est envoyé :

if quota.used + uploaded_file.size > quota.limit:
    raise ValidationError(
        "Votre espace de stockage est insuffisant."
    )

Cette logique devient très importante pour éviter qu’un utilisateur monopolise l’espace disponible.


Attention aux conditions de concurrence sur les quotas

Dans une application à fort trafic, deux uploads peuvent arriver simultanément.

Si le quota est de 100 Mo et que deux fichiers de 60 Mo arrivent en même temps, une logique naïve peut accepter les deux.

On dépasse alors le quota.

Pour les applications avancées, il faut utiliser des transactions, verrous ou mécanismes atomiques adaptés.

Cela montre encore une fois que la gestion des fichiers devient progressivement un problème d’architecture.


Stocker la taille du fichier

Même si Django peut obtenir :

file.size

il peut être pratique de conserver la taille dans la base :

size = models.PositiveBigIntegerField()

Puis :

document.size = document.file.size

On peut ainsi faire rapidement :

Document.objects.aggregate(
    total=Sum("size")
)

sans devoir interroger le stockage fichier par fichier.


Calculer un hash du fichier

Pour détecter les doublons, on peut calculer un hash.

Par exemple SHA-256 :

import hashlib


def calculate_hash(file):

    sha256 = hashlib.sha256()

    for chunk in file.chunks():
        sha256.update(chunk)

    return sha256.hexdigest()

On peut ensuite stocker :

sha256 = models.CharField(
    max_length=64,
    db_index=True,
)

Cela permet de détecter que deux fichiers ont exactement le même contenu.

Attention : le hash doit être calculé correctement sans consommer inutilement la mémoire.


Déduplication des fichiers

Supposons que 10 utilisateurs envoient exactement le même PDF.

Sans déduplication :

10 copies

Avec une stratégie de stockage partagé :

1 fichier
10 références

Cette optimisation peut économiser beaucoup d’espace dans certaines applications.

Mais elle augmente la complexité de la gestion de suppression.

Si l’utilisateur A supprime sa référence, le fichier physique ne doit être supprimé que si aucune autre référence ne l’utilise.


Une architecture de média avec référence séparée

Pour les plateformes complexes, on peut avoir :

class StoredFile(models.Model):

    key = models.CharField(
        max_length=500,
        unique=True,
    )

    size = models.PositiveBigIntegerField()

    sha256 = models.CharField(
        max_length=64,
        unique=True,
    )

Puis d’autres modèles font référence au fichier :

class Document(models.Model):

    title = models.CharField(
        max_length=255
    )

    stored_file = models.ForeignKey(
        StoredFile,
        on_delete=models.PROTECT,
    )

Cette architecture est plus complexe qu’un simple FileField, mais elle devient intéressante pour les plateformes de gestion documentaire avancées.


Upload sécurisé : checklist mentale

Avant d’autoriser un type de fichier, posez-vous toujours plusieurs questions.

Qui peut envoyer le fichier ?
Quelle taille maximale ?
Quels types sont autorisés ?
Le contenu est-il réellement valide ?
Le fichier sera-t-il public ?
Comment sera-t-il servi ?
Peut-il contenir du code actif ?
Faut-il une analyse antivirus ?
Où sera-t-il stocké ?
Combien de temps sera-t-il conservé ?
Comment sera-t-il supprimé ?
Comment sera-t-il sauvegardé ?

Cette réflexion est beaucoup plus importante que le simple fait de savoir utiliser FileField.


Exemple complet : système de documents

Voici un exemple regroupant plusieurs bonnes pratiques de base :

from django.conf import settings
from django.core.validators import (
    FileExtensionValidator,
)
from django.db import models


def document_upload_path(instance, filename):

    return (
        f"users/"
        f"{instance.user_id}/"
        f"documents/"
        f"{filename}"
    )


class Document(models.Model):

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="documents",
    )

    title = models.CharField(
        max_length=255
    )

    file = models.FileField(
        upload_to=document_upload_path,
        validators=[
            FileExtensionValidator(
                allowed_extensions=[
                    "pdf",
                    "docx",
                    "xlsx",
                    "txt",
                ]
            )
        ],
    )

    original_name = models.CharField(
        max_length=255,
        blank=True,
    )

    size = models.PositiveBigIntegerField(
        default=0
    )

    created_at = models.DateTimeField(
        auto_now_add=True
    )

    def save(self, *args, **kwargs):

        if self.file:
            self.size = self.file.size

            if not self.original_name:
                self.original_name = (
                    self.file.name
                )

        super().save(*args, **kwargs)

Ce modèle fournit déjà une bonne base pour une application documentaire.


Formulaire complet

from django import forms

from .models import Document


class DocumentForm(forms.ModelForm):

    class Meta:
        model = Document

        fields = [
            "title",
            "file",
        ]

    def clean_file(self):

        file = self.cleaned_data["file"]

        max_size = 10 * 1024 * 1024

        if file.size > max_size:
            raise forms.ValidationError(
                "Le fichier ne peut pas dépasser 10 Mo."
            )

        return file

Vue :

from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render


@login_required
def upload_document(request):

    if request.method == "POST":

        form = DocumentForm(
            request.POST,
            request.FILES,
        )

        if form.is_valid():

            document = form.save(
                commit=False
            )

            document.user = request.user

            document.save()

            return redirect(
                "document-list"
            )

    else:

        form = DocumentForm()

    return render(
        request,
        "documents/upload.html",
        {
            "form": form,
        },
    )

Template :

{% extends "base.html" %}

{% block content %}

<h2>Ajouter un document</h2>

<form
    method="post"
    enctype="multipart/form-data"
>
    {% csrf_token %}

    {{ form.non_field_errors }}

    <div>
        {{ form.title.label_tag }}
        {{ form.title }}

        {{ form.title.errors }}
    </div>

    <div>
        {{ form.file.label_tag }}
        {{ form.file }}

        {{ form.file.errors }}
    </div>

    <button type="submit">
        Envoyer le document
    </button>
</form>

{% endblock %}

Liste des documents

Vue :

@login_required
def document_list(request):

    documents = Document.objects.filter(
        user=request.user
    ).order_by("-created_at")

    return render(
        request,
        "documents/list.html",
        {
            "documents": documents,
        },
    )

Template :

{% for document in documents %}

    <article>

        <h3>
            {{ document.title }}
        </h3>

        <p>
            {{ document.original_name }}
        </p>

        <p>
            {{ document.size }} octets
        </p>

        <a
            href="{% url 'document-download' document.pk %}"
        >
            Télécharger
        </a>

    </article>

{% empty %}

    <p>
        Aucun document.
    </p>

{% endfor %}

Télécharger un document de manière sécurisée

from django.contrib.auth.decorators import (
    login_required,
)
from django.http import FileResponse
from django.shortcuts import get_object_or_404


@login_required
def download_document(request, pk):

    document = get_object_or_404(
        Document,
        pk=pk,
        user=request.user,
    )

    return FileResponse(
        document.file.open("rb"),
        as_attachment=True,
        filename=document.original_name,
    )

Cette vue est volontairement simple.

Elle vérifie que le document appartient à l’utilisateur connecté avant d’ouvrir le fichier.


Supprimer un document

from django.views.decorators.http import (
    require_POST,
)


@login_required
@require_POST
def delete_document(request, pk):

    document = get_object_or_404(
        Document,
        pk=pk,
        user=request.user,
    )

    document.file.delete(
        save=False
    )

    document.delete()

    return redirect(
        "document-list"
    )

L’ordre est volontaire :

document.file.delete(save=False)
document.delete()

On supprime explicitement le fichier puis l’objet.

Dans des architectures plus avancées, cette logique peut être déplacée dans un service ou gérée par des signaux selon les besoins.


Créer un service d’upload

Dans les projets importants, il peut être préférable de ne pas mettre toute la logique dans les vues.

On peut créer :

services/
└── media.py

Puis :

from .models import Document


class DocumentService:

    @staticmethod
    def create(
        *,
        user,
        title,
        uploaded_file,
    ):

        document = Document(
            user=user,
            title=title,
            file=uploaded_file,
            original_name=uploaded_file.name,
            size=uploaded_file.size,
        )

        document.save()

        return document

La vue devient :

document = DocumentService.create(
    user=request.user,
    title=form.cleaned_data["title"],
    uploaded_file=form.cleaned_data["file"],
)

Cette séparation devient utile lorsque la logique d’upload comprend :

  • validation ;

  • antivirus ;

  • génération de miniature ;

  • calcul de hash ;

  • notifications ;

  • stockage cloud ;

  • quotas ;

  • logs ;

  • traitement asynchrone.


Pourquoi une couche service peut être utile

Au début, on peut tout mettre dans :

views.py

Puis le fichier grandit :

300 lignes
500 lignes
800 lignes

La vue finit par gérer :

authentification
validation
upload
stockage
hash
thumbnail
quota
notifications
logging

Cela devient difficile à maintenir.

Une couche service permet de centraliser le comportement métier tout en gardant les vues plus simples.


Gestion des médias et transactions

Il faut faire attention à une idée importante :

Une transaction SQL ne garantit pas nécessairement que le stockage fichier suit exactement la même transaction.

Par exemple :

with transaction.atomic():

    document = Document.objects.create(
        ...
    )

    document.file.save(
        "document.pdf",
        content,
    )

La base peut revenir en arrière alors qu’une opération de stockage externe a déjà eu lieu.

Dans des architectures simples, cela peut être acceptable.

Dans des systèmes critiques, il faut concevoir explicitement le workflow.

C’est là que les statuts, tâches asynchrones et processus de nettoyage deviennent intéressants.


Architecture avec statut d’upload

On peut utiliser :

class Upload(models.Model):

    STATUS_PENDING = "pending"
    STATUS_READY = "ready"
    STATUS_FAILED = "failed"

    STATUS_CHOICES = [
        (STATUS_PENDING, "En attente"),
        (STATUS_READY, "Prêt"),
        (STATUS_FAILED, "Échec"),
    ]

    status = models.CharField(
        max_length=20,
        choices=STATUS_CHOICES,
        default=STATUS_PENDING,
    )

    file = models.FileField(
        upload_to="uploads/"
    )

    error_message = models.TextField(
        blank=True
    )

Le système peut alors faire :

pending
   ↓
processing
   ↓
ready

ou :

pending
   ↓
processing
   ↓
failed

Cette architecture est beaucoup plus robuste pour les traitements complexes.


Gestion des fichiers dans une API moderne

Une API peut exposer :

{
    "id": 25,
    "title": "Rapport annuel",
    "file": "https://cdn.example.com/documents/report.pdf",
    "size": 5423321,
    "created_at": "2026-08-22T10:00:00Z"
}

Le frontend n’a pas besoin de connaître :

MEDIA_ROOT

ou :

upload_to

Il reçoit simplement une URL utilisable.

Cette abstraction est précisément l’un des grands avantages du système de stockage Django.


Préparer Django à plusieurs environnements

Une configuration bien structurée peut séparer :

settings/
├── base.py
├── development.py
└── production.py

Dans base.py :

MEDIA_URL = "/media/"

Dans le développement :

MEDIA_ROOT = BASE_DIR / "media"

En production :

DEFAULT_FILE_STORAGE = ...

ou la configuration de stockage moderne appropriée à votre version de Django et à votre backend.

Cette séparation permet d’éviter les conditions complexes partout dans le code.


Ne pas confondre collectstatic et médias

Une confusion fréquente consiste à lancer :

python manage.py collectstatic

et à s’attendre à ce que les fichiers utilisateurs soient déplacés.

Ce n’est pas l’objectif de collectstatic.

collectstatic concerne les fichiers statiques.

Les fichiers médias suivent leur propre stratégie.

Donc :

static → collectstatic → STATIC_ROOT
media  → stockage média → MEDIA_ROOT ou stockage distant

Cette distinction doit être très claire dans l’architecture.


Exemple de structure complète

Une application Django bien organisée peut ressembler à :

project/
├── config/
│   ├── settings/
│   │   ├── base.py
│   │   ├── development.py
│   │   └── production.py
│   └── urls.py
│
├── media_app/
│   ├── models.py
│   ├── forms.py
│   ├── serializers.py
│   ├── services.py
│   ├── views.py
│   ├── admin.py
│   ├── tasks.py
│   └── tests/
│
├── templates/
│   └── media/
│
├── static/
│
└── media/

Cette organisation devient beaucoup plus facile à maintenir lorsqu’un projet évolue.


Les erreurs classiques à éviter

La première erreur consiste à oublier :

enctype="multipart/form-data"

La deuxième consiste à oublier :

request.FILES

La troisième consiste à rendre tous les fichiers publics.

La quatrième consiste à faire confiance uniquement à l’extension.

La cinquième consiste à accepter des fichiers sans limite de taille.

La sixième consiste à stocker de gros médias directement sur le serveur applicatif en production.

La septième consiste à oublier les fichiers orphelins.

La huitième consiste à ne pas vérifier les permissions lors du téléchargement.

La neuvième consiste à mettre les clés cloud dans Git.

La dixième consiste à générer des miniatures lourdes pendant chaque requête.

La onzième consiste à sauvegarder la base de données sans sauvegarder les médias.

La douzième consiste à supposer qu’un nom de fichier secret constitue une sécurité.

La treizième consiste à traiter des vidéos comme de simples petites images.

La quatorzième consiste à mettre toute la logique média dans une seule vue gigantesque.


Une stratégie réaliste pour démarrer

Si vous développez actuellement une petite application Django, ne commencez pas avec une architecture extrêmement complexe.

Commencez par :

MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"

Puis :

image = models.ImageField(
    upload_to="images/"
)

Ajoutez :

enctype="multipart/form-data"

et :

request.FILES

Ensuite, ajoutez progressivement :

validation
permissions
limites de taille
nettoyage
tests
stockage externe
CDN
traitements asynchrones

Cette progression est beaucoup plus saine.


Quand passer à un stockage cloud ?

Il n’existe pas un nombre magique d’utilisateurs.

La question est plutôt :

Le filesystem local répond-il encore aux besoins de mon architecture ?

Si vous avez :

1 serveur
petite quantité de médias
peu de trafic
sauvegardes simples

le stockage local peut être parfaitement adapté.

Si vous avez :

plusieurs serveurs
Docker
autoscaling
CDN
gros fichiers
fort trafic

un stockage objet devient beaucoup plus intéressant.


Une architecture moderne complète

Pour une application importante, on peut imaginer :

                    ┌───────────────┐
                    │   Frontend    │
                    │ React/Next.js │
                    └───────┬───────┘
                            │
                            ▼
                    ┌───────────────┐
                    │     Django    │
                    │      API      │
                    └───────┬───────┘
                            │
             ┌──────────────┼──────────────┐
             ▼              ▼              ▼
        PostgreSQL        Redis         Storage
                                         Object
                                           │
                                           ▼
                                          CDN

Pour les traitements :

Storage
   ↓
Celery
   ↓
Pillow / FFmpeg / Antivirus
   ↓
Storage

Cette architecture peut sembler complexe, mais elle répond à des problèmes très concrets.


Django et FFmpeg pour les vidéos

Pour les vidéos, FFmpeg est souvent utilisé pour :

  • extraire une miniature ;

  • convertir un format ;

  • réduire la résolution ;

  • produire plusieurs qualités ;

  • extraire l’audio ;

  • obtenir la durée.

Une tâche Celery pourrait lancer un processus externe :

import subprocess


@shared_task
def process_video(video_id):

    video = Video.objects.get(
        pk=video_id
    )

    subprocess.run(
        [
            "ffmpeg",
            "-i",
            video.file.path,
            "-vf",
            "scale=1280:-1",
            "output.mp4",
        ],
        check=True,
    )

Il faut évidemment sécuriser soigneusement les arguments transmis à des programmes externes et ne jamais construire une commande shell à partir d’une entrée utilisateur non contrôlée.


Nettoyage périodique

Une tâche périodique peut supprimer les uploads temporaires :

@shared_task
def cleanup_expired_uploads():

    uploads = Upload.objects.filter(
        status="pending",
        created_at__lt=timezone.now() - timedelta(hours=24),
    )

    for upload in uploads:

        if upload.file:
            upload.file.delete(
                save=False
            )

        upload.delete()

Cela évite de conserver indéfiniment des fichiers issus de sessions abandonnées.


La confidentialité des documents

Pour les documents personnels, il est préférable d’adopter une approche stricte.

Évitez :

https://example.com/media/user-document.pdf

si n’importe qui peut accéder directement à cette URL.

Préférez :

GET /api/documents/42/download/

puis :

Django
   ↓
authentification
   ↓
autorisation
   ↓
URL temporaire

Cela permet de contrôler précisément l’accès.


Contrôle d’accès objet par objet

Il faut distinguer :

Authentifié

de :

Autorisé à accéder à ce fichier

Un utilisateur connecté n’a pas automatiquement le droit de lire tous les fichiers.

Une bonne règle :

document = get_object_or_404(
    Document,
    pk=pk,
    user=request.user,
)

est souvent beaucoup plus importante qu’un système sophistiqué de stockage.


Gestion des fichiers supprimés

Lorsqu’un utilisateur clique sur supprimer, plusieurs choses peuvent se produire :

1. supprimer la référence en base
2. supprimer le fichier
3. supprimer les miniatures
4. invalider le cache
5. mettre à jour le quota
6. enregistrer l'opération

Dans un système simple, tout peut être effectué directement.

Dans un système distribué, certaines opérations peuvent être asynchrones.

Par exemple :

delete database reference
       ↓
queue cleanup task
       ↓
delete storage object
       ↓
invalidate CDN

Gestion du quota après suppression

Si un utilisateur possède :

used = 500 MB

et supprime un fichier de :

100 MB

il faut mettre à jour :

used = 400 MB

Cette logique semble triviale mais peut devenir délicate si les suppressions sont concurrentes ou si des traitements échouent.

Dans les systèmes avancés, les quotas doivent donc être considérés comme une donnée métier à part entière.


Utiliser des signaux avec modération

Les signaux Django sont utiles pour :

cleanup
notifications
audit

mais il faut éviter de cacher une logique critique dans trop de signaux.

Si un développeur regarde :

document.delete()

et qu’une suppression complexe se produit ailleurs dans plusieurs signaux, le comportement devient difficile à comprendre.

Pour les workflows importants, une méthode explicite ou un service peut être plus lisible :

DocumentService.delete(document)

Une méthode de suppression explicite

class DocumentService:

    @staticmethod
    def delete(document):

        if document.file:
            document.file.delete(
                save=False
            )

        document.delete()

Puis :

DocumentService.delete(document)

Cette approche rend l’intention très claire.


L’importance des tests d’intégration

Les tests unitaires ne suffisent pas toujours.

Pour les médias, il est utile de tester le flux complet :

formulaire
   ↓
upload
   ↓
validation
   ↓
stockage
   ↓
base
   ↓
affichage
   ↓
téléchargement
   ↓
suppression

Cela permet de détecter les erreurs qui n’apparaissent pas lorsqu’on teste chaque composant séparément.


Monitoring du stockage

En production, il est utile de surveiller :

nombre d'uploads
taille moyenne
taille totale
taux d'échec
temps moyen d'upload
nombre de fichiers
erreurs stockage
coût cloud

Pour les applications importantes, les médias peuvent représenter une part significative de l’infrastructure.

Une bonne observabilité permet de voir les problèmes avant qu’ils ne deviennent coûteux.


Les médias sont une donnée métier

C’est probablement l’idée la plus importante de tout ce guide.

Un fichier n’est pas simplement :

quelque chose que l’utilisateur envoie

Dans une application réelle, un média possède souvent :

propriétaire
type
taille
statut
date
permissions
version
hash
URL
métadonnées
durée de vie

Une architecture sérieuse traite donc les fichiers comme une véritable donnée métier.


Exemple de modèle média plus avancé

class MediaFile(models.Model):

    STATUS_CHOICES = [
        ("pending", "En attente"),
        ("processing", "Traitement"),
        ("ready", "Prêt"),
        ("failed", "Échec"),
    ]

    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    file = models.FileField(
        upload_to="media/"
    )

    original_name = models.CharField(
        max_length=255
    )

    mime_type = models.CharField(
        max_length=100
    )

    size = models.PositiveBigIntegerField()

    checksum = models.CharField(
        max_length=64,
        blank=True,
    )

    status = models.CharField(
        max_length=20,
        choices=STATUS_CHOICES,
        default="pending",
    )

    created_at = models.DateTimeField(
        auto_now_add=True
    )

    updated_at = models.DateTimeField(
        auto_now=True
    )

Ce modèle est beaucoup plus riche qu’un simple :

file = models.FileField()

mais il correspond mieux aux besoins d’une plateforme documentaire ou média importante.


Une approche pragmatique pour les projets Django

Il est facile de tomber dans l’excès inverse et de construire dès le premier jour :

S3
CloudFront
Celery
Redis
FFmpeg
Antivirus
UUID
Hash
Versioning
CDN
Presigned URLs
Quotas

alors que l’application contient seulement dix utilisateurs.

Ce n’est pas nécessaire.

Une bonne architecture est une architecture proportionnée au problème.

Commencez avec :

FileField
MEDIA_ROOT
MEDIA_URL
validation
permissions
tests

Puis évoluez lorsque les contraintes apparaissent réellement.


Résumé des bonnes pratiques

Pour gérer correctement les uploads dans Django, retenez notamment les principes suivants :

1. Séparer static et media.
2. Utiliser FileField ou ImageField.
3. Configurer MEDIA_ROOT et MEDIA_URL.
4. Utiliser multipart/form-data.
5. Passer request.FILES aux formulaires.
6. Valider la taille.
7. Utiliser des listes blanches de formats.
8. Ne jamais faire confiance uniquement à l'extension.
9. Contrôler les permissions.
10. Protéger les fichiers privés.
11. Éviter les chemins construits manuellement.
12. Nettoyer les fichiers supprimés.
13. Penser aux fichiers orphelins.
14. Utiliser des noms uniques lorsque nécessaire.
15. Ne jamais exposer les secrets cloud dans Git.
16. Utiliser un stockage objet pour les architectures distribuées.
17. Utiliser un CDN pour les médias publics à fort trafic.
18. Déplacer les traitements lourds vers Celery.
19. Optimiser les images.
20. Tester les uploads.
21. Sauvegarder les médias.
22. Surveiller le stockage.
23. Gérer les quotas si nécessaire.
24. Traiter les gros fichiers différemment des petits.
25. Considérer les fichiers comme des données métier.

Conclusion

La gestion des fichiers dans Django commence avec quelque chose d’extrêmement simple : un FileField, un formulaire et quelques lignes de configuration. Et c’est précisément ce qui rend Django agréable : pour une petite application, on peut obtenir un système fonctionnel sans construire une infrastructure gigantesque.

Mais dès qu’une application commence à recevoir beaucoup d’images, des documents privés, des vidéos ou des fichiers volumineux, les véritables questions apparaissent. Où sont stockés les fichiers ? Qui peut y accéder ? Combien de temps sont-ils conservés ? Que se passe-t-il lorsqu’un utilisateur remplace son avatar ? Que devient l’ancien fichier ? Comment éviter qu’un utilisateur envoie 10 000 fichiers ? Comment gérer un serveur Docker qui disparaît ? Comment servir des images rapidement ? Comment éviter de faire transiter une vidéo de plusieurs centaines de mégaoctets par Django ? Comment sauvegarder les médias ? Comment empêcher qu’un fichier malveillant soit utilisé comme vecteur d’attaque ?

La réponse n’est pas de chercher une seule fonctionnalité magique.

La véritable solution consiste à construire progressivement une architecture cohérente.

Pour commencer, FileField, ImageField, MEDIA_ROOT, MEDIA_URL, les formulaires Django et request.FILES constituent une base excellente. Ensuite viennent la validation de taille, les extensions autorisées, les permissions, les noms uniques, le nettoyage des fichiers, les tests et la gestion des erreurs. Lorsque le projet grandit, le stockage objet, les CDN, les URLs temporaires, les tâches Celery, les miniatures, les quotas et la surveillance deviennent progressivement pertinents.

Il est également essentiel de ne pas oublier que stockage et sécurité sont deux sujets différents. Un fichier dont le nom est difficile à deviner n’est pas nécessairement privé. Une extension .jpg ne garantit pas qu’il s’agit d’une image. Un utilisateur authentifié n’a pas automatiquement accès aux fichiers des autres utilisateurs. Et une base de données correctement sauvegardée ne signifie pas que les fichiers médias le sont également.

L’un des meilleurs réflexes que l’on puisse développer avec Django est donc de penser au cycle de vie complet d’un fichier :

Upload
   ↓
Validation
   ↓
Autorisation
   ↓
Stockage
   ↓
Traitement
   ↓
Diffusion
   ↓
Mise en cache
   ↓
Remplacement éventuel
   ↓
Suppression
   ↓
Sauvegarde / nettoyage

Lorsque chaque étape est pensée correctement, les médias cessent d’être une source de problèmes et deviennent simplement une composante normale de l’application.

Et c’est probablement le meilleur conseil que l’on puisse donner à quelqu’un qui commence un projet Django : ne compliquez pas l’upload avant d’en avoir besoin, mais ne le considérez jamais comme un simple champ de formulaire. Une petite application peut parfaitement commencer avec quelques lignes de code et un dossier media/. Lorsque les besoins évoluent, Django fournit les abstractions nécessaires pour faire évoluer progressivement cette architecture vers du stockage distant, des CDN, des APIs modernes et des traitements asynchrones.

Le plus important est de garder une chose en tête : un fichier envoyé par un utilisateur n’est pas seulement un fichier. C’est une donnée qui a un propriétaire, une durée de vie, des permissions, une taille, un format, une valeur métier et parfois des exigences fortes de confidentialité. En le traitant comme tel dès que le projet prend de l’ampleur, on évite énormément de problèmes futurs et on construit une application Django beaucoup plus solide, sécurisée et agréable à maintenir.

#Django upload fichiers #Django upload images #Django FileField #Django ImageField #Django media files #Django MEDIA_ROOT #Django MEDIA_URL #Django fichiers médias #Django images #Django formulaire upload #Django fichiers statiques #Django Cloud Storage

Abonnez-vous à notre newsletter

12k+

Abonnés

Hebdomadaire

Fréquence

Gratuit

Toujours