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\d*)$', + SessionWizardClass([SessionWizardPageOneForm, + SessionWizardPageTwoForm, + SessionWizardPageThreeForm])), + (r'^named_pages_wizard/(?P\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 ` 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 ` +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 `. + +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 %} +
    + {% for page_key,page in pages.items %} +
  • + {% if page.visited %} + {{ page.title }} + {% else %} + {{ page.title }} + {% endif %} +
  • + {% endfor %} +
+
+ + {{ 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\d*)$', ContactWizard([ContactForm1, ContactForm2])), + + ## Second form - a sequence of 2-tuples + (r'^contact/(?P\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 `. + 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