Index: /home/david/work/django/django-trunk/django/contrib/formtools/wizard.py =================================================================== --- /home/david/work/django/django-trunk/django/contrib/formtools/wizard.py (revision 9084) +++ /home/david/work/django/django-trunk/django/contrib/formtools/wizard.py (working copy) @@ -1,21 +1,22 @@ -""" -FormWizard class -- implements a multi-page form, validating between each -step and storing the form's state as HTML hidden fields so that no state is -stored on the server side. -""" - import cPickle as pickle from django import forms from django.conf import settings from django.http import Http404 +from django.http import HttpResponseRedirect from django.shortcuts import render_to_response from django.template.context import RequestContext from django.utils.hashcompat import md5_constructor from django.utils.translation import ugettext_lazy as _ from django.contrib.formtools.utils import security_hash + class FormWizard(object): + """ + FormWizard class -- implements a multi-page form, validating between each + step and storing the form's state as HTML hidden fields so that no state is + stored on the server side. + """ # Dictionary of extra template context variables. extra_context = {} @@ -239,3 +240,399 @@ data. """ raise NotImplementedError("Your %s class has not defined a done() method, which is required." % self.__class__.__name__) + + +class SessionWizard(object): + """ + SessionWizard class -- implements multi-page forms with the following + characteristics: + + 1) easily supports navigation to arbitrary pages in the wizard + 2) uses GETs to display forms (caveat validation errors) and POSTs for + form submissions + + Pros are support for back-button and arbitrary navigation within pages + (including the oddity of someone clicking on the refresh button) + + The major Con is use of the session scope. In particular, zero + consideration has been given to multipart form data. + """ + + # keys used to store wizard data in sessions + __form_list_key = 'form_list' + __cleaned_data_key = 'cleaned_data' + __POST_data_key = 'POST_data' + __page_infos_key = 'page_infos' + + def __init__(self, form_list): + """form_list should be a list of Form classes (not instances).""" + self.base_form_list = form_list[:] + + def _init_form_list(self, request): + """ + Copy self.base_form_list to the session scope so that subclasses can + manipulate the form_list for individual users. + """ + session_key = self.get_wizard_data_key(request) + if session_key not in request.session: + request.session[session_key] = { + self.__form_list_key : self.base_form_list[:], + self.__cleaned_data_key : [], + self.__POST_data_key : [], + self.__page_infos_key : [], + } + + def __call__(self, request, *args, **kwargs): + """ + Initialize the form_list for a session if needed and call GET or + POST depending on the http method. + """ + self._init_form_list(request) + page0 = int(kwargs['page0']) + + if request.method == 'POST': + return self.POST(request) + else: + return self.GET(request, page0) + + + def GET(self, request, page0): + """ + Display the form/page for the page identified by page0 + """ + page_data = self._get_cleaned_data(request.session, page0) + if page_data is None: + form = self._get_form_list(request.session)[page0]() + else: + form_class = self._get_form_list(request.session)[page0] + if issubclass(form_class, forms.ModelForm): + form = form_class(instance=form_class.Meta.model(**page_data)) + else: + form = form_class(initial=page_data) + return self._show_form(request, page0, form) + + def POST(self, request): + """ + Validate form submission, and redirect to GET the next form or return + the response from self.done(). + """ + page0 = int(request.POST['page0']) + url_base = self.get_URL_base(request, page0) + self._set_POST_data(request.session, request.POST, page0) + form_list = self._get_form_list(request.session) + form = form_list[page0](request.POST) + new_page0 = self.preprocess_submit_form(request, page0, form) + + if new_page0 is not None: + return HttpResponseRedirect(url_base + str(new_page0)) + else: + if form.is_valid(): + self._set_cleaned_data(request.session, page0, + form.cleaned_data) + self._set_page_info(request.session, page0, True) + is_done = self.process_submit_form(request, page0, form) + if (is_done is None or is_done == False) and \ + len(form_list) > page0 + 1: + return HttpResponseRedirect(url_base + str(page0 + 1)) + else: + first_broken_page, form = \ + self._validate_all_forms(request.session) + if first_broken_page is not None: + return self._show_form(request, first_broken_page, + form) + else: + return self.done(request) + else: + self._set_page_info(request.session, page0, False) + + return self._show_form(request, page0, form) + + + # form util methods # + def _validate_all_forms(self, session): + """ + Iterate through the session form list and validate based on the POST + data stored in the session for this wizard. Return the page index and + the form of the first invalid form or None, None if all forms are valid. + """ + i = 0 + for form_class in self._get_form_list(session): + form = form_class(self._get_POST_data(session, i)) + if not form.is_valid(): + return i, form + else: + i = i + 1 + return None, None + + def _show_form(self, request, page0, form): + """ + Show the form associated with indicated page index. + """ + url_base = self.get_URL_base(request, page0) + extra_context = self.process_show_form(request, page0, form) + self._set_current_page(request.session, page0) + page_infos = self._get_page_infos(request.session) + return render_to_response(self.get_template(page0), + {'page0' : page0, + 'page' : page0 + 1, + 'form' : form, + 'page_infos' : page_infos, + 'url_base' : url_base, + 'extra_context' : extra_context + }, RequestContext(request)) + + def _get_form_list(self, session): + """ + Return the list of form classes stored in the provided session. + """ + return session[self.get_wizard_data_key(session)][self.__form_list_key] + + def _insert_form(self, session, page0, form_class): + """ + Insert a form class into the provided session's form list at index + page0. + """ + form_list = self._get_form_list(session) + form_list.insert(page0, form_class) + self._insert_wizard_data(session, self.__form_list_key, form_list) + + def _remove_form(self, session, page0): + """ + Remove the form at index page0 from the provided sessions form list. + """ + self._del_wizard_data(session, self.__form_list_key, page0) + # end form util methods # + + + # Form data methods # + def _get_POST_data(self, session, page0): + """ + Return the POST data for a given page index page0, stored in the + provided session. + """ + post_data = self._get_all_POST_data(session) + if len(post_data) > page0: + return post_data[page0] + else: + return {} + + def _set_POST_data(self, session, data, page0, force_insert=False): + """ + Set the POST data for a given page index and session to the 'data' + provided. If force_insert is True then the data assignment is forced + as an list.insert(page0, data) call. + """ + post_data = self._get_all_POST_data(session) + if force_insert or len(post_data) <= page0: + post_data.insert(page0, data) + else: + post_data[page0] = data + self._insert_wizard_data(session, self.__POST_data_key, post_data) + + def _remove_POST_data(self, session, page0): + """ + Remove the POST data stored in the session at index page0. + """ + self._del_wizard_data(session, self.__POST_data_key, page0) + + def _get_all_POST_data(self, session): + """ + Return the list of all POST data for this wizard from the provided + session. + """ + return session[self.get_wizard_data_key(session)][self.__POST_data_key] + + def _get_cleaned_data(self, session, page0): + """ + Return all cleaned data for this wizard from the provided session. + """ + cleaned_data = self._get_all_cleaned_data(session) + if len(cleaned_data) > page0: + return cleaned_data[page0] + else: + return {} + + def _set_cleaned_data(self, session, page0, data, force_insert=False): + """ + Assign the cleaned data for this wizard in the session at index page0, + optionally forcing a call a list insert call based on the + 'force_insert' argument. + """ + cleaned_data = self._get_all_cleaned_data(session) + if force_insert or len(cleaned_data) <= page0: + cleaned_data.insert(page0, data) + else: + cleaned_data[page0] = data + self._insert_wizard_data(session, self.__cleaned_data_key, cleaned_data) + + + def _get_all_cleaned_data(self, session): + """ + Return a list of all the cleaned data in the session for this wizard. + """ + wizard_data = session[self.get_wizard_data_key(session)] + return wizard_data[self.__cleaned_data_key] + + def _remove_cleaned_data(self, session, page0): + """ + Remove the cleaned data at index page0 for this wizard from the + provided session. + """ + self._del_wizard_data(session, self.__cleaned_data_key, page0) + # end Form data methods # + + + # page methods # + def _set_current_page(self, session, page0): + """ + Iterate through the page info dicts in the session and set + 'current_page' to True for the page_info corresponding to page0 and + False for all others. + """ + page_infos = self._get_page_infos(session) + for i in range(len(page_infos)): + if i == page0: + page_infos[i]['current_page'] = True + else: + page_infos[i]['current_page'] = False + + def _get_page_infos(self, session): + """ + Return the list of page info dicts stored in the provided session for + this wizard. + """ + return session[self.get_wizard_data_key(session)][self.__page_infos_key] + + def _remove_page(self, session, page0): + """ + Remove the page for this wizard indicated by the page0 argument from + the provided session. + """ + self._remove_form(session, page0) + self._remove_page_info(session, page0) + self._remove_cleaned_data(session, page0) + self._remove_POST_data(session, page0) + + def _remove_page_info(self, session, page0): + """ + Remove the page info dict for this wizard stored at the page0 index + from the provided session. + """ + self._del_wizard_data(session, self.__page_infos_key, page0) + + def _insert_page(self, session, page0, form_class): + """ + Insert a page into this wizard, storing required session structures. + """ + self._insert_form(session, page0, form_class) + self._set_page_info(session, page0, False, True) + self._set_cleaned_data(session, page0, {}, True) + self._set_POST_data(session, {}, page0, True) + + def _set_page_info(self, session, page0, valid, force_insert=False): + """ + Set the page info in this wizard for a page at index page0 and stored + in the provided session. + """ + page_info = { + 'valid' : valid, + 'title' : self.get_page_title(session, page0) + } + page_infos = self._get_page_infos(session) + if force_insert or len(page_infos) <= page0: + page_infos.insert(page0, page_info) + else: + page_infos[page0] = page_info + self._insert_wizard_data(session, self.__page_infos_key, page_infos) + # end page methods # + + # start wizard data utils # + def _clear_wizard_data_from_session(self, session): + """ + Clear the session data used by this wizard from the provided session. + """ + del session[self.get_wizard_data_key(session)] + + def _insert_wizard_data(self, session, key, data): + """ + Inserts wizard data into the provided session at the provided key. + """ + wizard_data = session[self.get_wizard_data_key(session)] + wizard_data[key] = data + session[self.get_wizard_data_key(session)] = wizard_data + + def _del_wizard_data(self, session, key, page0): + """ + Deletes wizard data from the provided session at the key and page0 + index. + """ + wizard_data = session[self.get_wizard_data_key(session)] + sub_set = wizard_data[key] + if len(sub_set) > page0: + del sub_set[page0] + wizard_data[key] = sub_set + session[self.get_wizard_data_key(session)] = wizard_data + + # end wizard data utils # + + # typically overriden methods # + def get_wizard_data_key(self, session): + """ + Return a session key for this wizard. The provided session could be + used to prevent overlapping keys in the case that someone needs + multiple instances of this wizard at one time. + """ + return 'session_wizard_data' + + def get_URL_base(self, request, page0): + """ + Return the URL to this wizard minus the "page0" parto of the URL. This + value is passed to template as url_base. + """ + return request.path.replace("/" + str(page0), "/") + + def get_page_title(self, session, page0): + """ + Return a user friendly title for the page at index page0. + """ + return 'Page %s' % str(page0 + 1) + + def process_show_form(self, request, page0, form): + """ + Called before rendering a form either from a GET or when a form submit + is invalid. + """ + + def preprocess_submit_form(self, request, page0, form): + """ + Called when a form is POSTed, but before form is validated. If this + function returns None then form submission continues, else it should + return a new page index that will be redirected to as a GET. + """ + + def process_submit_form(self, request, page0, form): + """ + Called when a form is POSTed. This is only called if the form data is + valid. If this method returns True, the done() method is called, + otherwise the wizard continues. Note that it is possible that this + method would not return True, and done() would still be called because + there are no more forms left in the form_list. + """ + + def get_template(self, page0): + """ + Hook for specifying the name of the template to use for a given page. + Note that this can return a tuple of template names if you'd like to + use the template system's select_template() hook. + """ + return 'forms/session_wizard.html' + + def done(self, request): + """ + Hook for doing something with the validated data. This is responsible + for the final processing including clearing the session scope of items + created by this wizard. + """ + raise NotImplementedError("Your %s class has not defined a done() " + \ + "method, which is required." \ + % self.__class__.__name__)