1
0
mirror of https://github.com/django/django.git synced 2025-06-05 11:39:13 +00:00

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, HORIZONTAL,
VERTICAL, VERTICAL,
ModelAdmin, ModelAdmin,
ShowFacets,
StackedInline, StackedInline,
TabularInline, TabularInline,
) )
@ -42,6 +43,7 @@ __all__ = [
"AllValuesFieldListFilter", "AllValuesFieldListFilter",
"EmptyFieldListFilter", "EmptyFieldListFilter",
"RelatedOnlyFieldListFilter", "RelatedOnlyFieldListFilter",
"ShowFacets",
"autodiscover", "autodiscover",
] ]

View File

@ -24,6 +24,7 @@ class ListFilter:
template = "admin/filter.html" template = "admin/filter.html"
def __init__(self, request, params, model, model_admin): def __init__(self, request, params, model, model_admin):
self.request = request
# This dictionary will eventually contain the request's query string # This dictionary will eventually contain the request's query string
# parameters actually used by this filter. # parameters actually used by this filter.
self.used_parameters = {} 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. # The parameter that should be used in the query string for that filter.
parameter_name = None parameter_name = None
@ -111,13 +127,34 @@ class SimpleListFilter(ListFilter):
def expected_parameters(self): def expected_parameters(self):
return [self.parameter_name] 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): def choices(self, changelist):
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
yield { yield {
"selected": self.value() is None, "selected": self.value() is None,
"query_string": changelist.get_query_string(remove=[self.parameter_name]), "query_string": changelist.get_query_string(remove=[self.parameter_name]),
"display": _("All"), "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 { yield {
"selected": self.value() == str(lookup), "selected": self.value() == str(lookup),
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
@ -127,7 +164,7 @@ class SimpleListFilter(ListFilter):
} }
class FieldListFilter(ListFilter): class FieldListFilter(FacetsMixin, ListFilter):
_field_list_filters = [] _field_list_filters = []
_take_priority_index = 0 _take_priority_index = 0
list_separator = "," list_separator = ","
@ -224,7 +261,22 @@ class RelatedFieldListFilter(FieldListFilter):
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, ordering=ordering) 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): def choices(self, changelist):
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
yield { yield {
"selected": self.lookup_val is None and not self.lookup_val_isnull, "selected": self.lookup_val is None and not self.lookup_val_isnull,
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
@ -232,7 +284,11 @@ class RelatedFieldListFilter(FieldListFilter):
), ),
"display": _("All"), "display": _("All"),
} }
count = None
for pk_val, val in self.lookup_choices: for pk_val, val in self.lookup_choices:
if add_facets:
count = facet_counts[f"{pk_val}__c"]
val = f"{val} ({count})"
yield { yield {
"selected": self.lookup_val == str(pk_val), "selected": self.lookup_val == str(pk_val),
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
@ -240,13 +296,17 @@ class RelatedFieldListFilter(FieldListFilter):
), ),
"display": val, "display": val,
} }
empty_title = self.empty_value_display
if self.include_empty_choice: if self.include_empty_choice:
if add_facets:
count = facet_counts["__c"]
empty_title = f"{empty_title} ({count})"
yield { yield {
"selected": bool(self.lookup_val_isnull), "selected": bool(self.lookup_val_isnull),
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
{self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg] {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): def expected_parameters(self):
return [self.lookup_kwarg, self.lookup_kwarg2] 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): def choices(self, changelist):
field_choices = dict(self.field.flatchoices) field_choices = dict(self.field.flatchoices)
for lookup, title in ( add_facets = changelist.add_facets
(None, _("All")), facet_counts = self.get_facet_queryset(changelist) if add_facets else None
("1", field_choices.get(True, _("Yes"))), for lookup, title, count_field in (
("0", field_choices.get(False, _("No"))), (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 { yield {
"selected": self.lookup_val == lookup and not self.lookup_val2, "selected": self.lookup_val == lookup and not self.lookup_val2,
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
@ -287,12 +366,16 @@ class BooleanFieldListFilter(FieldListFilter):
"display": title, "display": title,
} }
if self.field.null: if self.field.null:
display = field_choices.get(None, _("Unknown"))
if add_facets:
count = facet_counts["null__c"]
display = f"{display} ({count})"
yield { yield {
"selected": self.lookup_val2 == "True", "selected": self.lookup_val2 == "True",
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
{self.lookup_kwarg2: "True"}, [self.lookup_kwarg] {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): def expected_parameters(self):
return [self.lookup_kwarg, self.lookup_kwarg_isnull] 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): def choices(self, changelist):
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
yield { yield {
"selected": self.lookup_val is None, "selected": self.lookup_val is None,
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
@ -321,7 +419,10 @@ class ChoicesFieldListFilter(FieldListFilter):
"display": _("All"), "display": _("All"),
} }
none_title = "" 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: if lookup is None:
none_title = title none_title = title
continue continue
@ -416,9 +517,20 @@ class DateFieldListFilter(FieldListFilter):
params.append(self.lookup_kwarg_isnull) params.append(self.lookup_kwarg_isnull)
return params 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): 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()} 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 { yield {
"selected": self.date_params == param_dict_str, "selected": self.date_params == param_dict_str,
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
@ -455,7 +567,22 @@ class AllValuesFieldListFilter(FieldListFilter):
def expected_parameters(self): def expected_parameters(self):
return [self.lookup_kwarg, self.lookup_kwarg_isnull] 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): def choices(self, changelist):
add_facets = changelist.add_facets
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
yield { yield {
"selected": self.lookup_val is None and self.lookup_val_isnull is None, "selected": self.lookup_val is None and self.lookup_val_isnull is None,
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
@ -464,9 +591,14 @@ class AllValuesFieldListFilter(FieldListFilter):
"display": _("All"), "display": _("All"),
} }
include_none = False 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: if val is None:
include_none = True include_none = True
empty_title = f"{empty_title} ({count})" if add_facets else empty_title
continue continue
val = str(val) val = str(val)
yield { yield {
@ -474,7 +606,7 @@ class AllValuesFieldListFilter(FieldListFilter):
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
{self.lookup_kwarg: val}, [self.lookup_kwarg_isnull] {self.lookup_kwarg: val}, [self.lookup_kwarg_isnull]
), ),
"display": val, "display": f"{val} ({count})" if add_facets else val,
} }
if include_none: if include_none:
yield { yield {
@ -482,7 +614,7 @@ class AllValuesFieldListFilter(FieldListFilter):
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(
{self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg] {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) self.lookup_val = params.get(self.lookup_kwarg)
super().__init__(field, request, params, model, model_admin, field_path) 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): def queryset(self, request, queryset):
if self.lookup_kwarg not in self.used_parameters: if self.lookup_kwarg not in self.used_parameters:
return queryset return queryset
if self.lookup_val not in ("0", "1"): if self.lookup_val not in ("0", "1"):
raise IncorrectLookupParameters raise IncorrectLookupParameters
lookup_conditions = [] lookup_condition = self.get_lookup_condition()
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)
if self.lookup_val == "1": if self.lookup_val == "1":
return queryset.filter(lookup_condition) return queryset.filter(lookup_condition)
return queryset.exclude(lookup_condition) return queryset.exclude(lookup_condition)
@ -536,12 +671,25 @@ class EmptyFieldListFilter(FieldListFilter):
def expected_parameters(self): def expected_parameters(self):
return [self.lookup_kwarg] 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): def choices(self, changelist):
for lookup, title in ( add_facets = changelist.add_facets
(None, _("All")), facet_counts = self.get_facet_queryset(changelist) if add_facets else None
("1", _("Empty")), for lookup, title, count_field in (
("0", _("Not empty")), (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 { yield {
"selected": self.lookup_val == lookup, "selected": self.lookup_val == lookup,
"query_string": changelist.get_query_string( "query_string": changelist.get_query_string(

View File

@ -1,4 +1,5 @@
import copy import copy
import enum
import json import json
import re import re
from functools import partial, update_wrapper from functools import partial, update_wrapper
@ -68,6 +69,13 @@ from django.views.generic import RedirectView
IS_POPUP_VAR = "_popup" IS_POPUP_VAR = "_popup"
TO_FIELD_VAR = "_to_field" TO_FIELD_VAR = "_to_field"
IS_FACETS_VAR = "_facets"
class ShowFacets(enum.Enum):
NEVER = "NEVER"
ALLOW = "ALLOW"
ALWAYS = "ALWAYS"
HORIZONTAL, VERTICAL = 1, 2 HORIZONTAL, VERTICAL = 1, 2
@ -628,6 +636,7 @@ class ModelAdmin(BaseModelAdmin):
save_on_top = False save_on_top = False
paginator = Paginator paginator = Paginator
preserve_filters = True preserve_filters = True
show_facets = ShowFacets.ALLOW
inlines = () inlines = ()
# Custom templates (designed to be over-ridden in subclasses) # 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; 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 { .addlink {
padding-left: 16px; padding-left: 16px;
background: url(../img/icon-addlink.svg) 0 1px no-repeat; background: url(../img/icon-addlink.svg) 0 1px no-repeat;

View File

@ -215,9 +215,9 @@
color: var(--link-hover-color); color: var(--link-hover-color);
} }
#changelist-filter #changelist-filter-clear a { #changelist-filter #changelist-filter-extra-actions {
font-size: 0.8125rem; font-size: 0.8125rem;
padding-bottom: 10px; margin-bottom: 10px;
border-bottom: 1px solid var(--hairline-color); 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 %} {% if cl.has_filters %}
<div id="changelist-filter"> <div id="changelist-filter">
<h2>{% translate 'Filter' %}</h2> <h2>{% translate 'Filter' %}</h2>
{% if cl.has_active_filters %}<h3 id="changelist-filter-clear"> {% if cl.is_facets_optional or cl.has_active_filters %}<div id="changelist-filter-extra-actions">
<a href="{{ cl.clear_all_filters_qs }}">&#10006; {% translate "Clear all filters" %}</a> {% if cl.is_facets_optional %}<h3>
</h3>{% endif %} {% 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 %} {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
</div> </div>
{% endif %} {% 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="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' %}"> <input type="submit" value="{% translate 'Search' %}">
{% if show_result_count %} {% 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 %} {% endif %}
{% for pair in cl.params.items %} {% for pair in cl.params.items %}
{% if pair.0 != search_var %}<input type="hidden" name="{{ pair.0 }}" value="{{ pair.1 }}">{% endif %} {% 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 ( from django.contrib.admin.views.main import (
ALL_VAR, ALL_VAR,
IS_FACETS_VAR,
IS_POPUP_VAR, IS_POPUP_VAR,
ORDER_VAR, ORDER_VAR,
PAGE_VAR, PAGE_VAR,
@ -475,6 +476,7 @@ def search_form(cl):
"show_result_count": cl.result_count != cl.full_result_count, "show_result_count": cl.result_count != cl.full_result_count,
"search_var": SEARCH_VAR, "search_var": SEARCH_VAR,
"is_popup_var": IS_POPUP_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, DisallowedModelAdminToField,
) )
from django.contrib.admin.options import ( from django.contrib.admin.options import (
IS_FACETS_VAR,
IS_POPUP_VAR, IS_POPUP_VAR,
TO_FIELD_VAR, TO_FIELD_VAR,
IncorrectLookupParameters, IncorrectLookupParameters,
ShowFacets,
) )
from django.contrib.admin.utils import ( from django.contrib.admin.utils import (
get_fields_from_path, get_fields_from_path,
@ -39,7 +41,14 @@ PAGE_VAR = "p"
SEARCH_VAR = "q" SEARCH_VAR = "q"
ERROR_FLAG = "e" 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): class ChangeListSearchForm(forms.Form):
@ -103,6 +112,10 @@ class ChangeList:
self.page_num = 1 self.page_num = 1
self.show_all = ALL_VAR in request.GET self.show_all = ALL_VAR in request.GET
self.is_popup = IS_POPUP_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) to_field = request.GET.get(TO_FIELD_VAR)
if to_field and not model_admin.to_field_allowed(request, to_field): if to_field and not model_admin.to_field_allowed(request, to_field):
raise DisallowedModelAdminToField( raise DisallowedModelAdminToField(
@ -114,6 +127,8 @@ class ChangeList:
del self.params[PAGE_VAR] del self.params[PAGE_VAR]
if ERROR_FLAG in self.params: if ERROR_FLAG in self.params:
del self.params[ERROR_FLAG] 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: if self.is_popup:
self.list_editable = () self.list_editable = ()
@ -492,7 +507,7 @@ class ChangeList:
ordering_fields[idx] = "desc" if pfx == "-" else "asc" ordering_fields[idx] = "desc" if pfx == "-" else "asc"
return ordering_fields return ordering_fields
def get_queryset(self, request): def get_queryset(self, request, exclude_parameters=None):
# First, we collect all the declared list filters. # First, we collect all the declared list filters.
( (
self.filter_specs, self.filter_specs,
@ -504,9 +519,13 @@ class ChangeList:
# Then, we let every list filter modify the queryset to its liking. # Then, we let every list filter modify the queryset to its liking.
qs = self.root_queryset qs = self.root_queryset
for filter_spec in self.filter_specs: for filter_spec in self.filter_specs:
new_qs = filter_spec.queryset(request, qs) if (
if new_qs is not None: exclude_parameters is None
qs = new_qs or filter_spec.expected_parameters() != exclude_parameters
):
new_qs = filter_spec.queryset(request, qs)
if new_qs is not None:
qs = new_qs
try: try:
# Finally, we apply the remaining lookup parameters from the query # 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 See the default template provided by Django (``admin/filter.html``) for a
concrete example. 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 editing, or deleting an object. You can have filters cleared by setting
this attribute to ``False``. 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 .. attribute:: ModelAdmin.radio_fields
By default, Django's admin uses a select-box interface (<select>) for 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 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 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.tests import AdminSeleniumTestCase
from django.contrib.admin.views.main import ( from django.contrib.admin.views.main import (
ALL_VAR, ALL_VAR,
IS_FACETS_VAR,
IS_POPUP_VAR, IS_POPUP_VAR,
ORDER_VAR, ORDER_VAR,
PAGE_VAR, PAGE_VAR,
@ -1031,6 +1032,7 @@ class ChangeListTests(TestCase):
{TO_FIELD_VAR: "id"}, {TO_FIELD_VAR: "id"},
{PAGE_VAR: "1"}, {PAGE_VAR: "1"},
{IS_POPUP_VAR: "1"}, {IS_POPUP_VAR: "1"},
{IS_FACETS_VAR: ""},
{"username__startswith": "test"}, {"username__startswith": "test"},
): ):
with self.subTest(data=data): with self.subTest(data=data):
@ -1599,6 +1601,11 @@ class ChangeListTests(TestCase):
for data, href in ( for data, href in (
({"is_staff__exact": "0"}, "?"), ({"is_staff__exact": "0"}, "?"),
({"is_staff__exact": "0", IS_POPUP_VAR: "1"}, f"?{IS_POPUP_VAR}=1"), ({"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): with self.subTest(data=data):
response = self.client.get(url, 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). # This field name is intentionally 2 characters long (#16080).
no = models.IntegerField(verbose_name="number", blank=True, null=True) 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): def __str__(self):
return self.title return self.title

View File

@ -12,11 +12,12 @@ from django.contrib.admin import (
SimpleListFilter, SimpleListFilter,
site, 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.admin import UserAdmin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ImproperlyConfigured 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 from .models import Book, Bookmark, Department, Employee, ImprovedBook, TaggedItem
@ -217,10 +218,28 @@ class BookAdminRelatedOnlyFilter(ModelAdmin):
class DecadeFilterBookAdmin(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",) ordering = ("-id",)
class DecadeFilterBookAdminWithAlwaysFacets(DecadeFilterBookAdmin):
show_facets = ShowFacets.ALWAYS
class DecadeFilterBookAdminDisallowFacets(DecadeFilterBookAdmin):
show_facets = ShowFacets.NEVER
class NotNinetiesListFilterAdmin(ModelAdmin): class NotNinetiesListFilterAdmin(ModelAdmin):
list_filter = (NotNinetiesListFilter,) list_filter = (NotNinetiesListFilter,)
@ -324,6 +343,7 @@ class ListFiltersTests(TestCase):
is_best_seller=True, is_best_seller=True,
date_registered=cls.today, date_registered=cls.today,
availability=True, availability=True,
category="non-fiction",
) )
cls.bio_book = Book.objects.create( cls.bio_book = Book.objects.create(
title="Django: a biography", title="Django: a biography",
@ -332,6 +352,7 @@ class ListFiltersTests(TestCase):
is_best_seller=False, is_best_seller=False,
no=207, no=207,
availability=False, availability=False,
category="fiction",
) )
cls.django_book = Book.objects.create( cls.django_book = Book.objects.create(
title="The Django Book", title="The Django Book",
@ -348,6 +369,7 @@ class ListFiltersTests(TestCase):
is_best_seller=True, is_best_seller=True,
date_registered=cls.one_week_ago, date_registered=cls.one_week_ago,
availability=None, availability=None,
category="",
) )
cls.guitar_book.contributors.set([cls.bob, cls.lisa]) 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.john = Employee.objects.create(name="John Blue", department=cls.dev)
cls.jack = Employee.objects.create(name="Jack Red", department=cls.design) 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): def test_choicesfieldlistfilter_has_none_choice(self):
""" """
The last choice is for the None value. The last choice is for the None value.
@ -1315,6 +1341,185 @@ class ListFiltersTests(TestCase):
self.assertIs(choices[2]["selected"], False) self.assertIs(choices[2]["selected"], False)
self.assertEqual(choices[2]["query_string"], "?publication-decade=the+00s") 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): def test_two_characters_long_field(self):
""" """
list_filter works with two-characters long field names (#16080). list_filter works with two-characters long field names (#16080).
@ -1698,3 +1903,10 @@ class ListFiltersTests(TestCase):
# Make sure the correct queryset is returned # Make sure the correct queryset is returned
queryset = changelist.get_queryset(request) queryset = changelist.get_queryset(request)
self.assertEqual(list(queryset), [jane]) 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): class WorkHourAdmin(admin.ModelAdmin):
list_display = ("datum", "employee") list_display = ("datum", "employee")
list_filter = ("employee",) list_filter = ("employee",)
show_facets = admin.ShowFacets.ALWAYS
class FoodDeliveryAdmin(admin.ModelAdmin): class FoodDeliveryAdmin(admin.ModelAdmin):
list_display = ("reference", "driver", "restaurant") list_display = ("reference", "driver", "restaurant")
list_editable = ("driver", "restaurant") list_editable = ("driver", "restaurant")
show_facets = admin.ShowFacets.NEVER
class CoverLetterAdmin(admin.ModelAdmin): class CoverLetterAdmin(admin.ModelAdmin):

View File

@ -807,6 +807,47 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
msg_prefix="Changelist filter not correctly limited by limit_choices_to", 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): def test_relation_spanning_filters(self):
changelist_url = reverse("admin:admin_views_chapterxtra1_changelist") changelist_url = reverse("admin:admin_views_chapterxtra1_changelist")
response = self.client.get(changelist_url) response = self.client.get(changelist_url)