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.
- User has voted on this object before.
- 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.
- The new vote is different, hence the user is changing their vote from upvote to downvote or from downvote to upvote.
- 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 thevote_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 theUserVote
object is saved. In our case, the only time we update this object is when we’re changing thevote_type
. - If the new
vote_type
corresponds to an upvote, then we increment theupvote_count
and decrement thedownvote_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:
- Show the score of a comment or post.
- Show whether a user has upvoted, downvoted, or not voted on a comment or post.
- 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 inreddit/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 theirtitle
ortext
.
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!
- Create a Subreddit creation form and hook it up to the frontend. The user who creates the subreddit is automatically added as the moderator.
- Instead of sorting the lists of posts by
date_created
, sort it by some function of the number ofupvotes
,downvotes
, anddate_created
. In other words implement a better scoring function and sort by that. - Allow comments to be edited.
- Allow voting of posts from both the
post_list
andsub_detail
pages. - 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