Django for Beginners /

Chapter 14: Permissions and Authorization

Course Contents

There are several issues with our current Newspaper website. For one thing, we want our newspaper to be financially sustainable. With more time, we could add a dedicated payments app to charge for access. But at a minimum, we want to add rules around permissions and authorization, such as requiring users to log in to view articles. As a mature web framework, Django has built-in authorization functionality that we can use to restrict access to the articles list page and add additional restrictions so that only the author of an article can edit or delete it.

Improved CreateView

Currently, the author on a new article can be set to any existing user. Instead, it should be automatically set to the currently logged-in user. We can modify Django's CreateView to achieve this by removing the author field and instead setting it automatically via the form_valid method.

# articles/views.py
class ArticleCreateView(CreateView):
    model = Article
    template_name = "article_new.html"
    fields = ("title", "body")  # new

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

How did I know I could update CreateView like this? The answer is that I looked at the source code and used Classy Class-Based Views, an amazing resource that breaks down how each generic class-based view works in Django. Generic class-based views are great, but when you want to customize them, you must roll up your sleeves and understand what's happening under the hood. This is the downside of class-based vs. function-based views: more is hidden, and the inheritance chain must be understood. The more you use and customize built-in views, the more comfortable you will become with making customizations like this. Generally, a specific method, like form_valid, can be overridden to achieve your desired result instead of having to rewrite everything from scratch yourself.

Now reload the browser and try clicking on the "+ New" link in the top nav. It will redirect to the updated create page where author is no longer a field.

New Article Link

If you create a new article and go into the admin, you will see it is automatically set to the currently logged-in user.

Authorizations

There are multiple issues related to the lack of authorizations for our current project. We want to restrict access to only users so we can one-day charge readers to access our newspaper. But beyond that, any random logged-out user who knows the correct URL can access any part of the site.

Consider what would happen if a logged-out user tried to create a new article. To try it out, click on your username in the upper right corner of the navbar, then select "Log Out" from the dropdown options. The "+ New" link disappears from the navbar, but what happens if you go to it directly: http://127.0.0.1:8000/articles/new/.

The page is still there.

Logged Out New

Now, try to create a new article with a title and body. Then, click on the "Save" button.

Create Page Error

An error! This is because our model expects an author field that is linked to the currently logged-in user. But since we are not logged in, there's no author, so the submission fails. What to do?

Mixins

We want to set some authorizations so only logged-in users can access specific URLs. To do this, we can use a mixin, a special kind of multiple inheritance that Django uses to avoid duplicate code and still allow customization. For example, the built-in generic ListView needs a way to return a template. But so does DetailView and almost every other view. Rather than repeat the same code in each big generic view, Django breaks out this functionality into a mixin known as TemplateResponseMixin. Both ListView and DetailView use this mixin to render the proper template.

You'll see mixins used everywhere if you read the Django source code, which is freely available on Github. To restrict view access to only logged-in users, Django has a LoginRequired mixin that we can use. It's powerful and extremely concise.

In the articles/views.py file, import LoginRequiredMixin and add it to ArticleCreateView. Make sure that the mixin is to the left of CreateView so it will be read first. We want the CreateView to know we intend to restrict access.

And that's it! We're done.

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

class ArticleCreateView(LoginRequiredMixin, CreateView):  # new
    ...

Return to the homepage at http://127.0.0.1:8000/ to avoid resubmitting the form. Navigate to http://127.0.0.1:8000/articles/new/ again to access the URL route for a new article.

Log In Redirect Page

What's happening? Django automatically redirected users to the login page! If you look closely, the URL is http://127.0.0.1:8000/accounts/login/?next=/articles/new/, which shows we tried to go to articles/new/ but were instead redirected to log in.

LoginRequiredMixin

Restricting view access requires adding LoginRequiredMixin at the beginning of all existing views. Let's update the rest of our articles views since we don't want a user to be able to create, read, update, or delete an article if they aren't logged in.

The complete views.py file should now look like this:

# articles/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView, DetailView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.urls import reverse_lazy

from .models import Article


class ArticleListView(LoginRequiredMixin, ListView):  # new
    model = Article
    template_name = "article_list.html"


class ArticleDetailView(LoginRequiredMixin, DetailView):  # new
    model = Article
    template_name = "article_detail.html"


class ArticleUpdateView(LoginRequiredMixin, UpdateView):  # new
    model = Article
    fields = (
        "title",
        "body",
    )
    template_name = "article_edit.html"


class ArticleDeleteView(LoginRequiredMixin, DeleteView):  # new
    model = Article
    template_name = "article_delete.html"
    success_url = reverse_lazy("article_list")


class ArticleCreateView(LoginRequiredMixin, CreateView):
    model = Article
    template_name = "article_new.html"
    fields = ("title", "body",)

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

Play around with the site to confirm that redirecting to the login works as expected. If you need help recalling the proper URLs, log in first and write down the URLs for each route to create, edit, delete, and list all articles.

UpdateView and DeleteView

We're progressing, but our edit and delete views are still an issue. Any logged-in user can change any article, but we want to restrict this access so that only the article's author has this permission.

We could add permissions logic to each view for this, but a more elegant solution is to create a dedicated mixin, a class with a particular feature we want to reuse in our Django code. And better yet, Django ships with a built-in mixin, UserPassesTestMixin, just for this purpose!

To use UserPassesTestMixin, first, import it at the top of the articles/views.py file and then add it to both the update and delete views where we want this restriction. The test_func method is used by UserPassesTestMixin for our logic; we need to override it. In this case, we set the variable obj to the current object returned by the view using get_object(). Then we say, if the author on the current object matches the current user on the webpage (whoever is logged in and trying to make the change), then allow it. If false, an error will automatically be thrown.

The code looks like this:

# articles/views.py
from django.contrib.auth.mixins import (
    LoginRequiredMixin,
    UserPassesTestMixin # new
)
from django.views.generic import ListView, DetailView
from django.views.generic.edit import UpdateView, DeleteView, CreateView
from django.urls import reverse_lazy

from .models import Article
...
class ArticleUpdateView(
  LoginRequiredMixin, UserPassesTestMixin, UpdateView):  # new
    model = Article
    fields = (
        "title",
        "body",
    )
    template_name = "article_edit.html"

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


class ArticleDeleteView(
  LoginRequiredMixin, UserPassesTestMixin, DeleteView):  # new
    model = Article
    template_name = "article_delete.html"
    success_url = reverse_lazy("article_list")

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

The order is critical when using mixins with class-based views. LoginRequiredMixin comes first so that we force login, then add UserPassesTestMixin for an additional layer of functionality, and finally, either UpdateView or DeleteView. The code will only work properly if you have this order.

Log in with your testuser account and go to the articles list page. If the code works, you should not be able to edit or delete any posts written by your superuser account; instead, you will see a Permission Denied 403 error page.

403 Error Page

However, if you create a new article with testuser, you will be able to edit and delete it. And if you log in with your superuser account instead, you can edit and delete posts written by that author.

Template Logic

Although we have successfully restricted access to the "edit" and "delete" pages for each article, they are still on the list all articles page and the individual article page. It would be better not to display them to users who cannot access them. In other words, we want to restrict their display to only the author of an article.

We can add simple logic to our article_list and article_detail template files by using the built-in if filter so that only the article author can see the edit and delete links.

<!-- templates/article_list.html -->
...
<div class="card-footer text-center text-muted">
  <!-- new code here -->
  {% if article.author.pk == request.user.pk %} 
  <a href="{% url 'article_edit' article.pk %}">Edit</a>
  <a href="{% url 'article_delete' article.pk %}">Delete</a>
  {% endif %} <!-- new code here -->
</div>
...
<!-- templates/article_detail.html -->
{% extends "base.html" %}

{% block content %}
<div class="article-entry">
  <h2>{{ object.title }}</h2>
  <p>by {{ object.author }} | {{ object.date }}</p>
  <p>{{ object.body }}</p>
</div>
<div>
  <!-- new code here -->
  {% if article.author.pk == request.user.pk %} 
  <p><a href="{% url 'article_edit' article.pk %}">Edit</a>
    <a href="{% url 'article_delete' article.pk %}">Delete</a>
  </p>
  {% endif %} <!-- new code here -->
  <p>Back to <a href="{% url 'article_list' %}">All Articles</a>.</p>
</div>
{% endblock content %}

To make sure that our new edit/delete logic works as intended, make sure you are logged in as testuser and create a new article using the "+ New" button in the top navbar. If you refresh the all articles webpage, only the article authored by testuser should have the edit and delete links visible.

Edit/Delete Links Not Shown

Click on the article name to navigate to its detail page. The edit and delete links are visible.

dit/Delete Links Shown for testuser

However, if you navigate to the detail page of an article created by superuser, the links are gone.

Edit/Delete Links Not Shown

Git

A quick save with Git is in order as we finish this chapter.

(.venv) $ git status
(.venv) $ git add -A
(.venv) $ git commit -m "permissions and authorizations"
(.venv) $ git push origin main

Conclusion

Our newspaper app is almost done. We could take further steps at this point, such as only displaying edit and delete links to the appropriate users, which would involve custom template tags, but overall, the app is in good shape. Our articles are correctly configured, set permissions and authorizations, and have a working user authentication flow. The last item needed is the ability for fellow logged-in users to leave comments, which we'll cover in the next chapter.