Polls on steroids 01

This tutorial is an attempt to add some extra content to the original Django polls tutorial, explore additional features of the framework and even make the app look and respond better with some front-end tools on future chapters. It will be assumed that you’ve completed Django's official tutorial and you are already quite comfortable with Python. I’ll be using Python 3.3 and Django 1.6 under a Debian based OS.
I’ll keep the code for this tutorial in this GitHub repository. The master branch contains the code of the original Django tutorial in almost the exact same state as if you’ve just completed it. The other branches are named with the format ‘XXPOS’, where XX is a two digit number indicating what part of the POS tutorial the code belongs to (so by the time you finish this first chapter, your code will look like the one contained on the 01POS branch). Notice that if you’re going to work with my GitHub repository, you’ll also get the sqlite3.db file including my example models.
I left all the apps that come installed by default, including the authentication system. In case you want to make use of this repository and the already created superuser, its username and password are admin and admin.


Editing the TIME_ZONE settings

It would be a good idea to edit the settings.py file of the project and change these settings to match your own. Sooner or later you’ll have to check Django’s documentation about time zones, and you might also want to check pytz, but for now you might just want to check this Wikipedia article. You should put the complete timezone name (TZ column), the country code won’t work (at least didn’t work for me). Try to synchronize the database now (python3 manage.py syncdb), if you changed the timezone for an invalid one, you’ll get an error message and the database won’t sync.


Preventing access to future polls on every view

Let’s start with a quite obvious improvement. Notice that you can still access unpublished polls through their pk number on the vote and results views if you manipulate the url. Let’s fix that by filtering our QuerySets (lines 2 and 21):

Code (click to collapse/expand)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

def vote(request, poll_id):
    p = get_object_or_404(Poll.objects.filter(pub_date__lte = timezone.now()),
                          pk = poll_id)
    try:
        selected_choice = p.choice_set.get(pk = request.POST['choice'])
    except (KeyError, Choice.DoesNotExist):
        return render(request, 'polls/detail.html',
            {'poll': p, 'error_message': "You didn't select a choice",}
        )
    else:
        selected_choice.votes += 1
        selected_choice.save()
    return HttpResponseRedirect(reverse('polls:results', args=(p.id,)))


class ResultsView(generic.DetailView):
    model = Poll
    template_name = 'polls/results.html'

    def get_queryset(self):
        return Poll.objects.filter(pub_date__lte = timezone.now())
  


Excluding polls with less than 2 choices

I went a little bit further than the original tutorial suggestion of eliminating polls with no choices, since I don’t think polls with less than 2 choices make much sense, but feel free to change this number to 1 or 0 if you want. We’ll need a little bit of aggregation for our querysets, you can see I’m using the annotate and the Count functions.

Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

from django.shortcuts import render, get_object_or_404
from django.http import HttpResponseRedirect, HttpResponse
from django.core.urlresolvers import reverse
from django.views import generic
from polls.models import Poll, Choice
from django.utils import timezone
from django.db.models import Count


class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_poll_list'

    def get_queryset(self):
        p = Poll.objects.annotate(num_choices=Count('choice'))
        return p.filter(
            pub_date__lte = timezone.now(), num_choices__gte=2
            ).order_by('-pub_date')[:5]


class DetailView(generic.DetailView):
    model = Poll
    template_name = 'polls/detail.html'

    def get_queryset(self):
        """
        Excludes any polls that aren't published yet.
        """
        p = Poll.objects.annotate(num_choices = Count('choice'))
        return p.filter(pub_date__lte = timezone.now(), num_choices__gte = 2)


def vote(request, poll_id):
    p = Poll.objects.annotate(num_choices = Count('choice'))
    p = get_object_or_404(p.filter(
            pub_date__lte = timezone.now()),
            pk = poll_id,
            num_choices__gte = 2)
    try:
        selected_choice = p.choice_set.get(pk = request.POST['choice'])
    except (KeyError, Choice.DoesNotExist):
        return render(request, 'polls/detail.html',
            {'poll': p, 'error_message': "You didn't select a choice",}
        )
    else:
        selected_choice.votes += 1
        selected_choice.save()
    return HttpResponseRedirect(reverse('polls:results', args=(p.id,)))


class ResultsView(generic.DetailView):
    model = Poll
    template_name = 'polls/results.html'

    def get_queryset(self):
        p = Poll.objects.annotate(num_choices = Count('choice'))
        return p.filter(pub_date__lte = timezone.now(), num_choices__gte = 2)
    



Pre filtering the QuerySets

You’ve probably already noticed there’s quite a lot of repeated code here. Since we’re applying the same filtering to the QuerySet of every view, my approach to solve this was adding an auxiliary function named filter_polls. Our function returns a QuerySet based on the Poll model, so we can also remove the “model = Poll” line from the DetailView based views. Here’s the current full code (except for the imports) for polls/views.py:

Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

import ...


def filter_polls():
    """
    Excludes polls with less than 2 choices or future pub_date.
    """
    p = Poll.objects.annotate(num_choices=Count('choice'))
    return p.filter(pub_date__lte = timezone.now(), num_choices__gte = 2)


class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_poll_list'

    def get_queryset(self):
        return filter_polls().order_by('-pub_date')[:5]


class DetailView(generic.DetailView):
    template_name = 'polls/detail.html'

    def get_queryset(self):
        return filter_polls()


def vote(request, poll_id):
    p = get_object_or_404(filter_polls().filter(pk = poll_id))
    try:
        selected_choice = p.choice_set.get(pk = request.POST['choice'])
    except (KeyError, Choice.DoesNotExist):
        return render(request, 'polls/detail.html',
            {'poll': p, 'error_message': "You didn't select a choice",}
        )
    else:
        selected_choice.votes += 1
        selected_choice.save()
    return HttpResponseRedirect(reverse('polls:results', args=(p.id,)))


class ResultsView(generic.DetailView):
    template_name = 'polls/results.html'

    def get_queryset(self):
        return filter_polls()
    


Adding some extra tests

And now I think it would be a perfect time to write some additional tests. Notice that if you run them right now, you’ll get a couple of assertion errors, but that’s perfectly fine since our previous tests didn’t take into account the number of choices with which the test polls were created, so we’ll also have to modify them. We can start by modifying the create_poll function so that it creates a number of choices for our polls determined by an extra parameter. The tests for the detail, vote and results views are exactly the same, but I think that trying to reduce the amount of code by keeping only one block of tests and iterating over the set of views would introduce tight coupling and go against the unit testing principles. Here’s the full (long and cumbersome) code of my tests:

Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184

import datetime
from django.test import TestCase
from django.utils import timezone
from polls.models import Poll
from django.core.urlresolvers import reverse


class PollMethodTests(TestCase):

    def test_was_published_recently_with_future_poll(self):
        """
        was_published_recently() should return False for polls whose
        pub_date is in the future
        """
        future_poll = Poll(pub_date=timezone.now() + datetime.timedelta(days=30))
        self.assertEqual(future_poll.was_published_recently(), False)

    def test_was_published_recently_with_old_poll(self):
        """
        was_published_recently() should return False for polls whose pub_date
        is older than 1 day
        """
        old_poll = Poll(pub_date=timezone.now() - datetime.timedelta(days=30))
        self.assertEqual(old_poll.was_published_recently(), False)

    def test_was_published_recently_with_recent_poll(self):
        """
        was_published_recently() should return True for polls whose pub_date
        is within the last day
        """
        recent_poll = Poll(pub_date=timezone.now() - datetime.timedelta(hours=1))
        self.assertEqual(recent_poll.was_published_recently(), True)


def create_poll(question, days, num_choices):
    """
    Creates a poll with the given 'question' published with an offset
    of 'days' with respect to now and 'num_choices' available choices.
    """
    p = Poll.objects.create(question=question,
        pub_date = timezone.now() + datetime.timedelta(days=days))
    for i in range (0, num_choices):
        p.choice_set.create(choice_text=str(i), votes=0)
    return p


class PollIndexViewTests(TestCase):
    """
    Only past polls with 2 or more choices must be shown.
    If there are no polls, or none of them satisfy these conditions,
    an appropriate message should be displayed.
    """
    def test_index_view_with_no_polls(self):
        response = self.client.get(reverse('polls:index'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available")
        self.assertQuerysetEqual(response.context['latest_poll_list'], [])

    def test_index_with_a_past_poll_with_2_choices(self):
        create_poll("Past poll.", days=-30, num_choices=2)
        response = self.client.get(reverse('polls:index'))
        self.assertEqual(response.status_code, 200)
        self.assertQuerysetEqual(response.context['latest_poll_list'],
            [''])

    def test_index_with_a_past_poll_with_0_choices(self):
        create_poll("Past poll.", days=-30, num_choices=0)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.", status_code=200)
        self.assertQuerysetEqual(response.context['latest_poll_list'], [])

    def test_index_view_with_a_future_poll_with_2_choices(self):
        create_poll(question="Future poll.", days=30, num_choices=2)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.", status_code=200)
        self.assertQuerysetEqual(response.context['latest_poll_list'], [])

    def test_index_view_with_a_future_poll_with_0_choices(self):
        create_poll(question="Future poll.", days=30, num_choices=0)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.", status_code=200)
        self.assertQuerysetEqual(response.context['latest_poll_list'], [])

    def test_index_view_with_future_poll_and_past_poll_with_2_choices_each(self):
        create_poll(question="Past poll.", days=-30, num_choices=2)
        create_poll(question="Future poll.", days=30, num_choices=2)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_poll_list'],
            ['']
        )

    def test_index_view_with_2_past_polls_with_0_and_2_choices(self):
        create_poll(question="Past poll with 0 choices.", days=-30, num_choices=0)
        create_poll(question="Past poll with 2 choices.", days=-30, num_choices=2)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_poll_list'],
            ['']
        )

    def test_index_view_with_two_past_polls_with_2_choices_each(self):
        create_poll(question="Past poll 1.", days=-30, num_choices=2)
        create_poll(question="Past poll 2.", days=-5, num_choices=2)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_poll_list'],
             ['', '']
        )


class DetailViewTests(TestCase):
    """
    Only a past poll with 2 or more choices will be accessible.
    """
    def test_detail_view_with_a_past_poll_with_2_choices(self):
        past_poll = create_poll(question='Past Poll.', days=-5, num_choices=2)
        response = self.client.get(reverse('polls:detail', args=(past_poll.id,)))
        self.assertContains(response, past_poll.question, status_code=200)

    def test_detail_view_with_a_past_poll_with_0_choices(self):
        future_poll = create_poll(question='Past poll.', days=5, num_choices=0)
        response = self.client.get(reverse('polls:detail', args=(future_poll.id,)))
        self.assertEqual(response.status_code, 404)

    def test_detail_view_with_a_future_poll_with_2_choices(self):
        future_poll = create_poll(question='Future poll.', days=5, num_choices=2)
        response = self.client.get(reverse('polls:detail', args=(future_poll.id,)))
        self.assertEqual(response.status_code, 404)

    def test_detail_view_with_a_future_poll_with_0_choices(self):
        future_poll = create_poll(question='Past poll.', days=5, num_choices=0)
        response = self.client.get(reverse('polls:detail', args=(future_poll.id,)))
        self.assertEqual(response.status_code, 404)


class ResultsViewTests(TestCase):
    """
    Only a past poll with 2 or more choices will be accessible.
    """
    def test_results_view_with_a_past_poll_with_2_choices(self):
        past_poll = create_poll(question='Past Poll.', days=-5, num_choices=2)
        response = self.client.get(reverse('polls:results', args=(past_poll.id,)))
        self.assertContains(response, past_poll.question, status_code=200)

    def test_results_view_with_a_past_poll_with_0_choices(self):
        future_poll = create_poll(question='Past poll.', days=5, num_choices=0)
        response = self.client.get(reverse('polls:results', args=(future_poll.id,)))
        self.assertEqual(response.status_code, 404)

    def test_results_view_with_a_future_poll_with_2_choices(self):
        future_poll = create_poll(question='Future poll.', days=5, num_choices=2)
        response = self.client.get(reverse('polls:results', args=(future_poll.id,)))
        self.assertEqual(response.status_code, 404)

    def test_results_view_with_a_future_poll_with_0_choices(self):
        future_poll = create_poll(question='Past poll.', days=5, num_choices=0)
        response = self.client.get(reverse('polls:results', args=(future_poll.id,)))
        self.assertEqual(response.status_code, 404)


class VoteViewTests(TestCase):
    """
    Only a past poll with 2 or more choices will be accessible.
    """
    def test_vote_view_with_a_past_poll_with_2_choices(self):
        past_poll = create_poll(question='Past Poll.', days=-5, num_choices=2)
        response = self.client.get(reverse('polls:vote', args=(past_poll.id,)))
        self.assertContains(response, past_poll.question, status_code=200)

    def test_vote_view_with_a_past_poll_with_0_choices(self):
        future_poll = create_poll(question='Past poll.', days=5, num_choices=0)
        response = self.client.get(reverse('polls:vote', args=(future_poll.id,)))
        self.assertEqual(response.status_code, 404)

    def test_vote_view_with_a_future_poll_with_2_choices(self):
        future_poll = create_poll(question='Future poll.', days=5, num_choices=2)
        response = self.client.get(reverse('polls:vote', args=(future_poll.id,)))
        self.assertEqual(response.status_code, 404)

    def test_vote_view_with_a_future_poll_with_0_choices(self):
        future_poll = create_poll(question='Past poll.', days=5, num_choices=0)
        response = self.client.get(reverse('polls:vote', args=(future_poll.id,)))
        self.assertEqual(response.status_code, 404)
    


Extra: Generating a random number of votes for a poll

It would be a good idea to add a decent amount of polls, so that this app starts to look like something real and useful. If you’re using my repository, you’ll notice I’ve added a couple of polls and choices, and also some completely random and unbiased results. You can easily add more or delete them at will with Django’s admin. Let’s combine our previous Python knowledge with the manage.py shell and Django’s db API to do something useful. There’s a sports cars related poll in my database, I’ll use this as an example. Fire up the manage.py shell and let’s do some database API hacking.

>>> from polls.models import Poll, Choice
>>> from random import randrange  # We’ll this to generate the random values
>>> p = Poll.objects.filter(question__contains='car')[0]
>>> p  # Checking we actually get the poll we wanted...
<Poll: What's your favorite sports car manufacturer?>
>>> choices = p.choice_set.all()
>>> [i.votes for i in choices]  # Checking the initial values
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
>>> for i in choices:
...   i.votes = randrange(2000, 6000)
...   i.save()  # Let us not forget to update the db for every choice!
... 
>>> p = Poll.objects.filter(question__contains='car')[0]
>>> choices = p.choice_set.all()  # Making sure that the values come from the db
>>> [i.votes for i in choices]  # Now let’s check if they’ve changed
[5399, 3015, 3972, 4218, 5492, 3324, 2949, 3199, 2710, 2256, 5780, 5270, 5508]

Reassigning p and choices as we’ve done here should be enough to check that the database has been updated, but you can (and probably should) use other ways to make sure that you’ve actually achieved this. You can do this through the manage.py shell (make sure you’re checking updated values in the database instead of the objects referenced in your current namespace!), checking it with your RDBMS, or (probably the quickest option) by logging into Django’s admin. If you want to create an outrageous amount of polls, you can go even further and create your own poll-o-matic! You could do this by creating a document with each poll question followed by a list of choices and process it with a Python script, also generating random values for the votes with some code similar to the one used in the previous example.

Comentarios

Entradas populares de este blog

Django and web apps 101