Using a text input for ManyToMany relations in Django
Sunday, February 7th, 2010Problem:
You have a many-to-many relationship on a model — i.e. Video has many Tags — and you want to be able to use just a text input to manage that relationship.
I recently came across this problem while working on a Video site that called for tagging of uploaded Media. Django’s default way of dealing with ManyToMany fields (checkboxes) did not seem appropriate since the UX would degrade very quickly as the number of tags grow. The UI paradigm that everyone is familiar with is obviously to enter the tags in a comma-delimited format, like how most popular blog sites have it. This post will walk you through how to implement this in a ModelForm that can be used in your admin. This tutorial is for Django version 1.1.1. In retrospect, I guess a custom widget may have been the option with best reusability, but if for nothing else, this post could give you a pretty good idea of how flexible Django is to extend and modify.
Models
First off, we create the VideoTag and Video class. Notice how the tags attribute of the Video model has editable=False - since we do not want to bother with the select boxes in our form.
from django.utils.translation import ugettext as _
from django.db import models
from django.template.defaultfilters import slugify
# models.py
class VideoTag(models.Model):
slug = models.SlugField(max_length=30, editable=False, blank=True, unique=True)
name = models.CharField(_('name'), max_length=30)
parent = models.ForeignKey('VideoTag', verbose_name=_('parent'), related_name='children', blank=True, null=True)
def __str__(self):
return '%s' % (self.name)
def __unicode__(self):
return u'%s' % (self.name)
def save(self, force_insert=False, force_update=False):
self.slug = slugify(self.name)
super(VideoTag, self).save(force_insert, force_update)
class Video(models.Model):
title = models.CharField(_('title'), max_length=32)
summary = models.TextField(_('summary'), max_length=300)
tags = models.ManyToManyField(VideoTag, verbose_name=_('tags'), related_name='videos', blank=True, editable=False)
Forms
Now that we have the models, we can run go straight to the forms part, where all the relevant code is. As you can see, there are two steps to this: the first part is to override the initialization of the form to set the initial value of the tags_text field to read the tags M2M fields; the second part is to capture the input and save it as related objects.
#forms.py
from django.utils.translation import ugettext as _
from django import forms
from django.template.defaultfilters import slugify
from your_app.models import Video, VideoTag
class VideoForm(forms.ModelForm):
tags_text = forms.CharField(label=_("video tags"), required=False, help_text=_("A comma-delimited list of the tags that you would like to tag this with"),)
def __init__(self, *args, **kwargs):
super(VideoForm, self).__init__(*args, **kwargs)
# args.__len__ is greater than zero if it is a postback
if len(args) == 0 and self.instance != None and self.instance.id != None:
tags = list(self.instance.tags.all().order_by('name'))
if len(tags) > 0:
initial_text = reduce(lambda x, y: u'%s, %s' % (x, y.name), tags)
self.fields["tags_text"].initial = initial_text
def save(self, commit=True):
instance = super(VideoForm, self).save(commit=True)
tags = self.cleaned_data["tags_text"]
instance.tags.clear()
if tags:
for tag in tags.split(','):
if tag.lower().strip() != "":
video_tag = None
tag = tag.strip()
try:
video_tag = VideoTag.objects.get(slug=slugify(tag))
except VideoTag.DoesNotExist:
video_tag = VideoTag()
video_tag.name = tag
video_tag.save()
instance.tags.add(video_tag)
return instance
def save_m2m(self):
pass
class Meta:
model = Video
Admin
Next, you may want to use this form in your admin, so let’s set the admin to use the correct form:
#admin.py from django.contrib import admin from your_app.models import Video from your_app.forms import VideoForm class VideoAdmin(admin.ModelAdmin): form = VideoForm admin.site.register(Video, VideoAdmin)