Skip to content

Instantly share code, notes, and snippets.

@glarrain
Created July 12, 2012 15:19
Show Gist options
  • Save glarrain/3098817 to your computer and use it in GitHub Desktop.
Save glarrain/3098817 to your computer and use it in GitHub Desktop.
Multi-page form manager, arranged as a (math) graph, with dynamic paths (next form depends on actual state and user input) and number of forms. Storage and validation are handled. Based in Django-1.4's `django.contrib.formtools.wizard.views.SessionWizard`
import copy
import logging
import re
from django.forms import ValidationError
from django.views.generic import TemplateView
from django.utils.datastructures import MultiValueDict
from django.contrib.formtools.wizard.forms import ManagementForm
logger_ = logging.getLogger()
class SessionStorage(object):
"""Custom SessionStorage class. Removed all the functionality that dealt
with files.
Based in Django-1.4's:
`django.contrib.formtools.wizard.storage.base.BaseStorage`
`django.contrib.formtools.wizard.storage.session.SessionStorage`
"""
step_key = 'step'
step_data_key = 'step_data'
extra_data_key = 'extra_data'
def __init__(self, prefix, request=None):
self.prefix = 'wizard_%s' % prefix
self.request = request
if self.prefix not in self.request.session:
self.init_data()
def init_data(self):
self.data = {
self.step_key: None,
self.step_data_key: {},
self.extra_data_key: {},
}
def _get_data(self):
self.request.session.modified = True
return self.request.session[self.prefix]
def _set_data(self, value):
self.request.session[self.prefix] = value
self.request.session.modified = True
data = property(_get_data, _set_data)
def reset(self):
self.init_data()
def _get_current_step(self):
return self.data[self.step_key]
def _set_current_step(self, step):
self.data[self.step_key] = step
current_step = property(_get_current_step, _set_current_step)
def _get_extra_data(self):
return self.data[self.extra_data_key] or {}
def _set_extra_data(self, extra_data):
self.data[self.extra_data_key] = extra_data
extra_data = property(_get_extra_data, _set_extra_data)
def get_step_data(self, step):
# When reading the serialized data, upconvert it to a MultiValueDict,
# some serializers (json) don't preserve the type of the object.
values = self.data[self.step_data_key].get(step, None)
if values is not None:
values = MultiValueDict(values)
return values
def set_step_data(self, step, cleaned_data):
# If the value is a MultiValueDict, convert it to a regular dict of the
# underlying contents. Some serializers call the public API on it (as
# opposed to the underlying dict methods), in which case the content
# can be truncated (__getitem__ returns only the first item).
if isinstance(cleaned_data, MultiValueDict):
cleaned_data = dict(cleaned_data.lists())
self.data[self.step_data_key][step] = cleaned_data
@property
def current_step_data(self):
return self.get_step_data(self.current_step)
class ChainedFormsView(TemplateView):
"""Multi-page form manager, arranged as a graph, with dynamic paths (next
form depends on actual state and user input) and number of forms. Storage
and validation are handled.
A few methods must be implemented by subclasses:
_get_form_class(self)
_get_next_form_class(self)
_get_form_id(self, form)
done(self, form, **kwargs)
is_last_step(self, form)
Based in Django-1.4's
`django.contrib.formtools.wizard.views.WizardView`
`django.contrib.formtools.wizard.views.SessionWizardView`
"""
template_name = 'wizard_form.html'
answers_key = 'answers'
answers_bk_key = 'answers_bk'
results_key = 'results'
step0 = u'0'
def __init__(self, **kwargs):
super(ChainedFormsView, self).__init__(**kwargs)
self.finish_now = False
@classmethod
def class_prefix(cls):
return normalize_name(cls.__name__)
#noinspection PyUnusedLocal
def get_prefix(self, *args, **kwargs):
return normalize_name(self.__class__.class_prefix())
def dispatch(self, request, *args, **kwargs):
"""This method gets called by the routing engine. The first argument is
`request` which contains a `HttpRequest` instance.
The request is stored in `self.request` for later use. The storage
instance is stored in `self.storage`.
"""
# add the storage engine to the current wizardview instance
self.prefix = self.get_prefix(*args, **kwargs)
self.storage = SessionStorage(self.prefix, request)
response = super(ChainedFormsView, self).dispatch(request,
*args, **kwargs)
return response
@property
def current_step(self):
return self.storage.current_step
def _get_from_extra_data(self, key):
"""Return the object stored under `key` in the `extra_data` dictionary
managed by `SessionStorage.
"""
return self.storage.extra_data.get(key)
def _update_extra_data(self, key, value):
"""Update the `extra_data` dictionary managed by `SessionStorage` with
{key: value}. Obviously, if another object is stored under `key` in
`extra_data` it will be overwritten.
"""
extra_data = self.storage.extra_data
extra_data.update({key: value})
self.storage.extra_data = extra_data
def _get_answers(self):
return self._get_from_extra_data(self.answers_key) or {}
def _set_answers(self, value):
self._update_extra_data(self.answers_key, value)
def _get_answers_bk(self):
return self._get_from_extra_data(self.answers_bk_key) or {}
def _set_answers_bk(self, value):
self._update_extra_data(self.answers_bk_key, value)
answers = property(_get_answers, _set_answers)
answers_bk = property(_get_answers_bk, _set_answers_bk)
def get(self, request, *args, **kwargs):
"""This method handles GET requests.
If a GET request reaches this point, the wizard assumes that the user
just starts at the first step or wants to restart the process. The
data of the wizard will be resetted before rendering the first step.
"""
self.storage.reset()
self.answers = {}
self.answers_bk = {}
form_ = self.get_form()
self.storage.current_step = self.step0
self._init_other_vars()
return self.render(form_)
def post(self, *args, **kwargs):
"""This method handles POST requests.
The wizard will render either the current step (if form validation
wasn't successful), the next step (if the current step was stored
successful) or the done view (if no more steps are available)
"""
# Check if form was refreshed
management_form = ManagementForm(self.request.POST, prefix=self.prefix)
if not management_form.is_valid():
raise ValidationError(
'ManagementForm data is missing or has been tampered.')
form_current_step = management_form.cleaned_data['current_step']
if form_current_step != self.storage.current_step:
# form refreshed, change current step
self.storage.current_step = form_current_step
# get the form for the current step
form = self.get_form(data=self.request.POST)
# and try to validate
if form.is_valid():
# if the form is valid, store the cleaned data.
self.storage.set_step_data(self.current_step,
self.process_step(form))
if self.is_last_step(form) or self.finish_now:
# no more steps, render done view
return self.render_done(form, **kwargs)
else:
# proceed to the next step: render next form
new_form = self.get_next_form()
self.storage.current_step = self._get_form_id(new_form)
return self.render(new_form, **kwargs)
# form is not valid => render the same form
return self.render(form)
#noinspection PyUnusedLocal
def get_form_prefix(self, step=None, form=None):
if step is None:
step = self.current_step
return str(step)
#noinspection PyUnusedLocal
def get_form(self, data=None):
step = self.current_step
# data & prefix are kwargs of `BaseForm.__init__`
kwargs = {'data': data, 'prefix': self.get_form_prefix(step)}
form_type = self._get_form_class()
return form_type(**kwargs)
def _get_form_class(self):
raise NotImplementedError()
def get_next_form(self, data=None):
form_type = self._get_next_form_class()
step = self._get_form_id(form_type)
# data & prefix are kwargs of `BaseForm.__init__`
kwargs = {'data': data, 'prefix': self.get_form_prefix(step)}
return form_type(**kwargs)
def _get_next_form_class(self):
raise NotImplementedError()
def _get_form_id(self, form):
raise NotImplementedError()
def process_step(self, form):
"""This method is used to postprocess the form data. By default, it
returns the raw `form.data` dictionary.
"""
data = self.get_form_step_data(form)
resp = self.answers
self.answers_bk = copy.copy(resp)
try:
form_resp = _remove_prefix(self.current_step, data)
except Exception as e:
logger_(e)
form_resp = {}
try:
resp.update({self.current_step: form_resp, })
self.answers = resp
except Exception as e:
logger_(e)
return data
def get_form_step_data(self, form):
"""Is used to return the raw form data. You may use this method to
manipulate the data.
"""
data = copy.copy(form.data)
data.pop(self.get_prefix() + '-current_step', None)
data.pop('csrfmiddlewaretoken', None)
return data
#noinspection PyMethodOverriding
def get_context_data(self, form, **kwargs):
"""Returns the template context for a step. You can overwrite this
method to add more data for all or some steps. This method returns a
dictionary containing the rendered form step. Available template
context variables are:
* all extra data stored in the storage backend
* `form` - form instance of the current step
* `wizard` - the wizard instance itself
Example:
.. code-block:: python
class MyWizard(ChainedFormsView):
def get_context_data(self, form, **kwargs):
context = super(MyWizard, self).get_context_data(form=form, **kwargs)
context.update({'another_var': True})
return context
"""
context = super(ChainedFormsView, self).get_context_data(**kwargs)
context.update(self.storage.extra_data)
context['wizard'] = {
'form': form,
'is_step0': self.is_step0(),
self.answers_key: self.answers,
self.answers_bk_key: self.answers_bk,
'management_form': ManagementForm(prefix=self.prefix,
initial={'current_step': self.current_step, }),
}
return context
def render(self, form=None, **kwargs):
"""Returns a ``HttpResponse`` containing all needed context data."""
form = form or self.get_form()
context = self.get_context_data(form=form, **kwargs)
return self.render_to_response(context)
def render_done(self, form, **kwargs):
"""Render the done view and reset the wizard before returning the
response. This is needed to prevent from rendering done with the
same data twice.
"""
done_response = self.done(form, **kwargs)
#self.storage.reset() #TODO: check it's OK!
return done_response
#noinspection PyUnusedLocal
def done(self, form, **kwargs):
"""This method must be overridden by a subclass."""
raise NotImplementedError("Your %s class has not defined a done() "
"method, which is required." % self.__class__.__name__)
def is_step0(self):
return self.current_step is None or self.current_step == self.step0
def is_last_step(self, form):
raise NotImplementedError()
def _init_other_vars(self):
pass
def _remove_prefix(step, data):
"""Remove prefix '<id>-' from POST's data-dictionary keys, thus '1-x' is
converted to 'x'. The returned dict `e_resp` will contain all the answers
of this `step` of the form (in the context of a multi-page form).
Args:
step (unicode): the current step, representing an integer value either
positive, zero or negative.
data (django.http.QueryDict): a dictionary customized to handle
multiple values for the same key.
Raises:
AttributeError: `data` does not have a 'lists' method.
"""
# original code
#e_resp = {k[re.search('(?<=%s[-])\w+' %
# self.steps.current, k).start():]: v if len(v) > 1 else v[0]
# for k, v in data.lists()}
# in python < 2.7 we can't use dictionary comprehensions
e_resp = {}
for k, v in data.lists():
match_ = re.search('(?<=%s[-])[\w-]+' % step, k)
if match_ is not None:
key = k[match_.start():]
e_resp[key] = v if len(v) > 1 else v[0]
return e_resp
def normalize_name(name):
"""Converts camel-case style names into underscore separated words.
Example::
>>> normalize_name('oneTwoThree')
'one_two_three'
>>> normalize_name('FourFiveSix')
'four_five_six'
Source:
django.contrib.formtools.wizard.views.normalize_name
"""
new = re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', '_\\1', name)
return new.lower().strip('_')
{% load i18n %}
{% csrf_token %}
{{ wizard.form.media }}
{{ wizard.management_form }}
{{ wizard.form.as_p }}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.first }}">
{% trans "first step" %}
</button>
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}">
{% trans "prev step" %}
</button>
{% endif %}
<input type="submit" name="submit" value="{% trans "submit" %}" />
@glarrain
Copy link
Author

Fixed some issues (I do not use exactly like this because it is a little intertwined with the propietary), added the SessionStorage class and renamed all spanish words left around. I also added a basic HTML template to work with this.

@glarrain
Copy link
Author

Maybe ChainedFormsView is not the best name. How about FormsGraphView or LinkedFormsView?

@akuchling
Copy link

May users assume that this code is made available under the Django license? Or do you wish to apply some different license to it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment