Using a FormWizard in the Django admin

28 Oct

A few weeks ago, I saw this question on Stackoverflow, asking how to integrate a Form Wizard with the Django admin. Although there’s an accepted answer, it doesn’t include any code, and doesn’t appear to take advantage of Django 1.1 features (more on these later), which make integrating custom views with the admin much easier and more seamless. I thought I’d share my solution here, in case there’s others out there asking the same question. You can download the source code for this example, if you’d like to follow along.

For our example, we’ll develop a wizard to add employers to a database, for a fictional recruitment application. Adding an employer will be a three-step process:

  1. First, choose a username and password, which the employer will use to login. Make sure to verify the password, by entering it twice.
  2. Enter the name and email address of the contact person for this employer.
  3. Enter the company’s name, description, address and website URL.

Clicking on “add new” on the employer changelist page, should display the wizard, rather than the default form .

Assumptions:

A number of features I mention are specific to Django 1.1 — if you’re already using 1.1, great! If not, maybe this’ll give you one more reason to upgrade? I sure hope so. I’ll assume you already have a functional installation of Django; head over to the docs if you need help setting up Django. I’ll also assume you know how to start a Django project — there’s plenty of introductory tutorials out there, including this one in the Django docs.

All set? Well, let’s get started then.

Step 1: Setup

  1. Create a new Django project and add a new app to it (We’ll call it “testapp”). Add the app to INSTALLED_APPS in your settings.py. Also, add “django.contrib.admin” to INSTALLED_APPS.
  2. Configure the DATABASE_* settings for your database of choice — I’ll be using SQLite.
  3. Add the path(s) to the folder where you’ll store your templates in TEMPLATE_DIRS
  4. Edit the main Urlconf, and follow the comments in it to enable the admin urls.
  5. Run “syncdb” to create the database tables, then “runserver” to start the development server.

Done? On to step 2 then.

Step 2: Create the model

Next, we’ll create the Employer model. Save the code below as “testapp/models.py” (move your mouse over the code below, and click the “copy to clipboard” link, then paste into a new file):

from django.db import models
from django.contrib.auth.models import User

class Employer(models.Model):
    user = models.OneToOneField(User)
    company_name = models.CharField(max_length=60)
    company_description = models.TextField(blank=True)
    address = models.TextField()
    website = models.URLField(verify_exists=False, blank=True)

    class Meta:
        ordering = ('company_name',)

    def __unicode__(self):
        return self.company_name

Pretty simple stuff. We’ll store the login and contact information for an employer in the User table, and the company information in the Employer table. Next, we’ll create our forms.

Step 3: Create the forms

We’ll need three forms for our wizard, one for each step of the employer creation process. Here’s the code (save as “testapp/forms.py”):

from django import forms
from django.forms import fields, models, widgets
from django.contrib.auth.models import User
from django.contrib.auth.forms import UserCreationForm
from django.contrib.formtools.wizard import FormWizard
from django.utils.encoding import force_unicode
from testapp.models import Employer

class ContactForm(forms.Form):
    first_name = fields.CharField(max_length=50, label="Contact's first name")
    last_name = fields.CharField(max_length=50, label="Contact's last name")
    email = fields.EmailField(label="Contact's email address")

class EmployerForm(models.ModelForm):
    class Meta:
        model = Employer
        exclude = ('user',)

For the first step in the wizard, we’ll reuse the UserCreationForm from django.contrib.auth.forms. The ContactForm and EmployerForm handle the second and third steps, respectively. So far, this is all pretty basic forms stuff; if any of this is new to you, you really should check out the forms documentation.

Next, we’ll create our wizard. A wizard is simply a subclass of django.contrib.formtools.wizard.FormWizard, which defines a “done” method, specifying what action to perform once all the wizard’s forms have been submitted; the actual forms are passed as a list to the subclass’s constructor. Once you’ve created an instance of your wizard, you can use it in a Urlconf, just like any other view (remember, a view doesn’t have to be a function — it just has to be callable).

We’ll create the wizard below the other forms:

class EmployerCreationWizard(FormWizard):
    @property
    def __name__(self):
        return self.__class__.__name__

    def done(self, request, form_list):
        data = {}
        for form in form_list:
            data.update(form.cleaned_data)
        # First, create user:
        user = User(
            username=data['username'],
            first_name=data['first_name'],
            last_name=data['last_name'],
            email=data['email']
        )
        user.set_password(data['password1'])
        user.save()
        # Next, create employer:
        employer = Employer.objects.create(
            user=user,
            company_name=data['company_name'],
            address=data['address'],
            company_description=data.get('company_description', ''),
            website=data.get('website', '')
        )
        # TODO: Display success message and redirect to changelist.

create_employer = EmployerCreationWizard([UserCreationForm, ContactForm, EmployerForm])

The code above should be mostly straightforward — we create an instance of User, save it, then pass it to the “create” method of the Employer model (well, strictly speaking, it’s the Employer model’s manager). Notice we call “set_password” on the User instance to handle the password hashing stuff for us.

We also define a “__name__” property on the wizard. We do this because I’ve noticed a number of Django decorators raise an AttributeError when you use them to decorate an instance, complaining they can’t find __name__ (Python instances don’t have a __name__ property, though functions and classes do). Since we’ll be decorating our wizard instance in a moment, we provide the __name__ property, so everyone’s happy.

See the #TODO at the end? We’ll be adding code to handle that in a moment. First, though, a small detour to register our Employer model with the admin.

Step 4: Register the Employer model with the admin

Now things start to get interesting. We’ll take advantage of two Django 1.1 features, to integrate the create_employer wizard with the admin:

  1. The “ModelAdmin.get_urls” method returns the URLs to be used for a ModelAdmin subclass in the same way as a Urlconf; because of this, you can easily add new urlpatterns to your ModelAdmin subclass.
  2. The “ModelAdmin.admin_site.admin_view” wrapper can be applied to any view, to mark it as an admin view. Each time the view is accessed, it checks the current user’s permissions and redirects to the login page, if necessary. It also marks the wrapped view as non-cacheable, by default (in effect, admin_view is equivalent to applying the staff_member_required and never_cache decorators).

Here’s the code for our ModelAdmin subclass; save this as “testapp/admin.py”:

from django.conf.urls.defaults import url, patterns
from django.contrib import admin
from django.utils.encoding import force_unicode
from django.utils.functional import update_wrapper
from testapp.models import Employer
from testapp.forms import create_employer

class EmployerAdmin(admin.ModelAdmin):
    def get_urls(self):
        def wrap(view):
            def wrapper(*args, **kwds):
                kwds['admin'] = self   # Use a closure to pass this admin instance to our wizard
                return self.admin_site.admin_view(view)(*args, **kwds)
            return update_wrapper(wrapper, view)

        urlpatterns = patterns('',
            url(r'^add/$',
                wrap(create_employer),
                name='testapp_employer_add')
        )
        urlpatterns += super(EmployerAdmin, self).get_urls()
        return urlpatterns

admin.site.register(Employer, EmployerAdmin)

There’s a few things to note in the above code:

  1. We bind the create_employer wizard to the pattern ‘^add/$’, which is the same pattern used by ModelAdmin.add_view; since our urlpattern appears before the default patterns, we’ve effectively overriden the default add view.
  2. We define a decorator “wrap”, which applies the admin_site.admin_view wrapper to our wizard, and calls “update_wrapper” on the wrapped function to preserve its name and docstring.
  3. We pass a reference to the current ModelAdmin instance to the wizard; this allows us access to the admin from within the wizard’s methods.

If you browse to the admin add view for the employer (if you’re using the development server defaults, your URl should look like: http://localhost:8000/admin/testapp/employer/add/), you should get a TemplateNotFound error. Let’s create a template, so we can see how everything looks.

Step 5: Create a template

By default, the wizard looks for the template “forms/wizard.html”. You can change this by overriding the “get_template” method, but we’ll use the default for now. Save the HTML below as “templates/forms/wizard.html” (assuming your TEMPLATE_DIRS in settings.py includes “templates”):

{% extends "admin/change_form.html" %}
{% load i18n %}

{% block content %}
<div id="content-main">
    <form {% if form.form.is_multipart %}enctype="multipart/form-data" {% endif %}method="post" action="" id="{{ opts.module_name }}_form">
        <div>
            {% if form.form.errors %}
            <p class="errornote">
                {% blocktrans count form.form.errors|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %}
            </p>
            <ul class="errorlist">
                {% for error in form.form.non_field_errors %}
                <li>{{ error }}</li>{% endfor %}
            </ul>
            {% endif %}

            {% for fieldset in form %}
              {% include "admin/includes/fieldset.html" %}
            {% endfor %}

            <input type="hidden" name="{{ step_field }}" value="{{ step0 }}" />
            {{ previous_fields|safe }}

            <div class="submit-row">
                <input type="submit" value="{% ifequal step step_count %}Finish{% else %}Next &raquo;{% endifequal %}" class="default" name="_save" />
            </div>

            <script type="text/javascript">document.getElementById("{{ form.first_field.auto_id }}").focus();</script>
        </div>
    </form>
</div>
{% endblock %}

Now go back to the admin add view for employer and hit refresh.

Oops.

What happened to the breadcrumbs? The page title? The form??

Don’t despair though…the hard part’s over. Go on and take a break — stretch, get coffee, grab a snack, talk to another human being.

Back already? You’re pretty quick. Let’s fix the template now, shall we?

Step 6: Putting things in Context

As you’ve probably figured out by now, our template context is a few variables short. From the ModelAdmin source code (somewhere around line 772 in “django/contrib/admin/options.py”):

context = {
            'title': _('Add %s') % force_unicode(opts.verbose_name),
            'adminform': adminForm,
            'is_popup': request.REQUEST.has_key('_popup'),
            'show_delete': False,
            'media': mark_safe(media),
            'inline_admin_formsets': inline_admin_formsets,
            'errors': helpers.AdminErrorList(form, formsets),
            'root_path': self.admin_site.root_path,
            'app_label': opts.app_label,
        }

We can safely ignore a number of these (such as “is_popup”, “show_delete” and “inline_admin_formsets”), but the others need to be present for the template to render correctly. Fortunately, the FormWizard provides a solution: the “parse_params” method.

According to the docstring, parse_params is a “hook for setting some state, given the request object, and whatever *args and **kwargs were passed to __call__()“. Remember the “admin” keyword argument we passed in get_urls? That gets passed along to parse_params, where we can extract it, and use it to populate FormWizard.extra_context. Add the code below to the EmployerCreationWizard in “testapp/forms.py”:

def parse_params(self, request, admin=None, *args, **kwargs):
    self._model_admin = admin # Save this so we can use it later.
    opts = admin.model._meta # Yes, I know we could've done Employer._meta, but this is cooler :)
    self.extra_context.update({
        'title': u'Add %s' % force_unicode(opts.verbose_name),
        'current_app': admin.admin_site.name,
        'has_change_permission': admin.has_change_permission(request),
        'add': True,
        'opts': opts,
        'root_path': admin.admin_site.root_path,
        'app_label': opts.app_label,
    })

Go to the employer add view again and hit refresh…much better. Our title now shows up, and breadcrumbs work. But what is up with our form?

It turns out that the “form” our template expects isn’t an instance of “django.forms.BaseForm”, but rather an instance of “django.contrib.admin.helpers.AdminForm”. What we need is to convert our BaseForm instance to an AdminForm instance. Again, the FormWizard comes to the rescue, by providing the “render_template” method. Add the code below to the EmployerCreationWizard, below parse_params:

def render_template(self, request, form, previous_fields, step, context=None):
    from django.contrib.admin.helpers import AdminForm
    # Wrap this form in an AdminForm so we get the fieldset stuff:
    form = AdminForm(form, [(
        'Step %d of %d' % (step + 1, self.num_steps()),
        {'fields': form.base_fields.keys()}
        )], {})
    context = context or {}
    context.update({
        'media': self._model_admin.media + form.media
    })
    return super(EmployerCreationWizard, self).render_template(request, form, previous_fields, step, context)

The AdminForm constructor takes a form instance, a list of fieldsets and a dictionary of prepopulated fields. We pass it the form instance passed to render_template, a single fieldset comprising all the fields in the form, and an empty dictionary (since we don’t care about the prepopulated fields). Next, we update the supplied context with the form media, then pass both the form and the updated context to the superclass render_template.

Go to the add view once more, and hit refresh. Sweet, eh?

Step 7: Wrapping up

Remember that #TODO in EmployerCreationWizard.done? Let’s fix that now. Replace the #TODO line with the following lines:

# Display success message and redirect to changelist:
return self._model_admin.response_add(request, employer)

That’s it, we’re done. Go on over to the add view and try adding a couple of employers.

I hope someone out there finds this useful. If you’ve got other tips for integrating custom views with the admin, share them in the comments (or, post a link to an article you wrote on your blog).

Download the demo project:

customadmin.zip (~7.49KB)

About these ads

14 Responses to “Using a FormWizard in the Django admin”

  1. shannon November 9, 2009 at 8:08 pm #

    Thanks! That scratched an itch i’ve had for over a year.

  2. elo80ka November 9, 2009 at 9:24 pm #

    You’re welcome. Glad I could help :)

  3. Sebastian January 21, 2010 at 1:54 pm #

    Thanks for this article, is very clear and useful.

  4. kotch July 5, 2010 at 7:04 am #

    Great!!! Thanks.

  5. Franck September 11, 2010 at 7:26 am #

    For Django 1.2, you will have to add the CSRF validation

    {% csrf_token %}

    in the html file

    {% csrf_token %}

  6. rick September 20, 2010 at 9:48 pm #

    This was extremely helpful to me…was able to work through this and get it working for myself, with a couple tweaks – of course.

    One question I don’t have a simple answer to: is there a simple way to get the forms in the Form Wizard to use the Admin Form Widgets? For what I’m doing, they don’t seem to appear, Django uses the standard form widgets which aren’t nearly as nice.

    In any case, thanks for posting – this was great.

  7. elo80ka September 21, 2010 at 6:24 pm #

    @rick: I’ve wondered the same thing myself. Each time I have to style forms for custom admin views, it always seems so…hard :) When I’ve got a little more time, I’d like to explore this some more. What d’you think of projects like django admin tools and grappelli?

  8. Carlos June 23, 2011 at 3:14 am #

    Thanks for sharing. I got an error “Caught TypeError while rendering: ‘BoundField’ object is not iterable” when trying to do the URL for adding In template c:\python27\lib\site-packages\django\contrib\admin\templates\admin\includes\fieldset.html, at line 6: {% for line in fieldset %} Any ideas?

  9. VJ June 30, 2011 at 3:52 pm #

    I got the same error as Carlos. I just removed the offending for loop in fieldset.html and the problem went away.

    However, I even with the csrf_token tag added to the html file, I have CSRF issues.

  10. VJ June 30, 2011 at 4:05 pm #

    Figured it out! The csrf_token tag needs to be within the form on the template.

    Great tutorial. Thanks!

  11. elo80ka June 30, 2011 at 8:28 pm #

    @VJ: Glad you figured it out.

    I initially wrote this for Django 1.1. Now that we’re heading towards 1.4, I should probably do an update. Soon.

  12. Kiran October 4, 2011 at 8:36 am #

    This works great – checked with the latest from 1.4 – works fine (just have to delete the line ‘root_path': admin.admin_site.root_path ) from testapp/forms.py

  13. Rogério Carrasqueira October 28, 2012 at 6:46 pm #

    Hi! Nice Post! So I would like to know from you how to deal with form wizards that needs inline forms into admin? Do you have any approach? Thanks!

Trackbacks/Pingbacks

  1. Default Django Admin Forms and FormWizard - July 21, 2012

    [...] second pages of this form, and I’d like to be able to edit them inline. I used the example at http://elo80ka.wordpress.com/2009/10/28/using-a-formwizard-in-the-django-admin/ as a base, however, he specifies his own form that he has the admin use. Of course, if you use your [...]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Follow

Get every new post delivered to your Inbox.

%d bloggers like this: