diff --git a/django/contrib/formtools/__init__.py b/django/contrib/formtools/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/django/contrib/formtools/preview.py b/django/contrib/formtools/preview.py new file mode 100644 index 0000000000..9a9371b5f8 --- /dev/null +++ b/django/contrib/formtools/preview.py @@ -0,0 +1,160 @@ +""" +Formtools Preview application. + +This is an abstraction of the following workflow: + + "Display an HTML form, force a preview, then do something with the submission." + +Given a django.newforms.Form object that you define, this takes care of the +following: + + * Displays the form as HTML on a Web page. + * Validates the form data once it's submitted via POST. + * If it's valid, displays a preview page. + * If it's not valid, redisplays the form with error messages. + * At the preview page, if the preview confirmation button is pressed, calls + a hook that you define -- a done() method. + +The framework enforces the required preview by passing a shared-secret hash to +the preview page. If somebody tweaks the form parameters on the preview page, +the form submission will fail the hash comparison test. + +Usage +===== + +Subclass FormPreview and define a done() method: + + def done(self, request, clean_data): + # ... + +This method takes an HttpRequest object and a dictionary of the form data after +it has been validated and cleaned. It should return an HttpResponseRedirect. + +Then, just instantiate your FormPreview subclass by passing it a Form class, +and pass that to your URLconf, like so: + + (r'^post/$', MyFormPreview(MyForm)), + +The FormPreview class has a few other hooks. See the docstrings in the source +code below. + +The framework also uses two templates: 'formtools/preview.html' and +'formtools/form.html'. You can override these by setting 'preview_template' and +'form_template' attributes on your FormPreview subclass. See +django/contrib/formtools/templates for the default templates. +""" + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404 +from django.shortcuts import render_to_response +import cPickle as pickle +import md5 + +AUTO_ID = 'formtools_%s' # Each form here uses this as its auto_id parameter. + +class FormPreview(object): + preview_template = 'formtools/preview.html' + form_template = 'formtools/form.html' + + # METHODS SUBCLASSES SHOULDN'T OVERRIDE ################################### + + def __init__(self, form): + # form should be a Form class, not an instance. + self.form, self.state = form, {} + + def __call__(self, request, *args, **kwargs): + stage = {'1': 'preview', '2': 'post'}.get(request.POST.get(self.unused_name('stage')), 'preview') + self.parse_params(*args, **kwargs) + try: + method = getattr(self, stage + '_' + request.method.lower()) + except AttributeError: + raise Http404 + return method(request) + + def unused_name(self, name): + """ + Given a first-choice name, adds an underscore to the name until it + reaches a name that isn't claimed by any field in the form. + + This is calculated rather than being hard-coded so that no field names + are off-limits for use in the form. + """ + while 1: + try: + f = self.form.fields[name] + except KeyError: + break # This field name isn't being used by the form. + name += '_' + return name + + def preview_get(self, request): + "Displays the form" + f = self.form(auto_id=AUTO_ID) + return render_to_response(self.form_template, {'form': f, 'stage_field': self.unused_name('stage'), 'state': self.state}) + + def preview_post(self, request): + "Validates the POST data. If valid, displays the preview page. Else, redisplays form." + f = self.form(request.POST, auto_id=AUTO_ID) + context = {'form': f, 'stage_field': self.unused_name('stage'), 'state': self.state} + if f.is_valid(): + context['hash_field'] = self.unused_name('hash') + context['hash_value'] = self.security_hash(request, f) + return render_to_response(self.preview_template, context) + else: + return render_to_response(self.form_template, context) + + def post_post(self, request): + "Validates the POST data. If valid, calls done(). Else, redisplays form." + f = self.form(request.POST, auto_id=AUTO_ID) + if f.is_valid(): + if self.security_hash(request, f) != request.POST.get(self.unused_name('hash')): + return self.failed_hash(request) # Security hash failed. + return self.done(request, f.clean_data) + else: + return render_to_response(self.form_template, {'form': f, 'stage_field': self.unused_name('stage'), 'state': self.state}) + + # METHODS SUBCLASSES MIGHT OVERRIDE IF APPROPRIATE ######################## + + def parse_params(self, *args, **kwargs): + """ + Given captured args and kwargs from the URLconf, saves something in + self.state and/or raises Http404 if necessary. + + For example, this URLconf captures a user_id variable: + + (r'^contact/(?P\d{1,6})/$', MyFormPreview(MyForm)), + + In this case, the kwargs variable in parse_params would be + {'user_id': 32} for a request to '/contact/32/'. You can use that + user_id to make sure it's a valid user and/or save it for later, for + use in done(). + """ + pass + + def security_hash(self, request, form): + """ + Calculates the security hash for the given Form instance. + + This creates a list of the form field names/values in a deterministic + order, pickles the result with the SECRET_KEY setting and takes an md5 + hash of that. + + Subclasses may want to take into account request-specific information + such as the IP address. + """ + data = [(bf.name, bf.data) for bf in form] + [settings.SECRET_KEY] + # Use HIGHEST_PROTOCOL because it's the most efficient. It requires + # Python 2.3, but Django requires 2.3 anyway, so that's OK. + pickled = pickle.dumps(data, protocol=pickle.HIGHEST_PROTOCOL) + return md5.new(pickled).hexdigest() + + def failed_hash(self, request): + "Returns an HttpResponse in the case of an invalid security hash." + return self.preview_post(request) + + # METHODS SUBCLASSES MUST OVERRIDE ######################################## + + def done(self, request, clean_data): + "Does something with the clean_data and returns an HttpResponseRedirect." + raise NotImplementedError('You must define a done() method on your %s subclass.' % self.__class__.__name__) diff --git a/django/contrib/formtools/templates/formtools/form.html b/django/contrib/formtools/templates/formtools/form.html new file mode 100644 index 0000000000..90da8b2b2b --- /dev/null +++ b/django/contrib/formtools/templates/formtools/form.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block content %} + +{% if form.errors %}

Please correct the following errors

{% else %}

Submit

{% endif %} + +
+ +{{ form }} +
+ +

+
+ +{% endblock %} diff --git a/django/contrib/formtools/templates/formtools/preview.html b/django/contrib/formtools/templates/formtools/preview.html new file mode 100644 index 0000000000..c7955d46e1 --- /dev/null +++ b/django/contrib/formtools/templates/formtools/preview.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block content %} + +

Preview your submission

+ + +{% for field in form %} + + + + +{% endfor %} +
{{ field.verbose_name }}:{{ field.data|escape }}
+ +

Security hash: {{ hash_value }}

+ +
+{% for field in form %}{{ field.as_hidden }} +{% endfor %} + + +

+
+ +

Or edit it again

+ +
+ +{{ form }} +
+ +

+
+ +{% endblock %} diff --git a/docs/add_ons.txt b/docs/add_ons.txt index a0377700d7..58c01c4fc0 100644 --- a/docs/add_ons.txt +++ b/docs/add_ons.txt @@ -48,6 +48,23 @@ See the `csrf documentation`_. .. _csrf documentation: http://www.djangoproject.com/documentation/csrf/ +formtools +========= + +**New in Django development version** + +A set of high-level abstractions for Django forms (django.newforms). + +django.contrib.formtools.preview +-------------------------------- + +An abstraction of the following workflow: + +"Display an HTML form, force a preview, then do something with the submission." + +Full documentation for this feature does not yet exist, but you can read the +code and docstrings in ``django/contrib/formtools/preview.py`` for a start. + humanize ========