Ticket #9200: formtools_patch.txt

File formtools_patch.txt, 41.5 KB (added by David Durham, 16 years ago)

includes SessionWizard class, tests and documentation

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