Code

Ticket #9200: session_wizard_patch.txt

File session_wizard_patch.txt, 42.1 KB (added by ddurham, 6 years ago)

did some more testing, fixed a couple bugs, fixed a small problem with the docs

Line 
1Index: /home/david/work/django/django-trunk/django/contrib/formtools/wizard.py
2===================================================================
3--- /home/david/work/django/django-trunk/django/contrib/formtools/wizard.py     (revision 9084)
4+++ /home/david/work/django/django-trunk/django/contrib/formtools/wizard.py     (working copy)
5@@ -1,21 +1,26 @@
6-"""
7-FormWizard class -- implements a multi-page form, validating between each
8-step and storing the form's state as HTML hidden fields so that no state is
9-stored on the server side.
10-"""
11-
12 import cPickle as pickle
13 
14 from django import forms
15 from django.conf import settings
16 from django.http import Http404
17+from django.http import HttpResponseRedirect
18+from django.http import HttpResponse
19 from django.shortcuts import render_to_response
20 from django.template.context import RequestContext
21 from django.utils.hashcompat import md5_constructor
22 from django.utils.translation import ugettext_lazy as _
23 from django.contrib.formtools.utils import security_hash
24+from django.utils.datastructures import SortedDict
25 
26-class FormWizard(object):
27+class BaseWizard(object):
28+    pass
29
30+class FormWizard(BaseWizard):
31+    """
32+    FormWizard class -- implements a multi-page form, validating between each
33+    step and storing the form's state as HTML hidden fields so that no state is
34+    stored on the server side.
35+    """   
36     # Dictionary of extra template context variables.
37     extra_context = {}
38 
39@@ -239,3 +244,428 @@
40         data.
41         """
42         raise NotImplementedError("Your %s class has not defined a done() method, which is required." % self.__class__.__name__)
43+   
44+
45+class SessionWizard(BaseWizard):
46+    """
47+    SessionWizard class -- implements multi-page forms with the following
48+    characteristics:
49+   
50+       1) easily supports navigation to arbitrary pages in the wizard
51+       2) uses GETs to display forms (caveat validation errors) and POSTs for
52+          form submissions
53+   
54+    Pros are support for back-button and arbitrary navigation within pages
55+    (including the oddity of someone clicking on the refresh button)
56+   
57+    The major Con is use of the session scope.  In particular, zero
58+    consideration has been given to multipart form data.
59+    """
60+
61+    # keys used to store wizard data in sessions
62+    __form_classes_key = 'form_classes'
63+    __cleaned_data_key = 'cleaned_data'
64+    __POST_data_key = 'POST_data'
65+    __pages_key = 'pages'
66+   
67+    def __init__(self, forms):
68+        """
69+        A form_classes can be a list of form classes or a list of 2-Tuples in
70+        the form (page_key, form_class).
71+        """
72+        self.base_forms = SortedDict()
73+        if forms:
74+            if type(forms[0]) == tuple:
75+                self.contains_named_pages = True
76+                for page_key, form_class in forms:
77+                    self.base_forms[page_key] = form_class
78+            else:
79+                self.contains_named_pages = False
80+                i = 0
81+                for form_class in forms:
82+                    self.base_forms[str(i)] = form_class
83+                    i = i + 1
84+
85+    def _init_wizard_data(self, request):
86+        """
87+        Copy self.base_forms to the session scope so that subclasses can
88+        manipulate the form_classes for individual users.  Also, initialize
89+        the pages dict.
90+        """
91+        wizard_key = self.get_wizard_data_key(request)
92+        if wizard_key not in request.session:
93+            pages = SortedDict()
94+            for page_key in self.base_forms.keys():
95+                pages[page_key] = {
96+                     'valid' : False,
97+                     'visited' : False,
98+                     'title' : self.get_page_title(request, page_key)
99+                    }
100+            request.session[wizard_key] = {
101+                self.__form_classes_key : self.base_forms.copy(),
102+                self.__cleaned_data_key : {},
103+                self.__POST_data_key : {},
104+                self.__pages_key : pages,
105+            }
106+
107+    def __call__(self, request, *args, **kwargs):
108+        """
109+        Initialize the form_classes for a session if needed and call GET or
110+        POST depending on the provided request's method.
111+        """
112+        self._init_wizard_data(request)
113+
114+        if request.method == 'POST':
115+            return self.POST(request)
116+        else:
117+            return self.GET(request, kwargs['page_key'])
118+   
119+    def GET(self, request, page_key):
120+        """
121+        Initialize a form if necessary, and display the form/page identified by
122+        page_key.
123+        """
124+        page_data = self._get_cleaned_data(request, page_key)
125+        if page_data is None:
126+            form = self._get_form_classes(request)[page_key]()
127+        else:
128+            form_class = self._get_form_classes(request)[page_key]
129+            if issubclass(form_class, forms.ModelForm):
130+                form = form_class(instance=form_class.Meta.model(**page_data))
131+            else:
132+                form = form_class(initial=page_data)
133+       
134+        return self._show_form(request, page_key, form)
135+       
136+    def POST(self, request):
137+        """
138+        Validate form submission, and redirect to GET the next form or return
139+        the response from self.done().  Note that the request.POST data must
140+        contain a value for the key 'page_key', and this value must reference
141+        a form in the form_classes collection for this wizard.
142+        """
143+        form_classes = self._get_form_classes(request)
144+        page_key = request.POST['page_key']
145+        page0 = form_classes.keys().index(page_key)
146+        URL_base = self.get_URL_base(request, page_key)
147+        self._set_POST_data(request, page_key, request.POST)
148+        form = form_classes[page_key](request.POST)
149+        new_page = self.preprocess_submit_form(request, page_key, form)
150+       
151+        if isinstance(new_page, HttpResponse):
152+            return new_page
153+        elif new_page:
154+            return HttpResponseRedirect(URL_base + new_page)
155+        else:
156+            if form.is_valid():
157+                self._set_cleaned_data(request, page_key, form.cleaned_data)
158+                self._set_page(request, page_key, True, True)
159+                is_done = self.process_submit_form(request, page_key, form)
160+                if (not is_done) and len(form_classes) > page0 + 1:
161+                    return HttpResponseRedirect(URL_base +
162+                            self._get_next_page_key(request, page_key))
163+                else:
164+                    first_broken_page, form = self._validate_all_forms(request)
165+                    if first_broken_page is not None:
166+                        return self._show_form(request, first_broken_page,
167+                                               form)
168+                    else:
169+                        return self.done(request)
170+            else:
171+                self._set_page(request, page_key, False)
172+               
173+        return self._show_form(request, page_key, form)
174+   
175+
176+    # form util methods #
177+    def _validate_all_forms(self, request):
178+        """
179+        Iterate through the session form list and validate based on 1) the
180+        'valid' attribute of the page data and 2) the POST data stored in the
181+        session for this wizard.  Return the page key and the form of the first
182+        invalid form or None, None if all forms are valid.
183+        """
184+        for page_key, form_class in self._get_form_classes(request).iteritems():
185+            if not self._get_pages(request)[page_key]['valid']:
186+                form = form_class(self._get_POST_data(request, page_key))
187+                if not form.is_valid():
188+                    return page_key, form
189+        return None, None
190+       
191+    def _show_form(self, request, page_key, form):
192+        """
193+        Show the form associated with indicated page index.
194+        """
195+        URL_base = self.get_URL_base(request, page_key)
196+        extra_context = self.process_show_form(request, page_key, form)
197+        self._set_current_page(request, page_key)
198+        pages = self._get_pages(request)
199+        context = {'page_key' : page_key,
200+                   'form' : form,
201+                   'pages' : pages,
202+                   'URL_base' : URL_base,
203+                   'extra_context' : extra_context }
204+        return render_to_response(self.get_template(page_key), context,
205+                                  RequestContext(request))
206+       
207+    def _get_form_classes(self, request):
208+        """
209+        Return the collection of form classes stored in the provided request's
210+        session.
211+        """
212+        return request.session[self.get_wizard_data_key(request)]\
213+                [self.__form_classes_key]
214+   
215+    def _insert_form(self, request, index, page_key, form_class):
216+        """
217+        Insert a form class into the provided session's form list at the
218+        provided index.
219+        """
220+        form_classes = self._get_form_classes(request)
221+        form_classes.insert(index, page_key, form_class)
222+        self._insert_wizard_data(request, self.__form_classes_key, form_classes)
223+       
224+    def _remove_form(self, request, page_key):
225+        """
226+        Remove the form at index page_key from the provided sessions form list.
227+        """
228+        self._del_wizard_data(request, self.__form_classes_key, page_key)
229+    # end form util methods #
230+
231+
232+    # Form data methods #
233+    def _get_POST_data(self, request, page_key):
234+        """
235+        Return the POST data for a page_key stored in the provided session.
236+        """
237+        post_data = self._get_all_POST_data(request)
238+        if page_key in post_data:
239+            return post_data[page_key]
240+        else:
241+            return {}
242+
243+    def _set_POST_data(self, request, page_key, data):
244+        """
245+        Set the POST data for a given page_key and session to the 'data'
246+        provided.
247+        """
248+        post_data = self._get_all_POST_data(request)
249+        post_data[page_key] = data
250+        self._insert_wizard_data(request, self.__POST_data_key, post_data)
251+   
252+    def _remove_POST_data(self, request, page_key):
253+        """
254+        Remove the POST data stored in the session at index page_key.
255+        """
256+        self._del_wizard_data(request, self.__POST_data_key, page_key)
257+   
258+    def _get_all_POST_data(self, request):
259+        """
260+        Return the dict of all POST data for this wizard from the provided
261+        session.
262+        """
263+        return request.session[self.get_wizard_data_key(request)]\
264+                [self.__POST_data_key]
265+
266+    def _get_cleaned_data(self, request, page_key):
267+        """
268+        Return the cleaned data from the provided session for this wizard based
269+        on the provided page_key.
270+        """
271+        cleaned_data = self._get_all_cleaned_data(request)
272+        if page_key in cleaned_data:
273+            return cleaned_data[page_key]
274+        else:
275+            return {}
276+           
277+    def _set_cleaned_data(self, request, page_key, data):
278+        """
279+        Assign the cleaned data for this wizard in the session at index
280+        page_key.
281+        """
282+        cleaned_data = self._get_all_cleaned_data(request)
283+        cleaned_data[page_key] = data
284+        self._insert_wizard_data(request, self.__cleaned_data_key, cleaned_data)
285+
286+    def _get_all_cleaned_data(self, request):
287+        """
288+        Return a list of all the cleaned data in the session for this wizard.
289+        """
290+        wizard_data = request.session[self.get_wizard_data_key(request)]
291+        return wizard_data[self.__cleaned_data_key]
292+   
293+    def _remove_cleaned_data(self, request, page_key):
294+        """
295+        Remove the cleaned data at index page_key for this wizard from the
296+        provided session.
297+        """
298+        self._del_wizard_data(request, self.__cleaned_data_key, page_key)
299+    # end Form data methods #
300+
301+   
302+    # page methods #
303+    def _get_next_page_key(self, request, page_key):
304+        """
305+        Return the next page_key after the provided page_key in the sequence of
306+        pages.  If this is a named pages wizard, this method iterates
307+        through keys.  Otherwise it will simply iterate the page_key.
308+        This method must return a String.
309+        """
310+        form_classes_keys = self._get_form_classes(request).keys()
311+        return form_classes_keys[form_classes_keys.index(page_key) + 1]
312+       
313+    def _set_current_page(self, request, page_key):
314+        """
315+        Iterate through the page dicts in the session and set 'current_page' to
316+        True for the page corresponding to page_key and False for all others.
317+        """
318+        for key, page in self._get_pages(request).iteritems():
319+            if key == page_key:
320+                page['current_page'] = True
321+            else:
322+                page['current_page'] = False
323+
324+    def _get_pages(self, request):
325+        """
326+        Return the list of page info dicts stored in the provided session for
327+        this wizard.
328+        """
329+        return request.session[self.get_wizard_data_key(request)]\
330+                [self.__pages_key]
331+
332+    def _remove_page_data(self, request, page_key):
333+        """
334+        Remove page data from the provided session for this wizard based on a
335+        given page_key.  This removes page information, form_class and form
336+        data.
337+        """
338+        self._remove_form(request, page_key)
339+        self._remove_page(request, page_key)
340+        self._remove_cleaned_data(request, page_key)
341+        self._remove_POST_data(request, page_key)
342+
343+    def _remove_page(self, request, page_key):
344+        """
345+        Remove the page info dict for this wizard stored at a given page_key
346+        from the provided session.
347+        """
348+        self._del_wizard_data(request, self.__pages_key, page_key)
349+   
350+    def _insert_page(self, request, index, page_key, form_class):
351+        """
352+        Insert a page into this wizard at the provided form_class index, storing
353+        required associated data.
354+        """
355+        self._insert_form(request, index, page_key, form_class)
356+        self._set_page(request, page_key, False)
357+        self._set_cleaned_data(request, page_key, {})
358+        self._set_POST_data(request, page_key, {})
359+
360+    def _set_page(self, request, page_key, valid=False, visited=False):
361+        """
362+        Set the page info in this wizard for a page at index page_key and stored
363+        in the provided session.
364+        """
365+        page_info = {
366+           'valid' : valid,
367+           'visited' : visited,
368+           'title' : self.get_page_title(request, page_key)
369+        }
370+        pages = self._get_pages(request)
371+        pages[page_key] = page_info
372+        self._insert_wizard_data(request, self.__pages_key, pages)
373+    # end page methods #
374+
375+    # start wizard data utils #
376+    def _clear_wizard_data_from_session(self, request):
377+        """
378+        Clear the session data used by this wizard from the provided session.
379+        """
380+        del request.session[self.get_wizard_data_key(request)]
381+
382+    def _insert_wizard_data(self, request, key, data):
383+        """
384+        Inserts wizard data into the provided session at the provided key.
385+        """
386+        wizard_data = request.session[self.get_wizard_data_key(request)]
387+        wizard_data[key] = data
388+        request.session[self.get_wizard_data_key(request)] = wizard_data
389+   
390+    def _del_wizard_data(self, request, key, page_key):
391+        """
392+        Deletes wizard data from the provided session based on a page_key.
393+        """
394+        wizard_data = request.session[self.get_wizard_data_key(request)]
395+        sub_set = wizard_data[key]
396+        if page_key in sub_set:
397+            del sub_set[page_key]
398+            wizard_data[key] = sub_set
399+            request.session[self.get_wizard_data_key(request)] = wizard_data
400+       
401+    # end wizard data utils #
402+   
403+    # typically overriden methods #
404+    def get_wizard_data_key(self, request):
405+        """
406+        Return a session key for this wizard.  The provided request could be
407+        used to prevent overlapping keys in the case that someone needs
408+        multiple instances of this wizard at one time.
409+        """
410+        return 'session_wizard_data'
411+   
412+    def get_URL_base(self, request, page_key):
413+        """
414+        Return the URL to this wizard minus the "page_key" part of the URL. 
415+        This value is passed to template as URL_base.
416+        """
417+        return request.path.replace("/" + page_key, "/")
418+   
419+    def get_page_title(self, request, page_key):
420+        """
421+        Return a user friendly title for the page at index page_key.
422+        """
423+        if self.contains_named_pages:
424+            return page_key.replace("_", " ").title()
425+        else:
426+            return 'Page %s' % str(int(page_key) + 1)
427+   
428+    def process_show_form(self, request, page_key, form):
429+        """
430+        Called before rendering a form either from a GET or when a form submit
431+        is invalid.
432+        """
433+
434+    def preprocess_submit_form(self, request, page_key, form):
435+        """
436+        Called when a form is POSTed, but before the form is validated.  If this
437+        function returns None then form submission continues, else it should
438+        return either a Response object or a new page index that will be
439+        redirected to as a GET.
440+        """
441+       
442+    def process_submit_form(self, request, page_key, form):
443+        """
444+        Called when a form is POSTed.  This is only called if the form data is
445+        valid.  If this method returns True, the done() method is called,
446+        otherwise the wizard continues.  Note that it is possible that this
447+        method would not return True, and done() would still be called because
448+        there are no more forms left in the form_classes.
449+        """
450+       
451+    def get_template(self, page_key):
452+        """
453+        Hook for specifying the name of the template to use for a given page.
454+        Note that this can return a tuple of template names if you'd like to
455+        use the template system's select_template() hook.
456+        """
457+        return 'forms/session_wizard.html'
458+
459+    def done(self, request):
460+        """
461+        Hook for doing something with the validated data. This is responsible
462+        for the final processing including clearing the session scope of items
463+        created by this wizard.
464+        """
465+        raise NotImplementedError("Your %s class has not defined a done() " + \
466+                                  "method, which is required." \
467+                                  % self.__class__.__name__)
468Index: /home/david/work/django/django-trunk/django/contrib/formtools/tests.py
469===================================================================
470--- /home/david/work/django/django-trunk/django/contrib/formtools/tests.py      (revision 9084)
471+++ /home/david/work/django/django-trunk/django/contrib/formtools/tests.py      (working copy)
472@@ -1,8 +1,11 @@
473 from django import forms
474+from django.db import models
475 from django.contrib.formtools import preview, wizard
476 from django import http
477 from django.test import TestCase
478+from django.test.client import Client
479 
480+
481 success_string = "Done was called!"
482 
483 class TestFormPreview(preview.FormPreview):
484@@ -141,4 +144,168 @@
485         request = DummyRequest(POST={"0-field":"test", "wizard_step":"0"})
486         response = wizard(request)
487         self.assertEquals(1, wizard.step)
488+       
489 
490+#
491+# SessionWizard tests
492+#
493+class SessionWizardModel(models.Model):
494+    field = models.CharField(max_length=10)
495+   
496+class SessionWizardPageOneForm(forms.Form):
497+    field = forms.CharField(required=True)
498+
499+class SessionWizardPageTwoForm(forms.ModelForm):
500+    class Meta:
501+        model = SessionWizardModel
502+
503+class SessionWizardPageThreeForm(forms.Form):
504+    field = forms.CharField()
505+
506+class SessionWizardDynamicPageForm(forms.Form):
507+    field = forms.CharField()
508+       
509+class SessionWizardClass(wizard.SessionWizard):
510+    def get_page_title(self, request, page_key):
511+        try:
512+            return "Custom Page Title: %s" % str(int(page_key) + 1)
513+        except ValueError:
514+            return super(SessionWizardClass, self).get_page_title(request,
515+                                                                  page_key)
516+
517+    def process_show_form(self, request, page_key, form):
518+        try:
519+            return {'form_title' : 'Form %s' % str(int(page_key) + 1)}
520+        except ValueError:
521+            return super(SessionWizardClass, self).process_show_form(request,
522+                                                             page_key, form)
523+
524+    def preprocess_submit_form(self, request, page_key, form):
525+        if page_key == "1" and request.POST['field'] == "":
526+            self._remove_page(request, page_key)
527+            return str(int(page_key) - 1)
528+
529+    def process_submit_form(self, request, page_key, form):
530+        if page_key == '2':
531+            self._insert_page(request, 3, str(int(page_key) + 1),
532+                              SessionWizardDynamicPageForm)
533+   
534+    def get_template(self, page_key):
535+        return "formtools/form.html"
536+
537+    def done(self, request):
538+        return http.HttpResponse(success_string)
539+
540+class SessionWizardTests(TestCase):
541+    urls = 'django.contrib.formtools.test_urls'
542+
543+    def test_named_pages_wizard_get(self):
544+        """
545+        Tests that a wizard is created properly based on it's initialization
546+        argument, which could be a sequence or dictionary-like object.
547+        """
548+        response = self.client.get('/named_pages_wizard/first_page_form')
549+        self.assertEquals(200, response.status_code)
550+        self.assertEquals('First Page Form',
551+                      response.context[0]['pages']['first_page_form']['title'])
552+       
553+
554+    def test_valid_POST(self):
555+        """
556+        Tests that a post containing valid data will set session values
557+        correctly and redirect to the next page.
558+        """
559+        response = self.client.post('/sessionwizard/', {"page_key":"0",
560+                                                          "field":"test"})
561+        self.assertEquals(302, response.status_code)
562+        self.assertEquals("http://testserver/sessionwizard/1",
563+                          response['Location'])
564+        session = self.client.session
565+        cleaned_data = session['session_wizard_data']['cleaned_data']
566+        post_data = session['session_wizard_data']['POST_data']
567+        self.assertEquals('test', cleaned_data['0']['field'])
568+        self.assertEquals('test', post_data['0']['field'])
569+
570+    def test_invalid_POST(self):
571+        """
572+        Tests that a post containing invalid data will set session values
573+        correctly and redisplay the form.
574+        """
575+        response = self.client.post('/sessionwizard/', {"page_key":"0",
576+                                                          "field":""})
577+        self.assertEquals(200, response.status_code)
578+        session = self.client.session
579+        post_data = session['session_wizard_data']['POST_data']
580+        self.assertEquals('', post_data['0']['field'])
581+   
582+    def test_GET(self):
583+        """
584+        Tests that a get will display a page properly.
585+        """
586+        response = self.client.get('/sessionwizard/0')
587+        self.assertEquals(200, response.status_code)
588+   
589+    def test_preprocess_submit_form(self):
590+        """
591+        Tests the preprocess_submit_form hook of SessionWizard POSTs.
592+        The SessionWizardClass is coded to short-circuit a POST for page index 1
593+        when form.cleaned_data['field'] == '' by returning a reference to page_key
594+        index 0.
595+        """
596+        response = self.client.post('/sessionwizard/', {"page_key":"1",
597+                                                          "field":""})
598+        self.assertEquals(302, response.status_code)
599+        self.assertEquals("http://testserver/sessionwizard/0",
600+                          response['Location'])
601+   
602+    def test_process_submit_form(self):
603+        """
604+        Tests the process_submit_form hook of SessionWizard POSTs.
605+        The SessionWizardClass is coded to insert a new page at index 3 on a
606+        POST for page index 2.
607+        """
608+        response = self.client.post('/sessionwizard/', {"page_key":"2",
609+                                                          "field":"test"})
610+        self.assertEquals(302, response.status_code)
611+        self.assertEquals("http://testserver/sessionwizard/3",
612+                          response['Location'])
613+        self.assertEquals({"0":SessionWizardPageOneForm,
614+                           "1":SessionWizardPageTwoForm,
615+                           "2":SessionWizardPageThreeForm,
616+                           "3":SessionWizardDynamicPageForm},
617+            self.client.session['session_wizard_data']['form_classes'])
618+   
619+    def test_process_show_form(self):
620+        """
621+        Tests the process_show_form hook.  SessionWizardClass is coded to
622+        return a extra_context having a specific 'form_title' attribute.
623+        """
624+        response = self.client.get('/sessionwizard/0')
625+        self.assertEquals(200, response.status_code)
626+        self.assertEquals("Form 1",
627+                          response.context[0]['extra_context']['form_title'])
628+   
629+    def test_validate_all(self):
630+        """
631+        Submits all forms, with one of them being invalid, and tests that
632+        submitting the last form will catch an invalid form earlier in the
633+        workflow and redisplay it.
634+        """
635+        response = self.client.post('/sessionwizard/', {"page_key":"0", "field":""})
636+        self.client.post('/sessionwizard/', {"page_key":"1", "field":"test1"})
637+        self.client.post('/sessionwizard/', {"page_key":"2", "field":"test2"})
638+        response = self.client.post('/sessionwizard/3',
639+                                   {"page_key":"3", "field":"test3"})
640+        self.assertEquals(True, response.context[0]['pages']['1']['visited'])
641+        self.assertEquals(True, response.context[0]['pages']['1']['valid'])
642+
643+        self.assertEquals("Form 1",
644+                          response.context[0]['extra_context']['form_title'])
645+   
646+    def test_done(self):
647+        self.client.post('/sessionwizard/', {"page_key":"0", "field":"test0"})
648+        self.client.post('/sessionwizard/', {"page_key":"1", "field":"test1"})
649+        self.client.post('/sessionwizard/', {"page_key":"2", "field":"test2"})
650+        response = self.client.post('/sessionwizard/',
651+                                   {"page_key":"3", "field":"test3"})
652+        self.assertEqual(response.content, success_string)
653Index: /home/david/work/django/django-trunk/django/contrib/formtools/test_urls.py
654===================================================================
655--- /home/david/work/django/django-trunk/django/contrib/formtools/test_urls.py  (revision 9084)
656+++ /home/david/work/django/django-trunk/django/contrib/formtools/test_urls.py  (working copy)
657@@ -9,4 +9,13 @@
658 
659 urlpatterns = patterns('',
660                        (r'^test1/', TestFormPreview(TestForm)),
661+                       (r'^sessionwizard/(?P<page_key>\d*)$',
662+                            SessionWizardClass([SessionWizardPageOneForm,
663+                                               SessionWizardPageTwoForm,
664+                                               SessionWizardPageThreeForm])),
665+                       (r'^named_pages_wizard/(?P<page_key>\w*)$',
666+                            SessionWizardClass((
667+                                ('first_page_form', SessionWizardPageOneForm),
668+                                ('page2', SessionWizardPageTwoForm),
669+                                ('page3', SessionWizardPageThreeForm),)))
670                       )
671Index: /home/david/work/django/django-trunk/docs/ref/contrib/formtools/session-wizard.txt
672===================================================================
673--- /home/david/work/django/django-trunk/docs/ref/contrib/formtools/session-wizard.txt  (revision 0)
674+++ /home/david/work/django/django-trunk/docs/ref/contrib/formtools/session-wizard.txt  (revision 0)
675@@ -0,0 +1,348 @@
676+.. _ref-contrib-formtools-session-wizard:
677+
678+==============
679+Session wizard
680+==============
681+
682+.. module:: django.contrib.formtools.wizard
683+    :synopsis: Splits forms across multiple Web pages using users' sessions to store form and page data.
684+
685+.. versionadded:: 1.x
686+
687+Django comes with an optional "session wizard" application that splits
688+:ref:`forms <topics-forms-index>` across multiple Web pages. It maintains
689+state in users' sessions incurring additional resource costs on a server
690+but also creating a smoother workflow for users.
691+
692+Note that SessionWizard is similar to :ref:`FormWizard <ref-contrib-formtools-form-wizard>`
693+and some of these examples and documentation mirror FormWizard examples and
694+documentation exactly.
695+
696+You might want to use this if you have a workflow or lengthy form and want to
697+provide navigation to various pages in the wizard.
698+
699+The term "wizard," in this context, is `explained on Wikipedia`_.
700+
701+.. _explained on Wikipedia: http://en.wikipedia.org/wiki/Wizard_%28software%29
702+.. _forms: ../forms/
703+
704+How it works
705+============
706+
707+Here's the basic workflow for how a user would use a wizard:
708+
709+    1. The user visits the first page of the wizard, fills in the form and
710+       submits it.
711+    2. The server validates the data. If it's invalid, the form is displayed
712+       again, with error messages. If it's valid, the server stores this data
713+       in a user's session and sends an HTTP redirect to GET the next page.
714+    3. Step 1 and 2 repeat, for every subsequent form in the wizard.
715+    4. Once the user has submitted all the forms and all the data has been
716+       validated, the wizard processes the data -- saving it to the database,
717+       sending an e-mail, or whatever the application needs to do.
718+
719+Usage
720+=====
721+
722+This application handles as much machinery for you as possible. Generally, you
723+just have to do these things:
724+
725+    1. Define a number of :mod:`django.forms`
726+       :class:`~django.forms.forms.Form` classes -- one per wizard page.
727+       
728+    2. Create a :class:`~django.contrib.formtools.wizard.SessionWizard` class
729+       that specifies what to do once all of your forms have been submitted
730+       and validated. This also lets you override some of the wizard's behavior.
731+       
732+    3. Create some templates that render the forms. You can define a single,
733+       generic template to handle every one of the forms, or you can define a
734+       specific template for each form.
735+       
736+    4. Point your URLconf at your
737+       :class:`~django.contrib.formtools.wizard.SessionWizard` class.
738+
739+Defining ``Form`` classes
740+=========================
741+
742+The first step in creating a form wizard is to create the
743+:class:`~django.forms.forms.Form` classes.  These should be standard
744+:mod:`django.forms` :class:`~django.forms.forms.Form` classes, covered in the
745+:ref:`forms documentation <topics-forms-index>`.
746+
747+These classes can live anywhere in your codebase, but convention is to put them
748+in a file called :file:`forms.py` in your application.
749+
750+For example, let's write a "contact form" wizard, where the first page's form
751+collects the sender's e-mail address and subject, and the second page collects
752+the message itself. Here's what the :file:`forms.py` might look like::
753+
754+   from django import forms
755+
756+    class ContactForm1(forms.Form):
757+        subject = forms.CharField(max_length=100)
758+        sender = forms.EmailField()
759+
760+    class ContactForm2(forms.Form):
761+        message = forms.CharField(widget=forms.Textarea)
762+
763+**Important limitation:** Because the wizard uses users' sessions to store
764+data between pages, you should seriously consider whether or not it
765+makes sense to include :class:`~django.forms.fields.FileField` in any forms.
766+
767+Creating a ``SessionWizard`` class
768+==================================
769+
770+The next step is to create a :class:`~django.contrib.formtools.wizard.SessionWizard`
771+class, which should be a subclass of ``django.contrib.formtools.wizard.SessionWizard``.
772+
773+As your :class:`~django.forms.forms.Form` classes, this
774+:class:`~django.contrib.formtools.wizard.SessionWizard` class can live anywhere
775+in your codebase, but convention is to put it in :file:`forms.py`.
776+
777+The only requirement on this subclass is that it implement a
778+:meth:`~django.contrib.formtools.wizard.SessionWizard.done()` method,
779+which specifies what should happen when the data for *every* form is submitted
780+and validated. This method is passed one argument:
781+
782+    * ``request`` -- an :class:`~django.http.HttpRequest` object
783+
784+In this simplistic example, rather than perform any database operation, the
785+method simply renders a template of the validated data::
786+
787+    from django.shortcuts import render_to_response
788+    from django.contrib.formtools.wizard import SessionWizard
789+
790+    class ContactWizard(SessionWizard):
791+        def done(self, request):
792+            form_data = self._get_all_cleaned_data(request.session)
793+            self._clear_wizard_data_from_session(request.session)
794+            return render_to_response('done.html', {
795+                'form_data': form_data,
796+            })
797+
798+Note that this method will be called via ``POST``, so it really ought to be a
799+good Web citizen and redirect after processing the data. Here's another
800+example::
801+
802+    from django.http import HttpResponseRedirect
803+    from django.contrib.formtools.wizard import SessionWizard
804+
805+    class ContactWizard(SessionWizard):
806+        def done(self, request):
807+            form_data = self._get_all_cleaned_data(request.session)
808+            self._clear_wizard_data_from_session(request.session)
809+            do_something_with_the_form_data(form_data)
810+            return HttpResponseRedirect('/page-to-redirect-to-when-done/')
811+
812+See the section `Advanced SessionWizard methods`_ below to learn about more
813+:class:`~django.contrib.formtools.wizard.SessionWizard` hooks.
814+
815+Creating templates for the forms
816+================================
817+
818+Next, you'll need to create a template that renders the wizard's forms. By
819+default, every form uses a template called :file:`forms/session_wizard.html`.
820+(You can change this template name by overriding
821+:meth:`~django.contrib.formtools.wizard..get_template()`, which is documented
822+below. This hook also allows you to use a different template for each form.)
823+
824+This template expects the following context:
825+
826+    * ``page_key`` -- A string representation of the current page in this
827+      wizard.  Depending on how a wizard is created, this could be a page name
828+      or a zero-based page index.
829+    * ``form`` -- The :class:`~django.forms.forms.Form` instance for the
830+      current page (empty, populated or populated with errors).
831+    * ``pages`` -- The current list of pages for this wizard.  This is a dict of
832+      dict objects in the form::
833+     
834+        {'page_key1' : {'title' : 'page1',
835+                        'visited': True,
836+                        'valid' : True,
837+                        'current_page' : False
838+                       },
839+         'page_key2' : {'title' : 'page2',
840+                        'visited': False,
841+                        'valid' : False,
842+                        'current_page' : True
843+                       },
844+          ..
845+         }
846+    * ``URL_base`` -- The base URL used to generate links to pages in this
847+      wizard.  By default, it is the request.path value minus the ``page_key``.
848+    * 'extra_context' -- A dict returned from the
849+      :meth:`~django.contrib.formtools.wizard.SessionWizard.process_show_form()`
850+      hook.
851+
852+Here's a full example template:
853+
854+.. code-block:: html+django
855+
856+    {% extends "base.html" %}
857+   
858+    {% block content %}
859+    <ul>
860+    {% for page_key,page in pages.items %}
861+        <li class="{% if page.valid %}valid{% endif %}
862+                   {% if page.current_page %}current{% endif %}">
863+        {% if page.visited %}
864+            <a href="{{ URL_base }}{{ page_key }}">{{ page.title }}</a>
865+        {% else %}
866+            {{ page.title }}
867+        {% endif %}
868+        </li>
869+    {% endfor %}
870+    </ul>
871+    <form action="." method="post">
872+    <table>
873+    {{ form }}
874+    </table>
875+    <input type="hidden" name="page_key" value="{{ page_key }}"/>
876+    <input type="submit">
877+    </form>
878+    {% endblock %}
879+
880+Note that ``page_key`` is required for the wizard to work properly.
881+
882+Hooking the wizard into a URLconf
883+=================================
884+
885+Finally, give your new :class:`~django.contrib.formtools.wizard.SessionWizard`
886+object a URL in ``urls.py``. The wizard has two types of initialization.  The
887+first takes a list of your form objects as arguments, and the seconds takes a
888+sequence of 2-tuples in the form (page_key, form_class).  The two types are
889+illustrated below::
890+
891+    from django.conf.urls.defaults import *
892+    from mysite.testapp.forms import ContactForm1, ContactForm2, ContactWizard
893+
894+    urlpatterns = patterns('',
895+        ## First form - a list of form classes
896+        (r'^contact/(?P<page_key>\d*)$', ContactWizard([ContactForm1, ContactForm2])),
897+       
898+        ## Second form - a sequence of 2-tuples
899+        (r'^contact/(?P<page_key>\w*)$', ContactWizard((("subject_and_sender", ContactForm1),
900+                                                        ("message", ContactForm2)))),
901+    )
902+   
903+In the first type of SessionWizard initialization, a list of form classes is
904+provided.  The ``page_key`` values matched from a URL are auto-generated zero-based
905+digits.  Note these values are stored as strings not integers, which is
906+something to keep in mind while referencing ``page_key`` values in any SessionWizard
907+hooks described below.
908+
909+In the second style of initialization, the ``page_key`` values from a URL are
910+matched exactly with ``page_key`` values provided in a sequence of 2-tuples.
911+
912+Advanced SessionWizard methods
913+==============================
914+
915+.. class:: SessionWizard
916+
917+    Aside from the :meth:`~django.contrib.formtools.wizard.SessionWizard.done()`
918+    method, :class:`~django.contrib.formtools.wizard.SessionWizard` offers a few
919+    advanced method hooks that let you customize how your wizard works.
920+
921+    Some of these methods take an argument ``page_key``, which is a string
922+    representing the current page.  As noted above, if a wizard is created
923+    from a list of form classes, then this string is a zero-based auto-incremented
924+    value.  Otherwise, if a wizard is created from a sequence of 2-tuples,
925+    the ``page_key`` is the name of the page.
926+
927+.. method:: SessionWizard.get_wizard_data_key
928+
929+    Given a user's session, returns a value to be used as a key for storing and
930+    retrieving a wizard's data.  Note that a request is provided so that a
931+    wizard could potentially avoid namespace collision in the event that
932+    multiple instances of a wizard are required concurrently for a single user.
933+
934+    Default implementation::
935+
936+        def get_wizard_data_key(self, request):
937+            return "session_wizard_data"
938+
939+.. method:: SessionWizard.get_URL_base
940+
941+    Returns a URL that will be used when generating redirects.  To
942+    generate a redirect to GET the next page in a wizard, the SessionWizard
943+    class appends a ``page_key`` to the value returned from this function. 
944+
945+    Default implementation::
946+
947+        def get_URL_base(self, request, page_key):
948+            return request.path.replace("/" + page_key, "/")
949+
950+.. method:: SessionWizard.get_page_title
951+
952+    Return a title that will be placed in the ``pages`` template context dict.
953+
954+    Default implementation::
955+
956+        def get_page_title(self, request, page_key):
957+            if self.contains_named_pages:
958+                return page_key.replace("_", " ").title()
959+            else:
960+                return 'Page %s' % str(int(page_key) + 1)
961+
962+.. method:: SessionWizard.process_show_form
963+
964+    A hook for providing ``extra_context`` for a page.
965+
966+    By default, this does nothing.
967+
968+    Example::
969+
970+        def process_show_form(self, request, page_key, form):
971+            return {'form_title' : '%s Form ' % page_key}
972+
973+.. method:: SessionWizard.get_template
974+
975+    Return the name of the template that should be used for a given ``page_key``.
976+
977+    By default, this returns :file:`'forms/session_wizard.html'`, regardless of
978+    ``page_key``.
979+
980+    Example::
981+
982+        def get_template(self, page_key):
983+            return 'myapp/wizard_%s.html' % page_key
984+
985+    If :meth:`~SessionWizard.get_template` returns a list of strings, then the
986+    wizard will use the template system's :func:`~django.template.loader.select_template()`
987+    function, :ref:`explained in the template docs <ref-templates-api-the-python-api>`.
988+    This means the system will use the first template that exists on the
989+    filesystem. For example::
990+
991+        def get_template(self, step):
992+            return ['myapp/wizard_%s.html' % step, 'myapp/wizard.html']
993+
994+    .. _explained in the template docs: ../templates_python/#the-python-api
995+
996+.. method:: SessionWizard.preprocess_submit_form
997+
998+    Provides a means to short-circuit form posts and do something different
999+    than the normal flow of validating the form and proceeding to the next page.
1000+    For instance, a wizard could present the user with a "Delete this page"
1001+    button, and use this hook to remove the stored data associated with the
1002+    provided ``page_key`` and redirect to a specific ``page_key``.
1003+   
1004+    The return value can be either an HttpResponse or a ``page_key`` string.
1005+
1006+    Example::
1007+   
1008+        def preprocess_submit_form(self, request, page_key, form):
1009+            if request.POST['submit'] == "Delete this page":
1010+                self._remove_page(request, page_key)
1011+                return "next_page"
1012+
1013+.. method:: SessionWizard.process_submit_form
1014+
1015+    This is a hook for doing something after a valid form submission.  For
1016+    instance, a wizard could persist the wizard's state after each submission
1017+    and later allow users to resume their work after a session timeout or
1018+    system failure.
1019+
1020+    The function signature::
1021+
1022+        def process_submit_form(self, request, page_key, form):
1023+            # ...
1024Index: /home/david/work/django/django-trunk/docs/ref/contrib/formtools/index.txt
1025===================================================================
1026--- /home/david/work/django/django-trunk/docs/ref/contrib/formtools/index.txt   (revision 9084)
1027+++ /home/david/work/django/django-trunk/docs/ref/contrib/formtools/index.txt   (working copy)
1028@@ -10,3 +10,4 @@
1029 
1030    form-preview
1031    form-wizard
1032+   session-wizard