diff options
| author | sarahboyce <sarahvboyce95@gmail.com> | 2023-02-16 13:23:24 +0100 |
|---|---|---|
| committer | Mariusz Felisiak <felisiak.mariusz@gmail.com> | 2023-03-03 20:24:57 +0100 |
| commit | 868e2fcddae6720d5713924a785339d1665f1bb9 (patch) | |
| tree | d775977fdf2d5da5792f7e17743d7db1d2bc260e | |
| parent | 50ca4defcb2e5d1c987ed006562f7a812179b3df (diff) | |
Fixed #32539 -- Added toggleable facet filters to ModelAdmin.
Thanks Carlton Gibson, Simon Willison, David Smith, and Mariusz
Felisiak for reviews.
20 files changed, 568 insertions, 38 deletions
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 @@ +<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> 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 %} <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 }}">✖ {% 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 }}">✖ {% translate "Clear all filters" %}</a> + </h3>{% endif %} + </div>{% endif %} {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %} </div> {% 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 @@ <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 %} 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 Binary files differindex 3fed5f1985..de20217f2d 100644 --- a/docs/intro/_images/admin13t.png +++ b/docs/intro/_images/admin13t.png diff --git a/docs/ref/contrib/admin/_images/list_filter.png b/docs/ref/contrib/admin/_images/list_filter.png Binary files differindex 8b6a5a7a54..35d30b8381 100644 --- a/docs/ref/contrib/admin/_images/list_filter.png +++ b/docs/ref/contrib/admin/_images/list_filter.png 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 (<select>) 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, + '<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) |
