Browse Source

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

Thanks Carlton Gibson, Simon Willison, David Smith, and Mariusz
Felisiak for reviews.
sarahboyce 2 years ago
parent
commit
868e2fcdda

+ 2 - 0
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",
 ]
 

+ 172 - 24
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(

+ 9 - 0
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)

+ 5 - 0
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;

+ 2 - 2
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);
 }
 

+ 3 - 0
django/contrib/admin/static/admin/img/icon-hidelink.svg

@@ -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>

+ 9 - 3
django/contrib/admin/templates/admin/change_list.html

@@ -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 %}

+ 1 - 1
django/contrib/admin/templates/admin/search_form.html

@@ -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 %}

+ 2 - 0
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,
     }
 
 

+ 24 - 5
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

BIN
docs/intro/_images/admin13t.png


BIN
docs/ref/contrib/admin/_images/list_filter.png


+ 11 - 0
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.

+ 48 - 0
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 (<select>) for

+ 8 - 0
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
 --------------
 

+ 7 - 0
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)

+ 7 - 0
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

+ 215 - 3
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)

+ 2 - 0
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):

+ 41 - 0
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,
+            '<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)