django-admin-select-filter 0.1.0__tar.gz

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.
Files changed (46) hide show
  1. django_admin_select_filter-0.1.0/.github/workflows/js-tests.yml +24 -0
  2. django_admin_select_filter-0.1.0/.github/workflows/pytest.yml +31 -0
  3. django_admin_select_filter-0.1.0/.github/workflows/release.yml +61 -0
  4. django_admin_select_filter-0.1.0/.gitignore +18 -0
  5. django_admin_select_filter-0.1.0/.pre-commit-config.yaml +59 -0
  6. django_admin_select_filter-0.1.0/CHANGELOG.md +65 -0
  7. django_admin_select_filter-0.1.0/CONTRIBUTING.md +52 -0
  8. django_admin_select_filter-0.1.0/LICENSE +21 -0
  9. django_admin_select_filter-0.1.0/PKG-INFO +266 -0
  10. django_admin_select_filter-0.1.0/README.md +228 -0
  11. django_admin_select_filter-0.1.0/docs/screenshots/choice-filter.png +0 -0
  12. django_admin_select_filter-0.1.0/docs/screenshots/foreign-key-filter.png +0 -0
  13. django_admin_select_filter-0.1.0/docs/screenshots/hero.png +0 -0
  14. django_admin_select_filter-0.1.0/docs/screenshots/multiple.png +0 -0
  15. django_admin_select_filter-0.1.0/package-lock.json +2963 -0
  16. django_admin_select_filter-0.1.0/package.json +15 -0
  17. django_admin_select_filter-0.1.0/pyproject.toml +114 -0
  18. django_admin_select_filter-0.1.0/pytest.ini +6 -0
  19. django_admin_select_filter-0.1.0/src/django_admin_select_filter/__init__.py +19 -0
  20. django_admin_select_filter-0.1.0/src/django_admin_select_filter/_typecheck_settings.py +15 -0
  21. django_admin_select_filter-0.1.0/src/django_admin_select_filter/apps.py +8 -0
  22. django_admin_select_filter-0.1.0/src/django_admin_select_filter/filters.py +603 -0
  23. django_admin_select_filter-0.1.0/src/django_admin_select_filter/py.typed +0 -0
  24. django_admin_select_filter-0.1.0/src/django_admin_select_filter/static/admin_select_filter/css/admin_select2_filter.css +68 -0
  25. django_admin_select_filter-0.1.0/src/django_admin_select_filter/static/admin_select_filter/js/admin_select2_filter.js +97 -0
  26. django_admin_select_filter-0.1.0/src/django_admin_select_filter/templates/admin_select_filter/filters/base.html +45 -0
  27. django_admin_select_filter-0.1.0/src/django_admin_select_filter/templates/admin_select_filter/filters/choice_filter.html +1 -0
  28. django_admin_select_filter-0.1.0/src/django_admin_select_filter/templates/admin_select_filter/filters/foreign_key_filter.html +1 -0
  29. django_admin_select_filter-0.1.0/src/django_admin_select_filter/urls.py +48 -0
  30. django_admin_select_filter-0.1.0/src/django_admin_select_filter/views.py +219 -0
  31. django_admin_select_filter-0.1.0/tests/__init__.py +0 -0
  32. django_admin_select_filter-0.1.0/tests/conftest.py +13 -0
  33. django_admin_select_filter-0.1.0/tests/e2e/__init__.py +0 -0
  34. django_admin_select_filter-0.1.0/tests/e2e/test_select2_filter.py +67 -0
  35. django_admin_select_filter-0.1.0/tests/e2e/test_zzz_generate_screenshots.py +115 -0
  36. django_admin_select_filter-0.1.0/tests/js/admin_select2_filter.test.js +310 -0
  37. django_admin_select_filter-0.1.0/tests/settings.py +51 -0
  38. django_admin_select_filter-0.1.0/tests/test_filters.py +1240 -0
  39. django_admin_select_filter-0.1.0/tests/test_urls.py +19 -0
  40. django_admin_select_filter-0.1.0/tests/test_views.py +391 -0
  41. django_admin_select_filter-0.1.0/tests/testapp/__init__.py +0 -0
  42. django_admin_select_filter-0.1.0/tests/testapp/admin.py +159 -0
  43. django_admin_select_filter-0.1.0/tests/testapp/models.py +47 -0
  44. django_admin_select_filter-0.1.0/tests/urls.py +11 -0
  45. django_admin_select_filter-0.1.0/uv.lock +1518 -0
  46. django_admin_select_filter-0.1.0/vitest.config.js +14 -0
@@ -0,0 +1,24 @@
1
+ name: JavaScript Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - name: Setup Node.js
15
+ uses: actions/setup-node@v4
16
+ with:
17
+ node-version: "20"
18
+ cache: "npm"
19
+
20
+ - name: Install dependencies
21
+ run: npm ci
22
+
23
+ - name: Run Tests & Coverage
24
+ run: npm run coverage:js
@@ -0,0 +1,31 @@
1
+ name: Python and Playwright Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [master, main]
6
+ pull_request:
7
+ branches: [master, main]
8
+
9
+ jobs:
10
+ test-python:
11
+ name: Python Tests
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Install uv
17
+ uses: astral-sh/setup-uv@v5
18
+ with:
19
+ enable-cache: true
20
+
21
+ - name: Set up Python
22
+ run: uv python install
23
+
24
+ - name: Install dependencies
25
+ run: uv sync
26
+
27
+ - name: Install Playwright browsers
28
+ run: uv run playwright install --with-deps chromium
29
+
30
+ - name: Run Pytest
31
+ run: uv run pytest
@@ -0,0 +1,61 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types:
6
+ - published
7
+
8
+ jobs:
9
+ release-build:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.x"
18
+
19
+ - name: Install build dependencies
20
+ run: |
21
+ python -m pip install --upgrade pip
22
+ python -m pip install build
23
+
24
+ - name: Show project version
25
+ run: |
26
+ grep "version" pyproject.toml
27
+
28
+ - name: Build release distributions
29
+ run: |
30
+ python -m build
31
+
32
+ - name: Upload distributions
33
+ uses: actions/upload-artifact@v4
34
+ with:
35
+ name: release-dists
36
+ path: dist/
37
+
38
+ pypi-publish:
39
+ runs-on: ubuntu-latest
40
+ needs:
41
+ - release-build
42
+ permissions:
43
+ # IMPORTANT: this permission is mandatory for trusted publishing
44
+ id-token: write
45
+ contents: read
46
+
47
+ environment:
48
+ name: pypi
49
+ url: https://pypi.org/project/django-admin-select-filter/
50
+
51
+ steps:
52
+ - name: Retrieve release distributions
53
+ uses: actions/download-artifact@v4
54
+ with:
55
+ name: release-dists
56
+ path: dist/
57
+
58
+ - name: Publish release distributions to PyPI
59
+ uses: pypa/gh-action-pypi-publish@release/v1
60
+ with:
61
+ packages-dir: dist/
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+ *.sqlite3
15
+ .DS_Store
16
+
17
+ node_modules/
18
+ coverage/
@@ -0,0 +1,59 @@
1
+ default_language_version:
2
+ python: &python-version python3.14
3
+
4
+ repos:
5
+ - repo: https://github.com/pre-commit/pre-commit-hooks
6
+ rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0
7
+ hooks:
8
+ - id: check-added-large-files
9
+ - id: check-case-conflict
10
+ - id: check-json
11
+ - id: check-merge-conflict
12
+ - id: check-symlinks
13
+ - id: check-toml
14
+ - id: debug-statements
15
+ - id: end-of-file-fixer
16
+ - id: trailing-whitespace
17
+
18
+ - repo: https://github.com/biomejs/pre-commit
19
+ rev: d1ce4972fb5ad09afd33b432bca71e8c5dfbb2b5 # frozen: v2.5.6
20
+ hooks:
21
+ - id: biome-check
22
+ additional_dependencies:
23
+ - '@biomejs/biome@2.3.8'
24
+ language_version: default
25
+
26
+ - repo: https://github.com/adamchainz/django-upgrade
27
+ rev: bdcc9646c249f00ec9781751791e7b75b48b3722 # frozen: 1.31.1
28
+ hooks:
29
+ - id: django-upgrade
30
+ args: [--target-version, "4.2"]
31
+
32
+ - repo: https://github.com/astral-sh/ruff-pre-commit
33
+ rev: 39d9ac5938dadb73df0564a45f163e25ff9fa6e2 # frozen: v0.16.1
34
+ hooks:
35
+ - id: ruff-check
36
+ args: [--fix]
37
+ - id: ruff-format
38
+
39
+ - repo: local
40
+ hooks:
41
+ - id: mypy
42
+ name: mypy
43
+ entry: mypy
44
+ language: system
45
+ types: [python]
46
+ files: ^src/
47
+ pass_filenames: false
48
+ args: [src/django_admin_select_filter]
49
+
50
+ - repo: https://github.com/adamchainz/djade-pre-commit
51
+ rev: 52a7ce253113456c2a1113186a4a19b15c8b68af # frozen: 1.9.0
52
+ hooks:
53
+ - id: djade
54
+ files: '(/|^)templates/'
55
+
56
+ - repo: https://github.com/tox-dev/pyproject-fmt
57
+ rev: d600c142bb19f521ae6a6a345f94a2efd7937759 # frozen: v2.26.0
58
+ hooks:
59
+ - id: pyproject-fmt
@@ -0,0 +1,65 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Initial project structure: `BaseSelectFilter`,`ForeignKeyFilter`, async
6
+ options view/URL, Select2 filter template, and test scaffolding.
7
+ - Extracted shared filter logic into `BaseSelectFilter` and added `ChoiceFilter`,
8
+ a general-purpose Select2 filter for scalar fields (e.g. `ChoiceField`) or
9
+ explicit `(value, label)` options, not tied to a related model.
10
+ - `ForeignKeyFilter.model` is now optional: when unset, it's inferred by
11
+ walking `parameter_name` across the admin model's relations, including a
12
+ nested lookup like `"publisher__country"`.
13
+ - Extracted that relation-walking into `BaseSelectFilter._resolve_field`, so
14
+ `ChoiceFilter` also supports a nested `parameter_name` (e.g.
15
+ `"author__status"`) when deriving options from field `choices`.
16
+ - Moved the Select2 filter template out of the generic `admin/filters/` path
17
+ into `admin_select_filter/filters/`, namespaced under the app's own label
18
+ (matching the existing static files and URL namespace) instead of a path
19
+ other packages could collide with. Split it into a
20
+ `base.html` with the shared markup and blocks, and a
21
+ `foreign_key_filter.html`/`choice_filter.html` pair that each extend it —
22
+ `ForeignKeyFilter` and `ChoiceFilter` now set their own `template`.
23
+ - Added `BaseSelectFilter.multiple`: both filters can now select several
24
+ values at once, filtering with an `__in` lookup (combined with the null
25
+ lookup when the null option is also selected). Selected values are joined
26
+ in the query string via `multiple_separator`. The Select2 widget switches
27
+ to its native multi-select mode and the JS waits for the dropdown to close
28
+ before navigating, instead of navigating on every single selection.
29
+ - `BaseSelectFilter`, `ForeignKeyFilter` and `ChoiceFilter` are now importable
30
+ directly from `django_admin_select_filter` (e.g.
31
+ `from django_admin_select_filter import ForeignKeyFilter`), not just from
32
+ `django_admin_select_filter.filters`.
33
+ - Fixed `Select2FilterOptionsView` returning 404 when the target model was
34
+ registered on a project's own `AdminSite` instead of the default
35
+ `django.contrib.admin.site`. It now checks every registered `AdminSite`.
36
+ - Added `BaseSelectFilter.searchable`: set to `False` to hide Select2's
37
+ search input and get a plain dropdown, for a short static option list.
38
+ - Fixed dark-mode styling for `multiple = True`: the CSS only targeted
39
+ Select2's single-selection markup, leaving the multi-select chips unstyled.
40
+ - Replaced `django_admin_select_filter.urls`'s plain `urlpatterns` list with
41
+ `django_admin_select_filter_path()`, a function projects drop straight
42
+ into their own `urlpatterns` — no `include()` needed. It still namespaces
43
+ the route as `admin_select_filter:options` internally via `include()`, and
44
+ accepts `route` (defaults to `"django_admin_select_filter/options/"`),
45
+ `view`, `name` and `as_view_kwargs` to customize it.
46
+ - Added `BaseSelectFilter.async_call_url`: the JS now reads the async
47
+ endpoint from this per-filter attribute. Left unset (the default), it
48
+ resolves via `reverse("admin_select_filter:options")` at request time, so
49
+ it's always correct regardless of the admin page it's rendered on and
50
+ wherever that urlconf is mounted (a bare relative path there would resolve
51
+ against the *current page*, not that mount point, and silently hit the
52
+ wrong URL). Set it explicitly on a filter to point it at a custom view — or
53
+ to match a custom `name=` passed to `django_admin_select_filter_path()`,
54
+ which filters otherwise have no way to discover.
55
+ - Added `Select2FilterOptionsView.use_registry`: enabled via
56
+ `as_view_kwargs={"use_registry": True}`, it resolves the matching filter
57
+ from a `{site: {app_label: {model_name: {parameter_name: filter}}}}` map
58
+ built once and cached for the process's lifetime, instead of calling
59
+ `get_list_filter()` on every registered `ModelAdmin` on every request.
60
+ - Added `field_list_filter()`, adapting `ForeignKeyFilter`/`ChoiceFilter` for
61
+ `list_filter`'s `(field_name, filter_class)` tuple shorthand so the same
62
+ class can be reused across fields without a dedicated subclass per field —
63
+ `parameter_name` (and, for `ForeignKeyFilter`, `model`) is inferred from
64
+ the field. Only supports `async_call = False`; it raises `TypeError`
65
+ immediately for a filter with `async_call = True`.
@@ -0,0 +1,52 @@
1
+ # Contributing
2
+
3
+ ## Development setup
4
+
5
+ With [uv](https://docs.astral.sh/uv/) (recommended):
6
+
7
+ ```bash
8
+ uv sync
9
+ uv run playwright install --with-deps chromium
10
+ npm install
11
+ uv run pytest
12
+ uv run pre-commit install
13
+ ```
14
+
15
+ `uv sync`/`uv run` install the `dev` and `test` dependency groups by default
16
+ (`[tool.uv] default-groups` in `pyproject.toml`) — no `--extra` flags needed.
17
+
18
+ Without uv:
19
+
20
+ ```bash
21
+ pip install -e ".[test,dev]"
22
+ playwright install --with-deps chromium
23
+ npm install
24
+ pytest
25
+ pre-commit install
26
+ ```
27
+
28
+ ## Tests
29
+
30
+ `tests/e2e/` drives a real Django admin page in a headless browser
31
+ (pytest-playwright) to check the Select2 widget actually renders and works —
32
+ both the synchronous dropdown and the asynchronous one, which exercises the
33
+ JS → `fetch` → view → DB round trip for real. It needs a browser installed
34
+ once via `playwright install`.
35
+
36
+ `pre-commit` runs ruff, mypy, django-upgrade, djade (template linting),
37
+ pyproject-fmt, biome and vitest (for the bundled JS/CSS). The `mypy` hook
38
+ runs against the project's own environment rather than an isolated one,
39
+ since `django-stubs` needs the package importable to resolve model/queryset
40
+ types — with uv, `uv run pre-commit run --all-files` picks up `.venv/bin`
41
+ automatically; without it, activate the venv first (or prefix commands with
42
+ its `bin/`).
43
+
44
+ ### Coverage
45
+
46
+ `pytest` always runs with coverage on (`--cov`, see `[tool.pytest]` /
47
+ `[tool.coverage]` in `pyproject.toml`) and prints a terminal report. For the
48
+ bundled JS:
49
+
50
+ ```bash
51
+ npm run coverage:js
52
+ ```
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rodolfo Becerra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,266 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-admin-select-filter
3
+ Version: 0.1.0
4
+ Summary: Select2-powered admin list filters for Django, including an async foreign-key filter.
5
+ Project-URL: Homepage, https://github.com/rodolvbg/django-admin-select-filter
6
+ Project-URL: Repository, https://github.com/rodolvbg/django-admin-select-filter
7
+ Author: Rodolfo Becerra
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Framework :: Django
11
+ Classifier: Framework :: Django :: 4.2
12
+ Classifier: Framework :: Django :: 5.0
13
+ Classifier: Framework :: Django :: 5.1
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: django>=4.2
24
+ Provides-Extra: dev
25
+ Requires-Dist: django-stubs>=5; extra == 'dev'
26
+ Requires-Dist: mypy>=1.11; extra == 'dev'
27
+ Requires-Dist: pre-commit>=4; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Provides-Extra: test
30
+ Requires-Dist: inline-snapshot-django>=1.1; extra == 'test'
31
+ Requires-Dist: inline-snapshot>=0.20.10; extra == 'test'
32
+ Requires-Dist: playwright>=1.62; extra == 'test'
33
+ Requires-Dist: pytest-cov>=5; extra == 'test'
34
+ Requires-Dist: pytest-django>=4.8; extra == 'test'
35
+ Requires-Dist: pytest-playwright>=0.9; extra == 'test'
36
+ Requires-Dist: pytest>=8; extra == 'test'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # django-admin-select-filter
40
+
41
+ Select2-powered admin list filters for Django.
42
+
43
+ ![Select2 filters in the admin sidebar](docs/screenshots/hero.png)
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install django-admin-select-filter
49
+ ```
50
+
51
+ Add to `INSTALLED_APPS`:
52
+
53
+ ```python
54
+ INSTALLED_APPS = [
55
+ ...
56
+ "django_admin_select_filter",
57
+ ]
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ### `ChoiceFilter`
63
+
64
+ The main filter: it works on any field with discrete values — a
65
+ `choices`-backed `CharField`/`IntegerField`, a `BooleanField`, or an explicit
66
+ `options` list you provide yourself:
67
+
68
+ ```python
69
+ from django.contrib import admin
70
+ from django_admin_select_filter import ChoiceFilter
71
+
72
+ from myapp.models import Book
73
+
74
+
75
+ class GenreFilter(ChoiceFilter):
76
+ parameter_name = "genre" # reads Book.genre.choices
77
+
78
+
79
+ class StatusFilter(ChoiceFilter):
80
+ parameter_name = "status"
81
+ options = [("draft", "Draft"), ("published", "Published")]
82
+
83
+
84
+ @admin.register(Book)
85
+ class BookAdmin(admin.ModelAdmin):
86
+ list_filter = [GenreFilter, StatusFilter]
87
+ ```
88
+
89
+ ![ChoiceFilter dropdown open, listing Fiction/Non-fiction](docs/screenshots/choice-filter.png)
90
+
91
+ Since `options` accepts any explicit `(value, label)` list, `ChoiceFilter`
92
+ can even filter a `ForeignKey` — pass one pair per related row:
93
+
94
+ ```python
95
+ class AuthorChoiceFilter(ChoiceFilter):
96
+ parameter_name = "author"
97
+ options = [(author.pk, str(author)) for author in Author.objects.all()]
98
+ ```
99
+
100
+ This isn't optimal, though: `options` is evaluated once, when the class body
101
+ runs (at import time), so it goes stale as rows are added or renamed —
102
+ there's no live query, search, or async loading behind it. For a real
103
+ `ForeignKey`, use `ForeignKeyFilter` below instead.
104
+
105
+ ### `ForeignKeyFilter`
106
+
107
+ Built specifically for `ForeignKey`/`ManyToManyField`s: it queries the
108
+ related model live, supports searching through the related model's own
109
+ `ModelAdmin.search_fields`, and can defer loading options until the widget
110
+ is opened (see `async_call` further down).
111
+
112
+ ```python
113
+ from django_admin_select_filter import ForeignKeyFilter
114
+
115
+
116
+ class AuthorFilter(ForeignKeyFilter):
117
+ parameter_name = "author"
118
+ ordering = ["name"]
119
+
120
+
121
+ @admin.register(Book)
122
+ class BookAdmin(admin.ModelAdmin):
123
+ list_filter = [AuthorFilter]
124
+ ```
125
+
126
+ ![ForeignKeyFilter dropdown with a search box, listing authors](docs/screenshots/foreign-key-filter.png)
127
+
128
+ `model` is only needed when it can't be inferred. By default it's resolved by
129
+ walking `parameter_name` across `Book`'s relations, following a nested lookup
130
+ (e.g. `parameter_name = "author__country"`) segment by segment — forward or
131
+ reverse — and taking the last segment's related model. Set `model` explicitly
132
+ if the lookup isn't a real relation chain, or to point somewhere else.
133
+
134
+ ### `field_list_filter()`
135
+
136
+ For a one-off filter that doesn't need its own subclass, use Django's own
137
+ `list_filter` shorthand — a `(field_name, filter_class)` tuple — via
138
+ `field_list_filter()`:
139
+
140
+ ```python
141
+ from django_admin_select_filter import ChoiceFilter, ForeignKeyFilter, field_list_filter
142
+
143
+
144
+ @admin.register(Book)
145
+ class BookAdmin(admin.ModelAdmin):
146
+ list_filter = [
147
+ ("author", field_list_filter(ForeignKeyFilter)),
148
+ ("genre", field_list_filter(ChoiceFilter)),
149
+ ]
150
+ ```
151
+
152
+ `parameter_name` (and, for `ForeignKeyFilter`, `model`) is inferred from the
153
+ field automatically — including a nested lookup like `"author__country"` —
154
+ so the same wrapped class can be reused across as many fields as you like.
155
+ It only supports `async_call = False`; for an `async_call` filter, define a
156
+ dedicated subclass instead (`field_list_filter()` raises `TypeError` right
157
+ away if you pass it one, rather than fail silently later).
158
+
159
+ ### Shared options
160
+
161
+ Both filters share the same options: `filter_only_used_values`, `async_call`,
162
+ `searchable`, `nullable` and `title`. Both also support a nested lookup for
163
+ `parameter_name` (e.g. `"author__status"`), resolving the field — and, for
164
+ `ForeignKeyFilter`, the `model` — by walking each `__`-separated relation in
165
+ turn, forward or reverse.
166
+
167
+ Set `searchable = False` to hide the search input entirely and get a plain
168
+ dropdown instead — best for a short, static option list.
169
+
170
+ Set `multiple = True` to let either filter accept several values at once:
171
+
172
+ ```python
173
+ class AuthorFilter(ForeignKeyFilter):
174
+ parameter_name = "author"
175
+ multiple = True
176
+ ```
177
+
178
+ ![multiple = True with two selected author chips](docs/screenshots/multiple.png)
179
+
180
+ Selected values are joined in the query string with `multiple_separator`
181
+ (`","` by default) and applied with an `__in` lookup, so configured values
182
+ (primary keys, option values) must not contain that character. The "All"
183
+ option is dropped in this mode — clearing every selected chip already means
184
+ no filter.
185
+
186
+ ## Async options (`async_call = True`)
187
+
188
+ Set `async_call = True` on a filter to defer loading its options until the
189
+ Select2 widget is opened, fetching them over AJAX instead of rendering every
190
+ option upfront — useful for a related model with many rows. It requires
191
+ wiring an options endpoint: drop `django_admin_select_filter_path()` into
192
+ your root `urlpatterns` — no `include()` needed:
193
+
194
+ ```python
195
+ # urls.py
196
+ from django_admin_select_filter import django_admin_select_filter_path
197
+
198
+ urlpatterns = [
199
+ ...
200
+ django_admin_select_filter_path(),
201
+ ]
202
+ ```
203
+
204
+ Its route defaults to `django_admin_select_filter/options/`; pass `route` in
205
+ case it collides with something else in your project:
206
+
207
+ ```python
208
+ django_admin_select_filter_path(route="custom-options/")
209
+ ```
210
+
211
+ The endpoint stays reversible as `admin_select_filter:options` either way —
212
+ `django_admin_select_filter_path()` still namespaces it internally via
213
+ `include()`, that's just no longer something you have to write yourself.
214
+ Pass `route`, `view`, `name` or `as_view_kwargs` to customize it further. If
215
+ you pass a custom `name`, set `async_call_url` explicitly on every
216
+ `async_call` filter to match it — filters resolve the default endpoint by
217
+ its default name, and have no way to discover a custom one chosen here.
218
+
219
+ Each `async_call` filter reads that shared endpoint through
220
+ `BaseSelectFilter.async_call_url`, resolved automatically at request time via
221
+ `reverse("admin_select_filter:options")`. Set `async_call_url` on a specific
222
+ filter to point it at a different view instead:
223
+
224
+ ```python
225
+ class AuthorFilter(ForeignKeyFilter):
226
+ parameter_name = "author"
227
+ async_call = True
228
+ async_call_url = "/api/custom-author-options/"
229
+ ```
230
+
231
+ If its route shares a prefix with your admin mount (e.g. both under
232
+ `admin/`), list `django_admin_select_filter_path()` **before**
233
+ `path("admin/", admin.site.urls)`. Since Django 4.1, `AdminSite` registers a
234
+ catch-all view (`AdminSite.final_catch_all_view`, enabled by default) that
235
+ matches every otherwise-unmatched URL under its own prefix and raises
236
+ `Http404` itself — so if the admin mount comes first, it swallows requests to
237
+ this app's options endpoint before its URLs ever get a chance to match, and
238
+ you'll see a 404 with a full HTML body (the admin's own "Page not found"
239
+ page) instead of this app's JSON response.
240
+
241
+ The filter template loads jQuery/Select2 from Django admin's bundled vendor
242
+ assets on demand, so no extra JS dependency is required. Make sure
243
+ `django.contrib.staticfiles` is installed and configured.
244
+
245
+ The endpoint only serves filters with `async_call = True` and requires the
246
+ requesting user to have view permission on the target model (checked via
247
+ `ModelAdmin.has_view_permission`) — anonymous or unprivileged requests get a
248
+ 403, and a `parameter_name` matching a non-async filter gets a 404. It looks
249
+ the model up across every registered `AdminSite` (not just the default
250
+ `django.contrib.admin.site`), so a project using its own `AdminSite` works too.
251
+
252
+ By default it does this by calling `get_list_filter()` on every matching
253
+ `ModelAdmin` on every request. For a project with many admins or filters,
254
+ pass `as_view_kwargs={"use_registry": True}` to
255
+ `django_admin_select_filter_path()` instead: it resolves filters from a
256
+ `{site: {app_label: {model_name: {parameter_name: filter}}}}` map built once
257
+ and cached for the process's lifetime (admin registrations are static after
258
+ startup), turning that per-request scan into a single dict lookup. The
259
+ trade-off: it calls `get_list_filter(request=None)` while building the
260
+ cache, so a `get_list_filter()` override that depends on the request isn't
261
+ supported in this mode.
262
+
263
+ ## Contributing
264
+
265
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, running the
266
+ test suite (including the browser-driven e2e tests), and pre-commit hooks.