Fixed #32539 -- Added toggleable facet filters to ModelAdmin.

Thanks Carlton Gibson, Simon Willison, David Smith, and Mariusz
Felisiak for reviews.
This commit is contained in:
sarahboyce 2023-02-16 13:23:24 +01:00 committed by Mariusz Felisiak
parent 50ca4defcb
commit 868e2fcdda
20 changed files with 568 additions and 38 deletions

View File

@ -15,6 +15,7 @@ from django.contrib.admin.options import (
HORIZONTAL,
VERTICAL,
ModelAdmin,
ShowFacets,
StackedInline,
TabularInline,
)
@ -42,6 +43,7 @@ __all__ = [
"AllValuesFieldListFilter",
"EmptyFieldListFilter",
"RelatedOnlyFieldListFilter",
"ShowFacets",
"autodiscover",
]

View File

@ -24,6 +24,7 @@ class ListFilter:
template = "admin/filter.html"
def __init__(self, request, params, model, model_admin):
self.request = request
# This dictionary will eventually contain the request's query string
# parameters actually used by this filter.
self.used_parameters = {}
@ -69,7 +70,22 @@ class ListFilter:
)
class SimpleListFilter(ListFilter):
class FacetsMixin:
def get_facet_counts(self, pk_attname, filtered_qs):
raise NotImplementedError(
"subclasses of FacetsMixin must provide a get_facet_counts() method."
)
def get_facet_queryset(self, changelist):
filtered_qs = changelist.get_queryset(
self.request, exclude_parameters=self.expected_parameters()
)
return filtered_qs.aggregate(
**self.get_facet_counts(changelist.pk_attname, filtered_qs)
)
class SimpleListFilter(FacetsMixin, ListFilter):
# The parameter that should be used in the query string for that filter.
parameter_name = None
@ -111,13 +127,34 @@ class SimpleListFilter(ListFilter):
def expected_parameters(self):
return [self.parameter_name]
def get_facet_counts(self, pk_attname, filtered_qs):
original_value = self.used_parameters.get(self.parameter_name)
counts = {}
for i, choice in enumerate(self.lookup_choices):
self.used_parameters[self.parameter_name] = choice[0]
lookup_qs = self.queryset(self.request, filtered_qs)
if lookup_qs is not None:
counts[f"{i}__c"] = models.Count(
pk_attname,
filter=lookup_qs.query.where,
)
self.used_parameters[self.parameter_name] = original_value
return counts
def choices(self, changelist):
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
yield {
"selected": self.value() is None,
"query_string": changelist.get_query_string(remove=[self.parameter_name]),
"display": _("All"),
}
for lookup, title in self.lookup_choices:
for i, (lookup, title) in enumerate(self.lookup_choices):
if add_facets:
if (count := facet_counts.get(f"{i}__c", -1)) != -1:
title = f"{title} ({count})"
else:
title = f"{title} (-)"
yield {
"selected": self.value() == str(lookup),
"query_string": changelist.get_query_string(
@ -127,7 +164,7 @@ class SimpleListFilter(ListFilter):
}
class FieldListFilter(ListFilter):
class FieldListFilter(FacetsMixin, ListFilter):
_field_list_filters = []
_take_priority_index = 0
list_separator = ","
@ -224,7 +261,22 @@ class RelatedFieldListFilter(FieldListFilter):
ordering = self.field_admin_ordering(field, request, model_admin)
return field.get_choices(include_blank=False, ordering=ordering)
def get_facet_counts(self, pk_attname, filtered_qs):
counts = {
f"{pk_val}__c": models.Count(
pk_attname, filter=models.Q(**{self.lookup_kwarg: pk_val})
)
for pk_val, _ in self.lookup_choices
}
if self.include_empty_choice:
counts["__c"] = models.Count(
pk_attname, filter=models.Q(**{self.lookup_kwarg_isnull: True})
)
return counts
def choices(self, changelist):
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
yield {
"selected": self.lookup_val is None and not self.lookup_val_isnull,
"query_string": changelist.get_query_string(
@ -232,7 +284,11 @@ class RelatedFieldListFilter(FieldListFilter):
),
"display": _("All"),
}
count = None
for pk_val, val in self.lookup_choices:
if add_facets:
count = facet_counts[f"{pk_val}__c"]
val = f"{val} ({count})"
yield {
"selected": self.lookup_val == str(pk_val),
"query_string": changelist.get_query_string(
@ -240,13 +296,17 @@ class RelatedFieldListFilter(FieldListFilter):
),
"display": val,
}
empty_title = self.empty_value_display
if self.include_empty_choice:
if add_facets:
count = facet_counts["__c"]
empty_title = f"{empty_title} ({count})"
yield {
"selected": bool(self.lookup_val_isnull),
"query_string": changelist.get_query_string(
{self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg]
),
"display": self.empty_value_display,
"display": empty_title,
}
@ -272,13 +332,32 @@ class BooleanFieldListFilter(FieldListFilter):
def expected_parameters(self):
return [self.lookup_kwarg, self.lookup_kwarg2]
def get_facet_counts(self, pk_attname, filtered_qs):
return {
"true__c": models.Count(
pk_attname, filter=models.Q(**{self.field_path: True})
),
"false__c": models.Count(
pk_attname, filter=models.Q(**{self.field_path: False})
),
"null__c": models.Count(
pk_attname, filter=models.Q(**{self.lookup_kwarg2: True})
),
}
def choices(self, changelist):
field_choices = dict(self.field.flatchoices)
for lookup, title in (
(None, _("All")),
("1", field_choices.get(True, _("Yes"))),
("0", field_choices.get(False, _("No"))),
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
for lookup, title, count_field in (
(None, _("All"), None),
("1", field_choices.get(True, _("Yes")), "true__c"),
("0", field_choices.get(False, _("No")), "false__c"),
):
if add_facets:
if count_field is not None:
count = facet_counts[count_field]
title = f"{title} ({count})"
yield {
"selected": self.lookup_val == lookup and not self.lookup_val2,
"query_string": changelist.get_query_string(
@ -287,12 +366,16 @@ class BooleanFieldListFilter(FieldListFilter):
"display": title,
}
if self.field.null:
display = field_choices.get(None, _("Unknown"))
if add_facets:
count = facet_counts["null__c"]
display = f"{display} ({count})"
yield {
"selected": self.lookup_val2 == "True",
"query_string": changelist.get_query_string(
{self.lookup_kwarg2: "True"}, [self.lookup_kwarg]
),
"display": field_choices.get(None, _("Unknown")),
"display": display,
}
@ -312,7 +395,22 @@ class ChoicesFieldListFilter(FieldListFilter):
def expected_parameters(self):
return [self.lookup_kwarg, self.lookup_kwarg_isnull]
def get_facet_counts(self, pk_attname, filtered_qs):
return {
f"{i}__c": models.Count(
pk_attname,
filter=models.Q(
(self.lookup_kwarg, value)
if value is not None
else (self.lookup_kwarg_isnull, True)
),
)
for i, (value, _) in enumerate(self.field.flatchoices)
}
def choices(self, changelist):
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
yield {
"selected": self.lookup_val is None,
"query_string": changelist.get_query_string(
@ -321,7 +419,10 @@ class ChoicesFieldListFilter(FieldListFilter):
"display": _("All"),
}
none_title = ""
for lookup, title in self.field.flatchoices:
for i, (lookup, title) in enumerate(self.field.flatchoices):
if add_facets:
count = facet_counts[f"{i}__c"]
title = f"{title} ({count})"
if lookup is None:
none_title = title
continue
@ -416,9 +517,20 @@ class DateFieldListFilter(FieldListFilter):
params.append(self.lookup_kwarg_isnull)
return params
def get_facet_counts(self, pk_attname, filtered_qs):
return {
f"{i}__c": models.Count(pk_attname, filter=models.Q(**param_dict))
for i, (_, param_dict) in enumerate(self.links)
}
def choices(self, changelist):
for title, param_dict in self.links:
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
for i, (title, param_dict) in enumerate(self.links):
param_dict_str = {key: str(value) for key, value in param_dict.items()}
if add_facets:
count = facet_counts[f"{i}__c"]
title = f"{title} ({count})"
yield {
"selected": self.date_params == param_dict_str,
"query_string": changelist.get_query_string(
@ -455,7 +567,22 @@ class AllValuesFieldListFilter(FieldListFilter):
def expected_parameters(self):
return [self.lookup_kwarg, self.lookup_kwarg_isnull]
def get_facet_counts(self, pk_attname, filtered_qs):
return {
f"{i}__c": models.Count(
pk_attname,
filter=models.Q(
(self.lookup_kwarg, value)
if value is not None
else (self.lookup_kwarg_isnull, True)
),
)
for i, value in enumerate(self.lookup_choices)
}
def choices(self, changelist):
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
yield {
"selected": self.lookup_val is None and self.lookup_val_isnull is None,
"query_string": changelist.get_query_string(
@ -464,9 +591,14 @@ class AllValuesFieldListFilter(FieldListFilter):
"display": _("All"),
}
include_none = False
for val in self.lookup_choices:
count = None
empty_title = self.empty_value_display
for i, val in enumerate(self.lookup_choices):
if add_facets:
count = facet_counts[f"{i}__c"]
if val is None:
include_none = True
empty_title = f"{empty_title} ({count})" if add_facets else empty_title
continue
val = str(val)
yield {
@ -474,7 +606,7 @@ class AllValuesFieldListFilter(FieldListFilter):
"query_string": changelist.get_query_string(
{self.lookup_kwarg: val}, [self.lookup_kwarg_isnull]
),
"display": val,
"display": f"{val} ({count})" if add_facets else val,
}
if include_none:
yield {
@ -482,7 +614,7 @@ class AllValuesFieldListFilter(FieldListFilter):
"query_string": changelist.get_query_string(
{self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg]
),
"display": self.empty_value_display,
"display": empty_title,
}
@ -517,18 +649,21 @@ class EmptyFieldListFilter(FieldListFilter):
self.lookup_val = params.get(self.lookup_kwarg)
super().__init__(field, request, params, model, model_admin, field_path)
def get_lookup_condition(self):
lookup_conditions = []
if self.field.empty_strings_allowed:
lookup_conditions.append((self.field_path, ""))
if self.field.null:
lookup_conditions.append((f"{self.field_path}__isnull", True))
return models.Q.create(lookup_conditions, connector=models.Q.OR)
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_conditions = []
if self.field.empty_strings_allowed:
lookup_conditions.append((self.field_path, ""))
if self.field.null:
lookup_conditions.append((f"{self.field_path}__isnull", True))
lookup_condition = models.Q.create(lookup_conditions, connector=models.Q.OR)
lookup_condition = self.get_lookup_condition()
if self.lookup_val == "1":
return queryset.filter(lookup_condition)
return queryset.exclude(lookup_condition)
@ -536,12 +671,25 @@ class EmptyFieldListFilter(FieldListFilter):
def expected_parameters(self):
return [self.lookup_kwarg]
def get_facet_counts(self, pk_attname, filtered_qs):
lookup_condition = self.get_lookup_condition()
return {
"empty__c": models.Count(pk_attname, filter=lookup_condition),
"not_empty__c": models.Count(pk_attname, filter=~lookup_condition),
}
def choices(self, changelist):
for lookup, title in (
(None, _("All")),
("1", _("Empty")),
("0", _("Not empty")),
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
for lookup, title, count_field in (
(None, _("All"), None),
("1", _("Empty"), "empty__c"),
("0", _("Not empty"), "not_empty__c"),
):
if add_facets:
if count_field is not None:
count = facet_counts[count_field]
title = f"{title} ({count})"
yield {
"selected": self.lookup_val == lookup,
"query_string": changelist.get_query_string(

View File

@ -1,4 +1,5 @@
import copy
import enum
import json
import re
from functools import partial, update_wrapper
@ -68,6 +69,13 @@ from django.views.generic import RedirectView
IS_POPUP_VAR = "_popup"
TO_FIELD_VAR = "_to_field"
IS_FACETS_VAR = "_facets"
class ShowFacets(enum.Enum):
NEVER = "NEVER"
ALLOW = "ALLOW"
ALWAYS = "ALWAYS"
HORIZONTAL, VERTICAL = 1, 2
@ -628,6 +636,7 @@ class ModelAdmin(BaseModelAdmin):
save_on_top = False
paginator = Paginator
preserve_filters = True
show_facets = ShowFacets.ALLOW
inlines = ()
# Custom templates (designed to be over-ridden in subclasses)

View File

@ -722,6 +722,11 @@ div.breadcrumbs a:focus, div.breadcrumbs a:hover {
background: url(../img/icon-viewlink.svg) 0 1px no-repeat;
}
.hidelink {
padding-left: 16px;
background: url(../img/icon-hidelink.svg) 0 1px no-repeat;
}
.addlink {
padding-left: 16px;
background: url(../img/icon-addlink.svg) 0 1px no-repeat;

View File

@ -215,9 +215,9 @@
color: var(--link-hover-color);
}
#changelist-filter #changelist-filter-clear a {
#changelist-filter #changelist-filter-extra-actions {
font-size: 0.8125rem;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid var(--hairline-color);
}

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#2b70bf" d="m555 1335 78-141q-87-63-136-159t-49-203q0-121 61-225-229 117-381 353 167 258 427 375zm389-759q0-20-14-34t-34-14q-125 0-214.5 89.5T592 832q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm363-191q0 7-1 9-105 188-315 566t-316 567l-49 89q-10 16-28 16-12 0-134-70-16-10-16-28 0-12 44-87-143-65-263.5-173T20 1029Q0 998 0 960t20-69q153-235 380-371t496-136q89 0 180 17l54-97q10-16 28-16 5 0 18 6t31 15.5 33 18.5 31.5 18.5T1291 358q16 10 16 27zm37 447q0 139-79 253.5T1056 1250l280-502q8 45 8 84zm448 128q0 35-20 69-39 64-109 145-150 172-347.5 267T896 1536l74-132q212-18 392.5-137T1664 960q-115-179-282-294l63-112q95 64 182.5 153T1772 891q20 34 20 69z"/>
</svg>

After

Width:  |  Height:  |  Size: 784 B

View File

@ -75,9 +75,15 @@
{% if cl.has_filters %}
<div id="changelist-filter">
<h2>{% translate 'Filter' %}</h2>
{% if cl.has_active_filters %}<h3 id="changelist-filter-clear">
<a href="{{ cl.clear_all_filters_qs }}">&#10006; {% translate "Clear all filters" %}</a>
</h3>{% endif %}
{% if cl.is_facets_optional or cl.has_active_filters %}<div id="changelist-filter-extra-actions">
{% if cl.is_facets_optional %}<h3>
{% if cl.add_facets %}<a href="{{ cl.remove_facet_link }}" class="hidelink">{% translate "Hide counts" %}</a>
{% else %}<a href="{{ cl.add_facet_link }}" class="viewlink">{% translate "Show counts" %}</a>{% endif %}
</h3>{% endif %}
{% if cl.has_active_filters %}<h3>
<a href="{{ cl.clear_all_filters_qs }}">&#10006; {% translate "Clear all filters" %}</a>
</h3>{% endif %}
</div>{% endif %}
{% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
</div>
{% endif %}

View File

@ -6,7 +6,7 @@
<input type="text" size="40" name="{{ search_var }}" value="{{ cl.query }}" id="searchbar"{% if cl.search_help_text %} aria-describedby="searchbar_helptext"{% endif %}>
<input type="submit" value="{% translate 'Search' %}">
{% if show_result_count %}
<span class="small quiet">{% blocktranslate count counter=cl.result_count %}{{ counter }} result{% plural %}{{ counter }} results{% endblocktranslate %} (<a href="?{% if cl.is_popup %}{{ is_popup_var }}=1{% endif %}">{% if cl.show_full_result_count %}{% blocktranslate with full_result_count=cl.full_result_count %}{{ full_result_count }} total{% endblocktranslate %}{% else %}{% translate "Show all" %}{% endif %}</a>)</span>
<span class="small quiet">{% blocktranslate count counter=cl.result_count %}{{ counter }} result{% plural %}{{ counter }} results{% endblocktranslate %} (<a href="?{% if cl.is_popup %}{{ is_popup_var }}=1{% if cl.add_facets %}&{% endif %}{% endif %}{% if cl.add_facets %}{{ is_facets_var }}{% endif %}">{% if cl.show_full_result_count %}{% blocktranslate with full_result_count=cl.full_result_count %}{{ full_result_count }} total{% endblocktranslate %}{% else %}{% translate "Show all" %}{% endif %}</a>)</span>
{% endif %}
{% for pair in cl.params.items %}
{% if pair.0 != search_var %}<input type="hidden" name="{{ pair.0 }}" value="{{ pair.1 }}">{% endif %}

View File

@ -10,6 +10,7 @@ from django.contrib.admin.utils import (
)
from django.contrib.admin.views.main import (
ALL_VAR,
IS_FACETS_VAR,
IS_POPUP_VAR,
ORDER_VAR,
PAGE_VAR,
@ -475,6 +476,7 @@ def search_form(cl):
"show_result_count": cl.result_count != cl.full_result_count,
"search_var": SEARCH_VAR,
"is_popup_var": IS_POPUP_VAR,
"is_facets_var": IS_FACETS_VAR,
}

View File

@ -9,9 +9,11 @@ from django.contrib.admin.exceptions import (
DisallowedModelAdminToField,
)
from django.contrib.admin.options import (
IS_FACETS_VAR,
IS_POPUP_VAR,
TO_FIELD_VAR,
IncorrectLookupParameters,
ShowFacets,
)
from django.contrib.admin.utils import (
get_fields_from_path,
@ -39,7 +41,14 @@ PAGE_VAR = "p"
SEARCH_VAR = "q"
ERROR_FLAG = "e"
IGNORED_PARAMS = (ALL_VAR, ORDER_VAR, SEARCH_VAR, IS_POPUP_VAR, TO_FIELD_VAR)
IGNORED_PARAMS = (
ALL_VAR,
ORDER_VAR,
SEARCH_VAR,
IS_FACETS_VAR,
IS_POPUP_VAR,
TO_FIELD_VAR,
)
class ChangeListSearchForm(forms.Form):
@ -103,6 +112,10 @@ class ChangeList:
self.page_num = 1
self.show_all = ALL_VAR in request.GET
self.is_popup = IS_POPUP_VAR in request.GET
self.add_facets = model_admin.show_facets is ShowFacets.ALWAYS or (
model_admin.show_facets is ShowFacets.ALLOW and IS_FACETS_VAR in request.GET
)
self.is_facets_optional = model_admin.show_facets is ShowFacets.ALLOW
to_field = request.GET.get(TO_FIELD_VAR)
if to_field and not model_admin.to_field_allowed(request, to_field):
raise DisallowedModelAdminToField(
@ -114,6 +127,8 @@ class ChangeList:
del self.params[PAGE_VAR]
if ERROR_FLAG in self.params:
del self.params[ERROR_FLAG]
self.remove_facet_link = self.get_query_string(remove=[IS_FACETS_VAR])
self.add_facet_link = self.get_query_string({IS_FACETS_VAR: True})
if self.is_popup:
self.list_editable = ()
@ -492,7 +507,7 @@ class ChangeList:
ordering_fields[idx] = "desc" if pfx == "-" else "asc"
return ordering_fields
def get_queryset(self, request):
def get_queryset(self, request, exclude_parameters=None):
# First, we collect all the declared list filters.
(
self.filter_specs,
@ -504,9 +519,13 @@ class ChangeList:
# Then, we let every list filter modify the queryset to its liking.
qs = self.root_queryset
for filter_spec in self.filter_specs:
new_qs = filter_spec.queryset(request, qs)
if new_qs is not None:
qs = new_qs
if (
exclude_parameters is None
or filter_spec.expected_parameters() != exclude_parameters
):
new_qs = filter_spec.queryset(request, qs)
if new_qs is not None:
qs = new_qs
try:
# Finally, we apply the remaining lookup parameters from the query

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -210,3 +210,14 @@ It is possible to specify a custom template for rendering a list filter::
See the default template provided by Django (``admin/filter.html``) for a
concrete example.
.. _facet-filters:
Facets
======
.. versionadded:: 5.0
By default, counts for each filter, known as facets, can be shown by toggling
on via the admin UI. These counts will update according to the currently
applied filters. See :attr:`ModelAdmin.show_facets` for more details.

View File

@ -1002,6 +1002,54 @@ subclass::
editing, or deleting an object. You can have filters cleared by setting
this attribute to ``False``.
.. attribute:: ModelAdmin.show_facets
.. versionadded:: 5.0
Controls whether facet counts are displayed for filters in the admin
changelist. Defaults to :attr:`.ShowFacets.ALLOW`.
When displayed, facet counts update in line with currently applied filters.
.. class:: ShowFacets
.. versionadded:: 5.0
Enum of allowed values for :attr:`.ModelAdmin.show_facets`.
.. attribute:: ALWAYS
Always show facet counts.
.. attribute:: ALLOW
Show facet counts when the ``_facets`` query string parameter is
provided.
.. attribute:: NEVER
Never show facet counts.
Set ``show_facets`` to the desired :class:`.ShowFacets` value. For example,
to always show facet counts without needing to provide the query
parameter::
from django.contrib import admin
class MyModelAdmin(admin.ModelAdmin):
...
# Have facets always shown for this model admin.
show_facets = admin.ShowFacets.ALWAYS
.. admonition:: Performance considerations with facets
Enabling facet filters will increase the number of queries on the admin
changelist page in line with the number of filters. These queries may
cause performance problems, especially for large datasets. In these
cases it may be appropriate to set ``show_facets`` to
:attr:`.ShowFacets.NEVER` to disable faceting entirely.
.. attribute:: ModelAdmin.radio_fields
By default, Django's admin uses a select-box interface (<select>) for

View File

@ -37,6 +37,14 @@ compatible with Django 5.0.
What's new in Django 5.0
========================
Facet filters in the admin
--------------------------
Facet counts are now show for applied filters in the admin changelist when
toggled on via the UI. This behavior can be changed via the new
:attr:`.ModelAdmin.show_facets` attribute. For more information see
:ref:`facet-filters`.
Minor features
--------------

View File

@ -8,6 +8,7 @@ from django.contrib.admin.templatetags.admin_list import pagination
from django.contrib.admin.tests import AdminSeleniumTestCase
from django.contrib.admin.views.main import (
ALL_VAR,
IS_FACETS_VAR,
IS_POPUP_VAR,
ORDER_VAR,
PAGE_VAR,
@ -1031,6 +1032,7 @@ class ChangeListTests(TestCase):
{TO_FIELD_VAR: "id"},
{PAGE_VAR: "1"},
{IS_POPUP_VAR: "1"},
{IS_FACETS_VAR: ""},
{"username__startswith": "test"},
):
with self.subTest(data=data):
@ -1599,6 +1601,11 @@ class ChangeListTests(TestCase):
for data, href in (
({"is_staff__exact": "0"}, "?"),
({"is_staff__exact": "0", IS_POPUP_VAR: "1"}, f"?{IS_POPUP_VAR}=1"),
({"is_staff__exact": "0", IS_FACETS_VAR: ""}, f"?{IS_FACETS_VAR}"),
(
{"is_staff__exact": "0", IS_POPUP_VAR: "1", IS_FACETS_VAR: ""},
f"?{IS_POPUP_VAR}=1&{IS_FACETS_VAR}",
),
):
with self.subTest(data=data):
response = self.client.get(url, data=data)

View File

@ -40,6 +40,13 @@ class Book(models.Model):
)
# This field name is intentionally 2 characters long (#16080).
no = models.IntegerField(verbose_name="number", blank=True, null=True)
CHOICES = [
("non-fiction", "Non-Fictional"),
("fiction", "Fictional"),
(None, "Not categorized"),
("", "We don't know"),
]
category = models.CharField(max_length=20, choices=CHOICES, blank=True, null=True)
def __str__(self):
return self.title

View File

@ -12,11 +12,12 @@ from django.contrib.admin import (
SimpleListFilter,
site,
)
from django.contrib.admin.options import IncorrectLookupParameters
from django.contrib.admin.filters import FacetsMixin
from django.contrib.admin.options import IncorrectLookupParameters, ShowFacets
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from django.core.exceptions import ImproperlyConfigured
from django.test import RequestFactory, TestCase, override_settings
from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
from .models import Book, Bookmark, Department, Employee, ImprovedBook, TaggedItem
@ -217,10 +218,28 @@ class BookAdminRelatedOnlyFilter(ModelAdmin):
class DecadeFilterBookAdmin(ModelAdmin):
list_filter = ("author", DecadeListFilterWithTitleAndParameter)
empty_value_display = "???"
list_filter = (
"author",
DecadeListFilterWithTitleAndParameter,
"is_best_seller",
"category",
"date_registered",
("author__email", AllValuesFieldListFilter),
("contributors", RelatedOnlyFieldListFilter),
("category", EmptyFieldListFilter),
)
ordering = ("-id",)
class DecadeFilterBookAdminWithAlwaysFacets(DecadeFilterBookAdmin):
show_facets = ShowFacets.ALWAYS
class DecadeFilterBookAdminDisallowFacets(DecadeFilterBookAdmin):
show_facets = ShowFacets.NEVER
class NotNinetiesListFilterAdmin(ModelAdmin):
list_filter = (NotNinetiesListFilter,)
@ -324,6 +343,7 @@ class ListFiltersTests(TestCase):
is_best_seller=True,
date_registered=cls.today,
availability=True,
category="non-fiction",
)
cls.bio_book = Book.objects.create(
title="Django: a biography",
@ -332,6 +352,7 @@ class ListFiltersTests(TestCase):
is_best_seller=False,
no=207,
availability=False,
category="fiction",
)
cls.django_book = Book.objects.create(
title="The Django Book",
@ -348,6 +369,7 @@ class ListFiltersTests(TestCase):
is_best_seller=True,
date_registered=cls.one_week_ago,
availability=None,
category="",
)
cls.guitar_book.contributors.set([cls.bob, cls.lisa])
@ -359,6 +381,10 @@ class ListFiltersTests(TestCase):
cls.john = Employee.objects.create(name="John Blue", department=cls.dev)
cls.jack = Employee.objects.create(name="Jack Red", department=cls.design)
def assertChoicesDisplay(self, choices, expected_displays):
for choice, expected_display in zip(choices, expected_displays, strict=True):
self.assertEqual(choice["display"], expected_display)
def test_choicesfieldlistfilter_has_none_choice(self):
"""
The last choice is for the None value.
@ -1315,6 +1341,185 @@ class ListFiltersTests(TestCase):
self.assertIs(choices[2]["selected"], False)
self.assertEqual(choices[2]["query_string"], "?publication-decade=the+00s")
def _test_facets(self, modeladmin, request, query_string=None):
request.user = self.alfred
changelist = modeladmin.get_changelist_instance(request)
queryset = changelist.get_queryset(request)
self.assertSequenceEqual(queryset, list(Book.objects.order_by("-id")))
filters = changelist.get_filters(request)[0]
# Filters for DateFieldListFilter.
expected_date_filters = ["Any date (4)", "Today (2)", "Past 7 days (3)"]
if (
self.today.month == self.one_week_ago.month
and self.today.year == self.one_week_ago.year
):
expected_date_filters.extend(["This month (3)", "This year (3)"])
elif self.today.year == self.one_week_ago.year:
expected_date_filters.extend(["This month (2)", "This year (3)"])
else:
expected_date_filters.extend(["This month (2)", "This year (2)"])
expected_date_filters.extend(["No date (1)", "Has date (3)"])
tests = [
# RelatedFieldListFilter.
["All", "alfred (2)", "bob (1)", "lisa (0)", "??? (1)"],
# SimpleListFilter.
[
"All",
"the 1980's (0)",
"the 1990's (1)",
"the 2000's (2)",
"other decades (-)",
],
# BooleanFieldListFilter.
["All", "Yes (2)", "No (1)", "Unknown (1)"],
# ChoicesFieldListFilter.
[
"All",
"Non-Fictional (1)",
"Fictional (1)",
"We don't know (1)",
"Not categorized (1)",
],
# DateFieldListFilter.
expected_date_filters,
# AllValuesFieldListFilter.
[
"All",
"alfred@example.com (2)",
"bob@example.com (1)",
"lisa@example.com (0)",
],
# RelatedOnlyFieldListFilter.
["All", "bob (1)", "lisa (1)", "??? (3)"],
# EmptyFieldListFilter.
["All", "Empty (2)", "Not empty (2)"],
]
for filterspec, expected_displays in zip(filters, tests, strict=True):
with self.subTest(filterspec.__class__.__name__):
choices = list(filterspec.choices(changelist))
self.assertChoicesDisplay(choices, expected_displays)
if query_string:
for choice in choices:
self.assertIn(query_string, choice["query_string"])
def test_facets_always(self):
modeladmin = DecadeFilterBookAdminWithAlwaysFacets(Book, site)
request = self.request_factory.get("/")
self._test_facets(modeladmin, request)
def test_facets_no_filter(self):
modeladmin = DecadeFilterBookAdmin(Book, site)
request = self.request_factory.get("/?_facets")
self._test_facets(modeladmin, request, query_string="_facets")
def test_facets_filter(self):
modeladmin = DecadeFilterBookAdmin(Book, site)
request = self.request_factory.get(
"/", {"author__id__exact": self.alfred.pk, "_facets": ""}
)
request.user = self.alfred
changelist = modeladmin.get_changelist_instance(request)
queryset = changelist.get_queryset(request)
self.assertSequenceEqual(
queryset,
list(Book.objects.filter(author=self.alfred).order_by("-id")),
)
filters = changelist.get_filters(request)[0]
tests = [
# RelatedFieldListFilter.
["All", "alfred (2)", "bob (1)", "lisa (0)", "??? (1)"],
# SimpleListFilter.
[
"All",
"the 1980's (0)",
"the 1990's (1)",
"the 2000's (1)",
"other decades (-)",
],
# BooleanFieldListFilter.
["All", "Yes (1)", "No (1)", "Unknown (0)"],
# ChoicesFieldListFilter.
[
"All",
"Non-Fictional (1)",
"Fictional (1)",
"We don't know (0)",
"Not categorized (0)",
],
# DateFieldListFilter.
[
"Any date (2)",
"Today (1)",
"Past 7 days (1)",
"This month (1)",
"This year (1)",
"No date (1)",
"Has date (1)",
],
# AllValuesFieldListFilter.
[
"All",
"alfred@example.com (2)",
"bob@example.com (0)",
"lisa@example.com (0)",
],
# RelatedOnlyFieldListFilter.
["All", "bob (0)", "lisa (0)", "??? (2)"],
# EmptyFieldListFilter.
["All", "Empty (0)", "Not empty (2)"],
]
for filterspec, expected_displays in zip(filters, tests, strict=True):
with self.subTest(filterspec.__class__.__name__):
choices = list(filterspec.choices(changelist))
self.assertChoicesDisplay(choices, expected_displays)
for choice in choices:
self.assertIn("_facets", choice["query_string"])
def test_facets_disallowed(self):
modeladmin = DecadeFilterBookAdminDisallowFacets(Book, site)
# Facets are not visible even when in the url query.
request = self.request_factory.get("/?_facets")
request.user = self.alfred
changelist = modeladmin.get_changelist_instance(request)
queryset = changelist.get_queryset(request)
self.assertSequenceEqual(queryset, list(Book.objects.order_by("-id")))
filters = changelist.get_filters(request)[0]
tests = [
# RelatedFieldListFilter.
["All", "alfred", "bob", "lisa", "???"],
# SimpleListFilter.
["All", "the 1980's", "the 1990's", "the 2000's", "other decades"],
# BooleanFieldListFilter.
["All", "Yes", "No", "Unknown"],
# ChoicesFieldListFilter.
["All", "Non-Fictional", "Fictional", "We don't know", "Not categorized"],
# DateFieldListFilter.
[
"Any date",
"Today",
"Past 7 days",
"This month",
"This year",
"No date",
"Has date",
],
# AllValuesFieldListFilter.
["All", "alfred@example.com", "bob@example.com", "lisa@example.com"],
# RelatedOnlyFieldListFilter.
["All", "bob", "lisa", "???"],
# EmptyFieldListFilter.
["All", "Empty", "Not empty"],
]
for filterspec, expected_displays in zip(filters, tests, strict=True):
with self.subTest(filterspec.__class__.__name__):
self.assertChoicesDisplay(
filterspec.choices(changelist),
expected_displays,
)
def test_two_characters_long_field(self):
"""
list_filter works with two-characters long field names (#16080).
@ -1698,3 +1903,10 @@ class ListFiltersTests(TestCase):
# Make sure the correct queryset is returned
queryset = changelist.get_queryset(request)
self.assertEqual(list(queryset), [jane])
class FacetsMixinTests(SimpleTestCase):
def test_get_facet_counts(self):
msg = "subclasses of FacetsMixin must provide a get_facet_counts() method."
with self.assertRaisesMessage(NotImplementedError, msg):
FacetsMixin().get_facet_counts(None, None)

View File

@ -679,11 +679,13 @@ class ReadOnlyPizzaAdmin(admin.ModelAdmin):
class WorkHourAdmin(admin.ModelAdmin):
list_display = ("datum", "employee")
list_filter = ("employee",)
show_facets = admin.ShowFacets.ALWAYS
class FoodDeliveryAdmin(admin.ModelAdmin):
list_display = ("reference", "driver", "restaurant")
list_editable = ("driver", "restaurant")
show_facets = admin.ShowFacets.NEVER
class CoverLetterAdmin(admin.ModelAdmin):

View File

@ -807,6 +807,47 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
msg_prefix="Changelist filter not correctly limited by limit_choices_to",
)
def test_change_list_facet_toggle(self):
# Toggle is visible when show_facet is the default of
# admin.ShowFacets.ALLOW.
admin_url = reverse("admin:admin_views_album_changelist")
response = self.client.get(admin_url)
self.assertContains(
response,
'<a href="?_facets=True" class="viewlink">Show counts</a>',
msg_prefix="Expected facet filter toggle not found in changelist view",
)
response = self.client.get(f"{admin_url}?_facets=True")
self.assertContains(
response,
'<a href="?" class="hidelink">Hide counts</a>',
msg_prefix="Expected facet filter toggle not found in changelist view",
)
# Toggle is not visible when show_facet is admin.ShowFacets.ALWAYS.
response = self.client.get(reverse("admin:admin_views_workhour_changelist"))
self.assertNotContains(
response,
"Show counts",
msg_prefix="Expected not to find facet filter toggle in changelist view",
)
self.assertNotContains(
response,
"Hide counts",
msg_prefix="Expected not to find facet filter toggle in changelist view",
)
# Toggle is not visible when show_facet is admin.ShowFacets.NEVER.
response = self.client.get(reverse("admin:admin_views_fooddelivery_changelist"))
self.assertNotContains(
response,
"Show counts",
msg_prefix="Expected not to find facet filter toggle in changelist view",
)
self.assertNotContains(
response,
"Hide counts",
msg_prefix="Expected not to find facet filter toggle in changelist view",
)
def test_relation_spanning_filters(self):
changelist_url = reverse("admin:admin_views_chapterxtra1_changelist")
response = self.client.get(changelist_url)