The recording for this lesson has not been published yet.
Your blog now has a PostCreateView, a PostUpdateView and a
PostDeleteView. All three need the same two rules:
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().
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:
| Mixin | Passes when | Decorator equivalent |
|---|---|---|
LoginRequiredMixin | request.user.is_authenticated | @login_required |
PermissionRequiredMixin | the user has permission_required | @permission_required |
UserPassesTestMixin | your 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
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")
self.get_object() in test_func() hits the database, and
UpdateView fetches the object again afterwards. For a hot page, filter the
queryset instead (section 5) - one query, and the object simply does not exist for the
wrong user.
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.
>>> from blog.views import PostUpdateView
>>> [c.__name__ for c in PostUpdateView.__mro__][:6]
['PostUpdateView', 'LoginRequiredMixin', 'AccessMixin',
'UserPassesTestMixin', 'UpdateView', 'SingleObjectTemplateResponseMixin']
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
One responsibility. Always call super() in every method you override. Never
inherit from View - a mixin is a fragment, not a 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"
Two levels of your own base classes is plenty. If a reader has to open four files to find out what a URL does, the inheritance has stopped paying for itself - and lesson 3.5 is about exactly that trade-off.
# 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)
LoginRequiredMixin to PostCreateView and check that logging
out and visiting the URL redirects you to the login page with a ?next=.class PostCreateView(CreateView, LoginRequiredMixin) and
see that the check disappears. Then fix the order and print
__mro__ to explain why.blog/mixins.py with AuthorRequiredMixin and use it in
both the update and the delete view.AuthorRequiredMixin for OwnedQuerysetMixin and adjust the
expected status code from 403 to 404.PageTitleMixin and use page_title in your
base.html heading.