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.
@@ -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,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class AdminSelectFilterConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "django_admin_select_filter"
7
+ label = "admin_select_filter"
8
+ verbose_name = "Admin Select Filter"
@@ -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