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

Comments:

  • enigma - 1 year, 8 months ago

    Thanks for the great tutorial. I just like to ask one question: Is is possible to build it using mysql.

    reply

  • Max Burstein - 1 year, 8 months ago

    If you're using MySQL you can use the CommaSeparatedIntegerField model type for your path. Then when you pull the data you can just split by "," and you'll have your path array. You'll also need to do the sorting in python since the database will treat the column as a string I believe, so 10 will come before 2. I've never tried it though so if you do end up trying it let me know how it works out for you.

    reply

  • enigma - 1 year, 8 months ago

    Hello again and thanks for the help. After a lot of attempts and debugging, i have changed the code like this: # views.py 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() # converting ID to int because save() gives a long int ID id = int(temp.id) temp.path = [id] else: # get the parent node node = Comment.objects.get(id = parent) temp.depth = node.depth + 1 s = str(node.path) temp.path = eval(s) #store parents path than apply comment ID temp.save() id= int(temp.id) temp.path.append(id) temp.save() # here i have reversed the order comment_tree = Comment.objects.all().order_by("-path") return render(request, 'index.html', locals()) in models.py i have just replaced IntegerArrayField with CommaSeparatedIntegerField as you've told me. Now it looks working, but could you try it, because i feel that i missed some thing

    reply

  • enigma - 1 year, 8 months ago

    Sorry for the mess

    reply

  • Max Burstein - 1 year, 8 months ago

    Yea I really need to implement markup for comments on here. Sorry about that. Drop me your e-mail on my contact form. I'll definitely take a look at using MySQL for this. It would be awesome if it was that simple of a fix.

    reply

  • enigma - 1 year, 8 months ago

    Did you recieve my email

    reply

  • Max Burstein - 1 year, 8 months ago

    Yes I did. I sent off a response a few days ago. If you haven't received the response let me know and I'll resend it.

    reply

  • Andrew - 1 year, 2 months ago

    Well I usually just grab code from blog posts and move on, but I have to comment here...

    This is some seriously good work and slick code. I ran into the issue of threaded comments last night and was unable to think of a good solution due to my coding inexperience. You sir have saved me a LOT of trouble with this code.

    Seriously. You're freaking awesome. Keep it up.

    • Andrew

    reply

  • Mahesh - 1 year ago

    Thanks a lot

    reply

  • kannor - 7 months, 2 weeks ago

    Awesome post. Reading the source I have realised the application should work perfectly without the "path" model field. Since the comments can be arranged by "id" field. Can you please let me know some other use of the "path" field. Thank you.

    reply

  • Max Burstein - 7 months, 1 week ago

    You only need the path field if you want to make the comments threaded (can reply to specific comments). If you want to do Facebook style comments then you absolutely don't need the path field.

    You could get away with doing threaded comments without the path field by using Postgres' recursive calls to build out a path as long as you have the parent id. I know at one point Disqus used this technique. I find the path array much simpler as it also allows you to use the Django ORM and keeps your code base more uniform.

    reply

  • Threading - 7 months, 1 week ago

    just wanna comment on this one that its really well written and I enjoyed reading this

    reply

  • Anonymous - 5 months, 2 weeks ago

    I think you could save one DB operation by not saving the current comment in the path, for example:

    comment_1.path = (0) (its child) comment_2.path = (0,1) (just the get the parent's path and append the parent's id) (child of 2) comment_3.path = (0,1,2) (just the get the parent's path and append the parent's id)

    An item doesn't have to have itself in its own path, does it? Otherwise seems like a good solution I'm going to use with that little modification

    reply

  • Anonymous - 5 months, 2 weeks ago

    Oops, my bad! Gave it a little more thought and understood that without having yourself in your own path you will not be able to sort 1st level comments by path in the right order.

    Sorry, thanks!

    reply

  • Max Burstein - 4 months, 4 weeks ago

    You can definitely do it without having to do the extra save if you break out in to raw SQL. If you want to use the ORM it ends up being easier to just do the extra save. In the raw SQL you'd essentially just append the ID to the path array and then run the order by path sort. Not sure what the speed difference is like. I suppose if you were more write heavy then this could be a good approach.

    reply

  • Anonymous - 3 months, 3 weeks ago

    Just chanced upon this. You can use natsorted in python for sorting the code.

    reply