Created
July 12, 2012 15:19
-
-
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`
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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('_') |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{% 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" %}" /> |
Maybe ChainedFormsView is not the best name. How about FormsGraphView or LinkedFormsView?
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
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.