Python

Django: Many-to-many tables and forms.

Django handles many-to-many relationships automatically by creating relationship tables. for instance, to use the typical example, consider the two models:

from django.db import models

class Topping(models.model):
    name = models.CharField(max_length=30)
    price = models.MoneyField()
     :

class Pizza(models.Model):
    name = models.CharField(max_length=30)
    price = models.MoneyField()
     :

If we wanted to add in a relationship between toppings and pizzas we could simply define Pizza as:

class Pizza(models.Mpdel):
    name = models.CharField(max_length=30)
    price = models.MoneyField()
    topping = models.ManyToManyField(Topping)

All very straightforward. Django will automatically create a table with a record in for every Pizza/Topping combination, allowing every pizza to have several toppings, and every topping to feature on one or more pizzas. A great thing about this is that if you create a modelform from the Pizza model, for instance, Django will create a control to allow you to select/remove toppings from the pizza, and the form save method will make sure all the relations are updated correctly.

Lovely.

Well, almost. If you need to keep other information on the relationship, like the date the relationship was created ("When did we add the pepperoni to the Hawaiian?") you will need to create a through table. This is not difficult, but the Pizza's topping.add() and topping.remove() methods will disappear, which is an inconvenience, but worse it breaks the modelform meaning that the form.save() method won't work unless you exclude the toppings field from the form.

Here's the new code:

from django.db import models

class Topping(models.model):
    name = models.CharField(max_length=30)
    price = models.MoneyField()
     :

class Pizza(models.Model):
    name = models.CharField(max_length=30)
    price = models.MoneyField()
    topping = models.ManyToManyField(Topping, through='PizzaTopping')

class PizzaTopping(models.Model):
    pizza = models.Foreignkey(Pizza)
    topping = models.ForeignKey(Topping)
    created = models.DateField(auto_now_add=True)

If you create a form:

from django import forms
class PizzaForm(forms.ModelForm):
    class Meta:
        model = Pizza

You will find all is OK until you try to call the PizzaForm.save() method. Django will raise an exception "Cannot set values on a ManyToManyField which specifies an intermediary model. Use Pizza's Manager instead."

This puzzled me for a while. On the model I was working on, there where several "standard" M2M fields which I didn't wnat to have to manually handle all the time, but the save() and save_m2m() functions would trip over on the through model.

In the end I worked out an easy way to fix this without going to mad extents. The view was something like this:

def pizza(request, id=None):
    if id is None:
        pizza = Pizza()
    else:
        pizza = Pizza.objects.get(id=id)
    if request.method == 'POST':
        form = PizzaForm(request.POST, instance=pizza)

        if form.is_valid():
           toppings = form.cleaned_data.pop('topping')
           form.save()

           pizza.topping.clear()    # delete existing toppings
           for topping in toppings: # add them back in
               PizzaTopping.objects.create(pizza=pizza, topping=topping)
           return HttpResponseRedirect('/')
    else:
        form = PizzaForm()
return render_to_response('save_pizza.html', locals())

The key point is removing the through-table field from the cleaned data (using the "pop" method as above) which stops save() from trying (and failing) to save it. However, pop returns whatever it removes from the list, so we know what the toppings are, and we can the create the toppings manually.

Of course its better to let Django manage it automatically, but sometimes the data you have access to is determined by external factors :)

Version 1 published 23 Mar 2015, 5:07 p.m.