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,22 @@
-"""
-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.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
 
+
 class FormWizard(object):
+    """
+    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 +240,399 @@
         data.
         """
         raise NotImplementedError("Your %s class has not defined a done() method, which is required." % self.__class__.__name__)
+    
+
+class SessionWizard(object):
+    """
+    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_list_key = 'form_list'
+    __cleaned_data_key = 'cleaned_data'
+    __POST_data_key = 'POST_data'
+    __page_infos_key = 'page_infos'
+    
+    def __init__(self, form_list):
+        """form_list should be a list of Form classes (not instances)."""
+        self.base_form_list = form_list[:]
+
+    def _init_form_list(self, request):
+        """ 
+        Copy self.base_form_list to the session scope so that subclasses can 
+        manipulate the form_list for individual users.
+        """
+        session_key = self.get_wizard_data_key(request)
+        if session_key not in request.session:
+            request.session[session_key] = {
+                self.__form_list_key : self.base_form_list[:], 
+                self.__cleaned_data_key : [], 
+                self.__POST_data_key : [], 
+                self.__page_infos_key : [],
+            }
+
+    def __call__(self, request, *args, **kwargs):
+        """
+        Initialize the form_list for a session if needed and call GET or
+        POST depending on the http method.
+        """
+        self._init_form_list(request)
+        page0 = int(kwargs['page0'])
+        
+        if request.method == 'POST':
+            return self.POST(request)
+        else:
+            return self.GET(request, page0)
+        
+    
+    def GET(self, request, page0):
+        """
+        Display the form/page for the page identified by page0
+        """
+        page_data = self._get_cleaned_data(request.session, page0)
+        if page_data is None:
+            form = self._get_form_list(request.session)[page0]()
+        else:
+            form_class = self._get_form_list(request.session)[page0]
+            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, page0, form)
+        
+    def POST(self, request):
+        """
+        Validate form submission, and redirect to GET the next form or return 
+        the response from self.done().
+        """ 
+        page0 = int(request.POST['page0'])
+        url_base = self.get_URL_base(request, page0)
+        self._set_POST_data(request.session, request.POST, page0)
+        form_list = self._get_form_list(request.session)
+        form = form_list[page0](request.POST)
+        new_page0 = self.preprocess_submit_form(request, page0, form)
+        
+        if new_page0 is not None:
+            return HttpResponseRedirect(url_base + str(new_page0))
+        else:
+            if form.is_valid():
+                self._set_cleaned_data(request.session, page0, 
+                                       form.cleaned_data)
+                self._set_page_info(request.session, page0, True)
+                is_done = self.process_submit_form(request, page0, form)
+                if (is_done is None or is_done == False) and \
+                        len(form_list) > page0 + 1:
+                    return HttpResponseRedirect(url_base + str(page0 + 1))
+                else:
+                    first_broken_page, form  = \
+                        self._validate_all_forms(request.session)
+                    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_info(request.session, page0, False)
+                
+        return self._show_form(request, page0, form)
+    
+
+    # form util methods #
+    def _validate_all_forms(self, session):
+        """
+        Iterate through the session form list and validate based on the POST 
+        data stored in the session for this wizard.  Return the page index and 
+        the form of the first invalid form or None, None if all forms are valid.
+        """ 
+        i = 0
+        for form_class in self._get_form_list(session):
+            form = form_class(self._get_POST_data(session, i))
+            if not form.is_valid():
+                return i, form
+            else:
+                i = i + 1
+        return None, None
+        
+    def _show_form(self, request, page0, form):
+        """
+        Show the form associated with indicated page index.
+        """
+        url_base = self.get_URL_base(request, page0)
+        extra_context = self.process_show_form(request, page0, form)
+        self._set_current_page(request.session, page0)
+        page_infos = self._get_page_infos(request.session)
+        return render_to_response(self.get_template(page0),
+             {'page0' : page0,
+              'page' : page0 + 1,
+              'form' : form,
+              'page_infos' : page_infos,
+              'url_base' : url_base,
+              'extra_context' : extra_context
+             }, RequestContext(request))
+        
+    def _get_form_list(self, session):
+        """
+        Return the list of form classes stored in the provided session.
+        """
+        return session[self.get_wizard_data_key(session)][self.__form_list_key]
+    
+    def _insert_form(self, session, page0, form_class):
+        """
+        Insert a form class into the provided session's form list at index 
+        page0.
+        """
+        form_list = self._get_form_list(session)
+        form_list.insert(page0, form_class)
+        self._insert_wizard_data(session, self.__form_list_key, form_list)
+        
+    def _remove_form(self, session, page0):
+        """
+        Remove the form at index page0 from the provided sessions form list.
+        """
+        self._del_wizard_data(session, self.__form_list_key, page0)
+    # end form util methods #
+
+
+    # Form data methods #
+    def _get_POST_data(self, session, page0):
+        """
+        Return the POST data for a given page index page0, stored in the 
+        provided session.
+        """
+        post_data = self._get_all_POST_data(session)
+        if len(post_data) > page0:
+            return post_data[page0]
+        else:
+            return {}
+
+    def _set_POST_data(self, session, data, page0, force_insert=False):
+        """
+        Set the POST data for a given page index and session to the 'data' 
+        provided.  If force_insert is True then the data assignment is forced 
+        as an list.insert(page0, data) call.
+        """
+        post_data = self._get_all_POST_data(session)
+        if force_insert or len(post_data) <= page0:
+            post_data.insert(page0, data)
+        else:
+            post_data[page0] = data
+        self._insert_wizard_data(session, self.__POST_data_key, post_data)
+    
+    def _remove_POST_data(self, session, page0):
+        """
+        Remove the POST data stored in the session at index page0.
+        """
+        self._del_wizard_data(session, self.__POST_data_key, page0)
+    
+    def _get_all_POST_data(self, session):
+        """
+        Return the list of all POST data for this wizard from the provided 
+        session.
+        """
+        return session[self.get_wizard_data_key(session)][self.__POST_data_key]
+
+    def _get_cleaned_data(self, session, page0):
+        """
+        Return all cleaned data for this wizard from the provided session.
+        """
+        cleaned_data = self._get_all_cleaned_data(session)
+        if len(cleaned_data) > page0:
+            return cleaned_data[page0]
+        else:
+            return {}
+            
+    def _set_cleaned_data(self, session, page0, data, force_insert=False):
+        """
+        Assign the cleaned data for this wizard in the session at index page0, 
+        optionally forcing a call a list insert call based on the 
+        'force_insert' argument.
+        """
+        cleaned_data = self._get_all_cleaned_data(session)
+        if force_insert or len(cleaned_data) <= page0: 
+            cleaned_data.insert(page0, data)
+        else:
+            cleaned_data[page0] = data
+        self._insert_wizard_data(session, self.__cleaned_data_key, cleaned_data)
+        
+
+    def _get_all_cleaned_data(self, session):
+        """
+        Return a list of all the cleaned data in the session for this wizard.
+        """
+        wizard_data = session[self.get_wizard_data_key(session)]
+        return wizard_data[self.__cleaned_data_key]
+    
+    def _remove_cleaned_data(self, session, page0):
+        """
+        Remove the cleaned data at index page0 for this wizard from the 
+        provided session.
+        """
+        self._del_wizard_data(session, self.__cleaned_data_key, page0)
+    # end Form data methods #
+
+    
+    # page methods #
+    def _set_current_page(self, session, page0):
+        """
+        Iterate through the page info dicts in the session and set 
+        'current_page' to True for the page_info corresponding to page0 and 
+        False for all others.
+        """
+        page_infos = self._get_page_infos(session)
+        for i in range(len(page_infos)):
+            if i == page0:
+                page_infos[i]['current_page'] = True
+            else:
+                page_infos[i]['current_page'] = False
+
+    def _get_page_infos(self, session):
+        """
+        Return the list of page info dicts stored in the provided session for 
+        this wizard.
+        """
+        return session[self.get_wizard_data_key(session)][self.__page_infos_key]
+
+    def _remove_page(self, session, page0):
+        """
+        Remove the page for this wizard indicated by the page0 argument from 
+        the provided session.
+        """
+        self._remove_form(session, page0)
+        self._remove_page_info(session, page0)
+        self._remove_cleaned_data(session, page0)
+        self._remove_POST_data(session, page0)
+
+    def _remove_page_info(self, session, page0):
+        """
+        Remove the page info dict for this wizard stored at the page0 index 
+        from the provided session.
+        """
+        self._del_wizard_data(session, self.__page_infos_key, page0)
+    
+    def _insert_page(self, session, page0, form_class):
+        """
+        Insert a page into this wizard, storing required session structures.
+        """
+        self._insert_form(session, page0, form_class)
+        self._set_page_info(session, page0, False, True)
+        self._set_cleaned_data(session, page0, {}, True)
+        self._set_POST_data(session, {}, page0, True)
+
+    def _set_page_info(self, session, page0, valid, force_insert=False):
+        """
+        Set the page info in this wizard for a page at index page0 and stored 
+        in the provided session.
+        """
+        page_info = {
+           'valid' : valid, 
+           'title' : self.get_page_title(session, page0)
+        }
+        page_infos = self._get_page_infos(session)
+        if force_insert or len(page_infos) <= page0:
+            page_infos.insert(page0, page_info)
+        else:
+            page_infos[page0] = page_info
+        self._insert_wizard_data(session, self.__page_infos_key, page_infos)
+    # end page methods #
+
+    # start wizard data utils #
+    def _clear_wizard_data_from_session(self, session):
+        """
+        Clear the session data used by this wizard from the provided session.
+        """
+        del session[self.get_wizard_data_key(session)]
+
+    def _insert_wizard_data(self, session, key, data):
+        """
+        Inserts wizard data into the provided session at the provided key.
+        """ 
+        wizard_data = session[self.get_wizard_data_key(session)]
+        wizard_data[key] = data
+        session[self.get_wizard_data_key(session)] = wizard_data
+    
+    def _del_wizard_data(self, session, key, page0):
+        """
+        Deletes wizard data from the provided session at the key and page0 
+        index.
+        """
+        wizard_data = session[self.get_wizard_data_key(session)]
+        sub_set = wizard_data[key]
+        if len(sub_set) > page0:
+            del sub_set[page0]
+            wizard_data[key] = sub_set
+            session[self.get_wizard_data_key(session)] = wizard_data
+        
+    # end wizard data utils #
+    
+    # typically overriden methods #
+    def get_wizard_data_key(self, session):
+        """
+        Return a session key for this wizard.  The provided session 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, page0):
+        """
+        Return the URL to this wizard minus the "page0" parto of the URL.  This 
+        value is passed to template as url_base.
+        """
+        return request.path.replace("/" + str(page0), "/")
+    
+    def get_page_title(self, session, page0):
+        """
+        Return a user friendly title for the page at index page0.
+        """
+        return 'Page %s' % str(page0 + 1)
+    
+    def process_show_form(self, request, page0, form):
+        """
+        Called before rendering a form either from a GET or when a form submit 
+        is invalid.
+        """
+
+    def preprocess_submit_form(self, request, page0, form):
+        """
+        Called when a form is POSTed, but before form is validated.  If this 
+        function returns None then form submission continues, else it should 
+        return a new page index that will be redirected to as a GET.
+        """
+        
+    def process_submit_form(self, request, page0, 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_list.
+        """
+        
+    def get_template(self, page0):
+        """
+        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__)
