Model Relationships and "list_display"

Yesterday I had one of my coworkers ask me what I thought to be a simple Django question: "in the admin pages im trying to show fields from different tables but it wont let me." I clarified the problem with this chap, and eventually suggested using something like this:

from django.contrib import admin
from project.app.models import AwesomeModel

class AwesomeModelAdmin(admin.ModelAdmin):
    list_display = ('fk_field__fk_attr1', 'fk_field2__fk_attr')

admin.site.register(AwesomeModel, AwesomeModelAdmin)

The Problem

As it just so happens, that does not work with Django as of SVN revision 9907 (or any previous versions I presume). You cannot span relationships in your Django models from the list_display item in your model admin classes. This completely caught me off guard, so I looked through the docs a bit. I found some work-arounds for the issue, but they all seemed pretty ridiculous and, more importantly, violations of the DRY principle. It also surprised me that I hadn't noticed this problem before! I guess that's why it's not fixed yet--it's not really required by all that many people?

Anyway, I did a bit of research into the issue. I stumbled upon a ticket which appears to be aimed at resolving this problem. The ticket is pretty old, and it looks like it's still up in the air as to whether or not the patches will be applied to trunk. That wasn't very encouraging.

A Solution

Being the nerd that I am, I set out to find an "efficient" solution of my own for the problem, without meddling with the Django codebase itself. Below you will find my attempt at some pure Python hackery (no Django involved other than overriding a method) to make our lives easier until someone with some pull in the Django community gets something better into Django's trunk.

Disclaimer: this might well be the absolute worst way to approach the problem. I'm okay with that, because I still like the results and I learned a lot while producing them. I don't have any benchmarks or anything like that, but I wouldn't complain if someone else came up with some and shared them in the comments.

from django.contrib import admin

def mygetattr(obj, hier):
    """
    Recursively attempts to find attributes across Django relationships.
    """
    if len(hier):
        return mygetattr(getattr(obj, hier[0]), hier[1:])
    return obj

def dynamic_attributes(self, attr, *args, **kwargs):
    """
    Retrieves object attributes.  If an attribute contains '__' in the name,
    and the attribute doesn't exist, this method will attempt to span Django
    model relationships to find the desired attribute.
    """
    try:
        # try to get the attribute the normal way
        return super(admin.ModelAdmin, self).__getattribute__(attr, *args, **kwargs)
    except AttributeError:
        # the attribute doesn't exist for the object.  See if the attribute has
        # two underscores in it (but doesn't begin with them).
        if attr and not attr.startswith('__') and '__' in attr:
            # it does!  make a callable for the attribute
            new_attr = lambda o: mygetattr(o, attr.split('__'))

            # add the new callable to the object's attributes
            setattr(self, attr, new_attr)

            # return the callable
            return new_attr

# override the __getattribute__ method on the admin.ModelAdmin class
admin.ModelAdmin.__getattribute__ = dynamic_attributes

This code could be placed, for example, in your project's root urls.py file. That would make it so that all of the apps in your project could benefit from the relationship spanning. Alternatively, you could place it in the admin.py module for a specific application. It would just need to be someplace that was actually processed when your site is "booted up."

Basically this code will override the built-in __getattribute__ method for the django.contrib.admin.ModelAdmin class. When an attribute such as fk_field__fk_attr1 is requested for an object, the code will check to see if an attribute already exists with that name. If so, the existing attribute will be used. If not, it chops up the attribute based on the __ (double underscores) that it can find. Next, the code does some recursive getattr() calls until it runs out of relationships to hop across so it can find the attribute you really want.

Once all of that is done, the end result is placed in a callable attribute for the respective admin.ModelAdmin subclass so it won't have to be built again in the future. The new callable attribute is what is returned by the __getattribute__ function.

Caveats

Now, there are some funky things that you must be aware of before you go an implement this code on your site. Very important things, I might add. Really, I've only found one large caveat, but I wouldn't be surprised if there are others. The biggest issue is that you have to define the list_display attribute of your ModelAdmin class after you register the model and the admin class with the admin site (see below for an example). Why? Because when the Django admin validates the model's admin class, it checks the items in the list_display. If it can't find a callable attribute called fk_field__fk_attr1 during validation, it will complain and the model won't be registered in the Django admin site. The dynamic attributes that are built by my hackery are added to the object after it is validated (more accurately, when the list page is rendered, from what I have observed).

This is by far the most disgusting side effect of my hackery (at least that I have observed so far). I don't like it, but I do like it a lot more than defining loads of callables in several ModelAdmin classes just to have something simple show up in the admin's list pages. You're free to form your own opinions.

Using the original code I offered to my coworker, this is how it would have to look in order for my hack to work properly.

from django.contrib import admin
from project.app.models import AwesomeModel

class AwesomeModelAdmin(admin.ModelAdmin):
    pass

admin.site.register(AwesomeModel, AwesomeModelAdmin)
AwesomeModelAdmin.list_display = ('fk_field__fk_attr1', 'fk_field2__fk_attr')

See, I told you it was funky. But again, I'll take it.

If any of you have thoughts for how this could be improved, please share. Constructive criticism is very welcome and encouraged. Please also consider reviewing the ticket that is to address this problem.

Comments

Comments powered by Disqus