Implement a comment system for a blog application with Django

Feb. 24, 2021 • Django, Python
Cover

Introduction

In this article we'll look at the basic use-case of implementing a comment system for a blog application. While it would be relatively easy to set up the comment form on a separate page, it would be better if the comment form was integrated directly on the post detail page. This way the user does not have to go to a separate page before leaving a comment.

Of course this can be achieved using function based views. In this article however, I will show how to implement it with Django's class-based generic views and a mix-in. I will also cover form validation to make sure that users cannot submit a comment under the name of the post author.

You should already have some familiarity with Django if you want to follow along this article. I will go relatively quickly over the first five chapters setting up the basic blog app structure. Once we get to implementing the comment form, I will spend more time explaining what is going on.

You can find the full source code in my Github repo.

Setup

Create a new project directory and virtual environment, activate the environment, install Django and create a new project.

$ mkdir django-blog
$ cd django-blog
$ python3 -m venv venv
$ source venv/bin/activate    (on Mac)
$ venv/Source/activate        (on Windows)
(venv) $ pip install Django~=3.1.0
(venv) $ django-admin startproject config .

Create a new app blog in your Django project and register it in the project settings.

(venv) $ python manage.py startapp blog
# config/settings.py
INSTALLED_APPS = [
    ...
    'blog',
]

To make sure everything works until that point, start the development server and go to http://127.0.0.1:8000 in your browser. You should see the familiar welcome page with the rocket taking off.

(venv) $ python manage.py runserver

Models

Add two models, Post and Comment, to your new blog app.

# blog/models.py
from django.db import models

class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(
        'auth.User',
        on_delete=models.CASCADE,
    )
    body = models.TextField()

    def __str__(self):
        return self.title

class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    name = models.CharField(max_length=100)
    comment = models.TextField(max_length=400)
    created_on = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ['created_on']

    def __str__(self):
        return self.comment[:60]

Update the database with the new model definition.

python manage.py makemigrations blog
python manage.py migrate

At this point you can add the models to the Django admin.

# blog/admin.py
from django.contrib import admin
from .models import Post, Comment

class CommentInline(admin.StackedInline):
    model = Comment
    extra = 0

class PostAdmin(admin.ModelAdmin):
    inlines = [
        CommentInline,
    ]

admin.site.register(Post, PostAdmin)

With the help of CommentInline, which subclasses admin.StackedInline, the comments can be displayed and edited inline with the Post they belong to.

Create a superuser.

python manage.py createsuperuser

Log in the Django admin and create a few posts and comments.

admin_editpost

If you're bored of the default lorem ipsum, you can get some cool hipster ipsum here.

Views

In order to view the posts in the blog app, you need a post list view and a post detail view.

# blog/views.py
from django.views.generic import ListView, DetailView
from .models import Post


class PostListView(ListView):
    model = Post
    template_name = 'home.html'
    context_object_name = 'posts'

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

    def get_absolute_url(self):
        return reverse('post-detail', args=[str(self.pk)])

URLs

Update the urlpatterns to wire up the views to the urls.

# blog/urls.py
from django.urls import path
from .views import PostListView, PostDetailView

urlpatterns = [
    path('', PostListView.as_view(), name='home'),
    path('blog/', PostDetailView.as_view(), name='post_detail')
]

Next, register these urls to the project-level urls.

# config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('blog.urls')),

]

Templates

Now you'll need to provide the templates. I'll be using a templates folder at the root of the project to store the template files. Make sure to update the config.settings with the new directory.

# config/settings.py
TEMPLATES = [
    {
        ...
        'DIRS': [str(BASE_DIR.joinpath('templates'))],
        ...
    },
]

The base template




<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>BlogCommentstitle>
head>
<body>
  <nav>
    <h1><a href="{% url 'home' %}">Django Bloga>h1>
    <hr>
  nav>
  <main>
    {% block content %}
    {% endblock %}
  main>
body>
html>

Post list


{% extends 'base.html' %}

{% block content %}
{% for post in posts %}
<h2><a href="{% url 'post_detail' post.pk %}">{{ post.title }}a> h2>
<p>{{ post.body|truncatewords:10 }}p>
{% endfor %}
{% endblock %}

Post detail page


{% extends 'base.html' %}

{% block content %}
<h1>{{ post.title }}h1>
<p>{{ post.body|linebreaks }}p>
<hr>
<h2>Commentsh2>
{% for comment in post.comment_set.all %}
<b>{{ comment.name }}b> said <b>{{ comment.created_on|timesince }} agob>
<p>{{ comment.comment }}p>
{% endfor %}
{% endblock %}

At this point you can check the URLs to make sure everything works.

Post list view Post list view

Post detail view Post detail view

Comment Form

Now the basic framework of the blog is working and things are starting to get more interesting. Create a form for Comment, so you can use it on the post detail view.

# blog/forms.py
from django.forms import ModelForm
from django.core.exceptions import ValidationError
from .models import Comment

class CommentForm(ModelForm):
    class Meta:
        model = Comment
        fields = ['name', 'comment']

    def __init__(self, *args, **kwargs):
        """Save the request with the form so it can be accessed in clean_*()"""
        self.request = kwargs.pop('request', None)
        super(CommentForm, self).__init__(*args, **kwargs)

    def clean_name(self):
        """Make sure people don't use my name"""
        data = self.cleaned_data['name']
        if not self.request.user.is_authenticated and data.lower().strip() == 'samuel':
            raise ValidationError("Sorry, you cannot use this name.")
        return data

Let's break things down here. The CommentForm subclasses ModelForm. The Meta class specifies the model and the model fields to use for the form. But what are the other two methods about?

To understand the context here let's take a step back. If you're having your own blog, it's likely that you want to prevent other users to post comments under your own name. That's why the comment form implements the clean_name method to perform additional form validation. If a user is not logged in (that means it isn't you), they cannot use the name Samuel (or whatever name you want to reserver for yourself). By applying data.lower().strip() before the comparison, it's preventing variations of the name with upper and lower case letters as well as whitespace at either end of the string. But this logic is introducing another issue: Out of the box, the form does not have access to the request object.

To solve this issue and make the request available for the form validation, the request needs to be passed to the form from the view using key word arguments. You'll see further down in the views how to accomplish this. Within the __init__ method, the request is then extracted from the kwargs dictionary and saved with the form object.

Update the views

So how the the comment form be integrated with the PostDetailView? This page on the official Django docs provides some really helpful guidance. If you want to dig deeper into this topic, I recommend you to check out the sections above, where different approaches are discussed.

The trick here is to write two views, one for the GET and one for the POST request, and take advantage of different class-based generic views and a mixin. The view triggered by the GET request will simply display the post and a form. The view for the POST request will handle the form data when the user submits a new comment.

For the GET request, we just need to modify the PostDetailView slightly so that the form gets passed to the template with the context. We also rename it to PostDisplay to indicate we're handling only the GET requests.

# blog/views.py

class PostDisplay(DetailView):
    model = Post
    template_name = 'post_detail.html'
    context_object_name = 'post'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['form'] = CommentForm()
        return context

This was quite simple. Now on to the POST request. The Django documentation suggests to subclass FormView to get the form handling functionality and combine it with the SingleObjectMixin in order to get the post, which will be referenced by the private key as part of the URL.

# blog/views.py
from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin
from django.urls import reverse
from .forms import CommentForm

class PostComment(SingleObjectMixin, FormView):
    model = Post
    form_class = CommentForm
    template_name = 'post_detail.html'

    def post(self, request, *args, **kwargs):
        self.object = self.get_object()
        return super().post(request, *args, **kwargs)
    
    def get_form_kwargs(self):
        kwargs = super(PostComment, self).get_form_kwargs()
        kwargs['request'] = self.request
        return kwargs

    def form_valid(self, form):
        comment = form.save(commit=False)
        comment.post = self.object
        comment.save()
        return super().form_valid(form)
    
    def get_success_url(self):
        post = self.get_object()
        return reverse('post_detail', kwargs={'pk': post.pk}) + '#comments'

The PostComment view subclasses SingleObjectMixin, so model and template_name are provided.

Next, you need to override the post() method to load the object (in this case the blog post). This is important, because otherwise if the POST request raises any validation errors and the form needs to be displayed with error messages, the post object would not be available in the template (more info here).

For the FormView the form_class is provided. To handle the form data properly and save the new comment to the database, the form_valid() method is used, which gets called when the form validation has succeeded. Before saving the comment object to the database, you need to specify the post it belongs to. In order to achieve this, commit needs to be set to False when first calling the form.save() method. After that, the post relationship is set and the comment is saved to the database.

In order for the form validation to be able to check if the current user is authenticated, the get_form_kwargs() method passes the current request to the form with the kwargs. When the form is instantiated with the __init__() method, the request is taken from the kwargs and saved with the form object.

After successful handling of the form data, the user will be redirected to the url provided by get_success_url(). It returns the url of the post with #comments added to it. This way the browser will directly display the comments section of the page and the user doesn't have to scroll all the way down to see his or her new post. For this to work, you need add an id attribute to the h2 tag in the post detail template, as shown below.

Finally, combine both views together in the PostDetailView and wire them up to the get and the post method, respectively.

# blog/views.py
class PostDetailView(View):

    def get(self, request, *args, **kwargs):
        view = PostDisplay.as_view()
        return view(request, *args, **kwargs)
    
    def post(self, request, *args, **kwargs):
        view = PostComment.as_view()
        return view(request, *args, **kwargs)

Update the template

In order to display the form, the post detail template needs to be updated.

Post detail page


{% extends 'base.html' %}

{% block content %}
<h1>{{ post.title }}h1>
<p>{{ post.body }}p>
<hr>
<h2 id="comments">Commentsh2>
{% for comment in post.comment_set.all %}
<b>{{ comment.name }}b> said <b>{{ comment.created_on|timesince }} agob>
<p>{{ comment.comment }}p>
{% empty %}
<p>Feel free to leave the first comment!p>
{% endfor %}
<hr>
<h3>Add a commenth3>
<form action="" method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Submitbutton>
form>
{% endblock %}

The form will now show up on the bottom of the post detail page. Give it a try to make sure that it works.

Post detail view with form

Next you can test if the form validation works. If you're currently logged in, first go to the Django Admin and log out. Now try to submit a commit under the name Samuel (or whatever name you're preventing to be used). After submitting, it should display the form again with a message to use a different name.

Form validation

Testing

Now that the desired functionality has been implemented, it's good practice to write some automated tests to make sure things don't break in the future.

Create a new file tests.py in the blog directory with the following content.

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

from .models import Post, Comment

class BlogTest(TestCase):

    def setUp(self):
        self.user = get_user_model().objects.create_user(
            username='samuel',
            email='user@email.com',
            password='secret',
        )

        self.post = Post.objects.create(
            title="First post",
            author=self.user,
            body="Some text about travelling the world"
        )

        self.comment = Comment.objects.create(
            post = self.post,
            name = "John Doe",
            comment = "A comment on this post"
        )
    
    def test_post_list(self):
        response = self.client.get(reverse('home'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, 'First post')
        self.assertTemplateUsed(response, 'home.html')

    def test_post_detail_view(self):
        response = self.client.get(reverse('post_detail', args=[1]))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, 'First post')
        self.assertContains(response, 'Some text about travelling the world')
        self.assertContains(response, 'John Doe')
        self.assertContains(response, 'A comment on this post')
        self.assertTemplateUsed(response, 'post_detail.html')

    def test_submit_comment_logged_in(self):
        self.client.login(username='samuel', password='secret')
        url = reverse('post_detail', args=[1])
        response = self.client.post(url, {
            'name': 'Samuel',
            'comment': 'Thanks for your feedback'
        })
        self.assertEqual(response.status_code, 302)  # Found redirect
        self.assertEqual(Comment.objects.last().name, 'Samuel')
        self.assertEqual(Comment.objects.last().comment, 'Thanks for your feedback')
    

    def test_submit_comment_logged_out_fail(self):
        self.client.logout()
        last_comment = Comment.objects.last()
        url = reverse('post_detail', args=[1])
        response = self.client.post(url, {
            'name': 'Samuel', 
            'comment': 'I am not the real author',
            }
        )
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "Sorry, you cannot use this name.")
        self.assertEqual(last_comment, Comment.objects.last())
    
    def test_submit_comment_logged_out_success(self):
        self.client.logout()
        url = reverse('post_detail', args=[1])
        response = self.client.post(url, {
            'name': 'Peter',
            'comment': "I definitely have to try this!"
        })
        self.assertEqual(response.status_code, 302)  # Found redirect
        self.assertEqual(Comment.objects.last().name, 'Peter')
        self.assertEqual(Comment.objects.last().comment, "I definitely have to try this!")

To implement the tests, create a new class BlogTest that subclasses the Django TestCase. In the method setUp, the test database is filled with a user, a post and a comment.

The next two methods, test_post_detail_view and test_post_list_view should be self-explanatory.

In the next method test_submit_comment_logged_in, a comment is submitted by a logged-in user. This should result in the comment being added to the database.

The last two method test the behavior for a not logged in user. If that user tries to use the protected name (test_submit_comment_logged_out_fail), the last comment should not have changed and the template should display an error message. If the user uses a different name (test_submit_comment_logged_out_success), the comment should be saved to the database.

Run the tests with the following command:

(venv) $ python manage.py test blog

Improve the User Experience

Notice that when there are validation errors, the user has to scroll back all the way down to get to the form and see the error message? This is not very user friendly. Well, you can use some Javascript to fix this. In the last part of the template make the following changes.


...
<h3 id="comment-form">Add a commenth3>
...
{% if form.is_bound %}
<script>
  document.addEventListener("DOMContentLoaded", function () {
    document.getElementById("comment-form").scrollIntoView();
  });
script>
{% endif %}

When the form validation fails and the form is re-displayed to the user with some error messages, the form is 'bound' (read more here). The javascript snippet is thus only included in the template when the form has already been submitted with some data and is redisplayed because of form validation errors. Try it out to see how it works. You might have to put a few paragraphs into your post to see the effect.

Conclusion

This article explains how to integrate a comment form on a post detail view using Django's class-based views and a mixin. It requires a bit of digging in the Django documentation and the source code to understand the internals of the class-based views and make all the bits and pieces work together. The article also explains how to make the request object available to the form. This way it is possible in the form validation to check whether is user submitting the form is authenticated on not. Finally a bit of JavaScript is introduced to improve the user experience.

I hope you've found this article useful. If you have any questions or suggestions, please leave a comment below.