CommonLounge Archive

Hands-on Assignment: Creating a Reddit Clone Part 2

May 17, 2018

In the second part of the project, we’ll implement querying using F expressions and Q operators, work with Django signals and receivers, and integrate the voting and searching logic throughout our models, views, and templates.

Step 1: Django Signals & Adding Automated Counts for Comments

We want an easy way to update our comment_count every time a Comment is created.

This is where Django signals come in! They let applications get notified when certain actions have occurred in various places.

Receivers

In a nutshell, signals allow certain senders to notify a set of receivers that some action has taken place. They’re especially useful when many pieces of code may be interested in the same events.

We will be using the built in Django model signals to automatically update our upvote, downvote, and comment counts.

Let’s start off by implementing the comment incrementing signal. Here’s what the code looks like:

reddit/models.py

from django.db.models import F
from django.dispatch import receiver
from django.db.models.signals import post_save, post_delete
@receiver(post_save, sender=Comment, dispatch_uid="comment_added")
def comment_added(sender, instance, **kwargs):
    created = kwargs.pop('created')
    post = instance.post
    if created:
        post.comment_count = F('comment_count') + 1
        post.save()

On line 1, we use the @receiver decorator to specify that the function we’re about to declare will be the receiver for the post_save signal on the Comment model. This means that every time a Comment model is saved (including creation and edits), this receiver will be called.

In some circumstances, the code connecting receivers to signals may run multiple times. This can cause your receiver function to be registered more than once, and thus called multiple times for a single signal event.

To prevent the above scenario, we pass in a dispatch_uid which is just a unique identifier for the receiver. We’ll just set it to be the name of our receiver (i.e. comment_added).

Now, this receiver will be passed a created argument if this object was just created as part of it’s keyword arguments or kwargs. The instance argument refers to the Comment object that was just saved. Now, since we only want to update the comment count when a comment is created, we first check that created is True.

F operator

You may be wondering what that funky F operator is.

An F() object represents the value of a model field or annotated column. It makes it possible to refer to model field values and perform database operations using them without actually having to pull them out of the database into Python memory.

Again it’s considered good Django practice to handle updates like this at the database level instead of at the python level. Otherwise, you could imagine a case where two requests come to the server at the same time, and both load the existing comment_count (say its 2), and both set it to 3, whereas the right answer would be 4. By letting the database handle in, we ensure that the database is maintaining the consistency. On the backend, Django will just generate an encapsulated SQL expression. In our case, it instructs the database to increment the database field represented by comment_count by 1.


In lines 9-10, we simply update the comment_count by 1 and save the post.

Step 2: Add Voting Logic

Let’s talk a bit about what we want to support with the voting part of this project.

  • A user can upvote or downvote any comment or post.
  • We want to update the upvote and downvote counts of each comment or post anytime a user does any voting action.
  • We want to store the votes a user has cast in the UserVote object. A user can also change their vote.

Toggling the User Vote

The first thing we’ll want to implement is a toggle_vote method inside of the Votable class. It will accept a voter, i.e. the user who is voting, and the vote_type which will be one of UserVote.UPVOTE or UserVote.DOWN_VOTE.

You will need to handle a few cases when you implement this function.

  1. User has voted on this object before.
  2. If the new vote is the same as the one the user voted on the last time, the user must be removing their vote. I.e. if I had upvoted in the past, I am now removing my upvote.
  3. The new vote is different, hence the user is changing their vote from upvote to downvote or from downvote to upvote.
  4. User has not voted on this object before.

This method will only be called when the user does a upvote or downvote action, hence the name toggle_vote.

class Votable(BaseModel):
    upvote_count = models.PositiveIntegerField(default=0)
    downvote_count = models.PositiveIntegerField(default=0)
    class Meta: abstract = True
    def toggle_vote(self, voter, vote_type):
       # your code here

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Here is our sample implementation:

It’s a direct translation of the cases we need to handle above.

def toggle_vote(self, voter, vote_type):
    uv = UserVote.get_or_none(voter=voter, object_id=self.eid)
    if uv:
        # Case 1.1: Cancel existing upvote/downvote (i.e. toggle)
        if uv.vote_type == vote_type:
            uv.delete()
        # Case 1.2: You're either switching from upvote to downvote, or from downvote to upvote
        else:
            uv.vote_type = vote_type
            uv.save()
    # Case 2: User has not voted on this object before, so create the object.
    else:
        UserVote.objects.create(voter=voter, content_object=self, vote_type=vote_type)

Figuring out What a User’s Vote Is

We’ll also need to know what a user has voted on a particular comment or post or if they’ve not voted at all. Try implementing this function below. Return None if the user hasn’t voted on this object, 1 if the user has upvoted it, and -1 if the user has downvoted it.

class Votable(BaseModel):
    ...
    def get_user_vote(self, user):
        # Your code here

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Here’s our solution:

def get_user_vote(self, user):
    if not user or not user.is_authenticated: return None
    uv = UserVote.get_or_none(voter=user, object_id=self.eid)
    if not uv: return None
    if uv.vote_type == UserVote.UP_VOTE: return 1
    else: return -1

We first check if the user object is defined and if the user is logged in, otherwise we return None. Then we check if the the UserVote object exists. If it doesn’t, we return None, otherwise we return 1 or -1, depending on the vote_type.

Updating Upvote and Downvote Counts

Next we need to update the upvote and downvote counts after each user voting action. This sounds like a prime example for signals!

We’ll first give you some dummy functions that you can fill out that will be called from the signal, and will update the vote count for a comment or post.

class Votable(BaseModel):
    ......
    def _change_vote_count(self, vote_type, delta):
        # Your code here
    def change_upvote_count(self, delta):
        self._change_vote_count(UserVote.UP_VOTE, delta)
    def change_downvote_count(self, delta):
        self._change_vote_count(UserVote.DOWN_VOTE, delta)

Fill out your code on line 5. This helper function will be really useful when we begin to implement the signals. delta just corresponds to whether we want the up/down vote count to increase or decrease, so delta = 1 will cause the respective vote to be incremented by 1 and delta = -1 will cause the respective vote to be decremented by 1.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Here is our sample implementation:

def _change_vote_count(self, vote_type, delta):
    self.refresh_from_db()
    if vote_type == UserVote.UP_VOTE:
        self.upvote_count = F('upvote_count') + delta
    elif vote_type == UserVote.DOWN_VOTE:
        self.downvote_count = F('downvote_count') + delta
    self.save()
    self.refresh_from_db()

Note that we added self.[refresh_from_db](https://docs.djangoproject.com/en/2.0/ref/models/instances/#django.db.models.Model.refresh_from_db)() at the very beginning and end of this function. We do this because F expressions remain on the object through saves, so if you want to make sure you’re working with the latest values, you can call refresh_from_db to reload the object from the database.

This is a common gotcha when using F objects. Here is a simple example to illustrate this concept:

Say you have a hypothetical product object.

product.price                  # price = 5
product.price = F('price') + 1
product.save()                 # price = 6
product.name='New name'
product.save()                 # price = 7

Notice how on line 5, the product’s price increased even though only the name was changed on line 3. This is because the F expression is still hanging around on the product object.


Here is what the final Votable class looks like now:

class Votable(BaseModel):
    upvote_count = models.PositiveIntegerField(default=0)
    downvote_count = models.PositiveIntegerField(default=0)
    class Meta: abstract = True
    def toggle_vote(self, voter, vote_type):
        uv = UserVote.get_or_none(voter=voter, object_id=self.eid)
        if uv:
            # Case 1.1: Cancel existing upvote/downvote (i.e. toggle)
            if uv.vote_type == vote_type:
                uv.delete()
            # Case 1.2: You're either switching from upvote to downvote, or from downvote to upvote
            else:
                uv.vote_type = vote_type
                uv.save()
        # Case 2: User has not voted on this object before
        else:
            UserVote.objects.create(voter=voter, content_object=self, vote_type=vote_type)
    def _change_vote_count(self, vote_type, delta):
        self.refresh_from_db()
        if vote_type == UserVote.UP_VOTE:
            self.upvote_count = F('upvote_count') + delta
        elif vote_type == UserVote.DOWN_VOTE:
            self.downvote_count = F('downvote_count') + delta
        self.save()
    def change_upvote_count(self, delta):
        self._change_vote_count(UserVote.UP_VOTE, delta)
    def change_downvote_count(self, delta):
        self._change_vote_count(UserVote.DOWN_VOTE, delta)
    def get_user_vote(self, user):
        if not user or not user.is_authenticated: return None
        uv = UserVote.get_or_none(voter=user, object_id=self.eid)
        if not uv: return None
        if uv.vote_type == UserVote.UP_VOTE: return 1
        else: return -1

Using Signals to Automatically Update Counts

Now let’s go ahead and connect the above helper functions we wrote to the signals!

We’ve provided you the templates for writing the signals. There are two receivers, one for post_save signal on the UserVote object, and one for the post_delete signal on the UserVote object.

Go ahead and try coming up with the code wherever you see # Your code here.

@receiver(post_save, sender=UserVote, dispatch_uid="user_voted")
def user_voted(sender, instance, **kwargs):
    created = kwargs.pop('created')
    content_obj = instance.content_object
    # The user is voting for the first time
    if created:
        # Your code here
    # The user must have switched votes
    else:
        # Your code here
@receiver(post_delete, sender=UserVote, dispatch_uid="user_vote_deleted")
def user_vote_deleted(sender, instance, **kwargs):
    content_obj = instance.content_object
    # Your code here

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Here is our sample implementation.

@receiver(post_save, sender=UserVote, dispatch_uid="user_voted")
def user_voted(sender, instance, **kwargs):
    created = kwargs.pop('created')
    content_obj = instance.content_object
    # The user is voting for the first time
    if created:
        if instance.vote_type == UserVote.UP_VOTE: content_obj.change_upvote_count(1)
        else: content_obj.change_downvote_count(1)
    # The user must have switched votes
    else:
        # The previous vote was a downvote, but now is switched to an upvote
        if instance.vote_type == UserVote.UP_VOTE:
            content_obj.change_upvote_count(1)
            content_obj.change_downvote_count(-1)
        else:
            content_obj.change_upvote_count(-1)
            content_obj.change_downvote_count(1)
@receiver(post_delete, sender=UserVote, dispatch_uid="user_vote_deleted")
def user_vote_deleted(sender, instance, **kwargs):
    content_obj = instance.content_object
    if instance.vote_type == UserVote.UP_VOTE: content_obj.change_upvote_count(-1)
    else: content_obj.change_downvote_count(-1)

Some quick notes about our implementation above:

  • If the UserVote object was just created, we either increment the upvote or downvote counts depending on the vote_type.
  • If the object already existed, then the user must have switched votes, since we don’t update or save the UserVote object in any other circumstance. This is a very important point, since this signal will fire WHENEVER the UserVote object is saved. In our case, the only time we update this object is when we’re changing the vote_type.
  • If the new vote_type corresponds to an upvote, then we increment the upvote_count and decrement the downvote_count using our helper functions above. Otherwise if it’s a downvote, we do the opposite.

Writing the View Function

Try implementing the vote view function below that will take in a primary key of either a Comment or Post object, and a boolean is_upvote denoting whether the vote is an upvote or not.

@login_required
def vote(request, pk, is_upvote):
    # Your code here

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Here is our implementation.

@login_required
def vote(request, pk, is_upvote):
    content_obj = Votable.get_object(pk)
    content_obj.toggle_vote(request.user, UserVote.UP_VOTE if is_upvote else UserVote.DOWN_VOTE)
    if isinstance(content_obj, Comment): post = content_obj.post
    else: post = content_obj
    return redirect('post_detail', pk=post.pk)

The first thing we need to do is have a way of converting the passed in pk to either a comment or post object. You’ll notice is that we call Votable.get_object and pass it the pk — we’ll implement this function in the Votable class in just a second.

We also convert the is_upvote boolean that is passed in to either a UserVote.UP_VOTE or a UserVote.DOWN_VOTE.

Other than that, we call our previously defined toggle_vote function and then redirect to post_detail. If the object being voted on is a comment, we need to find the post that it belongs to (i.e. line 6).

Let’s go back and implement the get_object function.

class Votable(BaseModel):
    ...
    @staticmethod
    def get_object(eid):
        post = Post.get_or_none(eid=eid)
        if post: return post
        comment = Comment.get_or_none(eid=eid)
        if comment: return comment

This is a staticmethod, i.e. one that belongs to the Model and not to any specific instance of the model. If we just have an eid, we’d want to know whether its a Post or a Comment. The code itself is quite simple. We just use our get_object method defined in BaseModel above to see if a Post or Comment with that UUID exists, and if so, we return it.

Updating urls.py

Let’s add the urls for up voting and down voting to reddit/urls.py.


Django 2.0 and above

path('content/<uuid:pk>/upvote/', views.vote, {'is_upvote': True}, name='upvote'),
path('content/<uuid:pk>/downvote/', views.vote, {'is_upvote': False}, name='downvote')

Django 1.11

url(r'^content/(?P<pk>[0-9a-f-]+)/upvote/$', views.vote, {'is_upvote': True}, name='upvote'),
url(r'^content/(?P<pk>[0-9a-f-]+)/downvote/$', views.vote, {'is_upvote': False}, name='downvote')

Alright, back to the common instructions, regardless of Django version!

You can pass any extra arguments to your view function by passing in a dictionary. This is how we pass the is_upvote to the vote function in reddit/views.py.

Updating the templates

Let’s create the vote.html template (located at reddit/templates/reddit/vote.html). We’ll need a few specific functionalities:

  1. Show the score of a comment or post.
  2. Show whether a user has upvoted, downvoted, or not voted on a comment or post.
  3. Allow the user to upvote or downvote.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Here’s our implementation.

First, we know we’ll need to hook up the get_user_vote function inside the Votable model to the template. Since we need to pass in the current user, and you can’t call a model method and pass it an argument inside the template, we will need to create a custom filter.

Create a folder called templatetags inside of the reddit app, and create a file called filters.py inside of that → reddit/templatetags/filters.py.

from django.template.defaulttags import register
@register.filter
def user_has_voted(el, user):
    return el.get_user_vote(user)

The actual syntax is super simple. We register the filter by using the @register.filter decorator. And we simply take the passed in user, and call get_user_vote.

Great, now we can implement vote.html.

{% load filters %}
<a class="glyphicon glyphicon-chevron-up
  {% if el|user_has_voted:user == 1 %} text-success {%else%} text-muted {% endif %}" href="{% url 'upvote' pk=el.pk%}"></a>
<span class="label label-primary">{{ el.get_score }}</span>
<a class="glyphicon glyphicon-chevron-down
  {% if el|user_has_voted:user == -1 %} text-success {%else%} text-muted {% endif %}"  href="{% url 'downvote' pk=el.pk%}"></a>

All we do is display an up arrow, a count, and a down arrow. We add the text-success class if the user has upvoted or downvoted respectively, and the text-muted class otherwise. In between the up and down arrow, we show the score of the post using el.get_score. Remeber the get_score method we defined in the Votable class earlier? This is where we’re using it. Remember that el can either be a post or a comment.


How does the filter syntax work?

Custom filters are just Python functions that take one or two arguments:

  • The value of the variable or input.
  • The value of the argument – this can have a default value, or be left out altogether.

For example, in the filter {{ el|user_has_voted:user }}, the filter user_has_voted would be passed the variable el and the argument user.


We also hook up both the down and up arrows to their respective urls so that our views can handle it from there.

Let’s add this vote.html template to our other templates.

Go to post_detail.html, and include the vote.html template and pass in the el as the post object.

...
<div class="post">
    ....
    <div class="date">
        {{ post.date_created }}
        {% include "reddit/subs_posted.html" with post=post %}
    </div>
    {% include "reddit/vote.html" with el=post %}
...

Gp to comment.html, and include the vote.html template and pass in the el as the comment object.

...
    <div class="comment">
        <div class="date"> <strong>{{ comment.author }}</strong> on {{ comment.date_created }}</div>
        {% include "reddit/vote.html" with el=comment %}
...

At this point, you should be able to upvote, downvote, see all your nested comments, etc!

Our sample nested comments with votes.

Step 3: Add Search

Whew! Almost there 🎉.

This is the last part of this project. We’re going to add a way to search through all the posts.

Here are the set of tasks:

  • Add a search form to reddit/forms.py
  • Modify post_list.html to include the search form.
  • Modify the post_list view function in reddit/views.py to take in the query that the user typed into the search form. Filter the results by posts that have the query in their title or text.

reddit/forms.py

from django import forms
from .models import *
...
class SearchForm(forms.Form):
    # Your code here

reddit/views.py

def post_list(request):
    # Your code here

Add the form to the post_list.html template as well.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

Here is our what we came up with:

reddit/forms.py

from django import forms
from .models import *
...
class SearchForm(forms.Form):
    query = forms.CharField(label='Query', required=False)

Note that since the SearchForm is not tied to a model, there is no need to inherit from forms.ModelForm, so we can just use the simple forms.Form. Also a query isn’t required, so we set required=False.

reddit/views.py

from django.db.models import Q
def post_list(request):
    query = request.GET.get('query')
    if query:
        posts = Post.objects.filter(Q(text__icontains=query)|Q(title__icontains=query)).order_by('-date_created')
    else:
        posts = Post.objects.all().order_by('-date_created')
    return render(request, 'reddit/post_list.html',  {'posts': posts, 'form': SearchForm(initial={'query' : query})})

In all our past views that dealt with forms, we’ve had the following pattern:

if request.method == "POST":
    form = SomeForm(request.POST)
else:
    form = SomeForm()
...

However, since we’re just doing a search, that data will be passed in via a GET parameter. As an aside, GET is an HTTP request method that specifies that data should only be retrieved, i.e. no changes to the backend are made via a GET request. A POST request actually causes some change on the server (like an object being created or modified).

On line 2 of post_list, you can see that we extract the query from request.GET. After that if the query is defined, we use a [Q](https://docs.djangoproject.com/en/1.11/topics/db/queries/#complex-lookups-with-q-objects) operator to do an OR lookup. A Q object allows us to do more complex queries. In the example above, we filter for posts where the query is contained within the post’s text or within its title.

When we call the render function, we also pass the SearchForm. Remember to pass the current query to the SearchForm in the initial argument so that the search field doesn’t clear every time the user searches.

post_list.html

{% extends 'reddit/base.html' %}
{% block content %}
     <form class="form-inline search-form" method="GET">
        {{ form.as_p }}
        <button type="submit" class="btn btn-primary">Search!</button>
    </form>
    <div class="post-list">
    ... 

We just define the HTTP method for the form as GET, and the rest is the same as other forms we have done.

We added the following css to our reddit/static/css/reddit.css file so that the search form and button appear in the same line.

.search-form p {
    display: inline
}

Searching with query = “second”

AND THAT’S IT! 🎉🎉🎉

Congratulations, you’ve just made your Reddit clone!

Conclusion

You’ve created your first fairly involved Django project and learned a lot of advanced Django functionality along the way. The best way to get better is to keep doing more practice. If you’re feeling adventurous, here are a few ways to make your Reddit clone even better!

  1. Create a Subreddit creation form and hook it up to the frontend. The user who creates the subreddit is automatically added as the moderator.
  2. Instead of sorting the lists of posts by date_created, sort it by some function of the number of upvotes, downvotes, and date_created. In other words implement a better scoring function and sort by that.
  3. Allow comments to be edited.
  4. Allow voting of posts from both the post_list and sub_detail pages.
  5. Try integrating social login from the previous project into this one.

The final code is also available here for your reference. If you’re using Django 1.11, use the final code here.

Note that, you’ll have to run the following commands after downloading the above project.

python3 -m venv myvenv
source myvenv/bin/activate
pip install -r requirements.txt
python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser

© 2016-2022. All rights reserved.