Comments
We want our logged in users to be able to comment on submissions. We'd also like users to reply to comments by other users. To achieve this, our comment
model needs to be able to track the submission it was made on and also have a link to its parent comment (if it was made in reply to some other user's comment).
If you have ever used forums on the Internet, the way our comments section works should seem familiar. One complaint that I've always had with all these forums is that they allow this hierarchy of comments to go on forever. Then you end up with 10-level deep comments that extend off the screen:
Comment 1 Comment 2 Comment 3 Comment 4 Comment 5 Comment 6 Comment 7 Comment 8 Comment 9 Comment 10
While there are a number of ways to solve this, the simplest probably is to cut off nested replies beyond a certain level. In our case, no comments can be replies to Comment 2
. Instead, they must all be in reply to Comment 1
or the parent submission. This will make the implementation easier as we'll see later.
From our discussion so far, we know that our comment model will need foreign keys to our submission models and also to itself in order to refer to parent comments. This self reference, or a recursive relationship as Django documentation calls it, is something that I have used maybe once in the five years (and more) I have been creating web apps in Django. It's not something that is needed very often but sometimes it results in elegant solutions, like the one you'll see here.
To keep things simple, we'll first implement commenting on link submissions and later add code to handle replying to comments. Let's start with the model. Add the following to links/models.py
:
class Comment(models.Model): body = models.TextField() commented_on = models.ForeignKey(Link) in_reply_to = models.ForeignKey('self', null=True) commented_by = models.ForeignKey(User) created_on = models.DateTimeField(auto_now_add=True, editable=False)
The in_reply_to
field here is the recursive foreign key that allows us to create a hierarchy of comments and replies to them. As you can see, creating a recursive foreign key is achieved by giving the model name self
instead of the model name like you would usually do with a normal foreign key.
Create and run the migrations to add this model to our database:
> python manage.py makemigrations > python manage.py migrate
Next, let's think about the view and template. As we're only implementing commenting on submissions for now, it makes sense that the form to create a new comment also be visible on the submission details page. Let's create the form first. Create a new links/forms.py
file and add the following code:
from django import forms from links.models import Comment class CommentModelForm(forms.ModelForm): link_pk = forms.IntegerField(widget=forms.HiddenInput) class Meta: model = Comment fields = ('body',)
We will just create a simple model form for the Comment
model and add one extra field that we'll use to keep track of which link the comment needs to be associated with. To make the form available to our submission details template, import the form in links/views.py
by adding the following to the top of the file:
from links.forms import CommentModelForm
We'll also add code to show comments for a submission on the details page now. So we need to import the Comment
model in the views file. Right after the line importing the form, add another line of code importing the model:
from links.models import Comment
To be able to display the comments associated with a submission and the form to create a new submission, we'll need to make these two things available in the template context of the submission details page. To do so, add a get_context_data
method to SubmissionDetailView
:
def get_context_data(self, **kwargs): ctx = super(SubmissionDetailView, self).get_context_data(**kwargs) submission_comments = Comment.objects.filter(commented_on=self.object) ctx['comments'] = submission_comments ctx['comment_form'] = CommentModelForm(initial={'link_pk': self.object.pk}) return ctx
We'll look at the initial attribute that we are passing to CommentModelForm
in a while. We'll also need to create a view where the new comment form is submitted. Here's the code that you'll need to add it to links/views.py
:
class NewCommentView(CreateView): form_class = CommentModelForm http_method_names = ('post',) template_name = 'comment.html' @method_decorator(login_required) def dispatch(self, *args, **kwargs): return super(NewCommentView, self).dispatch(*args, **kwargs) def form_valid(self, form): parent_link = Link.objects.get(pk=form.cleaned_data['link_pk']) new_comment = form.save(commit=False) new_comment.commented_on = parent_link new_comment.commented_by = self.request.user new_comment.save() return HttpResponseRedirect(reverse('submission-detail', kwargs={'pk': parent_link.pk})) def get_initial(self): initial_data = super(NewCommentView, self).get_initial() initial_data['link_pk'] = self.request.GET['link_pk'] def get_context_data(self, **kwargs): ctx = super(NewCommentView, self).get_context_data(**kwargs) ctx['submission'] = Link.objects.get(pk=self.request.GET['link_pk']) return ctx
Even though we show the form on the submission detail page, in case the user inputs incorrect data when submitting the form, such as pressing the submit button with an empty body, we need a template that can show the form again along with the errors. Create the comment.html
template in links/templates
:
{% extends "base.html" %} {% block content %} <h1>New Comment</h1> <p> <b>You are commenting on</b> <a href{% url 'submission-detail' pk=submission.pk %}">{{ submission.title }}</a> </p> <form action="" method="post">{% csrf_token %} {{ form.as_p }} <input type="submit" value="Post Comment" /> </form> {% endblock %}
You should already know what most of the code for the CreateView
subclass does. One thing that is new is the get_inital
method. We'll look at it in detail later. For now, let's get the comments feature up and running.
Let's add our new view to discuss/urls.py
. First, import the view:
from links.views import NewCommentView
Then, add it to the URL patterns:
url(r'new-comment/$', NewCommentView.as_view(), name='new-comment'),
Finally, change links/templates/submission_detail.html
to the following:
{% extends "base.html" %} {% block content %} <h1><a href="{{ object.url }}" target="_blank">{{ object.title }}</a></h1> <p>submitted by: <b>{{ object.submitted_by.username }}</b></p> <p>submitted on: <b>{{ object.submitted_on }}</b></p> <p> <b>New Comment</b> <form action="{% url "new-comment" %}?link_pk={{ object.pk }}" method="post">{% csrf_token %} {{ comment_form.as_p }} <input type="submit" value="Comment" /> </form> </p> <p> <b>Comments</b> <ul> {% for comment in comments %} <li>{{ comment.body }}</li> {% endfor %} </ul> </p> {% endblock %}
If you noticed the form action URL in our template, you'll see that we have added the link_pk
GET parameter to it. If you refer back to the code that you wrote for NewCommentView
, you'll see that we use this parameter value in the get_context_data
and get_inital
functions to get the Link
object that the user is commenting on.
Let's look at what we've made till now. Start the application using the runserver
command, open the home page in your browser, and then log in. As we don't yet have any way to access old submissions, we'll need to create a new submission. Do that and you'll see the new detail page. It should look similar to the following screenshot:
Add a comment and it should appear on the same page. Here's a screenshot with a few comments added:
If you leave the body empty and press the Comment button, you should see the comment template that you created earlier with an error message:
With basic commenting on submission working, let's look at how we'll implement replying to comments. As we've already seen, our comment model has a field to indicate that it was made in reply to another comment. So all we have to do in order to store a comment as a reply to another comment is correctly set the in_reply_to
field. Let's first modify our model form for the Comment
model to accept in addition to a link_pk
, a parent_comment_pk
as well to indicate which (if any) comment is the new comment a reply to. Add this field to CommentModelForm
right after the link_pk
field:
parent_comment_pk = forms.IntegerField(widget=forms.HiddenInput, required=False)
Now we need a place to show a form to the user to post his reply. We could show one form per comment on the submission details page, but that would end up making the page look very cluttered for any submission with more than a few comments. In a real-world project, we'd probably use JavaScript to generate a form dynamically when the user clicks on the reply link next to a comment and submits this. However, right now we are more focused on the Django backend and thus we'll come up with another way that doesn't involve a lot of frontend work.
A third way, which we'll be using here, is to have a little link next to each comment that takes the user to a separate page where they can record their reply. Here's the view for that page. Put this in links/views.py
:
class NewCommentReplyView(CreateView): form_class = CommentModelForm template_name = 'comment_reply.html' @method_decorator(login_required) def dispatch(self, *args, **kwargs): return super(NewCommentReplyView, self).dispatch(*args, **kwargs) def get_context_data(self, **kwargs): ctx = super(NewCommentReplyView, self).get_context_data(**kwargs) ctx['parent_comment'] = Comment.objects.get(pk=self.request.GET['parent_comment_pk']) return ctx def get_initial(self): initial_data = super(NewCommentReplyView, self).get_initial() link_pk = self.request.GET['link_pk'] initial_data['link_pk'] = link_pk parent_comment_pk = self.request.GET['parent_comment_pk'] initial_data['parent_comment_pk'] = parent_comment_pk return initial_data def form_valid(self, form): parent_link = Link.objects.get(pk=form.cleaned_data['link_pk']) parent_comment = Comment.objects.get(pk=form.cleaned_data['parent_comment_pk']) new_comment = form.save(commit=False) new_comment.commented_on = parent_link new_comment.in_reply_to = parent_comment new_comment.commented_by = self.request.user new_comment.save() return HttpResponseRedirect(reverse('submission-detail', kwargs={'pk': parent_link.pk}))
By now, having used it multiple times, you should be comfortable with CreateView
. The only new part here is the get_initial
method, which we also used in NewCommentView
previously. In Django, each form can have some initial data. This is data that is shown when the form is Unbound. The boundness of a form is an important concept. It took me a while to wrap my head around it, but it is quite simple. In Django, a form has essentially two functions. It can be displayed in the HTML code for a web page or it can validate some data.
A form is bound if you passed in some data for it to validate when you initialized an instance of the form class. Let's say you have a form
class called SomeForm
with two fields, name and city. Say you initialize an object of the form without any data:
form = SomeForm()
You have created an unbound instance of the form. The form doesn't have any data associated with it and so can't validate anything. However, it can still be displayed on a web page by calling {{ form.as_p }}
in the template (provided it was passed to the template via the context). It will render as a form with two empty fields: name
and city
.
Now let's say you pass in some data when initializing the form:
form = SomeForm({'name': 'Jibran', 'city': 'Dubai'})
This creates a bound instance of the form. You can call is_valid()
on this form object and it will validate the passed data. You can also render the form in an HTML template, just like before. However, this time, it will render the form with both the fields having the values that you passed here. If, for some reason, the values that you passed didn't validate (for example, if you left the value for the city field empty), the form will display the appropriate error message next to the field with the invalid data.
This is the concept of bound and unbound forms. Now let's look at what the initial data in a form is for. You can pass initial data to a form when initializing an instance by passing it in the initial keyword parameter:
form = SomeForm(initial={'name': 'Jibran'})
The form is still unbound as you did not pass in the data attribute (which is the first non-keyword argument to the constructor) but if you render it now, the name field will have the value 'Jibran'
while the city field will still be empty.
The confusion that I faced when I first learned of the initial data was why it was required. I could just pass the same data dictionary as the data parameter and the form would still only receive a value for one field. The problem with this is that when you initialize a form with some data, it will automatically try to validate that data. Assuming that the city field is a required field, if you then try to render the form in a web page, it will display an error next to the city field saying that this is a required field. The initial data parameter allows you to supply values for form fields without triggering validation on that data.
In our case, CreateView
calls the get_initial
method to get the dictionary to use it as the initial data for the form. We use the submission ID and parent comment ID that we will pass in the URL parameters to create the initial values for the link_pk
and parent_comment_pk
form fields. This way, when our form is rendered on the HTML web page, it will already have values for these two fields. Looking at the form_valid
method, we then extract these two values from the form's cleaned_data
attribute and use it to get the submission and parent comment to associate the reply with.
The get_context_data
method just adds the parent comment object to the context. We use it in the template to tell the user which comment they are replying to. Let's look at the template, which you need to create in links/templates/comment_reply.html
:
{% extends "base.html" %} {% block content %} <h1>Reply to comment</h1> <p> <b>You are replying to:</b> <i>{{ parent_comment.body }}</i> </p> <form action="" method="post">{% csrf_token %} {{ form.as_p }} <input type="submit" value="Submit Reply" /> </form> {% endblock %}
Nothing fancy here. Note how we use the parent_comment
object that we passed in the get_context_data
method of the view. It's good UI practice to make sure that the user is always given relevant information about the action that they are about to take.
Import our new view in discuss/urls.py
:
from links.views import NewCommentReplyView
Add this pattern to the URL patterns list:
url(r'new-comment-reply/$', NewCommentReplyView.as_view(), name='new-comment-reply'),
Lastly, we need to give the user a link to get to this page. As we discussed before, we'll be putting a link called Reply next to each comment on the submission detail page. To do so, note the following line in links/templates/submission_detail.html
:
<li>{{ comment.body }}</li>
Change it to the following:
<li>{{ comment.body }} (<a href="{% url "new-comment-reply" %}?link_pk={{ object.pk }}&parent_comment_pk={{ comment.pk }}">Reply</a>)</li>
Note how we pass in the submission ID and parent comment ID using GET params when creating the URL. We did this with the comment form on the submission page as well. This is a common technique you'll use a lot when creating Django apps. These are the same URL parameters that we used in the comment reply view to populate the initial data for the form and access the parent comment object.
Let's try it out. Click on Reply on one of the comments on the submission detail page. If you've closed the old submission details page, you can create a new submission and add some comments to it. By clicking on the Reply
link, you'll see a new page with a form for the comment body. Enter some text here and click on the Submit
button. Remember the text that you entered. We'll be looking for this next in the upcoming few steps. In my testing, I entered Reply to Comment 1. Let's see what our submission details page looks with our new reply comment:
It seems like it worked. However, if you look closely, you'll notice that the reply we made (in my case, the Reply to Comment 1 text) is shown at the end of the comments list. It should be shown after Comment 1 and ideally indented a bit to the right as well to indicate the hierarchy. Let's fix this. First, in the get_context_data
method of SubmissionDetailView
in the links/views.py
file, note this line:
submission_comments = Comment.objects.filter(commented_on=self.object)
Change it to the following:
submission_comments = Comment.objects.filter(commented_on=self.object, in_reply_to__isnull=True)
What we've done here is include only the comments that don't have a parent comment. We do this by getting only comments that have the in_reply_to
field set to NULL
. If you save this change and refresh the submission detail page, you'll notice that your reply comment will be gone. Let's bring it back. Modify link/templates/submission_detail.html
and change the paragraph that shows the comments (the one with the for
loop over the comments list) to match the following:
<p> <b>Comments</b> <ul> {% for comment in comments %} <li> {{ comment.body }} (<a href="{% url "new-comment-reply" %}?link_pk={{ object.pk }}&parent_comment_pk={{ comment.pk }}">Reply</a>) {% if comment.comment_set.exists %} <ul> {% for reply in comment.comment_set.all %} <li>{{ reply.body }}</li> {% endfor %} </ul> {% endif %} </li> {% endfor %} </ul> </p>
The new part here is between the if
tag. First, we use the reverse relationship that is created by the foreign key to itself to see if this comment has any other comments pointing to it. We know that the only comments pointing to this comment would be replies to this comment. If there are, we then create a new list and print the body for each of the replies. As we've already decided that we only allow replies to the first level of comments, we don't create any links to let users reply to the replies. Once you've save these changes, let's see what our submission details page looks now:
That's more like it! We now have a complete link submission and commenting system. Awesome! Let's move on to the other features now.