Index: /home/david/work/django/django-trunk/django/contrib/formtools/wizard.py
===================================================================
--- /home/david/work/django/django-trunk/django/contrib/formtools/wizard.py	(revision 9084)
+++ /home/david/work/django/django-trunk/django/contrib/formtools/wizard.py	(working copy)
@@ -1,21 +1,26 @@
-"""
-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.
-"""
-
 import cPickle as pickle
 
 from django import forms
 from django.conf import settings
 from django.http import Http404
+from django.http import HttpResponseRedirect
+from django.http import HttpResponse
 from django.shortcuts import render_to_response
 from django.template.context import RequestContext
 from django.utils.hashcompat import md5_constructor
 from django.utils.translation import ugettext_lazy as _
 from django.contrib.formtools.utils import security_hash
+from django.utils.datastructures import SortedDict
 
-class FormWizard(object):
+class BaseWizard(object):
+    pass
+  
+class FormWizard(BaseWizard):
+    """
+    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.
+    """    
     # Dictionary of extra template context variables.
     extra_context = {}
 
@@ -239,3 +244,428 @@
         data.
         """
         raise NotImplementedError("Your %s class has not defined a done() method, which is required." % self.__class__.__name__)
+    
+
+class SessionWizard(BaseWizard):
+    """
+    SessionWizard class -- implements multi-page forms with the following 
+    characteristics:
+    
+       1) easily supports navigation to arbitrary pages in the wizard
+       2) uses GETs to display forms (caveat validation errors) and POSTs for 
+          form submissions
+    
+    Pros are support for back-button and arbitrary navigation within pages 
+    (including the oddity of someone clicking on the refresh button)
+    
+    The major Con is use of the session scope.  In particular, zero 
+    consideration has been given to multipart form data. 
+    """
+
+    # keys used to store wizard data in sessions 
+    __form_classes_key = 'form_classes'
+    __cleaned_data_key = 'cleaned_data'
+    __POST_data_key = 'POST_data'
+    __pages_key = 'pages'
+    
+    def __init__(self, forms):
+        """
+        A form_classes can be a list of form classes or a list of 2-Tuples in 
+        the form (page_key, form_class).
+        """
+        self.base_forms = SortedDict()
+        if forms:
+            if type(forms[0]) == tuple:
+                self.contains_named_pages = True
+                for page_key, form_class in forms:
+                    self.base_forms[page_key] = form_class
+            else:
+                self.contains_named_pages = False
+                i = 0
+                for form_class in forms:
+                    self.base_forms[str(i)] = form_class
+                    i = i + 1
+
+    def _init_wizard_data(self, request):
+        """ 
+        Copy self.base_forms to the session scope so that subclasses can 
+        manipulate the form_classes for individual users.  Also, initialize
+        the pages dict.
+        """
+        wizard_key = self.get_wizard_data_key(request)
+        if wizard_key not in request.session:
+            pages = SortedDict()
+            for page_key in self.base_forms.keys():
+                pages[page_key] = {
+                     'valid' : False, 
+                     'visited' : False,
+                     'title' : self.get_page_title(request, page_key)
+                    }
+            request.session[wizard_key] = {
+                self.__form_classes_key : self.base_forms.copy(), 
+                self.__cleaned_data_key : {}, 
+                self.__POST_data_key : {}, 
+                self.__pages_key : pages,
+            }
+
+    def __call__(self, request, *args, **kwargs):
+        """
+        Initialize the form_classes for a session if needed and call GET or
+        POST depending on the provided request's method.
+        """
+        self._init_wizard_data(request)
+
+        if request.method == 'POST':
+            return self.POST(request)
+        else:
+            return self.GET(request, kwargs['page_key'])
+    
+    def GET(self, request, page_key):
+        """
+        Initialize a form if necessary, and display the form/page identified by 
+        page_key.
+        """
+        page_data = self._get_cleaned_data(request, page_key)
+        if page_data is None:
+            form = self._get_form_classes(request)[page_key]()
+        else:
+            form_class = self._get_form_classes(request)[page_key]
+            if issubclass(form_class, forms.ModelForm):
+                form = form_class(instance=form_class.Meta.model(**page_data))
+            else:
+                form = form_class(initial=page_data)
+        
+        return self._show_form(request, page_key, form)
+        
+    def POST(self, request):
+        """
+        Validate form submission, and redirect to GET the next form or return 
+        the response from self.done().  Note that the request.POST data must
+        contain a value for the key 'page_key', and this value must reference
+        a form in the form_classes collection for this wizard.
+        """
+        form_classes = self._get_form_classes(request)
+        page_key = request.POST['page_key']
+        page0 = form_classes.keys().index(page_key)
+        URL_base = self.get_URL_base(request, page_key)
+        self._set_POST_data(request, page_key, request.POST)
+        form = form_classes[page_key](request.POST)
+        new_page = self.preprocess_submit_form(request, page_key, form)
+        
+        if isinstance(new_page, HttpResponse):
+            return new_page
+        elif new_page:
+            return HttpResponseRedirect(URL_base + new_page)
+        else:
+            if form.is_valid():
+                self._set_cleaned_data(request, page_key, form.cleaned_data)
+                self._set_page(request, page_key, True, True)
+                is_done = self.process_submit_form(request, page_key, form)
+                if (not is_done) and len(form_classes) > page0 + 1:
+                    return HttpResponseRedirect(URL_base + 
+                            self._get_next_page_key(request, page_key))
+                else:
+                    first_broken_page, form = self._validate_all_forms(request)
+                    if first_broken_page is not None:
+                        return self._show_form(request, first_broken_page, 
+                                               form)
+                    else:
+                        return self.done(request)
+            else:
+                self._set_page(request, page_key, False)
+                
+        return self._show_form(request, page_key, form)
+    
+
+    # form util methods #
+    def _validate_all_forms(self, request):
+        """
+        Iterate through the session form list and validate based on 1) the 
+        'valid' attribute of the page data and 2) the POST data stored in the 
+        session for this wizard.  Return the page key and the form of the first 
+        invalid form or None, None if all forms are valid.
+        """
+        for page_key, form_class in self._get_form_classes(request).iteritems():
+            if not self._get_pages(request)[page_key]['valid']:
+                form = form_class(self._get_POST_data(request, page_key))
+                if not form.is_valid():
+                    return page_key, form
+        return None, None
+        
+    def _show_form(self, request, page_key, form):
+        """
+        Show the form associated with indicated page index.
+        """
+        URL_base = self.get_URL_base(request, page_key)
+        extra_context = self.process_show_form(request, page_key, form)
+        self._set_current_page(request, page_key)
+        pages = self._get_pages(request)
+        context = {'page_key' : page_key,
+                   'form' : form,
+                   'pages' : pages,
+                   'URL_base' : URL_base,
+                   'extra_context' : extra_context }
+        return render_to_response(self.get_template(page_key), context, 
+                                  RequestContext(request))
+        
+    def _get_form_classes(self, request):
+        """
+        Return the collection of form classes stored in the provided request's
+        session.
+        """
+        return request.session[self.get_wizard_data_key(request)]\
+                [self.__form_classes_key]
+    
+    def _insert_form(self, request, index, page_key, form_class):
+        """
+        Insert a form class into the provided session's form list at the
+        provided index.
+        """
+        form_classes = self._get_form_classes(request)
+        form_classes.insert(index, page_key, form_class)
+        self._insert_wizard_data(request, self.__form_classes_key, form_classes)
+        
+    def _remove_form(self, request, page_key):
+        """
+        Remove the form at index page_key from the provided sessions form list.
+        """
+        self._del_wizard_data(request, self.__form_classes_key, page_key)
+    # end form util methods #
+
+
+    # Form data methods #
+    def _get_POST_data(self, request, page_key):
+        """
+        Return the POST data for a page_key stored in the provided session.
+        """
+        post_data = self._get_all_POST_data(request)
+        if page_key in post_data:
+            return post_data[page_key]
+        else:
+            return {}
+
+    def _set_POST_data(self, request, page_key, data):
+        """
+        Set the POST data for a given page_key and session to the 'data' 
+        provided.
+        """
+        post_data = self._get_all_POST_data(request)
+        post_data[page_key] = data
+        self._insert_wizard_data(request, self.__POST_data_key, post_data)
+    
+    def _remove_POST_data(self, request, page_key):
+        """
+        Remove the POST data stored in the session at index page_key.
+        """
+        self._del_wizard_data(request, self.__POST_data_key, page_key)
+    
+    def _get_all_POST_data(self, request):
+        """
+        Return the dict of all POST data for this wizard from the provided 
+        session.
+        """
+        return request.session[self.get_wizard_data_key(request)]\
+                [self.__POST_data_key]
+
+    def _get_cleaned_data(self, request, page_key):
+        """
+        Return the cleaned data from the provided session for this wizard based 
+        on the provided page_key.
+        """
+        cleaned_data = self._get_all_cleaned_data(request)
+        if page_key in cleaned_data:
+            return cleaned_data[page_key]
+        else:
+            return {}
+            
+    def _set_cleaned_data(self, request, page_key, data):
+        """
+        Assign the cleaned data for this wizard in the session at index 
+        page_key.
+        """
+        cleaned_data = self._get_all_cleaned_data(request)
+        cleaned_data[page_key] = data
+        self._insert_wizard_data(request, self.__cleaned_data_key, cleaned_data)
+
+    def _get_all_cleaned_data(self, request):
+        """
+        Return a list of all the cleaned data in the session for this wizard.
+        """
+        wizard_data = request.session[self.get_wizard_data_key(request)]
+        return wizard_data[self.__cleaned_data_key]
+    
+    def _remove_cleaned_data(self, request, page_key):
+        """
+        Remove the cleaned data at index page_key for this wizard from the 
+        provided session.
+        """
+        self._del_wizard_data(request, self.__cleaned_data_key, page_key)
+    # end Form data methods #
+
+    
+    # page methods #
+    def _get_next_page_key(self, request, page_key):
+        """
+        Return the next page_key after the provided page_key in the sequence of 
+        pages.  If this is a named pages wizard, this method iterates
+        through keys.  Otherwise it will simply iterate the page_key.
+        This method must return a String. 
+        """
+        form_classes_keys = self._get_form_classes(request).keys()
+        return form_classes_keys[form_classes_keys.index(page_key) + 1]
+        
+    def _set_current_page(self, request, page_key):
+        """
+        Iterate through the page dicts in the session and set 'current_page' to 
+        True for the page corresponding to page_key and False for all others.
+        """
+        for key, page in self._get_pages(request).iteritems():
+            if key == page_key:
+                page['current_page'] = True
+            else:
+                page['current_page'] = False
+
+    def _get_pages(self, request):
+        """
+        Return the list of page info dicts stored in the provided session for 
+        this wizard.
+        """
+        return request.session[self.get_wizard_data_key(request)]\
+                [self.__pages_key]
+
+    def _remove_page_data(self, request, page_key):
+        """
+        Remove page data from the provided session for this wizard based on a 
+        given page_key.  This removes page information, form_class and form 
+        data.
+        """
+        self._remove_form(request, page_key)
+        self._remove_page(request, page_key)
+        self._remove_cleaned_data(request, page_key)
+        self._remove_POST_data(request, page_key)
+
+    def _remove_page(self, request, page_key):
+        """
+        Remove the page info dict for this wizard stored at a given page_key 
+        from the provided session.
+        """
+        self._del_wizard_data(request, self.__pages_key, page_key)
+    
+    def _insert_page(self, request, index, page_key, form_class):
+        """
+        Insert a page into this wizard at the provided form_class index, storing
+        required associated data.
+        """
+        self._insert_form(request, index, page_key, form_class)
+        self._set_page(request, page_key, False)
+        self._set_cleaned_data(request, page_key, {})
+        self._set_POST_data(request, page_key, {})
+
+    def _set_page(self, request, page_key, valid=False, visited=False):
+        """
+        Set the page info in this wizard for a page at index page_key and stored 
+        in the provided session.
+        """
+        page_info = {
+           'valid' : valid, 
+           'visited' : visited,
+           'title' : self.get_page_title(request, page_key)
+        }
+        pages = self._get_pages(request)
+        pages[page_key] = page_info
+        self._insert_wizard_data(request, self.__pages_key, pages)
+    # end page methods #
+
+    # start wizard data utils #
+    def _clear_wizard_data_from_session(self, request):
+        """
+        Clear the session data used by this wizard from the provided session.
+        """
+        del request.session[self.get_wizard_data_key(request)]
+
+    def _insert_wizard_data(self, request, key, data):
+        """
+        Inserts wizard data into the provided session at the provided key.
+        """ 
+        wizard_data = request.session[self.get_wizard_data_key(request)]
+        wizard_data[key] = data
+        request.session[self.get_wizard_data_key(request)] = wizard_data
+    
+    def _del_wizard_data(self, request, key, page_key):
+        """
+        Deletes wizard data from the provided session based on a page_key.
+        """
+        wizard_data = request.session[self.get_wizard_data_key(request)]
+        sub_set = wizard_data[key]
+        if page_key in sub_set:
+            del sub_set[page_key]
+            wizard_data[key] = sub_set
+            request.session[self.get_wizard_data_key(request)] = wizard_data
+        
+    # end wizard data utils #
+    
+    # typically overriden methods #
+    def get_wizard_data_key(self, request):
+        """
+        Return a session key for this wizard.  The provided request could be 
+        used to prevent overlapping keys in the case that someone needs 
+        multiple instances of this wizard at one time.
+        """
+        return 'session_wizard_data'
+    
+    def get_URL_base(self, request, page_key):
+        """
+        Return the URL to this wizard minus the "page_key" part of the URL.  
+        This value is passed to template as URL_base.
+        """
+        return request.path.replace("/" + page_key, "/")
+    
+    def get_page_title(self, request, page_key):
+        """
+        Return a user friendly title for the page at index page_key.
+        """
+        if self.contains_named_pages:
+            return page_key.replace("_", " ").title()
+        else:
+            return 'Page %s' % str(int(page_key) + 1)
+    
+    def process_show_form(self, request, page_key, form):
+        """
+        Called before rendering a form either from a GET or when a form submit 
+        is invalid.
+        """
+
+    def preprocess_submit_form(self, request, page_key, form):
+        """
+        Called when a form is POSTed, but before the form is validated.  If this 
+        function returns None then form submission continues, else it should 
+        return either a Response object or a new page index that will be 
+        redirected to as a GET.
+        """
+        
+    def process_submit_form(self, request, page_key, form):
+        """
+        Called when a form is POSTed.  This is only called if the form data is 
+        valid.  If this method returns True, the done() method is called, 
+        otherwise the wizard continues.  Note that it is possible that this 
+        method would not return True, and done() would still be called because 
+        there are no more forms left in the form_classes.
+        """
+        
+    def get_template(self, page_key):
+        """
+        Hook for specifying the name of the template to use for a given page.
+        Note that this can return a tuple of template names if you'd like to
+        use the template system's select_template() hook.
+        """
+        return 'forms/session_wizard.html'
+
+    def done(self, request):
+        """
+        Hook for doing something with the validated data. This is responsible
+        for the final processing including clearing the session scope of items 
+        created by this wizard.
+        """
+        raise NotImplementedError("Your %s class has not defined a done() " + \
+                                  "method, which is required." \
+                                  % self.__class__.__name__)
Index: /home/david/work/django/django-trunk/django/contrib/formtools/tests.py
===================================================================
--- /home/david/work/django/django-trunk/django/contrib/formtools/tests.py	(revision 9084)
+++ /home/david/work/django/django-trunk/django/contrib/formtools/tests.py	(working copy)
@@ -1,8 +1,11 @@
 from django import forms
+from django.db import models
 from django.contrib.formtools import preview, wizard
 from django import http
 from django.test import TestCase
+from django.test.client import Client
 
+
 success_string = "Done was called!"
 
 class TestFormPreview(preview.FormPreview):
@@ -141,4 +144,168 @@
         request = DummyRequest(POST={"0-field":"test", "wizard_step":"0"})
         response = wizard(request)
         self.assertEquals(1, wizard.step)
+        
 
+#
+# SessionWizard tests
+#
+class SessionWizardModel(models.Model):
+    field = models.CharField(max_length=10)
+    
+class SessionWizardPageOneForm(forms.Form):
+    field = forms.CharField(required=True)
+
+class SessionWizardPageTwoForm(forms.ModelForm):
+    class Meta:
+        model = SessionWizardModel
+
+class SessionWizardPageThreeForm(forms.Form):
+    field = forms.CharField()
+
+class SessionWizardDynamicPageForm(forms.Form):
+    field = forms.CharField()
+        
+class SessionWizardClass(wizard.SessionWizard):
+    def get_page_title(self, request, page_key):
+        try:
+            return "Custom Page Title: %s" % str(int(page_key) + 1)
+        except ValueError:
+            return super(SessionWizardClass, self).get_page_title(request, 
+                                                                  page_key)
+
+    def process_show_form(self, request, page_key, form):
+        try:
+            return {'form_title' : 'Form %s' % str(int(page_key) + 1)}
+        except ValueError:
+            return super(SessionWizardClass, self).process_show_form(request, 
+                                                             page_key, form)
+
+    def preprocess_submit_form(self, request, page_key, form):
+        if page_key == "1" and request.POST['field'] == "":
+            self._remove_page(request, page_key)
+            return str(int(page_key) - 1)
+
+    def process_submit_form(self, request, page_key, form):
+        if page_key == '2':
+            self._insert_page(request, 3, str(int(page_key) + 1), 
+                              SessionWizardDynamicPageForm)
+    
+    def get_template(self, page_key):
+        return "formtools/form.html"
+
+    def done(self, request):
+        return http.HttpResponse(success_string)
+
+class SessionWizardTests(TestCase):
+    urls = 'django.contrib.formtools.test_urls'
+
+    def test_named_pages_wizard_get(self):
+        """
+        Tests that a wizard is created properly based on it's initialization
+        argument, which could be a sequence or dictionary-like object.
+        """
+        response = self.client.get('/named_pages_wizard/first_page_form')
+        self.assertEquals(200, response.status_code)
+        self.assertEquals('First Page Form', 
+                      response.context[0]['pages']['first_page_form']['title'])
+        
+
+    def test_valid_POST(self):
+        """
+        Tests that a post containing valid data will set session values 
+        correctly and redirect to the next page.
+        """
+        response = self.client.post('/sessionwizard/', {"page_key":"0", 
+                                                          "field":"test"})
+        self.assertEquals(302, response.status_code)
+        self.assertEquals("http://testserver/sessionwizard/1", 
+                          response['Location'])
+        session = self.client.session
+        cleaned_data = session['session_wizard_data']['cleaned_data']
+        post_data = session['session_wizard_data']['POST_data']
+        self.assertEquals('test', cleaned_data['0']['field'])
+        self.assertEquals('test', post_data['0']['field'])
+
+    def test_invalid_POST(self):
+        """
+        Tests that a post containing invalid data will set session values 
+        correctly and redisplay the form.
+        """
+        response = self.client.post('/sessionwizard/', {"page_key":"0", 
+                                                          "field":""})
+        self.assertEquals(200, response.status_code)
+        session = self.client.session
+        post_data = session['session_wizard_data']['POST_data']
+        self.assertEquals('', post_data['0']['field'])
+    
+    def test_GET(self):
+        """
+        Tests that a get will display a page properly.
+        """
+        response = self.client.get('/sessionwizard/0')
+        self.assertEquals(200, response.status_code)
+    
+    def test_preprocess_submit_form(self):
+        """
+        Tests the preprocess_submit_form hook of SessionWizard POSTs.
+        The SessionWizardClass is coded to short-circuit a POST for page index 1
+        when form.cleaned_data['field'] == '' by returning a reference to page_key 
+        index 0.
+        """
+        response = self.client.post('/sessionwizard/', {"page_key":"1", 
+                                                          "field":""})
+        self.assertEquals(302, response.status_code)
+        self.assertEquals("http://testserver/sessionwizard/0", 
+                          response['Location'])
+    
+    def test_process_submit_form(self):
+        """
+        Tests the process_submit_form hook of SessionWizard POSTs.
+        The SessionWizardClass is coded to insert a new page at index 3 on a 
+        POST for page index 2.
+        """
+        response = self.client.post('/sessionwizard/', {"page_key":"2", 
+                                                          "field":"test"})
+        self.assertEquals(302, response.status_code)
+        self.assertEquals("http://testserver/sessionwizard/3", 
+                          response['Location'])
+        self.assertEquals({"0":SessionWizardPageOneForm, 
+                           "1":SessionWizardPageTwoForm,
+                           "2":SessionWizardPageThreeForm,
+                           "3":SessionWizardDynamicPageForm}, 
+            self.client.session['session_wizard_data']['form_classes'])
+    
+    def test_process_show_form(self):
+        """
+        Tests the process_show_form hook.  SessionWizardClass is coded to 
+        return a extra_context having a specific 'form_title' attribute.
+        """
+        response = self.client.get('/sessionwizard/0')
+        self.assertEquals(200, response.status_code)
+        self.assertEquals("Form 1", 
+                          response.context[0]['extra_context']['form_title'])
+    
+    def test_validate_all(self):
+        """
+        Submits all forms, with one of them being invalid, and tests that 
+        submitting the last form will catch an invalid form earlier in the 
+        workflow and redisplay it.
+        """ 
+        response = self.client.post('/sessionwizard/', {"page_key":"0", "field":""})
+        self.client.post('/sessionwizard/', {"page_key":"1", "field":"test1"})
+        self.client.post('/sessionwizard/', {"page_key":"2", "field":"test2"})
+        response = self.client.post('/sessionwizard/3', 
+                                   {"page_key":"3", "field":"test3"})
+        self.assertEquals(True, response.context[0]['pages']['1']['visited'])
+        self.assertEquals(True, response.context[0]['pages']['1']['valid'])
+
+        self.assertEquals("Form 1", 
+                          response.context[0]['extra_context']['form_title'])
+    
+    def test_done(self):
+        self.client.post('/sessionwizard/', {"page_key":"0", "field":"test0"})
+        self.client.post('/sessionwizard/', {"page_key":"1", "field":"test1"})
+        self.client.post('/sessionwizard/', {"page_key":"2", "field":"test2"})
+        response = self.client.post('/sessionwizard/', 
+                                   {"page_key":"3", "field":"test3"})
+        self.assertEqual(response.content, success_string)
Index: /home/david/work/django/django-trunk/django/contrib/formtools/test_urls.py
===================================================================
--- /home/david/work/django/django-trunk/django/contrib/formtools/test_urls.py	(revision 9084)
+++ /home/david/work/django/django-trunk/django/contrib/formtools/test_urls.py	(working copy)
@@ -9,4 +9,13 @@
 
 urlpatterns = patterns('',
                        (r'^test1/', TestFormPreview(TestForm)),
+                       (r'^sessionwizard/(?P<page_key>\d*)$', 
+                            SessionWizardClass([SessionWizardPageOneForm,
+                                               SessionWizardPageTwoForm,
+                                               SessionWizardPageThreeForm])),
+                       (r'^named_pages_wizard/(?P<page_key>\w*)$',
+                            SessionWizardClass((
+                                ('first_page_form', SessionWizardPageOneForm),
+                                ('page2', SessionWizardPageTwoForm),
+                                ('page3', SessionWizardPageThreeForm),)))
                       )
Index: /home/david/work/django/django-trunk/docs/ref/contrib/formtools/session-wizard.txt
===================================================================
--- /home/david/work/django/django-trunk/docs/ref/contrib/formtools/session-wizard.txt	(revision 0)
+++ /home/david/work/django/django-trunk/docs/ref/contrib/formtools/session-wizard.txt	(revision 0)
@@ -0,0 +1,348 @@
+.. _ref-contrib-formtools-session-wizard:
+
+==============
+Session wizard
+==============
+
+.. module:: django.contrib.formtools.wizard
+    :synopsis: Splits forms across multiple Web pages using users' sessions to store form and page data.
+
+.. versionadded:: 1.x
+
+Django comes with an optional "session wizard" application that splits
+:ref:`forms <topics-forms-index>` across multiple Web pages. It maintains
+state in users' sessions incurring additional resource costs on a server
+but also creating a smoother workflow for users.
+
+Note that SessionWizard is similar to :ref:`FormWizard <ref-contrib-formtools-form-wizard>`
+and some of these examples and documentation mirror FormWizard examples and 
+documentation exactly.
+
+You might want to use this if you have a workflow or lengthy form and want to 
+provide navigation to various pages in the wizard.
+
+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
+============
+
+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 stores this data
+       in a user's session and sends an HTTP redirect to GET the next page.
+    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,
+       sending an e-mail, or whatever the application needs to do.
+
+Usage
+=====
+
+This application handles as much machinery for you as possible. Generally, you
+just have to do these things:
+
+    1. Define a number of :mod:`django.forms`
+       :class:`~django.forms.forms.Form` classes -- one per wizard page.
+       
+    2. Create a :class:`~django.contrib.formtools.wizard.SessionWizard` 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.
+       
+    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:`~django.contrib.formtools.wizard.SessionWizard` class.
+
+Defining ``Form`` classes
+=========================
+
+The first step in creating a form wizard is to create the 
+:class:`~django.forms.forms.Form` classes.  These should be standard 
+:mod:`django.forms` :class:`~django.forms.forms.Form` classes, covered in the
+:ref:`forms documentation <topics-forms-index>`.
+
+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
+collects the sender's e-mail address and subject, and the second page collects
+the message itself. Here's what the :file:`forms.py` might look like::
+
+   from django import forms
+
+    class ContactForm1(forms.Form):
+        subject = forms.CharField(max_length=100)
+        sender = forms.EmailField()
+
+    class ContactForm2(forms.Form):
+        message = forms.CharField(widget=forms.Textarea)
+
+**Important limitation:** Because the wizard uses users' sessions to store
+data between pages, you should seriously consider whether or not it 
+makes sense to include :class:`~django.forms.fields.FileField` in any forms.
+
+Creating a ``SessionWizard`` class
+==================================
+
+The next step is to create a :class:`~django.contrib.formtools.wizard.SessionWizard`
+class, which should be a subclass of ``django.contrib.formtools.wizard.SessionWizard``.
+
+As your :class:`~django.forms.forms.Form` classes, this
+:class:`~django.contrib.formtools.wizard.SessionWizard` class can live anywhere
+in your codebase, but convention is to put it in :file:`forms.py`.
+
+The only requirement on this subclass is that it implement a
+:meth:`~django.contrib.formtools.wizard.SessionWizard.done()` method,
+which specifies what should happen when the data for *every* form is submitted
+and validated. This method is passed one argument:
+
+    * ``request`` -- an :class:`~django.http.HttpRequest` object
+
+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 import SessionWizard
+
+    class ContactWizard(SessionWizard):
+        def done(self, request):
+            form_data = self._get_all_cleaned_data(request.session)
+            self._clear_wizard_data_from_session(request.session)
+            return render_to_response('done.html', {
+                'form_data': form_data,
+            })
+
+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 import SessionWizard
+
+    class ContactWizard(SessionWizard):
+        def done(self, request):
+            form_data = self._get_all_cleaned_data(request.session)
+            self._clear_wizard_data_from_session(request.session)
+            do_something_with_the_form_data(form_data)
+            return HttpResponseRedirect('/page-to-redirect-to-when-done/')
+
+See the section `Advanced SessionWizard methods`_ below to learn about more
+:class:`~django.contrib.formtools.wizard.SessionWizard` 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/session_wizard.html`. 
+(You can change this template name by overriding
+:meth:`~django.contrib.formtools.wizard..get_template()`, which is documented
+below. This hook also allows you to use a different template for each form.)
+
+This template expects the following context:
+
+    * ``page_key`` -- A string representation of the current page in this 
+      wizard.  Depending on how a wizard is created, this could be a page name
+      or a zero-based page index.
+    * ``form`` -- The :class:`~django.forms.forms.Form` instance for the
+      current page (empty, populated or populated with errors).
+    * ``pages`` -- The current list of pages for this wizard.  This is a dict of
+      dict objects in the form::
+      
+        {'page_key1' : {'title' : 'page1', 
+                        'visited': True, 
+                        'valid' : True, 
+                        'current_page' : False
+                       }, 
+         'page_key2' : {'title' : 'page2', 
+                        'visited': False, 
+                        'valid' : False, 
+                        'current_page' : True
+                       }, 
+          ..
+         }
+    * ``URL_base`` -- The base URL used to generate links to pages in this 
+      wizard.  By default, it is the request.path value minus the ``page_key``.
+    * 'extra_context' -- A dict returned from the 
+      :meth:`~django.contrib.formtools.wizard.SessionWizard.process_show_form()`
+      hook.
+
+Here's a full example template:
+
+.. code-block:: html+django
+
+    {% extends "base.html" %}
+    
+    {% block content %}
+    <ul>
+    {% for page_key,page in pages.items %}
+        <li class="{% if page.valid %}valid{% endif %} 
+                   {% if page.current_page %}current{% endif %}">
+        {% if page.visited %}
+            <a href="{{ URL_base }}{{ page_key }}">{{ page.title }}</a>
+        {% else %}
+            {{ page.title }}
+        {% endif %}
+        </li>
+    {% endfor %}
+    </ul>
+    <form action="." method="post">
+    <table>
+    {{ form }}
+    </table>
+    <input type="hidden" name="page_key" value="{{ page_key }}"/>
+    <input type="submit">
+    </form>
+    {% endblock %}
+
+Note that ``page_key`` is required for the wizard to work properly.
+
+Hooking the wizard into a URLconf
+=================================
+
+Finally, give your new :class:`~django.contrib.formtools.wizard.SessionWizard`
+object a URL in ``urls.py``. The wizard has two types of initialization.  The 
+first takes a list of your form objects as arguments, and the seconds takes a
+sequence of 2-tuples in the form (page_key, form_class).  The two types are 
+illustrated below::
+
+    from django.conf.urls.defaults import *
+    from mysite.testapp.forms import ContactForm1, ContactForm2, ContactWizard
+
+    urlpatterns = patterns('',
+        ## First form - a list of form classes
+        (r'^contact/(?P<page_key>\d*)$', ContactWizard([ContactForm1, ContactForm2])),
+        
+        ## Second form - a sequence of 2-tuples
+        (r'^contact/(?P<page_key>\w*)$', ContactWizard((("subject_and_sender", ContactForm1),
+                                                        ("message", ContactForm2)))),
+    )
+    
+In the first type of SessionWizard initialization, a list of form classes is 
+provided.  The ``page_key`` values matched from a URL are auto-generated zero-based 
+digits.  Note these values are stored as strings not integers, which is 
+something to keep in mind while referencing ``page_key`` values in any SessionWizard
+hooks described below.
+
+In the second style of initialization, the ``page_key`` values from a URL are 
+matched exactly with ``page_key`` values provided in a sequence of 2-tuples.
+
+Advanced SessionWizard methods
+==============================
+
+.. class:: SessionWizard
+
+    Aside from the :meth:`~django.contrib.formtools.wizard.SessionWizard.done()`
+    method, :class:`~django.contrib.formtools.wizard.SessionWizard` offers a few
+    advanced method hooks that let you customize how your wizard works.
+
+    Some of these methods take an argument ``page_key``, which is a string 
+    representing the current page.  As noted above, if a wizard is created
+    from a list of form classes, then this string is a zero-based auto-incremented
+    value.  Otherwise, if a wizard is created from a sequence of 2-tuples,
+    the ``page_key`` is the name of the page. 
+
+.. method:: SessionWizard.get_wizard_data_key
+
+    Given a user's session, returns a value to be used as a key for storing and 
+    retrieving a wizard's data.  Note that a request is provided so that a
+    wizard could potentially avoid namespace collision in the event that 
+    multiple instances of a wizard are required concurrently for a single user.
+
+    Default implementation::
+
+        def get_wizard_data_key(self, request):
+            return "session_wizard_data"
+
+.. method:: SessionWizard.get_URL_base
+
+    Returns a URL that will be used when generating redirects.  To 
+    generate a redirect to GET the next page in a wizard, the SessionWizard 
+    class appends a ``page_key`` to the value returned from this function.  
+
+    Default implementation::
+
+        def get_URL_base(self, request, page_key):
+            return request.path.replace("/" + page_key, "/")
+
+.. method:: SessionWizard.get_page_title
+
+    Return a title that will be placed in the ``pages`` template context dict.
+
+    Default implementation::
+
+        def get_page_title(self, request, page_key):
+            if self.contains_named_pages:
+                return page_key.replace("_", " ").title()
+            else:
+                return 'Page %s' % str(int(page_key) + 1)
+
+.. method:: SessionWizard.process_show_form
+
+    A hook for providing ``extra_context`` for a page.
+
+    By default, this does nothing.
+
+    Example::
+
+        def process_show_form(self, request, page_key, form):
+            return {'form_title' : '%s Form ' % page_key}
+
+.. method:: SessionWizard.get_template
+
+    Return the name of the template that should be used for a given ``page_key``.
+
+    By default, this returns :file:`'forms/session_wizard.html'`, regardless of 
+    ``page_key``.
+
+    Example::
+
+        def get_template(self, page_key):
+            return 'myapp/wizard_%s.html' % page_key
+
+    If :meth:`~SessionWizard.get_template` returns a list of strings, then the 
+    wizard will use the template system's :func:`~django.template.loader.select_template()`
+    function, :ref:`explained in the template docs <ref-templates-api-the-python-api>`.
+    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']
+
+    .. _explained in the template docs: ../templates_python/#the-python-api
+
+.. method:: SessionWizard.preprocess_submit_form
+
+    Provides a means to short-circuit form posts and do something different
+    than the normal flow of validating the form and proceeding to the next page.
+    For instance, a wizard could present the user with a "Delete this page" 
+    button, and use this hook to remove the stored data associated with the 
+    provided ``page_key`` and redirect to a specific ``page_key``.
+    
+    The return value can be either an HttpResponse or a ``page_key`` string.
+
+    Example::
+    
+        def preprocess_submit_form(self, request, page_key, form):
+            if request.POST['submit'] == "Delete this page":
+                self._remove_page(request, page_key)
+                return "next_page"
+
+.. method:: SessionWizard.process_submit_form
+
+    This is a hook for doing something after a valid form submission.  For 
+    instance, a wizard could persist the wizard's state after each submission
+    and later allow users to resume their work after a session timeout or 
+    system failure.
+
+    The function signature::
+
+        def process_submit_form(self, request, page_key, form):
+            # ...
Index: /home/david/work/django/django-trunk/docs/ref/contrib/formtools/index.txt
===================================================================
--- /home/david/work/django/django-trunk/docs/ref/contrib/formtools/index.txt	(revision 9084)
+++ /home/david/work/django/django-trunk/docs/ref/contrib/formtools/index.txt	(working copy)
@@ -10,3 +10,4 @@
 
    form-preview
    form-wizard
+   session-wizard
