mirror of
https://github.com/django/django.git
synced 2025-06-05 11:39:13 +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,
|
HORIZONTAL,
|
||||||
VERTICAL,
|
VERTICAL,
|
||||||
ModelAdmin,
|
ModelAdmin,
|
||||||
|
ShowFacets,
|
||||||
StackedInline,
|
StackedInline,
|
||||||
TabularInline,
|
TabularInline,
|
||||||
)
|
)
|
||||||
@ -42,6 +43,7 @@ __all__ = [
|
|||||||
"AllValuesFieldListFilter",
|
"AllValuesFieldListFilter",
|
||||||
"EmptyFieldListFilter",
|
"EmptyFieldListFilter",
|
||||||
"RelatedOnlyFieldListFilter",
|
"RelatedOnlyFieldListFilter",
|
||||||
|
"ShowFacets",
|
||||||
"autodiscover",
|
"autodiscover",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -24,6 +24,7 @@ class ListFilter:
|
|||||||
template = "admin/filter.html"
|
template = "admin/filter.html"
|
||||||
|
|
||||||
def __init__(self, request, params, model, model_admin):
|
def __init__(self, request, params, model, model_admin):
|
||||||
|
self.request = request
|
||||||
# This dictionary will eventually contain the request's query string
|
# This dictionary will eventually contain the request's query string
|
||||||
# parameters actually used by this filter.
|
# parameters actually used by this filter.
|
||||||
self.used_parameters = {}
|
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.
|
# The parameter that should be used in the query string for that filter.
|
||||||
parameter_name = None
|
parameter_name = None
|
||||||
|
|
||||||
@ -111,13 +127,34 @@ class SimpleListFilter(ListFilter):
|
|||||||
def expected_parameters(self):
|
def expected_parameters(self):
|
||||||
return [self.parameter_name]
|
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):
|
def choices(self, changelist):
|
||||||
|
add_facets = changelist.add_facets
|
||||||
|
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
|
||||||
yield {
|
yield {
|
||||||
"selected": self.value() is None,
|
"selected": self.value() is None,
|
||||||
"query_string": changelist.get_query_string(remove=[self.parameter_name]),
|
"query_string": changelist.get_query_string(remove=[self.parameter_name]),
|
||||||
"display": _("All"),
|
"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 {
|
yield {
|
||||||
"selected": self.value() == str(lookup),
|
"selected": self.value() == str(lookup),
|
||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
@ -127,7 +164,7 @@ class SimpleListFilter(ListFilter):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class FieldListFilter(ListFilter):
|
class FieldListFilter(FacetsMixin, ListFilter):
|
||||||
_field_list_filters = []
|
_field_list_filters = []
|
||||||
_take_priority_index = 0
|
_take_priority_index = 0
|
||||||
list_separator = ","
|
list_separator = ","
|
||||||
@ -224,7 +261,22 @@ class RelatedFieldListFilter(FieldListFilter):
|
|||||||
ordering = self.field_admin_ordering(field, request, model_admin)
|
ordering = self.field_admin_ordering(field, request, model_admin)
|
||||||
return field.get_choices(include_blank=False, ordering=ordering)
|
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):
|
def choices(self, changelist):
|
||||||
|
add_facets = changelist.add_facets
|
||||||
|
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
|
||||||
yield {
|
yield {
|
||||||
"selected": self.lookup_val is None and not self.lookup_val_isnull,
|
"selected": self.lookup_val is None and not self.lookup_val_isnull,
|
||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
@ -232,7 +284,11 @@ class RelatedFieldListFilter(FieldListFilter):
|
|||||||
),
|
),
|
||||||
"display": _("All"),
|
"display": _("All"),
|
||||||
}
|
}
|
||||||
|
count = None
|
||||||
for pk_val, val in self.lookup_choices:
|
for pk_val, val in self.lookup_choices:
|
||||||
|
if add_facets:
|
||||||
|
count = facet_counts[f"{pk_val}__c"]
|
||||||
|
val = f"{val} ({count})"
|
||||||
yield {
|
yield {
|
||||||
"selected": self.lookup_val == str(pk_val),
|
"selected": self.lookup_val == str(pk_val),
|
||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
@ -240,13 +296,17 @@ class RelatedFieldListFilter(FieldListFilter):
|
|||||||
),
|
),
|
||||||
"display": val,
|
"display": val,
|
||||||
}
|
}
|
||||||
|
empty_title = self.empty_value_display
|
||||||
if self.include_empty_choice:
|
if self.include_empty_choice:
|
||||||
|
if add_facets:
|
||||||
|
count = facet_counts["__c"]
|
||||||
|
empty_title = f"{empty_title} ({count})"
|
||||||
yield {
|
yield {
|
||||||
"selected": bool(self.lookup_val_isnull),
|
"selected": bool(self.lookup_val_isnull),
|
||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
{self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg]
|
{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):
|
def expected_parameters(self):
|
||||||
return [self.lookup_kwarg, self.lookup_kwarg2]
|
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):
|
def choices(self, changelist):
|
||||||
field_choices = dict(self.field.flatchoices)
|
field_choices = dict(self.field.flatchoices)
|
||||||
for lookup, title in (
|
add_facets = changelist.add_facets
|
||||||
(None, _("All")),
|
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
|
||||||
("1", field_choices.get(True, _("Yes"))),
|
for lookup, title, count_field in (
|
||||||
("0", field_choices.get(False, _("No"))),
|
(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 {
|
yield {
|
||||||
"selected": self.lookup_val == lookup and not self.lookup_val2,
|
"selected": self.lookup_val == lookup and not self.lookup_val2,
|
||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
@ -287,12 +366,16 @@ class BooleanFieldListFilter(FieldListFilter):
|
|||||||
"display": title,
|
"display": title,
|
||||||
}
|
}
|
||||||
if self.field.null:
|
if self.field.null:
|
||||||
|
display = field_choices.get(None, _("Unknown"))
|
||||||
|
if add_facets:
|
||||||
|
count = facet_counts["null__c"]
|
||||||
|
display = f"{display} ({count})"
|
||||||
yield {
|
yield {
|
||||||
"selected": self.lookup_val2 == "True",
|
"selected": self.lookup_val2 == "True",
|
||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
{self.lookup_kwarg2: "True"}, [self.lookup_kwarg]
|
{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):
|
def expected_parameters(self):
|
||||||
return [self.lookup_kwarg, self.lookup_kwarg_isnull]
|
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):
|
def choices(self, changelist):
|
||||||
|
add_facets = changelist.add_facets
|
||||||
|
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
|
||||||
yield {
|
yield {
|
||||||
"selected": self.lookup_val is None,
|
"selected": self.lookup_val is None,
|
||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
@ -321,7 +419,10 @@ class ChoicesFieldListFilter(FieldListFilter):
|
|||||||
"display": _("All"),
|
"display": _("All"),
|
||||||
}
|
}
|
||||||
none_title = ""
|
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:
|
if lookup is None:
|
||||||
none_title = title
|
none_title = title
|
||||||
continue
|
continue
|
||||||
@ -416,9 +517,20 @@ class DateFieldListFilter(FieldListFilter):
|
|||||||
params.append(self.lookup_kwarg_isnull)
|
params.append(self.lookup_kwarg_isnull)
|
||||||
return params
|
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):
|
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()}
|
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 {
|
yield {
|
||||||
"selected": self.date_params == param_dict_str,
|
"selected": self.date_params == param_dict_str,
|
||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
@ -455,7 +567,22 @@ class AllValuesFieldListFilter(FieldListFilter):
|
|||||||
def expected_parameters(self):
|
def expected_parameters(self):
|
||||||
return [self.lookup_kwarg, self.lookup_kwarg_isnull]
|
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):
|
def choices(self, changelist):
|
||||||
|
add_facets = changelist.add_facets
|
||||||
|
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
|
||||||
yield {
|
yield {
|
||||||
"selected": self.lookup_val is None and self.lookup_val_isnull is None,
|
"selected": self.lookup_val is None and self.lookup_val_isnull is None,
|
||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
@ -464,9 +591,14 @@ class AllValuesFieldListFilter(FieldListFilter):
|
|||||||
"display": _("All"),
|
"display": _("All"),
|
||||||
}
|
}
|
||||||
include_none = False
|
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:
|
if val is None:
|
||||||
include_none = True
|
include_none = True
|
||||||
|
empty_title = f"{empty_title} ({count})" if add_facets else empty_title
|
||||||
continue
|
continue
|
||||||
val = str(val)
|
val = str(val)
|
||||||
yield {
|
yield {
|
||||||
@ -474,7 +606,7 @@ class AllValuesFieldListFilter(FieldListFilter):
|
|||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
{self.lookup_kwarg: val}, [self.lookup_kwarg_isnull]
|
{self.lookup_kwarg: val}, [self.lookup_kwarg_isnull]
|
||||||
),
|
),
|
||||||
"display": val,
|
"display": f"{val} ({count})" if add_facets else val,
|
||||||
}
|
}
|
||||||
if include_none:
|
if include_none:
|
||||||
yield {
|
yield {
|
||||||
@ -482,7 +614,7 @@ class AllValuesFieldListFilter(FieldListFilter):
|
|||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
{self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg]
|
{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)
|
self.lookup_val = params.get(self.lookup_kwarg)
|
||||||
super().__init__(field, request, params, model, model_admin, field_path)
|
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):
|
def queryset(self, request, queryset):
|
||||||
if self.lookup_kwarg not in self.used_parameters:
|
if self.lookup_kwarg not in self.used_parameters:
|
||||||
return queryset
|
return queryset
|
||||||
if self.lookup_val not in ("0", "1"):
|
if self.lookup_val not in ("0", "1"):
|
||||||
raise IncorrectLookupParameters
|
raise IncorrectLookupParameters
|
||||||
|
|
||||||
lookup_conditions = []
|
lookup_condition = self.get_lookup_condition()
|
||||||
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)
|
|
||||||
if self.lookup_val == "1":
|
if self.lookup_val == "1":
|
||||||
return queryset.filter(lookup_condition)
|
return queryset.filter(lookup_condition)
|
||||||
return queryset.exclude(lookup_condition)
|
return queryset.exclude(lookup_condition)
|
||||||
@ -536,12 +671,25 @@ class EmptyFieldListFilter(FieldListFilter):
|
|||||||
def expected_parameters(self):
|
def expected_parameters(self):
|
||||||
return [self.lookup_kwarg]
|
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):
|
def choices(self, changelist):
|
||||||
for lookup, title in (
|
add_facets = changelist.add_facets
|
||||||
(None, _("All")),
|
facet_counts = self.get_facet_queryset(changelist) if add_facets else None
|
||||||
("1", _("Empty")),
|
for lookup, title, count_field in (
|
||||||
("0", _("Not empty")),
|
(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 {
|
yield {
|
||||||
"selected": self.lookup_val == lookup,
|
"selected": self.lookup_val == lookup,
|
||||||
"query_string": changelist.get_query_string(
|
"query_string": changelist.get_query_string(
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import copy
|
import copy
|
||||||
|
import enum
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from functools import partial, update_wrapper
|
from functools import partial, update_wrapper
|
||||||
@ -68,6 +69,13 @@ from django.views.generic import RedirectView
|
|||||||
|
|
||||||
IS_POPUP_VAR = "_popup"
|
IS_POPUP_VAR = "_popup"
|
||||||
TO_FIELD_VAR = "_to_field"
|
TO_FIELD_VAR = "_to_field"
|
||||||
|
IS_FACETS_VAR = "_facets"
|
||||||
|
|
||||||
|
|
||||||
|
class ShowFacets(enum.Enum):
|
||||||
|
NEVER = "NEVER"
|
||||||
|
ALLOW = "ALLOW"
|
||||||
|
ALWAYS = "ALWAYS"
|
||||||
|
|
||||||
|
|
||||||
HORIZONTAL, VERTICAL = 1, 2
|
HORIZONTAL, VERTICAL = 1, 2
|
||||||
@ -628,6 +636,7 @@ class ModelAdmin(BaseModelAdmin):
|
|||||||
save_on_top = False
|
save_on_top = False
|
||||||
paginator = Paginator
|
paginator = Paginator
|
||||||
preserve_filters = True
|
preserve_filters = True
|
||||||
|
show_facets = ShowFacets.ALLOW
|
||||||
inlines = ()
|
inlines = ()
|
||||||
|
|
||||||
# Custom templates (designed to be over-ridden in subclasses)
|
# 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;
|
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 {
|
.addlink {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
background: url(../img/icon-addlink.svg) 0 1px no-repeat;
|
background: url(../img/icon-addlink.svg) 0 1px no-repeat;
|
||||||
|
@ -215,9 +215,9 @@
|
|||||||
color: var(--link-hover-color);
|
color: var(--link-hover-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
#changelist-filter #changelist-filter-clear a {
|
#changelist-filter #changelist-filter-extra-actions {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
padding-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
border-bottom: 1px solid var(--hairline-color);
|
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 %}
|
{% if cl.has_filters %}
|
||||||
<div id="changelist-filter">
|
<div id="changelist-filter">
|
||||||
<h2>{% translate 'Filter' %}</h2>
|
<h2>{% translate 'Filter' %}</h2>
|
||||||
{% if cl.has_active_filters %}<h3 id="changelist-filter-clear">
|
{% if cl.is_facets_optional or cl.has_active_filters %}<div id="changelist-filter-extra-actions">
|
||||||
<a href="{{ cl.clear_all_filters_qs }}">✖ {% translate "Clear all filters" %}</a>
|
{% if cl.is_facets_optional %}<h3>
|
||||||
</h3>{% endif %}
|
{% 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 %}
|
{% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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="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' %}">
|
<input type="submit" value="{% translate 'Search' %}">
|
||||||
{% if show_result_count %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% for pair in cl.params.items %}
|
{% for pair in cl.params.items %}
|
||||||
{% if pair.0 != search_var %}<input type="hidden" name="{{ pair.0 }}" value="{{ pair.1 }}">{% endif %}
|
{% 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 (
|
from django.contrib.admin.views.main import (
|
||||||
ALL_VAR,
|
ALL_VAR,
|
||||||
|
IS_FACETS_VAR,
|
||||||
IS_POPUP_VAR,
|
IS_POPUP_VAR,
|
||||||
ORDER_VAR,
|
ORDER_VAR,
|
||||||
PAGE_VAR,
|
PAGE_VAR,
|
||||||
@ -475,6 +476,7 @@ def search_form(cl):
|
|||||||
"show_result_count": cl.result_count != cl.full_result_count,
|
"show_result_count": cl.result_count != cl.full_result_count,
|
||||||
"search_var": SEARCH_VAR,
|
"search_var": SEARCH_VAR,
|
||||||
"is_popup_var": IS_POPUP_VAR,
|
"is_popup_var": IS_POPUP_VAR,
|
||||||
|
"is_facets_var": IS_FACETS_VAR,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -9,9 +9,11 @@ from django.contrib.admin.exceptions import (
|
|||||||
DisallowedModelAdminToField,
|
DisallowedModelAdminToField,
|
||||||
)
|
)
|
||||||
from django.contrib.admin.options import (
|
from django.contrib.admin.options import (
|
||||||
|
IS_FACETS_VAR,
|
||||||
IS_POPUP_VAR,
|
IS_POPUP_VAR,
|
||||||
TO_FIELD_VAR,
|
TO_FIELD_VAR,
|
||||||
IncorrectLookupParameters,
|
IncorrectLookupParameters,
|
||||||
|
ShowFacets,
|
||||||
)
|
)
|
||||||
from django.contrib.admin.utils import (
|
from django.contrib.admin.utils import (
|
||||||
get_fields_from_path,
|
get_fields_from_path,
|
||||||
@ -39,7 +41,14 @@ PAGE_VAR = "p"
|
|||||||
SEARCH_VAR = "q"
|
SEARCH_VAR = "q"
|
||||||
ERROR_FLAG = "e"
|
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):
|
class ChangeListSearchForm(forms.Form):
|
||||||
@ -103,6 +112,10 @@ class ChangeList:
|
|||||||
self.page_num = 1
|
self.page_num = 1
|
||||||
self.show_all = ALL_VAR in request.GET
|
self.show_all = ALL_VAR in request.GET
|
||||||
self.is_popup = IS_POPUP_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)
|
to_field = request.GET.get(TO_FIELD_VAR)
|
||||||
if to_field and not model_admin.to_field_allowed(request, to_field):
|
if to_field and not model_admin.to_field_allowed(request, to_field):
|
||||||
raise DisallowedModelAdminToField(
|
raise DisallowedModelAdminToField(
|
||||||
@ -114,6 +127,8 @@ class ChangeList:
|
|||||||
del self.params[PAGE_VAR]
|
del self.params[PAGE_VAR]
|
||||||
if ERROR_FLAG in self.params:
|
if ERROR_FLAG in self.params:
|
||||||
del self.params[ERROR_FLAG]
|
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:
|
if self.is_popup:
|
||||||
self.list_editable = ()
|
self.list_editable = ()
|
||||||
@ -492,7 +507,7 @@ class ChangeList:
|
|||||||
ordering_fields[idx] = "desc" if pfx == "-" else "asc"
|
ordering_fields[idx] = "desc" if pfx == "-" else "asc"
|
||||||
return ordering_fields
|
return ordering_fields
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request, exclude_parameters=None):
|
||||||
# First, we collect all the declared list filters.
|
# First, we collect all the declared list filters.
|
||||||
(
|
(
|
||||||
self.filter_specs,
|
self.filter_specs,
|
||||||
@ -504,9 +519,13 @@ class ChangeList:
|
|||||||
# Then, we let every list filter modify the queryset to its liking.
|
# Then, we let every list filter modify the queryset to its liking.
|
||||||
qs = self.root_queryset
|
qs = self.root_queryset
|
||||||
for filter_spec in self.filter_specs:
|
for filter_spec in self.filter_specs:
|
||||||
new_qs = filter_spec.queryset(request, qs)
|
if (
|
||||||
if new_qs is not None:
|
exclude_parameters is None
|
||||||
qs = new_qs
|
or filter_spec.expected_parameters() != exclude_parameters
|
||||||
|
):
|
||||||
|
new_qs = filter_spec.queryset(request, qs)
|
||||||
|
if new_qs is not None:
|
||||||
|
qs = new_qs
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Finally, we apply the remaining lookup parameters from the query
|
# 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
|
See the default template provided by Django (``admin/filter.html``) for a
|
||||||
concrete example.
|
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
|
editing, or deleting an object. You can have filters cleared by setting
|
||||||
this attribute to ``False``.
|
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
|
.. attribute:: ModelAdmin.radio_fields
|
||||||
|
|
||||||
By default, Django's admin uses a select-box interface (<select>) for
|
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
|
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
|
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.tests import AdminSeleniumTestCase
|
||||||
from django.contrib.admin.views.main import (
|
from django.contrib.admin.views.main import (
|
||||||
ALL_VAR,
|
ALL_VAR,
|
||||||
|
IS_FACETS_VAR,
|
||||||
IS_POPUP_VAR,
|
IS_POPUP_VAR,
|
||||||
ORDER_VAR,
|
ORDER_VAR,
|
||||||
PAGE_VAR,
|
PAGE_VAR,
|
||||||
@ -1031,6 +1032,7 @@ class ChangeListTests(TestCase):
|
|||||||
{TO_FIELD_VAR: "id"},
|
{TO_FIELD_VAR: "id"},
|
||||||
{PAGE_VAR: "1"},
|
{PAGE_VAR: "1"},
|
||||||
{IS_POPUP_VAR: "1"},
|
{IS_POPUP_VAR: "1"},
|
||||||
|
{IS_FACETS_VAR: ""},
|
||||||
{"username__startswith": "test"},
|
{"username__startswith": "test"},
|
||||||
):
|
):
|
||||||
with self.subTest(data=data):
|
with self.subTest(data=data):
|
||||||
@ -1599,6 +1601,11 @@ class ChangeListTests(TestCase):
|
|||||||
for data, href in (
|
for data, href in (
|
||||||
({"is_staff__exact": "0"}, "?"),
|
({"is_staff__exact": "0"}, "?"),
|
||||||
({"is_staff__exact": "0", IS_POPUP_VAR: "1"}, f"?{IS_POPUP_VAR}=1"),
|
({"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):
|
with self.subTest(data=data):
|
||||||
response = self.client.get(url, 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).
|
# This field name is intentionally 2 characters long (#16080).
|
||||||
no = models.IntegerField(verbose_name="number", blank=True, null=True)
|
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):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
@ -12,11 +12,12 @@ from django.contrib.admin import (
|
|||||||
SimpleListFilter,
|
SimpleListFilter,
|
||||||
site,
|
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.admin import UserAdmin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
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
|
from .models import Book, Bookmark, Department, Employee, ImprovedBook, TaggedItem
|
||||||
|
|
||||||
@ -217,10 +218,28 @@ class BookAdminRelatedOnlyFilter(ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class DecadeFilterBookAdmin(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",)
|
ordering = ("-id",)
|
||||||
|
|
||||||
|
|
||||||
|
class DecadeFilterBookAdminWithAlwaysFacets(DecadeFilterBookAdmin):
|
||||||
|
show_facets = ShowFacets.ALWAYS
|
||||||
|
|
||||||
|
|
||||||
|
class DecadeFilterBookAdminDisallowFacets(DecadeFilterBookAdmin):
|
||||||
|
show_facets = ShowFacets.NEVER
|
||||||
|
|
||||||
|
|
||||||
class NotNinetiesListFilterAdmin(ModelAdmin):
|
class NotNinetiesListFilterAdmin(ModelAdmin):
|
||||||
list_filter = (NotNinetiesListFilter,)
|
list_filter = (NotNinetiesListFilter,)
|
||||||
|
|
||||||
@ -324,6 +343,7 @@ class ListFiltersTests(TestCase):
|
|||||||
is_best_seller=True,
|
is_best_seller=True,
|
||||||
date_registered=cls.today,
|
date_registered=cls.today,
|
||||||
availability=True,
|
availability=True,
|
||||||
|
category="non-fiction",
|
||||||
)
|
)
|
||||||
cls.bio_book = Book.objects.create(
|
cls.bio_book = Book.objects.create(
|
||||||
title="Django: a biography",
|
title="Django: a biography",
|
||||||
@ -332,6 +352,7 @@ class ListFiltersTests(TestCase):
|
|||||||
is_best_seller=False,
|
is_best_seller=False,
|
||||||
no=207,
|
no=207,
|
||||||
availability=False,
|
availability=False,
|
||||||
|
category="fiction",
|
||||||
)
|
)
|
||||||
cls.django_book = Book.objects.create(
|
cls.django_book = Book.objects.create(
|
||||||
title="The Django Book",
|
title="The Django Book",
|
||||||
@ -348,6 +369,7 @@ class ListFiltersTests(TestCase):
|
|||||||
is_best_seller=True,
|
is_best_seller=True,
|
||||||
date_registered=cls.one_week_ago,
|
date_registered=cls.one_week_ago,
|
||||||
availability=None,
|
availability=None,
|
||||||
|
category="",
|
||||||
)
|
)
|
||||||
cls.guitar_book.contributors.set([cls.bob, cls.lisa])
|
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.john = Employee.objects.create(name="John Blue", department=cls.dev)
|
||||||
cls.jack = Employee.objects.create(name="Jack Red", department=cls.design)
|
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):
|
def test_choicesfieldlistfilter_has_none_choice(self):
|
||||||
"""
|
"""
|
||||||
The last choice is for the None value.
|
The last choice is for the None value.
|
||||||
@ -1315,6 +1341,185 @@ class ListFiltersTests(TestCase):
|
|||||||
self.assertIs(choices[2]["selected"], False)
|
self.assertIs(choices[2]["selected"], False)
|
||||||
self.assertEqual(choices[2]["query_string"], "?publication-decade=the+00s")
|
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):
|
def test_two_characters_long_field(self):
|
||||||
"""
|
"""
|
||||||
list_filter works with two-characters long field names (#16080).
|
list_filter works with two-characters long field names (#16080).
|
||||||
@ -1698,3 +1903,10 @@ class ListFiltersTests(TestCase):
|
|||||||
# Make sure the correct queryset is returned
|
# Make sure the correct queryset is returned
|
||||||
queryset = changelist.get_queryset(request)
|
queryset = changelist.get_queryset(request)
|
||||||
self.assertEqual(list(queryset), [jane])
|
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):
|
class WorkHourAdmin(admin.ModelAdmin):
|
||||||
list_display = ("datum", "employee")
|
list_display = ("datum", "employee")
|
||||||
list_filter = ("employee",)
|
list_filter = ("employee",)
|
||||||
|
show_facets = admin.ShowFacets.ALWAYS
|
||||||
|
|
||||||
|
|
||||||
class FoodDeliveryAdmin(admin.ModelAdmin):
|
class FoodDeliveryAdmin(admin.ModelAdmin):
|
||||||
list_display = ("reference", "driver", "restaurant")
|
list_display = ("reference", "driver", "restaurant")
|
||||||
list_editable = ("driver", "restaurant")
|
list_editable = ("driver", "restaurant")
|
||||||
|
show_facets = admin.ShowFacets.NEVER
|
||||||
|
|
||||||
|
|
||||||
class CoverLetterAdmin(admin.ModelAdmin):
|
class CoverLetterAdmin(admin.ModelAdmin):
|
||||||
|
@ -807,6 +807,47 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
|
|||||||
msg_prefix="Changelist filter not correctly limited by limit_choices_to",
|
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):
|
def test_relation_spanning_filters(self):
|
||||||
changelist_url = reverse("admin:admin_views_chapterxtra1_changelist")
|
changelist_url = reverse("admin:admin_views_chapterxtra1_changelist")
|
||||||
response = self.client.get(changelist_url)
|
response = self.client.get(changelist_url)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user