mirror of
https://github.com/django/django.git
synced 2024-12-22 09:05:43 +00:00
Fixed #32539 -- Added toggleable facet filters to ModelAdmin.
Thanks Carlton Gibson, Simon Willison, David Smith, and Mariusz Felisiak for reviews.
This commit is contained in:
parent
50ca4defcb
commit
868e2fcdda
@ -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",
|
||||
]
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
@ -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
django/contrib/admin/static/admin/img/icon-hidelink.svg
Normal file
3
django/contrib/admin/static/admin/img/icon-hidelink.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2b70bf" d="m555 1335 78-141q-87-63-136-159t-49-203q0-121 61-225-229 117-381 353 167 258 427 375zm389-759q0-20-14-34t-34-14q-125 0-214.5 89.5T592 832q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm363-191q0 7-1 9-105 188-315 566t-316 567l-49 89q-10 16-28 16-12 0-134-70-16-10-16-28 0-12 44-87-143-65-263.5-173T20 1029Q0 998 0 960t20-69q153-235 380-371t496-136q89 0 180 17l54-97q10-16 28-16 5 0 18 6t31 15.5 33 18.5 31.5 18.5T1291 358q16 10 16 27zm37 447q0 139-79 253.5T1056 1250l280-502q8 45 8 84zm448 128q0 35-20 69-39 64-109 145-150 172-347.5 267T896 1536l74-132q212-18 392.5-137T1664 960q-115-179-282-294l63-112q95 64 182.5 153T1772 891q20 34 20 69z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 784 B |
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
--------------
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user