Django’s built-in ORM has a ManyToMany field that you can select. I think the default multi-selector sucks, and this has been confirmed by many an end user describing how the selector causes them to make mistakes while editing those particular fields. Here I’ll describe two things. First, how to change that field’s widget to something more palatable. Next I’ll describe some hangups that you should be careful to watch out for when you’re using this field with ModelForms, especially during saving.
Many-to-many relationships are best avoided if at all possible. They are messy and force all your code to handle multiple instances of a relation. A list of ingredients in a meal could be represented with a many-to-one relationship, unless you want to re-use those ingredients. Then you have two options; either allow copying, or use a many-to-many field. Because these ingredients could be changed and this needs to propogate to all meals they are a part of, I opted for the latter solution.
Models and Form Code
Here is the basic model code for meals, also available as a models.py gist.
class Ingredient (models.Model):
"""Describes an ingredient for meals. This ingredient contains specific
diet and food preference information and can be attached to multiple meals."""
name = models.CharField(max_length=255)
franchise = models.ForeignKey(Franchise)
diets = models.ManyToManyField(Diet, null=True, blank=True, verbose_name="special diets or food allergies")
preferences = models.ManyToManyField(FoodPreference, null=True, blank=True)
class Meal (NutritionCapable, PricedModel, DatedModel):
"""Basic object representing a meal. NutritionCapable, PricedModel,
and DatedModel all come from a utility class that adds fields for
nutrition information, pricing information, and created/updated auto
fields."""
name = models.CharField(max_length=255)
mc = models.ManyToManyField(MC, verbose_name="Menu Category")
ingredients = models.ManyToManyField(Ingredient, null=True, blank=True)
def __uncode__ (self):
return unicode(self.name)
All this is basic modeling stuff. Ingredients
have a name and relationships to diets and food preferences. A Meal
can have many ingredients, a name, and a set of menu categories.1
Now let’s take a look at the Ingredient
add / edit form. This is a multi-use ModelForm, also available as a forms.py gist.
from django import forms
from menu.models import Ingredient, Diet, FoodPreference
class IngredientForm (forms.ModelForm):
class Meta:
model = Ingredient
exclude = ["franchise"]
def __init__ (self, *args, **kwargs):
brand = kwargs.pop("brand")
super(IngredientForm, self).__init__(*args, **kwargs)
self.fields["diets"].widget = forms.widgets.CheckboxSelectMultiple()
self.fields["diets"].help_text = ""
self.fields["diets"].queryset = Diet.objects.all()
self.fields["preferences"].widget = forms.widgets.CheckboxSelectMultiple()
self.fields["preferences"].help_text = ""
self.fields["preferences"].queryset = FoodPreference.objects.filter(franchise=brand)
All the fancy stuff happens in __init__
. I initialize a form with IngredientForm(brand=brand)
to setup the FoodPreference objects filtering, and change the widgets to CheckBoxSelectMultiple. This renders nicely as a row of checkboxes instead of the stupid multi-select.
Fancy M2M Relationships with ModelForms
The weirdness starts when we try to save this form.
form = IngredientForm(request.POST, brand=brand)
form.save()
This throws an error becuase that form specifically excludes the Franchise
relationship. Standard ForeignKey solution is to use commit=False
and define it yourself.
form = IngredientForm(request.POST, brand=brand)
ingredient = form.save(commit=False)
ingredient.franchise = brand
ingredient.save()
This works without visible error, until you go back into that object and notice that none of the M2M relationships (Diet
s and FoodPreference
s) were saved. Why does this happen?
Buried in the Django documentation on ModelForm is this snippet:
Another side effect of using commit=False is seen when your model has a many-to-many relation with another model. If your model has a many-to-many relation and you specify commit=False when you save a form, Django cannot immediately save the form data for the many-to-many relation. This is because it isn’t possible to save many-to-many data for an instance until the instance exists in the database.
This is how it changes the code.
form = IngredientForm(request.POST, brand=brand)
# Stage saving the ingredient object
ingredient = form.save(commit=False)
# Add the franchise and save the ingredient
ingredient.franchise = brand
ingredient.save()
# Finish saving the selected M2M relationships
form.save_m2m()
Note that save_m2m
is called on the old form object, not the saved model object.
This should be more prominent mentioned in the Django documentation. It was only after digging into how commit=False
works that I found this snippet.
Wrap Up
Django’s models and ModelForms are great, but certainly limited in some aspects. Once you master the basics, doing slight changes (like this) can take some time to figure out. Good news is once you do, they’re easy and you learn more.
Start putting together a list of custom Django fields, inheritable abstract models, and some new widgets to make the front end side more beautiful. This will save you time later and force you into a valuable write-reuse pattern.
-
This will be changed to a ForeignKey when I get a chance. ↩︎