Building a Hacker News clone in Django - Part 4 (AJAX and Mixin)

8 min read · Posted on: Aug 19, 2013 · Print this page

You are reading a post from a four-part tutorial series

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.

Screencast

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

  1. 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 our Vote model.

    Add a new form to links/forms.py:

        from .models import Vote
        ...
    
        class VoteForm(forms.ModelForm):
            class Meta:
                model = Vote
    
    
  2. We will use another generic view called FormView to handle the view part of this form. Add these lines to links/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.

  3. 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>    
    
  4. 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

  1. 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/
    
  2. 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>
    
  3. In views.py delete the entire class VoteFormView 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

  1. 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 in links/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
    
  2. 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

  1. 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()
    
  2. 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

  1. 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.

  2. 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


Arun Ravindran profile pic

Arun Ravindran

Arun is the author of "Django Design Patterns and Best Practices". Works as a Product Manager at Google. Avid open source enthusiast. Keen on Python. Loves to help people learn technology. Find out more about Arun on the about page.

Don't miss any future posts!

Comments →

Next: ▶   Real-time Applications and will Django adapt to it?

Prev: ◀   Building a Hacker News clone in Django - Part 3 (Comments and CRUD)

Up: ▲   Blog

Featured Posts

Frequent Tags

banner ad for Django book

Comments

powered by Disqus