Skip to content

Instantly share code, notes, and snippets.

@a-toms
Forked from zchtodd/comment_threads.md
Last active March 27, 2024 11:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save a-toms/4ef00eb50186d197bdbc3bab26d27d6e to your computer and use it in GitHub Desktop.
Save a-toms/4ef00eb50186d197bdbc3bab26d27d6e to your computer and use it in GitHub Desktop.
title description created_at
Add comments to Django in 9 mins (using HTMX) 🧵
Let people leave comments in your Django app, with HTMX to make it interactive.
2024-03-27 03:31:06 -0700

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 🖊️:

<style> /* Make video responsive */ .video-container video { width: 100% !important; height: auto !important; border-radius: 10px; } </style>

Let's get cooking 👨‍🍳 (i.e., coding)

Setup our Django app

  • Install packages and create our Django app
pip install --upgrade django
django-admin startproject core .
python3 manage.py startapp sim
  • Add the humanize app to INSTALLED_APPS to show how long ago a comment was published.
  • Add our app sim to the INSTALLED_APPS.
# settings.py
INSTALLED_APPS = [
  "django.contrib.humanize",
  "sim",
	...
]

Add templates

  1. Create a folder named templates/sim in the sim app.
  2. Create a file base.html in the in the templates/sim 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 the templates/sim folder.
  • Create a file _comment.html in the templates/sim/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 "sim/partials/_comment.html" with comment=child children=True indent=True %}
          {% endfor %}
        </div>
        {% endif %}
      </div>
    </td>
  </tr>
</table>
  • Create a file comments.html in the templates/sim directory:
{% extends "sim/base.html" %}
{% block content %}
<table class="comments">
  {% for comment in root_comments %}
  <tr>
    <td>{% include "sim/partials/_comment.html" with comment=comment children=True indent=False %}</td>
  </tr>
  {% endfor %}
</table>
{% endblock %}
  • Create a file reply.html in the templates/sim directory:
{% extends "sim/base.html" %}
{% block content %}
  <div id="reply-container">
    <div>
      {% include "sim/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 the sim/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, "sim/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, "sim/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 in sim, 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) 🧮](https://www.photondesigner.com/articles/loaddata-dumpdata?ref=pd-site)

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 do this:

  1. Visit the project fork page and click the Create Fork button.
  2. Migrations will run and the app will launch in about 10 seconds.
  3. To load our initial data (necessary to avoid a blank screen):
    1. open the Shell tab and click Connect.
    2. 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment