Model-Agnostic Django comments app
Status: Completed

There are plenty out there but, as usual, I want to understand what I'm using. We'll build one from scratch. The approach is based on the Content Types framework. The only addition here is the integration in our django app and templating.

Prerequisites

  • A basic django blogging app
  • Some basic understanding of Django

ContentType app

The key to our "model-independent" part is the 'django.contrib.contenttypes' app. It is included by default in the Django apps.

First, create a fake_comments app and add it to the settings file. Our basic Comment model is as follows

# fake_comments/models.py
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey


class Comment(models.Model):
    # Content-object field
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    content_id = models.PositiveIntegerField(db_index=True)
    # you can simply use content_obj = GenericForeignKey() when using the default names (content_type, object_id)
    content_object = GenericForeignKey(ct_field="content_type", fk_field="content_id")
    body = models.TextField('comment', max_length=settings.COMMENT_MAX_LENGTH)

    # Metadata about the comment
    class Meta:
        indexes = [models.Index(fields=["content_type", "content_id"])]
        db_table = 'fake_comments'

The Comment model uses content_type and content_id to keep track of the type and id of the object that the comment belongs to. content_object provides a many-to-one relation with the parent object.

In this fashion the comment model does not have to be parent-model specific.

To access the comments from the parent (Post) model you simply add the attribute:

comments = django.contrib.contenttypes.fields.GenericRelation(Comment, object_id_field="content_id")

The next step is to add a basic form to post comments and adjust our templates to display comments.

  • Start by adding a CommentAdmin to fake_comments/admin.py.
# fake_comments/admin.py
from django.contrib import admin
from fake_comments.models import Comment


@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
    list_display = ['body']
    list_filter = ['content_type']
    search_fields = ['body']
  • and the rest of the setup
# fake_comments/forms.py
from django import forms
from django.contrib.contenttypes.models import ContentType

from .models import Comment


class CommentForm(forms.Form):
    body = forms.CharField()
# fake_comments/views.py
from django.contrib.contenttypes.models import ContentType
# Create your views here.
from django.shortcuts import get_object_or_404
from django.http import HttpResponseRedirect

from .forms import CommentForm
from .models import Comment


def submit_comment(request, content_name, content_id):
    content_type = get_object_or_404(ContentType, **dict(zip(["app_label", "model"], content_name.split('_'))))
    model_class = content_type.model_class()
    content_obj = get_object_or_404(model_class, pk=content_id)
    if request.method == 'POST':
        form = CommentForm(request.POST)
        if form.is_valid():
            comment = Comment(content_type=content_type, content_id=content_id, body=form.data['body'])
            # or the following
            # comment = Comment(content_object=content_obj, body=form.data['body'])
            comment.save()
    return HttpResponseRedirect(content_obj.get_abs_url())

The comment's POST request needs the comment's body, the id of the content it's related to, and the content name.

I used the format {app_label}_{model_name} for content_name. We only need to define content_object or (content_type, content_id). Doing both is redundant.

# fake_comments/urls.py
from django.urls import path
from . import views

# important for url tag to recognize the app namespace
app_name = "fake_comments"

urlpatterns = [
    path('<str:content_name>/<int:content_id>/comment/', views.submit_comment, name='submit_comment'),
]

<!--templates/comments/submit_comment.html-->
{% load i18n %}
<form action="{% url 'fake_comments:submit_comment' content_name content_id %}" method="post">
    <p>
        <textarea name="body" cols="45" rows="10" placeholder="Type comment here" maxlength="1000" required=""
                  id="id_body" aria-label="body"></textarea>
    </p>
    {% csrf_token %}
    <button type="submit">Submit Comment</button>
</form>
  • In your post template, you simply need to {% include 'comments/submit_comment' %}.
  • In your post view, add content_name, content_id to your context:
{
    ...,
    'content_name': f'{post._meta.app_label}_{post._meta.model_name}',
'content_id': post.id,
...
}

I hope it all makes sense now. Unfortunately, any view in Django should direct you to a specific url (page refresh). The natural next step would be to post and get comments via javascript for a smoother experience... and some comments moderation.