Extending Django Wizards
-
Comments:
- here.
I quite like the wizard pattern. Most people are somewhat familiar with the concept of having to complete a logically grouped set of form elements before moving onto the next one, with a final confirmation step, after which the operation is committed.
Howver, there are some perceieved problems within the Django formtools implementation, at least from the perspective of our use case.
The big one my team have identified: when you reload the page with a GET request, it clears out the data from the current run of the wizard. This is problematic when something happens to your internet, and you have to reload a page manually, for instance. Or if you just happen to reload it through another means.
Related to this, if you don’t have all of the information you require, there’s no way to “stash” your current iteration, and return to it later. This later could be after a logout and login (or even on a different computer).
Additionally, there’s no way to have “extra” steps inserted based on the selections made in a previous step. You could take the other approach, and define all of the steps initially (and then skip those that aren’t required), but in our case, we need a repeatable step (I call them a sub-step), where we perform an operation on a bunch of items that were selected in a previous step.
Let’s look at each of these in turn.
The default implementation automatically clears the storage whenever a GET request is handled:
class WizardView(TemplateView):
def get(self, request, *args, **kwargs):
self.storage.reset()
self.storage.current_step = self.steps.first
return self.render(self.get_form())
We can replace this with an implementation that will only reset when it detects a special GET parameter:
def get(self, request, *args, **kwargs):
if not request.GET.get('reset'):
self.storage.reset()
self.storage.current_step = self.steps.first
step = self.storage.current_step or self.steps.first
return self.render(self.get_form(
step,
data=self.storage.get_step_data(step),
files=self.storage.get_step_files(step),
))
Not that we need to build up the form data to provide to the view - otherwise when you load up the wizard, it will render the first page as empty, but if you then use the navigation to select the first page it will correctly render it with data.
You’ll need to have an explicit link or button in your wizard template(s) to enable the user to restart the wizard if they need to.
Next up is persistent storage. For this we will need somewhere to store the data. The formtools implementation makes this easy to swap out - you can define the storage backend you want to use.
class MyWizard(WizardView):
form_list = [...]
storage_name = 'storage.wizard.DatabaseStorage'
That’s all you need to do to make your wizard use it - but now we need to build the storage class.
Let’s begin with a model to store the data in.
class WizardStorage(models.Model):
user = models.ForeignKey('auth.User', related_name='wizard_storage', on_delete=models.CASCADE)
prefix = models.TextField()
data = models.JSONField()
We could use extra fields for the various parts, but that just complicates things.
Now let’s see a storage implementation:
class DatabaseStorage(BaseStorage):
def __init__(self, prefix, request=None, file_storage=None):
super().__init__(prefix, request, file_storage)
self.init_data()
def init_data(self):
self.instance, _create = WizardStorage.objects.get_or_create(
user=self.request.user,
prefix=self.prefix,
defaults={
'data': {
self.step_key: None,
self.step_data_key: {},
self.step_files_key: {},
self.extra_data_key: {},
}
}
)
@property
def data(self):
return self.instance.data
def update_response(self, response):
if hasattr(self, 'instance'):
self.instance.save()
def reset(self):
if hasattr(self, 'instance'):
self.instance.delete()
del self.instance
You can make it a bit more configurable so that the model could be swapped quite easily.
From here, we can use this in a wizard, and it will persist the step data to the database. I’ve still got a bit to do to ensure it can handle files, but this has not been a requirement of mine as yet.
There could be a bit of fun around having multiple stashes for a given wizard, and allowing the user to select which one they want to work on. As it stands, it just uses the user id and wizard prefix to determine where the data is stored.
The third improvement, allowing sub-steps, is a bit more complicated. To do that, you need to replace a bit more of the internal formtools code, rather than just subclassing/extending it. This involves a bunch of patching of the StepsHelper
class from formtools - although you could replace this class by overriding WizardView.dispatch
.
That’s beyond the scope of this post.