From 632dfa233839f8c76bd6b065b8420fe0ab96175a Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Wed, 1 Jun 2011 13:47:00 +0000 Subject: [PATCH] =?UTF-8?q?Fixed=20#9200=20--=20Added=20new=20form=20wizar?= =?UTF-8?q?d=20to=20formtools=20based=20on=20class=20based=20views.=20Many?= =?UTF-8?q?=20thanks=20to=20Stephan=20J=C3=A4kel,=20ddurham=20and=20Elliot?= =?UTF-8?q?tM=20for=20their=20work.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git-svn-id: http://code.djangoproject.com/svn/django/trunk@16307 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/formtools/tests/__init__.py | 126 ++-- django/contrib/formtools/tests/forms.py | 43 ++ .../tests}/templates/forms/wizard.html | 26 +- .../tests/templates/formwizard/wizard.html | 9 - django/contrib/formtools/tests/urls.py | 15 +- django/contrib/formtools/wizard/__init__.py | 1 + django/contrib/formtools/wizard/forms.py | 7 + .../formtools/{wizard.py => wizard/legacy.py} | 23 +- .../formtools/wizard/storage/__init__.py | 22 + .../contrib/formtools/wizard/storage/base.py | 93 +++ .../formtools/wizard/storage/cookie.py | 32 + .../formtools/wizard/storage/exceptions.py | 10 + .../formtools/wizard/storage/session.py | 20 + .../formtools/wizard/wizard_form.html | 17 + .../formtools/wizard/tests/__init__.py | 6 + .../wizard/tests/cookiestoragetests.py | 43 ++ .../formtools/wizard/tests/formtests.py | 182 +++++ .../wizard/tests/loadstoragetests.py | 22 + .../wizard/tests/namedwizardtests/__init__.py | 1 + .../wizard/tests/namedwizardtests/forms.py | 42 ++ .../wizard/tests/namedwizardtests/tests.py | 355 +++++++++ .../wizard/tests/namedwizardtests/urls.py | 24 + .../wizard/tests/sessionstoragetests.py | 8 + .../formtools/wizard/tests/storagetests.py | 76 ++ .../wizard/tests/wizardtests/__init__.py | 1 + .../wizard/tests/wizardtests/forms.py | 57 ++ .../wizard/tests/wizardtests/tests.py | 248 +++++++ .../wizard/tests/wizardtests/urls.py | 16 + django/contrib/formtools/wizard/views.py | 684 ++++++++++++++++++ django/utils/functional.py | 21 + docs/internals/deprecation.txt | 4 + docs/ref/contrib/formtools/form-wizard.txt | 626 +++++++++++----- docs/releases/1.4.txt | 16 + tests/regressiontests/formwizard/__init__.py | 0 tests/regressiontests/formwizard/forms.py | 18 - tests/regressiontests/formwizard/models.py | 0 tests/regressiontests/formwizard/tests.py | 59 -- tests/regressiontests/formwizard/urls.py | 6 - tests/regressiontests/utils/functional.py | 19 +- 39 files changed, 2614 insertions(+), 364 deletions(-) create mode 100644 django/contrib/formtools/tests/forms.py rename {tests/regressiontests/formwizard => django/contrib/formtools/tests}/templates/forms/wizard.html (93%) delete mode 100644 django/contrib/formtools/tests/templates/formwizard/wizard.html create mode 100644 django/contrib/formtools/wizard/__init__.py create mode 100644 django/contrib/formtools/wizard/forms.py rename django/contrib/formtools/{wizard.py => wizard/legacy.py} (95%) create mode 100644 django/contrib/formtools/wizard/storage/__init__.py create mode 100644 django/contrib/formtools/wizard/storage/base.py create mode 100644 django/contrib/formtools/wizard/storage/cookie.py create mode 100644 django/contrib/formtools/wizard/storage/exceptions.py create mode 100644 django/contrib/formtools/wizard/storage/session.py create mode 100644 django/contrib/formtools/wizard/templates/formtools/wizard/wizard_form.html create mode 100644 django/contrib/formtools/wizard/tests/__init__.py create mode 100644 django/contrib/formtools/wizard/tests/cookiestoragetests.py create mode 100644 django/contrib/formtools/wizard/tests/formtests.py create mode 100644 django/contrib/formtools/wizard/tests/loadstoragetests.py create mode 100644 django/contrib/formtools/wizard/tests/namedwizardtests/__init__.py create mode 100644 django/contrib/formtools/wizard/tests/namedwizardtests/forms.py create mode 100644 django/contrib/formtools/wizard/tests/namedwizardtests/tests.py create mode 100644 django/contrib/formtools/wizard/tests/namedwizardtests/urls.py create mode 100644 django/contrib/formtools/wizard/tests/sessionstoragetests.py create mode 100644 django/contrib/formtools/wizard/tests/storagetests.py create mode 100644 django/contrib/formtools/wizard/tests/wizardtests/__init__.py create mode 100644 django/contrib/formtools/wizard/tests/wizardtests/forms.py create mode 100644 django/contrib/formtools/wizard/tests/wizardtests/tests.py create mode 100644 django/contrib/formtools/wizard/tests/wizardtests/urls.py create mode 100644 django/contrib/formtools/wizard/views.py delete mode 100644 tests/regressiontests/formwizard/__init__.py delete mode 100644 tests/regressiontests/formwizard/forms.py delete mode 100644 tests/regressiontests/formwizard/models.py delete mode 100644 tests/regressiontests/formwizard/tests.py delete mode 100644 tests/regressiontests/formwizard/urls.py diff --git a/django/contrib/formtools/tests/__init__.py b/django/contrib/formtools/tests/__init__.py index be0372ac2f..7084386501 100644 --- a/django/contrib/formtools/tests/__init__.py +++ b/django/contrib/formtools/tests/__init__.py @@ -1,13 +1,19 @@ import os +import re import warnings -from django import forms, http +from django import http from django.conf import settings from django.contrib.formtools import preview, wizard, utils from django.test import TestCase from django.test.utils import get_warnings_state, restore_warnings_state from django.utils import unittest +from django.contrib.formtools.wizard.tests import * +from django.contrib.formtools.tests.forms import * + +warnings.filterwarnings('ignore', category=PendingDeprecationWarning, + module='django.contrib.formtools.wizard') success_string = "Done was called!" @@ -24,12 +30,6 @@ class TestFormPreview(preview.FormPreview): return http.HttpResponse(success_string) -class TestForm(forms.Form): - field1 = forms.CharField() - field1_ = forms.CharField() - bool1 = forms.BooleanField(required=False) - - class PreviewTests(TestCase): urls = 'django.contrib.formtools.tests.urls' @@ -63,7 +63,7 @@ class PreviewTests(TestCase): is created to manage the stage. """ - response = self.client.get('/test1/') + response = self.client.get('/preview/') stage = self.input % 1 self.assertContains(response, stage, 1) self.assertEqual(response.context['custom_context'], True) @@ -81,7 +81,7 @@ class PreviewTests(TestCase): # Pass strings for form submittal and add stage variable to # show we previously saw first stage of the form. self.test_data.update({'stage': 1}) - response = self.client.post('/test1/', self.test_data) + response = self.client.post('/preview/', self.test_data) # Check to confirm stage is set to 2 in output form. stage = self.input % 2 self.assertContains(response, stage, 1) @@ -99,11 +99,11 @@ class PreviewTests(TestCase): # Pass strings for form submittal and add stage variable to # show we previously saw first stage of the form. self.test_data.update({'stage':2}) - response = self.client.post('/test1/', self.test_data) + response = self.client.post('/preview/', self.test_data) self.assertNotEqual(response.content, success_string) hash = self.preview.security_hash(None, TestForm(self.test_data)) self.test_data.update({'hash': hash}) - response = self.client.post('/test1/', self.test_data) + response = self.client.post('/preview/', self.test_data) self.assertEqual(response.content, success_string) def test_bool_submit(self): @@ -122,7 +122,7 @@ class PreviewTests(TestCase): self.test_data.update({'stage':2}) hash = self.preview.security_hash(None, TestForm(self.test_data)) self.test_data.update({'hash':hash, 'bool1':u'False'}) - response = self.client.post('/test1/', self.test_data) + response = self.client.post('/preview/', self.test_data) self.assertEqual(response.content, success_string) def test_form_submit_good_hash(self): @@ -133,11 +133,11 @@ class PreviewTests(TestCase): # Pass strings for form submittal and add stage variable to # show we previously saw first stage of the form. self.test_data.update({'stage':2}) - response = self.client.post('/test1/', self.test_data) + response = self.client.post('/preview/', self.test_data) self.assertNotEqual(response.content, success_string) hash = utils.form_hmac(TestForm(self.test_data)) self.test_data.update({'hash': hash}) - response = self.client.post('/test1/', self.test_data) + response = self.client.post('/preview/', self.test_data) self.assertEqual(response.content, success_string) @@ -149,12 +149,12 @@ class PreviewTests(TestCase): # Pass strings for form submittal and add stage variable to # show we previously saw first stage of the form. self.test_data.update({'stage':2}) - response = self.client.post('/test1/', self.test_data) + response = self.client.post('/preview/', self.test_data) self.assertEqual(response.status_code, 200) self.assertNotEqual(response.content, success_string) hash = utils.form_hmac(TestForm(self.test_data)) + "bad" self.test_data.update({'hash': hash}) - response = self.client.post('/test1/', self.test_data) + response = self.client.post('/previewpreview/', self.test_data) self.assertNotEqual(response.content, success_string) @@ -220,38 +220,14 @@ class FormHmacTests(unittest.TestCase): self.assertEqual(hash1, hash2) -class HashTestForm(forms.Form): - name = forms.CharField() - bio = forms.CharField() - - -class HashTestBlankForm(forms.Form): - name = forms.CharField(required=False) - bio = forms.CharField(required=False) - # # FormWizard tests # - -class WizardPageOneForm(forms.Form): - field = forms.CharField() - - -class WizardPageTwoForm(forms.Form): - field = forms.CharField() - -class WizardPageTwoAlternativeForm(forms.Form): - field = forms.CharField() - -class WizardPageThreeForm(forms.Form): - field = forms.CharField() - - -class WizardClass(wizard.FormWizard): +class TestWizardClass(wizard.FormWizard): def get_template(self, step): - return 'formwizard/wizard.html' + return 'forms/wizard.html' def done(self, request, cleaned_data): return http.HttpResponse(success_string) @@ -269,6 +245,20 @@ class DummyRequest(http.HttpRequest): class WizardTests(TestCase): urls = 'django.contrib.formtools.tests.urls' + input_re = re.compile('name="([^"]+)" value="([^"]+)"') + wizard_step_data = ( + { + '0-name': 'Pony', + '0-thirsty': '2', + }, + { + '1-address1': '123 Main St', + '1-address2': 'Djangoland', + }, + { + '2-random_crap': 'blah blah', + } + ) def setUp(self): self.old_TEMPLATE_DIRS = settings.TEMPLATE_DIRS @@ -290,21 +280,21 @@ class WizardTests(TestCase): """ step should be zero for the first form """ - response = self.client.get('/wizard/') + response = self.client.get('/wizard1/') self.assertEqual(0, response.context['step0']) def test_step_increments(self): """ step should be incremented when we go to the next page """ - response = self.client.post('/wizard/', {"0-field":"test", "wizard_step":"0"}) + response = self.client.post('/wizard1/', {"0-field":"test", "wizard_step":"0"}) self.assertEqual(1, response.context['step0']) def test_bad_hash(self): """ Form should not advance if the hash is missing or bad """ - response = self.client.post('/wizard/', + response = self.client.post('/wizard1/', {"0-field":"test", "1-field":"test2", "wizard_step": "1"}) @@ -319,7 +309,7 @@ class WizardTests(TestCase): "1-field": "test2", "hash_0": "7e9cea465f6a10a6fb47fcea65cb9a76350c9a5c", "wizard_step": "1"} - response = self.client.post('/wizard/', data) + response = self.client.post('/wizard1/', data) self.assertEqual(2, response.context['step0']) def test_11726(self): @@ -330,7 +320,7 @@ class WizardTests(TestCase): reached = [False] that = self - class WizardWithProcessStep(WizardClass): + class WizardWithProcessStep(TestWizardClass): def process_step(self, request, form, step): if step == 0: if self.num_steps() < 2: @@ -362,7 +352,7 @@ class WizardTests(TestCase): reached = [False] that = self - class WizardWithProcessStep(WizardClass): + class WizardWithProcessStep(TestWizardClass): def process_step(self, request, form, step): that.assertTrue(hasattr(form, 'cleaned_data')) reached[0] = True @@ -386,7 +376,7 @@ class WizardTests(TestCase): reached = [False] that = self - class Wizard(WizardClass): + class Wizard(TestWizardClass): def done(self, request, form_list): reached[0] = True that.assertTrue(len(form_list) == 2) @@ -409,7 +399,7 @@ class WizardTests(TestCase): reached = [False] that = self - class WizardWithProcessStep(WizardClass): + class WizardWithProcessStep(TestWizardClass): def process_step(self, request, form, step): if step == 0: self.form_list[1] = WizardPageTwoAlternativeForm @@ -426,3 +416,39 @@ class WizardTests(TestCase): "wizard_step": "1"} wizard(DummyRequest(POST=data)) self.assertTrue(reached[0]) + + def grab_field_data(self, response): + """ + Pull the appropriate field data from the context to pass to the next wizard step + """ + previous_fields = response.context['previous_fields'] + fields = {'wizard_step': response.context['step0']} + + def grab(m): + fields[m.group(1)] = m.group(2) + return '' + + self.input_re.sub(grab, previous_fields) + return fields + + def check_wizard_step(self, response, step_no): + """ + Helper function to test each step of the wizard + - Make sure the call succeeded + - Make sure response is the proper step number + - return the result from the post for the next step + """ + step_count = len(self.wizard_step_data) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Step %d of %d' % (step_no, step_count)) + + data = self.grab_field_data(response) + data.update(self.wizard_step_data[step_no - 1]) + + return self.client.post('/wizard2/', data) + + def test_9473(self): + response = self.client.get('/wizard2/') + for step_no in range(1, len(self.wizard_step_data) + 1): + response = self.check_wizard_step(response, step_no) diff --git a/django/contrib/formtools/tests/forms.py b/django/contrib/formtools/tests/forms.py new file mode 100644 index 0000000000..93bad924b8 --- /dev/null +++ b/django/contrib/formtools/tests/forms.py @@ -0,0 +1,43 @@ +from django import forms +from django.contrib.formtools.wizard import FormWizard +from django.http import HttpResponse + +class Page1(forms.Form): + name = forms.CharField(max_length=100) + thirsty = forms.NullBooleanField() + +class Page2(forms.Form): + address1 = forms.CharField(max_length=100) + address2 = forms.CharField(max_length=100) + +class Page3(forms.Form): + random_crap = forms.CharField(max_length=100) + +class ContactWizard(FormWizard): + def done(self, request, form_list): + return HttpResponse("") + +class TestForm(forms.Form): + field1 = forms.CharField() + field1_ = forms.CharField() + bool1 = forms.BooleanField(required=False) + +class HashTestForm(forms.Form): + name = forms.CharField() + bio = forms.CharField() + +class HashTestBlankForm(forms.Form): + name = forms.CharField(required=False) + bio = forms.CharField(required=False) + +class WizardPageOneForm(forms.Form): + field = forms.CharField() + +class WizardPageTwoForm(forms.Form): + field = forms.CharField() + +class WizardPageTwoAlternativeForm(forms.Form): + field = forms.CharField() + +class WizardPageThreeForm(forms.Form): + field = forms.CharField() diff --git a/tests/regressiontests/formwizard/templates/forms/wizard.html b/django/contrib/formtools/tests/templates/forms/wizard.html similarity index 93% rename from tests/regressiontests/formwizard/templates/forms/wizard.html rename to django/contrib/formtools/tests/templates/forms/wizard.html index a31378fc26..c911c3cbf8 100644 --- a/tests/regressiontests/formwizard/templates/forms/wizard.html +++ b/django/contrib/formtools/tests/templates/forms/wizard.html @@ -1,13 +1,13 @@ - - -

Step {{ step }} of {{ step_count }}

-
- - {{ form }} -
- - {{ previous_fields|safe }} - -
- - \ No newline at end of file + + +

Step {{ step }} of {{ step_count }}

+
+ + {{ form }} +
+ + {{ previous_fields|safe }} + +
+ + diff --git a/django/contrib/formtools/tests/templates/formwizard/wizard.html b/django/contrib/formtools/tests/templates/formwizard/wizard.html deleted file mode 100644 index 42b6e78be8..0000000000 --- a/django/contrib/formtools/tests/templates/formwizard/wizard.html +++ /dev/null @@ -1,9 +0,0 @@ -

Step {{ step }} of {{ step_count }}

-
{% csrf_token %} - -{{ form }} -
- -{{ previous_fields|safe }} - -
diff --git a/django/contrib/formtools/tests/urls.py b/django/contrib/formtools/tests/urls.py index 6fc1e4ee22..f058335b07 100644 --- a/django/contrib/formtools/tests/urls.py +++ b/django/contrib/formtools/tests/urls.py @@ -3,11 +3,14 @@ This is a URLconf to be loaded by tests.py. Add any URLs needed for tests only. """ from django.conf.urls.defaults import * -from django.contrib.formtools.tests import * +from django.contrib.formtools.tests import TestFormPreview, TestWizardClass + +from forms import (ContactWizard, Page1, Page2, Page3, TestForm, + WizardPageOneForm, WizardPageTwoForm, WizardPageThreeForm) urlpatterns = patterns('', - (r'^test1/', TestFormPreview(TestForm)), - (r'^wizard/$', WizardClass([WizardPageOneForm, - WizardPageTwoForm, - WizardPageThreeForm])), - ) + url(r'^preview/', TestFormPreview(TestForm)), + url(r'^wizard1/$', TestWizardClass( + [WizardPageOneForm, WizardPageTwoForm, WizardPageThreeForm])), + url(r'^wizard2/$', ContactWizard([Page1, Page2, Page3])), +) diff --git a/django/contrib/formtools/wizard/__init__.py b/django/contrib/formtools/wizard/__init__.py new file mode 100644 index 0000000000..8e51a3170d --- /dev/null +++ b/django/contrib/formtools/wizard/__init__.py @@ -0,0 +1 @@ +from django.contrib.formtools.wizard.legacy import FormWizard diff --git a/django/contrib/formtools/wizard/forms.py b/django/contrib/formtools/wizard/forms.py new file mode 100644 index 0000000000..bf46c5c992 --- /dev/null +++ b/django/contrib/formtools/wizard/forms.py @@ -0,0 +1,7 @@ +from django import forms + +class ManagementForm(forms.Form): + """ + ``ManagementForm`` is used to keep track of the current wizard step. + """ + current_step = forms.CharField(widget=forms.HiddenInput) diff --git a/django/contrib/formtools/wizard.py b/django/contrib/formtools/wizard/legacy.py similarity index 95% rename from django/contrib/formtools/wizard.py rename to django/contrib/formtools/wizard/legacy.py index c19578c390..532635a3d9 100644 --- a/django/contrib/formtools/wizard.py +++ b/django/contrib/formtools/wizard/legacy.py @@ -3,15 +3,7 @@ FormWizard class -- implements a multi-page form, validating between each step and storing the form's state as HTML hidden fields so that no state is stored on the server side. """ - -try: - import cPickle as pickle -except ImportError: - import pickle - -from django import forms -from django.conf import settings -from django.contrib.formtools.utils import form_hmac +from django.forms import HiddenInput from django.http import Http404 from django.shortcuts import render_to_response from django.template.context import RequestContext @@ -20,6 +12,7 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_protect +from django.contrib.formtools.utils import form_hmac class FormWizard(object): # The HTML (and POST data) field name for the "step" variable. @@ -42,6 +35,12 @@ class FormWizard(object): # A zero-based counter keeping track of which step we're in. self.step = 0 + import warnings + warnings.warn( + 'Old-style form wizards have been deprecated; use the class-based ' + 'views in django.contrib.formtools.wizard.views instead.', + PendingDeprecationWarning) + def __repr__(self): return "step: %d\nform_list: %s\ninitial_data: %s" % (self.step, self.form_list, self.initial) @@ -71,7 +70,7 @@ class FormWizard(object): """ if 'extra_context' in kwargs: self.extra_context.update(kwargs['extra_context']) - current_step = self.determine_step(request, *args, **kwargs) + current_step = self.get_current_or_first_step(request, *args, **kwargs) self.parse_params(request, *args, **kwargs) # Validate and process all the previous forms before instantiating the @@ -132,7 +131,7 @@ class FormWizard(object): old_data = request.POST prev_fields = [] if old_data: - hidden = forms.HiddenInput() + hidden = HiddenInput() # Collect all data from previous steps and render it as HTML hidden fields. for i in range(step): old_form = self.get_form(i, old_data) @@ -177,7 +176,7 @@ class FormWizard(object): """ return form_hmac(form) - def determine_step(self, request, *args, **kwargs): + def get_current_or_first_step(self, request, *args, **kwargs): """ Given the request object and whatever *args and **kwargs were passed to __call__(), returns the current step (which is zero-based). diff --git a/django/contrib/formtools/wizard/storage/__init__.py b/django/contrib/formtools/wizard/storage/__init__.py new file mode 100644 index 0000000000..b88ccc79ef --- /dev/null +++ b/django/contrib/formtools/wizard/storage/__init__.py @@ -0,0 +1,22 @@ +from django.utils.importlib import import_module + +from django.contrib.formtools.wizard.storage.base import BaseStorage +from django.contrib.formtools.wizard.storage.exceptions import ( + MissingStorageModule, MissingStorageClass, NoFileStorageConfigured) + + +def get_storage(path, *args, **kwargs): + i = path.rfind('.') + module, attr = path[:i], path[i+1:] + try: + mod = import_module(module) + except ImportError, e: + raise MissingStorageModule( + 'Error loading storage %s: "%s"' % (module, e)) + try: + storage_class = getattr(mod, attr) + except AttributeError: + raise MissingStorageClass( + 'Module "%s" does not define a storage named "%s"' % (module, attr)) + return storage_class(*args, **kwargs) + diff --git a/django/contrib/formtools/wizard/storage/base.py b/django/contrib/formtools/wizard/storage/base.py new file mode 100644 index 0000000000..475b39dc75 --- /dev/null +++ b/django/contrib/formtools/wizard/storage/base.py @@ -0,0 +1,93 @@ +from django.core.files.uploadedfile import UploadedFile +from django.utils.functional import lazy_property +from django.utils.encoding import smart_str + +from django.contrib.formtools.wizard.storage.exceptions import NoFileStorageConfigured + +class BaseStorage(object): + step_key = 'step' + step_data_key = 'step_data' + step_files_key = 'step_files' + extra_data_key = 'extra_data' + + def __init__(self, prefix, request=None, file_storage=None): + self.prefix = 'wizard_%s' % prefix + self.request = request + self.file_storage = file_storage + + def init_data(self): + self.data = { + self.step_key: None, + self.step_data_key: {}, + self.step_files_key: {}, + self.extra_data_key: {}, + } + + 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 = lazy_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 = lazy_property(_get_extra_data, _set_extra_data) + + def get_step_data(self, step): + return self.data[self.step_data_key].get(step, None) + + def set_step_data(self, step, cleaned_data): + self.data[self.step_data_key][step] = cleaned_data + + @property + def current_step_data(self): + return self.get_step_data(self.current_step) + + def get_step_files(self, step): + wizard_files = self.data[self.step_files_key].get(step, {}) + + if wizard_files and not self.file_storage: + raise NoFileStorageConfigured + + files = {} + for field, field_dict in wizard_files.iteritems(): + field_dict = dict((smart_str(k), v) + for k, v in field_dict.iteritems()) + tmp_name = field_dict.pop('tmp_name') + files[field] = UploadedFile( + file=self.file_storage.open(tmp_name), **field_dict) + return files or None + + def set_step_files(self, step, files): + if files and not self.file_storage: + raise NoFileStorageConfigured + + if step not in self.data[self.step_files_key]: + self.data[self.step_files_key][step] = {} + + for field, field_file in (files or {}).iteritems(): + tmp_filename = self.file_storage.save(field_file.name, field_file) + file_dict = { + 'tmp_name': tmp_filename, + 'name': field_file.name, + 'content_type': field_file.content_type, + 'size': field_file.size, + 'charset': field_file.charset + } + self.data[self.step_files_key][step][field] = file_dict + + @property + def current_step_files(self): + return self.get_step_files(self.current_step) + + def update_response(self, response): + pass diff --git a/django/contrib/formtools/wizard/storage/cookie.py b/django/contrib/formtools/wizard/storage/cookie.py new file mode 100644 index 0000000000..af26e01337 --- /dev/null +++ b/django/contrib/formtools/wizard/storage/cookie.py @@ -0,0 +1,32 @@ +from django.core.exceptions import SuspiciousOperation +from django.core.signing import BadSignature +from django.utils import simplejson as json + +from django.contrib.formtools.wizard import storage + + +class CookieStorage(storage.BaseStorage): + encoder = json.JSONEncoder(separators=(',', ':')) + + def __init__(self, *args, **kwargs): + super(CookieStorage, self).__init__(*args, **kwargs) + self.data = self.load_data() + if self.data is None: + self.init_data() + + def load_data(self): + try: + data = self.request.get_signed_cookie(self.prefix) + except KeyError: + data = None + except BadSignature: + raise SuspiciousOperation('FormWizard cookie manipulated') + if data is None: + return None + return json.loads(data, cls=json.JSONDecoder) + + def update_response(self, response): + if self.data: + response.set_signed_cookie(self.prefix, self.encoder.encode(self.data)) + else: + response.delete_cookie(self.prefix) diff --git a/django/contrib/formtools/wizard/storage/exceptions.py b/django/contrib/formtools/wizard/storage/exceptions.py new file mode 100644 index 0000000000..eab9030cf1 --- /dev/null +++ b/django/contrib/formtools/wizard/storage/exceptions.py @@ -0,0 +1,10 @@ +from django.core.exceptions import ImproperlyConfigured + +class MissingStorageModule(ImproperlyConfigured): + pass + +class MissingStorageClass(ImproperlyConfigured): + pass + +class NoFileStorageConfigured(ImproperlyConfigured): + pass diff --git a/django/contrib/formtools/wizard/storage/session.py b/django/contrib/formtools/wizard/storage/session.py new file mode 100644 index 0000000000..84a3848ab7 --- /dev/null +++ b/django/contrib/formtools/wizard/storage/session.py @@ -0,0 +1,20 @@ +from django.core.files.uploadedfile import UploadedFile +from django.contrib.formtools.wizard import storage + + +class SessionStorage(storage.BaseStorage): + + def __init__(self, *args, **kwargs): + super(SessionStorage, self).__init__(*args, **kwargs) + if self.prefix not in self.request.session: + self.init_data() + + 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) diff --git a/django/contrib/formtools/wizard/templates/formtools/wizard/wizard_form.html b/django/contrib/formtools/wizard/templates/formtools/wizard/wizard_form.html new file mode 100644 index 0000000000..b98e58d1c7 --- /dev/null +++ b/django/contrib/formtools/wizard/templates/formtools/wizard/wizard_form.html @@ -0,0 +1,17 @@ +{% load i18n %} +{% csrf_token %} +{{ wizard.management_form }} +{% if wizard.form.forms %} + {{ wizard.form.management_form }} + {% for form in wizard.form.forms %} + {{ form.as_p }} + {% endfor %} +{% else %} + {{ wizard.form.as_p }} +{% endif %} + +{% if wizard.steps.prev %} + + +{% endif %} + diff --git a/django/contrib/formtools/wizard/tests/__init__.py b/django/contrib/formtools/wizard/tests/__init__.py new file mode 100644 index 0000000000..7c66c82efc --- /dev/null +++ b/django/contrib/formtools/wizard/tests/__init__.py @@ -0,0 +1,6 @@ +from django.contrib.formtools.wizard.tests.formtests import * +from django.contrib.formtools.wizard.tests.sessionstoragetests import * +from django.contrib.formtools.wizard.tests.cookiestoragetests import * +from django.contrib.formtools.wizard.tests.loadstoragetests import * +from django.contrib.formtools.wizard.tests.wizardtests import * +from django.contrib.formtools.wizard.tests.namedwizardtests import * diff --git a/django/contrib/formtools/wizard/tests/cookiestoragetests.py b/django/contrib/formtools/wizard/tests/cookiestoragetests.py new file mode 100644 index 0000000000..74c7e822b4 --- /dev/null +++ b/django/contrib/formtools/wizard/tests/cookiestoragetests.py @@ -0,0 +1,43 @@ +from django.test import TestCase +from django.core import signing +from django.core.exceptions import SuspiciousOperation +from django.http import HttpResponse + +from django.contrib.formtools.wizard.storage.cookie import CookieStorage +from django.contrib.formtools.wizard.tests.storagetests import get_request, TestStorage + +class TestCookieStorage(TestStorage, TestCase): + def get_storage(self): + return CookieStorage + + def test_manipulated_cookie(self): + request = get_request() + storage = self.get_storage()('wizard1', request, None) + + cookie_signer = signing.get_cookie_signer(storage.prefix) + + storage.request.COOKIES[storage.prefix] = cookie_signer.sign( + storage.encoder.encode({'key1': 'value1'})) + + self.assertEqual(storage.load_data(), {'key1': 'value1'}) + + storage.request.COOKIES[storage.prefix] = 'i_am_manipulated' + self.assertRaises(SuspiciousOperation, storage.load_data) + + def test_reset_cookie(self): + request = get_request() + storage = self.get_storage()('wizard1', request, None) + + storage.data = {'key1': 'value1'} + + response = HttpResponse() + storage.update_response(response) + + cookie_signer = signing.get_cookie_signer(storage.prefix) + signed_cookie_data = cookie_signer.sign(storage.encoder.encode(storage.data)) + self.assertEqual(response.cookies[storage.prefix].value, signed_cookie_data) + + storage.init_data() + storage.update_response(response) + unsigned_cookie_data = cookie_signer.unsign(response.cookies[storage.prefix].value) + self.assertEqual(unsigned_cookie_data, '{"step_files":{},"step":null,"extra_data":{},"step_data":{}}') diff --git a/django/contrib/formtools/wizard/tests/formtests.py b/django/contrib/formtools/wizard/tests/formtests.py new file mode 100644 index 0000000000..111981fea2 --- /dev/null +++ b/django/contrib/formtools/wizard/tests/formtests.py @@ -0,0 +1,182 @@ +from django import forms, http +from django.conf import settings +from django.test import TestCase +from django.template.response import TemplateResponse +from django.utils.importlib import import_module + +from django.contrib.auth.models import User + +from django.contrib.formtools.wizard.views import (WizardView, + SessionWizardView, + CookieWizardView) + + +class DummyRequest(http.HttpRequest): + def __init__(self, POST=None): + super(DummyRequest, self).__init__() + self.method = POST and "POST" or "GET" + if POST is not None: + self.POST.update(POST) + self.session = {} + self._dont_enforce_csrf_checks = True + +def get_request(*args, **kwargs): + request = DummyRequest(*args, **kwargs) + engine = import_module(settings.SESSION_ENGINE) + request.session = engine.SessionStore(None) + return request + +class Step1(forms.Form): + name = forms.CharField() + +class Step2(forms.Form): + name = forms.CharField() + +class Step3(forms.Form): + data = forms.CharField() + +class UserForm(forms.ModelForm): + class Meta: + model = User + +UserFormSet = forms.models.modelformset_factory(User, form=UserForm, extra=2) + +class TestWizard(WizardView): + storage_name = 'django.contrib.formtools.wizard.storage.session.SessionStorage' + + def dispatch(self, request, *args, **kwargs): + response = super(TestWizard, self).dispatch(request, *args, **kwargs) + return response, self + +class FormTests(TestCase): + def test_form_init(self): + testform = TestWizard.get_initkwargs([Step1, Step2]) + self.assertEquals(testform['form_list'], {u'0': Step1, u'1': Step2}) + + testform = TestWizard.get_initkwargs([('start', Step1), ('step2', Step2)]) + self.assertEquals( + testform['form_list'], {u'start': Step1, u'step2': Step2}) + + testform = TestWizard.get_initkwargs([Step1, Step2, ('finish', Step3)]) + self.assertEquals( + testform['form_list'], {u'0': Step1, u'1': Step2, u'finish': Step3}) + + def test_first_step(self): + request = get_request() + + testform = TestWizard.as_view([Step1, Step2]) + response, instance = testform(request) + self.assertEquals(instance.steps.current, u'0') + + testform = TestWizard.as_view([('start', Step1), ('step2', Step2)]) + response, instance = testform(request) + + self.assertEquals(instance.steps.current, 'start') + + def test_persistence(self): + testform = TestWizard.as_view([('start', Step1), ('step2', Step2)]) + request = get_request({'test_wizard-current_step': 'start', + 'name': 'data1'}) + response, instance = testform(request) + self.assertEquals(instance.steps.current, 'start') + + instance.storage.current_step = 'step2' + + testform2 = TestWizard.as_view([('start', Step1), ('step2', Step2)]) + request.POST = {'test_wizard-current_step': 'step2'} + response, instance = testform2(request) + self.assertEquals(instance.steps.current, 'step2') + + def test_form_condition(self): + request = get_request() + + testform = TestWizard.as_view( + [('start', Step1), ('step2', Step2), ('step3', Step3)], + condition_dict={'step2': True}) + response, instance = testform(request) + self.assertEquals(instance.get_next_step(), 'step2') + + testform = TestWizard.as_view( + [('start', Step1), ('step2', Step2), ('step3', Step3)], + condition_dict={'step2': False}) + response, instance = testform(request) + self.assertEquals(instance.get_next_step(), 'step3') + + def test_form_prefix(self): + request = get_request() + + testform = TestWizard.as_view([('start', Step1), ('step2', Step2)]) + response, instance = testform(request) + + self.assertEqual(instance.get_form_prefix(), 'start') + self.assertEqual(instance.get_form_prefix('another'), 'another') + + def test_form_initial(self): + request = get_request() + + testform = TestWizard.as_view([('start', Step1), ('step2', Step2)], + initial_dict={'start': {'name': 'value1'}}) + response, instance = testform(request) + + self.assertEqual(instance.get_form_initial('start'), {'name': 'value1'}) + self.assertEqual(instance.get_form_initial('step2'), {}) + + def test_form_instance(self): + request = get_request() + the_instance = User() + testform = TestWizard.as_view([('start', UserForm), ('step2', Step2)], + instance_dict={'start': the_instance}) + response, instance = testform(request) + + self.assertEqual( + instance.get_form_instance('start'), + the_instance) + self.assertEqual( + instance.get_form_instance('non_exist_instance'), + None) + + def test_formset_instance(self): + request = get_request() + the_instance1, created = User.objects.get_or_create( + username='testuser1') + the_instance2, created = User.objects.get_or_create( + username='testuser2') + testform = TestWizard.as_view([('start', UserFormSet), ('step2', Step2)], + instance_dict={'start': User.objects.filter(username='testuser1')}) + response, instance = testform(request) + + self.assertEqual(list(instance.get_form_instance('start')), [the_instance1]) + self.assertEqual(instance.get_form_instance('non_exist_instance'), None) + + self.assertEqual(instance.get_form().initial_form_count(), 1) + + def test_done(self): + request = get_request() + + testform = TestWizard.as_view([('start', Step1), ('step2', Step2)]) + response, instance = testform(request) + + self.assertRaises(NotImplementedError, instance.done, None) + + def test_revalidation(self): + request = get_request() + + testform = TestWizard.as_view([('start', Step1), ('step2', Step2)]) + response, instance = testform(request) + instance.render_done(None) + self.assertEqual(instance.storage.current_step, 'start') + + +class SessionFormTests(TestCase): + def test_init(self): + request = get_request() + testform = SessionWizardView.as_view([('start', Step1)]) + self.assertTrue(isinstance(testform(request), TemplateResponse)) + + +class CookieFormTests(TestCase): + def test_init(self): + request = get_request() + testform = CookieWizardView.as_view([('start', Step1)]) + self.assertTrue(isinstance(testform(request), TemplateResponse)) + diff --git a/django/contrib/formtools/wizard/tests/loadstoragetests.py b/django/contrib/formtools/wizard/tests/loadstoragetests.py new file mode 100644 index 0000000000..267dee0379 --- /dev/null +++ b/django/contrib/formtools/wizard/tests/loadstoragetests.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from django.contrib.formtools.wizard.storage import (get_storage, + MissingStorageModule, + MissingStorageClass) +from django.contrib.formtools.wizard.storage.base import BaseStorage + + +class TestLoadStorage(TestCase): + def test_load_storage(self): + self.assertEqual( + type(get_storage('django.contrib.formtools.wizard.storage.base.BaseStorage', 'wizard1')), + BaseStorage) + + def test_missing_module(self): + self.assertRaises(MissingStorageModule, get_storage, + 'django.contrib.formtools.wizard.storage.idontexist.IDontExistStorage', 'wizard1') + + def test_missing_class(self): + self.assertRaises(MissingStorageClass, get_storage, + 'django.contrib.formtools.wizard.storage.base.IDontExistStorage', 'wizard1') + diff --git a/django/contrib/formtools/wizard/tests/namedwizardtests/__init__.py b/django/contrib/formtools/wizard/tests/namedwizardtests/__init__.py new file mode 100644 index 0000000000..4387356730 --- /dev/null +++ b/django/contrib/formtools/wizard/tests/namedwizardtests/__init__.py @@ -0,0 +1 @@ +from django.contrib.formtools.wizard.tests.namedwizardtests.tests import * \ No newline at end of file diff --git a/django/contrib/formtools/wizard/tests/namedwizardtests/forms.py b/django/contrib/formtools/wizard/tests/namedwizardtests/forms.py new file mode 100644 index 0000000000..ae981269f8 --- /dev/null +++ b/django/contrib/formtools/wizard/tests/namedwizardtests/forms.py @@ -0,0 +1,42 @@ +from django import forms +from django.forms.formsets import formset_factory +from django.http import HttpResponse +from django.template import Template, Context + +from django.contrib.auth.models import User + +from django.contrib.formtools.wizard.views import NamedUrlWizardView + +class Page1(forms.Form): + name = forms.CharField(max_length=100) + user = forms.ModelChoiceField(queryset=User.objects.all()) + thirsty = forms.NullBooleanField() + +class Page2(forms.Form): + address1 = forms.CharField(max_length=100) + address2 = forms.CharField(max_length=100) + +class Page3(forms.Form): + random_crap = forms.CharField(max_length=100) + +Page4 = formset_factory(Page3, extra=2) + +class ContactWizard(NamedUrlWizardView): + def done(self, form_list, **kwargs): + c = Context({ + 'form_list': [x.cleaned_data for x in form_list], + 'all_cleaned_data': self.get_all_cleaned_data() + }) + + for form in self.form_list.keys(): + c[form] = self.get_cleaned_data_for_step(form) + + c['this_will_fail'] = self.get_cleaned_data_for_step('this_will_fail') + return HttpResponse(Template('').render(c)) + +class SessionContactWizard(ContactWizard): + storage_name = 'django.contrib.formtools.wizard.storage.session.SessionStorage' + +class CookieContactWizard(ContactWizard): + storage_name = 'django.contrib.formtools.wizard.storage.cookie.CookieStorage' + diff --git a/django/contrib/formtools/wizard/tests/namedwizardtests/tests.py b/django/contrib/formtools/wizard/tests/namedwizardtests/tests.py new file mode 100644 index 0000000000..cc442d7294 --- /dev/null +++ b/django/contrib/formtools/wizard/tests/namedwizardtests/tests.py @@ -0,0 +1,355 @@ +import os + +from django.core.urlresolvers import reverse +from django.http import QueryDict +from django.test import TestCase +from django.conf import settings + +from django.contrib.auth.models import User + +from django.contrib.formtools import wizard + +from django.contrib.formtools.wizard.views import (NamedUrlSessionWizardView, + NamedUrlCookieWizardView) +from django.contrib.formtools.wizard.tests.formtests import (get_request, + Step1, + Step2) + +class NamedWizardTests(object): + urls = 'django.contrib.formtools.wizard.tests.namedwizardtests.urls' + + def setUp(self): + self.testuser, created = User.objects.get_or_create(username='testuser1') + self.wizard_step_data[0]['form1-user'] = self.testuser.pk + + wizard_template_dirs = [os.path.join(os.path.dirname(wizard.__file__), 'templates')] + settings.TEMPLATE_DIRS = list(settings.TEMPLATE_DIRS) + wizard_template_dirs + + def tearDown(self): + del settings.TEMPLATE_DIRS[-1] + + def test_initial_call(self): + response = self.client.get(reverse('%s_start' % self.wizard_urlname)) + self.assertEqual(response.status_code, 302) + response = self.client.get(response['Location']) + self.assertEqual(response.status_code, 200) + wizard = response.context['wizard'] + self.assertEqual(wizard['steps'].current, 'form1') + self.assertEqual(wizard['steps'].step0, 0) + self.assertEqual(wizard['steps'].step1, 1) + self.assertEqual(wizard['steps'].last, 'form4') + self.assertEqual(wizard['steps'].prev, None) + self.assertEqual(wizard['steps'].next, 'form2') + self.assertEqual(wizard['steps'].count, 4) + + def test_initial_call_with_params(self): + get_params = {'getvar1': 'getval1', 'getvar2': 'getval2'} + response = self.client.get(reverse('%s_start' % self.wizard_urlname), + get_params) + self.assertEqual(response.status_code, 302) + + # Test for proper redirect GET parameters + location = response['Location'] + self.assertNotEqual(location.find('?'), -1) + querydict = QueryDict(location[location.find('?') + 1:]) + self.assertEqual(dict(querydict.items()), get_params) + + def test_form_post_error(self): + response = self.client.post( + reverse(self.wizard_urlname, kwargs={'step': 'form1'}), + self.wizard_step_1_data) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + self.assertEqual(response.context['wizard']['form'].errors, + {'name': [u'This field is required.'], + 'user': [u'This field is required.']}) + + def test_form_post_success(self): + response = self.client.post( + reverse(self.wizard_urlname, kwargs={'step': 'form1'}), + self.wizard_step_data[0]) + response = self.client.get(response['Location']) + + self.assertEqual(response.status_code, 200) + wizard = response.context['wizard'] + self.assertEqual(wizard['steps'].current, 'form2') + self.assertEqual(wizard['steps'].step0, 1) + self.assertEqual(wizard['steps'].prev, 'form1') + self.assertEqual(wizard['steps'].next, 'form3') + + def test_form_stepback(self): + response = self.client.get( + reverse(self.wizard_urlname, kwargs={'step': 'form1'})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + + response = self.client.post( + reverse(self.wizard_urlname, kwargs={'step': 'form1'}), + self.wizard_step_data[0]) + response = self.client.get(response['Location']) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form2') + + response = self.client.post( + reverse(self.wizard_urlname, kwargs={ + 'step': response.context['wizard']['steps'].current + }), {'wizard_prev_step': response.context['wizard']['steps'].prev}) + response = self.client.get(response['Location']) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + + def test_form_jump(self): + response = self.client.get( + reverse(self.wizard_urlname, kwargs={'step': 'form1'})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + + response = self.client.get( + reverse(self.wizard_urlname, kwargs={'step': 'form3'})) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form3') + + def test_form_finish(self): + response = self.client.get( + reverse(self.wizard_urlname, kwargs={'step': 'form1'})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[0]) + response = self.client.get(response['Location']) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form2') + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[1]) + response = self.client.get(response['Location']) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form3') + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[2]) + response = self.client.get(response['Location']) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form4') + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[3]) + response = self.client.get(response['Location']) + self.assertEqual(response.status_code, 200) + + self.assertEqual(response.context['form_list'], [ + {'name': u'Pony', 'thirsty': True, 'user': self.testuser}, + {'address1': u'123 Main St', 'address2': u'Djangoland'}, + {'random_crap': u'blah blah'}, + [{'random_crap': u'blah blah'}, {'random_crap': u'blah blah'}]]) + + def test_cleaned_data(self): + response = self.client.get( + reverse(self.wizard_urlname, kwargs={'step': 'form1'})) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[0]) + response = self.client.get(response['Location']) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[1]) + response = self.client.get(response['Location']) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[2]) + response = self.client.get(response['Location']) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[3]) + response = self.client.get(response['Location']) + self.assertEqual(response.status_code, 200) + + self.assertEqual( + response.context['all_cleaned_data'], + {'name': u'Pony', 'thirsty': True, 'user': self.testuser, + 'address1': u'123 Main St', 'address2': u'Djangoland', + 'random_crap': u'blah blah', 'formset-form4': [ + {'random_crap': u'blah blah'}, + {'random_crap': u'blah blah'} + ]}) + + def test_manipulated_data(self): + response = self.client.get( + reverse(self.wizard_urlname, kwargs={'step': 'form1'})) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[0]) + response = self.client.get(response['Location']) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[1]) + response = self.client.get(response['Location']) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[2]) + loc = response['Location'] + response = self.client.get(loc) + self.assertEqual(response.status_code, 200, loc) + + self.client.cookies.pop('sessionid', None) + self.client.cookies.pop('wizard_cookie_contact_wizard', None) + + response = self.client.post( + reverse(self.wizard_urlname, + kwargs={'step': response.context['wizard']['steps'].current}), + self.wizard_step_data[3]) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + + def test_form_reset(self): + response = self.client.post( + reverse(self.wizard_urlname, kwargs={'step': 'form1'}), + self.wizard_step_data[0]) + response = self.client.get(response['Location']) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form2') + + response = self.client.get( + '%s?reset=1' % reverse('%s_start' % self.wizard_urlname)) + self.assertEqual(response.status_code, 302) + + response = self.client.get(response['Location']) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + +class NamedSessionWizardTests(NamedWizardTests, TestCase): + wizard_urlname = 'nwiz_session' + wizard_step_1_data = { + 'session_contact_wizard-current_step': 'form1', + } + wizard_step_data = ( + { + 'form1-name': 'Pony', + 'form1-thirsty': '2', + 'session_contact_wizard-current_step': 'form1', + }, + { + 'form2-address1': '123 Main St', + 'form2-address2': 'Djangoland', + 'session_contact_wizard-current_step': 'form2', + }, + { + 'form3-random_crap': 'blah blah', + 'session_contact_wizard-current_step': 'form3', + }, + { + 'form4-INITIAL_FORMS': '0', + 'form4-TOTAL_FORMS': '2', + 'form4-MAX_NUM_FORMS': '0', + 'form4-0-random_crap': 'blah blah', + 'form4-1-random_crap': 'blah blah', + 'session_contact_wizard-current_step': 'form4', + } + ) + +class NamedCookieWizardTests(NamedWizardTests, TestCase): + wizard_urlname = 'nwiz_cookie' + wizard_step_1_data = { + 'cookie_contact_wizard-current_step': 'form1', + } + wizard_step_data = ( + { + 'form1-name': 'Pony', + 'form1-thirsty': '2', + 'cookie_contact_wizard-current_step': 'form1', + }, + { + 'form2-address1': '123 Main St', + 'form2-address2': 'Djangoland', + 'cookie_contact_wizard-current_step': 'form2', + }, + { + 'form3-random_crap': 'blah blah', + 'cookie_contact_wizard-current_step': 'form3', + }, + { + 'form4-INITIAL_FORMS': '0', + 'form4-TOTAL_FORMS': '2', + 'form4-MAX_NUM_FORMS': '0', + 'form4-0-random_crap': 'blah blah', + 'form4-1-random_crap': 'blah blah', + 'cookie_contact_wizard-current_step': 'form4', + } + ) + + +class NamedFormTests(object): + urls = 'django.contrib.formtools.wizard.tests.namedwizardtests.urls' + + def test_revalidation(self): + request = get_request() + + testform = self.formwizard_class.as_view( + [('start', Step1), ('step2', Step2)], + url_name=self.wizard_urlname) + response, instance = testform(request, step='done') + + instance.render_done(None) + self.assertEqual(instance.storage.current_step, 'start') + +class TestNamedUrlSessionFormWizard(NamedUrlSessionWizardView): + + def dispatch(self, request, *args, **kwargs): + response = super(TestNamedUrlSessionFormWizard, self).dispatch(request, *args, **kwargs) + return response, self + +class TestNamedUrlCookieFormWizard(NamedUrlCookieWizardView): + + def dispatch(self, request, *args, **kwargs): + response = super(TestNamedUrlCookieFormWizard, self).dispatch(request, *args, **kwargs) + return response, self + + +class NamedSessionFormTests(NamedFormTests, TestCase): + formwizard_class = TestNamedUrlSessionFormWizard + wizard_urlname = 'nwiz_session' + + +class NamedCookieFormTests(NamedFormTests, TestCase): + formwizard_class = TestNamedUrlCookieFormWizard + wizard_urlname = 'nwiz_cookie' diff --git a/django/contrib/formtools/wizard/tests/namedwizardtests/urls.py b/django/contrib/formtools/wizard/tests/namedwizardtests/urls.py new file mode 100644 index 0000000000..a97ca98c1b --- /dev/null +++ b/django/contrib/formtools/wizard/tests/namedwizardtests/urls.py @@ -0,0 +1,24 @@ +from django.conf.urls.defaults import * +from django.contrib.formtools.wizard.tests.namedwizardtests.forms import ( + SessionContactWizard, CookieContactWizard, Page1, Page2, Page3, Page4) + +def get_named_session_wizard(): + return SessionContactWizard.as_view( + [('form1', Page1), ('form2', Page2), ('form3', Page3), ('form4', Page4)], + url_name='nwiz_session', + done_step_name='nwiz_session_done' + ) + +def get_named_cookie_wizard(): + return CookieContactWizard.as_view( + [('form1', Page1), ('form2', Page2), ('form3', Page3), ('form4', Page4)], + url_name='nwiz_cookie', + done_step_name='nwiz_cookie_done' + ) + +urlpatterns = patterns('', + url(r'^nwiz_session/(?P.+)/$', get_named_session_wizard(), name='nwiz_session'), + url(r'^nwiz_session/$', get_named_session_wizard(), name='nwiz_session_start'), + url(r'^nwiz_cookie/(?P.+)/$', get_named_cookie_wizard(), name='nwiz_cookie'), + url(r'^nwiz_cookie/$', get_named_cookie_wizard(), name='nwiz_cookie_start'), +) diff --git a/django/contrib/formtools/wizard/tests/sessionstoragetests.py b/django/contrib/formtools/wizard/tests/sessionstoragetests.py new file mode 100644 index 0000000000..c643921a40 --- /dev/null +++ b/django/contrib/formtools/wizard/tests/sessionstoragetests.py @@ -0,0 +1,8 @@ +from django.test import TestCase + +from django.contrib.formtools.wizard.tests.storagetests import TestStorage +from django.contrib.formtools.wizard.storage.session import SessionStorage + +class TestSessionStorage(TestStorage, TestCase): + def get_storage(self): + return SessionStorage diff --git a/django/contrib/formtools/wizard/tests/storagetests.py b/django/contrib/formtools/wizard/tests/storagetests.py new file mode 100644 index 0000000000..fec4fae0ef --- /dev/null +++ b/django/contrib/formtools/wizard/tests/storagetests.py @@ -0,0 +1,76 @@ +from datetime import datetime + +from django.http import HttpRequest +from django.conf import settings +from django.utils.importlib import import_module + +from django.contrib.auth.models import User + +def get_request(): + request = HttpRequest() + engine = import_module(settings.SESSION_ENGINE) + request.session = engine.SessionStore(None) + return request + +class TestStorage(object): + def setUp(self): + self.testuser, created = User.objects.get_or_create(username='testuser1') + + def test_current_step(self): + request = get_request() + storage = self.get_storage()('wizard1', request, None) + my_step = 2 + + self.assertEqual(storage.current_step, None) + + storage.current_step = my_step + self.assertEqual(storage.current_step, my_step) + + storage.reset() + self.assertEqual(storage.current_step, None) + + storage.current_step = my_step + storage2 = self.get_storage()('wizard2', request, None) + self.assertEqual(storage2.current_step, None) + + def test_step_data(self): + request = get_request() + storage = self.get_storage()('wizard1', request, None) + step1 = 'start' + step_data1 = {'field1': 'data1', + 'field2': 'data2', + 'field3': datetime.now(), + 'field4': self.testuser} + + self.assertEqual(storage.get_step_data(step1), None) + + storage.set_step_data(step1, step_data1) + self.assertEqual(storage.get_step_data(step1), step_data1) + + storage.reset() + self.assertEqual(storage.get_step_data(step1), None) + + storage.set_step_data(step1, step_data1) + storage2 = self.get_storage()('wizard2', request, None) + self.assertEqual(storage2.get_step_data(step1), None) + + def test_extra_context(self): + request = get_request() + storage = self.get_storage()('wizard1', request, None) + extra_context = {'key1': 'data1', + 'key2': 'data2', + 'key3': datetime.now(), + 'key4': self.testuser} + + self.assertEqual(storage.extra_data, {}) + + storage.extra_data = extra_context + self.assertEqual(storage.extra_data, extra_context) + + storage.reset() + self.assertEqual(storage.extra_data, {}) + + storage.extra_data = extra_context + storage2 = self.get_storage()('wizard2', request, None) + self.assertEqual(storage2.extra_data, {}) + diff --git a/django/contrib/formtools/wizard/tests/wizardtests/__init__.py b/django/contrib/formtools/wizard/tests/wizardtests/__init__.py new file mode 100644 index 0000000000..9173cd86d9 --- /dev/null +++ b/django/contrib/formtools/wizard/tests/wizardtests/__init__.py @@ -0,0 +1 @@ +from django.contrib.formtools.wizard.tests.wizardtests.tests import * \ No newline at end of file diff --git a/django/contrib/formtools/wizard/tests/wizardtests/forms.py b/django/contrib/formtools/wizard/tests/wizardtests/forms.py new file mode 100644 index 0000000000..726d74abee --- /dev/null +++ b/django/contrib/formtools/wizard/tests/wizardtests/forms.py @@ -0,0 +1,57 @@ +import tempfile + +from django import forms +from django.core.files.storage import FileSystemStorage +from django.forms.formsets import formset_factory +from django.http import HttpResponse +from django.template import Template, Context + +from django.contrib.auth.models import User + +from django.contrib.formtools.wizard.views import WizardView + +temp_storage_location = tempfile.mkdtemp() +temp_storage = FileSystemStorage(location=temp_storage_location) + +class Page1(forms.Form): + name = forms.CharField(max_length=100) + user = forms.ModelChoiceField(queryset=User.objects.all()) + thirsty = forms.NullBooleanField() + +class Page2(forms.Form): + address1 = forms.CharField(max_length=100) + address2 = forms.CharField(max_length=100) + file1 = forms.FileField() + +class Page3(forms.Form): + random_crap = forms.CharField(max_length=100) + +Page4 = formset_factory(Page3, extra=2) + +class ContactWizard(WizardView): + file_storage = temp_storage + + def done(self, form_list, **kwargs): + c = Context({ + 'form_list': [x.cleaned_data for x in form_list], + 'all_cleaned_data': self.get_all_cleaned_data() + }) + + for form in self.form_list.keys(): + c[form] = self.get_cleaned_data_for_step(form) + + c['this_will_fail'] = self.get_cleaned_data_for_step('this_will_fail') + return HttpResponse(Template('').render(c)) + + def get_context_data(self, form, **kwargs): + context = super(ContactWizard, self).get_context_data(form, **kwargs) + if self.storage.current_step == 'form2': + context.update({'another_var': True}) + return context + +class SessionContactWizard(ContactWizard): + storage_name = 'django.contrib.formtools.wizard.storage.session.SessionStorage' + +class CookieContactWizard(ContactWizard): + storage_name = 'django.contrib.formtools.wizard.storage.cookie.CookieStorage' + diff --git a/django/contrib/formtools/wizard/tests/wizardtests/tests.py b/django/contrib/formtools/wizard/tests/wizardtests/tests.py new file mode 100644 index 0000000000..f64b2ba303 --- /dev/null +++ b/django/contrib/formtools/wizard/tests/wizardtests/tests.py @@ -0,0 +1,248 @@ +import os + +from django.test import TestCase +from django.conf import settings +from django.contrib.auth.models import User + +from django.contrib.formtools import wizard + +class WizardTests(object): + urls = 'django.contrib.formtools.wizard.tests.wizardtests.urls' + + def setUp(self): + self.testuser, created = User.objects.get_or_create(username='testuser1') + self.wizard_step_data[0]['form1-user'] = self.testuser.pk + + wizard_template_dirs = [os.path.join(os.path.dirname(wizard.__file__), 'templates')] + settings.TEMPLATE_DIRS = list(settings.TEMPLATE_DIRS) + wizard_template_dirs + + def tearDown(self): + del settings.TEMPLATE_DIRS[-1] + + def test_initial_call(self): + response = self.client.get(self.wizard_url) + wizard = response.context['wizard'] + self.assertEqual(response.status_code, 200) + self.assertEqual(wizard['steps'].current, 'form1') + self.assertEqual(wizard['steps'].step0, 0) + self.assertEqual(wizard['steps'].step1, 1) + self.assertEqual(wizard['steps'].last, 'form4') + self.assertEqual(wizard['steps'].prev, None) + self.assertEqual(wizard['steps'].next, 'form2') + self.assertEqual(wizard['steps'].count, 4) + + def test_form_post_error(self): + response = self.client.post(self.wizard_url, self.wizard_step_1_data) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + self.assertEqual(response.context['wizard']['form'].errors, + {'name': [u'This field is required.'], + 'user': [u'This field is required.']}) + + def test_form_post_success(self): + response = self.client.post(self.wizard_url, self.wizard_step_data[0]) + wizard = response.context['wizard'] + self.assertEqual(response.status_code, 200) + self.assertEqual(wizard['steps'].current, 'form2') + self.assertEqual(wizard['steps'].step0, 1) + self.assertEqual(wizard['steps'].prev, 'form1') + self.assertEqual(wizard['steps'].next, 'form3') + + def test_form_stepback(self): + response = self.client.get(self.wizard_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + + response = self.client.post(self.wizard_url, self.wizard_step_data[0]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form2') + + response = self.client.post(self.wizard_url, { + 'wizard_prev_step': response.context['wizard']['steps'].prev}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + + def test_template_context(self): + response = self.client.get(self.wizard_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + self.assertEqual(response.context.get('another_var', None), None) + + response = self.client.post(self.wizard_url, self.wizard_step_data[0]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form2') + self.assertEqual(response.context.get('another_var', None), True) + + def test_form_finish(self): + response = self.client.get(self.wizard_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + + response = self.client.post(self.wizard_url, self.wizard_step_data[0]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form2') + + post_data = self.wizard_step_data[1] + post_data['form2-file1'] = open(__file__) + response = self.client.post(self.wizard_url, post_data) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form3') + + response = self.client.post(self.wizard_url, self.wizard_step_data[2]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form4') + + response = self.client.post(self.wizard_url, self.wizard_step_data[3]) + self.assertEqual(response.status_code, 200) + + all_data = response.context['form_list'] + self.assertEqual(all_data[1]['file1'].read(), open(__file__).read()) + del all_data[1]['file1'] + self.assertEqual(all_data, [ + {'name': u'Pony', 'thirsty': True, 'user': self.testuser}, + {'address1': u'123 Main St', 'address2': u'Djangoland'}, + {'random_crap': u'blah blah'}, + [{'random_crap': u'blah blah'}, + {'random_crap': u'blah blah'}]]) + + def test_cleaned_data(self): + response = self.client.get(self.wizard_url) + self.assertEqual(response.status_code, 200) + + response = self.client.post(self.wizard_url, self.wizard_step_data[0]) + self.assertEqual(response.status_code, 200) + + post_data = self.wizard_step_data[1] + post_data['form2-file1'] = open(__file__) + response = self.client.post(self.wizard_url, post_data) + self.assertEqual(response.status_code, 200) + + response = self.client.post(self.wizard_url, self.wizard_step_data[2]) + self.assertEqual(response.status_code, 200) + + response = self.client.post(self.wizard_url, self.wizard_step_data[3]) + self.assertEqual(response.status_code, 200) + + all_data = response.context['all_cleaned_data'] + self.assertEqual(all_data['file1'].read(), open(__file__).read()) + del all_data['file1'] + self.assertEqual(all_data, { + 'name': u'Pony', 'thirsty': True, 'user': self.testuser, + 'address1': u'123 Main St', 'address2': u'Djangoland', + 'random_crap': u'blah blah', 'formset-form4': [ + {'random_crap': u'blah blah'}, + {'random_crap': u'blah blah'}]}) + + def test_manipulated_data(self): + response = self.client.get(self.wizard_url) + self.assertEqual(response.status_code, 200) + + response = self.client.post(self.wizard_url, self.wizard_step_data[0]) + self.assertEqual(response.status_code, 200) + + post_data = self.wizard_step_data[1] + post_data['form2-file1'] = open(__file__) + response = self.client.post(self.wizard_url, post_data) + self.assertEqual(response.status_code, 200) + + response = self.client.post(self.wizard_url, self.wizard_step_data[2]) + self.assertEqual(response.status_code, 200) + self.client.cookies.pop('sessionid', None) + self.client.cookies.pop('wizard_cookie_contact_wizard', None) + + response = self.client.post(self.wizard_url, self.wizard_step_data[3]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + + def test_form_refresh(self): + response = self.client.get(self.wizard_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form1') + + response = self.client.post(self.wizard_url, self.wizard_step_data[0]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form2') + + response = self.client.post(self.wizard_url, self.wizard_step_data[0]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form2') + + post_data = self.wizard_step_data[1] + post_data['form2-file1'] = open(__file__) + response = self.client.post(self.wizard_url, post_data) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form3') + + response = self.client.post(self.wizard_url, self.wizard_step_data[2]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form4') + + response = self.client.post(self.wizard_url, self.wizard_step_data[0]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['wizard']['steps'].current, 'form2') + + response = self.client.post(self.wizard_url, self.wizard_step_data[3]) + self.assertEqual(response.status_code, 200) + + +class SessionWizardTests(WizardTests, TestCase): + wizard_url = '/wiz_session/' + wizard_step_1_data = { + 'session_contact_wizard-current_step': 'form1', + } + wizard_step_data = ( + { + 'form1-name': 'Pony', + 'form1-thirsty': '2', + 'session_contact_wizard-current_step': 'form1', + }, + { + 'form2-address1': '123 Main St', + 'form2-address2': 'Djangoland', + 'session_contact_wizard-current_step': 'form2', + }, + { + 'form3-random_crap': 'blah blah', + 'session_contact_wizard-current_step': 'form3', + }, + { + 'form4-INITIAL_FORMS': '0', + 'form4-TOTAL_FORMS': '2', + 'form4-MAX_NUM_FORMS': '0', + 'form4-0-random_crap': 'blah blah', + 'form4-1-random_crap': 'blah blah', + 'session_contact_wizard-current_step': 'form4', + } + ) + +class CookieWizardTests(WizardTests, TestCase): + wizard_url = '/wiz_cookie/' + wizard_step_1_data = { + 'cookie_contact_wizard-current_step': 'form1', + } + wizard_step_data = ( + { + 'form1-name': 'Pony', + 'form1-thirsty': '2', + 'cookie_contact_wizard-current_step': 'form1', + }, + { + 'form2-address1': '123 Main St', + 'form2-address2': 'Djangoland', + 'cookie_contact_wizard-current_step': 'form2', + }, + { + 'form3-random_crap': 'blah blah', + 'cookie_contact_wizard-current_step': 'form3', + }, + { + 'form4-INITIAL_FORMS': '0', + 'form4-TOTAL_FORMS': '2', + 'form4-MAX_NUM_FORMS': '0', + 'form4-0-random_crap': 'blah blah', + 'form4-1-random_crap': 'blah blah', + 'cookie_contact_wizard-current_step': 'form4', + } + ) + + diff --git a/django/contrib/formtools/wizard/tests/wizardtests/urls.py b/django/contrib/formtools/wizard/tests/wizardtests/urls.py new file mode 100644 index 0000000000..e305397a37 --- /dev/null +++ b/django/contrib/formtools/wizard/tests/wizardtests/urls.py @@ -0,0 +1,16 @@ +from django.conf.urls.defaults import * +from django.contrib.formtools.wizard.tests.wizardtests.forms import ( + SessionContactWizard, CookieContactWizard, Page1, Page2, Page3, Page4) + +urlpatterns = patterns('', + url(r'^wiz_session/$', SessionContactWizard.as_view( + [('form1', Page1), + ('form2', Page2), + ('form3', Page3), + ('form4', Page4)])), + url(r'^wiz_cookie/$', CookieContactWizard.as_view( + [('form1', Page1), + ('form2', Page2), + ('form3', Page3), + ('form4', Page4)])), +) diff --git a/django/contrib/formtools/wizard/views.py b/django/contrib/formtools/wizard/views.py new file mode 100644 index 0000000000..c0f8b3b832 --- /dev/null +++ b/django/contrib/formtools/wizard/views.py @@ -0,0 +1,684 @@ +import copy +import re + +from django import forms +from django.shortcuts import redirect +from django.core.urlresolvers import reverse +from django.forms import formsets, ValidationError +from django.views.generic import TemplateView +from django.utils.datastructures import SortedDict +from django.utils.decorators import classonlymethod + +from django.contrib.formtools.wizard.storage import get_storage +from django.contrib.formtools.wizard.storage.exceptions import NoFileStorageConfigured +from django.contrib.formtools.wizard.forms import ManagementForm + + +def normalize_name(name): + new = re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', '_\\1', name) + return new.lower().strip('_') + +class StepsHelper(object): + + def __init__(self, wizard): + self._wizard = wizard + + def __dir__(self): + return self.all + + def __len__(self): + return self.count + + def __repr__(self): + return '' % (self._wizard, self.all) + + @property + def all(self): + "Returns the names of all steps/forms." + return self._wizard.get_form_list().keys() + + @property + def count(self): + "Returns the total number of steps/forms in this the wizard." + return len(self.all) + + @property + def current(self): + """ + Returns the current step. If no current step is stored in the + storage backend, the first step will be returned. + """ + return self._wizard.storage.current_step or self.first + + @property + def first(self): + "Returns the name of the first step." + return self.all[0] + + @property + def last(self): + "Returns the name of the last step." + return self.all[-1] + + @property + def next(self): + "Returns the next step." + return self._wizard.get_next_step() + + @property + def prev(self): + "Returns the previous step." + return self._wizard.get_prev_step() + + @property + def index(self): + "Returns the index for the current step." + return self._wizard.get_step_index() + + @property + def step0(self): + return int(self.index) + + @property + def step1(self): + return int(self.index) + 1 + + +class WizardView(TemplateView): + """ + The WizardView is used to create multi-page forms and handles all the + storage and validation stuff. The wizard is based on Django's generic + class based views. + """ + storage_name = None + form_list = None + initial_dict = None + instance_dict = None + condition_dict = None + template_name = 'formtools/wizard/wizard_form.html' + + def __repr__(self): + return '<%s: forms: %s>' % (self.__class__.__name__, self.form_list) + + @classonlymethod + def as_view(cls, *args, **kwargs): + """ + This method is used within urls.py to create unique formwizard + instances for every request. We need to override this method because + we add some kwargs which are needed to make the formwizard usable. + """ + initkwargs = cls.get_initkwargs(*args, **kwargs) + return super(WizardView, cls).as_view(**initkwargs) + + @classmethod + def get_initkwargs(cls, form_list, + initial_dict=None, instance_dict=None, condition_dict=None): + """ + Creates a dict with all needed parameters for the form wizard instances. + + * `form_list` - is a list of forms. The list entries can be single form + classes or tuples of (`step_name`, `form_class`). If you pass a list + of forms, the formwizard will convert the class list to + (`zero_based_counter`, `form_class`). This is needed to access the + form for a specific step. + * `initial_dict` - contains a dictionary of initial data dictionaries. + The key should be equal to the `step_name` in the `form_list` (or + the str of the zero based counter - if no step_names added in the + `form_list`) + * `instance_dict` - contains a dictionary of instance objects. This list + is only used when `ModelForm`s are used. The key should be equal to + the `step_name` in the `form_list`. Same rules as for `initial_dict` + apply. + * `condition_dict` - contains a dictionary of boolean values or + callables. If the value of for a specific `step_name` is callable it + will be called with the formwizard instance as the only argument. + If the return value is true, the step's form will be used. + """ + kwargs = { + 'initial_dict': initial_dict or {}, + 'instance_dict': instance_dict or {}, + 'condition_dict': condition_dict or {}, + } + init_form_list = SortedDict() + + assert len(form_list) > 0, 'at least one form is needed' + + # walk through the passed form list + for i, form in enumerate(form_list): + if isinstance(form, (list, tuple)): + # if the element is a tuple, add the tuple to the new created + # sorted dictionary. + init_form_list[unicode(form[0])] = form[1] + else: + # if not, add the form with a zero based counter as unicode + init_form_list[unicode(i)] = form + + # walk through the ne created list of forms + for form in init_form_list.itervalues(): + if issubclass(form, formsets.BaseFormSet): + # if the element is based on BaseFormSet (FormSet/ModelFormSet) + # we need to override the form variable. + form = form.form + # check if any form contains a FileField, if yes, we need a + # file_storage added to the formwizard (by subclassing). + for field in form.base_fields.itervalues(): + if (isinstance(field, forms.FileField) and + not hasattr(cls, 'file_storage')): + raise NoFileStorageConfigured + + # build the kwargs for the formwizard instances + kwargs['form_list'] = init_form_list + return kwargs + + def get_wizard_name(self): + return normalize_name(self.__class__.__name__) + + def get_prefix(self): + # TODO: Add some kind of unique id to prefix + return self.wizard_name + + def get_form_list(self): + """ + This method returns a form_list based on the initial form list but + checks if there is a condition method/value in the condition_list. + If an entry exists in the condition list, it will call/read the value + and respect the result. (True means add the form, False means ignore + the form) + + The form_list is always generated on the fly because condition methods + could use data from other (maybe previous forms). + """ + form_list = SortedDict() + for form_key, form_class in self.form_list.iteritems(): + # try to fetch the value from condition list, by default, the form + # gets passed to the new list. + condition = self.condition_dict.get(form_key, True) + if callable(condition): + # call the value if needed, passes the current instance. + condition = condition(self) + if condition: + form_list[form_key] = form_class + return form_list + + 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`. + + After processing the request using the `dispatch` method, the + response gets updated by the storage engine (for example add cookies). + """ + # add the storage engine to the current formwizard instance + self.wizard_name = self.get_wizard_name() + self.prefix = self.get_prefix() + self.storage = get_storage(self.storage_name, self.prefix, request, + getattr(self, 'file_storage', None)) + self.steps = StepsHelper(self) + response = super(WizardView, self).dispatch(request, *args, **kwargs) + + # update the response (e.g. adding cookies) + self.storage.update_response(response) + return response + + 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() + + # reset the current step to the first step. + self.storage.current_step = self.steps.first + return self.render(self.get_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) + """ + # Look for a wizard_prev_step element in the posted data which + # contains a valid step name. If one was found, render the requested + # form. (This makes stepping back a lot easier). + wizard_prev_step = self.request.POST.get('wizard_prev_step', None) + if wizard_prev_step and wizard_prev_step in self.get_form_list(): + self.storage.current_step = wizard_prev_step + form = self.get_form( + data=self.storage.get_step_data(self.steps.current), + files=self.storage.get_step_files(self.steps.current)) + return self.render(form) + + # 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.steps.current and + self.storage.current_step is not None): + # 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, files=self.request.FILES) + + # and try to validate + if form.is_valid(): + # if the form is valid, store the cleaned data and files. + self.storage.set_step_data(self.steps.current, self.process_step(form)) + self.storage.set_step_files(self.steps.current, self.process_step_files(form)) + + # check if the current step is the last step + if self.steps.current == self.steps.last: + # no more steps, render done view + return self.render_done(form, **kwargs) + else: + # proceed to the next step + return self.render_next_step(form) + return self.render(form) + + def render_next_step(self, form, **kwargs): + """ + THis method gets called when the next step/form should be rendered. + `form` contains the last/current form. + """ + # get the form instance based on the data from the storage backend + # (if available). + next_step = self.steps.next + new_form = self.get_form(next_step, + data=self.storage.get_step_data(next_step), + files=self.storage.get_step_files(next_step)) + + # change the stored current step + self.storage.current_step = next_step + return self.render(new_form, **kwargs) + + def render_done(self, form, **kwargs): + """ + This method gets called when all forms passed. The method should also + re-validate all steps to prevent manipulation. If any form don't + validate, `render_revalidation_failure` should get called. + If everything is fine call `done`. + """ + final_form_list = [] + # walk through the form list and try to validate the data again. + for form_key in self.get_form_list(): + form_obj = self.get_form(step=form_key, + data=self.storage.get_step_data(form_key), + files=self.storage.get_step_files(form_key)) + if not form_obj.is_valid(): + return self.render_revalidation_failure(form_key, form_obj, **kwargs) + final_form_list.append(form_obj) + + # 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(final_form_list, **kwargs) + self.storage.reset() + return done_response + + def get_form_prefix(self, step=None, form=None): + """ + Returns the prefix which will be used when calling the actual form for + the given step. `step` contains the step-name, `form` the form which + will be called with the returned prefix. + + If no step is given, the form_prefix will determine the current step + automatically. + """ + if step is None: + step = self.steps.current + return str(step) + + def get_form_initial(self, step): + """ + Returns a dictionary which will be passed to the form for `step` + as `initial`. If no initial data was provied while initializing the + form wizard, a empty dictionary will be returned. + """ + return self.initial_dict.get(step, {}) + + def get_form_instance(self, step): + """ + Returns a object which will be passed to the form for `step` + as `instance`. If no instance object was provied while initializing + the form wizard, None be returned. + """ + return self.instance_dict.get(step, None) + + def get_form(self, step=None, data=None, files=None): + """ + Constructs the form for a given `step`. If no `step` is defined, the + current step will be determined automatically. + + The form will be initialized using the `data` argument to prefill the + new form. If needed, instance or queryset (for `ModelForm` or + `ModelFormSet`) will be added too. + """ + if step is None: + step = self.steps.current + # prepare the kwargs for the form instance. + kwargs = { + 'data': data, + 'files': files, + 'prefix': self.get_form_prefix(step, self.form_list[step]), + 'initial': self.get_form_initial(step), + } + if issubclass(self.form_list[step], forms.ModelForm): + # If the form is based on ModelForm, add instance if available. + kwargs.update({'instance': self.get_form_instance(step)}) + elif issubclass(self.form_list[step], forms.models.BaseModelFormSet): + # If the form is based on ModelFormSet, add queryset if available. + kwargs.update({'queryset': self.get_form_instance(step)}) + return self.form_list[step](**kwargs) + + def process_step(self, form): + """ + This method is used to postprocess the form data. By default, it + returns the raw `form.data` dictionary. + """ + return self.get_form_step_data(form) + + def process_step_files(self, form): + """ + This method is used to postprocess the form files. By default, it + returns the raw `form.files` dictionary. + """ + return self.get_form_step_files(form) + + def render_revalidation_failure(self, step, form, **kwargs): + """ + Gets called when a form doesn't validate when rendering the done + view. By default, it changed the current step to failing forms step + and renders the form. + """ + self.storage.current_step = step + return self.render(form, **kwargs) + + def get_form_step_data(self, form): + """ + Is used to return the raw form data. You may use this method to + manipulate the data. + """ + return form.data + + def get_form_step_files(self, form): + """ + Is used to return the raw form files. You may use this method to + manipulate the data. + """ + return form.files + + def get_all_cleaned_data(self): + """ + Returns a merged dictionary of all step cleaned_data dictionaries. + If a step contains a `FormSet`, the key will be prefixed with formset + and contain a list of the formset' cleaned_data dictionaries. + """ + cleaned_data = {} + for form_key in self.get_form_list(): + form_obj = self.get_form( + step=form_key, + data=self.storage.get_step_data(form_key), + files=self.storage.get_step_files(form_key) + ) + if form_obj.is_valid(): + if isinstance(form_obj.cleaned_data, (tuple, list)): + cleaned_data.update({ + 'formset-%s' % form_key: form_obj.cleaned_data + }) + else: + cleaned_data.update(form_obj.cleaned_data) + return cleaned_data + + def get_cleaned_data_for_step(self, step): + """ + Returns the cleaned data for a given `step`. Before returning the + cleaned data, the stored values are being revalidated through the + form. If the data doesn't validate, None will be returned. + """ + if step in self.form_list: + form_obj = self.get_form(step=step, + data=self.storage.get_step_data(step), + files=self.storage.get_step_files(step)) + if form_obj.is_valid(): + return form_obj.cleaned_data + return None + + def get_next_step(self, step=None): + """ + Returns the next step after the given `step`. If no more steps are + available, None will be returned. If the `step` argument is None, the + current step will be determined automatically. + """ + if step is None: + step = self.steps.current + form_list = self.get_form_list() + key = form_list.keyOrder.index(step) + 1 + if len(form_list.keyOrder) > key: + return form_list.keyOrder[key] + return None + + def get_prev_step(self, step=None): + """ + Returns the previous step before the given `step`. If there are no + steps available, None will be returned. If the `step` argument is + None, the current step will be determined automatically. + """ + if step is None: + step = self.steps.current + form_list = self.get_form_list() + key = form_list.keyOrder.index(step) - 1 + if key >= 0: + return form_list.keyOrder[key] + return None + + def get_step_index(self, step=None): + """ + Returns the index for the given `step` name. If no step is given, + the current step will be used to get the index. + """ + if step is None: + step = self.steps.current + return self.get_form_list().keyOrder.index(step) + + def get_context_data(self, form, *args, **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(FormWizard): + def get_context_data(self, form, **kwargs): + context = super(MyWizard, self).get_context_data(form, **kwargs) + if self.steps.current == 'my_step_name': + context.update({'another_var': True}) + return context + """ + context = super(WizardView, self).get_context_data(*args, **kwargs) + context.update(self.storage.extra_data) + context['wizard'] = { + 'form': form, + 'steps': self.steps, + 'managenent_form': ManagementForm(prefix=self.prefix, initial={ + 'current_step': self.steps.current, + }), + } + return context + + def render(self, form=None, **kwargs): + """ + Returns a ``HttpResponse`` containing a all needed context data. + """ + form = form or self.get_form() + context = self.get_context_data(form, **kwargs) + return self.render_to_response(context) + + def done(self, form_list, **kwargs): + """ + This method muss be overrided by a subclass to process to form data + after processing all steps. + """ + raise NotImplementedError("Your %s class has not defined a done() " + "method, which is required." % self.__class__.__name__) + + +class SessionWizardView(WizardView): + """ + A WizardView with pre-configured SessionStorage backend. + """ + storage_name = 'django.contrib.formtools.wizard.storage.session.SessionStorage' + + +class CookieWizardView(WizardView): + """ + A WizardView with pre-configured CookieStorage backend. + """ + storage_name = 'django.contrib.formtools.wizard.storage.cookie.CookieStorage' + + +class NamedUrlWizardView(WizardView): + """ + A WizardView with URL named steps support. + """ + url_name = None + done_step_name = None + + @classmethod + def get_initkwargs(cls, *args, **kwargs): + """ + We require a url_name to reverse URLs later. Additionally users can + pass a done_step_name to change the URL name of the "done" view. + """ + extra_kwargs = { + 'done_step_name': 'done' + } + assert 'url_name' in kwargs, 'URL name is needed to resolve correct wizard URLs' + extra_kwargs['url_name'] = kwargs.pop('url_name') + + if 'done_step_name' in kwargs: + extra_kwargs['done_step_name'] = kwargs.pop('done_step_name') + + initkwargs = super(NamedUrlWizardView, cls).get_initkwargs(*args, **kwargs) + initkwargs.update(extra_kwargs) + + assert initkwargs['done_step_name'] not in initkwargs['form_list'], \ + 'step name "%s" is reserved for "done" view' % initkwargs['done_step_name'] + + return initkwargs + + def get(self, *args, **kwargs): + """ + This renders the form or, if needed, does the http redirects. + """ + step_url = kwargs.get('step', None) + if step_url is None: + if 'reset' in self.request.GET: + self.storage.reset() + self.storage.current_step = self.steps.first + if self.request.GET: + query_string = "?%s" % self.request.GET.urlencode() + else: + query_string = "" + next_step_url = reverse(self.url_name, kwargs={ + 'step': self.steps.current, + }) + query_string + return redirect(next_step_url) + + # is the current step the "done" name/view? + elif step_url == self.done_step_name: + last_step = self.steps.last + return self.render_done(self.get_form(step=last_step, + data=self.storage.get_step_data(last_step), + files=self.storage.get_step_files(last_step) + ), **kwargs) + + # is the url step name not equal to the step in the storage? + # if yes, change the step in the storage (if name exists) + elif step_url == self.steps.current: + # URL step name and storage step name are equal, render! + return self.render(self.get_form( + data=self.storage.current_step_data, + files=self.storage.current_step_data, + ), **kwargs) + + elif step_url in self.get_form_list(): + self.storage.current_step = step_url + return self.render(self.get_form( + data=self.storage.current_step_data, + files=self.storage.current_step_data, + ), **kwargs) + + # invalid step name, reset to first and redirect. + else: + self.storage.current_step = self.steps.first + return redirect(self.url_name, step=self.steps.first) + + def post(self, *args, **kwargs): + """ + Do a redirect if user presses the prev. step button. The rest of this + is super'd from FormWizard. + """ + prev_step = self.request.POST.get('wizard_prev_step', None) + if prev_step and prev_step in self.get_form_list(): + self.storage.current_step = prev_step + return redirect(self.url_name, step=prev_step) + return super(NamedUrlWizardView, self).post(*args, **kwargs) + + def render_next_step(self, form, **kwargs): + """ + When using the NamedUrlFormWizard, we have to redirect to update the + browser's URL to match the shown step. + """ + next_step = self.get_next_step() + self.storage.current_step = next_step + return redirect(self.url_name, step=next_step) + + def render_revalidation_failure(self, failed_step, form, **kwargs): + """ + When a step fails, we have to redirect the user to the first failing + step. + """ + self.storage.current_step = failed_step + return redirect(self.url_name, step=failed_step) + + def render_done(self, form, **kwargs): + """ + When rendering the done view, we have to redirect first (if the URL + name doesn't fit). + """ + if kwargs.get('step', None) != self.done_step_name: + return redirect(self.url_name, step=self.done_step_name) + return super(NamedUrlWizardView, self).render_done(form, **kwargs) + + +class NamedUrlSessionWizardView(NamedUrlWizardView): + """ + A NamedUrlWizardView with pre-configured SessionStorage backend. + """ + storage_name = 'django.contrib.formtools.wizard.storage.session.SessionStorage' + + +class NamedUrlCookieWizardView(NamedUrlWizardView): + """ + A NamedUrlFormWizard with pre-configured CookieStorageBackend. + """ + storage_name = 'django.contrib.formtools.wizard.storage.cookie.CookieStorage' + diff --git a/django/utils/functional.py b/django/utils/functional.py index b0233de6ed..76f3639b58 100644 --- a/django/utils/functional.py +++ b/django/utils/functional.py @@ -265,3 +265,24 @@ class SimpleLazyObject(LazyObject): def _setup(self): self._wrapped = self._setupfunc() + + +class lazy_property(property): + """ + A property that works with subclasses by wrapping the decorated + functions of the base class. + """ + def __new__(cls, fget=None, fset=None, fdel=None, doc=None): + if fget is not None: + @wraps(fget) + def fget(instance, instance_type=None, name=fget.__name__): + return getattr(instance, name)() + if fset is not None: + @wraps(fset) + def fset(instance, value, name=fset.__name__): + return getattr(instance, name)(value) + if fdel is not None: + @wraps(fdel) + def fdel(instance, name=fdel.__name__): + return getattr(instance, name)() + return property(fget, fset, fdel, doc) diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 01e4500178..4d81cb7ba3 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -203,6 +203,10 @@ their deprecation, as per the :ref:`Django deprecation policy settings have been superseded by :setting:`IGNORABLE_404_URLS` in the 1.4 release. They will be removed. + * The :doc:`form wizard ` has been + refactored to use class based views with pluggable backends in 1.4. + The previous implementation will be deprecated. + * 2.0 * ``django.views.defaults.shortcut()``. This function has been moved to ``django.contrib.contenttypes.views.shortcut()`` as part of the diff --git a/docs/ref/contrib/formtools/form-wizard.txt b/docs/ref/contrib/formtools/form-wizard.txt index cbacd594bb..2434c8b2b7 100644 --- a/docs/ref/contrib/formtools/form-wizard.txt +++ b/docs/ref/contrib/formtools/form-wizard.txt @@ -2,23 +2,22 @@ Form wizard =========== -.. module:: django.contrib.formtools.wizard +.. module:: django.contrib.formtools.wizard.views :synopsis: Splits forms across multiple Web pages. Django comes with an optional "form wizard" application that splits :doc:`forms ` across multiple Web pages. It maintains -state in hashed HTML :samp:`` fields so that the full -server-side processing can be delayed until the submission of the final form. +state in one of the backends so that the full server-side processing can be +delayed until the submission of the final form. You might want to use this if you have a lengthy form that would be too unwieldy for display on a single page. The first page might ask the user for core information, the second page might ask for less important information, etc. -The term "wizard," in this context, is `explained on Wikipedia`_. +The term "wizard", in this context, is `explained on Wikipedia`_. .. _explained on Wikipedia: http://en.wikipedia.org/wiki/Wizard_%28software%29 -.. _forms: ../forms/ How it works ============ @@ -28,10 +27,8 @@ Here's the basic workflow for how a user would use a wizard: 1. The user visits the first page of the wizard, fills in the form and submits it. 2. The server validates the data. If it's invalid, the form is displayed - again, with error messages. If it's valid, the server calculates a - secure hash of the data and presents the user with the next form, - saving the validated data and hash in :samp:`` - fields. + again, with error messages. If it's valid, the server saves the current + state of the wizard in the backend and redirects to the next step. 3. Step 1 and 2 repeat, for every subsequent form in the wizard. 4. Once the user has submitted all the forms and all the data has been validated, the wizard processes the data -- saving it to the database, @@ -40,30 +37,33 @@ Here's the basic workflow for how a user would use a wizard: Usage ===== -This application handles as much machinery for you as possible. Generally, you -just have to do these things: +This application handles as much machinery for you as possible. Generally, +you just have to do these things: - 1. Define a number of :class:`~django.forms.Form` classes -- one per wizard - page. + 1. Define a number of :class:`~django.forms.Form` classes -- one per + wizard page. - 2. Create a :class:`FormWizard` class that specifies what to do once all of - your forms have been submitted and validated. This also lets you - override some of the wizard's behavior. + 2. Create a :class:`WizardView` subclass that specifies what to do once + all of your forms have been submitted and validated. This also lets + you override some of the wizard's behavior. 3. Create some templates that render the forms. You can define a single, generic template to handle every one of the forms, or you can define a specific template for each form. - 4. Point your URLconf at your :class:`FormWizard` class. + 4. Add ``django.contrib.formtools.wizard`` to your + :setting:`INSTALLED_APPS` list in your settings file. + + 5. Point your URLconf at your :class:`WizardView` :meth:`~WizardView.as_view` method. Defining ``Form`` classes -========================= +------------------------- The first step in creating a form wizard is to create the :class:`~django.forms.Form` classes. These should be standard :class:`django.forms.Form` classes, covered in the :doc:`forms documentation -`. These classes can live anywhere in your codebase, but -convention is to put them in a file called :file:`forms.py` in your +`. These classes can live anywhere in your codebase, +but convention is to put them in a file called :file:`forms.py` in your application. For example, let's write a "contact form" wizard, where the first page's form @@ -79,85 +79,100 @@ the message itself. Here's what the :file:`forms.py` might look like:: class ContactForm2(forms.Form): message = forms.CharField(widget=forms.Textarea) -**Important limitation:** Because the wizard uses HTML hidden fields to store -data between pages, you may not include a :class:`~django.forms.FileField` -in any form except the last one. -Creating a ``FormWizard`` class -=============================== +.. note:: + + In order to use :class:`~django.forms.FileField` in any form, see the + section :ref:`Handling files ` below to learn more about + what to do. + +Creating a ``WizardView`` class +------------------------------- The next step is to create a -:class:`django.contrib.formtools.wizard.FormWizard` subclass. As with your -:class:`~django.forms.Form` classes, this :class:`FormWizard` class can live -anywhere in your codebase, but convention is to put it in :file:`forms.py`. +:class:`django.contrib.formtools.wizard.view.WizardView` subclass. You can +also use the :class:`SessionWizardView` or :class:`CookieWizardView` class +which preselects the wizard storage backend. + +.. note:: + + To use the :class:`SessionWizardView` follow the instructions + in the :doc:`sessions documentation ` on + how to enable sessions. + +We will use the :class:`SessionWizardView` in all examples but is is completly +fine to use the :class:`CookieWizardView` instead. As with your +:class:`~django.forms.Form` classes, this :class:`WizardView` class can live +anywhere in your codebase, but convention is to put it in :file:`views.py`. The only requirement on this subclass is that it implement a -:meth:`~FormWizard.done()` method. +:meth:`~WizardView.done()` method. -.. method:: FormWizard.done +.. method:: WizardView.done(form_list) This method specifies what should happen when the data for *every* form is - submitted and validated. This method is passed two arguments: + submitted and validated. This method is passed a list of validated + :class:`~django.forms.Form` instances. - * ``request`` -- an :class:`~django.http.HttpRequest` object - * ``form_list`` -- a list of :class:`~django.forms.Form` classes + In this simplistic example, rather than performing any database operation, + the method simply renders a template of the validated data:: -In this simplistic example, rather than perform any database operation, the -method simply renders a template of the validated data:: + from django.shortcuts import render_to_response + from django.contrib.formtools.wizard.views import SessionWizardView - from django.shortcuts import render_to_response - from django.contrib.formtools.wizard import FormWizard + class ContactWizard(SessionWizardView): + def done(self, form_list, **kwargs): + return render_to_response('done.html', { + 'form_data': [form.cleaned_data for form in form_list], + }) - class ContactWizard(FormWizard): - def done(self, request, form_list): - return render_to_response('done.html', { - 'form_data': [form.cleaned_data for form in form_list], - }) + Note that this method will be called via ``POST``, so it really ought to be a + good Web citizen and redirect after processing the data. Here's another + example:: -Note that this method will be called via ``POST``, so it really ought to be a -good Web citizen and redirect after processing the data. Here's another -example:: + from django.http import HttpResponseRedirect + from django.contrib.formtools.wizard.views import SessionWizardView - from django.http import HttpResponseRedirect - from django.contrib.formtools.wizard import FormWizard + class ContactWizard(SessionWizardView): + def done(self, form_list, **kwargs): + do_something_with_the_form_data(form_list) + return HttpResponseRedirect('/page-to-redirect-to-when-done/') - class ContactWizard(FormWizard): - def done(self, request, form_list): - do_something_with_the_form_data(form_list) - return HttpResponseRedirect('/page-to-redirect-to-when-done/') - -See the section `Advanced FormWizard methods`_ below to learn about more -:class:`FormWizard` hooks. +See the section :ref:`Advanced WizardView methods ` +below to learn about more :class:`WizardView` hooks. Creating templates for the forms -================================ +-------------------------------- Next, you'll need to create a template that renders the wizard's forms. By -default, every form uses a template called :file:`forms/wizard.html`. (You can -change this template name by overriding :meth:`~FormWizard.get_template()`, -which is documented below. This hook also allows you to use a different -template for each form.) +default, every form uses a template called +:file:`formtools/wizard/wizard_form.html`. You can change this template name +by overriding either the :attr:`~WizardView.template_name` attribute or the +:meth:`~WizardView.get_template_names()` method, which is documented below. +This hook also allows you to use a different template for each form. -This template expects the following context: +This template expects a ``wizard`` object that has various items attached to +it: - * ``step_field`` -- The name of the hidden field containing the step. - * ``step0`` -- The current step (zero-based). - * ``step`` -- The current step (one-based). - * ``step_count`` -- The total number of steps. - * ``form`` -- The :class:`~django.forms.Form` instance for the current step - (either empty or with errors). - * ``previous_fields`` -- A string representing every previous data field, - plus hashes for completed forms, all in the form of hidden fields. Note - that you'll need to run this through the :tfilter:`safe` template filter, - to prevent auto-escaping, because it's raw HTML. + * ``form`` -- The :class:`~django.forms.Form` instance for the current + step (either empty or with errors). -You can supply extra context to this template in two ways: + * ``steps`` -- A helper object to access the various steps related data: - * Set the :attr:`~FormWizard.extra_context` attribute on your - :class:`FormWizard` subclass to a dictionary. + * ``step0`` -- The current step (zero-based). + * ``step1`` -- The current step (one-based). + * ``count`` -- The total number of steps. + * ``first`` -- The first step. + * ``last`` -- The last step. + * ``current`` -- The current (or first) step. + * ``next`` -- The next step. + * ``prev`` -- The previous step. + * ``index`` -- The index of the current step. + * ``all`` -- A list of all steps of the wizard. - * Pass a dictionary as a parameter named ``extra_context`` to your wizard's - URL pattern in your URLconf. See :ref:`hooking-wizard-into-urlconf`. +You can supply additional context variables by using the +:meth:`~FormWizard.get_context_data` method of your :class:`FormWizard` +subclass. Here's a full example template: @@ -166,170 +181,401 @@ Here's a full example template: {% extends "base.html" %} {% block content %} -

Step {{ step }} of {{ step_count }}

+

Step {{ wizard.steps.current }} of {{ wizard.steps.count }}

{% csrf_token %} - {{ form }} + {{ wizard.management_form }} + {% if wizard.form.forms %} + {{ wizard.form.management_form }} + {% for form in wizard.form.forms %} + {{ form }} + {% endfor %} + {% else %} + {{ wizard.form }} + {% endif %} + {% if wizard.steps.prev %} + + + {% endif %}
- - {{ previous_fields|safe }}
{% endblock %} -Note that ``previous_fields``, ``step_field`` and ``step0`` are all required -for the wizard to work properly. +.. note:: -.. _hooking-wizard-into-urlconf: + Note that ``{{ wizard.management_form }}`` **must be used** for + the wizard to work properly. + +.. _wizard-urlconf: Hooking the wizard into a URLconf -================================= +--------------------------------- Finally, we need to specify which forms to use in the wizard, and then -deploy the new :class:`FormWizard` object a URL in ``urls.py``. The -wizard takes a list of your :class:`~django.forms.Form` objects as -arguments when you instantiate the Wizard:: +deploy the new :class:`WizardView` object a URL in the ``urls.py``. The +wizard's :meth:`as_view` method takes a list of your +:class:`~django.forms.Form` classes as an argument during instantiation:: - from django.conf.urls.defaults import * - from testapp.forms import ContactForm1, ContactForm2, ContactWizard + from django.conf.urls.defaults import patterns + + from myapp.forms import ContactForm1, ContactForm2 + from myapp.views import ContactWizard urlpatterns = patterns('', - (r'^contact/$', ContactWizard([ContactForm1, ContactForm2])), + (r'^contact/$', ContactWizard.as_view([ContactForm1, ContactForm2])), ) -Advanced ``FormWizard`` methods +.. _wizardview-advanced-methods: + +Advanced ``WizardView`` methods =============================== -.. class:: FormWizard +.. class:: WizardView - Aside from the :meth:`~done()` method, :class:`FormWizard` offers a few + Aside from the :meth:`~done()` method, :class:`WizardView` offers a few advanced method hooks that let you customize how your wizard works. Some of these methods take an argument ``step``, which is a zero-based - counter representing the current step of the wizard. (E.g., the first form - is ``0`` and the second form is ``1``.) + counter as string representing the current step of the wizard. (E.g., the + first form is ``'0'`` and the second form is ``'1'``) -.. method:: FormWizard.prefix_for_step +.. method:: WizardView.get_form_prefix(step) - Given the step, returns a form prefix to use. By default, this simply uses + Given the step, returns a form prefix to use. By default, this simply uses the step itself. For more, see the :ref:`form prefix documentation `. - Default implementation:: - - def prefix_for_step(self, step): - return str(step) - -.. method:: FormWizard.render_hash_failure - - Renders a template if the hash check fails. It's rare that you'd need to - override this. - - Default implementation:: - - def render_hash_failure(self, request, step): - 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.'}) - -.. method:: FormWizard.security_hash - - Calculates the security hash for the given request object and - :class:`~django.forms.Form` instance. - - By default, this generates a SHA1 HMAC using your form data and your - :setting:`SECRET_KEY` setting. It's rare that somebody would need to - override this. - - Example:: - - def security_hash(self, request, form): - return my_hash_function(request, form) - -.. method:: FormWizard.parse_params - - A hook for saving state from the request object and ``args`` / ``kwargs`` - that were captured from the URL by your URLconf. - - By default, this does nothing. - - Example:: - - def parse_params(self, request, *args, **kwargs): - self.my_state = args[0] - -.. method:: FormWizard.get_template - - Returns the name of the template that should be used for the given step. - - By default, this returns :file:`'forms/wizard.html'`, regardless of step. - - Example:: - - def get_template(self, step): - return 'myapp/wizard_%s.html' % step - - If :meth:`~FormWizard.get_template` returns a list of strings, then the - wizard will use the template system's - :func:`~django.template.loader.select_template` function. - This means the system will use the first template that exists on the - filesystem. For example:: - - def get_template(self, step): - return ['myapp/wizard_%s.html' % step, 'myapp/wizard.html'] - -.. method:: FormWizard.render_template - - Renders the template for the given step, returning an - :class:`~django.http.HttpResponse` object. - - Override this method if you want to add a custom context, return a - different MIME type, etc. If you only need to override the template name, - use :meth:`~FormWizard.get_template` instead. - - The template will be rendered with the context documented in the - "Creating templates for the forms" section above. - -.. method:: FormWizard.process_step +.. method:: WizardView.process_step(form) Hook for modifying the wizard's internal state, given a fully validated :class:`~django.forms.Form` object. The Form is guaranteed to have clean, valid data. - This method should *not* modify any of that data. Rather, it might want to - set ``self.extra_context`` or dynamically alter ``self.form_list``, based - on previously submitted forms. - Note that this method is called every time a page is rendered for *all* submitted steps. - The function signature:: + The default implementation:: - def process_step(self, request, form, step): - # ... + def process_step(self, form): + return self.get_form_step_data(form) + +.. method:: WizardView.get_form_initial(step) + + Returns a dictionary which will be passed to the form for ``step`` as + ``initial``. If no initial data was provied while initializing the + form wizard, a empty dictionary should be returned. + + The default implementation:: + + def get_form_initial(self, step): + return self.initial_dict.get(step, {}) + +.. method:: WizardView.get_form_instance(step) + + Returns a object which will be passed to the form for ``step`` as + ``instance``. If no instance object was provied while initializing + the form wizard, None be returned. + + The default implementation:: + + def get_form_instance(self, step): + return self.instance_dict.get(step, None) + +.. method:: WizardView.get_context_data(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. + + The default template context variables are: + + * Any extra data the storage backend has stored + * ``form`` -- form instance of the current step + * ``wizard`` -- the wizard instance itself + + Example to add extra variables for a specific step:: + + def get_context_data(self, form, **kwargs): + context = super(MyWizard, self).get_context_data(form, **kwargs) + if self.steps.current == 'my_step_name': + context.update({'another_var': True}) + return context + +.. method:: WizardView.get_wizard_name() + + This method can be used to change the wizard's internal name. + + Default implementation:: + + def get_wizard_name(self): + return normalize_name(self.__class__.__name__) + +.. method:: WizardView.get_prefix() + + This method returns a prefix for the storage backends. These backends use + the prefix to fetch the correct data for the wizard. (Multiple wizards + could save their data in one session) + + You can change this method to make the wizard data prefix more unique to, + e.g. have multiple instances of one wizard in one session. + + Default implementation:: + + def get_prefix(self): + return self.wizard_name + +.. method:: WizardView.get_form(step=None, data=None, files=None) + + This method constructs the form for a given ``step``. If no ``step`` is + defined, the current step will be determined automatically. + The method gets three arguments: + + * ``step`` -- The step for which the form instance should be generated. + * ``data`` -- Gets passed to the form's data argument + * ``files`` -- Gets passed to the form's files argument + + You can override this method to add extra arguments to the form instance. + + Example code to add a user attribute to the form on step 2:: + + def get_form(self, step=None, data=None, files=None): + form = super(MyWizard, self).get_form(step, data, files) + if step == '1': + form.user = self.request.user + return form + +.. method:: WizardView.process_step(form) + + This method gives you a way to post-process the form data before the data + gets stored within the storage backend. By default it just passed the + form.data dictionary. You should not manipulate the data here but you can + use the data to do some extra work if needed (e.g. set storage extra data). + + Default implementation:: + + def process_step(self, form): + return self.get_form_step_data(form) + +.. method:: WizardView.process_step_files(form) + + This method gives you a way to post-process the form files before the + files gets stored within the storage backend. By default it just passed + the ``form.files`` dictionary. You should not manipulate the data here + but you can use the data to do some extra work if needed (e.g. set storage + extra data). + + Default implementation:: + + def process_step_files(self, form): + return self.get_form_step_files(form) + +.. method:: WizardView.render_revalidation_failure(step, form, **kwargs) + + When the wizard thinks, all steps passed it revalidates all forms with the + data from the backend storage. + + If any of the forms don't validate correctly, this method gets called. + This method expects two arguments, ``step`` and ``form``. + + The default implementation resets the current step to the first failing + form and redirects the user to the invalid form. + + Default implementation:: + + def render_revalidation_failure(self, step, form, **kwargs): + self.storage.current_step = step + return self.render(form, **kwargs) + +.. method:: WizardView.get_form_step_data(form) + + This method fetches the form data from and returns the dictionary. You + can use this method to manipulate the values before the data gets stored + in the storage backend. + + Default implementation:: + + def get_form_step_data(self, form): + return form.data + +.. method:: WizardView.get_form_step_files(form) + + This method returns the form files. You can use this method to manipulate + the files before the data gets stored in the storage backend. + + Default implementation:: + + def get_form_step_files(self, form): + return form.files + +.. method:: WizardView.render(form, **kwargs) + + This method gets called after the get or post request was handled. You can + hook in this method to, e.g. change the type of http response. + + Default implementation:: + + def render(self, form=None, **kwargs): + form = form or self.get_form() + context = self.get_context_data(form, **kwargs) + return self.render_to_response(context) Providing initial data for the forms ==================================== -.. attribute:: FormWizard.initial +.. attribute:: WizardView.initial_dict Initial data for a wizard's :class:`~django.forms.Form` objects can be - provided using the optional :attr:`~FormWizard.initial` keyword argument. - This argument should be a dictionary mapping a step to a dictionary - containing the initial data for that step. The dictionary of initial data + provided using the optional :attr:`~Wizard.initial_dict` keyword argument. + This argument should be a dictionary mapping the steps to dictionaries + containing the initial data for each step. The dictionary of initial data will be passed along to the constructor of the step's :class:`~django.forms.Form`:: - >>> from testapp.forms import ContactForm1, ContactForm2, ContactWizard + >>> from myapp.forms import ContactForm1, ContactForm2 + >>> from myapp.views import ContactWizard >>> initial = { - ... 0: {'subject': 'Hello', 'sender': 'user@example.com'}, - ... 1: {'message': 'Hi there!'} + ... '0': {'subject': 'Hello', 'sender': 'user@example.com'}, + ... '1': {'message': 'Hi there!'} ... } - >>> wiz = ContactWizard([ContactForm1, ContactForm2], initial=initial) - >>> form1 = wiz.get_form(0) - >>> form2 = wiz.get_form(1) + >>> wiz = ContactWizard.as_view([ContactForm1, ContactForm2], initial_dict=initial) + >>> form1 = wiz.get_form('0') + >>> form2 = wiz.get_form('1') >>> form1.initial {'sender': 'user@example.com', 'subject': 'Hello'} >>> form2.initial {'message': 'Hi there!'} + + The ``initial_dict`` can also take a list of dictionaries for a specific + step if the step is a ``FormSet``. + +.. _wizard-files: + +Handling files +============== + +To handle :class:`~django.forms.FileField` within any step form of the wizard, +you have to add a :attr:`file_storage` to your :class:`WizardView` subclass. + +This storage will temporarilyy store the uploaded files for the wizard. The +:attr:`file_storage` attribute should be a +:class:`~django.core.files.storage.Storage` subclass. + +.. warning:: + + Please remember to take care of removing old files as the + :class:`WizardView` won't remove any files, whether the wizard gets + finished corretly or not. + +Conditionally view/skip specific steps +====================================== + +.. attribute:: WizardView.condition_dict + +The :meth:`~WizardView.as_view` accepts a ``condition_dict`` argument. You can pass a +dictionary of boolean values or callables. The key should match the steps +name (e.g. '0', '1'). + +If the value of a specific step is callable it will be called with the +:class:`WizardView` instance as the only argument. If the return value is true, +the step's form will be used. + +This example provides a contact form including a condition. The condition is +used to show a message from only if a checkbox in the first step was checked. + +The steps are defined in a ``forms.py``:: + + from django import forms + + class ContactForm1(forms.Form): + subject = forms.CharField(max_length=100) + sender = forms.EmailField() + leave_message = forms.BooleanField(required=False) + + class ContactForm2(forms.Form): + message = forms.CharField(widget=forms.Textarea) + +We define our wizard in a ``views.py``:: + + from django.shortcuts import render_to_response + from django.contrib.formtools.wizard.views import SessionWizardView + + def show_message_form_condition(wizard): + # try to get the cleaned data of step 1 + cleaned_data = wizard.get_cleaned_data_for_step('0') or {} + # check if the field ``leave_message`` was checked. + return cleaned_data.get('leave_message', True) + + class ContactWizard(SessionWizardView): + + def done(self, form_list, **kwargs): + return render_to_response('done.html', { + 'form_data': [form.cleaned_data for form in form_list], + }) + +We need to add the ``ContactWizard`` to our ``urls.py`` file:: + + from django.conf.urls.defaults import pattern + + from myapp.forms import ContactForm1, ContactForm2 + from myapp.views import ContactWizard, show_message_form_condition + + contact_forms = [ContactForm1, ContactForm2] + + urlpatterns = patterns('', + (r'^contact/$', ContactWizard.as_view(contact_forms, + condition_dict={'1': show_message_form_condition} + )), + ) + +As you can see, we defined a ``show_message_form_condition`` next to our +:class:`WizardView` subclass and added a ``condition_dict`` argument to the +:meth:`~WizardView.as_view` method. The key refers to the second wizard step +(because of the zero based step index). + +How to work with ModelForm and ModelFormSet +=========================================== + +The WizardView supports :class:`~django.forms.ModelForm` and +:class:`~django.forms.ModelFormSet`. Additionally to the ``initial_dict``, +the :meth:`~WizardView.as_view` method takes a ``instance_dict`` argument +with a list of instances for the ``ModelForm`` and ``ModelFormSet``. + +Usage of NamedUrlWizardView +=========================== + +.. class:: NamedUrlWizardView + +There is a :class:`WizardView` subclass which adds named-urls support to the wizard. +By doing this, you can have single urls for every step. + +To use the named urls, you have to change the ``urls.py``. + +Below you will see an example of a contact wizard with two steps, step 1 with +"contactdata" as its name and step 2 with "leavemessage" as its name. + +Additionally you have to pass two more arguments to the +:meth:`~WizardView.as_view` method: + + * ``url_name`` -- the name of the url (as provided in the urls.py) + * ``done_step_name`` -- the name in the url for the done step + +Example code for the changed ``urls.py`` file:: + + from django.conf.urls.defaults import url, patterns + + from myapp.forms import ContactForm1, ContactForm2 + from myapp.views import ContactWizard + + named_contact_forms = ( + ('contactdata', ContactForm1), + ('leavemessage', ContactForm2), + ) + + contact_wizard = ContactWizard.as_view(named_contact_forms, + url_name='contact_step', done_step_name='finished') + + urlpatterns = patterns('', + url(r'^contact/(?P.+)/$', contact_wizard, name='contact_step'), + url(r'^contact/$', contact_wizard, name='contact'), + ) diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index 7fdf0d7e1c..2a2090a9b1 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -55,6 +55,22 @@ signing in Web applications. See :doc:`cryptographic signing ` docs for more information. +New form wizard +~~~~~~~~~~~~~~~ + +The previously shipped ``FormWizard`` of the formtools contrib app has been +replaced with a new implementation that is based on the class based views +introduced in Django 1.3. It features a pluggable storage API and doesn't +require the wizard to pass around hidden fields for every previous step. + +Django 1.4 ships with a session based storage backend and a cookie based +storage backend. The latter uses the tools for +:doc:`cryptographic signing ` also introduced in +Django 1.4 to store the wizard state in the user's cookies. + +See the :doc:`form wizard ` docs for +more information. + Simple clickjacking protection ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/regressiontests/formwizard/__init__.py b/tests/regressiontests/formwizard/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/regressiontests/formwizard/forms.py b/tests/regressiontests/formwizard/forms.py deleted file mode 100644 index f458eda4ac..0000000000 --- a/tests/regressiontests/formwizard/forms.py +++ /dev/null @@ -1,18 +0,0 @@ -from django import forms -from django.contrib.formtools.wizard import FormWizard -from django.http import HttpResponse - -class Page1(forms.Form): - name = forms.CharField(max_length=100) - thirsty = forms.NullBooleanField() - -class Page2(forms.Form): - address1 = forms.CharField(max_length=100) - address2 = forms.CharField(max_length=100) - -class Page3(forms.Form): - random_crap = forms.CharField(max_length=100) - -class ContactWizard(FormWizard): - def done(self, request, form_list): - return HttpResponse("") diff --git a/tests/regressiontests/formwizard/models.py b/tests/regressiontests/formwizard/models.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/regressiontests/formwizard/tests.py b/tests/regressiontests/formwizard/tests.py deleted file mode 100644 index 0c94d2e276..0000000000 --- a/tests/regressiontests/formwizard/tests.py +++ /dev/null @@ -1,59 +0,0 @@ -import re -from django import forms -from django.test import TestCase - -class FormWizardWithNullBooleanField(TestCase): - urls = 'regressiontests.formwizard.urls' - - input_re = re.compile('name="([^"]+)" value="([^"]+)"') - - wizard_url = '/wiz/' - wizard_step_data = ( - { - '0-name': 'Pony', - '0-thirsty': '2', - }, - { - '1-address1': '123 Main St', - '1-address2': 'Djangoland', - }, - { - '2-random_crap': 'blah blah', - } - ) - - def grabFieldData(self, response): - """ - Pull the appropriate field data from the context to pass to the next wizard step - """ - previous_fields = response.context['previous_fields'] - fields = {'wizard_step': response.context['step0']} - - def grab(m): - fields[m.group(1)] = m.group(2) - return '' - - self.input_re.sub(grab, previous_fields) - return fields - - def checkWizardStep(self, response, step_no): - """ - Helper function to test each step of the wizard - - Make sure the call succeeded - - Make sure response is the proper step number - - return the result from the post for the next step - """ - step_count = len(self.wizard_step_data) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Step %d of %d' % (step_no, step_count)) - - data = self.grabFieldData(response) - data.update(self.wizard_step_data[step_no - 1]) - - return self.client.post(self.wizard_url, data) - - def testWizard(self): - response = self.client.get(self.wizard_url) - for step_no in range(1, len(self.wizard_step_data) + 1): - response = self.checkWizardStep(response, step_no) diff --git a/tests/regressiontests/formwizard/urls.py b/tests/regressiontests/formwizard/urls.py deleted file mode 100644 index d964bc6505..0000000000 --- a/tests/regressiontests/formwizard/urls.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.conf.urls.defaults import * -from forms import ContactWizard, Page1, Page2, Page3 - -urlpatterns = patterns('', - url(r'^wiz/$', ContactWizard([Page1, Page2, Page3])), - ) diff --git a/tests/regressiontests/utils/functional.py b/tests/regressiontests/utils/functional.py index 2784ddd7be..90a6f08630 100644 --- a/tests/regressiontests/utils/functional.py +++ b/tests/regressiontests/utils/functional.py @@ -1,5 +1,5 @@ from django.utils import unittest -from django.utils.functional import lazy +from django.utils.functional import lazy, lazy_property class FunctionalTestCase(unittest.TestCase): @@ -20,3 +20,20 @@ class FunctionalTestCase(unittest.TestCase): t = lazy(lambda: Klazz(), Klazz)() self.assertTrue('base_method' in dir(t)) + + def test_lazy_property(self): + + class A(object): + + def _get_do(self): + raise NotImplementedError + def _set_do(self, value): + raise NotImplementedError + do = lazy_property(_get_do, _set_do) + + class B(A): + def _get_do(self): + return "DO IT" + + self.assertRaises(NotImplementedError, lambda: A().do) + self.assertEqual(B().do, 'DO IT')