Threaded Commenting System In Django (Posted on December 29th, 2012)

The goal of this project is to store a tree (comment thread) in a database. There are several ways to do this and a good list of references can be found at Stack Overflow. The algorithm I'll be using to create our threaded commenting system is called a Materialized Path.

With the Materialized Path algorithm each comment will store the path to itself. For instance, if we post a comment to an empty thread the path to the comment is {1}. Since there is only a single node in the path this also signifies that it is a parent node. If we post a reply to the parent node that comment will have the path {1, 2}. If another reply is posted to the parent node it will have the path {1, 3}. Finally we can post a comment to our second reply (first child) and it will have the path {1, 2, 4}. Here is a visual example of what the output should look like:

  • Comment 1 - Path: {1}
    • Comment 2 - Path: {1, 2}
      • Comment 4 - Path: {1, 2, 4}
    • Comment 3 - Path: {1, 3}

Requirements

  • Django 1.4+
  • Postgresql 8.x or 9.x (7.x may work as well but I haven't tested)
  • DBarray (Enables Postgresql arrays in Django)

The Setup

First things first, let's create a Django project with a core app:

django-admin.py startproject comments_tutorial && cd coments_tutorial
python manage.py startapp core

Now that our file structure is all setup lets update the settings file to include our database information. If you haven't created a blank database for this project yet go ahead and create one now. Here is a copy of my settings file for reference. I've added django.contrib.humanize and core to my installed apps and also told Django where it can find my templates. If you have a certain way you like to setup your settings feel free to do so but make sure to add the proper installed apps.

The Model

Download DBarray and place it inside the comments_tutorial/core app folder. I chose to download the init file and place it in a file called dbarray.py. Then open up comments_tutorial/core/models.py and edit it to look like mine:

from django.db import models
from django import forms
from dbarray import IntegerArrayField

class Comment(models.Model):
    content = models.TextField()
    date = models.DateTimeField(auto_now_add=True)
    path = IntegerArrayField(blank=True, editable=False)
    depth = models.PositiveSmallIntegerField(default=0)
    
    def __unicode__(self):
        return self.content
    
class CommentForm(forms.ModelForm):
    #Hidden value to get a child's parent
    parent = forms.CharField(widget=forms.HiddenInput(
                            attrs={'class': 'parent'}), required=False)
    
    class Meta:
        model = Comment
        fields = ('content',)

I have set the path field to be uneditable, otherwise you'll run in to errors should you decide to update comments from inside the Django admin panel. You'll notice a hidden parent field in the form. This is used when replying to comments. This field will be populated with the parent comment's ID so that we can store the proper path of the child node. This idea may make more sense once we've implemented our view function.

Since this is the only model we will be using it is safe to run a syncdb and create the tables for our app

python manage.py syncdb

The View

Go ahead and open up comments_tutorial/core/views.py and edit it to look like mine:

from core.models import Comment, CommentForm
from itertools import ifilter
from django.shortcuts import render

def home(request):
    form = CommentForm(request.POST or None)
    
    if request.method == "POST":
        if form.is_valid():
            temp = form.save(commit=False)
            parent = form['parent'].value()
            
            if parent == '':
                #Set a blank path then save it to get an ID
                temp.path = []
                temp.save()
                temp.path = [temp.id]
            else:
                #Get the parent node
                node = Comment.objects.get(id=parent)
                temp.depth = node.depth + 1
                temp.path = node.path
                
                #Store parents path then apply comment ID
                temp.save()
                temp.path.append(temp.id)
                
            #Final save for parents and children
            temp.save()
    
    #Retrieve all comments and sort them by path
    comment_tree = Comment.objects.all().order_by('path')
                
    return render(request, 'index.html', locals())

First up we tell Django to fill out or create a blank CommentForm. If the request to the method is POST that means that we are creating a comment and need to handle that accordingly. If the comment form is valid then we create a python object but do not save it to the database just yet (this is where commit=False comes in to play).

The next step is to check if the comment that is being created is a parent comment or a reply to another comment. If no value was sent in to our hidden field that means the comment is a parent comment so we give it a blank path and save it. If a value was sent then grab that node's information and apply it to the newly created comment. The database will return the ID of that comment which we then add to the comment's path. You'll notice that this step will require two calls to the database. If you drop down to raw sql you can manage to do it one by prepending the row ID to the path array when selecting comments. However, to keep things simple and use the ORM, we will make two calls to the database.

The great part about this setup is that to get the full comment tree we simply need to order by "path". This will make it really easy to output in HTML. This works because every path is unique since the final element in the array is the comment's primary key.

The URL

Open comments_tutorial/comments_tutorial/urls.py and route the homepage to our home view we just created like so:

from django.conf.urls import patterns, include, url

urlpatterns = patterns('',
    url(r'^$', 'core.views.home', name='home'),
)

The Template

In my settings file I told Django to look for the templates in a folder located at comments_tutorial/comments_tutorial/templates. So I just created the templates folder and have added a file called index.html to match our view's return method. Here is what your index.html file needs to look like (I have added some CSS to make it pretty):

{% load humanize %}
<!DOCTYPE html>
<html>
<head>
    <title>Comments Tutorial by Max Burstein</title>
    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script>
    <script>
        $(document).ready(function(){
            $("#commenters").on("click", ".reply", function(event){
                event.preventDefault();
                var form = $("#postcomment").clone(true);
                form.find('.parent').val($(this).parent().parent().attr('id'));
                $(this).parent().append(form);
            });
        });
    </script>
    <style>
        a {
            font-weight: bold;
            color: #ff982c;
            text-decoration: none;
        }

        a:hover {
            text-decoration: underline;
        }
        
        #commenters {
            padding-left: 0px;
        }
        
            #commenters li {
                list-style-type: none;
            }
        
        .poster {
            font-size: 12px;
            color: #AAAAAA;
        }
        
        #postcomment ul {
            padding-left: 0px;
        }
        
            #postcomment ul li {
                list-style-type:  none;
                padding-bottom: 5px;
            }
                
        #postcomment label {
            width: 74px;
            display: inline-block;
        }
        
        .c {
            font-size: 14px;
            background: #0E0E0E;
            -webkit-border-radius: 10px;
            -moz-border-radius: 10px;
            border-radius: 10px;
            color: #FFFFFF;
            padding: 10px;
            margin-bottom: 10px;
        }
    </style>
</head>
<body>
    <h1>Comments Tutorial by Max Burstein</h1>
    <form id="postcomment" method="post" action="">
        {{form.as_p}}
        <p><input type="submit" value="Submit" /></p>
        {% csrf_token %}
    </form>
    <ul id="commenters">
    {% for c in comment_tree %}     
        <li id="{{c.id}}" class="c" style="margin-left:{{c.depth|add:c.depth}}em;">
            <p class="poster">Anonymous - {{c.date|naturaltime}}</p>
            <p>{{c.content}}</p>
            <p><a href="" class="reply">reply</a></p>
        </li>
    {% empty %}
        <li>There are currently no comments. You can be first!</li>
    {% endfor %}
    </ul>
</body>
</html>

The body of the HTML starts by loading our comment form so that we can post parent comments. Below that it loops through all comments currently in the database and outputs them in a hierarchical order based on the depth of a comment (a parent comment has a depth of 0). The Django Humanize module converts our stored time to how many seconds, minutes or hours ago it was posted, kind of like Reddit. If there are no comments then we simply print no comments.

At the top I've imported jQuery and wrote a simple script to place a comment box below any comment we want to reply to. When the reply button is clicked we prevent the page from going anywhere and then make a clone of the comment box from the top of the page. We then take the ID of the node we clicked reply on and put that value in the hidden parent field we created with our ModelForm up above. When we hit submit from this form the view will recognize that this comment has a parent and will store the proper path for this comment.

Source Code

If you've followed along this far then you've reached the end. Congrats! As an exercise go ahead and add in a comment rating system, such as upvoting and downvoting. This article on how not to sort by average rating is a good read.

I have posted my source code for this project on Github. Feel free to ask me any questions. I'd be happy to help.

Tags: Django