Fixed #28991 -- Added EmptyFieldListFilter class in admin.filters.
Thanks Simon Charette and Carlton Gibson for reviews. Co-Authored-By: Jonas Haag <jonas@lophus.org> Co-Authored-By: Christophe Baldy <christophe.baldy@polyconseil.fr>
This commit is contained in:
parent
03f6159407
commit
372eaa395f
|
@ -1,8 +1,8 @@
|
||||||
from django.contrib.admin.decorators import register
|
from django.contrib.admin.decorators import register
|
||||||
from django.contrib.admin.filters import (
|
from django.contrib.admin.filters import (
|
||||||
AllValuesFieldListFilter, BooleanFieldListFilter, ChoicesFieldListFilter,
|
AllValuesFieldListFilter, BooleanFieldListFilter, ChoicesFieldListFilter,
|
||||||
DateFieldListFilter, FieldListFilter, ListFilter, RelatedFieldListFilter,
|
DateFieldListFilter, EmptyFieldListFilter, FieldListFilter, ListFilter,
|
||||||
RelatedOnlyFieldListFilter, SimpleListFilter,
|
RelatedFieldListFilter, RelatedOnlyFieldListFilter, SimpleListFilter,
|
||||||
)
|
)
|
||||||
from django.contrib.admin.options import (
|
from django.contrib.admin.options import (
|
||||||
HORIZONTAL, VERTICAL, ModelAdmin, StackedInline, TabularInline,
|
HORIZONTAL, VERTICAL, ModelAdmin, StackedInline, TabularInline,
|
||||||
|
@ -15,7 +15,8 @@ __all__ = [
|
||||||
"TabularInline", "AdminSite", "site", "ListFilter", "SimpleListFilter",
|
"TabularInline", "AdminSite", "site", "ListFilter", "SimpleListFilter",
|
||||||
"FieldListFilter", "BooleanFieldListFilter", "RelatedFieldListFilter",
|
"FieldListFilter", "BooleanFieldListFilter", "RelatedFieldListFilter",
|
||||||
"ChoicesFieldListFilter", "DateFieldListFilter",
|
"ChoicesFieldListFilter", "DateFieldListFilter",
|
||||||
"AllValuesFieldListFilter", "RelatedOnlyFieldListFilter", "autodiscover",
|
"AllValuesFieldListFilter", "EmptyFieldListFilter",
|
||||||
|
"RelatedOnlyFieldListFilter", "autodiscover",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -427,3 +427,48 @@ class RelatedOnlyFieldListFilter(RelatedFieldListFilter):
|
||||||
pk_qs = model_admin.get_queryset(request).distinct().values_list('%s__pk' % self.field_path, flat=True)
|
pk_qs = model_admin.get_queryset(request).distinct().values_list('%s__pk' % self.field_path, flat=True)
|
||||||
ordering = self.field_admin_ordering(field, request, model_admin)
|
ordering = self.field_admin_ordering(field, request, model_admin)
|
||||||
return field.get_choices(include_blank=False, limit_choices_to={'pk__in': pk_qs}, ordering=ordering)
|
return field.get_choices(include_blank=False, limit_choices_to={'pk__in': pk_qs}, ordering=ordering)
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyFieldListFilter(FieldListFilter):
|
||||||
|
def __init__(self, field, request, params, model, model_admin, field_path):
|
||||||
|
if not field.empty_strings_allowed and not field.null:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"The list filter '%s' cannot be used with field '%s' which "
|
||||||
|
"doesn't allow empty strings and nulls." % (
|
||||||
|
self.__class__.__name__,
|
||||||
|
field.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.lookup_kwarg = '%s__isempty' % field_path
|
||||||
|
self.lookup_val = params.get(self.lookup_kwarg)
|
||||||
|
super().__init__(field, request, params, model, model_admin, field_path)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.lookup_kwarg not in self.used_parameters:
|
||||||
|
return queryset
|
||||||
|
if self.lookup_val not in ('0', '1'):
|
||||||
|
raise IncorrectLookupParameters
|
||||||
|
|
||||||
|
lookup_condition = models.Q()
|
||||||
|
if self.field.empty_strings_allowed:
|
||||||
|
lookup_condition |= models.Q(**{self.field_path: ''})
|
||||||
|
if self.field.null:
|
||||||
|
lookup_condition |= models.Q(**{'%s__isnull' % self.field_path: True})
|
||||||
|
if self.lookup_val == '1':
|
||||||
|
return queryset.filter(lookup_condition)
|
||||||
|
return queryset.exclude(lookup_condition)
|
||||||
|
|
||||||
|
def expected_parameters(self):
|
||||||
|
return [self.lookup_kwarg]
|
||||||
|
|
||||||
|
def choices(self, changelist):
|
||||||
|
for lookup, title in (
|
||||||
|
(None, _('All')),
|
||||||
|
('1', _('Empty')),
|
||||||
|
('0', _('Not empty')),
|
||||||
|
):
|
||||||
|
yield {
|
||||||
|
'selected': self.lookup_val == lookup,
|
||||||
|
'query_string': changelist.get_query_string({self.lookup_kwarg: lookup}),
|
||||||
|
'display': title,
|
||||||
|
}
|
||||||
|
|
|
@ -971,11 +971,24 @@ subclass::
|
||||||
limit the ``list_filter`` choices to the users who have written a book
|
limit the ``list_filter`` choices to the users who have written a book
|
||||||
instead of listing all users.
|
instead of listing all users.
|
||||||
|
|
||||||
|
You can filter empty values using ``EmptyFieldListFilter``, which can
|
||||||
|
filter on both empty strings and nulls, depending on what the field
|
||||||
|
allows to store::
|
||||||
|
|
||||||
|
class BookAdmin(admin.ModelAdmin):
|
||||||
|
list_filter = (
|
||||||
|
('title', admin.EmptyFieldListFilter),
|
||||||
|
)
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
The ``FieldListFilter`` API is considered internal and might be
|
The ``FieldListFilter`` API is considered internal and might be
|
||||||
changed.
|
changed.
|
||||||
|
|
||||||
|
.. versionadded:: 3.1
|
||||||
|
|
||||||
|
The ``EmptyFieldListFilter`` class was added.
|
||||||
|
|
||||||
List filter's typically appear only if the filter has more than one choice.
|
List filter's typically appear only if the filter has more than one choice.
|
||||||
A filter's ``has_output()`` method controls whether or not it appears.
|
A filter's ``has_output()`` method controls whether or not it appears.
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,9 @@ Minor features
|
||||||
:mod:`django.contrib.admin`
|
:mod:`django.contrib.admin`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
* ...
|
* The new ``django.contrib.admin.EmptyFieldListFilter`` for
|
||||||
|
:attr:`.ModelAdmin.list_filter` allows filtering on empty values (empty
|
||||||
|
strings and nulls) in the admin changelist view.
|
||||||
|
|
||||||
:mod:`django.contrib.admindocs`
|
:mod:`django.contrib.admindocs`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
|
@ -3,8 +3,8 @@ import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from django.contrib.admin import (
|
from django.contrib.admin import (
|
||||||
AllValuesFieldListFilter, BooleanFieldListFilter, ModelAdmin,
|
AllValuesFieldListFilter, BooleanFieldListFilter, EmptyFieldListFilter,
|
||||||
RelatedOnlyFieldListFilter, SimpleListFilter, site,
|
ModelAdmin, RelatedOnlyFieldListFilter, SimpleListFilter, site,
|
||||||
)
|
)
|
||||||
from django.contrib.admin.options import IncorrectLookupParameters
|
from django.contrib.admin.options import IncorrectLookupParameters
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
@ -248,6 +248,17 @@ class BookmarkAdminGenericRelation(ModelAdmin):
|
||||||
list_filter = ['tags__tag']
|
list_filter = ['tags__tag']
|
||||||
|
|
||||||
|
|
||||||
|
class BookAdminWithEmptyFieldListFilter(ModelAdmin):
|
||||||
|
list_filter = [
|
||||||
|
('author', EmptyFieldListFilter),
|
||||||
|
('title', EmptyFieldListFilter),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentAdminWithEmptyFieldListFilter(ModelAdmin):
|
||||||
|
list_filter = [('description', EmptyFieldListFilter)]
|
||||||
|
|
||||||
|
|
||||||
class ListFiltersTests(TestCase):
|
class ListFiltersTests(TestCase):
|
||||||
request_factory = RequestFactory()
|
request_factory = RequestFactory()
|
||||||
|
|
||||||
|
@ -1374,3 +1385,92 @@ class ListFiltersTests(TestCase):
|
||||||
changelist = modeladmin.get_changelist_instance(request)
|
changelist = modeladmin.get_changelist_instance(request)
|
||||||
changelist.get_results(request)
|
changelist.get_results(request)
|
||||||
self.assertEqual(changelist.full_result_count, 4)
|
self.assertEqual(changelist.full_result_count, 4)
|
||||||
|
|
||||||
|
def test_emptylistfieldfilter(self):
|
||||||
|
empty_description = Department.objects.create(code='EMPT', description='')
|
||||||
|
none_description = Department.objects.create(code='NONE', description=None)
|
||||||
|
empty_title = Book.objects.create(title='', author=self.alfred)
|
||||||
|
|
||||||
|
department_admin = DepartmentAdminWithEmptyFieldListFilter(Department, site)
|
||||||
|
book_admin = BookAdminWithEmptyFieldListFilter(Book, site)
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
# Allows nulls and empty strings.
|
||||||
|
(
|
||||||
|
department_admin,
|
||||||
|
{'description__isempty': '1'},
|
||||||
|
[empty_description, none_description],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
department_admin,
|
||||||
|
{'description__isempty': '0'},
|
||||||
|
[self.dev, self.design],
|
||||||
|
),
|
||||||
|
# Allows nulls.
|
||||||
|
(book_admin, {'author__isempty': '1'}, [self.guitar_book]),
|
||||||
|
(
|
||||||
|
book_admin,
|
||||||
|
{'author__isempty': '0'},
|
||||||
|
[self.django_book, self.bio_book, self.djangonaut_book, empty_title],
|
||||||
|
),
|
||||||
|
# Allows empty strings.
|
||||||
|
(book_admin, {'title__isempty': '1'}, [empty_title]),
|
||||||
|
(
|
||||||
|
book_admin,
|
||||||
|
{'title__isempty': '0'},
|
||||||
|
[self.django_book, self.bio_book, self.djangonaut_book, self.guitar_book],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for modeladmin, query_string, expected_result in tests:
|
||||||
|
with self.subTest(
|
||||||
|
modeladmin=modeladmin.__class__.__name__,
|
||||||
|
query_string=query_string,
|
||||||
|
):
|
||||||
|
request = self.request_factory.get('/', query_string)
|
||||||
|
request.user = self.alfred
|
||||||
|
changelist = modeladmin.get_changelist_instance(request)
|
||||||
|
queryset = changelist.get_queryset(request)
|
||||||
|
self.assertCountEqual(queryset, expected_result)
|
||||||
|
|
||||||
|
def test_emptylistfieldfilter_choices(self):
|
||||||
|
modeladmin = BookAdminWithEmptyFieldListFilter(Book, site)
|
||||||
|
request = self.request_factory.get('/')
|
||||||
|
request.user = self.alfred
|
||||||
|
changelist = modeladmin.get_changelist_instance(request)
|
||||||
|
filterspec = changelist.get_filters(request)[0][0]
|
||||||
|
self.assertEqual(filterspec.title, 'Verbose Author')
|
||||||
|
choices = list(filterspec.choices(changelist))
|
||||||
|
self.assertEqual(len(choices), 3)
|
||||||
|
|
||||||
|
self.assertEqual(choices[0]['display'], 'All')
|
||||||
|
self.assertIs(choices[0]['selected'], True)
|
||||||
|
self.assertEqual(choices[0]['query_string'], '?')
|
||||||
|
|
||||||
|
self.assertEqual(choices[1]['display'], 'Empty')
|
||||||
|
self.assertIs(choices[1]['selected'], False)
|
||||||
|
self.assertEqual(choices[1]['query_string'], '?author__isempty=1')
|
||||||
|
|
||||||
|
self.assertEqual(choices[2]['display'], 'Not empty')
|
||||||
|
self.assertIs(choices[2]['selected'], False)
|
||||||
|
self.assertEqual(choices[2]['query_string'], '?author__isempty=0')
|
||||||
|
|
||||||
|
def test_emptylistfieldfilter_non_empty_field(self):
|
||||||
|
class EmployeeAdminWithEmptyFieldListFilter(ModelAdmin):
|
||||||
|
list_filter = [('department', EmptyFieldListFilter)]
|
||||||
|
|
||||||
|
modeladmin = EmployeeAdminWithEmptyFieldListFilter(Employee, site)
|
||||||
|
request = self.request_factory.get('/')
|
||||||
|
request.user = self.alfred
|
||||||
|
msg = (
|
||||||
|
"The list filter 'EmptyFieldListFilter' cannot be used with field "
|
||||||
|
"'department' which doesn't allow empty strings and nulls."
|
||||||
|
)
|
||||||
|
with self.assertRaisesMessage(ImproperlyConfigured, msg):
|
||||||
|
modeladmin.get_changelist_instance(request)
|
||||||
|
|
||||||
|
def test_emptylistfieldfilter_invalid_lookup_parameters(self):
|
||||||
|
modeladmin = BookAdminWithEmptyFieldListFilter(Book, site)
|
||||||
|
request = self.request_factory.get('/', {'author__isempty': 42})
|
||||||
|
request.user = self.alfred
|
||||||
|
with self.assertRaises(IncorrectLookupParameters):
|
||||||
|
modeladmin.get_changelist_instance(request)
|
||||||
|
|
Loading…
Reference in New Issue