Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • LoginRequiredMixin and UserPassesTestMixin
  • Method resolution order and mixin ordering
  • An AuthorRequiredMixin for the blog
  • A project-wide base view class

Lesson notes

1. The check you are about to repeat five times

Your blog now has a PostCreateView, a PostUpdateView and a PostDeleteView. All three need the same two rules:

  • You must be logged in.
  • For editing and deleting, you must be the author of that post.

In the tutorial you solved the first rule with a decorator:

# before
from django.contrib.auth.decorators import login_required


@login_required
def post_new(request):
    ...

A decorator on a class does not work the same way, because the callable Django gets is the one as_view() returns. Mixins are the class-based equivalent - and unlike decorators, they can also hook into get_queryset() or get_context_data().

2. LoginRequiredMixin

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import CreateView

from .forms import PostForm
from .models import Post


class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    form_class = PostForm

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)

An anonymous visitor is redirected to settings.LOGIN_URL with ?next= pointing back at the page. You can tune that per view:

class PostCreateView(LoginRequiredMixin, CreateView):
    login_url = "/accounts/login/"        # default: settings.LOGIN_URL
    redirect_field_name = "next"          # default: "next"
    raise_exception = False               # True -> 403 instead of a redirect

Its siblings work the same way:

MixinPasses whenDecorator equivalent
LoginRequiredMixinrequest.user.is_authenticated@login_required
PermissionRequiredMixinthe user has permission_required@permission_required
UserPassesTestMixinyour test_func() returns True@user_passes_test
from django.contrib.auth.mixins import PermissionRequiredMixin


class PostDeleteView(PermissionRequiredMixin, DeleteView):
    model = Post
    permission_required = "blog.delete_post"   # or a tuple of several

3. UserPassesTestMixin

For "only the author may edit", implement test_func(). It runs during dispatch(), so self.request and self.kwargs are available, and self.get_object() works.

from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin


class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
    model = Post
    form_class = PostForm

    def test_func(self):
        return self.get_object().author == self.request.user

A failing test raises PermissionDenied (403) for a logged-in user and redirects an anonymous one to the login page. Override handle_no_permission() if you want a friendlier answer:

    def handle_no_permission(self):
        messages.error(self.request, "You can only edit your own posts.")
        return redirect("blog:post_list")

4. Mixin order is not decoration

Python resolves attributes left to right, so class A(Mixin, View) means "look in Mixin first, then in View". Access mixins must therefore come before the generic view, otherwise View.dispatch() answers the request before the check ever runs.

Correct: class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView) LoginRequiredMixin dispatch(): logged in? UserPassesTestMixin dispatch(): test_func() UpdateView get() / post() View.dispatch() Wrong: class PostUpdateView(UpdateView, LoginRequiredMixin) UpdateView answers first 200 OK for anyone the check never runs Rule: access mixins first, the concrete generic view last. Every mixin must call super().
>>> from blog.views import PostUpdateView
>>> [c.__name__ for c in PostUpdateView.__mro__][:6]
['PostUpdateView', 'LoginRequiredMixin', 'AccessMixin',
 'UserPassesTestMixin', 'UpdateView', 'SingleObjectTemplateResponseMixin']

5. An AuthorRequiredMixin for the blog

Writing test_func() in three views is the same duplication one level up. Give the rule a name and a home:

# blog/mixins.py
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin


class AuthorRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
    """Only the author of the object (or a staff user) may proceed."""

    def test_func(self):
        obj = self.get_object()
        return obj.author == self.request.user or self.request.user.is_staff
# blog/views.py
from .mixins import AuthorRequiredMixin


class PostUpdateView(AuthorRequiredMixin, UpdateView):
    model = Post
    form_class = PostForm


class PostDeleteView(AuthorRequiredMixin, DeleteView):
    model = Post
    success_url = reverse_lazy("blog:post_list")

The query-friendly variant does not test at all - it narrows what exists. The wrong user gets a plain 404, which also avoids telling them that the post is there:

# blog/mixins.py
class OwnedQuerysetMixin(LoginRequiredMixin):
    """Restrict the queryset of any object view to the current user's rows."""

    owner_field = "author"

    def get_queryset(self):
        return super().get_queryset().filter(**{self.owner_field: self.request.user})

Mixins are not limited to permissions. Anything a view does more than once qualifies:

# blog/mixins.py
class PageTitleMixin:
    """Put a page title in the context of any view."""

    page_title = ""

    def get_page_title(self):
        return self.page_title

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["page_title"] = self.get_page_title()
        return context


class FormMessageMixin:
    """Add a success message after any valid form submission."""

    success_message = "Saved."

    def form_valid(self, form):
        response = super().form_valid(form)
        messages.success(self.request, self.success_message)
        return response

6. A project-wide base view

When every page in your project needs the same two or three mixins, spell that out once in common/views.py and inherit from it. The class name then documents the policy.

# common/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import DetailView, ListView

from .mixins import PageTitleMixin


class BaseListView(PageTitleMixin, ListView):
    paginate_by = 20


class PrivateListView(LoginRequiredMixin, BaseListView):
    pass


class PrivateDetailView(LoginRequiredMixin, PageTitleMixin, DetailView):
    pass
# blog/views.py
from common.views import BaseListView


class PostListView(BaseListView):
    model = Post
    context_object_name = "posts"
    paginate_by = 5                 # overrides the base
    page_title = "Blog"

7. Prove the restriction, do not assume it

# blog/tests/test_permissions.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse

from blog.models import Post

User = get_user_model()


class PostUpdatePermissionTests(TestCase):
    def setUp(self):
        self.author = User.objects.create_user("author", password="pw")
        self.other = User.objects.create_user("other", password="pw")
        self.post = Post.objects.create(title="Hi", text="...", author=self.author)
        self.url = reverse("blog:post_edit", kwargs={"pk": self.post.pk})

    def test_anonymous_is_redirected_to_login(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 302)
        self.assertIn("/login/", response["Location"])

    def test_other_user_gets_403(self):
        self.client.force_login(self.other)
        self.assertEqual(self.client.get(self.url).status_code, 403)

    def test_author_can_edit(self):
        self.client.force_login(self.author)
        self.assertEqual(self.client.get(self.url).status_code, 200)

8. Exercise

  1. Add LoginRequiredMixin to PostCreateView and check that logging out and visiting the URL redirects you to the login page with a ?next=.
  2. Deliberately write class PostCreateView(CreateView, LoginRequiredMixin) and see that the check disappears. Then fix the order and print __mro__ to explain why.
  3. Create blog/mixins.py with AuthorRequiredMixin and use it in both the update and the delete view.
  4. Write the three tests above and make them pass. Then swap AuthorRequiredMixin for OwnedQuerysetMixin and adjust the expected status code from 403 to 404.
  5. Add a PageTitleMixin and use page_title in your base.html heading.

Further reading

After this lesson you will

  • Restrict views to logged-in or allowed users
  • Extract shared view behaviour into a mixin