Defying Classification

by Malcolm Tredinnick

Sun 6 Jan 2008

Django Tip: Complex Forms

Posted at 13:39 +1100

On the Django mailing list, we periodically see requests for help by somebody trying to create a semi-complex form of some description. Often, getting a second perspective is a good choice because the original poster was overlooking a possibly easier alternate approach.

Today, I want to fill in one of the gaps. Something that is really easy once you know the machinery and possibly not so obvious until you sweat it out once: putting multiple repetitive sections into a single form.

The Problem

Let's consider a fairly typical example. You are presenting a selection of multiple choice questions. A Quiz model contains a number of Questions, with each question having a number of Answers associated with it, one of which is the correct answer. I knocked together the following models:

class Answer(models.Model):
    statement = models.CharField(max_length=200)

class Question(models.Model):
    problem = models.CharField(max_length=200)
    answers = models.ManyToManyField(Answer)
    correct_answer = models.ForeignKey(Answer, related_name="correct")

class Quiz(models.Model):
    name = models.CharField(max_length=50)
    questions = models.ManyToManyField(Question)

All of this code is available for download. The admin user is admin and password is abc123. As you'll see in the download, I've simplified things slightly for conciseness here; leaving out the Admin classes and the like..

Given a quiz id, I want to display the associated questions and their answers in a single HTML form.

Designing The Forms

I'll be using Django's newforms package here, since oldforms is... well... old (and deprecated for good reasons. Don't use it in new code).

This is the step where many people get tripped up because it looks like displaying many questions, each with many answers doesn't fit naturally into the layout of the form classes. The trick is to remember that a newforms.Form class corresponds to an HTML form fragment — not the entire HTML form. This is why you need to supply the outer <form>...</form> tags yourself, for example. You can happily use multiple form classes (at the Python level) to produce one form at the HTML level.

Let's build a form class that represents a single question. Then our page will be a collection of those form classes. Well, a question display contains some form data — choices for the answer possibilities — as well as some static data — the question statement itself. We have to remember the question statement, since it's not really "form" information, as it's only displayed, not edited by the end-user.

The form class might look something like this::

from django import newforms as forms

class QuestionForm(forms.Form):
    answers = forms.ChoiceField(widget=forms.RadioSelect())

    def __init__(self, question, *args, **kwargs):
        super(QuestionForm, self).__init__(*args, **kwargs)
        self.problem = question.problem
        answers = question.answers.order_by('statement')
        self.fields['answers'].choices = [(i, a.statement) for i, a in
                enumerate(answers)]

        # We need to work out the position of the correct answer in the 
        # sorted list of all possible answers.
        for pos, answer in enumerate(answers):
            if answer.id == question.correct_answer_id:
                self.correct = pos
            break

    def is_correct(self):
        """
        Determines if the given answer is correct (for a bound form).
        """
        if not self.is_valid():
            return False
        return self.cleaned_data['answers'] == str(self.correct)

A couple of things are worth noting here. Firstly, I made sure that the answers were sorted in some repeatable fashion so that each time I create the form (initial display and later when processing the user's input), the options are in the same order. I also had to make sure the correct answer attribute corresponded to the position of the correct answer in the sorted list. There were a few ways to do this; the way I chose above avoids any extra database queries, relying on the fact that the foreign key attribute correct_answer on the Question model creates a hidden attribute correct_answer_id, which is the primary key value of the related entry. So I can get away with comparing primary key values to check for the correct answer.

Finally, I added a convenience method, is_correct(), to the form class to make it easier to check for correct answers later on. The correct answer to a question has nothing to do with the form's validity (you can successfully submit a form containing an incorrect answer), so this isn't part of cleaning the form's data, but since the user's submission and the correct answer are both available as part of this class, it's worthwhile to check it here. Something to note is that choice values are always strings in Django form classes (since the submission from the HTML form is always a string), so I have to remember to convert my numerical index value (self.correct) to a string before comparing with the form submission. Forgetting something like that can add a few minutes to the debugging process, although after you do it once or twice, you tend to remember forever.

So now we can create a form for a single question and display it in a template. The remaining problem is how to handle multiple instances of this class on the same page, without the HTML input elements clashing (by using the same names). The secret here is the prefix attribute accepted by the Form class. The prefix is attached to the front of any elements produced by that form instance. So we can display multiple questions by giving each one a different prefix.

Since I'm going to need to create these multiple form classes lots of times, I decided to make a convenience method for it. Given a quiz identifier, I want to create a form instance for each question in that quiz and, if the form has any data associated with it, plug in the submitted data. This leads to the following:

from django.http import Http404

def create_quiz_forms(quiz_id, data=None):
    questions = Question.objects.filter(quiz__pk=quiz_id).order_by('id')
    form_list = []
    for pos, question in enumerate(questions):
        form_list.append(QuestionForm(question, data, prefix=pos))
    if not form_list:
        # No questions found, so the quiz_id must have been bad.
        raise Http404('Invalid quiz id.')
    return form_list

This function creates one form for each question and returns the list of form instances, as required. The data parameter to the function is used when a form submission is made (it will be the request.POST dictionary) Initialising a form with data=None creates an unbound form, which is why I've chosen that default.

The Views and Templates

We have a function to create forms for a given quiz. Now let's pull it together in a view.

from django.shortcuts import render_to_response
from django.http import HttpResponseRedirect
from django.core.urlresolvers import reverse

def quiz_form(request, quiz_id):
    if request.method == 'POST':
        form_list = create_quiz_forms(quiz_id, request.POST)
        answers = []
        for form in form_list:
            if not form.is_valid():
                break
            answers.append(str(int(form.is_correct())))
        else:
            # All forms are valid. Go to the results page.
            return HttpResponseRedirect('%s?a=%s'
                    % (reverse('result-display', args=[quiz_id]),
                        ''.join(answers)))
    else:
        form_list = create_quiz_forms(quiz_id)

    return render_to_response('quiz.html', {'forms': form_list})

This should all be fairly straightforward if you've been following along so far. If the user submitted data (a POST), we create the forms using the submitted data, so that we can see if the form is invalid (mostly, this catches missing fields in our case). Otherwise, (a non-POST), we send back an empty form for the given quiz id.

If data was submitted, we check the forms for validity (all of them; remembering that we have more than just a single form instance here) and, if they all pass, build up the list of correct/incorrect answers. I've chosen a fairly simple scheme here for representing the answers. The questions are in a pre-determined order, so I pass a string of '1' and '0' characters to the results page: '1' meaning the user got the question correct.

Attentive readers will notice that I'm using urlresolvers.reverse() in this view to avoid hard-coding the URL. For a simple example, this is slight over-kill, but in a more complicated setup I've found it worthwhile to take this extra effort as it sometimes takes a couple of attempts to get the URLs spelt exactly as I wish, so only have to change one place later on is an advantage.

Display the result of this view in a template is done with something like this:

    <form action="." method="post">
        <ol>
            {% for form in forms %}
            <li> <p>{{ form.problem }}</p>
                {{ form.as_p }}
            </li>
            {% endfor %}
        </ol>
        <input type="submit">
    </form>

Of course, in practice, you'll probably do a lot more here with CSS classes and perhaps a custom display method instead of as_p(), but that's fairly standard stuff and would take us too far afield for our current purposes.

I haven't given the results display view here, but you can find it in the source code, along with the URL definitions. You can fire up the development server (./manage.py runserver) and point your browser to http://localhost:8080/quiz/1/ to see the example in action.

Topics: software/django/tips