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 (
FoodPreferences) 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()
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.
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. ↩