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)
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))
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.