IAN WALDRON IAN WALDRON

How to Test a Django TemplateView Requiring Staff Permissions

A collection of tests useful in determining whether a TemplateView is structured as expected and whether the view demands the expected permissions.
November 9, 2023

In this article, we're going to look at how to test a Django TemplateView class-based view, making sure attributes are set with the values we expect as well as whether certain permissions are required to access the view. This article builds upon an earlier article How to Test a Django TemplateView. If you're interested in learning how to test a TemplateView and you're not concerned with Django Permissions, then begin with that article instead.

Set Up

Consider the following view:


from django.views.generic import DetailView
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin, PermissionRequiredMixin

from apps.article.models import Article


class ArticleDetailView(LoginRequiredMixin, UserPassesTestMixin, PermissionRequiredMixin, DetailView):

    permission_required = ['article.view_article',]
    raise_exception = True

    template_name = 'admin_article/article-detail.html'
    model = Article
    pk_url_kwarg = 'article'

    def test_func(self):
        return self.request.user.is_staff

Here we have a view class 'ArticleDetailView' which provides admins means of inspecting an Article object without the needing to navigate to a public page. Perhaps there's a need to view an Article instance prior to it being 'published,' etc. What matters is we have content that's restricted to the public, and we want to make sure it stays that way.

First, the view class relies on a couple of Django mixins for authentication and authorization: LoginRequiredMixin, UserPassesTestMixin, and the PermissionRequiredMixin. As a reminder, the difference between authentication and authorization is that authentication verifies a user is who they say they are whereas authorization verifies a user is allowed to do the things they're requesting to do. In this case, the 'LoginRequiredMixin' ensures that the user associated with the request is 'authenticated,' and the other mixins 'UserPassesTestMixin' and 'PermissionRequiredMixin' checks that the user is 'authorized.'

Next, we have a required permission: 'article.view_article.' This permission is in the form 'app_label'.'codename.' For this view, we're checking whether the user has the permission belonging to the app 'article' with a codename 'view_article.' The attribute raise_exception is set True so that PermissionDenied is raised when a user doesn't possess the required permission prompting the 403 view. If this was set False, the user would instead be redirected to the login page.

Next, we have a template name, a model class, and a pk_url_kwarg name 'article' for use with our URL pattern. For our purposes, assume the URL patter looks like:


from django.urls import path

from admin_app.views import ArticleDetailView


app_name = "admin_article"

urlpatterns = [
    path("admin/article/<int:article>/", ArticleDetailView.as_view(), name="article-detail"),
]

Since a url namespace is used, the view would be accessed like:


# assuming some object 'article' which is an instance of the class Article

reverse("admin_article:article-detail", kwargs={"article": article.pk})

Last, we have the class method 'test_func' which relates to the mixin 'UserPassesTestMixin.' This is the test its name is referring to. For our purposes, we're testing whether our user 'is_staff.' This is a field of the Django User class, so we can access it directly with 'self.request.user.is_staff.'

The Test

Now that we know what we're testing, let's get to the fun part. First, we'll establish a bit of required data with the 'setUpTestData' and 'setUp' methods. Then we'll check permissions, followed by a URL check, and finally that the correct template is used.

Required Data

Our tests need several items to be able to function: user instances, permissions, URLs, and a template name. Since these items will be accessed by more than one test, we'll use the 'setUp' and 'setUpTestData' methods to define these attributes so we're not repeating ourselves. As a reminder, 'setUp' is run before each test whereas 'setUpTestData' is run just once when the entire test class is constructed. We'll use the former for data that could be altered by a given test (user instances, etc.) and the latter for information we don't expect to change or be affected by anything else happening in the test (the permissions, template_name, etc.). 

Let's start with setUpTestData():


from django.test import TestCase
from django.contrib.auth.models import Permission


class TestArticleDetailView(TestCase):

    @classmethod
    def setUpTestData(cls):
        perms = [
            Permission.objects.get_by_natural_key(
                codename="view_article",
                app_label="article",
                model="article",
            ),
        ]
        cls.perms = perms

        cls.template_name = "admin_article/article-detail.html"

First, we create a list of permissions. Each permission object is retrieved using the codename, app label, and model name. If the view were to require more permissions, simply add another Permission lookup to the chain within the list. Next, assign the resulting list of permissions to a class attribute so it can be accessed by our tests. And finally, store the template name so it can be checked later. Also, don't forget since this is a class method, we need to use the @classmethod decorator, and it accepts class as the first parameter rather than instance. By convention we use 'cls,' but you can use anything you'd like (though your IDE might complain if not 'cls'). In contrast, your standard instance methods accept instance as the first argument and the argument is named 'self' by convention.

Now let's add data that will be set up with each test using setUp():


from uuid import uuid4

from django.urls import reverse
from django.contrib.auth import get_user_model

from apps.article.models import Article


User = get_user_model()

class TestArticleDetailView(TestCase):
    ...

    def setUp(self) -> None:
        uuid_string = str(uuid4())

        first_name = "John"
        last_name = f"Smith-{uuid_string}"
        self.standard_user = User.objects.create(
            first_name=first_name, 
            last_name=last_name,
            email=f"{first_name}.{last_name}@company.com"

        first_name = "Jane"
        last_name = f"Doe-{uuid_string}"
        self.staff_user = User.objects.create(
            first_name=first_name, 
            last_name=last_name,
            email=f"{first_name}.{last_name}@company.com"

        self.article = Article.objects.create(title="A Title", content="Article content.")

        self.reversed_url = reverse("admin_article:article-detail", kwargs={"article": article.pk})
        self.static_url = f"/admin/article/{self.article.pk}/"

With this method, we create two user instances: a standard user and a staff user. To ensure uniqueness, I'm using uuid4 from the uuid built-in. In practice, I'll use a model factory instead to generate model instances. Third-party packages, like factory_boy can make this process simpler.

Once we have our users, we then create an Article instance and build our URLs. The Article instance is created with just two fields, 'title' and 'content' for demonstration purposes. Since the URLs are dependent on the Article instance, I built the static URL using an f-string and passed the article's primary key as an argument.

With the data established in this method as well as 'setUpTestData,' we're ready to start testing.

Test Staff Only

With our first test, 'test_staff_only,' we're going to be testing both that the user is a staff user as well as any required permissions. To accomplish this, the edge cases I'm interested in testing for are:

  • User not staff but full permission: status 403.
  • User is staff but no permission: status 403.
  • User is staff but doesn't possess all permissions required: status 403.
  • User is staff and has all required permissions: status 200.

First, let's check that a non-staff user who possesses all needed permissions is denied:


class TestArticleDetailView(TestCase):
    ...

    def test_staff_only(self):
        # test 403 for non-staff, but has perms
        self.standard_user.user_permissions.add(*self.perms)
        self.client.force_login(self.standard_user)
        response = self.client.get(self.reversed_url)
        self.assertEqual(response.status_code, 403)

We start by adding the permissions established in 'setUpTestData' to our standard_user. User permissions are stored as a many-to-many field with the name 'user_permissions.' Permission relationships can be added by passing the permissions set to 'add().' Just be sure to use the python '*' operator indicating you're passing an iterable containing permissions and not just a single permission.

Now that our user has the required permissions, we 'log in' the user to the client using self.client.force_login(self.standard_user). Then it's as simple as running a get request with the test client and checking the correct status code of 403 is produced.

With the first case covered, let's check that a staff user without permissions is denied:


class TestArticleDetailView(TestCase):
    ...

    def test_staff_only(self):
        ...

        if self.perms:
            # test 403 for is-staff, but no perms
            # only works if one or more permissions is present
            self.client.force_login(self.staff_user)
            response = self.client.get(self.reversed_url)
            self.assertEqual(response.status_code, 403)

With this block, we log in the staff user, but we don't add any permissions. This should result in permission denied. But we want to wrap this block with the conditional that one or more permissions exists. Because, if len(self.perms) == 0, we'll get a status code of 200 instead of 403 and an AssertionError will be raised.

Finally, we'll check the last two edge cases where a staff user has some but not all permissions which results in permission denied and a staff user possesses all permissions that results in a good status code.


class TestArticleDetailView(TestCase):
    ...

    def test_staff_only(self):
        ...

        # test 200 for is-staff and has perms

        # add each permission one at a time so that we know a good status code isn't granted for anything
        #   less than full permission

        # since has_perms uses strings and not the actual permission objects, we need to convert the permissions to
        #   a list of strings for the test
        perm_strings = [f"{p.content_type.app_label}.{p.codename}" for p in self.perms]
        for perm in self.perms:
            self.staff_user.user_permissions.add(perm)
            self.client.force_login(self.staff_user)
            response = self.client.get(self.reversed_url)
            if self.staff_user.has_perms(perm_strings):
                # all perms have been added to the user so this should be the last item and the status code
                #   should be 200
                self.assertEqual(response.status_code, 200)
            else:
                # we're at some stage user perms < total perms
                # we should get permission denied 403
                self.assertEqual(response.status_code, 403)

With this block, we loop through the permissions, adding a single permission with each step. As long as the user possesses at least one fewer permission than full permission, we should receive a status code of 403. Once the user has been assigned all permissions in self.perms, we should receive a status code of 200.

Test View URL

To ensure we have good URLs at the location we expect, we'll use the same approach as How to Test a Django TemplateView where we simply check for equality against a static location. Since the reversed URL is checked in other tests using the client, there isn't a need to involve the client here and generate a request/response. This will look like:


class TestArticleDetailView(TestCase):
    ...

    def test_view_url(self):
        # we already know we have a good status code with the reversed url if test_staff_only hasn't already failed
        # so, just test the reversed url against the static url to ensure the reversed url exists where we think it
        #   should

        self.assertEqual(self.reversed_url, self.static_url)

Test Uses Correct Template

Testing that we have the correct template is a bit more involved than the URL test because we need the client with a user that's both authenticated and authorized.


class TestArticleDetailView(TestCase):

    def test_uses_correct_template(self):
        self.staff_user.user_permissions.add(*self.perms)
        self.client.force_login(self.staff_user)
        response = self.client.get(self.reversed_url)
        self.assertEqual(response.status_code, 200)  # this has already been tested above
        self.assertTemplateUsed(response, self.template_name)

All Together

Combining all of the above tests results in:


from uuid import uuid4

from django.urls import reverse
from django.contrib.auth import get_user_model

from apps.article.models import Article


User = get_user_model()

class TestArticleDetailView(TestCase):
    ...

    def setUp(self) -> None:
        uuid_string = str(uuid4())

        first_name = "John"
        last_name = f"Smith-{uuid_string}"
        self.standard_user = User.objects.create(
            first_name=first_name, 
            last_name=last_name,
            email=f"{first_name}.{last_name}@company.com"

        first_name = "Jane"
        last_name = f"Doe-{uuid_string}"
        self.staff_user = User.objects.create(
            first_name=first_name, 
            last_name=last_name,
            email=f"{first_name}.{last_name}@company.com"

        self.article = Article.objects.create(title="A Title", content="Article content.")

        self.reversed_url = reverse("admin_article:article-detail", kwargs={"article": article.pk})
        self.static_url = f"/admin/article/{self.article.pk}/"

    def test_staff_only(self):
        # test 403 for non-staff, but has perms
        self.standard_user.user_permissions.add(*self.perms)
        self.client.force_login(self.standard_user)
        response = self.client.get(self.reversed_url)
        self.assertEqual(response.status_code, 403)

        if self.perms:
            # test 403 for is-staff, but no perms
            # only works if one or more permissions is present
            self.client.force_login(self.staff_user)
            response = self.client.get(self.reversed_url)
            self.assertEqual(response.status_code, 403)

        # test 200 for is-staff and has perms

        # add each permission one at a time so that we know a good status code isn't granted for anything
        #   less than full permission

        # since has_perms uses strings and not the actual permission objects, we need to convert the permissions to
        #   a list of strings for the test
        perm_strings = [f"{p.content_type.app_label}.{p.codename}" for p in self.perms]
        for perm in self.perms:
            self.staff_user.user_permissions.add(perm)
            self.client.force_login(self.staff_user)
            response = self.client.get(self.reversed_url)
            if self.staff_user.has_perms(perm_strings):
                # all perms have been added to the user so this should be the last item and the status code
                #   should be 200
                self.assertEqual(response.status_code, 200)
            else:
                # we're at some stage user perms < total perms
                # we should get permission denied 403
                self.assertEqual(response.status_code, 403)

    def test_view_url(self):
        # we already know we have a good status code with the reversed url if test_staff_only hasn't already failed
        # so, just test the reversed url against the static url to ensure the reversed url exists where we think it
        #   should

        self.assertEqual(self.reversed_url, self.static_url)

    def test_uses_correct_template(self):
        self.staff_user.user_permissions.add(*self.perms)
        self.client.force_login(self.staff_user)
        response = self.client.get(self.reversed_url)
        self.assertEqual(response.status_code, 200)  # this has already been tested above
        self.assertTemplateUsed(response, self.template_name)

Final Thoughts

Testing a staff-only view with required permissions adds complexity over testing a public view. But it's important to make sure that the views we expect to be access-restricted are protected in the ways we intended. With the above test, we cover that non-staff users are denied, staff users with less-than-full permissions are denied, and staff users with full permissions are granted access. We then check that we have good URLs and the template is what we expect it to be. With these tests, we can be confident our view is well-covered.