Django polls api using Django REST Framework

Adding an api layer to Django polls app

Agenda

This blog post is targeted towards beginner Django REST Framework users and assumes that you have completed Django poll app tutorial.

We have deliberately avoided usage of class based views, ModelSerializer and advanced concepts like routers. If you understand advanced DRF concepts, this post may not be for you.

The code samples of this post have been tried with Python 3, Django 2 and DRF 3.9.

We will be creating the following apis in this post.

  • An api to create a poll question.
  • Api to list questions.
  • Api to get question detail.
  • Api to edit a question.
  • Api to delete a question.
  • Api to create choice for a particular question.
  • Api to see question detail along with available choices.
  • Api to vote for a particular choice of a question.
  • Api to see result for a particular question.

Setup

This post assumes that you have the project setup as described at https://docs.djangoproject.com/en/2.2/intro/tutorial01/.

You must have a polls app in your project and the models Question and Choice.

# polls/models.py
class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField('date published')

    def __str__(self):
        return self.question_text

    def was_published_recently(self):
        now = timezone.now()
        return now - datetime.timedelta(days=1) <= self.pub_date <= now


class Choice(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)
    votes = models.IntegerField(default=0)

    def __str__(self):
        return self.choice_text

Apis

Django documentation says: “You should know basic math before you start using a calculator.”. Following this advice, we will initially get our api up using more explicit code and with minimal features of DRF.

As we proceed with the tutorial we will use more advanced features and shortcuts provided by DRF. DRF would enable readable and maintainable code. You will start appreciating it’s power as we introduce it’s features.

Create question

Let’s write an api to create question.

This api should be available at POST /api/polls/questions/.

Side note: This. is an informative resource to learn about REST api endpoints design best practices.

Create a module called polls/apiviews.py to keep polls apis.

touch polls/apiviews.py

Add following code to polls/apiviews.py

from datetime import datetime

from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt

from .models import Question

@csrf_exempt
def questions_view(request):
    if request.method == 'GET':
        return HttpResponse("Not Implemented")
    elif request.method == 'POST':
        question_text = request.POST['question_text']
        pub_date = datetime.strptime(request.POST['pub_date'], '%Y-%m-%d')
        Question.objects.create(question_text=question_text, pub_date=pub_date)
        return HttpResponse("Question created", status=201)

Add following urlpattern to root URLconf file.

# mysite/urls.py
path('api/polls/', include('polls.urls'))

Add following urlpattern to polls/urls.py

from . import apiviews

path('questions/', apiviews.questions_view, name='questions_view'),

Let’s use Postman to make a POST call to this api endpoint. Postman is an extremely useful tool, start using it if you aren’t already.

You could use Python requests and make api call from shell.

In [1]: import requests

In [2]: resp = requests.post('http://localhost:8000/api/polls/questions/', data={'pub_date': '2019-04-18', 'question_text': "What's new?"})

Or you could use curl.

curl -X POST -F 'question_text=What is the meaning of life?' -F 'pub_date=2019-04-18' http://localhost:8000/api/polls/questions/

These POST calls should have created Question instances.

In [3]: Question.objects.all()
Out[3]: <QuerySet [<Question: What's new?>, <Question: What's the color of sky?>, <Question: What is the meaning of life?>]>

Issues

There are several issues with this apiview.

This apiview cannot handle json data. It can only handle form data. Api best practices dictate that api should be capable of handling json data and not just form data.

Try posting json data and the api would fail.

Notice how we selected raw in postman and type as JSON (application/json)

The error on Django runserver console would be:

django.utils.datastructures.MultiValueDictKeyError: 'question_text'

You can post json data with Python requests in following way:

In [2]: resp = requests.post('http://localhost:8000/api/polls/questions/', json={'pub_date': '2019-04-18', 'question_text': "What's new?"})

In [3]: resp.status_code
Out[3]: 500              # status_code 500 means that the api failed

This would have caused same error on runserver console.

Another issue with this api is if POST data is missing question_text or pub_date then the api would fail. The api shouldn’t fail in such cases and instead return a descriptive message.

We will have to add the following validation code to our api if we want it to not fail and return a descriptive message.

if 'question_text' not in request.POST or 'pub_date' not in request.POST:
    return HttpResponse("question_text or pub_date missing", status=400)

Thirdly, our api would fail if pub_date do not confirm to our assumed date format.

We came across these 3 api fail scenarios with just two model fields. As number of model fields increase the validation code needed to make the api full proof would become longer and unmaintainable. This is where DRF api_view and DRF serializers come to rescue.

Using DRF api_view and Serializer

api_view can handle form-data as well as application/json data.

Modify apiview to look like.

from rest_framework.decorators import api_view

@api_view(['GET', 'POST'])
def questions_view(request):
    if request.method == 'GET':
        return HttpResponse("Not Implemented")
    elif request.method == 'POST':
        question_text = request.data['question_text']
        pub_date = datetime.strptime(request.data['pub_date'], '%Y-%m-%d')
        Question.objects.create(question_text=question_text, pub_date=pub_date)
        return HttpResponse("Question created", status=201)

Try posting json data again and the api view would succeed this time.

You might have noticed that we changed request.POST to request.data. When not using DRF, i.e in request.POST, request was an HttpRequest object provided by Django.

When we decorated our view with @api_view, the first argument passed to view i.e request is a DRF Request object and is not HttpRequest anymore. DRF Request has a special attribute called data which works seamlessly with json data in addition to form-data.

The argument to @api_view, i.e ['GET', 'POST'] is the list of methods that will be serviceable by this view.

The issue of data validation still remains, let’s fix it with a serializer.

Let’s create a serializer in polls/serializers.py.

from rest_framework import serializers

class QuestionSerializer(serializers.Serializer):
    question_text = serializers.CharField(max_length=200)
    pub_date = serializers.DateTimeField()

A serializers.Serializer is a class provided by DRF which can handle data validation.

Let’s use this serializer in the view.

from django.http import HttpResponse
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status

from .models import Question
from .serializers import QuestionSerializer


@api_view(['GET', 'POST'])
def questions_view(request):
    if request.method == 'GET':
        return HttpResponse("Not Implemented")
    elif request.method == 'POST':
        serializer = QuestionSerializer(data=request.data)
        if serializer.is_valid():
            question_text = serializer.data['question_text']
            pub_date = serializer.data['pub_date']
            Question.objects.create(question_text=question_text, pub_date=pub_date)
            return Response("Question created", status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

The new bits used in the apiview are:

  • Using the serializer and inserting a question only if the data is valid.
  • Using a DRF specific Response object instead of Django HttpResponse. Response is a subclass of HttpResponse having additional functionality.
  • Returning serializer errors if posted data is invalid.

Let’s post data without pub_date.

Let’s post with wrong format of pub_date.

As you should have noticed, the descriptive error messages were provided to us by the serializer for free.

View is able to check data sanity and report on invalid data if we use a serializer.

You should be able to create a question by passing pub_date in correct format.

We can make the view more concise by using serializer.validated_data instead of using serializer.data[‘question_text’] and serializer.data[‘pub_date’].

@api_view(['GET', 'POST'])
def questions_view(request):
    if request.method == 'GET':
        return HttpResponse("Not Implemented")
    elif request.method == 'POST':
        serializer = QuestionSerializer(data=request.data)
        if serializer.is_valid():
            Question.objects.create(**serializer.validated_data)
            return Response("Question created", status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

This view is an api view in real sense. It can handle json data. It can also handle data validation and report with descriptive error messages.

List questions

The api should be available at GET /api/polls/questions/.

We want the api response to provide the question_text and pub_date for all questions.

Modify questions_view so it looks like the following:

import json

@api_view(['GET', 'POST'])
def questions_view(request):
    if request.method == 'GET':
        questions = []
        for question in Question.objects.all():
            question_representation = {'question_text': question.question_text, 'pub_date': question.pub_date.strftime("%Y-%m-%d")}
            questions.append(question_representation)
        return HttpResponse(json.dumps(questions), content_type='application/json')
    elif request.method == 'POST':
        serializer = QuestionSerializer(data=request.data)
        if serializer.is_valid():
            Question.objects.create(**serializer.validated_data)
            return Response("Question created", status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Try GET /api/polls/questions/

Currently view’s GET part is manageable since Question only has two fields. When number of fields of Question grows, the code will keep becoming longer and longer. When a ForeignKey or ManyToManyField is added the code would become increasingly complex. This is where serializers again come to rescue.

Modify questions_view to use QuestionSerializer.

@api_view(['GET', 'POST'])
def questions_view(request):
    if request.method == 'GET':
        questions = Question.objects.all()
        serializer = QuestionSerializer(questions, many=True)
        return Response(serializer.data)
    elif request.method == 'POST':
        serializer = QuestionSerializer(data=request.data)
        if serializer.is_valid():
            Question.objects.create(**serializer.validated_data)
            return Response("Question created", status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Try GET /api/polls/questions/ again.

You should have noticed that we were able to get same response with fewer lines and much cleaner code.

By using DRF features, we achieved the following advantages:

  • As we used serializer, we didn’t have to loop through the questions and create each question_representation.
  • DRF Response took care of setting the content_type of response.

DRF serializer provides a method called .save() which in turn calls .create(). DRF recommended way to achieve our post functionality would involve adding a .create() implementation to serializer and calling .save() from the view.

# polls/serializers.py
class QuestionSerializer(serializers.Serializer):
    question_text = serializers.CharField(max_length=200)
    pub_date = serializers.DateTimeField()

    # DRF serializer.save() calls self.create(self.validated_data)
    def create(self, validated_data):
        return Question.objects.create(**validated_data)

# polls/apiviews.py
@api_view(['GET', 'POST'])
def questions_view(request):
    if request.method == 'GET':
        questions = Question.objects.all()
        serializer = QuestionSerializer(questions, many=True)
        return Response(serializer.data)
    elif request.method == 'POST':
        serializer = QuestionSerializer(data=request.data)
        if serializer.is_valid():
            question = serializer.save()
            return Response(QuestionSerializer(question).data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

List with derived fields

Question has a method called was_published_recently which returns a boolean. We also want to send was_published_recently for all Questions in GET call.

This will need adding the following line to QuestionSerializer.

was_published_recently = serializers.BooleanField(read_only=True)

We made it read_only because it’s not a model field and so serializer should not be expecting it in POST calls.

Serializer is smart enough to infer that was_published_recently is not a model field on Question but is instead a method. Serializer uses Question.was_published_recently() to get a boolean value and add it in the serializer representation for Question.

Say you want to add one more model method called Question.verbose_question_text and want it in the GET response. You would do the following:

# polls/models.py
# Add this method to model Question
def verbose_question_text(self):
    return "Question : %s" % (self.question_text)

# polls/serializers.py
# Add following to QuestionSerializer
verbose_question_text = serializers.CharField(read_only=True)

Detail of a question

The api should be available at GET /api/polls/questions/<question_id>/.

Add the following view to polls/apiviews.py

from django.shortcuts import get_object_or_404


@api_view(['GET', 'PATCH', 'DELETE'])
def question_detail_view(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    if request.method == 'GET':
        serializer = QuestionSerializer(question)
        return Response(serializer.data)
    elif request.method == 'PATCH':
        raise NotImplementedError("PATCH currently not supported")
    elif request.method == 'DELETE':
        raise NotImplementedError("DELETE currently not supported")

Currently PATCH and DELETE are not implemented.

Add a urlpattern to polls/urls.py

path('questions/<int:question_id>/', apiviews.question_detail_view, name='question_detail_view'),

Make a GET request to /api/polls/questions/1/

We have introduced a Django shortcut get_object_or_404(). DRF is smart enough to deal with this Django shortcut and return a 404 response when a question with is doesn’t exist.

Edit a poll question

The api should be available at PATCH /api/polls/questions/<question_id>.

We want to send a partial represention of a question in the api request and want an attribute of question to be changed accordingly. eg: We might want to change question_text of a Question instance. PATCH is the recommended HTTP method for such cases.

Modify question_detail_view PATCH handler code.

@api_view(['GET', 'PATCH', 'DELETE'])
def question_detail_view(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    if request.method == 'GET':
        serializer = QuestionSerializer(question)
        return Response(serializer.data)
    elif request.method == 'PATCH':
        serializer = QuestionSerializer(question, data=request.data, partial=True)
        if serializer.is_valid():
            question = serializer.save()
            return Response(QuestionSerializer(question).data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
    elif request.method == 'DELETE':
        raise NotImplementedError("DELETE currently not supported")

In this case, serializer.save() would call update() on serializer and not create(). create() is called by save() when no instance is given during serializer instantiation. In this case we have passed a Question instance during serializer instantiation. So save() would call update(). We need to add an update() implementation to QuestionSerializer.

# Add update() implementation on QuestionSerializer
def update(self, instance, validated_data):
    for key, value in validated_data.items():
        setattr(instance, key, value)
    instance.save()
    return instance

DRF provides a keyword argument called partial on Serializer and that’s what we used here.

question_text of Question id 1 is “What’s new?”. Let’s change it to “What’s newer?” by making a PATCH request to /api/polls/questions/1/.

Delete a poll question

The api should be available at DELETE /api/polls/questions/<question_id>.

Modify DELETE handler code to look like:

elif request.method == 'DELETE':
    question.delete()
    return Response("Question deleted", status=status.HTTP_204_NO_CONTENT)

Post a question choice

The api should be available at POST /api/polls/questions/<question_id>/choices/.

As we want to POST a choice, so we would need a ChoiceSerializer to validate posted data. Let’s add a ChoiceSerializer.

class ChoiceSerializer(serializers.Serializer):
    choice_text = serializers.CharField(max_length=200)

    def create(self, validated_data):
        return Choice.objects.create(**validated_data)

We added a create() implementation, similar to how we added a create() implementation for QuestionSerializer.

Let’s add the needed url pattern.

path('questions/<int:question_id>/choices/', apiviews.choices_view, name='choices_view')

Let’s add the following apiview.

@api_view(['POST'])
def choices_view(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    serializer = ChoiceSerializer(data=request.data)
    if serializer.is_valid():
        choice = serializer.save(question=question)
        return Response(ChoiceSerializer(choice).data, status=status.HTTP_201_CREATED)
    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

You must have noticed keyword argument question in serializer.save() call. Any keyword argument passed to serializer save() is added to the validated_data before calling serializer’s create() or update().

Our Choice modelling dictates that a choice be always related with a Question. So we need a Question instance during Choice.objects.create(). Calling serializer.save(question=question) would ensure that validted_data is populated with a question instance.

Let’s make the api call to create a choice for Question id 1.

Question detail with choices

We already added an api for question detail which is available at GET /api/polls/questions/<question_id>.

But currently the response for a question doesn’t include the choices for the question. We can get question choices in the api response by modifying the serializer and adding a model method.

Add the following model method to Question.

class Question(models.Model):
    ....
    def choices(self):
        if not hasattr(self, '_choices'):
            self._choices = self.choice_set.all()
        return self._choices

Add the following line to QuestionSerializer

choices = ChoiceSerializer(many=True, read_only=True)

Make a GET request for Question detail for question 1 and you should see the choices for question in the api response.

This has a caveat though. This would also include the choices for each question in Question list api. We don’t want choices in Question list api, we only want it in Question detail api.

Let’s do some refactoring to split QuestionSerializer into two classes which are QuestionListPageSerializer and QuestionDetailPageSerializer.

class QuestionListPageSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    question_text = serializers.CharField(max_length=200)
    pub_date = serializers.DateTimeField()
    was_published_recently = serializers.BooleanField(read_only=True) # Serializer is smart enough to understand that was_published_recently is a method on Question

    def create(self, validated_data):
        return Question.objects.create(**validated_data)

    def update(self, instance, validated_data):
        for key, value in validated_data.items():
            setattr(instance, key, value)
        instance.save()
        return instance


class QuestionDetailPageSerializer(QuestionListPageSerializer):
    choices = ChoiceSerializer(many=True, read_only=True)

Vote for a particular choice

The api should be available at PATCH /api/polls/questions//vote/.

Let’s add a serializer which will do the validation during voting.

class VoteSerializer(serializers.Serializer):
    choice_id = serializers.IntegerField()

Let’s add a url pattern for voting on a particular question.

path('questions/<int:question_id>/vote/', apiviews.vote_view, name='vote_view'),

Let’s add the apiview vote_view.

@api_view(['PATCH'])
def vote_view(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    serializer = VoteSerializer(data=request.data)
    if serializer.is_valid():
        choice = get_object_or_404(Choice, pk=serializer.validated_data['choice_id'], question=question)
        choice.votes += 1
        choice.save()
        return Response("Voted")
    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Let’s make a valid request.

Let’s make an invalid request where choice_id is missing and see the serializer do its job of generating a descriptive error message.

Let’s try to vote on a choice_id which doesn’t belong to the question and we will get a 404 Not Found.

Result for a question

This api should be available at GET /api/polls/questions//result/

We want to have an api which tells the question detail along with number of votes cast on each choice for the question.

Let’s add the following serializers:

class ChoiceSerializerWithVotes(ChoiceSerializer):
    votes = serializers.IntegerField(read_only=True)

class QuestionResultPageSerializer(QuestionListPageSerializer):
    choices = ChoiceSerializerWithVotes(many=True, read_only=True)

Let’s add the apiview.

@api_view(['GET'])
def question_result_view(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    serializer = QuestionResultPageSerializer(question)
    return Response(serializer.data)

Let’s add the urlpattern

path('questions/<int:question_id>/result/', apiviews.question_result_view, name='question_result_view')

Let’s make a call to get result for Question id 1.

This concludes all the apis we intended to write.

Final code

The final urls.py, apiviews.py and serializers.py look like the following:

# polls/urls.py

from django.urls import path
from . import apiviews

app_name = 'polls'
urlpatterns = [
    path('questions/', apiviews.questions_view, name='questions_view'),
    path('questions/<int:question_id>/', apiviews.question_detail_view, name='question_detail_view'),
    path('questions/<int:question_id>/choices/', apiviews.choices_view, name='choices_view'),
    path('questions/<int:question_id>/vote/', apiviews.vote_view, name='vote_view'),
    path('questions/<int:question_id>/result/', apiviews.question_result_view, name='question_result_view'),
]

serializers.py look like:

from rest_framework import serializers

from .models import Question, Choice

class ChoiceSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    choice_text = serializers.CharField(max_length=200)

    def create(self, validated_data):
        return Choice.objects.create(**validated_data)


class ChoiceSerializerWithVotes(ChoiceSerializer):
    votes = serializers.IntegerField(read_only=True)


class QuestionListPageSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    question_text = serializers.CharField(max_length=200)
    pub_date = serializers.DateTimeField()
    was_published_recently = serializers.BooleanField(read_only=True) # Serializer is smart enough to understand that was_published_recently is a method on Question

    def create(self, validated_data):
        return Question.objects.create(**validated_data)

    def update(self, instance, validated_data):
        for key, value in validated_data.items():
            setattr(instance, key, value)
        instance.save()
        return instance


class QuestionDetailPageSerializer(QuestionListPageSerializer):
    choices = ChoiceSerializer(many=True, read_only=True)


class QuestionResultPageSerializer(QuestionListPageSerializer):
    choices = ChoiceSerializerWithVotes(many=True, read_only=True)


class VoteSerializer(serializers.Serializer):
    choice_id = serializers.IntegerField()

apiviews.py look like:

from django.shortcuts import get_object_or_404

from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status

from .models import Question, Choice
from .serializers import QuestionListPageSerializer, QuestionDetailPageSerializer, ChoiceSerializer, VoteSerializer, QuestionResultPageSerializer


@api_view(['GET', 'POST'])
def questions_view(request):
    if request.method == 'GET':
        questions = Question.objects.all()
        serializer = QuestionListPageSerializer(questions, many=True)
        return Response(serializer.data)
    elif request.method == 'POST':
        serializer = QuestionListPageSerializer(data=request.data)
        if serializer.is_valid():
            question = serializer.save()
            return Response(QuestionListPageSerializer(question).data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


@api_view(['GET', 'PATCH', 'DELETE'])
def question_detail_view(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    if request.method == 'GET':
        serializer = QuestionDetailPageSerializer(question)
        return Response(serializer.data)
    elif request.method == 'PATCH':
        serializer = QuestionDetailPageSerializer(question, data=request.data, partial=True)
        if serializer.is_valid():
            question = serializer.save()
            return Response(QuestionDetailPageSerializer(question).data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
    elif request.method == 'DELETE':
        question.delete()
        return Response("Question deleted", status=status.HTTP_204_NO_CONTENT)


@api_view(['POST'])
def choices_view(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    serializer = ChoiceSerializer(data=request.data)
    if serializer.is_valid():
        choice = serializer.save(question=question)
        return Response(ChoiceSerializer(choice).data, status=status.HTTP_201_CREATED)
    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


@api_view(['PATCH'])
def vote_view(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    serializer = VoteSerializer(data=request.data)
    if serializer.is_valid():
        choice = get_object_or_404(Choice, pk=serializer.validated_data['choice_id'], question=question)
        choice.votes += 1
        choice.save()
        return Response("Voted")
    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


@api_view(['GET'])
def question_result_view(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    serializer = QuestionResultPageSerializer(question)
    return Response(serializer.data)

You can see the full code on GitHub

See the next post of this series here.

We wrote another post where we use ModelSerializer instead of Serializer to write the above apis.

Thank you for reading the Agiliq blog. This article was written by Akshar on Apr 22, 2019 in APIdjangodrf .

You can subscribe ⚛ to our blog.

We love building amazing apps for web and mobile for our clients. If you are looking for development help, contact us today ✉.

Would you like to download 10+ free Django and Python books? Get them here