diff --git a/django/contrib/admin/__init__.py b/django/contrib/admin/__init__.py
index ef5c64ffef..0d9189f6b1 100644
--- a/django/contrib/admin/__init__.py
+++ b/django/contrib/admin/__init__.py
@@ -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",
]
diff --git a/django/contrib/admin/filters.py b/django/contrib/admin/filters.py
index 7d9e877066..9e468dedd9 100644
--- a/django/contrib/admin/filters.py
+++ b/django/contrib/admin/filters.py
@@ -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(
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 1e08458fe1..5240b07cd6 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -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)
diff --git a/django/contrib/admin/static/admin/css/base.css b/django/contrib/admin/static/admin/css/base.css
index 72f4ae169b..2a2d6f8f5d 100644
--- a/django/contrib/admin/static/admin/css/base.css
+++ b/django/contrib/admin/static/admin/css/base.css
@@ -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;
diff --git a/django/contrib/admin/static/admin/css/changelists.css b/django/contrib/admin/static/admin/css/changelists.css
index 641dccd7e3..b399cff427 100644
--- a/django/contrib/admin/static/admin/css/changelists.css
+++ b/django/contrib/admin/static/admin/css/changelists.css
@@ -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);
}
diff --git a/django/contrib/admin/static/admin/img/icon-hidelink.svg b/django/contrib/admin/static/admin/img/icon-hidelink.svg
new file mode 100644
index 0000000000..2a8b404b60
--- /dev/null
+++ b/django/contrib/admin/static/admin/img/icon-hidelink.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html
index a0ecde1991..e03e705df2 100644
--- a/django/contrib/admin/templates/admin/change_list.html
+++ b/django/contrib/admin/templates/admin/change_list.html
@@ -75,9 +75,15 @@
{% if cl.has_filters %}
{% translate 'Filter' %}
- {% if cl.has_active_filters %}
{% endif %}
+ {% if cl.is_facets_optional or cl.has_active_filters %}{% endif %}
{% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
{% endif %}
diff --git a/django/contrib/admin/templates/admin/search_form.html b/django/contrib/admin/templates/admin/search_form.html
index ad86af91cf..e3a0ee540b 100644
--- a/django/contrib/admin/templates/admin/search_form.html
+++ b/django/contrib/admin/templates/admin/search_form.html
@@ -6,7 +6,7 @@
{% if show_result_count %}
- {% blocktranslate count counter=cl.result_count %}{{ counter }} result{% plural %}{{ counter }} results{% endblocktranslate %} ({% 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 %} )
+ {% blocktranslate count counter=cl.result_count %}{{ counter }} result{% plural %}{{ counter }} results{% endblocktranslate %} ({% 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 %} )
{% endif %}
{% for pair in cl.params.items %}
{% if pair.0 != search_var %} {% endif %}
diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py
index 761a64ae5f..9bff84d501 100644
--- a/django/contrib/admin/templatetags/admin_list.py
+++ b/django/contrib/admin/templatetags/admin_list.py
@@ -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,
}
diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py
index 3323de1f1c..6d88dfc883 100644
--- a/django/contrib/admin/views/main.py
+++ b/django/contrib/admin/views/main.py
@@ -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
diff --git a/docs/intro/_images/admin13t.png b/docs/intro/_images/admin13t.png
index 3fed5f1985..de20217f2d 100644
Binary files a/docs/intro/_images/admin13t.png and b/docs/intro/_images/admin13t.png differ
diff --git a/docs/ref/contrib/admin/_images/list_filter.png b/docs/ref/contrib/admin/_images/list_filter.png
index 8b6a5a7a54..35d30b8381 100644
Binary files a/docs/ref/contrib/admin/_images/list_filter.png and b/docs/ref/contrib/admin/_images/list_filter.png differ
diff --git a/docs/ref/contrib/admin/filters.txt b/docs/ref/contrib/admin/filters.txt
index ca8881a645..fc70a1d6b2 100644
--- a/docs/ref/contrib/admin/filters.txt
+++ b/docs/ref/contrib/admin/filters.txt
@@ -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.
diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
index 67007baef2..aa3044e66b 100644
--- a/docs/ref/contrib/admin/index.txt
+++ b/docs/ref/contrib/admin/index.txt
@@ -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 () for
diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt
index 84689f223d..254654eb7d 100644
--- a/docs/releases/5.0.txt
+++ b/docs/releases/5.0.txt
@@ -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
--------------
diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py
index 3897453cda..aabe8bedc8 100644
--- a/tests/admin_changelist/tests.py
+++ b/tests/admin_changelist/tests.py
@@ -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)
diff --git a/tests/admin_filters/models.py b/tests/admin_filters/models.py
index 53b471dd90..3302a75791 100644
--- a/tests/admin_filters/models.py
+++ b/tests/admin_filters/models.py
@@ -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
diff --git a/tests/admin_filters/tests.py b/tests/admin_filters/tests.py
index d542bcd3ec..b7b1aa6b83 100644
--- a/tests/admin_filters/tests.py
+++ b/tests/admin_filters/tests.py
@@ -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)
diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py
index 9241034ffb..aa8b183f14 100644
--- a/tests/admin_views/admin.py
+++ b/tests/admin_views/admin.py
@@ -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):
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 727362beaf..30207ee4b0 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -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,
+ 'Show counts ',
+ msg_prefix="Expected facet filter toggle not found in changelist view",
+ )
+ response = self.client.get(f"{admin_url}?_facets=True")
+ self.assertContains(
+ response,
+ 'Hide counts ',
+ 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)