Ticket #3218: form_wizard_with_tests_and_docs_r6199.diff

File form_wizard_with_tests_and_docs_r6199.diff, 20.1 KB (added by Øyvind Saltvik <oyvind@…>, 17 years ago)

Complete patch for newforms-admin branch

  • django/contrib/formtools/wizard.py

     
     1"""
     2USAGE:
     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"""
     29from django.conf import settings
     30from django.http import Http404
     31from django.shortcuts import render_to_response
     32from django.template.context import RequestContext
     33 
     34from django import newforms as forms
     35import cPickle as pickle
     36import md5
     37 
     38class 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

     
     1from django.contrib.formtools.wizard import Wizard
     2from django import newforms as forms
     3from django.shortcuts import render_to_response
     4
     5class NameForm(forms.Form):
     6    first_name = forms.CharField()
     7    last_name = forms.CharField()
     8
     9class DetailsForm(forms.Form):
     10    phone = forms.CharField()
     11    adress = forms.CharField(widget=forms.Textarea)
     12
     13class ConfirmForm(forms.Form):
     14    confirm = forms.BooleanField()
     15
     16class SureForm(forms.Form):
     17    sure = forms.BooleanField()
     18
     19class 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
     26RegisterWizard = MyWizard([NameForm, DetailsForm, ConfirmForm])
     27
     28def 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

     
     1import unittest
     2from django.test.client import Client
     3
     4first_post = {
     5    '0-first_name': 'John',
     6    '0-last_name': 'Doe',
     7    'wizard_step': '0'
     8}
     9
     10second_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
     18tampered_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
     27third_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
     36fourth_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
     47class 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

     
     1from django.conf.urls.defaults import *
     2import views
     3
     4urlpatterns = 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

     
    1111
    1212    # test urlconf for {% url %} template tag
    1313    (r'^url_tag/', include('regressiontests.templates.urls')),
     14
     15    # test for form wizard
     16    (r'^formtools/', include('regressiontests.formtools.urls')),
    1417)
  • docs/form_wizard.txt

     
     1===========
     2Form Wizard
     3===========
     4
     5**New in Django devleopment version.**
     6
     7The form wizard allows you to divide your forms into multiple pages,
     8maintaining form data in hidden fields until submission in the final step.
     9
     10Usage
     11=====
     12
     13Define the forms you will use in you wizard in a ``forms.py`` file for your
     14application::
     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
     30In your application's ``views.py``, subclass a form wizard object.  You'll
     31need to override the ``done`` method with your own form processing code. 
     32In the example below, rather than perform any database operation, the method
     33simply returns a list of the (validated) data the user entered into the form
     34to 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
     44Next, 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
     55Finally, you'll need to create a template named ``wizard.html`` in your
     56``templates`` directory.  That template will contain template code that will
     57display your form fields so that they can be processed properly by the form
     58wizard.  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
     74The ``current_step`` and ``step_count`` variables can be displayed to notify
     75your users where they are in the process.  Note also the presence of the
     76template variables ``step_fields``, ``step``, ``previous_fields``, which are
     77used for hidden form fields.
     78
     79Advanced Usage
     80==============
     81
     82You may override the ``process_step`` method in your wizard class to, for
     83example, 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
     91The method is called every time a page is rendered, for all submitted steps.
Back to Top