Login

Session-backed FormWizard

Author:
donspaulding
Posted:
October 3, 2008
Language:
Python
Version:
1.0
Score:
1 (after 1 ratings)

A rewrite of the django.contrib.formtools.FormWizard class to be session-based instead of saving state in hidden form variables.

It started out as a simple port of the standard FormWizard class, and much of the original behavior was unchanged because I wanted to limit the scope of the "rewrite". Along the way I think I made it a bit more OO, and a bit more flexible, but there are still warts.

I threw this code up here before I was completely happy with it just to take part in the discussion around [this ticket][http://code.djangoproject.com/ticket/9200 "Django Ticket #9200]. There's certainly a few things that the patch on that ticket does that I'd like to incorporate into my Wizard, whenever time allows.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
from django.shortcuts import render_to_response
from django.template.context import RequestContext

class FormWizard(object):
    """
    FormWizard class -- implements a multi-page form, validating between each
    step and storing the form's state in the current session.
    """
    # Dictionary of extra template context variables.
    extra_context = {}

    # List of decorators to be applied to the __call__ "view"
    decorators = []

    # The key to use when storing and retrieving form data from the session.
    data_session_key = "wizard_data"

    # The key to use when storing and retrieving context data from the session.
    context_session_key = "wizard_extra_context"

    # The key to use when storing and retrieving the current step from the session.
    step_session_key = "wizard_step"

    # Methods that subclasses shouldn't need to override #######################

    def __init__(self, form_list):
        "form_list should be a list of Form classes (not instances)."
        self.form_list = form_list[:]
        self.step = 0 # A zero-based counter keeping track of which step we're in.
        for dec in self.decorators[::-1]:
            self.__call__ = dec(self.__call__)
    def __repr__(self):
        return "step: %d\nform_list: %s\ninitial_data: %s" % (self.step, self.form_list, self.initial)

    def get_form(self, step=None, data=None):
        "Helper method that returns the Form instance for the given step."
        if step is None:
            step = self.step
        return self.form_list[step](data=data, prefix=self.get_form_prefix(step), initial=self.get_form_initial(step))

    def _num_steps(self):
        # You might think we should just set "self.num_steps = len(form_list)"
        # in __init__(), but this calculation needs to be dynamic, because some
        # hook methods might alter self.form_list.
        return len(self.form_list)
    num_steps = property(_num_steps)

    def __call__(self, request, *args, **kwargs):
        """
        Main method that does all the hard work, conforming to the Django view
        interface.
        """
        self.current_request = request
        if 'extra_context' in kwargs:
            self.extra_context.update(kwargs['extra_context'])
        self.step = self.determine_step(*args, **kwargs)
        self.parse_params(*args, **kwargs)

        # GET requests automatically start the FormWizard at the first form.
        if request.method == 'GET':
            self.reset_wizard()
            form = self.get_form()
            return self.render(form)
        else:
            form = self.get_form(data=request.POST)
            self.extra_context.update(self.current_request.session.get(self.context_session_key,{}))
            if form.is_valid():
                self.step_data = form.cleaned_data
                self.process_step(form)
                self.store_step_data()
                self.store_extra_context()
                next_step = self.step + 1

                # If this was the last step, validate all of the forms one more
                # time, as a sanity check, and call done().
                if next_step == self.num_steps:
                    final_form_list = []
                    # Validate all the forms. If any of them fail validation, that
                    # must mean something like self.process_step(form) modified
                    # self.step_data after it was validated.
                    for i in range(self.num_steps):
                        frm = self.get_form(step=i, data=request.session.get(self.data_session_key, {}))
                        if not frm.is_valid():
                            return self.render_revalidation_failure(step, frm)
                        final_form_list.append(frm)
                    final_form_list.append(form)
                    return self.done(final_form_list)
                # Otherwise, move along to the next step.
                else:
                    new_form = self.get_form(next_step)
                    self.store_step(next_step)
                    return self.render(new_form)
            # Form is invalid, render it with errors.
            return self.render(form)

    def render(self, form):
        "Renders the given Form object, returning an HttpResponse."
        return self.render_template(form)

    def reset_wizard(self):
        try:
            del(self.current_request.session[self.data_session_key])
        except:
            pass
        self.current_request.session.modified = True
        self.store_step(0)

    # METHODS SUBCLASSES MIGHT OVERRIDE IF APPROPRIATE ########################

    def get_form_prefix(self, step=None):
        "Returns a Form prefix to use."
        if step is None:
            step = self.step
        return str(step)

    def get_form_initial(self, step):
        "Returns the value to pass as the 'initial' keyword argument to the Form class."
        return None

    def store_extra_context(self):
        """
        Stores self.extra_context to the session.  Note that this means
        extra_context is global across all steps of the Wizard.  You can
        redefine a context variable "author" to mean two different things in
        different steps, but you don't get your first one back once you've
        overwritten it.  This can affect your ability to assign to self.step.
        """
        self.current_request.session[self.context_session_key] = self.extra_context
        self.current_request.session.modified = True

    def get_step_data(self, request, step):
        "Retrieves data for the specified step"
        return request.session[self.data_session_key][step]

    def store_step_data(self, step=None, data=None):
        if step is None:
            step = self.step
        if data is None:
            data = self.step_data
        if self.data_session_key not in self.current_request.session:
            self.current_request.session[self.data_session_key] = {step:data}
        else:
            self.current_request.session[self.data_session_key][step] = data
        self.current_request.session.modified=True

    def store_step(self, step=None):
        if step is not None:
            self.step = step
        self.current_request.session[self.step_session_key] = self.step
        self.current_request.session.modified = True

    def render_revalidation_failure(self, step, form):
        """
        Hook for rendering a template if final revalidation failed.

        It is highly unlikely that this point would ever be reached, but see
        the comment in __call__() for an explanation.
        """
        return self.render(form)

    def determine_step(self, *args, **kwargs):
        """
        Given the request object and whatever *args and **kwargs were passed to
        __call__(), returns the current step (which is zero-based).
        """
        return self.current_request.session.get(self.step_session_key, 0)

    def parse_params(self, *args, **kwargs):
        """
        Hook for setting some state, given the request object and whatever
        *args and **kwargs were passed to __call__(), sets some state.

        This is called at the beginning of __call__().
        """
        pass

    def get_template(self):
        """
        Hook for specifying the name of the template to use for a given step.

        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/wizard.html'

    def render_template(self, form=None):
        """
        Renders the template for the current step, returning an HttpResponse object.

        Override this method if you want to add a custom context, return a
        different MIME type, etc. If you only need to override the template
        name, use get_template() instead.

        The template will be rendered with the following context:
            step       -- The current step (one-based).
            step0      -- The current step (zero-based).
            step_count -- The total number of steps.
            form       -- The Form instance for the current step (either empty
                          or with errors).
        """
        form = form or self.get_form()
        return render_to_response(self.get_template(), dict(
            self.extra_context,
            step=self.step+1,
            step0=self.step,
            step_count=self.num_steps,
            form=form,
        ), context_instance=RequestContext(self.current_request))

    def process_step(self, form):
        """
        Hook for modifying the FormWizard's internal state, given a fully
        validated Form object. The Form is guaranteed to have clean, valid
        data.  This method is where you could assign things to self.extra_context,
        self.current_request, or self.step_data.

        Assign a dict-like object to self.step_data to override the data being
        stored for the current step.  self.step_data is set to form.cleaned_data
        by default.
        """
        pass

    # METHODS SUBCLASSES MUST OVERRIDE ########################################

    def done(self, form_list):
        """
        Hook for doing something with the validated data. This is responsible
        for the final processing.

        form_list is a list of Form instances, each containing clean, valid
        data.
        """
        raise NotImplementedError("Your %s class has not defined a done() method, which is required." % self.__class__.__name__)





###########################################
###########################################
#####  In your application's forms.py #####

class NewCustomerWizard(FormWizard):
    decorators = [staff_required]
    def process_step(self, form):
        if self.step == 0:
            prof = self.current_request.user.get_profile()
            self.extra_context['profile'] = prof
            self.extra_context['cities'] = form.cleaned_data['nearby_cities']
        elif self.step == 1:
            if is_suburb(form.cleaned_data['city']):
                self.form_list.append(MiddleClassForm)

    def get_template(self):
        default = super(NewCustomerWizard, self).get_template()
        template_overrides = {
            0: "forms/custom_templ.html",
            2: "forms/custom_templ2.html",
        }
        return template_overrides.get(self.step, default)

    def get_form_initial(self, step):
        if step == 1:
            return {'city': self.current_request.user.get_profile().city}
        return super(NewCustomerWizard, self).get_form_initial(step)

    def done(self, final_form_list):
        """
        Create a new customer record.
        """
        pass

More like this

  1. Template tag - list punctuation for a list of items by shapiromatron 10 months, 2 weeks ago
  2. JSONRequestMiddleware adds a .json() method to your HttpRequests by cdcarter 10 months, 2 weeks ago
  3. Serializer factory with Django Rest Framework by julio 1 year, 5 months ago
  4. Image compression before saving the new model / work with JPG, PNG by Schleidens 1 year, 6 months ago
  5. Help text hyperlinks by sa2812 1 year, 6 months ago

Comments

jsandell (on October 4, 2008):

If this works as advertised (haven't had a chance to try it, but I will very soon) then kudos to you, kind sir!

#

exogen (on October 4, 2008):

Has this been tested with multiple users, or with the same user/session filling out multiple wizards, in different tabs for instance? I don't really trust that these situations will work properly with storing data in the view instance (self).

#

donspaulding (on October 9, 2008):

@exogen: this code hasn't been thoroughly vetted yet(insert US politician reference here). In particular, I haven't rolled this code out to anyone yet, and so it definitely hasn't been tested with more than one user/session/tab.

#

krylatij (on December 15, 2008):

It does not work with unbound form as i understand. good idea - bas realization

#

Please login first before commenting.