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 whosesession_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!