Using an “exclude__” prefix to restrict choices for raw_id_fields in Django admin
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 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
)