Generic Collections in Django
by Corey Oordt • Published 12 Jan 2009
Django‘s generic relations are a great way to connect an object to any other type of object. Django provides a GenericForeignKey field to connect one object to any other object. For a recent project I needed to create a Many-to-Many relationship between an object and certain types of other objects, and have them editable in the admin. Here’s how I did it.
The Models
For this example we’ll create several simple models.
class Photo(models.Model): "A simple photo model" title = models.CharField(max_length=50) image = models.ImageField(upload_to='img/photos/%Y/%m/%d') class Audio(models.Model): "A simple audio model" title = models.CharField(max_length=50) image = models.ImageField(upload_to='audio/%Y/%m/%d') class Link(models.Model): "A simple link model" title = models.CharField(max_length=50) link = models.URLField() class BlogEntry(models.Model): "A simple Blog Entry model" title = models.CharField(max_length=50) slug = models.SlugField(max_length=50) body = models.TextField()
Now let’s find a way to relate any number of these objects to a single Blog Entry. To do that we are going to need an intermediary table that will handle the relations.
blogentryrelation_limits = {'model_in':('photo', 'audio', 'link', 'blogentry')} class BlogEntryRelation(models.Model): "A simple photo model" blogentry = models.ForeignKey(BlogEntry) content_type = models.ForeignKey(ContentType, limit_choices_to=blogentryrelation_limits) object_id = models.PositiveIntegerField() content_object = generic.GenericForeignKey('content_type', 'object_id')
The blogentryrelation_limits
variable enables you to allow the selection of certain models in the admin.
The Admin
To see everything properly in the admin site, we need to create an admin.py file like so:
from django.contrib import admin from sampleproject.models import * admin.site.register(Photo) admin.site.register(Audio) admin.site.register(Link) class BlogEntryRelationInline(admin.TabularInline): "Easy editing of blog entry relations from the blog entry page" model = BlogEntryRelation class BlogEntryAdmin(admin.ModelAdmin): "The admin for the BlogEntry model" prepopulated_fields = {'slug': ('title',)} inlines = [BlogEntryRelationInline,]
Now that will allow you to create an arbitrary collection of related items to your blog, but it isn’t very elegant; you have to know the object id of the content type you select. Chances are you don’t know that. It would be really cool if you could use the widget for the raw_id_field
to search or create the object based on the selection of the content_type
.
Creating a new InlineModelAdmin
The first thing to do is to create our own InlineModelAdmin subclass. Let’s call it GenericCollectionInlineModelAdmin, because there already is a GenericInlineModelAdmin in Django that does something a bit different.
from django.contrib import admin from django.contrib.contenttypes.models import ContentType class GenericCollectionInlineModelAdmin(admin.options.InlineModelAdmin): ct_field = "content_type" ct_fk_field = "object_id" def __init__(self, parent_model, admin_site): super(GenericCollectionInlineModelAdmin, self)\ .__init__(parent_model, admin_site) ctypes = ContentType.objects.all()\ .order_by('id')\ .values_list('id', 'app_label','model') elements = ["%s: '%s/%s'" % (id, app_label, model) \ for id, app_label, model in ctypes] self.content_types = "{%s}" % ",".join(elements) def get_formset(self, request, obj=None): result = super(GenericCollectionInlineModelAdmin, self)\ .get_formset(request, obj) result.content_types = self.content_types result.ct_fk_field = self.ct_fk_field return result class GenericCollectionTabularInline(GenericCollectionInlineModelAdmin): template = 'admin/edit_inline/gen_coll_tabular.html' class GenericCollectionStackedInline(GenericCollectionInlineModelAdmin): template = 'admin/edit_inline/gen_coll_stacked.html'
The GenericCollectionInlineModelAdmin overrides the __init__
method so it can set a content_types
property. This property is a string formatted as a Javascript object. We will need this bit of Javascript in the template to tell us the correlation of the content type id to the URL for the pop up window we’ll create.
We also have to override the get_formset
method so we can add our Javascript object to the formset object passed to the template, and the name of the foriegn key field, so we can look for it in the template. The last two classes simply implement the correct template for either the tabular style or the stacked style.
The InlineModel templates
Now we need to do is the templates. You can store the templates anywhere, I store them in the project templates folder under admin/edit_inline
. First copy the files from django/contrib/admin/templates/admin/edit_inline/
I’ll go through just the tabular template and leave the stacked template for you. First, at about line 41 you’ll see the following block of code:
{% for fieldset in inline_admin_form %} {% for line in fieldset %} {% for field in line %} <td class="{{ field.field.name }}"> {{ field.field.errors.as_ul }} {{ field.field }} </td> {% endfor %} {% endfor %} {% endfor %}
We have to add a few things in there like so:
{% for fieldset in inline_admin_form %} {% for line in fieldset %} {% for field in line %} <td class="{{ field.field.name }}"> {{ field.field.errors.as_ul }} {% ifequal field.field.name inline_admin_formset.formset.ct_fk_field %}{{ field.field }} <a id="lookup_id_{{field.field.html_name}}" class="related-lookup" onclick="return showGenericRelatedObjectLookupPopup(this, {{ inline_admin_formset.formset.content_types }});" href="#"> <img width="16" height="16" alt="Lookup" src="/media/img/admin/selector-search.gif"/> </a> {% else %}{{ field.field }} {% endifequal %} </td> {% endfor %} {% endfor %} {% endfor %}
We added a check for the field name. For the object_id
field (or whatever it was overridden as), we want to add the magnifying glass icon. In the <a>
tag we add an onclick
reference to a Javascript function that we we go over in a bit. Notice that the second parameter of the function is the content type object we created in our custom inline admin class.
One weaknesses with this implementation: if you don’t have Javascript enabled, it doesn’t work. I really can’t see anything around this as Javascript is required to access the current value of the content type select box.
The Javascript
The penultimate piece is the Javascript function to display the open window. I heavily borrowed from the RelatedObjectLookups.js file in the admin. The Javascript file looks like:
function showGenericRelatedObjectLookupPopup(triggeringLink, ctArray) { var realName = triggeringLink.id.replace(/^lookup_/, ''); var name = id_to_windowname(realName); realName = realName.replace(/object_id/, 'content_type'); var select = document.getElementById(realName); if (select.selectedIndex === 0) { alert("Select a content type first."); return false; } var selectedItem = select.item(select.selectedIndex).value; var href = triggeringLink.href.replace(/#/,'../../../'+ctArray[selectedItem]+"/?t=id"); if (href.search(/\?/) >= 0) { href = href + '&pop=1'; } else { href = href + '?pop=1'; } var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); win.focus(); return false; }
The script basically converts the name of the link into the name of the content type select box. Then it creates the url, based on the the values in the ctArray passed. All the other functionality is built into Django.
Modify our admin.py
Finally, we have to add this script into he admin for our BlogEntry:
class BlogEntryAdmin(admin.ModelAdmin): "The admin for the BlogEntry model" prepopulated_fields = {'slug': ('title',)} inlines = [BlogEntryRelationInline,] class Media: js = ('/static/js/genericcollection.js',)
Now if you go to the admin, you will see the the inline forms at the bottom.
blog comments powered by Disqus