Yes, all good things do come to an end. It gets even better when the ending is good. Steel Rumors was a project to help Django beginners progress to the next level from basic tutorials. It had elements which would be useful for most practical sites like user registrations and making CRUD views.
Honestly, I don’t like most video tutorials myself as they need a considerable amount of time to watch. However, if they come with a full transcript then I can skim through the text and decide if it contains tips which are worth watching on video. Sometimes, the additional commentary explaining the context is well worth the watch.
So, I set out to create Steel Rumors as something that everyone including me would enjoy watching. But it turns out creating the transcript is much harder than recording a quick video. In fact, it gets monotonous at times (I really sympathise those who work in Medical Transcription!).
The videos also get difficult to record in a longer, complex project such as this. I don’t edit out any mistakes or typos I make, since debugging those presents a valuable learning opportunity for beginners. But bringing all the details together while maintaining the continuity can get incredibly demanding.
Also most tutorials would try to show you how to use the most popular package or the easiest way to implement a feature. I deliberately avoided that, perhaps inspired by Learn Python the Hard Way. It might be okay to reinvent the wheel the first time because it will help you understand how wheels work for a lifetime. So, despite many comments telling me that it is easier to use X than Y, I stuck to the alternative which helps you learn the most.
In this tutorial, we would cover some interesting areas like how you can make Django forms work with AJAX and how a simple ranking algorithm works. As always you can choose to watch the video or read the step by step description below or follow both.
I would recommend watching all the previous parts before watching this video.
Did you learn quite a bit from this video series? Then you should sign up for my upcoming book “Building a Social News Site in Django”. It explains in a learn-from-a-friend style how websites are built and gradually tackles advanced topics like testing, security, database migrations and debugging.
Step-by-step Instructions
This is the transcript of the video. In part 3, we created a social news site where users can post and comment about rumours of “Man of Steel” but cannot vote.
The outline of Part 4 of the screencast is:
- Voting with FormView
- Voting over AJAX
- Mixins
- Display Voted Status
- Ranking algorithm
- Background tasks
Voting with FormView
-
We will add an upvote button (with a plus sign) to each headline. Clicking on this will toggle the user’s “voted” status for a link i.e. voted or did not vote. The safest way to implement it is using a
ModelForm
for ourVote
model.Add a new form to
links/forms.py
:from .models import Vote ... class VoteForm(forms.ModelForm): class Meta: model = Vote
-
We will use another generic view called
FormView
to handle the view part of this form. Add these lines tolinks/views.py
from django.shortcuts import redirect from django.shortcuts import get_object_or_404 from django.views.generic.edit import FormView from .forms import VoteForm from .models import Vote ... class VoteFormView(FormView): form_class = VoteForm def form_valid(self, form): link = get_object_or_404(Link, pk=form.data["link"]) user = self.request.user prev_votes = Vote.objects.filter(voter=user, link=link) has_voted = (prev_votes.count() > 0) if not has_voted: # add vote Vote.objects.create(voter=user, link=link) print("voted") else: # delete vote prev_votes[0].delete() print("unvoted") return redirect("home") def form_invalid(self, form): print("invalid") return redirect("home")
Those print statements will be removed soon and they are definitely not recommended for a production site.
-
Edit the home page template to add a voting form per headline. Add lines with ‘+’ sign (removing the ‘+’ sign) to
steelrumors/templates/links/link_list.html
:{% for link in object_list %} + <form method="post" action="{% url 'vote' %}" class="vote_form"> <li> [{{ link.votes }}] + {% csrf_token %} + <input type="hidden" id="id_link" name="link" class="hidden_id" value="{{ link.pk }}" /> + <input type="hidden" id="id_voter" name="voter" class="hidden_id" value="{{ user.pk }}" /> + <button>+</button> <a href="{% url 'link_detail' pk=link.pk %}"> <b>{{ link.title }}</b> </a> </li> + </form>
-
Add this view in
steelrumours/urls.py
:from links.views import VoteFormView url(r'^vote/$', auth(VoteFormView.as_view()), name="vote"),
Refresh the browser to see the ‘+’ buttons on every headline. You can vote them as well. But you can read the voting status only from the console.
Voting with AJAX
-
You have already copied the static folder of the goodies pack in the previous part. But in case you haven’t, then follow this step.
Create a folder named ‘js’ under
steelrumors/static
our javascript files. Copy jquery and vote.js from the goodies pack into this folder.mkdir steelrumors/static/js cp /tmp/sr-goodies-master/static/js/* ~/proj/steelrumors/steelrumors/static/js/
-
Add these lines to
steelrumors/templates/base.html
within<head>
block:<title>Steel Rumors</title> <link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}css/main.css" /> + <script src="{{ STATIC_URL }}js/jquery.min.js"></script> + <script src="{{ STATIC_URL }}js/vote.js"></script> </head> <body>
-
In
views.py
delete the entire classVoteFormView
and replace with these three classes. We are using a mixin to implement a JSON response for our AJAX requests:import json from django.http import HttpResponse ... class JSONFormMixin(object): def create_response(self, vdict=dict(), valid_form=True): response = HttpResponse(json.dumps(vdict), content_type='application/json') response.status = 200 if valid_form else 500 return response class VoteFormBaseView(FormView): form_class = VoteForm def create_response(self, vdict=dict(), valid_form=True): response = HttpResponse(json.dumps(vdict)) response.status = 200 if valid_form else 500 return response def form_valid(self, form): link = get_object_or_404(Link, pk=form.data["link"]) user = self.request.user prev_votes = Vote.objects.filter(voter=user, link=link) has_voted = (len(prev_votes) > 0) ret = {"success": 1} if not has_voted: # add vote v = Vote.objects.create(voter=user, link=link) ret["voteobj"] = v.id else: # delete vote prev_votes[0].delete() ret["unvoted"] = 1 return self.create_response(ret, True) def form_invalid(self, form): ret = {"success": 0, "form_errors": form.errors } return self.create_response(ret, False) class VoteFormView(JSONFormMixin, VoteFormBaseView): pass
Showing the Voted state
-
We need some indication to know if the headline was voted or not. To achieve this, we can pass ids of all the links that have been voted by the logged in user. This can be passed as a context variable i.e.
voted
.Add this to
LinkListView
class inlinks/views.py
:class LinkListView(ListView): ... def get_context_data(self, **kwargs): context = super(LinkListView, self).get_context_data(**kwargs) if self.request.user.is_authenticated(): voted = Vote.objects.filter(voter=self.request.user) links_in_page = [link.id for link in context["object_list"]] voted = voted.filter(link_id__in=links_in_page) voted = voted.values_list('link_id', flat=True) context["voted"] = voted return context
-
Change the home page template again. Add lines with ‘+’ sign (removing the ‘+’ sign) to
steelrumors/templates/links/link_list.html
:<input type="hidden" id="id_voter" name="voter" class="hidden_id" value="{{ user.pk }}" /> + {% if not user.is_authenticated %} + <button disabled title="Please login to vote">+</button> + {% elif link.pk not in voted %} <button>+</button> + {% else %} + <button>-</button> + {% endif %} <a href="{% url 'link_detail' pk=link.pk %}">
Now, the button changes based on the voted state of a headline. Try it on your browser with different user logins.
Calculating Rank Score
-
We are going to change the sorting order of links from highest voted to highest score. Add a new function to
models.py
to calculate the rank score:from django.utils.timezone import now ... class Link(models.Model): ... def set_rank(self): # Based on HN ranking algo at http://amix.dk/blog/post/19574 SECS_IN_HOUR = float(60*60) GRAVITY = 1.2 delta = now() - self.submitted_on item_hour_age = delta.total_seconds() // SECS_IN_HOUR votes = self.votes - 1 self.rank_score = votes / pow((item_hour_age+2), GRAVITY) self.save()
-
In the same file, change the sort criteria in the
LinkVoteCountManager
class. The changed line has been marked with a ‘+’ sign.class LinkVoteCountManager(models.Manager): def get_query_set(self): return super(LinkVoteCountManager, self).get_query_set().annotate( + votes=Count('vote')).order_by('-rank_score', '-votes')
Ranking Job
-
Calculating the score for all links is generally a periodic task which should happen in the background. Create a file called rerank.py in the project root with the following content:
#!/usr/bin/env python import os os.environ.setdefault("DJANGO_SETTINGS_MODULE", "steelrumors.settings") from links.models import Link def rank_all(): for link in Link.with_votes.all(): link.set_rank() import time def show_all(): print "\n".join("%10s %0.2f" % (l.title, l.rank_score, ) for l in Link.with_votes.all()) print "----\n\n\n" if __name__=="__main__": while 1: print "---" rank_all() show_all() time.sleep(5)
This runs every 5 secs in the foreground.
-
Turn it into a background job
(nohup python -u rerank.py&) tail -f nohup.out
Note that this is a very simplistic implementation of a background job. For a more robust solution, check out Celery.
Watching the News Dive
It is fun to watch the rank scores rise and fall for links. It is almost as fun as watching an aquarium except with numbers. But the ranking function set_rank
in models.py
has the resolution of an hour. This makes it quite boring.
To see a more dramatic change in rank scores change the SECS_IN_HOUR
constant to small value like 5.0. Now submit a new link and watch the scores drop like a stone!
Final Comments
Steel Rumors is far from being a complete Hacker News clone. But it supports voting, submission of links and user registrations. In fact, it is quite useable at this point.
Check out a demo of Steel Rumors yourself.
Hope you enjoyed this tutorial series as much as I did while making them. If you get stuck anywhere make sure you check the github source first for reference. Keep your comments flowing!
Resources
- Full Source on Github