Populating Default ManyToMany Field Values in Django

Randall
Sat, 26 Jun 2010 19:05:19 +0000

At work, I'm the lead developer of a rather large, complex web application which interacts with many different technologies (Asterisk, Freeswitch, Cisco routers, python, XML-RPC, JSON, Django--to name a few). A few days ago, while implementing a ban system, I bumped into an interesting problem that was not trivial to find a solution to. So, here it is :)

Background

The web application I'm developing is a private portal which allows users to manage teleconference lines real time. Since all of our telephony services are free of charge, we often get callers onto certain teleconference lines who want to abuse services (think of those trolls on the internet, except over the phone). As you can probably imagine, without strict regulation & technology in place, telephone trolls could cause huge problems for normal users.

To combat this, I wrote a relatively simple ban system which allows web admins to remove abusive callers from specific teleconference lines. Each teleconference line is represented by a Django model class, which looks something like:

class Teleconference(models.Model):

    name = models.CharField('Teleconference name.', max_length=50)
    did = PhoneNumberField('Teleconference phone number.', unique=True)
    owner = models.ForeignKey(User)
    bans = models.ManyToManyField(Caller, blank=True, null=True)

    def __unicode__(self):
        return '%s:%s' % (self.name, self.did)

The newly added bans field stores a list of Caller objects which map to individual callers, and allow the web users to ban specific callers if they're causing trouble.

Problem

The problem came up when I was trying to finish the view which allows web admins to select which callers they want to ban.

The view code (originally) looked something like:

def edit_teleconference(request, id=None):
    teleconference = get_object_or_404(Teleconference, id=id)

    if request.method == 'POST':
        form = TeleconferenceForm(request.POST, instance=teleconference)
        if form.is_valid():
            if 'name' in form.cleaned_data:
                teleconference.name = form.cleaned_data['name']
            if 'did' in form.cleaned_data:
                teleconference.did = form.cleaned_data['did']
            if 'owner' in form.cleaned_data:
                teleconference.owner = form.cleaned_data['owner']
            if 'bans' in form.cleaned_data:
                teleconference.bans = form.cleaned_data['bans']

            teleconference.save()
    else:
        defaults = {
            'name': teleconference.name,
            'did': teleconference.did,
            'owner': teleconference.owner.id
        }
        if teleconference.bans:
            defaults['bans'] = teleconference.bans.id

        form = TeleconferenceForm(request.POST, instance=teleconference)

    variables = RequestContext(request, {'form': form})
    return render_to_response('portal/teleconf/edit.html', variables)

The problem in the code above resides on line 24. Attempting to populate the default values for a ManyToMany field using the id attribute does not work.

After a bit of playing around, I was unable to find a solution, so I checked google. After ~20 minutes of google, I was still stuck with the same problem.

Solution

To resolve the issue, and successfully populate the default bans field values, I had to do:

def edit_teleconference(request, id=None):
    teleconference = get_object_or_404(Teleconference, id=id)

    if request.method == 'POST':
        form = TeleconferenceForm(request.POST, instance=teleconference)
        if form.is_valid():
            if 'name' in form.cleaned_data:
                teleconference.name = form.cleaned_data['name']
            if 'did' in form.cleaned_data:
                teleconference.did = form.cleaned_data['did']
            if 'owner' in form.cleaned_data:
                teleconference.owner = form.cleaned_data['owner']
            if 'bans' in form.cleaned_data:
                teleconference.bans = form.cleaned_data['bans']

            teleconference.save()
    else:
        defaults = {
            'name': teleconference.name,
            'did': teleconference.did,
            'owner': teleconference.owner.id
        }
        if teleconference.bans:
            defaults['bans'] = [t.pk for t in teleconference.bans.all()]

        form = TeleconferenceForm(request.POST, instance=teleconference)

    variables = RequestContext(request, {'form': form})
    return render_to_response('portal/teleconf/edit.html', variables)

Which basically passes a list of Caller id attributes to the form. While this now seems an intuitive solution to me, I had a great deal of trouble initially figuring it out.

The way that form defaults work for Foreign and ManyToMany fields is that they take in either a model's id attribute for ForeignKeys, or a list of model ids for ManyToMany fields.

Conclusion

Populating Django ManyToMany field default values can be a bit confusing, and somewhat undocumented. Hopefully the code presented above helps clarify how to do it properly, and why it works the way it does.