Django class-based views with multiple inline formsets

This is just a quick explanation of how to use Django's inline model formsets with generic class-based views. Handling model forms is an area where it makes sense to use class-based views but there aren't many examples of how to add inline formsets to the mix. Maxime Haineault has a blog post which helped me a lot when I first tried to do this, but his approach of creating inline formsets in the get_context_data method doesn't work if we need to do any validation spanning multiple forms within a formset. The view class I'm using here follows the structure of the generic CreateView more closely so adding mixins, turning this pattern into a mixin, or upgrading to new versions of Django is less likely to cause unpleasant surprises. I've written and tested this using Django 1.5 but it should work for 1.3 and up.

Forms and models

Let's say that our site lists recipes, which in essence are just lists of ingredients and lists of instructions for preparing those ingredients. So at their most basic our forms and models might look something like this.

# models.py
from django.db import models


class Recipe(models.Model):
    title = models.CharField(max_length=255)
    description = models.TextField()


class Ingredient(models.Model):
    recipe = models.ForeignKey(Recipe)
    description = models.CharField(max_length=255)


class Instruction(models.Model):
    recipe = models.ForeignKey(Recipe)
    number = models.PositiveSmallIntegerField()
    description = models.TextField()
# forms.py
from django.forms import ModelForm
from django.forms.models import inlineformset_factory

from .models import Recipe, Ingredient, Instruction


class RecipeForm(ModelForm):
    class Meta:
        model = Recipe


IngredientFormSet = inlineformset_factory(Recipe, Ingredient)
InstructionFormSet = inlineformset_factory(Recipe, Instruction)

View

Our recipe creation view is very similar to Django's generic CreateView. The only thing we need to do is override a few methods so that our inline formsets are created, validated, and saved along with the main recipe form. We don't need to override the get_context_data method because it already updates the template context with any keyword arguments you call it with.

# views.py
from django.http import HttpResponseRedirect
from django.views.generic import CreateView

from .forms import IngredientFormSet, InstructionFormSet, RecipeForm
from .models import Recipe


class RecipeCreateView(CreateView):
    template_name = 'recipe_add.html'
    model = Recipe
    form_class = RecipeForm
    success_url = 'success/'

    def get(self, request, *args, **kwargs):
        """
        Handles GET requests and instantiates blank versions of the form
        and its inline formsets.
        """
        self.object = None
        form_class = self.get_form_class()
        form = self.get_form(form_class)
        ingredient_form = IngredientFormSet()
        instruction_form = InstructionFormSet()
        return self.render_to_response(
            self.get_context_data(form=form,
                                  ingredient_form=ingredient_form,
                                  instruction_form=instruction_form))

    def post(self, request, *args, **kwargs):
        """
        Handles POST requests, instantiating a form instance and its inline
        formsets with the passed POST variables and then checking them for
        validity.
        """
        self.object = None
        form_class = self.get_form_class()
        form = self.get_form(form_class)
        ingredient_form = IngredientFormSet(self.request.POST)
        instruction_form = InstructionFormSet(self.request.POST)
        if (form.is_valid() and ingredient_form.is_valid() and
            instruction_form.is_valid()):
            return self.form_valid(form, ingredient_form, instruction_form)
        else:
            return self.form_invalid(form, ingredient_form, instruction_form)

    def form_valid(self, form, ingredient_form, instruction_form):
        """
        Called if all forms are valid. Creates a Recipe instance along with
        associated Ingredients and Instructions and then redirects to a
        success page.
        """
        self.object = form.save()
        ingredient_form.instance = self.object
        ingredient_form.save()
        instruction_form.instance = self.object
        instruction_form.save()
        return HttpResponseRedirect(self.get_success_url())

    def form_invalid(self, form, ingredient_form, instruction_form):
        """
        Called if a form is invalid. Re-renders the context data with the
        data-filled forms and errors.
        """
        return self.render_to_response(
            self.get_context_data(form=form,
                                  ingredient_form=ingredient_form,
                                  instruction_form=instruction_form))

Template

Here's the basic template for adding a recipe. Since it doesn't matter to the template whether we're using a function-based view or a class-based one I'm including it here mostly for the sake of completeness. The jquery.formset.js script is from the django-dynamic-formset jQuery plugin which adds buttons to dynamically add or remove inline formsets. Because we've got multiple formsets on the page we need to give each one a prefix and tell the plugin which forms belong to each formset as explained in the django-dynamic-formset docs.

<!DOCTYPE html>
<html>
<head>
    <title>Multiformset Demo</title>
    <script src="{{ STATIC_URL }}js/jquery.min.js"></script>
    <script src="{{ STATIC_URL }}js/jquery.formset.js"></script>
    <script type="text/javascript">
        $(function() {
            $(".inline.{{ ingredient_form.prefix }}").formset({
                prefix: "{{ ingredient_form.prefix }}",
            })
            $(".inline.{{ instruction_form.prefix }}").formset({
                prefix: "{{ instruction_form.prefix }}",
            })
        })
    </script>
</head>

<body>
    <div>
        <h1>Add Recipe</h1>
        <form action="." method="post">
            {% csrf_token %}
            <div>
                {{ form.as_p }}
            </div>
            <fieldset>
                <legend>Recipe Ingredient</legend>
                {{ ingredient_form.management_form }}
                {{ ingredient_form.non_form_errors }}
                {% for form in ingredient_form %}
                    {{ form.id }}
                    <div class="inline {{ ingredient_form.prefix }}">
                        {{ form.description.errors }}
                        {{ form.description.label_tag }}
                        {{ form.description }}
                    </div>
                {% endfor %}
            </fieldset>
            <fieldset>
                <legend>Recipe instruction</legend>
                {{ instruction_form.management_form }}
                {{ instruction_form.non_form_errors }}
                {% for form in instruction_form %}
                    {{ form.id }}
                    <div class="inline {{ instruction_form.prefix }}">
                        {{ form.number.errors }}
                        {{ form.number.label_tag }}
                        {{ form.number }}
                        {{ form.description.errors }}
                        {{ form.description.label_tag }}
                        {{ form.description }}
                    </div>
                {% endfor %}
            </fieldset>
            <input type="submit" value="Add recipe" class="submit" />
        </form>
    </div>
</body>
</html>