Skip to main content

abstract models and the django admin

  • FACT 1: Everyone loves django.contrib.admin
  • FACT 2: Django provides many superb ways to avoid writing boilerplate code, and provides an excellent model inheritance system.

What the django admin doesn't do is provide a way to automatically register all subclasses of a given abstract model. Although there is plenty of justification for not doing this in django.contrib.admin, there is a rather elegant way to achieve this behaviour using Python's incredible metaclass, type. I'll briefly cover what metaclasses are below, but first consider the following abstract model defined by a hypothetical publishing house in a reusable django app intended to be used for cataloguing items they publish : genericadmin.models

from django.db import models
from django.utils.timezone import now, timedelta


class PublishedItemBase(models.Model):
    class Meta:
        abstract = True

    title = models.CharField(max_length=128)
    published = models.DateTimeField(default=now)
    author = models.CharField(max_length=128)
    description = models.TextField()

    #some commonly useful methods
    def published_within(self, **kwargs):
        return self.published + timedelta(**kwargs) > now()

    def published_recently(self):
        return self.published_within(days=5)

Right now, it's only a skeleton - because this is an example, and I've never worked for a publishing house - but it should serve for the purposes of demonstration. You could then use this model in your new bookshop app by doing the following. bookshop.models:

from genericadmin.models import PublishedItemBase

class Comic(PublishedItemBase):
    pass

This will now actually create tables in your database when you run syncdb and you can instantiate/query Comic in the normal way. To be able to do CRUD operations in the django admin, however,  you would typically do something like this for each model in yourbookshop.admin` version 1:

from django.contrib import admin
from bookshop.models import Comic


class ComicAdmin(admin.ModelAdmin):
    class Meta:
        model = Comic
    #insert other useful stuff here


admin.site.register(Comic, ComicAdmin)

After a syncdb and python manage.py runserver, you'll see something like this:

django-admin-generic-1

Now realise that as the publishing house is constantly coming up with new things to publish -books, jounals, magazines, software, boardgames, artbooks, books on tape etc. etc.  - programmers will find themselves manually adding something like the above for every single type of item the house publishes, even if it's just adding a field or method or two to the baseclass without modifying the basic behaviours of the ModelAdmin.  This is not really maintainable, and is certainly not DRY. Some days pass, and our bookshop.models module has a number of new publishments, but no-one bothered to add them to the admin. THAT MAKES JOHNNY A DULL BOY. New bookshop.models:

from django.db import models
from genericadmin.models import PublishedItemBase
from django.core.exceptions import ValidationError


def is_valid_isbn13(isbn):
    #(C) Wikipedia contributors
    #https://en.wikipedia.org/wiki/International_Standard_Book_Number
    isbn=isbn.replace('-', '')
    check = (10 - (sum(int(digit) * (3 if idx % 2 else 1) 
        for idx, digit in enumerate(isbn[:12])) % 10)) % 10
    if check != int(isbn[-1]):
        raise ValidationError("Bad ISBN 13")


class Editor(models.Model):
    name = models.CharField(max_length=128)
    email_address = models.EmailField()


class Pamphlet(PublishedItemBase):
    pass


class Comic(PublishedItemBase):
    pass


class boardgame(PublishedItemBase):
    players = models.PositiveIntegerField()
    age_min = models.PositiveIntegerField()


class Book(PublishedItemBase):
    editor = models.ForeignKey(Editor, null=True)
    city = models.CharField(max_length=187)
    pages = models.PositiveIntegerField()
    isbn13 = models.CharField(max_length=32, validators=[is_valid_isbn13,])


class Software(PublishedItemBase):
    homepage = models.CharField(max_length=65536)
    license = models.CharField(max_length=128)
    programming_language = models.CharField(max_length=64)
    scm = models.CharField(max_length=64)
    version_major = models.PositiveIntegerField()
    version_minor = models.PositiveIntegerField()
    version_patch = models.PositiveIntegerField()

Adding five models manually to the admin might not be any trouble, but things will soon become tiring when your boss decides they will let marketing decide on how many types of items they will be publishing. Enter the type keyword.

Most novice Python programmers are familiar with the first usage of type:

>>> type('mystringhere')
<type 'str'>
>>> class MyClass(object):
...     def hello(self):
...         print "hello"
... 
>>> type(MyClass)
<type 'type'>
>>> type(MyClass())
<class '__main__.MyClass'>
>>> def hello():
...     print "Hello, World!"
... 
>>> 
>>> type(hello)
<type 'function'>
>>> type(type)
<type 'type'>
>>> 

In this scenario, it is useful for determining what kind of an object you're dealing with.   It works for instances, as well as classes as can be seen above.  I would argue that this usage of type()  isn't really needed for lots of cases like this because of Python's duck-typing. However, this isn't the usage of type I'm getting at. Indeed, you might be asking "Wait, why is type(type) returning 'type';  isn't type a function?" Sort of.  type()  in this case works like a function, but actually has type of 'type'.  WTF? Everything in Python is an object. Functions are objects, strings are objects, and classes (and instances of those classes) are all objects along with modules too. They're all objects. You hear me? This means that you can set attributes of almost any variable in your code, and pass those objects around as variables to be manipulated just as you would with things like ints or strings.

"If you can create a new object of type str at runtime, and everything in python is an object, surely you can create new class objects dynamically?"

Yes. This is what metaclasses are for.

Type: type String Form:\<type 'type'> Namespace: Python builtin Docstring: type(object) -> the object's type type(name, bases, dict) -> a new type

I won't go into detail explaining metaclasses or the basic usage of the type function, because I really can't do it justice compared to This incredible Stack Overflow answer that you should read before continuing.  It's probably one of the most beautifully explained, succinct and thoroughly handled answers I've read on Stack Overflow, and is essential reading for anyone who hopes to understand the beauty of Python.  In short: it explains that there are various ways we can dynamically create new classes, but they all boil down to using type()  in its 3-argument form as above. This is an amazing power that few languages possess and it is what we'll use to create our ModelAdmin classes dynamically, that the django admin interface may  know about them without us lifting a finger in future. As you can imagine, there are some subtleties to doing this properly; to quote a comment on the above-linked S.O. post "there are hacks, tricks, voodoo magic, dark arts, and then there are Python metaclasses". Time to get our feet wet...Finished reading that article yet? Once we understand that we can use the type keyword in its second usage - type(name, bases, dict)  to create a new type - and that we can use Python's introspection capabilities to decide what to put in the new class - then we can take an abstract django model and generate all the classes for the admin pages at runtime doing everything you need to register all models of our baseclassPublishedItemBase  within the admin interface

from django.contrib import admin
from django import forms

from genericadmin.models import PublishedItemBase


class _PublishedItemModelFormTemplate(forms.ModelForm):
    #Add your template methods/search fields etc here
    pass


class _PublishedItemModelAdminTemplate(admin.ModelAdmin):
    #Add any behavioural specifics you want here
    pass


class _PublishedItemGenericModelFormMeta(type):
    """
    This is where Python's metamagic happens.
    Classes that inherit from ``type`` do not create instances of a class, but 
    an object that encapsulates the definition of how that object should behave
    i.e. a 'class' object. Remember, in Python *everything* is an object.

    The value returned by our __new__ method is a newly instantiated object 
    of type 'type':

        >>>class MyMetaClass(type):
        >>>    pass
        >>>
        >>>MyMetaClass(object)(str) == type(str)
        True

    Read the amazing writeup again: http://stackoverflow.com/a/6581949
    """

    def __new__(cls, clsname, bases, attrs):
        """
        To create our class we use type() to generate a new class that we 
        return as an object from __new__. __new__() is called before __init__() to 
        create an object of the expected type.
        """
        #first some basic checks
        if len(bases) < 1:
            raise ValueError(
                "PublishedItemAdminForm requires a base class"
            )
        assert issubclass(bases[0], PublishedItemBase)

        class_meta = type('PublishedItemAdminModelFormMeta', 
            (object,), 
            {'model':bases[0]}
        )
        class_dict = dict({'Meta':class_meta})

        #add user overrides (if given)
        class_dict.update(attrs)
        model_form = type(
            bases[0].__name__ + 'ModelForm', 
            (_PublishedItemModelFormTemplate,), 
            class_dict
        )
        return model_form


class PublishedItemGenericModelAdminMeta(type):
    """More ``type()`` magic here, this time for the ModelAdmin class"""
    def __new__(cls, clsname, bases, attrs):
        if len(bases) < 1:
            raise ValueError(
                "PublishedItemAdminForm requires a base class"
            )
        #django ModelAdmin classes are required to have a Meta member class with 
        #a 'model' attribute that points to the model type
        class_meta = type('PublishedItemAdminModelAdminMeta', 
            (object,), 
            {'model':bases[0]}
        )
        class_dict = dict({'Meta':class_meta})

        #we want all our generic form behaviours to be inherited as well, 
        #so add these to the attribute dict.
        class_dict['form'] = _PublishedItemGenericModelFormMeta(clsname, bases, attrs)
        class_dict.update(attrs)
        #use type to create the class
        model_admin = type(
            bases[0].__name__ + 'ModelAdmin', 
            (_PublishedItemModelAdminTemplate,), 
            class_dict
        )      
        return model_admin


def register_all_publishing_models(mixins=None, **attr_dict):
    if mixins is None:
        mixins = ()
    #type() doesn't like lists, only tuples
    mixins = tuple(mixins)
    #TODO add capability to inherit from multiple base classes (mixins). 
    #This can be done easily, I just haven't done it yet. Leave me alone.

    #all new-style classes (those that inherit from object have the __subclasses__ 
    #method that returns...a list of subclasses!
    classes =  PublishedItemBase.__subclasses__()
    model_admins = [
        PublishedItemGenericModelAdminMeta(x.__name__, (x,) + mixins, attr_dict) 
        for x in classes 
    ]

    for x, y in zip(classes, model_admins):
        print "registering model %s%s" % (x, y)
        admin.site.register(x, y)

Now in genericadmin.admin we just import and call register_all_publishing_models() and magically, the admin interface knows how to handle all the new PublishedItemBase subclasses - thus:

django-admin-generic-2

I've thrown together a quick django project to demonstrate all of this that you can download here. You can even put something really filthy in your bookshop.models just to hit-home how fun this dynamic class generation business is:

# This is nasty...

for x in range(100):
    a = type('bookshop.models.MyPublishedModel' + str(x), 
        (PublishedItemBase,), 
        {'__module__':'bookshop.models','myfunc':lambda self,a:a}
    )

Feedback / improvements / questions welcome.