Django Project Blueprints
上QQ阅读APP看书,第一时间看更新

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.

Tip

I'm saving describing what the get_initial method does until the next section when we get to adding replies to comments.

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:

Comments

Add a comment and it should appear on the same page. Here's a screenshot with a few comments added:

Comments

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:

Comments

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:

Comments

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:

Comments

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.