Django : les vues génériques

Les vues génériques de Django permettent de gagner du temps avec les opérations CRUD (Create, Read, Update, Delete). Elles sont prêtes à l'emploi, et donnent de la flexibilité via l'héritage.

Vues génériques

Un projet exemple "blog" est parfait pour illustrer les vues génériques :

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


User = get_user_model()


class Author(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)

    def __str__(self):
        return self.user.username

    class Meta:
        verbose_name = 'Auteur'


class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='posts')

    def __str__(self):
        return self.title

    class Meta:
        ordering = ['-created_at']
        verbose_name = 'Article'

Voici les imports qui seront utilisés dans l'article :

from .models import Post
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.core.exceptions import PermissionDenied
from django.urls import reverse_lazy, reverse

ListView : Afficher une liste d'objets

La ListView permet de récupérer une liste d'objets :

from .models import Post
from django.views.generic import ListView


class PostListView(ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts' # 'object_list' par défaut
# urls.py
path('', PostListView.as_view(), name='home')
# Je m'en sers comme page d'index

Il suffit de définir le modèle utilisé et le template. Je préfère redéfinir le contexte, qui est par défaut object_list.

<h1>Blog Posts</h1>
  <ul>
    {% for post in posts %}
      <li>
        <a href="#">{{ post.title }}</a>
      </li>
    {% endfor %}
  </ul>

On peut même implémenter la pagination en une ligne ? :

class PostListView(ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts' # 'object_list' par défaut
    paginate_by = 10

Par défaut, le queryset est Model.objects.all(), mais on pourrait très bien le modifier avec la méthode get_queryset :

class PostListView(ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts' # 'object_list' par défaut
    paginate_by = 10
    
    def get_queryset(self):
        queryset = super().get_queryset()
        return queryset.filter(...)

Et pourquoi ne pas ajouter du contexte ?

class PostListView(ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts' # 'object_list' par défaut
    paginate_by = 10
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['autre'] = 'Génial mon contexte !'
        return context

Comme vous pouvez le voir, rien qu'avec la ListView, les vues génériques sont très flexibles.

Nous allons nous pencher sur cet exemple pour voir comment on peut restreindre l'accès à notre vue.

Différentes façons de restreindre l'accès aux vues

Avec des mixins

Il est possible d'utiliser le LoginRequiredMixin pour obliger l'utilisateur à être connecté :

# Autres imports ...
from django.contrib.auth.mixins import LoginRequiredMixin


class PostListView(LoginRequiredMixin, ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts' # 'object_list' par défaut
    paginate_by = 10
    
    # def get_queryset(self):
    #     queryset = super().get_queryset()
    #     return queryset.filter(...)
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['autre'] = 'Génial mon contexte !'
        return context

On peut aussi utiliser les permissions de Django :

# Autres imports ...
from django.contrib.auth.mixins import PermissionRequiredMixin


class PostListView(PermissionRequiredMixin, ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts' # 'object_list' par défaut
    paginate_by = 10
    permission_required = 'blog.view_post'
    
    # def get_queryset(self):
    #     queryset = super().get_queryset()
    #     return queryset.filter(...)
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['autre'] = 'Génial mon contexte !'
        return context

On peut spécifier la ou les permissions grâce à l'attribut permission_required. Si on le souhaite, le PermissionRequiredMixin nous donne la possibilité de surcharger la méthode has_permission :

class PostListView(PermissionRequiredMixin, ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts' # 'object_list' par défaut
    paginate_by = 10
    
    # def get_queryset(self):
    #     queryset = super().get_queryset()
    #     return queryset.filter(...)
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['autre'] = 'Génial mon contexte !'
        return context
    
    def has_permission(self):
        return self.request.user.has_perm('blog.view_post') or self.request.user.is_superuser

Avec un décorateur

Vous pouvez aussi décorer la classe elle-même :

# Autres imports ...
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator


@method_decorator(login_required, name='dispatch')
class PostListView(ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts' # 'object_list' par défaut
    paginate_by = 10
    
    # def get_queryset(self):
    #     queryset = super().get_queryset()
    #     return queryset.filter(...)
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['autre'] = 'Génial mon contexte !'
        return context

Dans ce cas précis, on cible directement la méthode dispatch avec le décorateur login_required, décorateur que l'on retrouve assez souvent avec des vues basées sur des fonctions.

La méthode dispatch ?

Surcharger la méthode dispatch

On a vu comment cibler la méthode dispatch en décorant la classe elle-même. Mais il est possible de gérer l'accès aux vues en surchargeant cette méthode. Bien que je préfère les solutions précédentes, voyons comment on peut faire cela :

# Autres imports ...
from django.core.exceptions import PermissionDenied


class PostListView(ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts' # 'object_list' par défaut
    paginate_by = 10
    
    # def get_queryset(self):
    #     queryset = super().get_queryset()
    #     return queryset.filter(...)
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['autre'] = 'Génial mon contexte !'
        return context
    
    def dispatch(self, request, *args, **kwargs):

        if not request.user.is_superuser:
            raise PermissionDenied("Vous devez être un administrateur pour voir cette page.")
        
        return super().dispatch(request, *args, **kwargs)

Les concepts d'authentification et de permissions vus ci-dessus s'appliquent à toutes les CBV Django.

DetailView : afficher une instance

La DetailView permet de récupérer une instance de modèle.

Voici un exemple typique :

# Autres imports (notamment Post)
from django.views.generic import DetailView


class PostDetailView(DetailView):
    model = Post
    template_name = 'blog/post_detail.html'
    context_object_name = 'post'

Comme avec la ListView, je préfère redéfinir le contexte pour afficher mon instance, qui par défaut est object.

Le reste est basique, on définit le model et le template.

Django cherchera le pk ou le slug ( en fonction de ce que vous allez définir dans l'url) pour afficher l'instance :

from django.urls import path
from .views import PostDetailView


app_name = 'blog'
urlpatterns = [
    path('<int:pk>/', PostDetailView.as_view(), name='post_detail'),
]

# Si on utilise le slug : 
# path('post/<slug:slug>/', PostDetailView.as_view(), name='post_detail'),

Vous pouvez modifier le contexte de la même manière que la ListView. Pourquoi modifier le queryset pour récupérer notre objet ?

Voici un exemple d'optimisation :

class PostDetailView(DetailView):
    model = Post
    template_name = 'blog/post_detail.html'
    context_object_name = 'post'
    
    def get_queryset(self):
        return super().get_queryset().select_related('author')

select_related ? Je vous invite à regarder ma courte vidéo Youtube sur le sujet ?.

À ce stade, il peut être intéressant de voir un exemple du code HTML pour les deux premières vues :

<!-- post_list.html  -->
 <h1>Blog Posts</h1>
    {{ autre }} <!-- Le contexte ajouté avec def get_context_data -->
  <ul>
    {% for post in posts %}

      <li>
        <a href="{% url 'blog:post_detail' post.pk %}">{{ post.title }}</a>
      </li>
    {% endfor %}
  </ul>

  <div class="pagination">
    <span class="step-links">
        {% if page_obj.has_previous %}
            <a href="?page=1">&laquo; first</a>
            <a href="?page={{ page_obj.previous_page_number }}">previous</a>
        {% endif %}

        <span class="current">
            Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
        </span>

        {% if page_obj.has_next %}
            <a href="?page={{ page_obj.next_page_number }}">next</a>
            <a href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
        {% endif %}
    </span>
</div>
<!-- post_detail.html -->
<h1>{{ post.title }}</h1>
<p>Par {{ post.author.user.username }} le {{ post.created_at|date:"d/m/Y" }}</p>
<div>{{ post.content }}</div>

La CreateView : création d'objet simplifiée !

La CreateView permet de générer une vue avec formulaire pour créer une instance de modèle.

Commençons avec quelques attributs et la méthode form_valid :

# Autres imports (notamment Post)
from django.views.generic import CreateView
from django.urls import reverse_lazy, reverse


class PostCreateView(CreateView):
    model = Post
    template_name = 'blog/post_form.html'
    fields = ['title', 'content']
    # form_class = CustomForm # Si vous avez un formulaire personnalisé dans forms.py
    success_url = reverse_lazy('home')  # Redirection après la création
    
    def form_valid(self, form):
        form.instance.author = self.request.user.author
        return super().form_valid(form)
# urls.py
path('create/', PostCreateView.as_view(), name='post_create')
<h1>Créer un nouvel article</h1>

<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Créer</button>
</form>

Deux nouveaux attributs : fields et success_url. fields permet d'afficher les champs souhaités dans le formulaire, tandis que success_url détermine l'url de redirection en cas de succès.

form_class (commenté dans ce cas) permet d'utiliser un formulaire personnalisé importé depuis forms.py.

La méthode form_valid est pratique pour réaliser des actions sur la validation du formulaire : je n'affiche pas l'auteur dans le formulaire, mais le champ author sera automatiquement renseigné en fonction de l'user qui valide le formulaire.

Mais on peut aller plus loin avec la méthode form_valid :

  • Récupérer des éléments passés en paramètres d'URL (via les kwargs)
  • Récupérer des champs du formulaire
class PostCreateView(CreateView):
    model = Post
    template_name = 'blog/post_form.html'
    fields = ['title', 'content']
    # form_class = CustomForm # Si vous avez un formulaire personnalisé dans forms.py
    # success_url = reverse_lazy('home')  # Redirection après la création
    
    def form_valid(self, form):
        # foo_in_kwargs = self.kwargs.get('foo', None) # Récupération d'un paramètre dans l'URL
        
        title = form.cleaned_data.get('title')
        form.instance.title = title.upper()
        
        form.instance.author = self.request.user.author
        return super().form_valid(form)
    
    def get_success_url(self): # Redirection après la création
        return reverse('blog:post_detail', args=[self.object.pk])

Contrairement à l'attribut success_url, la méthode get_success_url permet de passer des paramètres à une URL, et de créer une logique conditionnelle pour la redirection.

Je me répète, mais... on voit bien à quel point les vues génériques sont très flexibles.

L'UpdateView : modification d'un objet

Je ne vais pas passer trop de temps dessus, car tout comme la CreateView, on peut utiliser la méthode form_valid pour manipuler les données.

Il faut penser à passer le pk de l'objet en paramètre d'URL.

# Autres imports (notamment Post)
from django.views.generic import UpdateView


class PostUpdateView(UpdateView):
    model = Post
    template_name = 'blog/post_update_form.html'
    fields = ['title', 'content']
    
    def get_success_url(self):
        return reverse('blog:post_detail', args=[self.object.pk])
# urls.py
path('<int:pk>/update/', PostUpdateView.as_view(), name='post_update')
<h1>Modifier l'article : {{ form.instance.title }}</h1>

<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Modifier</button>
</form>

Le HTML est tellement similaire entre la CreateView et l'UpdateView qu'on pourrait facilement utiliser le même template pour les deux.

La DeleteView : suppression d'un objet

La DeleteView permet de supprimer une instance de modèle :

from django.urls import reverse_lazy
from django.views.generic import DeleteView


class PostDeleteView(DeleteView):
    model = Post
    template_name = 'blog/post_confirm_delete.html'
    success_url = reverse_lazy('home')

Il faut donc penser à passer le pk en paramètre :

# urls.py
path('<int:pk>/delete/', PostDeleteView.as_view(), name='post_delete')

Qu'il s'agisse de l'UpdateView ou de la DeleteView, pensez à passer le pk dans vos liens :

<!-- post_detail.html -->
<h1>{{ post.title }}</h1>
<p>Par {{ post.author.user.username }} le {{ post.created_at|date:"d/m/Y" }}</p>
<div>{{ post.content }}</div>
<a href="{% url 'blog:post_update' post.pk %}">Modifier</a>
<a href="{% url 'blog:post_delete' post.pk %}">Supprimer</a>

Pour la DeleteView, le template est utilisé comme page de confirmation :

<!-- post_confirm_delete.html -->

<h1>Confirmer la suppression de l'article : {{ post.title }}</h1>

<form method="post">
  {% csrf_token %}
  <p>Êtes-vous sûr de vouloir supprimer cet article ?</p>
  <button type="submit">Supprimer</button>
</form>

Quelques mots pour conclure

Restreindre l'accès à vos vues

En début d'article (pour la ListView), j'ai montré comment restreindre l'accès à vos vues. Même si je ne l'ai pas fait pour les autres exemples, pensez à bien protéger toutes vos vues sensibles.

La méthode setup

Dans mes vues génériques, il m'arrive parfois d'avoir besoin d'utiliser un objet à plusieurs endroits.

Dans l'exemple suivant (qui n'a rien à voir avec nos exemples précédent, car il s'agit d'un projet personnel), je récupère le pk d'un objet parent via l'url avec les kwargs. Je crée un attribut d'instance self.project, que je réutilise dans plusieurs méthodes.

En réalité, la méthode setup est appelée juste après la méthode __init__ avec les vues génériques.

# Nouveaux imports
from django.contrib.admin.views.decorators import staff_member_required
from django.shortcuts import get_object_or_404
# Autres imports ...

@method_decorator(staff_member_required, name="dispatch")
class CreateService(CreateView):
    model = Service
    template_name = "customers/service_form.html"
    form_class = ServiceForm

    def setup(self, request, *args, **kwargs):
        super().setup(request, *args, **kwargs)
        self.project = get_object_or_404(Project, pk=kwargs.get("pk"))

    def form_valid(self, form):
        form.instance.project = self.project
        return super().form_valid(form)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["status"] = "Création d'un service"
        context["project"] = self.project
        return context

    def get_success_url(self):
        return reverse("customers:project", kwargs={"pk": self.project.pk})

Retour