Josh Ourisman » On the other hand

Navigation: Blog, Portfolio

Django admin awesomeness

October 15th, 2009

Yes, I realize that this is now my third post not related to DVCSes since I said my next post would be about DVCSes. So sue me.

I recently encountered an interesting requirement for the Django admin: we wanted people to normally only see (and be able to edit) objects that either they created themselves, or that the creator had assigned them to. Fortunately this is insanely easy in Django 1.1, all you have to do is override the queryset() method of the appropriate ModelAdmin like so:

This snippet very easily allows you to apply essentially any filter you want to the QuerySet that gets passed on to the change_list and allows you to provide an exception for super users (always a good thing!). That part was really easy and something I've done before (even in 1.0.x where the functionality is there, just not exposed). Where it got trickier was where the client also wanted the functionality to be able to view all the objects regardless of who created them, but still only have editing capabilities for their own objects (and only see the information available on the change_list for others).

This part was much harder, but fortunately also made very possible by the updates to ModelAdmin in the 1.1.x branch. The first thing I wanted to do was just provide a new URL and view integrated seamlessly into the admin. Again this was very simple with the new admin, and required only overriding the get_urls() method on the ModelAdmin:

Getting that all_objects view to return something essentially identical to the normal change_list, but with a completely different filter on the queryset, and some different display options was the real problem I had to address. Wrapping my head around the problem took some time, but fortunately even this was pretty simple once I really started to get into the flexibility of the ModelAdmin class.

Broken down to it's most basic level, what I wanted to do was return the change_list view from a Model Admin. This in itself is very simple to do, and requires very little code:

Once I figure that out it was pretty obvious that what I needed to do was subclass my ModelAdmin and just re-overide the relevant functions. Turns out this is really easy to do, and gives you a whole lot of flexibility. So I created a special AllItemsAdmin subclass of ThisAdmin, and overrode that queryset() method to return all the objects. I then had to figure out a way to get it to only display but not link to edit pages for objects that the current user doesn't own. Since I needed them to be in the queryset, this was a little trickier.

Anyone who's been working with the Django admin should know about the list_display option on the ModelAdmin. What you might not know (I didn't until recently) is that no only can the list_display list contain field names and properties from the model (Actually, if you didn't know that you can provide callable properties from your model, you should check that out. It lets you do some very useful things.), you can also provide callables from within your model admin. So you could set your list_display to something like ['title', 'my_function'] where my_function is a method on your model admin with a definition along the lines of my_function(self, object) that can perform operations on the Django object for that row and return whatever value you want. The normal options for model properties (such as allow_tags and short_description) work here as well. So what I was able to do with this was create a custom column for my change_list that looked at the specific object, checked the ownership properties as above, and then returned either just the name of the object, or the name of the object as a link to the regular edit page for that object. By setting list_display_links to (None,) I was able to prevent any of the fields from automatically be turned into links. Of course doing this required that method to have access to the request which it usually wouldn't, but since I was already working with a hacked up subclass of my ModelAdmin I was able to just override the __init__() to take the request object and pass that in when I instantiated it. What I ended up with was this really awesome view (if I do say so myself):

As you can see, this is also using another new feature in the 1.1.x admin: reversing of admin URLs. After this all I needed was a few simple changes to the change_list.html template for this model let me add a 'View all' link to go to this new view, and then the 'all' context variable being passed in as extra context simply tells it to provide a link back to the standard view otherwise.

The result of all this was a seamless integration of my custom 'view, but don't edit all' view into the Django admin.

Ordering on inline edited items in Django's admin with jQuery

February 13th, 2009

I just finished up with some relatively simple, but still fun modifications to the Django admin site for one of the projects I'm working on. For this project I needed to create a many-to-many relationship between two models with ordering information associated with it. This is fairly easy to accomplish by creating a many-to-many with an intermediary table. But providing a convenient mechanism for managing that information is a little trickier. By default you'll just end up with a text entry box in which to manually type the order for that item. This gets pretty old pretty fast, so finding a better method is important. In the past I've used jQuery to add drag and drop re-ordering to inline edited models, but this time I needed to do a little more as well. Specifically I wanted to make it easier to add more inline item. Ordinarily you just set an arbitrary number of empty boxes to have displayed (by default 3) and if you want more you have to fill those boxes, then hit 'Save and continue editing' to get three more. This is a pretty crappy way to do it (but the only way without introducing unwanted dependencies).

Some googling revealed that Arne Brodowski had done pretty much exactly what I wanted to so, so I worked up some modified version of his scripts. The first step was setting it up to hide entries that were marked for deletion. Arne provided a prototype script to do this, but I made a few modifications that clean it up a bit and made it actually work (at least with jQuery 1.3.1). What I ended up with was this somewhat more elegant looking script:

jQuery(function($) {
$("div.inline-related input:checkbox[id$=DELETE]").change(function() {
if ($(this).attr('checked')) {
$(this).parents('div.inline-related').children('fieldset.module').addClass('collapsed');
} else {
$(this).parents('div.inline-related').children('fieldset.module').removeClass('collapsed');
}
});
});

The changes I made are mainly in the selector for grabbing the checkboxes ($("div.inline-related input:checkbox[id$=DELETE]"), and in the method for checking whether or not the box is actually checked ($(this).attr('checked')). With those changes it works exactly as advertised, which is a pretty handy bit of functionality.

The next step was slightly more complex. To be able to dynamically add more relationships without having to 'Save and continue editing', I made basically the same template changes as Arne did, and fortunately this time didn't need to make many changes to the script. I basically just removed the 'return false' from the end and wound up with this:

function increment_form_ids(el, to, name) {
var from = to-1 ;
$(':input', $(el)).each(function(i,e){
var old_name = $(e).attr('name');
var old_id = $(e).attr('id');
$(e).attr('name', old_name.replace(from, to));
$(e).attr('id', old_id.replace(from, to));
$(e).val('');
});
}

function add_inline_form(name) {
var first = $('#id_'+name+'-0-id').parents('.inline-related');
var last = $(first).parent().children('.last-related');
var copy = $(last).clone(true);
var count = $(first).parent().children('.inline-related').length;
$(last).removeClass('last-related');
$(last).after(copy);
$('input#id_'+name+'-TOTAL_FORMS').val(count+1);
increment_form_ids($(first).parents('.inline-group').children('.last-related'), count, name);
$(first).parents('.inline-group').children('.last-related').find('input[id$=order]').val(0);
$('div.inline-group').find('div.inline-related').each(function(i) {
$(this).find('input[id$=order]').val(i+1);
});
}

You'll probably notice that I did actually make one other change. After I had gotten all this working, I realized there was one problem: If when starting from scratch you just happened to enter your choices in the order you wanted it wouldn't actually save that ordering information. By default (with my model definitions anyway) everything was assigned an order of 0 until you actually dragged things around to reorder them. This might be ok for some applications, but just won't work for this particular project. So I added those three lines of code to the function for adding new entries and also made sure that it's also done when you first load the page. This way you can be sure that everything will always be numbered appropriately and you won't end up with any unwanted 0s in your ordering information.

Should have done this a long time ago

December 12th, 2008

Really, this was a pretty major oversight on my part, but I just now finished added a 'status' field to each entry in my blog app. It's a pretty simple thing in and of itself, I just added a tiny bit of code to my Entry model:

STATUS_CHOICES = (
('dr', 'draft'),
('ac', 'active'),
('ar', 'archive'),
)

status = models.CharField(choices=STATUS_CHOICES, max_length=2, default='dr')

And then a custom manager as well:

class EntryManager(models.Manager):
def active(self):
return self.get_query_set().filter(status='ac')

Remembering, of course, to make sure that manager was actually accessible from the model (in this case by adding 'objects = EntryManager()' to my model class).

Once I did that I was able to change the Entry.objects.all() in my view(s) to Entry.objects.active() and now I'm able to write a post and save it without actually publishing! As I said, something of a bonehead move that I didn't do this in the first place.

Since I was messing around in my code anyway I decided to clean up the admin for my Entry model as well. Since I'm the only one who ever uses the admin for my blog, I hadn't bothered to before, but now I've got a little more info available to me when looking at my entries:

class EntryAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('title',)}
save_on_top = True
list_display = ['title', 'post_date', 'site_list', 'status',]
list_filter = ('sites', 'status',)

So my admin now looks something like this:

A cool thing there is the 'site_list' column. By default, you can't use a ManyToManyField in the list_display for your model. But I've dealt with this before, and it's only 5 lines of code to make this work:

def site_list(self):
if self.sites:
sites = [site.name for site in self.sites.all()]
string = ", ".join(sites)
return string

Good stuff!

Modifying the Django Admin: redirects after adding, changing, and deleting

October 27th, 2008

One of the Django projects that I've been working on for about a year and and will (fingers crossed) be going live in the very very very near future has involved a lot of modification to Django's admin interface. I plan on writing more about the many, many specific changes that I've made to the interface (without modifying the actual Django codebase, so that the changes can be easily applied by anyone without breaking updates), but to talk about them all at once would make far too long of a post. So I'll be taking them on one at a time.

The change I want to talk about right now involves redirecting the user to the page I want them to be at after they've finished adding or editing an object rather than to that object's model's change_list. Normally, if you're editing an Entry object in the Blog app, when you hit save it will take you to /admin/blog/entry/, which is a list of all the Entry objects in the database. However there are some instances in which this isn't the behavior I want. Once such instance involves model inheritance. Say, for example, you have multiple types of Entries which you've accomodated through multi-table-inheritance. Because the different sub-classes are different models, they all have their own change_lists in the Django admin. But I want to be able to view, edit, and create Entries of all types from one page.

Fortunately, Django makes this fairly easy to accomplish. All that is necessary is to override the appropriate methods in the Entry ModelAdmin. That will end up looking something like this:

class EntryAdmin(admin.ModelAdmin):
def change_view(self, request, object_id, extra_context=None):
result = super(EntryAdminAdmin, self).change_view(request, object_id, extra_context)

if not request.POST.has_key('_addanother') and not request.POST.has_key('_continue'):
result['Location'] = iri_to_uri("/admin/blog/entry/")
return result

The exact same modification should be made to add_view as well, and a nearly identical modification to delete_view though it doesn't need to deal with the _addanother and _continue cases. You can then use the EntryAdmin class for all of your varioud Entry sub-classes, or, if you need some other changes to the admin for different Entries sub-classes you can sub-class EntryAdmin for them. Now, whenever you hit the save button after editing any sort of Entry, it will always take you back to /admin/blog/entry/ rather than /admin/blog/linkentry/ or whatever your other subclasses are. If you want it to only take you back to /admin/blog/entry/ if you're coming from a particular page and otherwise take you to /admin/blog/linkentry/ all you need is to add a GET variable to your url (something like '/admin/blog/linkentry/add/?return_to_main=True') and then check for it in your modified change_view, add_view, and delete_view methods with a request.GET.get('return_to_main', False). I've even used this between objects of different model types to create a 'dashboard' page that allows you to view, and alter the relationships between an object of one type with objects of another type. All that's necessary in a case like that is to pass the id of the object in your GET variable and take that into account when creating your uri. An added benefit of that it makes it easy to auto-fill the ForeignKey field when creating a related object. In such a case you'll also need to keep that GET variable in the URLs down the line in order to maintain compatilibility with the 'Save and add another' and 'Save and continue editing' features. But that's still a simple modification:

class EntryAdmin(admin.ModelAdmin):
def change_view(self, request, object_id, extra_context=None):
result = super(EntryAdminAdmin, self).change_view(request, object_id, extra_context)

other_id = request.GET.get("other_id", None)
if not request.POST.has_key('_addanother') and not request.POST.has_key('_continue'):
if other_id:
result['Location'] = iri_to_uri("/admin/dashboard/%s/") % other_id
return result
elif request.POST.has_key('_continue'):
if other_id:
result['Location'] = iri_to_uri("?other_id=%s" % other_id)
return result
elif request.POST.has_key('_addanother'):
if other_id:
result['Location'] = iri_to_uri("%s?other_id=%s" % (result['Location'], other_id))
return result
return result

But more on that sort of thing in other posts.

My first Django patch

September 16th, 2008

I just submitted my first patch to Django! Among other things, this is my first real forray into the inner depths of the Django code. This patch fixes an issues that had been bothering me for quite some time. In Django's admin interface it's possible to specify that a particular field should be automatically filled in with the value(s) you enter in some other field(s). For example, as I typed 'My first Django patch' into the title field of the form I used to write this post, it was automatically filling in a slug field that's being used for the permalink to this post with 'my-first-django-patch'. This is a very useful features and uses just a little bit of javascript to accomplish it. The only problem is that it only works when you're trying to pull information from a text field. Sometimes, however, you might want to pull information from another sort of field, such as a drop-down menu. Previously Django simply wasn't capable of this. With my changes, however, it is able to handle this potentiality quite well.

It's not really a huge patch, just a fairly a little added code to a single javascript method, and there's no guarantee that my patch will every make it's way into the Django code base, but it's still fun to be able to contribute to one of my favorite open source projects ever. The patch, for those that are curious, can be found here, and I've also submitted it to djangosnippets.org here.


copyright © Joshua Ourisman 2006-2010 all rights reserved