Tech Blog

Multipage Forms in Django

Multipage Forms in Django

Recently updated on

Introduction

Most online forms fit on a single page.  Think of a "join our forum" or "contact us" form into which the user enters a name, email address, and maybe a few other pieces of information.  If you're building this kind of functionality into a Django site, you can take advantage of Django's built-in form classes.  These are especially handy when dealing with model forms, where the form fields correspond to the fields on a model that will be recorded in your database.

But what if you need a form that spans more than one page?  Like a multipage job application where your personal details are on page 1, your relevant experience is on page 2, and so on?  There are third-party libraries to help with this, but if you do it yourself you can sidestep a dependency and maybe become more familiar with Django's form handling.

So let's do that.  In the post below we'll go step-by-step through the creation of a multipage job application form.  We'll start with the simplest functionality and then make it (slightly) more sophisticated. The most important modules ("models.py", "forms.py", and "views.py") will be reproduced here, but a working, standalone project is available from our GitHub account.

Feel free to clone the repo, get the demo up and running, and use or modify it to your own purposes.  Or just follow along here in the post.

Disclaimer: The approach below is one that I personally have used to create multipage forms on a few different websites.  I'm sure that there are other approaches too, but this is my personal take on the problem.

Now let's jump in!

Requirements

  • Python 3
  • Django 2.2
  • Some basic knowledge of how Django sites are put together

The Model

We'll be working with a model form, so the first thing we need is a model that will represent a submitted job application.  One thing to keep in mind: spreading a form over multiple pages means that model instances must be savable to the database before they are complete. After all, the fields on page 2 will not have values yet when page 1 is submitted.  Therefore some of the fields on your model must be defined with null=True and/or blank=True, even if you would not normally want to allow this.  Don't worry - we'll still be able to require that the user submit a non-blank value, but we'll be doing it at the form level, not at the model (database) level.

So here's our job application model.  Apparently this company isn't asking for much information -- just a name, job experience, and a promise of accuracy -- but for the purposes of this demonstration, it will do:

models.py

import hashlib, random, sys
from django.db import models
from . import constants

def create_session_hash():
  hash = hashlib.sha1()
  hash.update(str(random.randint(0,sys.maxsize)).encode('utf-8'))
  return hash.hexdigest()

class JobApplication(models.Model):
  # operational
  session_hash = models.CharField(max_length=40, unique=True)
  stage = models.CharField(max_length=10, default=constants.STAGE_1)
  # stage 1 fields
  first_name = models.CharField(max_length=20, blank=True)
  last_name = models.CharField(max_length=20, blank=True)
  # stage 2 fields

  prior_experience = models.TextField(blank=True)
  # stage 3 fields
  all_is_accurate = models.BooleanField(default=False)

  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    if not self.session_hash:
      while True:
        session_hash = create_session_hash()
        if JobApplication.objects.filter(session_hash=session_hash).count() == 0:
          self.session_hash = session_hash
          break

  @staticmethod
  def get_fields_by_stage(stage):
    fields = ['stage']  # Must always be present
    if stage == constants.STAGE_1:
      fields.extend(['first_name', 'last_name'])
    elif stage == constants.STAGE_2:
      fields.extend(['prior_experience'])
    elif stage == constants.STAGE_3:
      fields.extend(['all_is_accurate'])
    return fields

(Note that this module refers to a module called "constants", which defines values for "stage" and is used both here and in "views.py". This module is not reproduced in this blog post, but if you download the complete project from Github, you will find it there.)

One field essential to this multipage form is stage, which lets us determine which subset of fields to render on page 1 of the form, which on page 2, and so on.  Then the data fields (first_name, last_name, prior_experience, and all_is_accurate) will handle the values submitted by the user.

But let's talk about the session_hash field.  The key to making a multipage form work is that when the data from page 2 (for example) is submitted, you save it to the same model that you used for page 1. Since html is inherently stateless, we store a hash code in the session to tie the separate GET and POST requests together.  Each model instance gets its own unique SHA-1 hash, which will be saved to the model on the first valid POST request.  Later requests from the user's browser will include this hash, allowing us to retrieve the correct model instance.

The create_session_hash() and __init__() methods on the model support this.  Note the use of the while loop to guard against the vanishingly tiny possibility that we would randomly generate a hash that already exists on a model.  Since there are 2^160 different 40-character hexadecimal hash codes, we won't get stuck in that loop for long (and almost certainly not at all).

Finally, we need something to separate the model fields into groups that will be rendered on page 1, page 2, and page 3 of the form.  The get_fields_by_stage() method does this for us.  The method does not require a JobApplication instance to work, so I've made it a static method.

The Form

The fields on a typical Django form are hard coded.  One might look like:

class MyForm(ModelForm):

  foo = forms.IntegerField()

  class Meta:
    model = MyModel
    fields = "__all__"

But a multipage form is a dynamic form.  The fields need to be determined at runtime, depending on the state a particular instance is in.  Here's the very minimal "forms.py" for our project:

forms.py

from django.forms.models import ModelForm

class BaseApplicationForm(ModelForm):
  pass

Note that our BaseApplicationForm class doesn't have hard-coded fields like the typical example.  In fact it doesn't have anything except what it inherits from "ModelForm".  Later on we'll add more, but this is all we need to start.

The View

Here's the last big piece of this project: "views.py":

views.py

from django.forms import modelform_factory
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import FormView
from . import constants
from .forms import BaseApplicationForm
from .models import JobApplication

def get_job_application_from_hash(session_hash):
  # Find and return a not-yet-completed JobApplication with a matching
  # session_hash, or None if no such object exists.
  return JobApplication.objects.filter(
    session_hash=session_hash,
  ).exclude(
    stage=constants.COMPLETE
  ).first()

class JobApplicationView(FormView):
  template_name = 'job_application/job_application.html'
  job_application = None
  form_class = None

  def dispatch(self, request, *args, **kwargs):
    session_hash = request.session.get("session_hash", None)
    # Get the job application for this session. It could be None.
    self.job_application = get_job_application_from_hash(session_hash)
    # Attach the request to "self" so "form_valid()" can access it below.
    self.request = request
    return super().dispatch(request, *args, **kwargs)

  def form_valid(self, form):
    # This data is valid, so set this form's session hash in the session.
    self.request.session["session_hash"] = form.instance.session_hash
    current_stage = form.cleaned_data.get("stage")
    # Get the next stage after this one.
    new_stage = constants.STAGE_ORDER[constants.STAGE_ORDER.index(current_stage)+1]
    form.instance.stage = new_stage
    form.save()  # This will save the underlying instance.
    if new_stage == constants.COMPLETE:
      return redirect(reverse("job_application:thank_you"))
    # else
    return redirect(reverse("job_application:job_application"))

  def get_form_class(self):
    # If we found a job application that matches the session hash, look at
    # its "stage" attribute to decide which stage of the application we're
    # on. Otherwise assume we're on stage 1.
    stage = self.job_application.stage if self.job_application else constants.STAGE_1
    # Get the form fields appropriate to that stage.
    fields = JobApplication.get_fields_by_stage(stage)
    # Use those fields to dynamically create a form with "modelform_factory"
    return modelform_factory(JobApplication, BaseApplicationForm, fields)

  def get_form_kwargs(self):
    # Make sure Django uses the same JobApplication instance we've already
    # been working on.
    kwargs = super().get_form_kwargs()
    kwargs["instance"] = self.job_application
    return kwargs

(Note that this module refers to the template "job_application.html" and a second view called "thank_you".  Its use of the reverse() method also implies the existence of a "urls.py".  These elements are not reproduced in this blog post, but if you download the complete project from Github, you will find them there.)

Details on the various methods of this view are in the comments above, but to summarize briefly:

  • The dispatch() method tries to find an existing JobApplication instance whose session_hash field matches the user's current session.
  • Execution of the form_valid() method means that all fields received valid values and we can move on to the next stage, which might be another part of the form or the "thank-you" page.
  • The get_form_class() method will return a form class with fields appropriate to the stage of the current application.
  • The get_form_kwargs() method is critical because if we don't specify an "instance", Django's default behavior is to instantiate a new Form object for each request.

Required Fields and Validation

At this point our multipage form is working, but there are still some odd things about it.  For one thing, we are not validating the user's input at all.  We also have no way to make a field required.  Oddest of all, the "stage" field can be changed by the user when they submit the form!

So let's address these issues now:

Validating Input

This part is no different from any Django form.  You can add clean_<fieldname>() methods for the fields you want to validate or a general clean() method as usual.  For example, we could change our "BaseApplicationForm" class, which currently only has a pass statement, so that it looks like this:

forms.py

class BaseApplicationForm(ModelForm):

  def clean_first_name(self):
    first_name = self.cleaned_data.get("first_name", "")
    if "e" in first_name:
      raise ValidationError("People with 'e' in their first name need not apply.")
    # else
    return first_name

Required Fields and Hidden Inputs

We need the "stage" field to be a hidden field, but since "stage" is a CharField on the JobApplication model, Django defaults to using a TextInput for the corresponding field on the Form.  Let's also suppose that we would like to make the "first_name", "last_name", and "all_is_accurate" fields be required.  We need a way to tell our dynamic form that certain fields should be required and that other fields should be rendered as hidden inputs.

First let's add a couple of new lines to our model.  Maybe put them right above the defintion of the __init__() method:

models.py

  ...

  hidden_fields = ['stage']
  required_fields = ['first_name', 'last_name', 'all_is_accurate']

  def __init__(self, *args, **kwargs):
    ...

Notice those new variables are defined at the class level.  They will be the same for every instance of our model, but that's all right because we're only ever going to read from them.

And again we'll modify the BaseApplicationForm class in "forms.py". Add an __init__() method so that it looks like this:

forms.py

class BaseApplicationForm(ModelForm):

  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    required_fields = self.instance.required_fields
    hidden_fields = self.instance.hidden_fields
    for field in self.fields:
      if field in required_fields:
        self.fields.get(field).required = True
      if field in hidden_fields:
        self.fields.get(field).widget = HiddenInput()

  def clean_first_name(self):
    first_name = self.cleaned_data.get("first_name", "")
    if "e" in first_name:
      raise ValidationError("People with 'e' in their first name need not apply.")
    # else
    return first_name

Now the "stage" field should be hidden and the "first_name", "last_name", and "all_is_accurate" fields are all required at the form level.  We now have a working multipage form.

Conclusion

This brings us to the end of our discussion of how to create a multipage form in Django.  We have created a JobApplication model and an accompanying form with three pages complete with input validation, required fields, and hidden fields.

Again, for a working example, you can clone the codebase from GitHub.  The working example contains all the missing modules and templates referred to above, and also lets you see submitted JobApplication objects using Django's built-in admin.

Of course, there are many ways in which our little job application app could be improved for use in a production site. For example, we could add "created" and "modified" fields to the JobApplication model, which will allow us to forcibly expire JobApplication instances that were left in an incomplete state for too long.

models.py

  ...
  created = models.DateTimeField(auto_now_add=True)
  modified = models.DateTimeField(auto_now=True)
  ...

(The working example in GitHub implements this feature also).

We could also add an in-form "back" button to allow the user to revisit pages they have already submitted, and a "forward" button to allow them to quickly return to where they were before they used the "back" button.  Maybe some fields should only be required if other fields are given values.  And maybe the user's progression from page 1 of the form to the end should not be the same for all users.  We could implement pages of the form that are only rendered if the user has entered certain values earlier on.

Perhaps these topics can be the subject of a follow-up post.  Until then, happy coding!