Chapter 15: Comments
Course Contents
- Changelog
- Foreword
- Introduction
- Chapter 1: Initial Set Up
- Chapter 2: Hello, World Website
- Chapter 3: Personal Website
- Chapter 4: Company Website
- Chapter 5: Message Board Website
- Chapter 6: Blog Website
- Chapter 7: Forms
- Chapter 8: User Accounts
- Chapter 9: Newspaper Project
- Chapter 10: User Authentication
- Chapter 11: Bootstrap
- Chapter 12: Password Change and Reset
- Chapter 13: Articles App
- Chapter 14: Permissions and Authorization
- Chapter 15: Comments
- Chapter 16: Deployment
- Chapter 17: Conclusion
We could add comments to our Newspaper site in two ways. The first is to create a dedicated comments app and link it to articles; however, that seems like over-engineering. Instead, we can add a model called Comment
to our articles app and link it to the Article
model through a foreign key. We will take the more straightforward approach since adding more complexity later is always possible.
The whole Django structure of one project containing multiple smaller apps is designed to help the developer reason about the website. The computer doesn't care how the code is structured. Breaking functionality into smaller pieces helps us--and future teammates--understand the logic in a web application. But you don't need to optimize prematurely. If your eventual comments logic becomes lengthy, then yes, by all means, spin it off into its own comments
app. But the first thing to do is make the code work, make sure it is performant, and structure it, so it's understandable to you or someone else months later.
What do we need to add comments functionality to our website? We already know it will involve models, URLs, views, templates, and in this case, forms. We need all four for the final solution, but the order in which we tackle them is largely up to us. Many Django developers find that going in this order--models -> URLs -> views -> templates/forms--works best, so that is what we will use here. By the end of this chapter, users will be able to add comments to any existing article on our website.
Model
Let's begin by adding another table to our existing database called Comment
. This model will have a many-to-one foreign key relationship to Article
: one article can have many comments, but not vice versa. Traditionally the name of the foreign key field is simply the model it links to, so this field will be called article
. The other two fields will be comment
and author
.
Open up the file articles/models.py
and underneath the existing code, add the following. Note that we include __str__
and get_absolute_url
methods as best practices.
# articles/models.py
...
class Comment(models.Model): # new
article = models.ForeignKey(Article, on_delete=models.CASCADE)
comment = models.CharField(max_length=140)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
)
def __str__(self):
return self.comment
def get_absolute_url(self):
return reverse("article_list")
Since we've updated our models, it's time to make a new migration file and apply it. Note that by adding articles
at the end of the makemigrations
command--which is optional--we are specifying we want to use just the articles
app here. This is a good habit because consider what would happen if we changed models in two different apps? If we did not specify an app, then both apps' changes would be incorporated in the same migrations file, making it harder to debug errors in the future. Keep each migration as small and contained as possible.
(.venv) $ python manage.py makemigrations articles
Migrations for 'articles':
articles/migrations/0002_comment.py
- Create model Comment
(.venv) $ python manage.py migrate
Operations to perform:
Apply all migrations: accounts, admin, articles, auth, contenttypes, sessions
Running migrations:
Applying articles.0002_comment... OK
Admin
After making a new model, it's a good idea to play around with it in the admin app before displaying it on our website. Add Comment
to our admin.py
file so it will be visible.
# articles/admin.py
from django.contrib import admin
from .models import Article, Comment # new
class ArticleAdmin(admin.ModelAdmin):
list_display = [
"title",
"body",
"author",
]
admin.site.register(Article, ArticleAdmin)
admin.site.register(Comment) # new
Then start the server with python manage.py runserver
and navigate to our main page http://127.0.0.1:8000/admin/
.
Under the "Articles" app, you'll see our two tables: Comments and Articles. Click on the "+ Add" next to Comments. There are dropdowns for Article, Author, and a text field next to Comment.
Select an article, write a comment, and then choose an author that is not your superuser, perhaps testuser
as I've done in the picture. Then click on the "Save" button.
You should next see your comment on the admin "Comments" page.
At this point, we could add an additional admin field to see the comment and the article on this page. But wouldn't it be better to see all Comment
models related to a single Article
model? We can with a Django admin feature called inlines, which displays foreign key relationships in a visual way.
There are two main inline views used: TabularInline and StackedInline. The only difference between the two is the template for displaying information. In a TabularInline, all model fields appear on one line; in a StackedInline, each field has its own line. We'll implement both so you can decide which one you prefer.
Update articles/admin.py
in your text editor to add the StackedInline view.
# articles/admin.py
from django.contrib import admin
from .models import Article, Comment
class CommentInline(admin.StackedInline): # new
model = Comment
class ArticleAdmin(admin.ModelAdmin): # new
inlines = [
CommentInline,
]
list_display = [
"title",
"body",
"author",
]
admin.site.register(Article, ArticleAdmin) # new
admin.site.register(Comment)
Now go to the main admin page at http://127.0.0.1:8000/admin/
and click "Articles." Select the article you just commented on.
Better, right? We can see and modify all our related articles and comments in one place. Note that, by default, the Django admin will display three empty rows here. You can change the default number that appears with the extra
field. So if you wanted no extra fields by default, the code would look like this:
# articles/admin.py
...
class CommentInline(admin.StackedInline):
model = Comment
extra = 0 # new
Personally, though, I prefer using TabularInline
as it shows more information in less space: the comment, author, and more on one single line. To switch to it, we only need to change our CommentInline
from admin.StackedInline
to admin.TabularInline
.
# articles/admin.py
from django.contrib import admin
from .models import Article, Comment
class CommentInline(admin.TabularInline): # new
model = Comment
extra = 0 #
class ArticleAdmin(admin.ModelAdmin):
inlines = [
CommentInline,
]
list_display = [
"title",
"body",
"author",
]
admin.site.register(Article, ArticleAdmin)
admin.site.register(Comment)
Refresh the current admin page for Articles and you'll see the new change: all fields for each model are displayed on the same line.
Much better. Now we need to display the comments on our website by updating our template.
Template
We want comments to appear on the articles list page and allow logged-in users to add a comment on the detail page for an article. That means updating the template files article_list.html
and article_detail.html
.
Let's start with article_list.html
. If you look at the articles/models.py
file again, it is clear that Comment has a foreign key relationship to the article. To display all comments related to a specific article, we will follow the relationship backward via a "query," which is a way to ask the database for a specific bit of information. Django has a built-in syntax known as FOO_set
where FOO
is the lowercase source model name. So for our Article
model, we use the syntax {% for comment in article.comment_set.all %}
to view all related comments. And then, within this for
loop, we can specify what to display, such as the comment
itself and author
.
Here is the updated article_list.html
file --the changes start after the "card-body"
div class.
<!-- templates/article_list.html -->
...
<div class="card-body">
{{ article.body }}
{% 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 %}
</div>
<div class="card-footer">
{% for comment in article.comment_set.all %}
<p>
<span class="fw-bold">
{{ comment.author }} ·
</span>
{{ comment }}
</p>
{% endfor %}
</div>
</div>
<br/>
{% endfor %}
{% endblock content %}
If you refresh the articles page at http://127.0.0.1:8000/articles/
, we can see our new comment on the page.
Let's also add comments to the detail page for each article. We'll use the same technique of following the relationship backward to access comments as a foreign key of the article model.
<!-- 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>
<!-- Changes start here! -->
<hr>
<h4>Comments</h4>
{% for comment in article.comment_set.all %}
<p>{{ comment.author }} · {{ comment }}</p>
{% endfor %}
<hr>
<!-- Changes end 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 %}
<p>Back to <a href="{% url 'article_list' %}">All Articles</a>.</p>
{% endblock content %}
Navigate to the detail page of your article with a comment, and any comments will be visible.
We won't win any design awards for this layout, but this is a book on Django, so our goal is to output the correct content.
Comment Form
The comments are now visible, but we need to add a form so users can add them to the website. Web forms are a very complicated topic since security is essential: any time you accept data from a user that will be stored in a database, you must be highly cautious. The good news is Django forms handle most of this work for us.
ModelForm is a helper class that translates database models into forms. We can use it to create a form called, appropriately enough, CommentForm
. We could put this form in our existing articles/models.py
file, but generally, the best practice is to put all forms in a dedicated forms.py
file within your app. That's the approach we'll use here.
With your text editor, create a new file called articles/forms.py
. At the top, import forms
, which has ModelForm
as a module. Then import our model, Comment
, since we'll need to add that, too. Finally, create the class CommentForm
, specifying both the underlying model and the specific field to expose, comment
. When we create the corresponding view, we will automatically set the author
to the currently logged-in user.
# articles/forms.py
from django import forms
from .models import Comment
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ("comment",)
Web forms can be incredibly complex, but Django has thankfully abstracted away much of the complexity for us.
Comment View
Currently, we rely on the generic class-based DetailView
to power our ArticleDetailView
. It displays individual entries but needs to be configured to add additional information like a form. Class-based views are powerful because their inheritance structure means that if we know where to look, there is often a specific module we can override to attain our desired outcome.
The one we want in this case is called get_context_data(). It is used to add information to a template by updating the context, a dictionary object containing all the variable names and values available in our template. For performance reasons, Django templates compile only once; if we want something available in the template, it must load into the context at the beginning.
What do we want to add in this case? Well, just our CommentForm
. And since context is a dictionary, we must also assign a variable name. How about form
? Here is what the new code looks like in articles/views.py
.
# articles/views.py
...
from .models import Article
from .forms import CommentForm # new
class ArticleDetailView(LoginRequiredMixin, DetailView):
model = Article
template_name = "article_detail.html"
def get_context_data(self, **kwargs): # new
context = super().get_context_data(**kwargs)
context["form"] = CommentForm()
return context
Near the top of the file, just above from .models import Article
, we added an import line for CommentForm
and then updated the module for get_context_data()
. First, we pulled all existing information into the context using super()
, added the variable name form
with the value of CommmentForm()
, and returned the updated context.
Comment Template
To display the form in our article_detail.html
template file, we'll rely on the form
variable and crispy forms. This pattern is the same as what we've done before in our other forms. At the top, load crispy_form_tags
, create a standard-looking post
form that uses a csrf_token
for security, and display our form fields via {{ form|crispy }}
.
<!-- templates/article_detail.html -->
{% extends "base.html" %}
{% load crispy_forms_tags %} <!-- new! -->
{% block content %}
<div class="article-entry">
<h2>{{ object.title }}</h2>
<p>by {{ object.author }} | {{ object.date }}</p>
<p>{{ object.body }}</p>
</div>
<hr>
<h4>Comments</h4>
{% for comment in article.comment_set.all %}
<p>{{ comment.author }} · {{ comment }}</p>
{% endfor %}
<hr>
<!-- Changes start here! -->
<h4>Add a comment</h4>
<form action="" method="post">{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success ms-2" type="submit">Save</button>
</form>
<!-- Changes end here! -->
<div>
{% 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 %}
<p>Back to <a href="{% url 'article_list' %}">All Articles</a>.</p>
</div>
{% endblock content %}
If you refresh the detail page, the form is now displayed with familiar Bootstrap and crispy forms styling.
Success! However, we are only half done. If you attempt to submit the form, you'll receive an error because our view doesn't yet support any POST
methods!
Comment Post View
We ultimately need a view that handles both GET
and POST
requests depending upon whether the form should be merely displayed or capable of being submitted. We could reach for FormMixin to combine both into our ArticleDetailView
, but as the Django docs illustrate quite well, there are risks with this approach.
To avoid subtle interactions between DetailView
and FormMixin
, we will separate the GET
and POST
variations into their dedicated views. We can then transform ArticleDetailView
into a wrapper view that combines them. This is a very common pattern in more advanced Django development because a single URL must often behave differently based on the user request (GET
, POST
, etc.) or even the format (returning HTML vs. JSON).
Let's start by renaming ArticleDetailView
into CommentGet
since it handles GET
requests but not POST
requests. We'll then create a new CommentPost
view that is empty for now. And we can combine both CommentPost
and CommentGet
into a new ArticleDetailView
that subclasses View, the foundational class upon which all other class-based views are built.
# articles/views.py
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views import View # new
from django.views.generic import ListView, DetailView
...
class CommentGet(DetailView): # new
model = Article
template_name = "article_detail.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["form"] = CommentForm()
return context
class CommentPost(): # new
pass
class ArticleDetailView(LoginRequiredMixin, View): # new
def get(self, request, *args, **kwargs):
view = CommentGet.as_view()
return view(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
view = CommentPost.as_view()
return view(request, *args, **kwargs)
...
Navigate back to the homepage in your web browser and then reload the article page with a comment. Everything should work as before.
We're ready to write CommentPost
and complete the task of adding comments to our website. We are almost done!
FormView is a built-in view that displays a form, any validation errors, and redirects to a new URL. We will use it with SingleObjectMixin to associate the current article with our form; in other words, if you have a comment at articles/4/
, as I do in the screenshots, then SingleObjectMixin
will grab the 4
so that our comment is saved to the article with a pk
of 4.
Here is the complete code, which we'll run through below line-by-line.
# articles/views.py
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views import View
from django.views.generic import ListView, DetailView, FormView # new
from django.views.generic.detail import SingleObjectMixin # new
from django.views.generic.edit import UpdateView, DeleteView, CreateView
from django.urls import reverse_lazy, reverse # new
from .forms import CommentForm
from .models import Article
...
class CommentPost(SingleObjectMixin, FormView): # new
model = Article
form_class = CommentForm
template_name = "article_detail.html"
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)
def form_valid(self, form):
comment = form.save(commit=False)
comment.article = self.object
comment.author = self.request.user
comment.save()
return super().form_valid(form)
def get_success_url(self):
article = self.object
return reverse("article_detail", kwargs={"pk": article.pk})
...
At the top, import FormView
, SingleObjectMixin
, and reverse
. FormView relies on form_class
to set the form name we're using, CommentForm
. First up is post()
: we use get_object()
from SingleObjectMixin
to grab the article pk
from the URL. Next is form_valid()
, which is called when form validation has succeeded. Before we save our comment to the database, we must specify the article it belongs to. Initially, we save the form but set commit
to False
because we associate the correct article with the form object in the next line. We also set the author
field in our Comment
model to the current user. In the following line, we save the form. Finally, we return it as part of form_valid()
. The final module, get_success_url()
, is called after the form data is saved; we redirect the user to the current page.
And we're done! Go ahead and load your articles page now, refresh the page, then try to submit a second comment.
It should automatically reload the page with the new comment displayed like this:
New Comment Link
Although you can add a comment to an article from its detail page, it is far more likely that a reader will want to comment on something from the all articles page. They can do that if they know to click on the detail link but that is not very good user design. Let's add a "New Comment" link to each article listed; it will navigate to the detail page but allow for that functionality. Do so by adding one line to the card-body
section outside the if/endif
loop.
<!-- templates/article_list.html -->
...
<div class="card-body">
<p>{{ article.body }}</p>
{% 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 %}
<a href="{{ article.get_absolute_url }}">New Comment</a> <!-- new -->
</div>
Refresh the all articles webpage to see the change and then click the "New Comment" link to confirm it works as expected.
Git
We added quite a lot of code in this chapter. Let's make sure to save our work before the upcoming final chapter.
(.venv) $ git status
(.venv) $ git add -A
(.venv) $ git commit -m "comments app"
(.venv) $ git push origin main
Conclusion
Our Newspaper app is now complete. It has a robust user authentication flow that uses a custom user model, articles, comments, and improved styling with Bootstrap. We even dipped our toes into permissions and authorizations.
The remaining task is to deploy it online. In the next chapter, we'll see how to properly deploy a Django site using environment variables, PostgreSQL, and additional settings.