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.
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 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.
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.
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.