| 1 |
""" |
|---|
| 2 |
FormWizard class -- implements a multi-page form, validating between each |
|---|
| 3 |
step and storing the form's state as HTML hidden fields so that no state is |
|---|
| 4 |
stored on the server side. |
|---|
| 5 |
""" |
|---|
| 6 |
|
|---|
| 7 |
import cPickle as pickle |
|---|
| 8 |
|
|---|
| 9 |
from django import forms |
|---|
| 10 |
from django.conf import settings |
|---|
| 11 |
from django.http import Http404 |
|---|
| 12 |
from django.shortcuts import render_to_response |
|---|
| 13 |
from django.template.context import RequestContext |
|---|
| 14 |
from django.utils.hashcompat import md5_constructor |
|---|
| 15 |
|
|---|
| 16 |
class FormWizard(object): |
|---|
| 17 |
# Dictionary of extra template context variables. |
|---|
| 18 |
extra_context = {} |
|---|
| 19 |
|
|---|
| 20 |
# The HTML (and POST data) field name for the "step" variable. |
|---|
| 21 |
step_field_name="wizard_step" |
|---|
| 22 |
|
|---|
| 23 |
# METHODS SUBCLASSES SHOULDN'T OVERRIDE ################################### |
|---|
| 24 |
|
|---|
| 25 |
def __init__(self, form_list, initial=None): |
|---|
| 26 |
"form_list should be a list of Form classes (not instances)." |
|---|
| 27 |
self.form_list = form_list[:] |
|---|
| 28 |
self.initial = initial or {} |
|---|
| 29 |
self.step = 0 # A zero-based counter keeping track of which step we're in. |
|---|
| 30 |
|
|---|
| 31 |
def __repr__(self): |
|---|
| 32 |
return "step: %d\nform_list: %s\ninitial_data: %s" % (self.step, self.form_list, self.initial) |
|---|
| 33 |
|
|---|
| 34 |
def get_form(self, step, data=None): |
|---|
| 35 |
"Helper method that returns the Form instance for the given step." |
|---|
| 36 |
return self.form_list[step](data, prefix=self.prefix_for_step(step), initial=self.initial.get(step, None)) |
|---|
| 37 |
|
|---|
| 38 |
def num_steps(self): |
|---|
| 39 |
"Helper method that returns the number of steps." |
|---|
| 40 |
# You might think we should just set "self.form_list = len(form_list)" |
|---|
| 41 |
# in __init__(), but this calculation needs to be dynamic, because some |
|---|
| 42 |
# hook methods might alter self.form_list. |
|---|
| 43 |
return len(self.form_list) |
|---|
| 44 |
|
|---|
| 45 |
def __call__(self, request, *args, **kwargs): |
|---|
| 46 |
""" |
|---|
| 47 |
Main method that does all the hard work, conforming to the Django view |
|---|
| 48 |
interface. |
|---|
| 49 |
""" |
|---|
| 50 |
if 'extra_context' in kwargs: |
|---|
| 51 |
self.extra_context.update(kwargs['extra_context']) |
|---|
| 52 |
current_step = self.determine_step(request, *args, **kwargs) |
|---|
| 53 |
self.parse_params(request, *args, **kwargs) |
|---|
| 54 |
|
|---|
| 55 |
# Sanity check. |
|---|
| 56 |
if current_step >= self.num_steps(): |
|---|
| 57 |
raise Http404('Step %s does not exist' % current_step) |
|---|
| 58 |
|
|---|
| 59 |
# For each previous step, verify the hash and process. |
|---|
| 60 |
# TODO: Move "hash_%d" to a method to make it configurable. |
|---|
| 61 |
for i in range(current_step): |
|---|
| 62 |
form = self.get_form(i, request.POST) |
|---|
| 63 |
if request.POST.get("hash_%d" % i, '') != self.security_hash(request, form): |
|---|
| 64 |
return self.render_hash_failure(request, i) |
|---|
| 65 |
self.process_step(request, form, i) |
|---|
| 66 |
|
|---|
| 67 |
# Process the current step. If it's valid, go to the next step or call |
|---|
| 68 |
# done(), depending on whether any steps remain. |
|---|
| 69 |
if request.method == 'POST': |
|---|
| 70 |
form = self.get_form(current_step, request.POST) |
|---|
| 71 |
else: |
|---|
| 72 |
form = self.get_form(current_step) |
|---|
| 73 |
if form.is_valid(): |
|---|
| 74 |
self.process_step(request, form, current_step) |
|---|
| 75 |
next_step = current_step + 1 |
|---|
| 76 |
|
|---|
| 77 |
# If this was the last step, validate all of the forms one more |
|---|
| 78 |
# time, as a sanity check, and call done(). |
|---|
| 79 |
num = self.num_steps() |
|---|
| 80 |
if next_step == num: |
|---|
| 81 |
final_form_list = [self.get_form(i, request.POST) for i in range(num)] |
|---|
| 82 |
|
|---|
| 83 |
# Validate all the forms. If any of them fail validation, that |
|---|
| 84 |
# must mean the validator relied on some other input, such as |
|---|
| 85 |
# an external Web site. |
|---|
| 86 |
for i, f in enumerate(final_form_list): |
|---|
| 87 |
if not f.is_valid(): |
|---|
| 88 |
return self.render_revalidation_failure(request, i, f) |
|---|
| 89 |
return self.done(request, final_form_list) |
|---|
| 90 |
|
|---|
| 91 |
# Otherwise, move along to the next step. |
|---|
| 92 |
else: |
|---|
| 93 |
form = self.get_form(next_step) |
|---|
| 94 |
current_step = next_step |
|---|
| 95 |
|
|---|
| 96 |
return self.render(form, request, current_step) |
|---|
| 97 |
|
|---|
| 98 |
def render(self, form, request, step, context=None): |
|---|
| 99 |
"Renders the given Form object, returning an HttpResponse." |
|---|
| 100 |
old_data = request.POST |
|---|
| 101 |
prev_fields = [] |
|---|
| 102 |
if old_data: |
|---|
| 103 |
hidden = forms.HiddenInput() |
|---|
| 104 |
# Collect all data from previous steps and render it as HTML hidden fields. |
|---|
| 105 |
for i in range(step): |
|---|
| 106 |
old_form = self.get_form(i, old_data) |
|---|
| 107 |
hash_name = 'hash_%s' % i |
|---|
| 108 |
prev_fields.extend([bf.as_hidden() for bf in old_form]) |
|---|
| 109 |
prev_fields.append(hidden.render(hash_name, old_data.get(hash_name, self.security_hash(request, old_form)))) |
|---|
| 110 |
return self.render_template(request, form, ''.join(prev_fields), step, context) |
|---|
| 111 |
|
|---|
| 112 |
# METHODS SUBCLASSES MIGHT OVERRIDE IF APPROPRIATE ######################## |
|---|
| 113 |
|
|---|
| 114 |
def prefix_for_step(self, step): |
|---|
| 115 |
"Given the step, returns a Form prefix to use." |
|---|
| 116 |
return str(step) |
|---|
| 117 |
|
|---|
| 118 |
def render_hash_failure(self, request, step): |
|---|
| 119 |
""" |
|---|
| 120 |
Hook for rendering a template if a hash check failed. |
|---|
| 121 |
|
|---|
| 122 |
step is the step that failed. Any previous step is guaranteed to be |
|---|
| 123 |
valid. |
|---|
| 124 |
|
|---|
| 125 |
This default implementation simply renders the form for the given step, |
|---|
| 126 |
but subclasses may want to display an error message, etc. |
|---|
| 127 |
""" |
|---|
| 128 |
return self.render(self.get_form(step), request, step, context={'wizard_error': 'We apologize, but your form has expired. Please continue filling out the form from this page.'}) |
|---|
| 129 |
|
|---|
| 130 |
def render_revalidation_failure(self, request, step, form): |
|---|
| 131 |
""" |
|---|
| 132 |
Hook for rendering a template if final revalidation failed. |
|---|
| 133 |
|
|---|
| 134 |
It is highly unlikely that this point would ever be reached, but See |
|---|
| 135 |
the comment in __call__() for an explanation. |
|---|
| 136 |
""" |
|---|
| 137 |
return self.render(form, request, step) |
|---|
| 138 |
|
|---|
| 139 |
def security_hash(self, request, form): |
|---|
| 140 |
""" |
|---|
| 141 |
Calculates the security hash for the given HttpRequest and Form instances. |
|---|
| 142 |
|
|---|
| 143 |
This creates a list of the form field names/values in a deterministic |
|---|
| 144 |
order, pickles the result with the SECRET_KEY setting and takes an md5 |
|---|
| 145 |
hash of that. |
|---|
| 146 |
|
|---|
| 147 |
Subclasses may want to take into account request-specific information, |
|---|
| 148 |
such as the IP address. |
|---|
| 149 |
""" |
|---|
| 150 |
data = [(bf.name, bf.data or '') for bf in form] + [settings.SECRET_KEY] |
|---|
| 151 |
# Use HIGHEST_PROTOCOL because it's the most efficient. It requires |
|---|
| 152 |
# Python 2.3, but Django requires 2.3 anyway, so that's OK. |
|---|
| 153 |
pickled = pickle.dumps(data, pickle.HIGHEST_PROTOCOL) |
|---|
| 154 |
return md5_constructor(pickled).hexdigest() |
|---|
| 155 |
|
|---|
| 156 |
def determine_step(self, request, *args, **kwargs): |
|---|
| 157 |
""" |
|---|
| 158 |
Given the request object and whatever *args and **kwargs were passed to |
|---|
| 159 |
__call__(), returns the current step (which is zero-based). |
|---|
| 160 |
|
|---|
| 161 |
Note that the result should not be trusted. It may even be a completely |
|---|
| 162 |
invalid number. It's not the job of this method to validate it. |
|---|
| 163 |
""" |
|---|
| 164 |
if not request.POST: |
|---|
| 165 |
return 0 |
|---|
| 166 |
try: |
|---|
| 167 |
step = int(request.POST.get(self.step_field_name, 0)) |
|---|
| 168 |
except ValueError: |
|---|
| 169 |
return 0 |
|---|
| 170 |
return step |
|---|
| 171 |
|
|---|
| 172 |
def parse_params(self, request, *args, **kwargs): |
|---|
| 173 |
""" |
|---|
| 174 |
Hook for setting some state, given the request object and whatever |
|---|
| 175 |
*args and **kwargs were passed to __call__(), sets some state. |
|---|
| 176 |
|
|---|
| 177 |
This is called at the beginning of __call__(). |
|---|
| 178 |
""" |
|---|
| 179 |
pass |
|---|
| 180 |
|
|---|
| 181 |
def get_template(self, step): |
|---|
| 182 |
""" |
|---|
| 183 |
Hook for specifying the name of the template to use for a given step. |
|---|
| 184 |
|
|---|
| 185 |
Note that this can return a tuple of template names if you'd like to |
|---|
| 186 |
use the template system's select_template() hook. |
|---|
| 187 |
""" |
|---|
| 188 |
return 'forms/wizard.html' |
|---|
| 189 |
|
|---|
| 190 |
def render_template(self, request, form, previous_fields, step, context=None): |
|---|
| 191 |
""" |
|---|
| 192 |
Renders the template for the given step, returning an HttpResponse object. |
|---|
| 193 |
|
|---|
| 194 |
Override this method if you want to add a custom context, return a |
|---|
| 195 |
different MIME type, etc. If you only need to override the template |
|---|
| 196 |
name, use get_template() instead. |
|---|
| 197 |
|
|---|
| 198 |
The template will be rendered with the following context: |
|---|
| 199 |
step_field -- The name of the hidden field containing the step. |
|---|
| 200 |
step0 -- The current step (zero-based). |
|---|
| 201 |
step -- The current step (one-based). |
|---|
| 202 |
step_count -- The total number of steps. |
|---|
| 203 |
form -- The Form instance for the current step (either empty |
|---|
| 204 |
or with errors). |
|---|
| 205 |
previous_fields -- A string representing every previous data field, |
|---|
| 206 |
plus hashes for completed forms, all in the form of |
|---|
| 207 |
hidden fields. Note that you'll need to run this |
|---|
| 208 |
through the "safe" template filter, to prevent |
|---|
| 209 |
auto-escaping, because it's raw HTML. |
|---|
| 210 |
""" |
|---|
| 211 |
context = context or {} |
|---|
| 212 |
context.update(self.extra_context) |
|---|
| 213 |
return render_to_response(self.get_template(self.step), dict(context, |
|---|
| 214 |
step_field=self.step_field_name, |
|---|
| 215 |
step0=step, |
|---|
| 216 |
step=step + 1, |
|---|
| 217 |
step_count=self.num_steps(), |
|---|
| 218 |
form=form, |
|---|
| 219 |
previous_fields=previous_fields |
|---|
| 220 |
), context_instance=RequestContext(request)) |
|---|
| 221 |
|
|---|
| 222 |
def process_step(self, request, form, step): |
|---|
| 223 |
""" |
|---|
| 224 |
Hook for modifying the FormWizard's internal state, given a fully |
|---|
| 225 |
validated Form object. The Form is guaranteed to have clean, valid |
|---|
| 226 |
data. |
|---|
| 227 |
|
|---|
| 228 |
This method should *not* modify any of that data. Rather, it might want |
|---|
| 229 |
to set self.extra_context or dynamically alter self.form_list, based on |
|---|
| 230 |
previously submitted forms. |
|---|
| 231 |
|
|---|
| 232 |
Note that this method is called every time a page is rendered for *all* |
|---|
| 233 |
submitted steps. |
|---|
| 234 |
""" |
|---|
| 235 |
pass |
|---|
| 236 |
|
|---|
| 237 |
# METHODS SUBCLASSES MUST OVERRIDE ######################################## |
|---|
| 238 |
|
|---|
| 239 |
def done(self, request, form_list): |
|---|
| 240 |
""" |
|---|
| 241 |
Hook for doing something with the validated data. This is responsible |
|---|
| 242 |
for the final processing. |
|---|
| 243 |
|
|---|
| 244 |
form_list is a list of Form instances, each containing clean, valid |
|---|
| 245 |
data. |
|---|
| 246 |
""" |
|---|
| 247 |
raise NotImplementedError("Your %s class has not defined a done() method, which is required." % self.__class__.__name__) |
|---|