Code

Ticket #9200: formtools_patch.txt

File formtools_patch.txt, 41.5 KB (added by ddurham, 6 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