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.
- django_admin_select_filter-0.1.0/.github/workflows/js-tests.yml +24 -0
- django_admin_select_filter-0.1.0/.github/workflows/pytest.yml +31 -0
- django_admin_select_filter-0.1.0/.github/workflows/release.yml +61 -0
- django_admin_select_filter-0.1.0/.gitignore +18 -0
- django_admin_select_filter-0.1.0/.pre-commit-config.yaml +59 -0
- django_admin_select_filter-0.1.0/CHANGELOG.md +65 -0
- django_admin_select_filter-0.1.0/CONTRIBUTING.md +52 -0
- django_admin_select_filter-0.1.0/LICENSE +21 -0
- django_admin_select_filter-0.1.0/PKG-INFO +266 -0
- django_admin_select_filter-0.1.0/README.md +228 -0
- django_admin_select_filter-0.1.0/docs/screenshots/choice-filter.png +0 -0
- django_admin_select_filter-0.1.0/docs/screenshots/foreign-key-filter.png +0 -0
- django_admin_select_filter-0.1.0/docs/screenshots/hero.png +0 -0
- django_admin_select_filter-0.1.0/docs/screenshots/multiple.png +0 -0
- django_admin_select_filter-0.1.0/package-lock.json +2963 -0
- django_admin_select_filter-0.1.0/package.json +15 -0
- django_admin_select_filter-0.1.0/pyproject.toml +114 -0
- django_admin_select_filter-0.1.0/pytest.ini +6 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/__init__.py +19 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/_typecheck_settings.py +15 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/apps.py +8 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/filters.py +603 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/py.typed +0 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/static/admin_select_filter/css/admin_select2_filter.css +68 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/static/admin_select_filter/js/admin_select2_filter.js +97 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/templates/admin_select_filter/filters/base.html +45 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/templates/admin_select_filter/filters/choice_filter.html +1 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/templates/admin_select_filter/filters/foreign_key_filter.html +1 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/urls.py +48 -0
- django_admin_select_filter-0.1.0/src/django_admin_select_filter/views.py +219 -0
- django_admin_select_filter-0.1.0/tests/__init__.py +0 -0
- django_admin_select_filter-0.1.0/tests/conftest.py +13 -0
- django_admin_select_filter-0.1.0/tests/e2e/__init__.py +0 -0
- django_admin_select_filter-0.1.0/tests/e2e/test_select2_filter.py +67 -0
- django_admin_select_filter-0.1.0/tests/e2e/test_zzz_generate_screenshots.py +115 -0
- django_admin_select_filter-0.1.0/tests/js/admin_select2_filter.test.js +310 -0
- django_admin_select_filter-0.1.0/tests/settings.py +51 -0
- django_admin_select_filter-0.1.0/tests/test_filters.py +1240 -0
- django_admin_select_filter-0.1.0/tests/test_urls.py +19 -0
- django_admin_select_filter-0.1.0/tests/test_views.py +391 -0
- django_admin_select_filter-0.1.0/tests/testapp/__init__.py +0 -0
- django_admin_select_filter-0.1.0/tests/testapp/admin.py +159 -0
- django_admin_select_filter-0.1.0/tests/testapp/models.py +47 -0
- django_admin_select_filter-0.1.0/tests/urls.py +11 -0
- django_admin_select_filter-0.1.0/uv.lock +1518 -0
- 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,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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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.
|