Using an “exclude__” prefix to restrict choices for raw_id_fields in Django admin

Mario Orlandi
2 min readDec 17, 2020

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_horizontalproperty, 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 modelsclass 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

class MyManager(models.Manager):    def get_queryset(self):
return MyQuerySet(
model=self.model,
using=self._db,
hints=self._hints
)

--

--