"""
USAGE:
views:
class MyWizard( Wizard ):
def done( self, request, form_list ):
return render_to_response('done.html', {'form_data' : [ form.clean_data for form in form_list ] })
urls:
( r'^$', MyWizard( [MyForm, MyForm, MyForm] ) ),
form template:
done.html:
{% for data in form_data %}
{% for item in data.items %}
{{ item.0 }} : {{ item.1 }}
{% endfor %}
{% endfor %}
"""
from django.conf import settings
from django.http import Http404
from django.shortcuts import render_to_response
from django.template.context import RequestContext
from django import newforms as forms
import cPickle as pickle
import md5
class Wizard( object ):
PREFIX="%d"
STEP_FIELD="wizard_step"
# METHODS SUBCLASSES SHOULDN'T OVERRIDE ###################################
def __init__( self, form_list, initial=None ):
" Pass list of Form classes (not instances !) "
self.form_list = form_list[:]
self.initial = initial or {}
def __repr__( self ):
return "step: %d\nform_list: %s\ninitial_data: %s" % ( self.step, self.form_list, self.initial )
def get_form( self, step, data=None ):
" Shortcut to return form instance. "
return self.form_list[step]( data, prefix=self.PREFIX % step, initial=self.initial.get( step, None ) )
def __call__( self, request, *args, **kwargs ):
"""
Main function that does all the hard work:
- initializes the wizard object (via parse_params())
- veryfies (using security_hash()) that noone has tempered with the data since we last saw them
calls failed_hash() if it is so
calls process_step() for every previously submitted form
- validates current form and
returns it again if errors were found
returns done() if it was the last form
returns next form otherwise
"""
# add extra_context, we don't care if somebody overrides it, as long as it remains a dict
self.extra_context = kwargs.get( 'extra_context', {} )
self.parse_params( request, *args, **kwargs )
# we only accept POST method for form delivery no POST, no data
if not request.POST:
self.step = 0
return self.render( self.get_form( 0 ), request )
# verify old steps' hashes
for i in range( self.step ):
form = self.get_form( i, request.POST )
# somebody is trying to corrupt our data
if request.POST.get( "hash_%d" % i, '' ) != self.security_hash( request, form ):
# revert to the corrupted step
return self.failed_hash( request, i )
self.process_step( request, form, i )
# process current step
form = self.get_form( self.step, request.POST )
if form.is_valid():
self.process_step( request, form, self.step )
self.step += 1
# this was the last step
if self.step == len( self.form_list ):
return self.done( request, [ self.get_form( i, request.POST ) for i in range( len( self.form_list ) ) ] )
form = self.get_form( self.step )
return self.render( form, request )
def render( self, form, request ):
"""
Prepare the form and call the render_template() method to do tha actual rendering.
"""
if self.step >= len( self.form_list ):
raise Http404
old_data = request.POST
prev_fields = ''
if old_data:
# old data
prev_fields = '\n'.join(
bf.as_hidden() for i in range(self.step) for bf in self.get_form( i, old_data )
)
# hashes for old forms
hidden = forms.widgets.HiddenInput()
prev_fields += '\n'.join(
hidden.render( "hash_%d" % i, old_data.get( "hash_%d" % i, self.security_hash( request, self.get_form( i, old_data ) ) ) )
for i in range( self.step)
)
return self.render_template( request, form, prev_fields )
# METHODS SUBCLASSES MIGHT OVERRIDE IF APPROPRIATE ########################
def failed_hash( self, request, i ):
"""
One of the hashes verifying old data doesn't match.
"""
self.step = i
return self.render( self.get_form(self.step), request )
def security_hash(self, request, form):
"""
Calculates the security hash for the given Form instance.
This creates a list of the form field names/values in a deterministic
order, pickles the result with the SECRET_KEY setting and takes an md5
hash of that.
Subclasses may want to take into account request-specific information
such as the IP address.
"""
data = [(bf.name, bf.data) for bf in form] + [settings.SECRET_KEY]
# Use HIGHEST_PROTOCOL because it's the most efficient. It requires
# Python 2.3, but Django requires 2.3 anyway, so that's OK.
pickled = pickle.dumps(data, protocol=pickle.HIGHEST_PROTOCOL)
return md5.new(pickled).hexdigest()
def parse_params( self, request, *args, **kwargs ):
"""
Set self.step, process any additional info from parameters and/or form data
"""
if request.POST:
self.step = int( request.POST.get( self.STEP_FIELD, 0 ) )
else:
self.step = 0
def get_template( self ):
"""
Return name of the template to be rendered, use self.step to get the step number.
"""
return "wizard.html"
def render_template( self, request, form, previous_fields ):
"""
Render template for current step, override this method if you wish to add custom context, return a different mimetype etc.
If you only wish to override the template name, use get_template
Some additional items are added to the context:
'step_field' is the name of the hidden field containing step
'step' holds the current step
'form' containing the current form to be processed (either empty or with errors)
'previous_data' contains all the addtitional information, including
hashes for finished forms and old data in form of hidden fields
any additional data stored in self.extra_context
"""
return render_to_response( self.get_template(), dict(
step_field=self.STEP_FIELD,
step=self.step,
form=form,
previous_fields=previous_fields,
** self.extra_context
), context_instance=RequestContext( request ) )
def process_step( self, request, form, step ):
"""
This should not modify any data, it is only a hook to modify wizard's internal state
(such as dynamically generating form_list based on previously submited forms).
It can also be used to add items to self.extra_context base on the contents of previously submitted forms.
Note that this method is called every time a page is rendered for ALL submitted steps.
Only valid data enter here.
"""
pass
# METHODS SUBCLASSES MUST OVERRIDE ########################################
def done( self, request, form_list ):
"""
this method must be overriden, it is responsible for the end processing - it will be called with instances of all form_list with their data
"""
raise NotImplementedError('You must define a done() method on your %s subclass.' % self.__class__.__name__)