Ticket #3218: form_wizard_with_tests_and_docs_r6199.diff
File form_wizard_with_tests_and_docs_r6199.diff, 20.1 KB (added by , 17 years ago) |
---|
-
django/contrib/formtools/wizard.py
1 """ 2 USAGE: 3 views: 4 class MyWizard(Wizard): 5 def done(self, request, form_list): 6 return render_to_response('done.html', {'form_data' : [ form.cleaned_data for form in form_list ] }) 7 urls: 8 (r'^$', MyWizard([MyForm, MyForm, MyForm])), 9 10 form template: 11 <form action="." method="POST"> 12 FORM({{ step }}): {{ form }} 13 14 step_info : <input type="hidden" name="{{ step_field }}" value="{{ step }}" /> 15 16 previous_fields: {{ previous_fields }} 17 18 <input type="submit"> 19 </form> 20 21 done.html: 22 {% for data in form_data %} 23 {% for item in data.items %} 24 {{ item.0 }} : {{ item.1 }}<br /> 25 {% endfor %} 26 {% endfor %} 27 28 """ 29 from django.conf import settings 30 from django.http import Http404 31 from django.shortcuts import render_to_response 32 from django.template.context import RequestContext 33 34 from django import newforms as forms 35 import cPickle as pickle 36 import md5 37 38 class Wizard(object): 39 PREFIX="%d" 40 STEP_FIELD="wizard_step" 41 42 # METHODS SUBCLASSES SHOULDN'T OVERRIDE ################################### 43 def __init__(self, form_list, initial=None): 44 " Pass list of Form classes (not instances !) " 45 self.form_list = form_list[:] 46 self.initial = initial or {} 47 48 def __repr__(self): 49 return "step: %d\nform_list: %s\ninitial_data: %s" % (self.step, self.form_list, self.initial) 50 51 def get_form(self, step, data=None): 52 " Shortcut to return form instance. " 53 return self.form_list[step](data, prefix=self.PREFIX % step, initial=self.initial.get(step, None)) 54 55 def __call__(self, request, *args, **kwargs): 56 """ 57 Main function that does all the hard work: 58 - initializes the wizard object (via parse_params()) 59 - veryfies (using security_hash()) that noone has tempered with the data since we last saw them 60 calls failed_hash() if it is so 61 calls process_step() for every previously submitted form 62 - validates current form and 63 returns it again if errors were found 64 returns done() if it was the last form 65 returns next form otherwise 66 """ 67 # add extra_context, we don't care if somebody overrides it, as long as it remains a dict 68 self.extra_context = getattr(self, 'extra_context', {}) 69 if 'extra_context' in kwargs: 70 self.extra_context.update(kwargs['extra_context']) 71 72 self.parse_params(request, *args, **kwargs) 73 74 # we only accept POST method for form delivery no POST, no data 75 if not request.POST: 76 self.step = 0 77 return self.render(self.get_form(0), request) 78 79 # verify old steps' hashes 80 for i in range(self.step): 81 form = self.get_form(i, request.POST) 82 # somebody is trying to corrupt our data 83 if request.POST.get("hash_%d" % i, '') != self.security_hash(request, form): 84 # revert to the corrupted step 85 return self.failed_hash(request, i) 86 self.process_step(request, form, i) 87 88 # process current step 89 form = self.get_form(self.step, request.POST) 90 if form.is_valid(): 91 self.process_step(request, form, self.step) 92 self.step += 1 93 # this was the last step 94 if self.step == len(self.form_list): 95 form_list = [self.get_form(i, request.POST) for i in range(len(self.form_list))] 96 # validate all the forms befora passing it to done() 97 for f in form_list: f.is_valid() 98 return self.done(request, form_list) 99 form = self.get_form(self.step) 100 return self.render(form, request) 101 102 def render(self, form, request): 103 """ 104 Prepare the form and call the render_template() method to do tha actual rendering. 105 """ 106 if self.step >= len(self.form_list): 107 raise Http404 108 109 old_data = request.POST 110 prev_fields = '' 111 if old_data: 112 # old data 113 prev_fields = '\n'.join( 114 bf.as_hidden() for i in range(self.step) for bf in self.get_form(i, old_data) 115 ) 116 # hashes for old forms 117 hidden = forms.widgets.HiddenInput() 118 prev_fields += '\n'.join( 119 hidden.render("hash_%d" % i, old_data.get("hash_%d" % i, self.security_hash(request, self.get_form(i, old_data)))) 120 for i in range(self.step) 121 ) 122 return self.render_template(request, form, prev_fields) 123 124 # METHODS SUBCLASSES MIGHT OVERRIDE IF APPROPRIATE ######################## 125 126 def failed_hash(self, request, i): 127 """ 128 One of the hashes verifying old data doesn't match. 129 """ 130 self.step = i 131 return self.render(self.get_form(self.step), request) 132 133 def security_hash(self, request, form): 134 """ 135 Calculates the security hash for the given Form instance. 136 137 This creates a list of the form field names/values in a deterministic 138 order, pickles the result with the SECRET_KEY setting and takes an md5 139 hash of that. 140 141 Subclasses may want to take into account request-specific information 142 such as the IP address. 143 """ 144 data = [(bf.name, bf.data) for bf in form] + [settings.SECRET_KEY] 145 # Use HIGHEST_PROTOCOL because it's the most efficient. It requires 146 # Python 2.3, but Django requires 2.3 anyway, so that's OK. 147 pickled = pickle.dumps(data, protocol=pickle.HIGHEST_PROTOCOL) 148 return md5.new(pickled).hexdigest() 149 150 def parse_params(self, request, *args, **kwargs): 151 """ 152 Set self.step, process any additional info from parameters and/or form data 153 """ 154 if request.POST: 155 self.step = int(request.POST.get(self.STEP_FIELD, 0)) 156 else: 157 self.step = 0 158 159 def get_template(self): 160 """ 161 Return name of the template to be rendered, use self.step to get the step number. 162 """ 163 return "wizard.html" 164 165 def render_template(self, request, form, previous_fields): 166 """ 167 Render template for current step, override this method if you wish to add custom context, return a different mimetype etc. 168 169 If you only wish to override the template name, use get_template 170 171 Some additional items are added to the context: 172 'step_field' is the name of the hidden field containing step 173 'step' holds the current step 174 'form' containing the current form to be processed (either empty or with errors) 175 'previous_data' contains all the addtitional information, including 176 hashes for finished forms and old data in form of hidden fields 177 any additional data stored in self.extra_context 178 """ 179 return render_to_response(self.get_template(), dict( 180 step_field=self.STEP_FIELD, 181 step=self.step, 182 current_step=self.step + 1, 183 step_count=self.step_count(), 184 form=form, 185 previous_fields=previous_fields, 186 ** self.extra_context 187 ), context_instance=RequestContext(request)) 188 189 def process_step(self, request, form, step): 190 """ 191 This should not modify any data, it is only a hook to modify wizard's internal state 192 (such as dynamically generating form_list based on previously submited forms). 193 It can also be used to add items to self.extra_context base on the contents of previously submitted forms. 194 195 Note that this method is called every time a page is rendered for ALL submitted steps. 196 197 Only valid data enter here. 198 """ 199 pass 200 201 def step_count(self): 202 """Return number of steps""" 203 return len(self.form_list) 204 205 # METHODS SUBCLASSES MUST OVERRIDE ######################################## 206 207 def done(self, request, form_list): 208 """ 209 this method must be overriden, it is responsible for the end processing - it will be called with instances of all form_list with their data 210 """ 211 raise NotImplementedError('You must define a done() method on your %s subclass.' % self.__class__.__name__) -
tests/regressiontests/formtools/views.py
1 from django.contrib.formtools.wizard import Wizard 2 from django import newforms as forms 3 from django.shortcuts import render_to_response 4 5 class NameForm(forms.Form): 6 first_name = forms.CharField() 7 last_name = forms.CharField() 8 9 class DetailsForm(forms.Form): 10 phone = forms.CharField() 11 adress = forms.CharField(widget=forms.Textarea) 12 13 class ConfirmForm(forms.Form): 14 confirm = forms.BooleanField() 15 16 class SureForm(forms.Form): 17 sure = forms.BooleanField() 18 19 class MyWizard(Wizard): 20 21 def done(self, request, form_list): 22 form_data = [f.cleaned_data for f in form_list] 23 return render_to_response('done.html', {'form_data': form_data}) 24 25 26 RegisterWizard = MyWizard([NameForm, DetailsForm, ConfirmForm]) 27 28 def wizard(request): 29 if request.POST and 'extra_form' in request.POST: 30 RegisterWizard.form_list.append(SureForm) 31 32 response = RegisterWizard(request) 33 34 hashes = [RegisterWizard.security_hash( 35 request, 36 RegisterWizard.get_form(i, request.POST) 37 ) for i in range(RegisterWizard.step)] 38 39 response['WizardHashes'] = hashes 40 41 return response 42 43 -
tests/regressiontests/formtools/tests.py
1 import unittest 2 from django.test.client import Client 3 4 first_post = { 5 '0-first_name': 'John', 6 '0-last_name': 'Doe', 7 'wizard_step': '0' 8 } 9 10 second_post = { 11 '0-first_name': 'John', 12 '0-last_name': 'Doe', 13 '1-phone': '99240889', 14 '1-adress': 'memory lane', 15 'wizard_step': '1' 16 } 17 18 tampered_second_post = { 19 '0-first_name': 'John', 20 '0-last_name': 'Dere', 21 '1-phone': '99240889', 22 '1-adress': 'memory lane', 23 'wizard_step': '1' 24 } 25 26 27 third_post = { 28 '0-first_name': 'John', 29 '0-last_name': 'Doe', 30 '1-phone': '99240889', 31 '1-adress': 'memory lane', 32 '2-confirm': '1', 33 'wizard_step': '2' 34 } 35 36 fourth_post = { 37 '0-first_name': 'John', 38 '0-last_name': 'Doe', 39 '1-phone': '99240889', 40 '1-adress': 'memory lane', 41 '2-confirm': '1', 42 '3-sure': '1', 43 'wizard_step': '2' 44 } 45 46 47 class WizardTest(unittest.TestCase): 48 def setUp(self): 49 # Every test needs a client. 50 self.client = Client() 51 52 def test_details(self): 53 # Issue a GET request. 54 response_1 = self.client.get('/formtools/wizard/') 55 56 # Check that the respose is 200 OK. 57 self.failUnlessEqual(response_1.status_code, 200) 58 59 # Issue a POST request. 60 response_2 = self.client.post('/formtools/wizard/', first_post) 61 62 hashes_1 = response_2['WizardHashes'] 63 64 # should fail, returns the first form in content 65 postdata = dict(tampered_second_post, hash_0=hashes_1[0]) 66 response_3 = self.client.post('/formtools/wizard/', postdata) 67 self.failUnlessEqual('<form action="." method="POST"><h1>FORM( 0 ): <tr><th><label for="id_0-first_name">First name:</label></th><td><input type="text" name="0-first_name" id="id_0-first_name" /></td></tr><tr><th><label for="id_0-last_name">Last name:</label></th><td><input type="text" name="0-last_name" id="id_0-last_name" /></td></tr></h1><p><strong>step_info :</strong><input type="hidden" name="wizard_step" value="0" /></p><p><input type="submit"></p></form> \n', response_3.content) 68 69 # should not fail, returns the third form in content 70 postdata = dict(second_post, hash_0=hashes_1[0]) 71 response_3 = self.client.post('/formtools/wizard/', postdata) 72 self.failUnlessEqual('<form action="." method="POST"><h1>FORM( 2 ): <tr><th><label for="id_2-confirm">Confirm:</label></th><td><input type="checkbox" name="2-confirm" id="id_2-confirm" /></td></tr></h1><p><strong>step_info :</strong><input type="hidden" name="wizard_step" value="2" /></p><p><input type="hidden" name="0-first_name" value="John" id="id_0-first_name" /><input type="hidden" name="0-last_name" value="Doe" id="id_0-last_name" /><input type="hidden" name="1-phone" value="99240889" id="id_1-phone" /><input type="hidden" name="1-adress" value="memory lane" id="id_1-adress" /><input type="hidden" name="hash_0" value="82a79154a40ed63c309f8bb349cb09e9" /><input type="hidden" name="hash_1" value="9ce63eda4282aa24049870edca937b02" /><input type="submit"></p></form> \n', response_3.content) 73 74 hashes_2 = response_3['WizardHashes'] 75 76 # make sure hashes are the same 77 self.failUnlessEqual(hashes_1[0], hashes_2[0]) 78 79 postdata = dict(third_post, hash_0=hashes_2[0], hash_1=hashes_2[1]) 80 response_4 = self.client.post('/formtools/wizard/', postdata) 81 82 hashes_3 = response_4['WizardHashes'] 83 84 # make sure hashes are the same 85 self.failUnlessEqual(hashes_1[0], hashes_3[0]) 86 self.failUnlessEqual(hashes_2[1], hashes_3[1]) 87 88 # with extra dynamically added fourth form 89 postdata = dict(fourth_post, hash_0=hashes_3[0], hash_1=hashes_3[1], hash_2=hashes_3[2], extra_form='1') 90 response_5 = self.client.post('/formtools/wizard/', postdata) 91 92 hashes_4 = response_5['WizardHashes'] 93 94 self.failUnlessEqual(hashes_1[0], hashes_4[0]) 95 self.failUnlessEqual(hashes_2[0], hashes_4[0]) 96 97 self.failUnlessEqual(hashes_3[1], hashes_4[1]) 98 self.failUnlessEqual(hashes_3[2], hashes_4[2]) -
tests/regressiontests/formtools/urls.py
1 from django.conf.urls.defaults import * 2 import views 3 4 urlpatterns = patterns('', 5 (r'^wizard/$', views.wizard), 6 ) -
tests/regressiontests/formtools/templates/wizard.html
1 {% spaceless %} 2 <form action="." method="POST"> 3 <h1>FORM( {{ step }} ): {{ form }}</h1> 4 <p><strong>step_info :</strong> <input type="hidden" name="{{ step_field }}" value="{{ step }}" /></p> 5 <p> {{ previous_fields }} <input type="submit"> </p> 6 </form> 7 {% endspaceless %} -
tests/regressiontests/formtools/templates/done.html
1 {% spaceless %} 2 <h1>Result:</h1> 3 {% for data in form_data %} 4 {% for item in data.items %} 5 <p><strong>{{ item.0 }}</strong> : {{ item.1 }}</p> 6 {% endfor %} 7 {% endfor %} 8 {% endspaceless %} -
tests/urls.py
11 11 12 12 # test urlconf for {% url %} template tag 13 13 (r'^url_tag/', include('regressiontests.templates.urls')), 14 15 # test for form wizard 16 (r'^formtools/', include('regressiontests.formtools.urls')), 14 17 ) -
docs/form_wizard.txt
1 =========== 2 Form Wizard 3 =========== 4 5 **New in Django devleopment version.** 6 7 The form wizard allows you to divide your forms into multiple pages, 8 maintaining form data in hidden fields until submission in the final step. 9 10 Usage 11 ===== 12 13 Define the forms you will use in you wizard in a ``forms.py`` file for your 14 application:: 15 16 from django import newforms as forms 17 18 class ContactForm(forms.Form): 19 subject = forms.CharField(max_length=100) 20 message = forms.CharField() 21 sender = forms.EmailField() 22 cc_myself = forms.BooleanField() 23 24 class ContactFormPage2(forms.Form): 25 code = forms.CharField() 26 27 .. warning:: Do not include ``FileField`` or ``ImageField`` in any form that 28 will not be displayed on the last page of your form wizard. 29 30 In your application's ``views.py``, subclass a form wizard object. You'll 31 need to override the ``done`` method with your own form processing code. 32 In the example below, rather than perform any database operation, the method 33 simply returns a list of the (validated) data the user entered into the form 34 to be displayed by a template ``done.html``:: 35 36 from django.shortcuts import render_to_response 37 from django.contrib.formtools.wizard import Wizard 38 39 class ContactWizard(Wizard): 40 def done(self, request, form_list): 41 return render_to_response('done.html', 42 {'form_data' : [ form.cleaned_data for form in form_list ] }) 43 44 Next, connect your new form wizard object to the path of your choosing in 45 ``urls.py``. The wizard takes a list of your form objects as arguments:: 46 47 from django.conf.urls.defaults import * 48 from mysite.testapp.forms import ContactForm, ContactFormPage2 49 from mysite.testapp.views import ContactWizard 50 51 urlpatterns = patterns('', 52 (r'^contact/$', ContactWizard([ContactForm, ContactFormPage2])), 53 ) 54 55 Finally, you'll need to create a template named ``wizard.html`` in your 56 ``templates`` directory. That template will contain template code that will 57 display your form fields so that they can be processed properly by the form 58 wizard. For example:: 59 60 {% extends "base.html" %} 61 {% block content %} 62 <p>Step {{ current_step }} of {{ step_count }}</p> 63 <form action="." method="POST"> 64 <table> 65 {{ form }} 66 </table> 67 <input type="hidden" name="{{ step_field }}" value="{{ step }}" /> 68 <!-- include previous fields --> 69 {{ previous_fields }} 70 <input type="submit"> 71 </form> 72 {% endblock %} 73 74 The ``current_step`` and ``step_count`` variables can be displayed to notify 75 your users where they are in the process. Note also the presence of the 76 template variables ``step_fields``, ``step``, ``previous_fields``, which are 77 used for hidden form fields. 78 79 Advanced Usage 80 ============== 81 82 You may override the ``process_step`` method in your wizard class to, for 83 example, dynamically generate a ``form_list`` and/or add items to 84 ``self.extra_context`` based on the contents of previously submitted forms:: 85 86 def process_step(self, request, form, step): 87 if step == 0: 88 # do something for the second step in your form wizard 89 # based on data submitted from step 1 90 91 The method is called every time a page is rendered, for all submitted steps.