Django for Beginners /

Chapter 15: Comments

Course Contents

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

Admin Homepage with Comments

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.

Admin Comments

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.

Admin testuser Comment

You should next see your comment on the admin "Comments" page.

Admin Comment One

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.

Admin Change Page

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

Admin No Extra Comments

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.

TabularInline Page

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 }} &middot;
      </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.

Articles Page with Comments

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 }} &middot; {{ 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.

Article Details Page with Comments

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 }} &middot; {{ 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.

Form Displayed

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.

Submit Comment in Form

It should automatically reload the page with the new comment displayed like this:

Comment Displayed

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.

New Comment on All Articles Page

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.