Using an “exclude__” prefix to restrict choices for raw_id_fields in Django admin
By: Mario Orlandi
Tags: Filtering, Django

Django admin uses Select as default widget for related lookups.

When the target of the relation has a limited number of instances, it’s the most obvious choice, and quite acceptable to ForeignKeys;

for ManyToManyFields, the user experience is far from optimal, but can be improved significantly by taking advantage of the ModelAdmin’s filter_horizontal property, which replaces the plain <select multiple> with two boxes side by side.

However, when you have to choose among a significant number of related instances, the Select widget is no more an acceptable option. For those cases, Django ModelAdmin provides the raw_ids_field property, which replaces it with an Input widget + a link to display the changelist for the related target.

The problem

Sometimes, you will might need to further restrict the list of related instances available for selection. A simple 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.

A possible workaround is presented is this interesting article: “How to exclude choices of m2m raw_id_field in Django admin” (https://www.abidibo.net/blog/2015/03/05/how-exclude-choices-m2m-raw_id_field-django-admin/).

The proposed solution is clever, but somehow hacky. I had no luck in using it, since it removes the filtering query parameter from the request, which means that no persistence is possible. As a consequence, as soon as you start navigating the changeview, using either pagination or columns sorting, your initial filtering is lost.

Hacking QuerySet.filter()

A more effective solution consists in overriding the QuerySet.filter() method:

def filter(self, *args, **kwargs):

"""

Return a new QuerySet instance with the args ANDed to the existing set.

"""

return self._filter_or_exclude(False, *args, **kwargs)

in order to recognize an “exclude__” prefix as a valid option for queryset filtering:

def filter(self, *args, **kwargs):

"""

Return a new QuerySet instance with the args ANDed to the existing set. Filter starting with 'exclude__' will be negated.

"""

exclusion__pattern = 'exclude__'

plain = {}

negated = {}

for k, v in kwargs.items():

if k.startswith(exclusion__pattern):

negated[k[len(exclusion__pattern):]] = v

else:

plain[k] = v if len(negated):

clone = self._filter_or_exclude(False, *args, **plain)

clone.query.add_q(~Q(*args, **negated))

return clone return self._filter_or_exclude(False, *args, **kwargs)

which enables you to specify something like ?exclude__authors__in=111 in the query string, to negate the effect of a filter, thus excluding the matching instances from the changelists.

No further changes to ModelAdmin.changelist_view() are required, since unmanaged query parameters are simply passed to QuerySet.filter().

How to use in your project

Define a custom QuerySet to provide the filter() override:

from django.db import models class MyQuerySet(models.QuerySet): def filter(self, *args, **kwargs):

"""

Experimental !

Like Django QuerySet.filter(),

but provides support for 'exclude__' variation.

""" exclusion__pattern = 'exclude__'

plain = {}

negated = {}

for k, v in kwargs.items():

if k.startswith(exclusion__pattern):

negated[k[len(exclusion__pattern):]] = v

else:

plain[k] = v if len(negated):

clone = self._filter_or_exclude(False, *args, **plain)

clone.query.add_q(~Q(*args, **negated))

return clone return super().filter(*args, **kwargs)

then use a custom Manager to apply it