This website is made possible by displaying online advertisements to our visitors.
Please consider supporting us by disabling your ad blocker. Thank you for your support.
This website is made possible by displaying online advertisements to our visitors.
Please consider supporting us by disabling your ad blocker.

Django blog tutorial part 4: Posts and Comments

April 21 2021 Yacine Rouizi
Blog Django Testing Class Based View
Django blog tutorial part 4: Posts and Comments

Welcome to the fourth part of the Django blog tutorial series. In this part, we are going to allow users to create blog posts. We are also going to implement a comment system.

We will continue where we left off in the third part. If you want to follow along with me, download the source code of the project from this link: https://github.com/Rouizi/django-blog/tree/v0.3

Below, I included a list of the articles in this series:

  1. Django blog tutorial part 1: Project Configuration
  2. Django blog tutorial part 2: Model View Template
  3. Django blog tutorial part 3: Authentication and Profile Page
  4. Django blog tutorial 4: Posts and Comments (This post)
  5. Django blog tutorial part 5: Deployment on Heroku
  6. Django blog tutorial part 6: Setting Up an Email Service
  7. Django blog tutorial part 7: Serving Media Files From Amazon S3

Users' Posts

Now that we have users in the system, we want to offer them a way to create, edit, and delete their posts. To do so, we are going to use the built-in class-based views CreateView, UpdateView, and DeleteView.

In the beginning, it can be difficult to work with generic class-based view but they can save you a lot of time.

Before we begin, check out the ccbv.co.uk website. This can be useful when working with GCBV.

Let's start by adding the following code:

# core/views.py
from django.views.generic import (
    ListView,
    DetailView,
    CreateView,
    UpdateView,
    DeleteView
)
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from django.urls import reverse_lazy
from django.contrib import messages

# ...

class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    fields = ["title", "content", "image", "tags"]

    def get_success_url(self):
        messages.success(
            self.request, 'Your post has been created successfully.')
        return reverse_lazy("core:home")

    def form_valid(self, form):
        obj = form.save(commit=False)
        obj.author = self.request.user
        obj.slug = slugify(form.cleaned_data['title'])
        obj.save()
        return super().form_valid(form)


class PostUpdateView(LoginRequiredMixin, UpdateView):
    model = Post
    fields = ["title", "content", "image", "tags"]

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        update = True
        context['update'] = update

        return context

    def get_success_url(self):
        messages.success(
            self.request, 'Your post has been updated successfully.')
        return reverse_lazy("core:home")

    def get_queryset(self):
        return self.model.objects.filter(author=self.request.user)


class PostDeleteView(LoginRequiredMixin, DeleteView):
    model = Post

    def get_success_url(self):
        messages.success(
            self.request, 'Your post has been deleted successfully.')
        return reverse_lazy("core:home")

    def get_queryset(self):
        return self.model.objects.filter(author=self.request.user)

With these views, users have all the features to deal with the post.

To ensure that only logged in users have access to these views, we used the mixin LoginRequiredMixin.

The get_queryset() method filters the post so that only its owner can access it.

We are using the get_success_url() method to redirect to the home page and at the same time display a message. We could have used the success_url parameter to specify a URL to redirect to.

Now add the following URLs in the urls.py file:

# core/urls.py
from django.urls import path
from .views import HomeView, PostView, PostCreateView, PostUpdateView, PostDeleteView

urlpatterns = [
    # ...
    path('post/create/', PostCreateView.as_view(), name='post_create'),
    path('post/<int:pk>/', PostUpdateView.as_view(), name='post_update'),
    path('post/<int:pk>/delete/', PostDeleteView.as_view(), name='post_delete'),
]

Template Post

Since we are not specifying template names for these views, we have to follow the pattern <app_name>/<model_name>_<operation_name>.html.

For the PostCreateView, the template name is core/post_form.html:

<!-- templates/core/post_form.html -->
{% extends "base.html" %}

{% block head_title %}{% if update %}Update post{% else %}Create a post{% endif %}{% endblock head_title %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-8 offset-2">
      <h2 class=" my-5">{% if update %}Update post{% else %}Create a post{% endif %}</h2>
      <form method="post" enctype="multipart/form-data">
        {% csrf_token %}

        <div class="form-group">
          {{ form.title.label_tag }}
          <input type="text" class="form-control {% if form.title.errors %}is-invalid{% endif %}" id="id_title"
            name="title" value='{{ form.title.value|default:"" }}'>
          {% if form.title.errors %}
          <div class="invalid-feedback">{{ form.title.errors }}</div>
          {% endif %}
        </div>

        <div class="form-group">
          {{ form.content.label_tag }}
          <textarea type="text" class="form-control {% if form.content.errors %}is-invalid{% endif %}" id="id_content"
            name="content" cols="40" rows="10">{{ form.content.value|default:"" }}</textarea>
          {% if form.content.errors %}
          <div class="invalid-feedback">{{ form.content.errors }}</div>
          {% endif %}
        </div>

        <div class="form-group">
          {{ form.image.label_tag }}<br>
          <input type="file" class="{% if form.image.errors %}is-invalid{% endif %}" id="id_image" name="image"
            accept="image/*">
          {% if form.image.errors %}
          <div class="invalid-feedback">{{ form.image.errors }}</div>
          {% endif %}
        </div>

        <div class="form-group">
          {{ form.tags.label_tag }}<br>
          <select class="custom-select w-25" name="tags" id="id_tags" multiple>
            {% for name, value in form.tags.field.choices %}
            <option value="{{ name }}">{{ value }}</option>
            {% endfor %}
          </select>
          {% if form.tags.errors %}
          <div class="invalid-feedback">{{ form.tags.errors }}</div>
          {% endif %}
        </div>

        <button type="submit" class="btn btn-primary">
          {% if update %}Update the post{% else %}Create a post{% endif %}
        </button>
      </form>

    </div>
  </div>
</div>
{% endblock content %}

The PostUpdateView will also load the template core/post_form.html and the PostDeleteView will load the template core/post_confirm_delete.html. Actually, it loads a confirmation page:

<!-- templates/core/post_confirm_delete.html -->
{% extends "base.html" %}

{% block head_title %}Delete post{% endblock head_title %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-8 offset-2">
      <h2 class=" my-5">Delete post</h2>
      <form method="POST">
        {% csrf_token %}
        <p>Are you sure you want to delete it?</p>
        <button type="submit" class="btn btn-danger">Submit
        </button>
      </form>
    </div>
  </div>
</div>
{% endblock content %}

To make it easy for users to access these views, let's add some links:

<!-- templates/base.html -->

<!-- ... -->

{% if request.user.is_authenticated %}
<div class="navbar-nav ml-auto">
  <a href="{% url 'core:post_create' %}"
    class="nav-item nav-link {% if request.path == '/post/create/' %}active{% endif %}">
    Create a post
  </a>
  <!-- ... -->
{% endif %}

<!-- ... -->
<!-- templates/core/post.html -->

<!-- ... -->

{% if post.image %}
  <img class="card-img-top" src="{{ post.image.url }}" alt="{{ post.title }}">
{% endif %}
{% if post.author == request.user %}
<div class="mt-4 mx-3">
  <a class="btn btn-primary" href="{% url 'core:post_update' post.id %}">Edit</a>
  <a class="btn btn-danger" href="{% url 'core:post_delete' post.id %}">Delete</a>
</div>
{% endif %}
<div class="card-text mt-5 p-4">
  {{ post.content }}
</div>

<!-- ... -->

Create Post

Edit Delete Links

Delete Post

Testing the Post Views

We are going to test that the user cannot update a post of another user and that the post creation is associated with the current user:

# core/test.py
from django.urls import reverse
from django.test import TestCase

from .models import Post
from users.models import User


class PostCreateViewTest(TestCase):
    def test_post_create_stores_user(self):
        user1 = User.objects.create_user(
            username='user1', email='user1@gmail.com', password='1234'
        )
        post_data = {
            'title': 'test post',
            'content': 'Hello world',
        }
        self.client.force_login(user1)
        self.client.post(reverse('core:post_create'), post_data)

        self.assertTrue(Post.objects.filter(author=user1).exists())


class PostUpdateViewTest(TestCase):
    def test_post_update_returns_404(self):
        user1 = User.objects.create_user(
            username='user1', email='user1@gmail.com', password='1234'
        )
        user2 = User.objects.create_user(
            username='user2', email='user2@gmail.com', password='1234'
        )
        post = Post.objects.create(
            author=user1, title='test post', content='Hello world')

        self.client.force_login(user2)
        response = self.client.post(
            reverse('core:post_update', kwargs=({'pk': post.id})),
            {'title': 'change title'}
        )
        self.assertEqual(response.status_code, 404)

Comments System

Let's start with the model:

# core/models.py

# ...

class Comment(models.Model):
    name = models.CharField(max_length=50)
    email = models.EmailField(max_length=100)
    content = models.TextField()
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    created = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ('-created',)

    def __str__(self):
        return 'Comment by {}'.format(self.name)

Generate the migrations and apply them to the database:

(venv) $ python manage.py makemigrations 
(venv) $ python manage.py migrate

Comment Form from the Comment Model

Create a file form.py in the core app:

# core/forms.py
from django import forms
from .models import Comment

class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ('name', 'email', 'content')

ModelForm is a special type of form. These are forms that are automatically generated from a model. You can include fields via the fields attribute.

The view

We will extend the PostView to handle the logic of comment:

# core/views.py
# ...
from .models import Post, Comment
from .forms import CommentForm

# ...

class PostView(DetailView):
    model = Post
    template_name = "core/post.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        pk = self.kwargs["pk"]
        slug = self.kwargs["slug"]

        form = CommentForm()
        post = get_object_or_404(Post, pk=pk, slug=slug)
        comments = post.comment_set.all()

        context['post'] = post
        context['comments'] = comments
        context['form'] = form
        return context

    def post(self, request, *args, **kwargs):
        form = CommentForm(request.POST)
        self.object = self.get_object()
        context = super().get_context_data(**kwargs)

        post = Post.objects.filter(id=self.kwargs['pk'])[0]
        comments = post.comment_set.all()

        context['post'] = post
        context['comments'] = comments
        context['form'] = form

        if form.is_valid():
            name = form.cleaned_data['name']
            email = form.cleaned_data['email']
            content = form.cleaned_data['content']

            comment = Comment.objects.create(
                name=name, email=email, content=content, post=post
            )

            form = CommentForm()
            context['form'] = form
            return self.render_to_response(context=context)

        return self.render_to_response(context=context)

Basically, what we've done is that when a post request is made, we get all the comments for the current post. And if the form is valid, we save the new comment and assign it to the current post, otherwise, we initialize the form with the post data and send it to the template. Note that even when the form is valid, we instantiate an object form and send it to the template, because the post request returns to the same page

Adding Comments in the Post Page

Let's edit the post page to include all comments for a post as well as a form to leave a comment:

<!-- templates/core/post.html -->
{% extends 'base.html' %}
{% load static %}  <!-- add this line -->
{% block head_title %}{{ post.title }}{% endblock %}
{% block content %}
<div class="container-fluid my-5">

<!-- ... -->

 <!-- List of comments -->
  {% if comments %}
  <div class="row mt-5">
    <div class="col-lg-6 offset-lg-3">
      Comment{{ comments.count|pluralize }}
      <span class="badge badge-dark ml-2">{{ comments.count }}</span>
    </div>
    {% for comment in comments %}
    <div class="col-lg-6 offset-lg-3 mt-2">
      <div class="card p-2">
        <div class="row">
          <div class="col-12">
            <img class="rounded-circle mr-2" src="{% static 'img/avatar.svg' %}" alt="Avatar">
            <strong>{{ comment.name }}</strong> said
          </div>
          <div class="col-12">
            <p class="m-1 mt-3">{{ comment.content }}</p>
            <p class="text-right text-muted"><small>{{ comment.created }}</small></p>
          </div>
        </div>
      </div>
    </div>
    {% endfor %}
  </div>
  {% endif %}

  <!-- Form to leave comment -->
  <div class="row mt-5">
    <div class="col-lg-6 offset-lg-3">
      <h3>Leave a comment</h3>
      <form method='POST'>
        {% csrf_token %}
        <div class="form-group">
          <span class="ml-2"></span>{{ form.name.label_tag }}
          <input type="text" class="form-control {% if form.name.errors %}is-invalid{% endif %}" id="id_name"
            name="name" value="{{ form.name.value|default:'' }}">
        </div>
        <div class="form-group">
          <span class="ml-2"></span>
          {{ form.email.label_tag }}
          <span class="text-muted"><small>(Your email address will not be published)</small></span>
          <input type="text" class="form-control {% if form.email.errors %}is-invalid{% endif %}" id="id_email"
            name="email" value="{{ form.email.value|default:'' }}">
        </div>
        <div class="form-group">
          <span class="ml-2"></span>{{ form.content.label_tag }}
          <textarea class="form-control {% if form.content.errors %}is-invalid{% endif %}" id="id_content"
            name="content" rows="4">{{ form.content.value|default:'' }}</textarea>
        </div>
        <button class="btn btn-primary ml-2" type="submit">Reply</button>
      </form>
    </div>
  </div>

</div>
{% endblock content %}

The template itself is quite simple. The two things to note are that we are using comments.count, this is equivalent to comments.count() in views. This displays the number of comments.

We are also using the template filter pluralize, this adds the suffix 's' to the word comment if the number of comments is above 1.

Now let's run the server and see the end result:

Comments

This completes our fourth tutorial. We can improve the comment feature a little bit. For example, if a user is logged in, we can fill out the comment form with his username and email address. We can also allow visiting a user's profile from his comment. You can do that as an exercise, it's not that hard.

You can find the source code of the project, including this chapter, at this link: https://github.com/Rouizi/django-blog/tree/v0.4

If you want to say something please leave a comment below. Also, don't forget to subscribe to the mailing list to be notified of future posts. See you in the next part.

Comments 18
Avatar Roʜɩt Kʋɱʌʀ said
Hey, this is very nice article and my all dot clear. thank you so much.

July 4, 2021, 9:33 p.m.

Avatar Yacine said
Thanks Roʜɩt Kʋɱʌʀ. I am glad you found my article useful.

July 4, 2021, 9:40 p.m.

Avatar Lau said
Hello, I loved your project, would you like to help me implement more things? How can I do so that the tags can filter them from a menu on the home page?
for example "categories"> tag1, tag2, tag3
I use your same repository, thank you very much :D

Dec. 1, 2021, 10:15 p.m.

Avatar Yacine said
Hi Lau,
Thank you for the positive feedback. I am really glad you find this series helpful!
I think to filter by tags you just need to do  "model_name.objects.filter(tag=tag-name)" in the appropriate view.
Hope this will help.

Dec. 1, 2021, 10:42 p.m.

Avatar Lau said
Thanks for the help, I still don't know how to implement it

Dec. 2, 2021, 12:24 a.m.

Avatar Yacine said
For example, if you want to add the filter by tag feature on the home page then you should add the following code in the HomeView:
"""
tag = self.request.GET.get('tag', None)
if tag is not None:
    posts_by_tag = Post.objects.filter(tags__name=tag)
    if posts_by_tag.exists():
        return redirct(self.request, "core:post_by_tag.html")
# ...
tags = Tag.objects.all().distinct()
context['tags'] = tags
return context
"""
Or something like that.

Dec. 2, 2021, 10:13 a.m.

Avatar Lau said
Thanks I was able to fix it :))
I ask you one last question, do you know why all the text is pressing after writing an article? Is it possible to leave spaces between paragraphs?
https://ibb.co/c8b3dbz

Dec. 6, 2021, 6:13 p.m.

Avatar Yacine said
I'm glad you were able to solve your problem Lau.
I think that to be able to leave spaces between paragraphs you'll need to wrap the text in a <pre></pre> tag. Try it and let me know if this will solve this issue.

Dec. 6, 2021, 6:31 p.m.

Avatar Lau said
no, it appears this way
https://ibb.co/jGM3syx

Dec. 6, 2021, 9:51 p.m.

Avatar Yacine said
You are almost there. Now just add the CSS style "white-space: pre-wrap;" to the <pre></pre> tag. This should fix the problem

Dec. 6, 2021, 10:06 p.m.

Avatar vintage info solutions said
Nice blog. The content was informative and useful. Thanks for sharing.

Jan. 1, 2022, 8:18 a.m.

Avatar Yacine said
Thank you. I am glad you find something useful here.

Jan. 1, 2022, 12:19 p.m.

Avatar Clintonplelt said
I hope to give something back and help others like you helped me.

Feb. 6, 2022, 9:20 p.m.

Avatar Yacine said
Thank you @Clintonplelt. By leaving a positive comment, you are helping me :)

If you want you can help by buying my course on computer vision.
Learn more here: https://dontrepeatyourself.teachable.com/p/computer-vision-with-opencv-and-python

Feb. 7, 2022, 8:48 a.m.

Avatar ellie hodjayev said
How am i able to replace the `post = Movie.objects.filter(id=self.kwargs['pk'])[0]` field? since my code does not have a PK field ..

class MovieDetail(DetailView):
    model = Movie

    def render_to_response(self, *args, **kwargs):
        self.object.refresh_from_db()
        self.object.views_count += 1
        self.object.save()
        return super().render_to_response(*args, **kwargs)

    def get_context_data(self, **kwargs):
        context = super(MovieDetail, self).get_context_data(**kwargs)
        context['links'] = MovieLink.objects.filter(movie=self.get_object())
        context['related_movies'] = Movie.objects.filter(category=self.get_object().category)
        return context
    
    def post(self, request, *args, **kwargs):
        form = CommentForm(request.POST)
        self.object = self.get_object()
        context = super().get_context_data(**kwargs)

        post = Movie.objects.filter(id=self.kwargs['pk'])[0]
        comments = post.comment_set.all()

        context['post'] = post
        context['comments'] = comments
        context['form'] = form

        if form.is_valid():
            name = form.cleaned_data['name']
            email = form.cleaned_data['email']
            content = form.cleaned_data['content']

            comment = Comment.objects.create(
                name=name, email=email, content=content, post=post
            )

            form = CommentForm()
            context['form'] = form
            return self.render_to_response(context=context)

        return self.render_to_response(context=context)

June 6, 2022, 3:23 p.m.

Avatar Yacine said
@ellie In Django, models do have a PK field it's just that the field is implicit. You can access the PK field though

June 6, 2022, 6:24 p.m.

Avatar Rafael Benes said
Hi dontrepeatyourself.org owner, Your posts are always well-written and easy to understand.

Feb. 15, 2023, 11:41 a.m.

Avatar Yacine said
Thank you Rafael for the positive feedback. I am glad you find my content helpful :)

Feb. 15, 2023, 2:02 p.m.

Leave a comment

(Your email address will not be published)