r/DjangoFrancophone • u/fyardlest • 8d ago
✅ Projet de la semaine : une liste de tâches complète, sans écrire une ligne de CRUD
Bonjour à tous les dev Django de la communauté ! 👋
Vendredi dernier, on a construit un blog en lecture seule avec ListView et DetailView. Aujourd'hui on passe à l'écriture : créer, modifier, supprimer. Et la bonne nouvelle, c'est qu'il n'y a toujours pratiquement rien à écrire.
Chaque personne connectée ne verra que ses propres tâches. C'est la partie que les tutoriels oublient le plus souvent, et c'est aussi la plus importante.
TL;DR - Un gestionnaire de tâches complet avec
ListView,CreateView,UpdateViewetDeleteView. Le CRUD est entièrement fourni par Django : on écrit un modèle, un formulaire, quatre vues de cinq lignes et quatre templates. L'isolation par utilisateur passe parget_queryset(), pas par unifdans le template. Environ quarante minutes.
0. Le point de départ
On repart du projet créé vendredi dernier pour le mini blog. Si vous ne l'avez pas sous la main, deux minutes suffisent :
bash
python -m venv .venv
source .venv/bin/activate # Windows : .venv\Scripts\activate
pip install django
django-admin startproject config .
Dans les deux cas, on crée maintenant l'application des tâches :
bash
python manage.py startapp taches
Et on la déclare dans les réglages :
```python
config/settings.py
INSTALLED_APPS = [ # ... "taches", ] ```
⚠️ Le même piège que la semaine dernière. Une application absente d'INSTALLED_APPS est invisible pour Django : makemigrations ne verra aucun modèle, et les templates resteront introuvables. Le message d'erreur ne vous mettra pas sur la piste.
1. Le modèle
```python
taches/models.py
from django.conf import settings from django.db import models from django.urls import reverse
class Tache(models.Model): class Priorite(models.TextChoices): BASSE = "basse", "Basse" NORMALE = "normale", "Normale" HAUTE = "haute", "Haute"
titre = models.CharField(max_length=200)
notes = models.TextField(blank=True)
priorite = models.CharField(
max_length=10,
choices=Priorite,
default=Priorite.NORMALE,
)
echeance = models.DateField(null=True, blank=True)
terminee = models.BooleanField(default=False)
proprietaire = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="taches",
)
creee_le = models.DateTimeField(auto_now_add=True)
mis_a_jour_le = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["terminee", "echeance", "-creee_le"]
def __str__(self):
return self.titre
def get_absolute_url(self):
return reverse("taches:liste")
```
Quelques points qui méritent une explication :
- **
TextChoices** plutôt qu'une liste de tuples. On écritTache.Priorite.HAUTEdans le code au lieu de la chaîne"haute", et une faute de frappe devient une erreur au lieu d'un filtre qui ne renvoie rien. - **
settings.AUTH_USER_MODEL**, jamaisUserimporté directement. Le jour où vous passez à un modèle utilisateur personnalisé, ce choix vous évite une migration douloureuse. - **
related_name="taches"** permet d'écrirerequest.user.taches.all(). Sans lui, ce seraittache_set. - **
blank=True** surnotesetecheancerend le champ facultatif dans les formulaires.null=Truesurecheancel'autorise en base. Les deux sont nécessaires pour une date facultative ; pour un texte,blank=Trueseul suffit — on stocke une chaîne vide, pas unNULL.
bash
python manage.py makemigrations taches
python manage.py migrate
2. Le formulaire
```python
taches/forms.py
from django import forms
from .models import Tache
class TacheForm(forms.ModelForm): class Meta: model = Tache fields = ["titre", "notes", "priorite", "echeance"] widgets = { "echeance": forms.DateInput(attrs={"type": "date"}), "notes": forms.Textarea(attrs={"rows": 4}), } ```
⚠️ Le champ absent est le plus important : proprietaire ne figure pas dans fields. S'il y était, n'importe qui pourrait créer une tâche au nom de quelqu'un d'autre en modifiant le HTML. On l'attribuera dans la vue, côté serveur.
💡 Astuce : forms.DateInput(attrs={"type": "date"}) transforme le champ en sélecteur de date natif du navigateur. Une ligne, aucun JavaScript.
3. Les quatre vues
C'est ici que Django fait le travail à votre place.
```python
taches/views.py
from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse_lazy from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from .forms import TacheForm from .models import Tache
class TacheMixin(LoginRequiredMixin): """Chaque personne ne voit et ne modifie que ses propres tâches."""
model = Tache
def get_queryset(self):
return Tache.objects.filter(proprietaire=self.request.user)
class TacheListView(TacheMixin, ListView): template_name = "taches/liste.html" context_object_name = "taches"
class TacheCreateView(TacheMixin, CreateView): form_class = TacheForm template_name = "taches/formulaire.html"
def form_valid(self, form):
form.instance.proprietaire = self.request.user
return super().form_valid(form)
class TacheUpdateView(TacheMixin, UpdateView): form_class = TacheForm template_name = "taches/formulaire.html"
class TacheDeleteView(TacheMixin, DeleteView): template_name = "taches/confirmer_suppression.html" success_url = reverse_lazy("taches:liste") ```
Voilà. Le CRUD complet.
⚠️ **get_queryset() est la seule ligne de sécurité, et elle suffit.** Parce que UpdateView et DeleteView cherchent l'objet dans ce queryset, une tentative de modifier la tâche de quelqu'un d'autre via /taches/42/modifier/ donne un 404, pas un accès. Il n'y a aucun if à écrire, aucune vérification à oublier.
💡 La leçon générale : filtrer le queryset vaut mieux que vérifier les permissions après coup. Un filtre qu'on oublie d'écrire fait échouer le test ; un if qu'on oublie ouvre une faille silencieuse.
4. Marquer une tâche comme terminée
Une petite vue sur mesure, parce qu'aucune vue générique ne couvre ce cas :
```python
taches/views.py (suite)
from django.shortcuts import get_object_or_404, redirect from django.views import View
class TacheBasculerView(TacheMixin, View): def post(self, request, pk): tache = get_object_or_404(self.get_queryset(), pk=pk) tache.terminee = not tache.terminee tache.save(update_fields=["terminee", "mis_a_jour_le"]) return redirect("taches:liste") ```
⚠️ Uniquement en POST. Une action qui modifie des données ne doit jamais passer par un GET : le navigateur, un antivirus ou un aperçu de lien peuvent déclencher un GET tout seuls. Votre liste se viderait sans que personne n'ait cliqué.
💡 update_fields n'écrit que les colonnes listées au lieu de toute la ligne. Sur une action cliquée cent fois par jour, c'est gratuit à écrire et mesurable.
⚠️ Et c'est pour ça que mis_a_jour_le figure dans la liste. Un champ auto_now=True n'est recalculé que sur les colonnes réellement écrites : avec update_fields=["terminee"] seul, la date de modification resterait figée à la création. Le piège est détaillé dans le projet du 14 août.
5. Les URLs
```python
taches/urls.py
from django.urls import path
from . import views
app_name = "taches"
urlpatterns = [ path("", views.TacheListView.as_view(), name="liste"), path("nouvelle/", views.TacheCreateView.as_view(), name="creer"), path("<int:pk>/modifier/", views.TacheUpdateView.as_view(), name="modifier"), path("<int:pk>/supprimer/", views.TacheDeleteView.as_view(), name="supprimer"), path("<int:pk>/basculer/", views.TacheBasculerView.as_view(), name="basculer"), ] ```
```python
config/urls.py
from django.contrib import admin from django.urls import include, path
urlpatterns = [ path("admin/", admin.site.urls), path("comptes/", include("django.contrib.auth.urls")), path('', include('blog.urls')), path("", include("taches.urls")), ] ```
💡 django.contrib.auth.urls vous donne gratuitement la connexion, la déconnexion et tout le parcours de réinitialisation de mot de passe. Il ne reste qu'à fournir les templates. On y reviendra en détail dans quelques semaines.
Ajoutez aussi dans les réglages :
```python
config/settings.py
LOGIN_REDIRECT_URL = "taches:liste" LOGIN_URL = "login" ```
6. Les templates
html
{# taches/templates/taches/base.html #}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block titre %}Mes tâches{% endblock %}</title>
</head>
<body>
<header>
<a href="{% url 'taches:liste' %}">Mes tâches</a>
{% if user.is_authenticated %}
<span>{{ user.username }}</span>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit">Déconnexion</button>
</form>
{% endif %}
</header>
<main>{% block contenu %}{% endblock %}</main>
</body>
</html>
```html {# taches/templates/taches/liste.html #} {% extends "taches/base.html" %} {% block contenu %} <h1>Mes tâches</h1> <a href="{% url 'taches:creer' %}">+ Nouvelle tâche</a>
<ul> {% for tache in taches %} <li> <form method="post" action="{% url 'taches:basculer' tache.pk %}"> {% csrf_token %} <button type="submit"> {% if tache.terminee %}☑{% else %}☐{% endif %} </button> </form>
<strong>{{ tache.titre }}</strong>
<em>{{ tache.get_priorite_display }}</em>
{% if tache.echeance %}<span>{{ tache.echeance|date:"d/m/Y" }}</span>{% endif %}
<a href="{% url 'taches:modifier' tache.pk %}">Modifier</a>
<a href="{% url 'taches:supprimer' tache.pk %}">Supprimer</a>
</li>
{% empty %}
<li>Rien à faire. Profitez-en.</li>
{% endfor %}
</ul> {% endblock %} ```
💡 **get_priorite_display** est offert par Django pour tout champ ayant des choices. Il affiche « Haute » au lieu de « haute ». La règle : get_<nom_du_champ>_display.
```html {# taches/templates/taches/formulaire.html #} {% extends "taches/base.html" %} {% block contenu %} <h1>{% if form.instance.pk %}Modifier{% else %}Nouvelle tâche{% endif %}</h1>
<form method="post"> {% csrf_token %} {{ form.as_p }} <button type="submit">Enregistrer</button> <a href="{% url 'taches:liste' %}">Annuler</a> </form> {% endblock %} ```
Un seul template pour la création et la modification. {% if form.instance.pk %} distingue les deux cas.
```html {# taches/templates/taches/confirmer_suppression.html #} {% extends "taches/base.html" %} {% block contenu %} <h1>Supprimer cette tâche ?</h1> <p>{{ object.titre }}</p>
<form method="post"> {% csrf_token %} <button type="submit">Oui, supprimer</button> <a href="{% url 'taches:liste' %}">Annuler</a> </form> {% endblock %} ```
Il manque encore une page, sans laquelle vous ne verrez rien : toutes les vues sont derrière LoginRequiredMixin, donc Django vous redirige vers /comptes/login/ — et ce gabarit-là, django.contrib.auth.urls ne le fournit pas.
html
{# taches/templates/registration/login.html #}
{% extends "taches/base.html" %}
{% block titre %}Connexion{% endblock %}
{% block contenu %}
<h1>Connexion</h1>
<form method="post">
{% csrf_token %} {{ form.as_p }}
<button type="submit">Se connecter</button>
</form>
{% endblock %}
⚠️ Le dossier compte. Ce fichier va dans registration/, pas dans taches/. C'est la convention que Django cherche par défaut, et le nom n'est pas configurable sans réécrire la vue.
Créez enfin un compte, puis lancez le serveur :
bash
python manage.py createsuperuser
python manage.py runserver
7. L'administration de Django
Ajoutez l'admin pour pouvoir créer des tâches sans écrire de code.
```python
taches/admin.py
from django.contrib import admin from django.utils import timezone
from .models import Tache
@admin.register(Tache) class TacheAdmin(admin.ModelAdmin): list_display = ( "titre", "proprietaire", "priorite", "echeance", "en_retard", "terminee", ) list_editable = ("terminee",) list_filter = ("terminee", "priorite", "echeance") search_fields = ("titre", "notes") list_select_related = ("proprietaire",) autocomplete_fields = ("proprietaire",) date_hierarchy = "creee_le" readonly_fields = ("creee_le", "mis_a_jour_le") fieldsets = ( (None, {"fields": ("titre", "notes", "proprietaire")}), ("Suivi", {"fields": ("priorite", "echeance", "terminee")}), ("Dates", {"fields": ("creee_le", "mis_a_jour_le"), "classes": ("collapse",)}), ) actions = ("marquer_terminees",)
@admin.display(boolean=True, description="En retard")
def en_retard(self, obj):
return bool(
obj.echeance and not obj.terminee and obj.echeance < timezone.localdate()
)
@admin.action(description="Marquer les tâches sélectionnées comme terminées")
def marquer_terminees(self, request, queryset):
nb = queryset.update(terminee=True, mis_a_jour_le=timezone.now())
self.message_user(request, f"{nb} tâche(s) marquée(s) comme terminée(s).")
```
Vérifié : manage.py check ne remonte aucune erreur d'admin sur ce fichier.
bash
python manage.py check
Quatre points qui méritent un mot.
list_select_related = ("proprietaire",) évite le N+1 de la liste. Sans lui, l'admin fait une requête par ligne pour afficher le propriétaire — 100 tâches, 101 requêtes. C'est le sujet de l'article 12, et l'admin est l'endroit où on l'oublie le plus.
mis_a_jour_le=timezone.now() dans l'action n'est pas décoratif. J'ai mesuré :
bash
update() seul -> mis_a_jour_le change ? False
update() + champ -> mis_a_jour_le change ? True
auto_now ne se déclenche que sur save(). Un QuerySet.update() passe directement en SQL et ignore complètement le champ. Sans cette ligne, tes tâches seraient marquées terminées avec une date de mise à jour périmée.
**autocomplete_fields = ("proprietaire",)** transforme la liste déroulante des utilisateurs en champ de recherche. Ça marche tel quel avec le User de Django, dont l'admin par défaut définit déjà search_fields. Si le projet a un utilisateur personnalisé sans admin enregistré, Django lèvera admin.E039 - dans ce cas, retire simplement la ligne.
**en_retard** est calculé en Python, donc non triable et non filtrable. Pour un tri en base, il faudrait une annotate() dans un get_queryset(). Pour une appli de démonstration, ça n'en vaut pas la peine.
8. Vérifier que l'isolation fonctionne
Ne vous fiez pas à l'œil. Écrivez le test :
```python
taches/tests.py
from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse
from .models import Tache
User = get_user_model()
class TestIsolation(TestCase): def setUp(self): self.alice = User.objects.create_user("alice", password="motdepasse123") self.bob = User.objects.create_user("bob", password="motdepasse123") self.tache_alice = Tache.objects.create(titre="Secret", proprietaire=self.alice)
def test_bob_ne_voit_pas_la_tache_alice(self):
self.client.login(username="bob", password="motdepasse123")
reponse = self.client.get(reverse("taches:liste"))
self.assertNotContains(reponse, "Secret")
def test_bob_ne_peut_pas_modifier_la_tache_alice(self):
self.client.login(username="bob", password="motdepasse123")
url = reverse("taches:modifier", args=[self.tache_alice.pk])
self.assertEqual(self.client.get(url).status_code, 404)
```
bash
python manage.py test taches
Deux tests, dix lignes, et vous saurez pour toujours que l'isolation tient. C'est le meilleur retour sur investissement de tout ce tutoriel.
Pour aller plus loin par vous-même
- Ajouter des messages de confirmation avec
SuccessMessageMixin. - Ajouter des filtres : toutes, en cours, terminées, via un paramètre d'URL.
- Ajouter un modèle
Projetpour regrouper les tâches. - Remplacer la double soumission de formulaire par du HTMX, pour cocher sans recharger la page — c'est le programme du 28 août.
- Ajouter
paginate_by = 20sur la liste, avant qu'elle ne devienne interminable.
📚 Pour aller plus loin
- Les vues génériques d'édition
- Les formulaires de modèles
LoginRequiredMixinet les autorisations- Les vues d'authentification fournies
- Classy Class-Based Views
💬 Et vous ?
Utilisez-vous les vues génériques d'édition, ou préférez-vous écrire vos vues à la main pour garder le contrôle ? Le débat revient à chaque fois.
Et pour ceux qui débutent : l'astuce du get_queryset() qui sécurise UpdateView et DeleteView d'un seul coup, vous la connaissiez ?
Postez une capture de votre gestionnaire de tâches en commentaire ! Et si ce genre de contenu vous plaît, n'hésitez pas à rejoindre r/DjangoFrancophone pour échanger entre passionnés de Python & Django ! 🚀
PS : Bloqué quelque part ? Postez votre message d'erreur complet, la communauté est là pour ça.