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

- ✖ {% translate "Clear all filters" %} -

{% endif %} + {% if cl.is_facets_optional or cl.has_active_filters %}
+ {% if cl.is_facets_optional %}

+ {% if cl.add_facets %}{% translate "Hide counts" %} + {% else %}{% translate "Show counts" %}{% endif %} +

{% endif %} + {% if cl.has_active_filters %}

+ ✖ {% translate "Clear all filters" %} +

{% endif %} +
{% 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 (