Django

nora.nckm.eu

Illustration
Table des matières

Django est un framework de développement web en Python. La documentation officielle est très complète avec de nombreux guides et tutoriels. Le code source du projet décrit dans cet article est disponible sur sourcehut : django-sandbox ainsi qu'un modèle pour démarrer un nouveau projet : django-template (voir aussi cookiecutter-django).

Création d'un projet

Le terme projet décrit une application web Django.

En prérequis il faut installer le module django. On peut ensuite créer un nouveau projet avec la commande django-admin :

django-admin startproject project

La structure créée est la suivante :

project
├── manage.py
└── project
    ├── __init__.py
    ├── asgi.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py

Les scripts importants sont settings.py contenant la configuration, urls.py contenant les routes et manage.py permettant d'intéragir avec le projet.

Pour démarrer le serveur web de développement on lance dans un environnement virtuel :

python manage.py runserver

Ensuite le site est accessible via http://localhost:8000/ et l'administration via http://localhost:8000/admin/.

Pour que l'interface d'administration fonctionne il faut au préalable exécuter les migrations de base de données et créer un super utilisateur :

python manage.py migrate
python manage.py createsuperuser

Création d'une application

Le terme application décrit un module réutilisable qui fournit un ensemble de fonctionnalités.

On crée une nouvelle application avec la commande :

python manage.py startapp app

La structure créée est la suivante :

app
├── migrations  # scripts base de données
│   └── __init__.py
├── __init__.py
├── admin.py    # administration
├── apps.py     # application
├── models.py   # modèles base de données
├── tests.py    # tests unitaires
└── views.py    # vues

Configuration

La configuration du projet est située dans le fichier settings.py.

LANGUAGE_CODE = 'fr-FR'

ROOT_URLCONF = 'project.urls'

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'app',
]

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

STATICFILES_DIRS = [
    BASE_DIR / 'project/static'
]

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

Par défaut, les templates et les fichiers statiques doivent être situés dans les répertoires templates et static d'une application. Pour gérer ces éléments à la racine du projet il faut l'indiquer dans la configuration avec les propriétés TEMPLATES['DIRS'] et STATICFILES_DIRS.

PostgreSQL

La base de données configurée par défaut est SQLite3. Pour utiliser PostgreSQL, il faut installer le package postgresql sous ArchLinux et le module psycopg2 dans l'environnement virtuel du projet. La configuration est la suivante :

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'mydatabase',
        'USER': 'mydatabaseuser',
        'PASSWORD': 'mypassword',
        'HOST': 'localhost',
        'PORT': '5432',
    }
}

Routes

Les routes font le lien entre les URL saisies dans le navigateur et les vues affichées en retour.

Les routes racines sont définies dans le fichier project/urls.py (propriété ROOT_URLCONF dans settings.py) :

from django.contrib import admin
from django.urls import path, include
from .views import index

urlpatterns = [
    path('', index, name="index"),
    path('app/', include("app.urls")),
    path('admin/', admin.site.urls),
]

Les routes de l'application sont définies dans le fichier app/urls.py :

from django.urls import path
from .views import index

urlpatterns = [
    path('', index, name="app-index"),
    path('<str:slug>/', index, name="app-index-slug"),
]

Vues

Les vues représentent l'interface utilisateur. Elles peuvent retourner plusieurs types de réponses HTTP et des fonctions raccourcies permettent de rediriger l'utilisateur ou d'utiliser un gabarit pour mettre en forme la réponse.

from django.http import HttpResponse, JsonResponse, Http404
from django.shortcuts import render, redirect, get_object_or_404

def index(request):

    # Réponses HTTP
    return HttpResponse("<h1>Bonjour tout le monde !</h1>")
    return JsonResponse({"1": "Bonjour tout le monde !"})
    raise Http404("La page n'existe pas.")

    # Fonctions raccourcies
    return render(request, "website/index.html", context={"title": "Mon Site"}) # équivaut à HttpResponse(render_to_string(...))
    return redirect("index") # redirige vers la vue nommée index
    get_object_or_404(BlogPost, pk=3) # retourne un objet ou déclenche une erreur 404

Les vues racines sont définies dans le fichier project/views.py. Dans l'exemple suivant, l'accès aux vues peut être restreint grâce au décorations @login_required et @user_passes_test. Si l'utilisateur n'est pas connecté il est automatiquement redirigé vers la page accounts/login.

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

@login_required
@user_passes_test(lambda u: u.username == "test")
def index(request):
    return render(request, "project/index.html", context={"title": "Mon Site"})

Les vues de l'application sont définies dans le fichier app/views.py. Dans l'exemple suivant, une erreur 404 est retournée si l'objet recherché n'existe pas dans la base de données.

from django.shortcuts import render, get_object_or_404
from app.models import BlogPost

def index(request, slug=None):
    if slug:
        post = get_object_or_404(BlogPost, slug=slug)
        return render(request, "app/index.html", context={"post": post})
    posts = BlogPost.objects.all()
    return render(request, "app/index.html", context={"posts": posts})

Gabarits

Les gabarits sont utilisés par les vues pour générer des pages HTML. Un gabarit contient des variables qui sont remplacées par des valeurs lorsque le gabarit est évalué, ainsi que des balises qui contrôlent la logique du gabarit. La liste complète des balises et des filtres est détaillée dans la documentation. La syntaxe est la suivante :

Le gabarit project/templates/project/index.html est utilisé par la vue index :

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>{{ title }}</title>
</head>
<body>
    <h1>Bonjour tout le monde !</h1>
</body>
</html>

Le gabarit app/templates/app/base.html :

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    {% block title %}{% endblock %}
</head>
<body>
    {% block content %}

    {% endblock %}
</body>
</html>

Le gabarit app/templates/app/index.html est utilisé par la vue app-index, il étend base.html :

{% extends 'app/base.html' %}

{% block title %}
    <title>Mon App</title>
{% endblock %}

{% block content %}
    <h1>Blog</h1>
    {% if post %}
        <h2>{{ post.title }}</h2>
        <small>{{ post.create_date|date:"d F Y H:i:s" }}</small>
        <p>{{ post.content }}</p>
    {% elif posts %}
        <p>Le blog contient {{ posts.count }} articles</p>
        {% for post in posts %}
        {% with categories=post.category.count %}
            <ul>
                <li>
                    <a href="{{ post.get_absolute_url }}">
                        {{ forloop.counter }}. {{ post.title }} ({{ categories }})
                    </a>
                    <p>
                        {{ post.content|striptags|safe|truncatewords:50 }}
                    </p>
                </li>
            </ul>
        {% endwith %}
        {% empty %}
            <p>Aucun article</p>
        {% endfor %}
    {% else %}
        <p>Aucun article</p>
    {% endif %}
{% endblock %}

En plus de l'utilisation de balises telles que des conditions et des boucles, il contient des expressions comme post.content|truncatewords:100|striptags|safe qui affiche la variable post.content, tronque les 100 premiers caractères, supprime les balises HTML et bloque l'échappement des caractères spéciaux.

On peut ajouter des arguments à une URL comme ceci :

{% url 'index' val1 val2 %}
{% url 'index' arg1=val1 arg2=val2 %}

Le fichier base.html contient la structure générale du site. Il est étendu par les autres pages du site qui redéfinissent les blocs de code au besoin. On peut aussi inclure des blocs de code utilisés de façon répétitive avec le mot clé include :

{% include 'accounts/address.html' %}

Fichiers statiques et dynamiques

Les fichiers statiques sont chargés de la manière suivante dans un template :

{% load static %}
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="{% static 'css/style.css' %}">
</head>
</html>

Astuce : installer le middleware django-browser-reload pour que les changements dans les fichiers statiques soient visibles en direct. Dans le fichier settings.py :

INSTALLED_APPS = [
    ...
    'django_browser_reload',
]

MIDDLEWARE = [
    ...
    "django_browser_reload.middleware.BrowserReloadMiddleware",
]

Ajouter la route suivante dans le fichier urls.py :

urlpatterns = [
    ...
    path("__reload__/", include("django_browser_reload.urls")),
]

Puis lancer le serveur Django normalement :

python manage.py runserver

Le chemin du répertoire STATIC_ROOT et l'URL d'accès STATIC_URL sont configurés dans settings.py :

STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'static'

Attention : avant de déployer une application sur un serveur de production, il faut collecter les fichiers statiques dans un unique répertoire avec la commande python manage.py collectstatic.

Les fichiers dynamiques peuvent être modifiés dynamiquement par l'application. Il s'agit par exemple d'une image envoyée au serveur via un formulaire. Dans ce cas il faut installer le module pillow et ajouter un champ dans le modèle models.py (accessible dans les vues avec la variable post.thumbnail.url) :

thumbnail = models.ImageField(blank=True, upload_to="blog")

Comme pour les fichiers statiques, le chemin du répertoire MEDIA_ROOT et l'URL d'accès MEDIA_URL sont configurés dans settings.py :

MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'

Attention : en développement les routes doivent être ajoutées dans urls.py :

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

urlpatterns = [...] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Modèles

Les modèles représentent les tables d'une base de données en Python.

Les modèles de l'application sont définies dans le fichier app/models.py.

from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from django.utils.text import slugify

User = get_user_model()

class Category(models.Model):
    name = models.CharField(max_length=50)
    slug = models.SlugField()

class BlogPost(models.Model):
    title = models.CharField(max_length=255, unique=True, verbose_name="Titre")
    slug = models.SlugField(max_length=255, unique=True, blank=True)
    author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
    update_date = models.DateTimeField(auto_now=True)
    create_date = models.DateField(blank=True, null=True)
    published = models.BooleanField(default=False, verbose_name="Publié")
    content = models.TextField(blank=True, verbose_name="Contenu")
    category = models.ManyToManyField(Category)
    thumbnail = models.ImageField(blank=True, upload_to="blog")

    class Meta:
        verbose_name = "Article"
        verbose_name_plural = "Tous les articles"
        ordering = ["-create_date"]

        db_table = "table_name"
        db_tablespace = "tablespace_name"

        indexes = [
            models.Index(fields=['last_name', 'first_name']),
            models.Index(fields=['first_name'], name='first_name_idx'),
        ]
        constraints = [
            models.CheckConstraint(check=models.Q(age__gte=18), name='age_gte_18'),
        ]

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

    def get_absolute_url(self):
        return reverse("app-index-slug", kwargs={"slug": self.slug})

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)

    @property
    def word_count(self):
        return len(self.content.split())

La méthode save est surchargée pour générer la valeur de l'attribut slug lorsque l'objet est persisté. La propriété word_count et la méthode get_absolute_url ont été ajoutées pour personnaliser l'affichage dans l'interface d'administration.

D'autres éléments du modèle (tablename, tablespace, indexes, constraints...) peuvent être modifiés grâce à la classe interne Meta.

blank et null

Par défaut les champs du modèle sont obligatoires. Les paramètres blank=True et null=True permettent respectivement d'autoriser les valeurs vides dans le formulaire et les valeurs null dans la base de données.

Lorsqu'on fait des modifications dans les modèles, il faut ensuite mettre à niveau la base de données. Cela ce fait avec les migrations. On peut voir la liste des migrations effectuées dans la table django_migrations.

  1. Générer les scripts de migration dans app/migrations :

    python manage.py makemigrations app
    
  2. Vérifier les migrations :

    python manage.py showmigrations
    python manage.py sqlmigrate app 0001_initial
    
  3. Appliquer les migrations dans la base de données :

    python manage.py migrate app
    

Manipulation des objets

Grâce aux modèles on peut manipuler des objets Python et les persister en base de données. C'est le principe d'un ORM (object-relational mapping) qui est une abstraction de la base de données relationnel en base orientée objet.

from app.models import BlogPost

# Création d'un objet (persisté avec save)
post = BlogPost(title="Titre 1", content="Contenu 1")
post.save()

# Création d'un objet (persisté automatiquement)
BlogPost.objects.create(title="Titre 2", content="Contenu 2")

# Sélection d'un objet
post = BlogPost.objects.get(pk=1) # équivaut à id=1
post = BlogPost.objects.get(title="Titre 1")

# Sélection de plusieurs objets dans un QuerySet
posts = BlogPost.objects.all()
posts = BlogPost.objects.last()
posts = BlogPost.objects.filter(published=True)
posts = BlogPost.objects.filter(pk__in=[1, 2, 3])
posts = BlogPost.objects.exclude(create_date__year="2022")
posts = BlogPost.objects.exclude(create_date__gt=datetime.date(2022, 2, 22))
posts = BlogPost.objects.order_by("title")
posts = BlogPost.objects.distinct("title")
posts = BlogPost.objects.count()

# Modification d'un objet
post.date = datetime.now()
post.published = True
post.save()

# Suppression d'un objet
post.delete()

# Suppression de plusieurs objets d'un QuerySet
posts.delete()
posts[0].delete()

Pour rappel les valeurs possibles de on_delete sur une clé étrangère sont :

Dans le cas des relations entres modèles les manipulations se font comme indiqué ci-dessous.

from django.contrib.auth.models import User
from app.models import BlogPost, Category

# Relation OneToMany : ForeignKey (persisté avec save)
user = User.objects.get(pk=1)
post.author = user
post.save()

# Relation ManyToMany (persisté automatiquement)
cat_python = Category.objects.get(slug="python")
cat_django = Category.objects.get(slug="django")
cat_sql = Category.objects.get(slug="sql")
post.category.set([cat_python, cat_java])
post.category.all()
post.category.add(cat_sql)
post.category.remove(cat_sql)
post.category.clear()

# Relations inverses
cat_python.blogpost_set.all()
user.blogpost_set.all()

La commande python manage.py shell permet de démarrer une console Python dans l'environnement de notre projet.

Formulaires

Les formulaires Django offrent la possibilité d'automatiser beaucoup d'aspects relatifs au formulaires. Le code HTML est généré, la validation et la persistance des données sont grandement simplifiées.

Les formulaires dans website/forms.py :

from django import forms
from blog.models import BlogPost

JOBS = (
    ("python", "Développeur Python"),
    ("java", "Développeur Java"),
    ("html/css", "Développeur HTML/CSS"),
)

class SignupForm(forms.Form):
    pseudo = forms.CharField(max_length=8, required=False)
    email = forms.EmailField()
    password = forms.CharField(min_length=6, widget=forms.PasswordInput())
    job = forms.ChoiceField(choices=JOBS)
    cgu_accept = forms.BooleanField(initial=True)

    def clean_pseudo(self):
        pseudo = self.cleaned_data.get("pseudo")
        if "$" in pseudo:
            raise forms.ValidationError("Le pseudo ne doit pas contenir de $.")
        return pseudo

class BlogPostForm(forms.ModelForm):
    class Meta:
        model = BlogPost
        #fields = "__all__"
        fields = ["title", "author", "create_date", "category", "content"]
        labels = {"title": "Titre", "category": "Catégorie"}
        widgets = {"create_date": forms.SelectDateWidget(years=range(2020, 2040))}

Le formulaire SignupForm est créé de toutes pièces alors que le formulaire BlogPostForm est lié à un modèle. La méthode clean_pseudo permet d'ajouter un contrôle sur le champ pseudo. La classe interne Meta permet de paramétrer l'affichage des champs issus du modèle.

Les vues dans website/views.py :

from datetime import datetime
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from website.forms import SignupForm, BlogPostForm

def signup(request):
    if request.method == "POST":
        form = SignupForm(request.POST)
        if form.is_valid():
            print(form.cleaned_data)
            return HttpResponse("Inscription terminée.")
    else:
        form = SignupForm()
    return render(request, "website/signup.html", {"form": form})

def post(request):
    if request.method == "POST":
        form = BlogPostForm(request.POST)
        if form.is_valid():
            form.save()
            return HttpResponseRedirect(request.path)
    else:
        init_values = {}
        if request.user.is_authenticated:
            init_values["author"] = request.user
        init_values["create_date"] = datetime.today()
        form = BlogPostForm(initial=init_values)
    return render(request, "website/post.html", {"form": form})

Dans le cas d'une requête de type GET, le formulaire est initialisé avec des valeurs par défault (request.user...) et passé au gabarit. Dans le cas d'une requête de type POST, le formulaire est initialisé avec les valeurs soumises par l'utilisateur (request.POST) et enregistré en base.

Astuce : il est possible de différer l'enregistrement du formulaire afin de modifier certaines de ses valeurs :

blog_post = form.save(commit=False)
blog_post.published = True
blog_post.save()

Les gabarits dans website/signup.html et website/post.html :

<!-- signup.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Création de compte</title>
</head>
<body>
    <h1>Création de compte</h1>
    <!--form method="POST" action="{% url 'index' %}"-->
    <form method="POST">
        {% csrf_token %}
        {{ form.as_p }}
        <input type="submit" value="Valider">
    </form>
</body>
</html>


<!-- post.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Création d'un article</title>
</head>
<body>
    <h1>Création d'un article</h1>
    <form method="POST">
        {% csrf_token %}
        {{ form.as_p }}
        <input type="submit" value="Créer">
    </form>
</body>
</html>

Le formulaire est généré avec la variable form et peut être formaté en tant que paragraphes, liste ou tableau avec as_p, as_ul ou as_table. La balise csrf_token permet d'envoyer un jeton de sécurité avec le formulaire pour s'assurer de sa provenance.

Et enfin les routes dans le fichier website/urls.py :

path('signup/', signup, name="signup"),
path('post/', post, name="app-post"),

Dans une vue affichant le profil de l'utilisateur, on peut initialiser le formulaire en fonction de la saisie de l'utilisateur :

form = UserForm(initial=model_to_dict(request.user, exclude="password"))

Formulaires groupés

Les formulaires groupés (FormSet) sont utiles lorsqu'on a plusieurs formulaires sur une page. L'exemple qui suit concerne la modification des quantités d'articles dans un panier. Dans le gabarit il faut penser à ajouter la variable forms.management_form qui ajoute les champs nécessaires à la gestion des formulaires groupés.

Le modèle :

from django import forms
from store.models import Order

class OrderForm(forms.ModelForm):
    quantity = forms.ChoiceField(choices=[(i, i) for i in range(1, 11)])

    class Meta:
        model = Order
        fields = ["quantity"]

La vue :

from django.forms import modelformset_factory
from store.forms import OrderForm

def cart(request):
    orders = Order.objects.filter(user=request.user, ordered=False)
    OrderFormSet = modelformset_factory(Order, form=OrderForm, extra=0)
    formset = OrderFormSet(queryset=orders)
    return render(request, 'store/cart.html', context={"forms": formset})

def update_quantities(request):
    orders = Order.objects.filter(user=request.user, ordered=False)
    OrderFormSet = modelformset_factory(Order, form=OrderForm, extra=0)
    formset = OrderFormSet(request.POST, queryset=orders)
    if formset.is_valid():
        formset.save()
    return redirect("store:cart")

Le gabarit :

<form action="{% url 'store:update-quantities' %}" method="POST">
    {% csrf_token %}
    {{ forms.management_form }}
    {% for form in forms %}
    <article>
        <h2>{{ form.instance.product.name }}</h2>
        <p>{{ form.instance.product.description }}</p>
        {{ form.as_p }}
    </article>
    {% endfor %}
    <button type="submit">Mettre à jour les quantités</button>
</form>

Astuce : personnalisation du rendu des formulaires avec les modules django-widget-tweaks et django-crispy-forms.

Utilisateurs et authentification

La classe User est le modèle par défaut pour gérer des utilisateurs. Elle peut être suffisante mais il est préférable de créer sa propre classe héritant de AbstractUser ou AbstractBaseUser selon le niveau de personnalisation souhaité.

La configuration dans settings.py :

AUTH_USER_MODEL = "accounts.CustomUser"

Le modèle dans accounts/models.py :

from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.contrib.auth.models import AbstractUser
from django.db import models

class CustomUserManager(BaseUserManager):
    def create_user(self, email, password=None, **kwargs):
        if not email:
            raise ValueError("Vous devez saisir un email")

        user = self.model(email=normalize_email(email), **kwargs)
        user.set_password(password)
        user.save()
        return user

    def create_superuser(self, email, password=None, **kwargs):
        kwargs["is_staff"] = True
        kwargs["is_superuser"] = True
        kwargs["is_active"] = True

        return self.create_user(email=email, password=password, **kwargs)

# Option 1, AbstractUser : extension de l'utilisateur par défaut
class CustomUser(AbstractUser):
    username = None
    email = models.EmailField(max_length=250, unique=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []
    objects = CustomUserManager()

# Option 2, AbstractBaseUser : remplacement de l'utilisateur par défaut
class CustomUser(AbstractBaseUser):
    email = models.EmailField(unique=True, blank=False, max_length=255)

    # Champs obligatoires pour l'interface d'administration
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    is_admin = models.BooleanField(default=False)

    zip_code = models.CharField(blank=True, max_length=5)

    USERNAME_FIELD = "email"
    #REQUIRED_FIELD = ["field"]
    objects = CustomUserManager()

    has_perm(self, perm, obj=None):
        return True

    has_module_perms(self, app_label):
        return True

# Gestion d'un profil d'utilisateur
class Profile(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)

def post_save_receiver(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)

post_save.connect(post_save_receiver, sender=settings.AUTH_USER_MODEL)

La vue d'inscription dans accounts/views.py avec un formulaire héritant de UserCreationForm :

from django.contrib.auth.forms import UserCreationForm
from accounts.models import CustomUser

class CustomSignupForm(UserCreationForm):
    class Meta:
        model = CustomUser

        # Option 1, AbstractUser
        fields = UserCreationForm.Meta.fields

        # Option 2, AbstractBaseUser
        fields = ("email", "zip_code")

def signup(request):
    if request.method == "POST":
        form = CustomSignupForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect("home")

    form = CustomSignupForm()
    return render(request, "accounts/signup.html", context={"form": form, "errors": form.errors})

Les vues de connexion et déconnexion, utilisant les fonctions authenticate, login et logout :

from django.contrib.auth import authenticate, login, logout

def login(request):
    if request.method == "POST":
        username = request.POST.get("username")
        password = request.POST.get("password")
        user = authenticate(request, username=username, password=password)
        if user:
            login(request, user)
            return redirect("home")

    return render(request, "registration/login.html")

def logout(request):
    logout(request)
    return redirect("home")

Les vues de connexion et déconnexion peuvent se baser sur les classes de Django. Il suffit alors d'inclure les routes dans accounts/urls.py :

path('compte/', include('django.contrib.auth.urls')),

Les gabarits correspondants doivent être ajoutés dans accounts/templates/registration/login.html et logout.html. Et la redirection doit être configurée dans settings.py :

LOGIN_REDIRECT_URL = 'home'

Interface d'administration

Les modèles peuvent être gérés dans l'interface d'administration de Django. Pour cela il faut les enregistrer dans le fichier app/admin.py. Ils sont ensuite disponibles sur http://localhost:8000/admin/.

from django.contrib import admin
from app.models import BlogPost, Category

# Enregistrement simple
admin.site.register(Category)

# Enregistrement avec options (sans décorateur)
admin.site.register(BlogPost, BlogPostAdmin)

# Enregistrement avec options (avec décorateur)
@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
    empty_value_display = "Inconnu"
    list_per_page = 20
    list_display = ("title", "published", "create_date", "update_date", "author", "word_count")
    list_editable = ("published",)
    list_display_links = ("title",)
    list_filter = ("published", "author")
    search_fields = ("title", "author")
    autocomplete_fields = ("author",)
    filter_horizontal = ("category",)

D'autres éléments de l'interface peuvent être personnalisés en modifiant le modèle (cf paragraphe Modèles) :

Il est également possible de modifier l'apparence de l'interface en surchargeant les fichiers HTML/CSS de Django. En créant par exemple un fichier website/templates/admin/base_site.html qui surcharge .env/lib/python3.10/site-packages/django/contrib/admin/templates/admin/base.html.

{% extends 'admin/base.html' %}
{% load static %}

{% block branding %}
    <h1>Mon Blog</h1>
{% endblock %}

{% block extrastyle %}
    <style>
        #header {
            background-color: SlateBlue;
        }
    </style>
{% endblock %}

Vues fondées sur les classes

Les vues fondées sur les classes (Class-Based Views) adoptent une approche orientée objet. Un ensemble de classes génériques qui peuvent être étendues et mixées pour construire des vues plus complexes. Une description détaillée est disponible sur le site Classy Class-Based Views.

Les étapes pour développer des fonctionnalités de type CRUD (Create, Read, Update, Delete) sont les suivantes.


Les routes dans le fichier app/urls.py :

from django.urls import path
from .views import BlogHome, BlogPostCreate, BlogPostUpdate, BlogPostDetail, BlogPostDelete

app_name = "blog"

urlpatterns = [
    path('blog/', BlogHome.as_view(), name='home'),
    path('blog/create/', BlogPostCreate.as_view(), name='create'),
    path('blog/edit/<str:slug>', BlogPostUpdate.as_view(), name='edit'),
    path('blog/delete/<str:slug>', BlogPostDelete.as_view(), name='delete'),
    path('blog/<str:slug>', BlogPostDetail.as_view(), name='post'),
]

La variable app_name permet de préfixer chaque route de l'application, par exemple : blog:home, blog:create... Les vues sont créées avec la méthode as_view.


Les vues dans le fichier app/views.py :

from django.contrib.auth.decorators import login_required
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from blog.models import BlogPost

class BlogHome(ListView):
    model = BlogPost
    context_object_name = "posts"

    def get_queryset(self):
        queryset = super().get_queryset()
        if self.request.user.is_authenticated:
            return queryset
        return queryset.filter(published=True)

class BlogPostDetail(DetailView):
    model = BlogPost
    context_object_name = "post"

@method_decorator(login_required, name="dispatch")
class BlogPostCreate(CreateView):
    model = BlogPost
    template_name = "blog/blogpost_create.html"
    fields = ["title", "content"]

@method_decorator(login_required, name="dispatch")
class BlogPostUpdate(UpdateView):
    model = BlogPost
    template_name = "blog/blogpost_edit.html"
    fields = ["title", "content", "published"]

@method_decorator(login_required, name="dispatch")
class BlogPostDelete(DeleteView):
    model = BlogPost
    context_object_name = "post"
    success_url = reverse_lazy("blog:home")

Les vues héritent des classes génériques de Django et sont liées au modèle BlogPost. Il est possible de modifier leur comportement en surchargeant certaines méthodes et attributs (cf Classy Class-Based Views). Les droits d'accès sont gérés avec le décorateur @method_decorator(login_required).


Le modèle dans le fichier app/models.py :

def get_absolute_url(self):
    return reverse("blog:home")

Le modèle est le même que celui du paragraphe Modèles. Seule l'url retournée par la méthode get_absolute_url diffère.


Les gabarits dans le répertoire app/templates/app/ :

<!-- base.html -->
{% load static %}
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    {% block title %}
    {% endblock %}
    <link rel="stylesheet" href="{% static 'css/style.css' %}">
</head>
<body>
    <header>
        <h1>Mon Blog</h1>
        <nav>
            <a href="{% url 'blog:home' %}">Accueil</a>
            {% if request.user.is_authenticated %}
                <a href="{% url 'blog:create' %}">Ajouter un article</a>
            {% endif %}
        </nav>
    </header>
    <main>
        {% block content %}
        {% endblock %}
    </main>
    <footer>
        Site propulsé par <a href="https://simplecss.org/">Simple.css</a>.
    </footer>
</body>
</html>


<!-- blogpost_list.html -->
{% extends 'blog/base.html' %}

{% block title %}
    <title>Accueil du blog</title>
{% endblock %}

{% block content %}
    <h1>Accueil du blog</h1>
    {% for post in posts %}
        <article>
            <h2>{{ post.title }}</h2>
            <small>
                Publié par <em>{{ post.author.username }}</em> le {{ post.create_date|date:"j F Y" }}
                {% if request.user.is_authenticated %}
                    <a href="{% url 'blog:edit' slug=post.slug %}">Modifier</a>
                    <a href="{% url 'blog:delete' slug=post.slug %}">Supprimer</a>
                {% endif %}
            </small>
            <p>{{ post.content|safe|truncatewords:50 }}</p>

            <form action="{% url 'blog:post' slug=post.slug %}">
                <button>Lire l'article</button>
            </form>
        </article>
    {% empty %}
        <p>Aucun article</p>
    {% endfor %}
{% endblock %}


<!-- blogpost_detail.html -->
{% extends 'blog/base.html' %}

{% block title %}
    <title>{{ post.title }}</title>
{% endblock %}

{% block content %}
    <article>
        <h1>{{ post.title }}</h1>
        {% if post.thumbnail %}
            <img src="{{ post.thumbnail.url }}" alt="image de l'article">
        {% endif %}
        <p>{{ post.content|linebreaks|safe }}</p>
    </article>
{% endblock %}


<!-- blogpost_create.html -->
{% extends 'blog/base.html' %}

{% block title %}
    <title>Ajouter un article</title>
{% endblock %}

{% block content %}
    <h1>Ajouter un article</h1>
    <form method="POST">
        {% csrf_token %}
        {{ form.as_p }}
        <input type="submit" value="Créer">
    </form>
{% endblock %}


<!-- blogpost_edit.html -->
{% extends 'blog/base.html' %}

{% block title %}
    <title>Modifier un article</title>
{% endblock %}

{% block content %}
    <h1>Modifier un article</h1>
    <form method="POST">
        {% csrf_token %}
        {{ form.as_p }}
        <input type="submit" value="Modifier">
    </form>
{% endblock %}


<!-- blogpost_confirm_delete.html -->
{% extends 'blog/base.html' %}

{% block title %}
    <title>Supprimer un article</title>
{% endblock %}

{% block content %}
    <form method="POST">
        {% csrf_token %}
        <p>Êtes-vous sûr de vouloir supprimer "{{ post }}" ?</p>
        <input type="submit" value="Oui, supprimer">
    </form>
{% endblock %}

Les gabarits héritent de base.html. Ils utilisent une feuille de style Simple.css.

Tests unitaires

Les tests unitaires de Django utilisent le module unittest. Les cas de tests doivent hériter de TestCase et être définis dans des fichiers test*.py à la racine de l'application ou dans des modules spécifiques. Une base de données temporaire est créée au lancement de la suite de tests et supprimée à la fin.

Les tests sont lancés avec la commande test sur l'ensemble du projet ou sur un package, module, classe, méthode :

./manage.py test
./manage.py test package.module.TestCase.test_method

Tests dans un module tests/test_models.py :

from django.test import TestCase
from django.urls import reverse
from accounts.models import Shopper
from store.models import Product, Cart, Order

class ProductTest(TestCase):
    def setUp(self):
        self.product = Product.objects.create(
            name="Sneakers Catalyst Suede Sailor",
            price=110,
            stock=30,
            description="Lorem ipsum dolor sit amet",
        )

    def test_product_slug_is_automatically_generated(self):
        self.assertEqual(self.product.slug, "sneakers-catalyst-suede-sailor")

    def test_product_absolute_url(self):
        self.assertEqual(self.product.get_absolute_url(), reverse("store:product", kwargs={"slug": self.product.slug}))

class CartTest(TestCase):
    def setUp(self):
        user = Shopper.objects.create_user(email="test@example.com", password="123456")
        product = Product.objects.create(name="Sneakers Catalyst Suede Sailor")
        self.cart = Cart.objects.create(user=user)
        order = Order.objects.create(user=user, product=product)
        self.cart.orders.add(order)
        self.cart.save()

    def test_orders_changed_when_cart_is_delete(self):
        orders_pk = [order.pk for order in self.cart.orders.all()]
        self.cart.delete()
        for order_pk in orders_pk:
            order = Order.objects.get(pk=order_pk)
            self.assertIsNotNone(order.order_date)
            self.assertTrue(order.ordered)

Tests dans un module tests/test_views.py :

from django.test import TestCase
from django.urls import reverse
from store.models import Product

class StoreTest(TestCase):
    def setUp(self):
        self.product = Product.objects.create(
            name="Sneakers Catalyst Suede Sailor",
            price=110,
            stock=30,
            description="Lorem ipsum dolor sit amet",
        )

    def test_products_shown_on_index_page(self):
        r = self.client.get(reverse("store:index"))
        self.assertEqual(r.status_code, 200)
        self.assertIn(self.product.name, str(r.content))
        self.assertIn(self.product.thumbnail_url(), str(r.content))

    def test_connection_link_shown_when_user_not_connected(self):
        r = self.client.get(reverse("store:index"))
        self.assertIn("Connexion", str(r.content))

    def test_redirect_when_anonymous_user_acess_cart_view(self):
        r = self.client.get(reverse("store:cart"))
        self.assertEqual(r.status_code, 302)
        self.assertRedirects(r, f"{reverse('accounts:login')}?next={reverse('store:cart')}", status_code=302)

Tests à la racine tests.py :

from django.test import TestCase
from django.urls import reverse
from accounts.models import Shopper
from store.models import Product

class UserTest(TestCase):
    def setUp(self):
        Product.objects.create(name="Sneakers Catalyst Suede Sailor")
        self.user = Shopper.objects.create_user(email="test@example.com", password="123456")

    def test_add_to_cart(self):
        self.user.add_to_cart(slug="sneakers-catalyst-suede-sailor")
        self.assertEqual(self.user.cart.orders.count(), 1)
        self.assertEqual(self.user.cart.orders.first().product.slug, "sneakers-catalyst-suede-sailor")
        self.user.add_to_cart(slug="sneakers-catalyst-suede-sailor")
        self.assertEqual(self.user.cart.orders.count(), 1)
        self.assertEqual(self.user.cart.orders.first().quantity, 2)

class LoggedInTest(TestCase):
    def setUp(self):
        self.user = Shopper.objects.create_user(
            email="test@example.com",
            first_name="John",
            last_name="Smith",
            password="123456",
        )

    def test_valid_login(self):
        data = {"email": "test@example.com", "password": "123456"}
        r = self.client.post(reverse("accounts:login"), data=data)
        self.assertEqual(r.status_code, 302)
        r = self.client.get(reverse("accounts:profile"))
        self.assertIn("Profil", str(r.content))

    def test_invalid_login(self):
        data = {"email": "test@example.com", "password": "1234"}
        r = self.client.post(reverse("accounts:login"), data=data)
        self.assertEqual(r.status_code, 200)
        self.assertTemplateUsed(r, "accounts/login.html")

    def test_profile_change(self):
        self.client.login(email="test@example.com", password="123456")
        data = {
            "email": "test@example.com",
            "password": "123456",
            "first_name": "John",
            "last_name": "Snow",
        }
        r = self.client.post(reverse("accounts:profile"), data=data)
        self.assertEqual(r.status_code, 302)
        user = Shopper.objects.get(email="test@example.com")
        self.assertEqual(user.last_name, "Snow")

Alternatives :

  • Tests unitaires et d'intégration avec les modules pytest et pytest-django
  • Tests fonctionnels avec le module selenium
Emojis

Un commentaire sur un de mes articles ? Commencez une discussion sur ma liste de diffusion en envoyant un email à ~nora/public-inbox@lists.sr.ht [règles]