We'll build a simple app that shows user comments in 9 minutes. This includes:
- allowing users to add comments and reply to comments.
- showing each user's profile image (using the Gravatar API).
- adding sample comments into the Django database from yaml (using the Django
loaddata
management command).
To see a full demo of the app, check out the Circumeo link at the end of the article.
Here's a video of our final product with comments 🖊️:
Optional video guide (featuring me 🏇🏿) here:
Let's get cooking 👨🍳 (i.e., coding)
Set up our Django app
- Install packages and create our Django app
pip install --upgrade django pyyaml
django-admin startproject core .
python3 manage.py startapp sim
- Add our app
sim
to the INSTALLED_APPS. - Add the
humanize
app to INSTALLED_APPS. We'll use this to show how long ago a comment was published.
# settings.py
INSTALLED_APPS = [
"django.contrib.humanize",
"sim",
...
]
Add templates
- Create a folder named
templates
in thesim
app. - Create a file
base.html
in thetemplates
directory.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
#container {
display: flex;
justify-content: center;
padding: 10px;
}
#reply-container {
padding: 10px;
}
#reply-container,
.comments {
background-color: #f6f6ef;
}
.comment-head {
display: flex;
gap: 8px;
font-size: 10px;
color: #828282;
margin-bottom: 4px;
}
.comment {
font-family: Verdana, Geneva, sans-serif;
font-size: 9pt;
}
.comment-body {
margin-bottom: 4px;
}
.reply-link {
display: inline-block;
margin-bottom: 8px;
color: #000;
}
</style>
</head>
<body>
<div id="container">
{% block content %}{% endblock %}
</div>
</body>
</html>
- Create a folder called
partials
in thetemplates
folder. - Create a file
_comment.html
in thetemplates/partial
folder:
{% load humanize %}
{% load custom_tags %}
<table>
<tr>
<td>
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
width="{% if indent %}40{% else %}0{% endif %}"
height="1"/>
</td>
<td>
<div class="comment">
<div class="comment-head">
<div class="author-gravatar">
<img src="https://www.gravatar.com/avatar/{{ comment.author.username|md5 }}?d=retro&s=12" alt="Gravatar"/>
</div>
<div class="comment-author">{{ comment.author.username }}</div>
<div class="comment-date">{{ comment.created_at|naturaltime }}</div>
</div>
<div class="comment-body">{{ comment.content }}</div>
<font size="1">
<a href="{% url 'reply' comment_id=comment.id %}" class="reply-link">Reply</a>
</font>
{% if children %}
<div class="children">
{% for child in comment.get_children %}
{% include "partials/_comment.html" with comment=child children=True indent=True %}
{% endfor %}
</div>
{% endif %}
</div>
</td>
</tr>
</table>
- Create a file
comments.html
in thetemplates
directory:
{% extends "base.html" %}
{% block content %}
<table class="comments">
{% for comment in root_comments %}
<tr>
<td>{% include "partials/_comment.html" with comment=comment children=True indent=False %}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
- Create a file
reply.html
in thetemplates/sim
directory:
{% extends "base.html" %}
{% block content %}
<div id="reply-container">
<div>
{% include "partials/_comment.html" with comment=comment children=False indent=False %}
</div>
<div>
<form method="post">
{% csrf_token %}
<textarea name="content" rows="8" cols="80" autofocus="true"></textarea>
<div>
<button type="submit" style="margin-top: 10px">Submit</button>
</div>
</form>
</div>
</div>
{% endblock %}
Add custom template tags
The Gravatar API expects an MD5 hash of the username. By using a hash, the API never sees any user data, but will return a consistent avatar.
- Add the
sim/templatetags
directory. - Create the
custom_tags.py
file within thesim/templatetags
folder.
from django import template
import hashlib
register = template.Library()
@register.filter
def md5(value):
return hashlib.md5(value.encode('utf-8')).hexdigest()
Now our templates can use the md5
filter.
Add forms
- Copy the below into
sim/forms.py
:
from django import forms
from .models import Comment
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ["content"]
Add views
- Copy the below into
sim/views.py
:
import uuid
from django.contrib.auth.models import User
from django.shortcuts import render, redirect
from django.contrib.auth import login
from .forms import CommentForm
from .models import Comment
def comments(request):
root_comments = Comment.objects.filter(parent=None)
return render(request, "comments.html", {"root_comments": root_comments})
def reply(request, comment_id):
parent_comment = Comment.objects.get(pk=comment_id)
if request.method == "POST":
form = CommentForm(request.POST)
if form.is_valid():
new_comment = form.save(commit=False)
new_comment.parent = parent_comment
if request.user.is_authenticated:
new_comment.author = request.user
else:
# Create an anonymous user so that we don't have to be logged in
# to make comments or replies.
anonymous_username = f'Anonymous_{uuid.uuid4().hex[:8]}'
anonymous_user, created = User.objects.get_or_create(username=anonymous_username)
if created:
anonymous_user.save()
login(request, anonymous_user)
new_comment.author = anonymous_user
new_comment.save()
return redirect("comments")
else:
form = CommentForm()
return render(request, "reply.html", {"comment": parent_comment, "form": form})
Urls
- Update
core.urls
with the below:
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("sim.urls")),
]
- Create a file
urls.py
insim
, containing:
from django.urls import path
from . import views
urlpatterns = [
path("", views.comments, name="comments"),
path("reply/<int:comment_id>", views.reply, name="reply"),
]
Add the database structure for storing comments
Add models.py
- Copy the below into
sim/models.py
:
from django.contrib.auth import get_user_model
from django.conf import settings
from django.db import models
from django.utils import timezone
User = get_user_model()
class Comment(models.Model):
content = models.TextField()
created_at = models.DateTimeField(default=timezone.now, db_index=True)
parent = models.ForeignKey(
"self",
on_delete=models.CASCADE,
related_name="children",
db_index=True,
null=True,
blank=True,
)
author = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="comments", db_index=True
)
class Meta:
ordering = ["created_at"]
def is_root_comment(self):
"""Check if this comment is a root comment (no parent)"""
return self.parent is None
def get_children(self):
"""Retrieve all direct child comments."""
return Comment.objects.filter(parent=self)
- Run the below to create the database tables:
python manage.py makemigrations
python manage.py migrate
Load comment data into your database
The slow way (Not recommended) 💤
Load data manually by:
- creating a Django superuser and adding data through the Django admin, or
- adding data through the Django shell, or
- adding data directly into your database using SQL
The fast way (Recommended) 🏎
- Load a batch of data into your database using the Django
loaddata
management command.
Doing the fast way by using loaddata
with yaml
- Create a file
comment_data.yaml
in the root folder. Here is some sample comment data that I've written to get you started:
Click to see the sample data (Scroll down to the copy button)
- model: auth.user
pk: 1
fields:
username: 'PurrfectPaws'
email: 'purrfectpaws@example.com'
is_staff: false
is_active: true
date_joined: '2024-03-24T00:00:00Z'
- model: auth.user
pk: 2
fields:
username: 'TheCatWhisperer'
email: 'thecatwhisperer@example.com'
is_staff: false
is_active: true
date_joined: '2024-03-24T01:00:00Z'
- model: auth.user
pk: 3
fields:
username: 'FelinePhilosopher'
email: 'felinephilosopher@example.com'
is_staff: false
is_active: true
date_joined: '2024-03-24T01:30:00Z'
- model: auth.user
pk: 4
fields:
username: 'CatNapConnoisseur'
email: 'catnapconnoisseur@example.com'
is_staff: false
is_active: true
date_joined: '2024-03-24T02:00:00Z'
- model: sim.comment
pk: 1
fields:
content: 'Anyone else’s cat obsessed with knocking things off tables? Mine seems to think it’s his life mission 😂'
created_at: '2024-03-24T02:00:00Z'
parent: null
author: 1
- model: sim.comment
pk: 2
fields:
content: 'Gravity checks, obviously! Cats are just doing important scientific work. Mine is currently researching the flight patterns of pens.'
created_at: '2024-03-24T02:15:00Z'
parent: 1
author: 2
- model: sim.comment
pk: 3
fields:
content: 'Haha, that’s one way to look at it. Next, they’ll be winning Nobel Prizes for their contributions to physics!'
created_at: '2024-03-24T02:30:00Z'
parent: 2
author: 1
- model: sim.comment
pk: 4
fields:
content: 'Right? 😆 Meanwhile, my cat’s dissertation on “The Optimal Time to Demand Feeding: A Study Conducted at 3AM” is pending review.'
created_at: '2024-03-24T02:45:00Z'
parent: 3
author: 2
- model: sim.comment
pk: 5
fields:
content: 'Does anyone else’s cat have an existential crisis at midnight or is it just mine? Staring into the void, meowing at shadows...'
created_at: '2024-03-24T03:00:00Z'
parent: null
author: 3
- model: sim.comment
pk: 6
fields:
content: 'Oh definitely, it’s their way of pondering the universe. Mine likes to present his findings at 5AM, loudly, by my bedside.'
created_at: '2024-03-24T03:15:00Z'
parent: 5
author: 4
- model: sim.comment
pk: 7
fields:
content: 'I introduced a new toy to my cat today, and now I can’t find it. I suspect it’s under the couch, along with all those missing socks.'
created_at: '2024-03-24T03:30:00Z'
parent: null
author: 2
- model: sim.comment
pk: 8
fields:
content: 'Update: Found the toy. It was indeed under the couch, along with a treasure trove of socks and a single, inexplicable cucumber.'
created_at: '2024-03-24T03:45:00Z'
parent: 7
author: 2
- model: sim.comment
pk: 9
fields:
content: 'The cucumber mystery deepens. Perhaps it’s a new cat currency we’re yet to understand.'
created_at: '2024-03-24T04:00:00Z'
parent: 8
author: 3
- model: sim.comment
pk: 10
fields:
content: 'Mine too!'
created_at: '2024-03-24T04:00:00Z'
parent: 5
author: 1
Load the data into your database
- Run the below to load the data into your Django database. It will overwrite any existing data:
python manage.py loaddata comment_data.yaml
Here's an earlier post that covers importing and exporting data with YAML in more detail:
Simply add (and export) data from your Django database with YAML (3 mins) 🧮
Run our app
If you're running the app locally, run the below to start the server:
If running locally
python manage.py runserver
If running on Circumeo (Full online demo 🎪):
Here's a full demo of the app (using Circumeo). To use this:
- Visit the project fork page and click the "Create Fork" button.
- Migrations will run and the app will launch in about 10 seconds.
- To load our initial data (necessary to avoid a blank screen):
- open the Shell tab and click Connect.
- type
python3 manage.py loaddata comment_data.yaml
into the shell and press enter
Congrats - You've created comment threads with Django 🎉
You've just built an app using Django that has comment threads, just like many popular social websites.
Here are some future enhancements you might consider:
- Content moderation. Allow an admin to approve comments before they are published.
- Allow users to edit and delete their own comments.
- Instead of a simple textarea, use a rich text editor.
P.S Want to build Django frontend faster? ⚡️
I'm building Photon Designer. It's a visual editor that puts the 'fast' in 'very fast.'
When I'm using Photon Designer, I create clean Django UI faster than light escaping a black hole (in a metaphorical, non-literal way).
Here's a quick video of me using Photon Designer to expand the comment thread app (to add Reddit-style collapsible threads):