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
.
Base de données
La base de données configurée par défaut est SQLite3. La configuration peut être optimisée ainsi à partir de Django 5.1 :
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', 'OPTIONS': { 'init_command': ( 'PRAGMA foreign_keys = ON;' 'PRAGMA journal_mode = WAL;' 'PRAGMA synchronous = NORMAL;' 'PRAGMA busy_timeout = 5000;' 'PRAGMA temp_store = MEMORY;' 'PRAGMA mmap_size = 134217728;' 'PRAGMA journal_size_limit = 67108864;' 'PRAGMA cache_size = 2000;' ), 'transaction_mode': 'IMMEDIATE', }, }, }
Pour utiliser PostgreSQL, il faut installer le package
postgresql
sous ArchLinux et le modulepsycopg2
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 :
- variable :
{{ variable }}
- filtre :
{{ variable|filter1:param1|filter2:param2... }}
- balise :
{% tag %}
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
etnull=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
.
-
Générer les scripts de migration dans
app/migrations
:python manage.py makemigrations app
-
Vérifier les migrations :
python manage.py showmigrations python manage.py sqlmigrate app 0001_initial
-
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 :
SET_NULL
: valeur null si l'objet lié est suppriméSET_DEFAULT
: valeur par défaut si l'objet lié est suppriméCASCADE
: supprime l'objet si l'objet lié est suppriméPROTECT
: erreur si l'objet lié est supprimé
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
etdjango-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) :
- libellé du modèle : attribut
Meta.verbose_name
- libellés des occurences : méthode
__str__
- colonne supplémentaire : propriété
word_count
- lien entre le modèle et une page du site : méthode
get_absolute_url
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
etpytest-django
- Tests fonctionnels avec le module
selenium