Ticket #9200: session_wizard_patch.txt

File session_wizard_patch.txt, 42.1 KB (added by David Durham, 15 years ago)

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

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