abidibo.net

How to exclude choices of m2m raw_id_field in django admin

django programming django-admin

This procedure took a lot of effort to be understood, but now it seems quite easy.

Scenario

Imagine we need to create a recursive m2m relationship, something like a "related posts" field assigned to a Post model. It would be nice if we could exclude the current editing post from the available choices.

This is quite straightforward if we go with the default select multiple widget. In fact we only need to create a custom form class and override the queryset property of the related_posts field in the __init__ function, excluding the id of the current instance, and then assign such form class in the ModelAdmin class, see this SO question for more info.

But things become more complicated when we want to use the raw_id_fields property, in order to choose the items from an orderable and filterable list (which is a good practice when we have many elements). In fact in such case the queryset property is simply ignored.

Well, this doesn't appear so strange, in fact with raw_id_fields, a new request is sent, retrieving all the items of the related class.

Solution

As you can see here, we can override the queryset method of the ModelAdmin class, but pay attention, because that affects every admin that needs to use the Post model. Moreover we need to dinamically filter the queryset, depending upon the current object which is beeing modified. In other words we need to pass a query string parameter in the url.

A way to filter the changelist items is to pass lookups directly in the query string, something like ?id__exact=1, but unfortunately there isn't a not equal lookup that can be used in this way. Not equal lookups are obtained using the exclude method on a queryset object. So we need two thing:

  1. pass the id of the current object in the url which opens the changelist view of related items
  2. read such id and edit the queryset using the exclude method

Ok this is what we need. But we have to overcome another obstacle. Directly from the django source code, ModelAdmin class, changelist_view method:

try:
    cl = ChangeList(request, self.model, list_display,
        list_display_links, list_filter, self.date_hierarchy,
        search_fields, self.list_select_related, self.list_per_page,
        self.list_max_show_all, self.list_editable, self)

    except IncorrectLookupParameters:
        # Wacky lookup parameters were given, so redirect to the main
        # changelist page, without parameters, and pass an 'invalid=1'
        # parameter via the query string. If wacky parameters were given
        # and the 'invalid=1' parameter was already in the query string,
        # something is screwed up with the database, so display an error
        # page.
        if ERROR_FLAG in request.GET.keys():
            return SimpleTemplateResponse('admin/invalid_setup.html', {
                'title': _('Database error'),
            })
        return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')

Here is explained why, if we call an url like
http://localhost:8000/admin/myapp/mymodel/?_to_field=id&relid=5&_popup=1,
we are redirected to the following url:
http://localhost:8000/admin/myapp/mymodel/?e=1,
and bye bye related objects selection.

This is simply because django checks the GET parameters and if it finds any not expected param (not related to model fields) then it performs such redirect. This is for security reason of course.

This is the reason why we can't just only edit the href attribure of the link rendered in the raw_id_fields widget (by javascript, or overriding a template as in my case with django-salmonella), but we need also to whitelist the param we intend to add.

Here comes the code.

class PostAdmin(admin.ModelAdmin):
    list_display =  ('title', 'date', 'published', )
    list_filter = ('published',)
    search_fields = ['title', ]
    raw_id_fields = ('related_posts', )

    def changelist_view(self, request, extra_context=None):
        request.GET._mutable=True

        try:
            self.relid = request.GET.pop('relid')[0]
        except KeyError:
            self.relid = None

        request.GET._mutable=False

        return super(PostAdmin, self).changelist_view(request, extra_context=extra_context)

    def get_queryset(self, request):
        qs = super(OperaAdmin, self).get_queryset(request)
        if hasattr(self, 'relid') and self.relid:
            qs = qs.exclude(pk=self.relid)
            # when saving the object, the get_object method of the ModelAdmin class
            # is called, and it calls this function to get the queryset to use to 
            # check the existence of the pk which is going to be saved.
            # The page is not reloaded and the self.relid property still exists
            # if we do not clear it, causing a 404 error! 
            self.relid = None
        return qs

admin.site.register(Post, PostAdmin)

The trick is to override the changelist_view method, set the request.GET as mutable, remove and store the param value, set the request.GET as immutable and proceed with the django way. Now we can simply override the get_queryset method and use the stored value to exclude the current object from the queryset (read also here).

Now the simplest part, add a relid=current_object_id param to the href attribute of the raw_id_fields widget search button in the change admin form template. It's as simple as including such js code using the method you prefer (custom template or add js through ModelAdmin Media class):

(function() {
    var id = location.href.match(/(\d*)\/$/)[1];
    if(!/relid/.test(document.getElementById('lookup_id_related_posts').href)) 
        document.getElementById('lookup_id_related_posts').href += '&relid=' + id;
})()

That's it!

Summary

In order to exclude the current object from a selection of recursive m2m fields using the raw_id_fields property, we need to

  1. Add a param to the url called when clicking the raw_id_fields search button, with the information of the current edited object id, taking it from the location.href js property.
  2. Whitelist such param so that django security system doesn't redirect to an error url, and store it in a ModelAdmin instance  property
  3. Use the stored param, if present, to exclude the current object from the final queryset

I hoe you enjoy the entry, hasta la proxima!

Subscribe to abidibo.net!

If you want to stay up to date with new contents published on this blog, then just enter your email address, and you will receive blog updates! You can set you preferences and decide to receive emails only when articles are posted regarding a precise topic.

I promise, you'll never receive spam or advertising of any kind from this subscription, just content updates.

Subscribe to this blog

Comments are welcome!

blog comments powered by Disqus

Your Smartwatch Loves Tasker!

Your Smartwatch Loves Tasker!

Now available for purchase!

Featured