django-admin-select-filter 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_admin_select_filter/__init__.py +19 -0
- django_admin_select_filter/_typecheck_settings.py +15 -0
- django_admin_select_filter/apps.py +8 -0
- django_admin_select_filter/filters.py +603 -0
- django_admin_select_filter/py.typed +0 -0
- django_admin_select_filter/static/admin_select_filter/css/admin_select2_filter.css +68 -0
- django_admin_select_filter/static/admin_select_filter/js/admin_select2_filter.js +97 -0
- django_admin_select_filter/templates/admin_select_filter/filters/base.html +45 -0
- django_admin_select_filter/templates/admin_select_filter/filters/choice_filter.html +1 -0
- django_admin_select_filter/templates/admin_select_filter/filters/foreign_key_filter.html +1 -0
- django_admin_select_filter/urls.py +48 -0
- django_admin_select_filter/views.py +219 -0
- django_admin_select_filter-0.1.0.dist-info/METADATA +266 -0
- django_admin_select_filter-0.1.0.dist-info/RECORD +16 -0
- django_admin_select_filter-0.1.0.dist-info/WHEEL +4 -0
- django_admin_select_filter-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from django_admin_select_filter.filters import (
|
|
2
|
+
BaseSelectFilter,
|
|
3
|
+
ChoiceFilter,
|
|
4
|
+
ForeignKeyFilter,
|
|
5
|
+
field_list_filter,
|
|
6
|
+
)
|
|
7
|
+
from django_admin_select_filter.urls import django_admin_select_filter_path
|
|
8
|
+
from django_admin_select_filter.views import Select2FilterOptionsView
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BaseSelectFilter",
|
|
14
|
+
"ChoiceFilter",
|
|
15
|
+
"ForeignKeyFilter",
|
|
16
|
+
"Select2FilterOptionsView",
|
|
17
|
+
"django_admin_select_filter_path",
|
|
18
|
+
"field_list_filter",
|
|
19
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Minimal Django settings so django-stubs' mypy plugin can boot standalone.
|
|
2
|
+
|
|
3
|
+
Not used at runtime; only ``tool.django-stubs.django_settings_module`` points
|
|
4
|
+
here. Kept to only stdlib Django apps so the mypy pre-commit hook (which
|
|
5
|
+
type-checks this package in isolation, without installing it) can import it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
SECRET_KEY = "mypy"
|
|
9
|
+
USE_TZ = True
|
|
10
|
+
DATABASES: dict[str, dict[str, str]] = {}
|
|
11
|
+
INSTALLED_APPS = [
|
|
12
|
+
"django.contrib.admin",
|
|
13
|
+
"django.contrib.auth",
|
|
14
|
+
"django.contrib.contenttypes",
|
|
15
|
+
]
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from django.contrib.admin import ModelAdmin
|
|
8
|
+
from django.contrib.admin.filters import SimpleListFilter
|
|
9
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
10
|
+
from django.db import models
|
|
11
|
+
from django.http import HttpRequest
|
|
12
|
+
from django.urls import reverse
|
|
13
|
+
from django.utils.functional import cached_property
|
|
14
|
+
from django.utils.translation import gettext
|
|
15
|
+
|
|
16
|
+
# The default ``name`` django_admin_select_filter_path() registers its route
|
|
17
|
+
# under. Imported from there (rather than declared alongside it) so filters
|
|
18
|
+
# and urls.py share one source of truth without a circular import — urls.py
|
|
19
|
+
# already depends on views.py, which depends on this module.
|
|
20
|
+
ASYNC_CALL_URL_NAME = "options"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseSelectFilter(SimpleListFilter):
|
|
24
|
+
"""Shared plumbing for the project's Select2-powered admin list filters.
|
|
25
|
+
|
|
26
|
+
Handles everything that doesn't depend on where the options come from:
|
|
27
|
+
null-value support, facet counts, queryset filtering by the raw lookup
|
|
28
|
+
value, and the Select2 template's ``choices()`` rendering. Subclasses
|
|
29
|
+
only need to supply the available options (see ``get_options``,
|
|
30
|
+
``get_async_options`` and ``lookups`` on :class:`ForeignKeyFilter` and
|
|
31
|
+
:class:`ChoiceFilter` for the two supported shapes).
|
|
32
|
+
|
|
33
|
+
``parameter_name``
|
|
34
|
+
Lookup applied to the changelist queryset, such as ``"group"`` or
|
|
35
|
+
``"groups"``. Required.
|
|
36
|
+
``filter_only_used_values``
|
|
37
|
+
When true, only expose relations already present in the current
|
|
38
|
+
ModelAdmin queryset. It defaults to true.
|
|
39
|
+
``async_call``
|
|
40
|
+
When true, defer loading options until Select2 requests them through
|
|
41
|
+
the API. It defaults to false.
|
|
42
|
+
``searchable``
|
|
43
|
+
When false, hide Select2's search input and behave like a plain
|
|
44
|
+
dropdown. Best for a short, static option list. It defaults to true.
|
|
45
|
+
``nullable``
|
|
46
|
+
Controls whether the ``-`` option is available. When left as ``None``,
|
|
47
|
+
the value is inferred from the configured model field.
|
|
48
|
+
``title``
|
|
49
|
+
Label shown by Django above the filter. By default it uses the related
|
|
50
|
+
model admin's plural verbose name.
|
|
51
|
+
``multiple``
|
|
52
|
+
When true, the Select2 widget accepts several values at once and
|
|
53
|
+
``queryset()`` filters with an ``__in`` lookup. Selected values are
|
|
54
|
+
stored in the query string joined by ``multiple_separator``, so
|
|
55
|
+
configured values (primary keys, option values) must not contain it.
|
|
56
|
+
It defaults to false.
|
|
57
|
+
``multiple_separator``
|
|
58
|
+
Character joining multiple selected values in the query string. It
|
|
59
|
+
defaults to ``","``.
|
|
60
|
+
``async_call_url``
|
|
61
|
+
URL the Select2 widget's JS fetches options from when ``async_call``
|
|
62
|
+
is true. Left as ``None`` (the default), it resolves to
|
|
63
|
+
``reverse(f"admin_select_filter:{ASYNC_CALL_URL_NAME}")`` — the
|
|
64
|
+
shared endpoint registered via ``django_admin_select_filter_path()``.
|
|
65
|
+
``reverse()`` is what makes this work correctly regardless of the
|
|
66
|
+
admin page the filter is rendered on and wherever that route is
|
|
67
|
+
actually mounted — a bare path segment like ``"options/"`` would
|
|
68
|
+
resolve relative to the *current page*, not that mount point, and
|
|
69
|
+
silently hit the wrong URL. If you pass a custom ``name=`` to
|
|
70
|
+
``django_admin_select_filter_path()``, set ``async_call_url``
|
|
71
|
+
explicitly on every ``async_call`` filter to match — there's no way
|
|
72
|
+
for a filter to otherwise discover which name was used for the
|
|
73
|
+
specific route it should call. Also set it explicitly to point a
|
|
74
|
+
filter at a genuinely different, custom view.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
filter_only_used_values: ClassVar[bool] = True
|
|
78
|
+
async_call: ClassVar[bool] = False
|
|
79
|
+
searchable: ClassVar[bool] = True
|
|
80
|
+
nullable: bool | None = None
|
|
81
|
+
all_value: ClassVar[str] = "__all__"
|
|
82
|
+
null_value: ClassVar[str] = "__null__"
|
|
83
|
+
multiple: ClassVar[bool] = False
|
|
84
|
+
multiple_separator: ClassVar[str] = ","
|
|
85
|
+
async_call_url: str | None = None
|
|
86
|
+
title: Any | None = None
|
|
87
|
+
parameter_name: str | None = None
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
request: HttpRequest,
|
|
92
|
+
params: dict[str, Any],
|
|
93
|
+
admin_model: type[models.Model],
|
|
94
|
+
model_admin: ModelAdmin[Any],
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Configure the filter for an admin changelist request."""
|
|
97
|
+
if self.nullable is None:
|
|
98
|
+
self.nullable = self._is_nullable(admin_model)
|
|
99
|
+
self.request = request
|
|
100
|
+
self.model_admin = model_admin
|
|
101
|
+
self.admin_app_label = admin_model._meta.app_label
|
|
102
|
+
self.admin_model_name = admin_model._meta.model_name
|
|
103
|
+
self.has_null_option = (
|
|
104
|
+
not self.async_call or not self.filter_only_used_values
|
|
105
|
+
) and self._has_null_option()
|
|
106
|
+
self.facets = self.request.GET.get("_facets") == "True"
|
|
107
|
+
if self.async_call and self.async_call_url is None:
|
|
108
|
+
self.async_call_url = reverse(f"admin_select_filter:{ASYNC_CALL_URL_NAME}")
|
|
109
|
+
|
|
110
|
+
super().__init__(request, params, admin_model, model_admin)
|
|
111
|
+
|
|
112
|
+
@cached_property
|
|
113
|
+
def model_admin_queryset(self) -> models.QuerySet[Any]:
|
|
114
|
+
"""Return and cache the source ModelAdmin queryset when first needed."""
|
|
115
|
+
return self.model_admin.get_queryset(self.request)
|
|
116
|
+
|
|
117
|
+
def _is_nullable(self, admin_model: type[models.Model]) -> bool:
|
|
118
|
+
"""Determine whether the configured field accepts null values."""
|
|
119
|
+
for field in admin_model._meta.get_fields():
|
|
120
|
+
if self.parameter_name in (field.name, getattr(field, "attname", None)):
|
|
121
|
+
return bool(getattr(field, "null", False))
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
def _has_null_option(self) -> bool:
|
|
125
|
+
"""Return whether the filter should expose its null-value option."""
|
|
126
|
+
if not self.nullable or self.parameter_name is None:
|
|
127
|
+
return False
|
|
128
|
+
if not self.filter_only_used_values:
|
|
129
|
+
return True
|
|
130
|
+
return self.model_admin_queryset.filter(
|
|
131
|
+
**{f"{self.parameter_name}__isnull": True}
|
|
132
|
+
).exists()
|
|
133
|
+
|
|
134
|
+
def _resolve_field(self, admin_model: type[models.Model]) -> Any | None:
|
|
135
|
+
"""Walk ``parameter_name`` across relations and return its final field.
|
|
136
|
+
|
|
137
|
+
Handles a nested lookup, such as ``"publisher__country"``, by
|
|
138
|
+
following each ``__``-separated segment's relation in turn — forward
|
|
139
|
+
or reverse — except the last, whose field is returned as-is. Returns
|
|
140
|
+
``None`` when ``parameter_name`` is unset or any segment doesn't
|
|
141
|
+
resolve to a real field.
|
|
142
|
+
"""
|
|
143
|
+
if self.parameter_name is None:
|
|
144
|
+
return None
|
|
145
|
+
current_model: type[models.Model] = admin_model
|
|
146
|
+
parts = self.parameter_name.split("__")
|
|
147
|
+
for part in parts[:-1]:
|
|
148
|
+
try:
|
|
149
|
+
field = current_model._meta.get_field(part)
|
|
150
|
+
except FieldDoesNotExist:
|
|
151
|
+
return None
|
|
152
|
+
related_model = getattr(field, "related_model", None)
|
|
153
|
+
if related_model is None:
|
|
154
|
+
return None
|
|
155
|
+
current_model = related_model
|
|
156
|
+
try:
|
|
157
|
+
return current_model._meta.get_field(parts[-1])
|
|
158
|
+
except FieldDoesNotExist:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def _get_option_facet_counts(self) -> dict[str, int]:
|
|
162
|
+
"""Count options for either filter presentation.
|
|
163
|
+
|
|
164
|
+
Asynchronous filters use these counts in their API results. Synchronous
|
|
165
|
+
filters may reuse the helper, although Django's standard list-filter
|
|
166
|
+
rendering already calculates its own facet counts.
|
|
167
|
+
"""
|
|
168
|
+
if self.parameter_name is None:
|
|
169
|
+
return {}
|
|
170
|
+
counts = (
|
|
171
|
+
self.model_admin_queryset.order_by()
|
|
172
|
+
.values(self.parameter_name)
|
|
173
|
+
.annotate(count=models.Count("pk"))
|
|
174
|
+
)
|
|
175
|
+
return {
|
|
176
|
+
self.null_value if value is None else str(value): count
|
|
177
|
+
for value, count in counts.values_list(self.parameter_name, "count")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def _build_async_options(
|
|
181
|
+
self,
|
|
182
|
+
request: HttpRequest,
|
|
183
|
+
items: list[tuple[str, str]],
|
|
184
|
+
) -> list[tuple[str, str]]:
|
|
185
|
+
"""Assemble the "All"/items/null options, applying facet counts if asked.
|
|
186
|
+
|
|
187
|
+
The "All" option is skipped when ``multiple`` is enabled: clearing
|
|
188
|
+
every selected value already means "no filter" for a multi-select.
|
|
189
|
+
"""
|
|
190
|
+
options = (
|
|
191
|
+
list(items) if self.multiple else [(self.all_value, gettext("All")), *items]
|
|
192
|
+
)
|
|
193
|
+
if self._has_null_option():
|
|
194
|
+
options.append((self.null_value, "-"))
|
|
195
|
+
if request.GET.get("facets") == "true":
|
|
196
|
+
facet_counts = self._get_option_facet_counts()
|
|
197
|
+
options = [
|
|
198
|
+
(
|
|
199
|
+
value,
|
|
200
|
+
label
|
|
201
|
+
if value == self.all_value
|
|
202
|
+
else f"{label} ({facet_counts.get(value, 0)})",
|
|
203
|
+
)
|
|
204
|
+
for value, label in options
|
|
205
|
+
]
|
|
206
|
+
return options
|
|
207
|
+
|
|
208
|
+
def has_output(self) -> bool:
|
|
209
|
+
"""Keep asynchronous filters visible before their options are loaded."""
|
|
210
|
+
return self.async_call or super().has_output()
|
|
211
|
+
|
|
212
|
+
def selected_values(self) -> list[str]:
|
|
213
|
+
"""Return the selected raw values, splitting on ``multiple_separator``
|
|
214
|
+
when ``multiple`` is enabled. Empty when nothing is selected."""
|
|
215
|
+
value = self.value()
|
|
216
|
+
if not value:
|
|
217
|
+
return []
|
|
218
|
+
if not self.multiple:
|
|
219
|
+
return [value]
|
|
220
|
+
return [item for item in value.split(self.multiple_separator) if item]
|
|
221
|
+
|
|
222
|
+
def queryset(
|
|
223
|
+
self,
|
|
224
|
+
request: HttpRequest,
|
|
225
|
+
queryset: models.QuerySet[Any],
|
|
226
|
+
) -> models.QuerySet[Any]:
|
|
227
|
+
"""Apply the selected value(s) or the null lookup to the changelist queryset."""
|
|
228
|
+
if self.parameter_name is None:
|
|
229
|
+
return queryset
|
|
230
|
+
values = self.selected_values()
|
|
231
|
+
if not values:
|
|
232
|
+
return queryset
|
|
233
|
+
if not self.multiple:
|
|
234
|
+
value = values[0]
|
|
235
|
+
if value == self.null_value:
|
|
236
|
+
return queryset.filter(**{f"{self.parameter_name}__isnull": True})
|
|
237
|
+
return queryset.filter(**{self.parameter_name: value})
|
|
238
|
+
real_values = [value for value in values if value != self.null_value]
|
|
239
|
+
condition = models.Q()
|
|
240
|
+
if real_values:
|
|
241
|
+
condition |= models.Q(**{f"{self.parameter_name}__in": real_values})
|
|
242
|
+
if self.null_value in values:
|
|
243
|
+
condition |= models.Q(**{f"{self.parameter_name}__isnull": True})
|
|
244
|
+
return queryset.filter(condition)
|
|
245
|
+
|
|
246
|
+
def choices(self, changelist: Any) -> Iterator[dict[str, Any]]: # type: ignore[override]
|
|
247
|
+
"""Yield Django choices with the raw lookup key required by Select2.
|
|
248
|
+
|
|
249
|
+
``selected`` is recomputed against :meth:`selected_values` rather than
|
|
250
|
+
Django's own single-value comparison, so it stays correct when
|
|
251
|
+
``multiple`` is enabled and several values are selected at once.
|
|
252
|
+
"""
|
|
253
|
+
selected_values = self.selected_values()
|
|
254
|
+
for index, choice in enumerate(super().choices(changelist)):
|
|
255
|
+
key = "" if index == 0 else str(self.lookup_choices[index - 1][0])
|
|
256
|
+
selected = not selected_values if index == 0 else key in selected_values
|
|
257
|
+
yield {**choice, "key": key, "selected": selected}
|
|
258
|
+
|
|
259
|
+
def get_async_options(
|
|
260
|
+
self,
|
|
261
|
+
request: HttpRequest,
|
|
262
|
+
q: str,
|
|
263
|
+
) -> list[tuple[str, str]]:
|
|
264
|
+
"""Return Select2 options for an API request and its search term."""
|
|
265
|
+
raise NotImplementedError
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class ForeignKeyFilter(BaseSelectFilter):
|
|
269
|
+
"""Filter an admin changelist by selectable related-model instances.
|
|
270
|
+
|
|
271
|
+
Subclasses configure the filter through these class attributes:
|
|
272
|
+
|
|
273
|
+
``model``
|
|
274
|
+
Related model whose instances become the available options. When left
|
|
275
|
+
unset, it's inferred by walking ``parameter_name`` across the admin
|
|
276
|
+
model's relations — including a nested lookup such as
|
|
277
|
+
``"publisher__country"`` — and taking the last relation's target
|
|
278
|
+
model. Configure it explicitly when it can't be inferred (the lookup
|
|
279
|
+
isn't a real relation chain) or to point elsewhere.
|
|
280
|
+
``ordering``
|
|
281
|
+
Field names used to order the related instances.
|
|
282
|
+
``only``
|
|
283
|
+
Related-model fields loaded by the options queryset. Include every
|
|
284
|
+
field needed by the model's string representation.
|
|
285
|
+
|
|
286
|
+
``all_value`` and ``null_value`` are reserved values used by the async API
|
|
287
|
+
for the translated “All” option and the null option, respectively. They can
|
|
288
|
+
be overridden if those values conflict with valid primary keys.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
template = "admin_select_filter/filters/foreign_key_filter.html"
|
|
292
|
+
model: type[models.Model] | None = None
|
|
293
|
+
ordering: ClassVar[list[str]] = []
|
|
294
|
+
only: ClassVar[list[str]] = []
|
|
295
|
+
|
|
296
|
+
def __init__(
|
|
297
|
+
self,
|
|
298
|
+
request: HttpRequest,
|
|
299
|
+
params: dict[str, Any],
|
|
300
|
+
admin_model: type[models.Model],
|
|
301
|
+
model_admin: ModelAdmin[Any],
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Configure the filter for an admin changelist request."""
|
|
304
|
+
if self.model is None:
|
|
305
|
+
self.model = self._resolve_model(admin_model)
|
|
306
|
+
if self.model is None:
|
|
307
|
+
raise TypeError(
|
|
308
|
+
f"{type(self).__name__}.model must be configured, or "
|
|
309
|
+
f"{self.parameter_name!r} must resolve to a related model"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if self.title is None:
|
|
313
|
+
registered_admin = model_admin.admin_site._registry.get(self.model)
|
|
314
|
+
title_model = (
|
|
315
|
+
registered_admin.model if registered_admin is not None else self.model
|
|
316
|
+
)
|
|
317
|
+
self.title = title_model._meta.verbose_name_plural
|
|
318
|
+
super().__init__(request, params, admin_model, model_admin)
|
|
319
|
+
|
|
320
|
+
def _resolve_model(
|
|
321
|
+
self, admin_model: type[models.Model]
|
|
322
|
+
) -> type[models.Model] | None:
|
|
323
|
+
"""Return ``parameter_name``'s target model, following relations.
|
|
324
|
+
|
|
325
|
+
Handles a nested lookup, such as ``"publisher__country"``, since it
|
|
326
|
+
relies on :meth:`BaseSelectFilter._resolve_field` to walk each
|
|
327
|
+
``__``-separated segment's relation in turn — forward or reverse.
|
|
328
|
+
"""
|
|
329
|
+
field = self._resolve_field(admin_model)
|
|
330
|
+
if field is None:
|
|
331
|
+
return None
|
|
332
|
+
return getattr(field, "related_model", None)
|
|
333
|
+
|
|
334
|
+
def get_options(
|
|
335
|
+
self,
|
|
336
|
+
request: HttpRequest | None = None,
|
|
337
|
+
q: str = "",
|
|
338
|
+
) -> models.QuerySet[Any]:
|
|
339
|
+
"""Return allowed related instances, optionally searched with ``q``.
|
|
340
|
+
|
|
341
|
+
The supplied request belongs to the API call. When it is omitted, the
|
|
342
|
+
original admin changelist request stored on the filter is used.
|
|
343
|
+
"""
|
|
344
|
+
assert self.model is not None
|
|
345
|
+
queryset = self.model._default_manager.all()
|
|
346
|
+
if self.filter_only_used_values and self.parameter_name is not None:
|
|
347
|
+
used_values = self.model_admin_queryset.values_list(
|
|
348
|
+
self.parameter_name, flat=True
|
|
349
|
+
)
|
|
350
|
+
queryset = queryset.filter(pk__in=used_values)
|
|
351
|
+
if self.only:
|
|
352
|
+
queryset = queryset.only(*self.only)
|
|
353
|
+
if self.ordering:
|
|
354
|
+
queryset = queryset.order_by(*self.ordering)
|
|
355
|
+
related_admin = self.model_admin.admin_site._registry.get(self.model)
|
|
356
|
+
if q and related_admin is not None:
|
|
357
|
+
queryset, may_have_duplicates = related_admin.get_search_results(
|
|
358
|
+
request or self.request,
|
|
359
|
+
queryset,
|
|
360
|
+
q,
|
|
361
|
+
)
|
|
362
|
+
if may_have_duplicates:
|
|
363
|
+
queryset = queryset.distinct()
|
|
364
|
+
return queryset
|
|
365
|
+
|
|
366
|
+
def get_async_options(
|
|
367
|
+
self,
|
|
368
|
+
request: HttpRequest,
|
|
369
|
+
q: str,
|
|
370
|
+
) -> list[tuple[str, str]]:
|
|
371
|
+
"""Return Select2 options for an API request and its search term.
|
|
372
|
+
|
|
373
|
+
``self.request`` is the admin changelist request used to construct the
|
|
374
|
+
filter. ``request`` is the separate API-view request that asks for the
|
|
375
|
+
asynchronous options, and ``q`` is the search term sent by that view.
|
|
376
|
+
:param request: The API-view request asking for asynchronous options.
|
|
377
|
+
:param q: The search term sent by the API-view request.
|
|
378
|
+
:return: A list of tuples representing the Select2 options.
|
|
379
|
+
"""
|
|
380
|
+
items = [
|
|
381
|
+
(str(instance.pk), str(instance))
|
|
382
|
+
for instance in self.get_options(request=request, q=q)
|
|
383
|
+
]
|
|
384
|
+
return self._build_async_options(request, items)
|
|
385
|
+
|
|
386
|
+
def lookups(
|
|
387
|
+
self,
|
|
388
|
+
request: HttpRequest,
|
|
389
|
+
model_admin: ModelAdmin[Any],
|
|
390
|
+
) -> list[tuple[str, str]]:
|
|
391
|
+
"""Return choices rendered initially by Django's list-filter template."""
|
|
392
|
+
assert self.model is not None
|
|
393
|
+
if self.async_call:
|
|
394
|
+
selected_pks = [
|
|
395
|
+
value for value in self.selected_values() if value != self.null_value
|
|
396
|
+
]
|
|
397
|
+
selected_instances = (
|
|
398
|
+
self.model._default_manager.filter(pk__in=selected_pks)
|
|
399
|
+
if selected_pks
|
|
400
|
+
else self.model._default_manager.none()
|
|
401
|
+
)
|
|
402
|
+
options = [
|
|
403
|
+
(str(instance.pk), str(instance)) for instance in selected_instances
|
|
404
|
+
]
|
|
405
|
+
else:
|
|
406
|
+
options = [
|
|
407
|
+
(str(instance.pk), str(instance)) for instance in self.get_options()
|
|
408
|
+
]
|
|
409
|
+
if self.has_null_option:
|
|
410
|
+
options.append((self.null_value, "-"))
|
|
411
|
+
return options
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class ChoiceFilter(BaseSelectFilter):
|
|
415
|
+
"""Filter an admin changelist by scalar values, not tied to a relation.
|
|
416
|
+
|
|
417
|
+
Unlike :class:`ForeignKeyFilter`, options aren't related-model instances —
|
|
418
|
+
they're plain ``(value, label)`` pairs, which makes this usable for any
|
|
419
|
+
field with discrete values: a ``ChoiceField``-backed ``CharField`` or
|
|
420
|
+
``IntegerField``, a ``BooleanField``, or any other lookup for which you
|
|
421
|
+
can supply an explicit option list.
|
|
422
|
+
|
|
423
|
+
Subclasses configure the filter through these class attributes:
|
|
424
|
+
|
|
425
|
+
``options``
|
|
426
|
+
Explicit ``(value, label)`` pairs to offer. When left unset, options
|
|
427
|
+
are read from the configured field's ``choices`` (e.g. a
|
|
428
|
+
``ChoiceField``); configuring this is required if the field has none.
|
|
429
|
+
|
|
430
|
+
``all_value`` and ``null_value`` are reserved values used by the async API
|
|
431
|
+
for the translated “All” option and the null option, respectively. They can
|
|
432
|
+
be overridden if those values conflict with valid option values.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
template = "admin_select_filter/filters/choice_filter.html"
|
|
436
|
+
options: list[tuple[Any, str]] | None = None
|
|
437
|
+
|
|
438
|
+
def __init__(
|
|
439
|
+
self,
|
|
440
|
+
request: HttpRequest,
|
|
441
|
+
params: dict[str, Any],
|
|
442
|
+
admin_model: type[models.Model],
|
|
443
|
+
model_admin: ModelAdmin[Any],
|
|
444
|
+
) -> None:
|
|
445
|
+
"""Configure the filter for an admin changelist request."""
|
|
446
|
+
if self.parameter_name is None:
|
|
447
|
+
raise TypeError(f"{type(self).__name__}.parameter_name must be configured")
|
|
448
|
+
|
|
449
|
+
field = self._resolve_field(admin_model)
|
|
450
|
+
if self.options is None:
|
|
451
|
+
field_choices = getattr(field, "choices", None) if field else None
|
|
452
|
+
if not field_choices:
|
|
453
|
+
raise TypeError(
|
|
454
|
+
f"{type(self).__name__}.options must be configured, or "
|
|
455
|
+
f"{self.parameter_name!r} must name a field with choices"
|
|
456
|
+
)
|
|
457
|
+
self.options = [(value, str(label)) for value, label in field_choices]
|
|
458
|
+
if self.title is None:
|
|
459
|
+
self.title = (
|
|
460
|
+
field.verbose_name if field is not None else self.parameter_name
|
|
461
|
+
)
|
|
462
|
+
super().__init__(request, params, admin_model, model_admin)
|
|
463
|
+
|
|
464
|
+
def get_options(
|
|
465
|
+
self,
|
|
466
|
+
request: HttpRequest | None = None,
|
|
467
|
+
q: str = "",
|
|
468
|
+
) -> list[tuple[Any, str]]:
|
|
469
|
+
"""Return allowed options, optionally searched with ``q``.
|
|
470
|
+
|
|
471
|
+
The supplied request is accepted for parity with
|
|
472
|
+
:meth:`ForeignKeyFilter.get_options` but isn't needed here, since
|
|
473
|
+
matching happens in Python against the configured ``options``.
|
|
474
|
+
"""
|
|
475
|
+
assert self.options is not None
|
|
476
|
+
options = self.options
|
|
477
|
+
if self.filter_only_used_values and self.parameter_name is not None:
|
|
478
|
+
used_values = set(
|
|
479
|
+
self.model_admin_queryset.exclude(
|
|
480
|
+
**{f"{self.parameter_name}__isnull": True}
|
|
481
|
+
).values_list(self.parameter_name, flat=True)
|
|
482
|
+
)
|
|
483
|
+
options = [
|
|
484
|
+
(value, label) for value, label in options if value in used_values
|
|
485
|
+
]
|
|
486
|
+
if q:
|
|
487
|
+
q_lower = q.lower()
|
|
488
|
+
options = [
|
|
489
|
+
(value, label) for value, label in options if q_lower in label.lower()
|
|
490
|
+
]
|
|
491
|
+
return options
|
|
492
|
+
|
|
493
|
+
def get_async_options(
|
|
494
|
+
self,
|
|
495
|
+
request: HttpRequest,
|
|
496
|
+
q: str,
|
|
497
|
+
) -> list[tuple[str, str]]:
|
|
498
|
+
"""Return Select2 options for an API request and its search term."""
|
|
499
|
+
items = [
|
|
500
|
+
(str(value), label)
|
|
501
|
+
for value, label in self.get_options(request=request, q=q)
|
|
502
|
+
]
|
|
503
|
+
return self._build_async_options(request, items)
|
|
504
|
+
|
|
505
|
+
def lookups(
|
|
506
|
+
self,
|
|
507
|
+
request: HttpRequest,
|
|
508
|
+
model_admin: ModelAdmin[Any],
|
|
509
|
+
) -> list[tuple[str, str]]:
|
|
510
|
+
"""Return choices rendered initially by Django's list-filter template."""
|
|
511
|
+
assert self.options is not None
|
|
512
|
+
options: list[tuple[str, str]]
|
|
513
|
+
if self.async_call:
|
|
514
|
+
selected_values = [
|
|
515
|
+
value for value in self.selected_values() if value != self.null_value
|
|
516
|
+
]
|
|
517
|
+
option_by_key = {str(value): label for value, label in self.options}
|
|
518
|
+
options = [
|
|
519
|
+
(value, option_by_key[value])
|
|
520
|
+
for value in selected_values
|
|
521
|
+
if value in option_by_key
|
|
522
|
+
]
|
|
523
|
+
else:
|
|
524
|
+
options = [(str(value), label) for value, label in self.get_options()]
|
|
525
|
+
if self.has_null_option:
|
|
526
|
+
options.append((self.null_value, "-"))
|
|
527
|
+
return options
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@functools.cache
|
|
531
|
+
def _bind_parameter_name(
|
|
532
|
+
select_filter_class: type[BaseSelectFilter], parameter_name: str
|
|
533
|
+
) -> type[BaseSelectFilter]:
|
|
534
|
+
"""Return (and cache) a ``select_filter_class`` subclass bound to ``parameter_name``.
|
|
535
|
+
|
|
536
|
+
Cached so repeated ``field_list_filter()`` calls for the same
|
|
537
|
+
``(class, field)`` pair across requests reuse one dynamic subclass
|
|
538
|
+
instead of creating a new one every time.
|
|
539
|
+
"""
|
|
540
|
+
return type(
|
|
541
|
+
f"{select_filter_class.__name__}[{parameter_name}]",
|
|
542
|
+
(select_filter_class,),
|
|
543
|
+
{"parameter_name": parameter_name},
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def field_list_filter(
|
|
548
|
+
select_filter_class: type[BaseSelectFilter],
|
|
549
|
+
) -> Any:
|
|
550
|
+
"""Adapt ``select_filter_class`` for ``list_filter``'s ``(field_name,
|
|
551
|
+
filter_class)`` tuple shorthand — Django's built-in way to reuse one
|
|
552
|
+
filter class across several fields without a dedicated ``parameter_name``
|
|
553
|
+
subclass per field::
|
|
554
|
+
|
|
555
|
+
list_filter = [
|
|
556
|
+
("author", field_list_filter(ForeignKeyFilter)),
|
|
557
|
+
("genre", field_list_filter(ChoiceFilter)),
|
|
558
|
+
]
|
|
559
|
+
|
|
560
|
+
Plain classes (``AuthorFilter`` set with its own ``parameter_name = "author"``)
|
|
561
|
+
still work as before and can be mixed freely with this form.
|
|
562
|
+
|
|
563
|
+
This can't return ``select_filter_class`` itself: for a tuple entry,
|
|
564
|
+
Django calls the second element as
|
|
565
|
+
``filter_class(field, request, params, model, model_admin, field_path=...)``
|
|
566
|
+
— a different signature from :class:`BaseSelectFilter`'s
|
|
567
|
+
``(request, params, model, model_admin)`` (Django's own
|
|
568
|
+
``FieldListFilter`` protocol, which ``BaseSelectFilter`` doesn't
|
|
569
|
+
implement). This wraps that call instead, deriving ``parameter_name``
|
|
570
|
+
from ``field_path`` and constructing ``select_filter_class`` normally —
|
|
571
|
+
it works because Django only actually requires the returned object to
|
|
572
|
+
support ``choices()``, ``has_output()`` and the other
|
|
573
|
+
:class:`~django.contrib.admin.filters.SimpleListFilter` methods that
|
|
574
|
+
:class:`BaseSelectFilter` already provides, not a real ``FieldListFilter``
|
|
575
|
+
subclass.
|
|
576
|
+
|
|
577
|
+
Only supports ``async_call = False``. ``Select2FilterOptionsView`` finds
|
|
578
|
+
a matching *async* filter by looking for a ``list_filter`` entry whose
|
|
579
|
+
own ``parameter_name`` equals the requested one — but this factory's
|
|
580
|
+
``parameter_name`` isn't fixed; it's only known once Django calls it for
|
|
581
|
+
a specific field, so there's no single value to match against ahead of
|
|
582
|
+
time. Use a dedicated subclass for an ``async_call`` filter instead.
|
|
583
|
+
"""
|
|
584
|
+
if select_filter_class.async_call:
|
|
585
|
+
raise TypeError(
|
|
586
|
+
f"field_list_filter() doesn't support async_call filters "
|
|
587
|
+
f"({select_filter_class.__name__}.async_call is True); use a "
|
|
588
|
+
"dedicated subclass instead."
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
def factory(
|
|
592
|
+
field: Any,
|
|
593
|
+
request: HttpRequest,
|
|
594
|
+
params: dict[str, Any],
|
|
595
|
+
model: type[models.Model],
|
|
596
|
+
model_admin: ModelAdmin[Any],
|
|
597
|
+
field_path: str | None = None,
|
|
598
|
+
) -> BaseSelectFilter:
|
|
599
|
+
parameter_name = field_path if field_path is not None else field.name
|
|
600
|
+
bound_class = _bind_parameter_name(select_filter_class, parameter_name) # type: ignore[arg-type]
|
|
601
|
+
return bound_class(request, params, model, model_admin)
|
|
602
|
+
|
|
603
|
+
return factory
|
|
File without changes
|