django-admin-search-field 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_search_field-0.1.0/LICENSE +21 -0
- django_admin_search_field-0.1.0/PKG-INFO +163 -0
- django_admin_search_field-0.1.0/README.md +130 -0
- django_admin_search_field-0.1.0/django_admin_search_field/__init__.py +71 -0
- django_admin_search_field-0.1.0/django_admin_search_field/apps.py +10 -0
- django_admin_search_field-0.1.0/django_admin_search_field/fields.py +196 -0
- django_admin_search_field-0.1.0/django_admin_search_field/install.py +79 -0
- django_admin_search_field-0.1.0/django_admin_search_field/static/django_admin_search_field/css/search_field.css +55 -0
- django_admin_search_field-0.1.0/django_admin_search_field/templates/admin/search_form.html +34 -0
- django_admin_search_field-0.1.0/django_admin_search_field.egg-info/PKG-INFO +163 -0
- django_admin_search_field-0.1.0/django_admin_search_field.egg-info/SOURCES.txt +16 -0
- django_admin_search_field-0.1.0/django_admin_search_field.egg-info/dependency_links.txt +1 -0
- django_admin_search_field-0.1.0/django_admin_search_field.egg-info/requires.txt +5 -0
- django_admin_search_field-0.1.0/django_admin_search_field.egg-info/top_level.txt +1 -0
- django_admin_search_field-0.1.0/pyproject.toml +78 -0
- django_admin_search_field-0.1.0/setup.cfg +4 -0
- django_admin_search_field-0.1.0/tests/test_fields.py +90 -0
- django_admin_search_field-0.1.0/tests/test_install.py +90 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fabio Valle
|
|
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,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-admin-search-field
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Per-field search selector combobox for the Django admin changelist
|
|
5
|
+
Author: Fabio Valle
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/fdelvalle/django-admin-search-field
|
|
8
|
+
Project-URL: Issues, https://github.com/fdelvalle/django-admin-search-field/issues
|
|
9
|
+
Keywords: django,admin,search,changelist,django-admin
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 4.2
|
|
14
|
+
Classifier: Framework :: Django :: 5.0
|
|
15
|
+
Classifier: Framework :: Django :: 5.1
|
|
16
|
+
Classifier: Framework :: Django :: 5.2
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: Django>=4.2
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-django>=4.8; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# django-admin-search-field
|
|
35
|
+
|
|
36
|
+
A per-field search selector for the Django admin changelist.
|
|
37
|
+
|
|
38
|
+
By default, the Django admin searches the submitted term across **every**
|
|
39
|
+
field listed in a `ModelAdmin.search_fields` (joined with `OR`). On tables
|
|
40
|
+
with many fields, several relations, or large row counts, that makes the
|
|
41
|
+
search slow — and there is no built-in way to search a single field instead.
|
|
42
|
+
|
|
43
|
+
This package adds a small combobox next to the search box, letting the user
|
|
44
|
+
restrict the search to **one** field — or keep "All fields" for the native
|
|
45
|
+
behaviour.
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- Adds a `<select>` next to the changelist search box with one option per
|
|
50
|
+
`search_fields` entry, using a friendly label (`verbose_name`, following
|
|
51
|
+
relations, e.g. `author__name` → "Author › Name").
|
|
52
|
+
- Restricts the actual search query to the chosen field via the `sf` GET
|
|
53
|
+
parameter (name configurable).
|
|
54
|
+
- Works globally, for every registered `ModelAdmin`, via a single opt-in
|
|
55
|
+
call — no need to touch each `admin.py`.
|
|
56
|
+
- Also available as an explicit mixin (`SearchFieldSelectMixin`) if you'd
|
|
57
|
+
rather opt in per `ModelAdmin`.
|
|
58
|
+
- Defensive by design: any unexpected failure falls back to Django's native
|
|
59
|
+
search behaviour instead of breaking the admin page.
|
|
60
|
+
- Ships a template override (`admin/search_form.html`) and a small,
|
|
61
|
+
dependency-free CSS file that follows the admin's own light/dark theme
|
|
62
|
+
variables.
|
|
63
|
+
|
|
64
|
+
## Installation
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install django-admin-search-field
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Add the app to `INSTALLED_APPS`, **before** `django.contrib.admin` (required
|
|
71
|
+
so the bundled `admin/search_form.html` overrides Django's default template
|
|
72
|
+
via the `app_directories` template loader):
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
INSTALLED_APPS = [
|
|
76
|
+
"django_admin_search_field",
|
|
77
|
+
"django.contrib.admin",
|
|
78
|
+
...
|
|
79
|
+
]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Enable the selector once, globally — e.g. from your own app's
|
|
83
|
+
`AppConfig.ready()`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# myapp/apps.py
|
|
87
|
+
from django.apps import AppConfig
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MyAppConfig(AppConfig):
|
|
91
|
+
name = "myapp"
|
|
92
|
+
|
|
93
|
+
def ready(self):
|
|
94
|
+
from django_admin_search_field import install_search_field_selector
|
|
95
|
+
|
|
96
|
+
install_search_field_selector()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
(Optional) include the bundled CSS in your `admin/base_site.html` (or
|
|
100
|
+
wherever you already extend the admin base template):
|
|
101
|
+
|
|
102
|
+
```html
|
|
103
|
+
{% load static %}
|
|
104
|
+
<link rel="stylesheet" href="{% static 'django_admin_search_field/css/search_field.css' %}">
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
That's it — every `ModelAdmin` with `search_fields` configured now shows the
|
|
108
|
+
field selector.
|
|
109
|
+
|
|
110
|
+
## Usage without the global monkey-patch
|
|
111
|
+
|
|
112
|
+
If you'd rather enable this on a single `ModelAdmin`, skip
|
|
113
|
+
`install_search_field_selector()` and use the mixin instead:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from django.contrib import admin
|
|
117
|
+
from django_admin_search_field import SearchFieldSelectMixin
|
|
118
|
+
|
|
119
|
+
from myapp.models import Book
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@admin.register(Book)
|
|
123
|
+
class BookAdmin(SearchFieldSelectMixin, admin.ModelAdmin):
|
|
124
|
+
search_fields = ["title", "isbn", "author__name"]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
In this mode you're responsible for including the CSS/template yourself,
|
|
128
|
+
and you don't need to add `django_admin_search_field` to `INSTALLED_APPS` —
|
|
129
|
+
only the Python mixin is used.
|
|
130
|
+
|
|
131
|
+
## Configuration
|
|
132
|
+
|
|
133
|
+
| Setting | Default | Description |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `ADMIN_SEARCH_FIELD_VAR` | `"sf"` | Name of the GET parameter used to carry the chosen field. Override it if `sf` collides with something else in your project. |
|
|
136
|
+
|
|
137
|
+
## How it works
|
|
138
|
+
|
|
139
|
+
- `resolve_search_fields()` restricts `search_fields` to the field selected
|
|
140
|
+
via the `sf` GET parameter — or returns the original list unchanged when
|
|
141
|
+
`sf` is empty ("All fields") or points to a field that isn't configured.
|
|
142
|
+
- `sf` is registered in the changelist's `IGNORED_PARAMS`, so it's never
|
|
143
|
+
misread as a list filter (which would otherwise raise
|
|
144
|
+
`IncorrectLookupParameters`).
|
|
145
|
+
- The template only receives the `cl` (ChangeList) object, so the combobox
|
|
146
|
+
data is attached directly to it: `cl.search_field_choices`,
|
|
147
|
+
`cl.search_field_selected`, `cl.search_field_var`.
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install -e ".[dev]"
|
|
153
|
+
pytest
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Compatibility
|
|
157
|
+
|
|
158
|
+
- Python 3.10+
|
|
159
|
+
- Django 4.2+
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# django-admin-search-field
|
|
2
|
+
|
|
3
|
+
A per-field search selector for the Django admin changelist.
|
|
4
|
+
|
|
5
|
+
By default, the Django admin searches the submitted term across **every**
|
|
6
|
+
field listed in a `ModelAdmin.search_fields` (joined with `OR`). On tables
|
|
7
|
+
with many fields, several relations, or large row counts, that makes the
|
|
8
|
+
search slow — and there is no built-in way to search a single field instead.
|
|
9
|
+
|
|
10
|
+
This package adds a small combobox next to the search box, letting the user
|
|
11
|
+
restrict the search to **one** field — or keep "All fields" for the native
|
|
12
|
+
behaviour.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Adds a `<select>` next to the changelist search box with one option per
|
|
17
|
+
`search_fields` entry, using a friendly label (`verbose_name`, following
|
|
18
|
+
relations, e.g. `author__name` → "Author › Name").
|
|
19
|
+
- Restricts the actual search query to the chosen field via the `sf` GET
|
|
20
|
+
parameter (name configurable).
|
|
21
|
+
- Works globally, for every registered `ModelAdmin`, via a single opt-in
|
|
22
|
+
call — no need to touch each `admin.py`.
|
|
23
|
+
- Also available as an explicit mixin (`SearchFieldSelectMixin`) if you'd
|
|
24
|
+
rather opt in per `ModelAdmin`.
|
|
25
|
+
- Defensive by design: any unexpected failure falls back to Django's native
|
|
26
|
+
search behaviour instead of breaking the admin page.
|
|
27
|
+
- Ships a template override (`admin/search_form.html`) and a small,
|
|
28
|
+
dependency-free CSS file that follows the admin's own light/dark theme
|
|
29
|
+
variables.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install django-admin-search-field
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Add the app to `INSTALLED_APPS`, **before** `django.contrib.admin` (required
|
|
38
|
+
so the bundled `admin/search_form.html` overrides Django's default template
|
|
39
|
+
via the `app_directories` template loader):
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
INSTALLED_APPS = [
|
|
43
|
+
"django_admin_search_field",
|
|
44
|
+
"django.contrib.admin",
|
|
45
|
+
...
|
|
46
|
+
]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Enable the selector once, globally — e.g. from your own app's
|
|
50
|
+
`AppConfig.ready()`:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# myapp/apps.py
|
|
54
|
+
from django.apps import AppConfig
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MyAppConfig(AppConfig):
|
|
58
|
+
name = "myapp"
|
|
59
|
+
|
|
60
|
+
def ready(self):
|
|
61
|
+
from django_admin_search_field import install_search_field_selector
|
|
62
|
+
|
|
63
|
+
install_search_field_selector()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
(Optional) include the bundled CSS in your `admin/base_site.html` (or
|
|
67
|
+
wherever you already extend the admin base template):
|
|
68
|
+
|
|
69
|
+
```html
|
|
70
|
+
{% load static %}
|
|
71
|
+
<link rel="stylesheet" href="{% static 'django_admin_search_field/css/search_field.css' %}">
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
That's it — every `ModelAdmin` with `search_fields` configured now shows the
|
|
75
|
+
field selector.
|
|
76
|
+
|
|
77
|
+
## Usage without the global monkey-patch
|
|
78
|
+
|
|
79
|
+
If you'd rather enable this on a single `ModelAdmin`, skip
|
|
80
|
+
`install_search_field_selector()` and use the mixin instead:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from django.contrib import admin
|
|
84
|
+
from django_admin_search_field import SearchFieldSelectMixin
|
|
85
|
+
|
|
86
|
+
from myapp.models import Book
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@admin.register(Book)
|
|
90
|
+
class BookAdmin(SearchFieldSelectMixin, admin.ModelAdmin):
|
|
91
|
+
search_fields = ["title", "isbn", "author__name"]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
In this mode you're responsible for including the CSS/template yourself,
|
|
95
|
+
and you don't need to add `django_admin_search_field` to `INSTALLED_APPS` —
|
|
96
|
+
only the Python mixin is used.
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
| Setting | Default | Description |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `ADMIN_SEARCH_FIELD_VAR` | `"sf"` | Name of the GET parameter used to carry the chosen field. Override it if `sf` collides with something else in your project. |
|
|
103
|
+
|
|
104
|
+
## How it works
|
|
105
|
+
|
|
106
|
+
- `resolve_search_fields()` restricts `search_fields` to the field selected
|
|
107
|
+
via the `sf` GET parameter — or returns the original list unchanged when
|
|
108
|
+
`sf` is empty ("All fields") or points to a field that isn't configured.
|
|
109
|
+
- `sf` is registered in the changelist's `IGNORED_PARAMS`, so it's never
|
|
110
|
+
misread as a list filter (which would otherwise raise
|
|
111
|
+
`IncorrectLookupParameters`).
|
|
112
|
+
- The template only receives the `cl` (ChangeList) object, so the combobox
|
|
113
|
+
data is attached directly to it: `cl.search_field_choices`,
|
|
114
|
+
`cl.search_field_selected`, `cl.search_field_var`.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install -e ".[dev]"
|
|
120
|
+
pytest
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Compatibility
|
|
124
|
+
|
|
125
|
+
- Python 3.10+
|
|
126
|
+
- Django 4.2+
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""A per-field search selector for the Django admin changelist.
|
|
2
|
+
|
|
3
|
+
By default, the Django admin searches the submitted term across every field
|
|
4
|
+
in ``search_fields`` (joined with OR). On large tables with many fields and
|
|
5
|
+
relations, that makes search slow. This package adds a combobox next to the
|
|
6
|
+
search box so users can restrict a search to a single field.
|
|
7
|
+
|
|
8
|
+
Installation
|
|
9
|
+
------------
|
|
10
|
+
1. ``pip install django-admin-search-field``
|
|
11
|
+
|
|
12
|
+
2. Add ``"django_admin_search_field"`` to ``INSTALLED_APPS``, **before**
|
|
13
|
+
``"django.contrib.admin"`` (required so the bundled
|
|
14
|
+
``admin/search_form.html`` template overrides Django's default one)::
|
|
15
|
+
|
|
16
|
+
INSTALLED_APPS = [
|
|
17
|
+
"django_admin_search_field",
|
|
18
|
+
"django.contrib.admin",
|
|
19
|
+
...
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
3. Enable the selector globally, once — e.g. in your own app's
|
|
23
|
+
``AppConfig.ready()``::
|
|
24
|
+
|
|
25
|
+
from django_admin_search_field import install_search_field_selector
|
|
26
|
+
|
|
27
|
+
class MyAppConfig(AppConfig):
|
|
28
|
+
def ready(self):
|
|
29
|
+
install_search_field_selector()
|
|
30
|
+
|
|
31
|
+
4. (Optional) include the bundled CSS in your ``admin/base_site.html``::
|
|
32
|
+
|
|
33
|
+
{% load static %}
|
|
34
|
+
<link rel="stylesheet" href="{% static 'django_admin_search_field/css/search_field.css' %}">
|
|
35
|
+
|
|
36
|
+
Explicit, per-admin use (no global monkey-patch)
|
|
37
|
+
-------------------------------------------------
|
|
38
|
+
To enable the selector on a single ``ModelAdmin`` only, use the mixin::
|
|
39
|
+
|
|
40
|
+
from django_admin_search_field import SearchFieldSelectMixin
|
|
41
|
+
|
|
42
|
+
@admin.register(MyModel)
|
|
43
|
+
class MyModelAdmin(SearchFieldSelectMixin, admin.ModelAdmin):
|
|
44
|
+
search_fields = ["name", "owner__email"]
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
from django_admin_search_field.fields import (
|
|
50
|
+
SearchFieldSelectMixin,
|
|
51
|
+
build_search_field_choices,
|
|
52
|
+
get_selected_search_field,
|
|
53
|
+
label_for_search_field,
|
|
54
|
+
resolve_search_fields,
|
|
55
|
+
strip_search_prefix,
|
|
56
|
+
)
|
|
57
|
+
from django_admin_search_field.install import install_search_field_selector
|
|
58
|
+
|
|
59
|
+
__version__ = "0.1.0"
|
|
60
|
+
|
|
61
|
+
default_app_config = "django_admin_search_field.apps.AdminSearchFieldConfig"
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"SearchFieldSelectMixin",
|
|
65
|
+
"build_search_field_choices",
|
|
66
|
+
"get_selected_search_field",
|
|
67
|
+
"install_search_field_selector",
|
|
68
|
+
"label_for_search_field",
|
|
69
|
+
"resolve_search_fields",
|
|
70
|
+
"strip_search_prefix",
|
|
71
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AdminSearchFieldConfig(AppConfig):
|
|
7
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
8
|
+
name = "django_admin_search_field"
|
|
9
|
+
label = "admin_search_field"
|
|
10
|
+
verbose_name = "Admin Search Field Selector"
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Search field selector logic for the Django admin.
|
|
2
|
+
|
|
3
|
+
By default, the Django admin searches the submitted term across every field
|
|
4
|
+
listed in ``search_fields`` (joined with OR). On large tables with many
|
|
5
|
+
fields and relations, this makes search slow.
|
|
6
|
+
|
|
7
|
+
This module adds a combobox next to the search box, letting the user pick a
|
|
8
|
+
**single** field to search — or "All fields" to keep the native behaviour.
|
|
9
|
+
|
|
10
|
+
How it works
|
|
11
|
+
-------------
|
|
12
|
+
* The bundled ``admin/search_form.html`` template renders a ``<select>`` with
|
|
13
|
+
one option per entry in ``search_fields`` (with a friendly label for each
|
|
14
|
+
field) plus an "All fields" option. The chosen field is submitted via the
|
|
15
|
+
``sf`` GET parameter (configurable, see ``SEARCH_FIELD_VAR`` below).
|
|
16
|
+
* ``resolve_search_fields`` restricts the search to the chosen field when
|
|
17
|
+
``sf`` is valid; otherwise it returns the original fields unchanged.
|
|
18
|
+
* The ``sf`` parameter must be registered in the changelist's
|
|
19
|
+
``IGNORED_PARAMS`` so it isn't treated as a list filter — this is handled by
|
|
20
|
+
:func:`django_admin_search_field.install.install_search_field_selector`.
|
|
21
|
+
|
|
22
|
+
The feature is enabled globally by ``install_search_field_selector`` (an
|
|
23
|
+
additive monkey-patch on the base ``ModelAdmin`` class). Everything here is
|
|
24
|
+
defensive: any failure falls back to native admin behaviour.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from django.conf import settings
|
|
30
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
31
|
+
from django.db.models.constants import LOOKUP_SEP
|
|
32
|
+
|
|
33
|
+
# Lookup prefixes accepted by Django in ``search_fields``.
|
|
34
|
+
_LOOKUP_PREFIXES = ("^", "=", "@")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _search_field_var() -> str:
|
|
38
|
+
"""Name of the GET parameter carrying the chosen field.
|
|
39
|
+
|
|
40
|
+
Override with ``ADMIN_SEARCH_FIELD_VAR`` in your Django settings if ``sf``
|
|
41
|
+
collides with something else in your project.
|
|
42
|
+
"""
|
|
43
|
+
return getattr(settings, "ADMIN_SEARCH_FIELD_VAR", "sf")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Kept as a module-level constant for convenience/backwards compatibility.
|
|
47
|
+
# Prefer calling ``_search_field_var()`` internally so overrides via settings
|
|
48
|
+
# take effect even after this module has been imported.
|
|
49
|
+
SEARCH_FIELD_VAR = "sf"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def strip_search_prefix(field: str) -> str:
|
|
53
|
+
"""Strip the lookup prefixes (``^``, ``=``, ``@``) from a search_field entry."""
|
|
54
|
+
field = str(field)
|
|
55
|
+
while field and field[0] in _LOOKUP_PREFIXES:
|
|
56
|
+
field = field[1:]
|
|
57
|
+
return field
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def label_for_search_field(model, field: str) -> str:
|
|
61
|
+
"""Derive a friendly label for a ``search_fields`` entry.
|
|
62
|
+
|
|
63
|
+
Tries to use the field's ``verbose_name`` (following relations, e.g.
|
|
64
|
+
``owner__name``). Falls back to a label derived from the raw field name
|
|
65
|
+
(``owner__name`` -> "Owner name") when it can't be resolved.
|
|
66
|
+
"""
|
|
67
|
+
clean = strip_search_prefix(field)
|
|
68
|
+
parts = clean.split(LOOKUP_SEP)
|
|
69
|
+
|
|
70
|
+
opts = getattr(model, "_meta", None)
|
|
71
|
+
labels: list[str] = []
|
|
72
|
+
resolved = True
|
|
73
|
+
|
|
74
|
+
for part in parts:
|
|
75
|
+
if opts is None:
|
|
76
|
+
resolved = False
|
|
77
|
+
break
|
|
78
|
+
name = opts.pk.name if part == "pk" else part
|
|
79
|
+
try:
|
|
80
|
+
model_field = opts.get_field(name)
|
|
81
|
+
except (FieldDoesNotExist, AttributeError):
|
|
82
|
+
resolved = False
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
verbose = getattr(model_field, "verbose_name", None) or name
|
|
86
|
+
labels.append(str(verbose).strip().capitalize())
|
|
87
|
+
|
|
88
|
+
# Follow the relation, if any, to resolve the next part.
|
|
89
|
+
related = getattr(model_field, "related_model", None)
|
|
90
|
+
opts = getattr(related, "_meta", None) if related else None
|
|
91
|
+
|
|
92
|
+
if resolved and labels:
|
|
93
|
+
return " › ".join(labels)
|
|
94
|
+
|
|
95
|
+
# Fallback: build a label from the raw name.
|
|
96
|
+
return clean.replace(LOOKUP_SEP, " ").replace("_", " ").strip().capitalize()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_selected_search_field(request) -> str:
|
|
100
|
+
"""Return the field chosen in the GET params, or ``""`` for "All fields"."""
|
|
101
|
+
try:
|
|
102
|
+
return (request.GET.get(_search_field_var()) or "").strip()
|
|
103
|
+
except Exception:
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_search_fields(original_search_fields, request):
|
|
108
|
+
"""Restrict ``search_fields`` to the field chosen in the combobox.
|
|
109
|
+
|
|
110
|
+
If the selector is empty ("All fields") or points to a field that isn't
|
|
111
|
+
configured (e.g. an arbitrary value from the URL), the original list is
|
|
112
|
+
returned unchanged — keeping native admin behaviour.
|
|
113
|
+
"""
|
|
114
|
+
selected = get_selected_search_field(request)
|
|
115
|
+
if not selected:
|
|
116
|
+
return original_search_fields
|
|
117
|
+
if selected in {str(f) for f in (original_search_fields or ())}:
|
|
118
|
+
return (selected,)
|
|
119
|
+
return original_search_fields
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_search_field_choices(model_admin, request) -> list[dict]:
|
|
123
|
+
"""Build the combobox options from ``search_fields``.
|
|
124
|
+
|
|
125
|
+
Returns a list of dicts ``{"value", "label", "selected"}``. Does not
|
|
126
|
+
include the "All fields" option (the template handles that). Returns an
|
|
127
|
+
empty list if the admin has no ``search_fields``.
|
|
128
|
+
|
|
129
|
+
Uses the FULL list of fields (stashed on ``_original_search_fields`` by
|
|
130
|
+
``get_search_fields``) so every option is always listed, even once the
|
|
131
|
+
search is already restricted to a single field.
|
|
132
|
+
"""
|
|
133
|
+
search_fields = getattr(model_admin, "_original_search_fields", None)
|
|
134
|
+
if search_fields is None:
|
|
135
|
+
try:
|
|
136
|
+
search_fields = model_admin.get_search_fields(request)
|
|
137
|
+
except Exception:
|
|
138
|
+
search_fields = getattr(model_admin, "search_fields", None) or ()
|
|
139
|
+
|
|
140
|
+
selected = get_selected_search_field(request)
|
|
141
|
+
model = getattr(model_admin, "model", None)
|
|
142
|
+
|
|
143
|
+
choices: list[dict] = []
|
|
144
|
+
seen: set[str] = set()
|
|
145
|
+
for field in search_fields or ():
|
|
146
|
+
value = str(field)
|
|
147
|
+
if value in seen:
|
|
148
|
+
continue
|
|
149
|
+
seen.add(value)
|
|
150
|
+
choices.append(
|
|
151
|
+
{
|
|
152
|
+
"value": value,
|
|
153
|
+
"label": label_for_search_field(model, value),
|
|
154
|
+
"selected": value == selected,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
return choices
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SearchFieldSelectMixin:
|
|
161
|
+
"""Restrict search to the field chosen in the combobox (``sf`` param).
|
|
162
|
+
|
|
163
|
+
``install_search_field_selector`` applies this behaviour globally via a
|
|
164
|
+
monkey-patch (every ``ModelAdmin`` gets the selector). This mixin is also
|
|
165
|
+
available for explicit use/tests, or to enable the feature on a single
|
|
166
|
+
admin class without the global monkey-patch.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def get_search_fields(self, request):
|
|
170
|
+
original = super().get_search_fields(request)
|
|
171
|
+
# Stash the full list so the template can list every option, even
|
|
172
|
+
# when the search is currently restricted to one field.
|
|
173
|
+
try:
|
|
174
|
+
self._original_search_fields = tuple(original)
|
|
175
|
+
except Exception:
|
|
176
|
+
self._original_search_fields = original
|
|
177
|
+
return resolve_search_fields(original, request)
|
|
178
|
+
|
|
179
|
+
def get_changelist_instance(self, request):
|
|
180
|
+
"""Attach the selector's data to the ChangeList instance.
|
|
181
|
+
|
|
182
|
+
The ``admin/search_form.html`` template only receives the ``cl``
|
|
183
|
+
object (the ``search_form`` inclusion tag builds its context from
|
|
184
|
+
it), so we expose the options there: ``cl.search_field_choices``,
|
|
185
|
+
``cl.search_field_selected`` and ``cl.search_field_var``.
|
|
186
|
+
"""
|
|
187
|
+
cl = super().get_changelist_instance(request)
|
|
188
|
+
try:
|
|
189
|
+
cl.search_field_choices = build_search_field_choices(self, request)
|
|
190
|
+
cl.search_field_selected = get_selected_search_field(request)
|
|
191
|
+
cl.search_field_var = _search_field_var()
|
|
192
|
+
except Exception:
|
|
193
|
+
cl.search_field_choices = []
|
|
194
|
+
cl.search_field_selected = ""
|
|
195
|
+
cl.search_field_var = _search_field_var()
|
|
196
|
+
return cl
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Global installation of the search field selector on every ModelAdmin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django_admin_search_field.fields import (
|
|
6
|
+
_search_field_var,
|
|
7
|
+
build_search_field_choices,
|
|
8
|
+
get_selected_search_field,
|
|
9
|
+
resolve_search_fields,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def install_search_field_selector() -> None:
|
|
14
|
+
"""Enable the search field selector on EVERY ``ModelAdmin``.
|
|
15
|
+
|
|
16
|
+
Instead of reclassifying every admin registered with ``@admin.register``,
|
|
17
|
+
this patches the base ``ModelAdmin`` class itself (additive, idempotent
|
|
18
|
+
monkey-patch):
|
|
19
|
+
|
|
20
|
+
* ``get_search_fields`` now honours the ``sf`` GET parameter (restricts
|
|
21
|
+
the search to a single field) — via
|
|
22
|
+
:class:`django_admin_search_field.fields.SearchFieldSelectMixin`.
|
|
23
|
+
* ``get_changelist_instance`` attaches the combobox options to the ``cl``
|
|
24
|
+
object.
|
|
25
|
+
* The ``sf`` parameter is registered in the changelist's
|
|
26
|
+
``IGNORED_PARAMS`` so it isn't treated as a list filter.
|
|
27
|
+
|
|
28
|
+
Call this once, for example from your own app's ``AppConfig.ready()``:
|
|
29
|
+
|
|
30
|
+
# myapp/apps.py
|
|
31
|
+
class MyAppConfig(AppConfig):
|
|
32
|
+
def ready(self):
|
|
33
|
+
from django_admin_search_field import install_search_field_selector
|
|
34
|
+
install_search_field_selector()
|
|
35
|
+
|
|
36
|
+
Defensive: any failure here must never break the admin.
|
|
37
|
+
"""
|
|
38
|
+
from django.contrib.admin.options import ModelAdmin
|
|
39
|
+
|
|
40
|
+
if getattr(ModelAdmin, "_admin_search_field_installed", False):
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
# 1) 'sf' (or the configured override) must not be read as a list filter.
|
|
44
|
+
from django.contrib.admin.views import main as admin_main
|
|
45
|
+
|
|
46
|
+
search_field_var = _search_field_var()
|
|
47
|
+
if search_field_var not in admin_main.IGNORED_PARAMS:
|
|
48
|
+
admin_main.IGNORED_PARAMS = tuple(admin_main.IGNORED_PARAMS) + (search_field_var,)
|
|
49
|
+
|
|
50
|
+
# 2) Wrap the original base-class methods, preserving them.
|
|
51
|
+
original_get_search_fields = ModelAdmin.get_search_fields
|
|
52
|
+
original_get_changelist_instance = ModelAdmin.get_changelist_instance
|
|
53
|
+
|
|
54
|
+
def get_search_fields(self, request):
|
|
55
|
+
original = original_get_search_fields(self, request)
|
|
56
|
+
try:
|
|
57
|
+
self._original_search_fields = tuple(original)
|
|
58
|
+
except Exception:
|
|
59
|
+
self._original_search_fields = original
|
|
60
|
+
try:
|
|
61
|
+
return resolve_search_fields(original, request)
|
|
62
|
+
except Exception:
|
|
63
|
+
return original
|
|
64
|
+
|
|
65
|
+
def get_changelist_instance(self, request):
|
|
66
|
+
cl = original_get_changelist_instance(self, request)
|
|
67
|
+
try:
|
|
68
|
+
cl.search_field_choices = build_search_field_choices(self, request)
|
|
69
|
+
cl.search_field_selected = get_selected_search_field(request)
|
|
70
|
+
cl.search_field_var = _search_field_var()
|
|
71
|
+
except Exception:
|
|
72
|
+
cl.search_field_choices = []
|
|
73
|
+
cl.search_field_selected = ""
|
|
74
|
+
cl.search_field_var = _search_field_var()
|
|
75
|
+
return cl
|
|
76
|
+
|
|
77
|
+
ModelAdmin.get_search_fields = get_search_fields
|
|
78
|
+
ModelAdmin.get_changelist_instance = get_changelist_instance
|
|
79
|
+
ModelAdmin._admin_search_field_installed = True
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* Styling for the search field selector combobox (django-admin-search-field).
|
|
2
|
+
* Uses the Django admin's own theme variables (--border-color, --body-fg,
|
|
3
|
+
* --body-bg, --link-fg), so it automatically follows the admin's light/dark
|
|
4
|
+
* mode without depending on any project-specific visual component. */
|
|
5
|
+
|
|
6
|
+
#changelist-search .admin-search-field-group {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: stretch;
|
|
9
|
+
vertical-align: middle;
|
|
10
|
+
border: 1px solid var(--border-color);
|
|
11
|
+
border-radius: 4px;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
background: var(--body-bg);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#changelist-search .admin-search-field-group .admin-search-field-select,
|
|
17
|
+
#changelist-search .admin-search-field-group #searchbar,
|
|
18
|
+
#changelist-search .admin-search-field-group .admin-search-field-submit {
|
|
19
|
+
border: none;
|
|
20
|
+
border-radius: 0;
|
|
21
|
+
background: transparent;
|
|
22
|
+
color: var(--body-fg);
|
|
23
|
+
height: 2.25rem;
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#changelist-search .admin-search-field-group .admin-search-field-select {
|
|
28
|
+
border-right: 1px solid var(--border-color);
|
|
29
|
+
padding: 0 0.5rem;
|
|
30
|
+
max-width: 12rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#changelist-search .admin-search-field-group #searchbar {
|
|
34
|
+
padding: 0 0.5rem;
|
|
35
|
+
flex: 1 1 auto;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#changelist-search .admin-search-field-group .admin-search-field-submit {
|
|
39
|
+
display: inline-flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
padding: 0 0.75rem;
|
|
43
|
+
cursor: pointer;
|
|
44
|
+
color: var(--link-fg);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#changelist-search .admin-search-field-group .admin-search-field-submit:hover {
|
|
48
|
+
color: var(--link-hover-color, var(--link-fg));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#changelist-search .admin-search-field-group .admin-search-field-select:focus,
|
|
52
|
+
#changelist-search .admin-search-field-group #searchbar:focus {
|
|
53
|
+
outline: none;
|
|
54
|
+
box-shadow: inset 0 0 0 2px var(--link-fg);
|
|
55
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{% load i18n static %}
|
|
2
|
+
{% if cl.search_fields %}
|
|
3
|
+
<div id="toolbar"><form id="changelist-search" method="get" role="search">
|
|
4
|
+
<div><!-- DIV needed for valid HTML -->
|
|
5
|
+
<div class="admin-search-field-group">
|
|
6
|
+
{% if cl.search_field_choices %}
|
|
7
|
+
<select name="{{ cl.search_field_var|default:'sf' }}" id="search-field-select" class="admin-search-field-select" title="{% translate 'Search by field' %}" aria-label="{% translate 'Search by field' %}">
|
|
8
|
+
<option value=""{% if not cl.search_field_selected %} selected{% endif %}>{% translate 'All fields' %}</option>
|
|
9
|
+
{% for choice in cl.search_field_choices %}
|
|
10
|
+
<option value="{{ choice.value }}"{% if choice.selected %} selected{% endif %}>{{ choice.label }}</option>
|
|
11
|
+
{% endfor %}
|
|
12
|
+
</select>
|
|
13
|
+
{% endif %}
|
|
14
|
+
<input type="text" size="40" name="{{ search_var }}" value="{{ cl.query }}" id="searchbar"{% if cl.search_help_text %} aria-describedby="searchbar_helptext"{% endif %} placeholder="{% translate 'Search' %}">
|
|
15
|
+
<button type="submit" class="admin-search-field-submit" title="{% translate 'Search' %}" aria-label="{% translate 'Search' %}">
|
|
16
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
|
17
|
+
<circle cx="11" cy="11" r="7"></circle>
|
|
18
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
19
|
+
</svg>
|
|
20
|
+
</button>
|
|
21
|
+
</div>
|
|
22
|
+
{% if show_result_count %}
|
|
23
|
+
<span class="small quiet">{% blocktranslate count counter=cl.result_count %}{{ counter }} result{% plural %}{{ counter }} results{% endblocktranslate %} (<a href="?{% if cl.is_popup %}{{ is_popup_var }}=1{% if cl.add_facets %}&{% endif %}{% endif %}{% if cl.add_facets %}{{ is_facets_var }}{% endif %}">{% if cl.show_full_result_count %}{% blocktranslate with full_result_count=cl.full_result_count %}{{ full_result_count }} total{% endblocktranslate %}{% else %}{% translate "Show all" %}{% endif %}</a>)</span>
|
|
24
|
+
{% endif %}
|
|
25
|
+
{% for pair in cl.params.items %}
|
|
26
|
+
{% if pair.0 != search_var and pair.0 != cl.search_field_var %}<input type="hidden" name="{{ pair.0 }}" value="{{ pair.1 }}">{% endif %}
|
|
27
|
+
{% endfor %}
|
|
28
|
+
</div>
|
|
29
|
+
{% if cl.search_help_text %}
|
|
30
|
+
<br class="clear">
|
|
31
|
+
<div class="help" id="searchbar_helptext">{{ cl.search_help_text }}</div>
|
|
32
|
+
{% endif %}
|
|
33
|
+
</form></div>
|
|
34
|
+
{% endif %}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-admin-search-field
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Per-field search selector combobox for the Django admin changelist
|
|
5
|
+
Author: Fabio Valle
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/fdelvalle/django-admin-search-field
|
|
8
|
+
Project-URL: Issues, https://github.com/fdelvalle/django-admin-search-field/issues
|
|
9
|
+
Keywords: django,admin,search,changelist,django-admin
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 4.2
|
|
14
|
+
Classifier: Framework :: Django :: 5.0
|
|
15
|
+
Classifier: Framework :: Django :: 5.1
|
|
16
|
+
Classifier: Framework :: Django :: 5.2
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: Django>=4.2
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-django>=4.8; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# django-admin-search-field
|
|
35
|
+
|
|
36
|
+
A per-field search selector for the Django admin changelist.
|
|
37
|
+
|
|
38
|
+
By default, the Django admin searches the submitted term across **every**
|
|
39
|
+
field listed in a `ModelAdmin.search_fields` (joined with `OR`). On tables
|
|
40
|
+
with many fields, several relations, or large row counts, that makes the
|
|
41
|
+
search slow — and there is no built-in way to search a single field instead.
|
|
42
|
+
|
|
43
|
+
This package adds a small combobox next to the search box, letting the user
|
|
44
|
+
restrict the search to **one** field — or keep "All fields" for the native
|
|
45
|
+
behaviour.
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- Adds a `<select>` next to the changelist search box with one option per
|
|
50
|
+
`search_fields` entry, using a friendly label (`verbose_name`, following
|
|
51
|
+
relations, e.g. `author__name` → "Author › Name").
|
|
52
|
+
- Restricts the actual search query to the chosen field via the `sf` GET
|
|
53
|
+
parameter (name configurable).
|
|
54
|
+
- Works globally, for every registered `ModelAdmin`, via a single opt-in
|
|
55
|
+
call — no need to touch each `admin.py`.
|
|
56
|
+
- Also available as an explicit mixin (`SearchFieldSelectMixin`) if you'd
|
|
57
|
+
rather opt in per `ModelAdmin`.
|
|
58
|
+
- Defensive by design: any unexpected failure falls back to Django's native
|
|
59
|
+
search behaviour instead of breaking the admin page.
|
|
60
|
+
- Ships a template override (`admin/search_form.html`) and a small,
|
|
61
|
+
dependency-free CSS file that follows the admin's own light/dark theme
|
|
62
|
+
variables.
|
|
63
|
+
|
|
64
|
+
## Installation
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install django-admin-search-field
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Add the app to `INSTALLED_APPS`, **before** `django.contrib.admin` (required
|
|
71
|
+
so the bundled `admin/search_form.html` overrides Django's default template
|
|
72
|
+
via the `app_directories` template loader):
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
INSTALLED_APPS = [
|
|
76
|
+
"django_admin_search_field",
|
|
77
|
+
"django.contrib.admin",
|
|
78
|
+
...
|
|
79
|
+
]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Enable the selector once, globally — e.g. from your own app's
|
|
83
|
+
`AppConfig.ready()`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# myapp/apps.py
|
|
87
|
+
from django.apps import AppConfig
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MyAppConfig(AppConfig):
|
|
91
|
+
name = "myapp"
|
|
92
|
+
|
|
93
|
+
def ready(self):
|
|
94
|
+
from django_admin_search_field import install_search_field_selector
|
|
95
|
+
|
|
96
|
+
install_search_field_selector()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
(Optional) include the bundled CSS in your `admin/base_site.html` (or
|
|
100
|
+
wherever you already extend the admin base template):
|
|
101
|
+
|
|
102
|
+
```html
|
|
103
|
+
{% load static %}
|
|
104
|
+
<link rel="stylesheet" href="{% static 'django_admin_search_field/css/search_field.css' %}">
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
That's it — every `ModelAdmin` with `search_fields` configured now shows the
|
|
108
|
+
field selector.
|
|
109
|
+
|
|
110
|
+
## Usage without the global monkey-patch
|
|
111
|
+
|
|
112
|
+
If you'd rather enable this on a single `ModelAdmin`, skip
|
|
113
|
+
`install_search_field_selector()` and use the mixin instead:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from django.contrib import admin
|
|
117
|
+
from django_admin_search_field import SearchFieldSelectMixin
|
|
118
|
+
|
|
119
|
+
from myapp.models import Book
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@admin.register(Book)
|
|
123
|
+
class BookAdmin(SearchFieldSelectMixin, admin.ModelAdmin):
|
|
124
|
+
search_fields = ["title", "isbn", "author__name"]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
In this mode you're responsible for including the CSS/template yourself,
|
|
128
|
+
and you don't need to add `django_admin_search_field` to `INSTALLED_APPS` —
|
|
129
|
+
only the Python mixin is used.
|
|
130
|
+
|
|
131
|
+
## Configuration
|
|
132
|
+
|
|
133
|
+
| Setting | Default | Description |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `ADMIN_SEARCH_FIELD_VAR` | `"sf"` | Name of the GET parameter used to carry the chosen field. Override it if `sf` collides with something else in your project. |
|
|
136
|
+
|
|
137
|
+
## How it works
|
|
138
|
+
|
|
139
|
+
- `resolve_search_fields()` restricts `search_fields` to the field selected
|
|
140
|
+
via the `sf` GET parameter — or returns the original list unchanged when
|
|
141
|
+
`sf` is empty ("All fields") or points to a field that isn't configured.
|
|
142
|
+
- `sf` is registered in the changelist's `IGNORED_PARAMS`, so it's never
|
|
143
|
+
misread as a list filter (which would otherwise raise
|
|
144
|
+
`IncorrectLookupParameters`).
|
|
145
|
+
- The template only receives the `cl` (ChangeList) object, so the combobox
|
|
146
|
+
data is attached directly to it: `cl.search_field_choices`,
|
|
147
|
+
`cl.search_field_selected`, `cl.search_field_var`.
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install -e ".[dev]"
|
|
153
|
+
pytest
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Compatibility
|
|
157
|
+
|
|
158
|
+
- Python 3.10+
|
|
159
|
+
- Django 4.2+
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
django_admin_search_field/__init__.py
|
|
5
|
+
django_admin_search_field/apps.py
|
|
6
|
+
django_admin_search_field/fields.py
|
|
7
|
+
django_admin_search_field/install.py
|
|
8
|
+
django_admin_search_field.egg-info/PKG-INFO
|
|
9
|
+
django_admin_search_field.egg-info/SOURCES.txt
|
|
10
|
+
django_admin_search_field.egg-info/dependency_links.txt
|
|
11
|
+
django_admin_search_field.egg-info/requires.txt
|
|
12
|
+
django_admin_search_field.egg-info/top_level.txt
|
|
13
|
+
django_admin_search_field/static/django_admin_search_field/css/search_field.css
|
|
14
|
+
django_admin_search_field/templates/admin/search_form.html
|
|
15
|
+
tests/test_fields.py
|
|
16
|
+
tests/test_install.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_admin_search_field
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-admin-search-field"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Per-field search selector combobox for the Django admin changelist"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Fabio Valle" }]
|
|
14
|
+
keywords = ["django", "admin", "search", "changelist", "django-admin"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Web Environment",
|
|
18
|
+
"Framework :: Django",
|
|
19
|
+
"Framework :: Django :: 4.2",
|
|
20
|
+
"Framework :: Django :: 5.0",
|
|
21
|
+
"Framework :: Django :: 5.1",
|
|
22
|
+
"Framework :: Django :: 5.2",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"Django>=4.2",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8.0",
|
|
39
|
+
"pytest-django>=4.8",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/fdelvalle/django-admin-search-field"
|
|
44
|
+
Issues = "https://github.com/fdelvalle/django-admin-search-field/issues"
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
48
|
+
python_files = ["test_*.py"]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
pythonpath = ["."]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["."]
|
|
54
|
+
include = ["django_admin_search_field*"]
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.package-data]
|
|
57
|
+
django_admin_search_field = [
|
|
58
|
+
"templates/admin/*.html",
|
|
59
|
+
"static/django_admin_search_field/css/*.css",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[tool.ruff]
|
|
63
|
+
line-length = 120
|
|
64
|
+
target-version = "py310"
|
|
65
|
+
exclude = [".git", "__pycache__", "migrations", ".venv", "dist", "build"]
|
|
66
|
+
|
|
67
|
+
lint.select = ["E", "F", "I", "UP", "B", "C4", "SIM"]
|
|
68
|
+
lint.ignore = ["E501", "B008"]
|
|
69
|
+
fix = true
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint.isort]
|
|
72
|
+
known-first-party = ["django_admin_search_field"]
|
|
73
|
+
combine-as-imports = true
|
|
74
|
+
|
|
75
|
+
[tool.ruff.format]
|
|
76
|
+
quote-style = "double"
|
|
77
|
+
indent-style = "space"
|
|
78
|
+
line-ending = "lf"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Tests for django_admin_search_field.fields.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
* ``strip_search_prefix`` / ``label_for_search_field`` (friendly labels).
|
|
5
|
+
* ``resolve_search_fields`` (restricting search to the chosen field).
|
|
6
|
+
* ``build_search_field_choices`` (options exposed to the template).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from unittest.mock import MagicMock
|
|
10
|
+
|
|
11
|
+
from django.test import RequestFactory, SimpleTestCase
|
|
12
|
+
|
|
13
|
+
from django_admin_search_field.fields import (
|
|
14
|
+
build_search_field_choices,
|
|
15
|
+
label_for_search_field,
|
|
16
|
+
resolve_search_fields,
|
|
17
|
+
strip_search_prefix,
|
|
18
|
+
)
|
|
19
|
+
from tests.testapp.models import Book
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _request(sf=None):
|
|
23
|
+
factory = RequestFactory()
|
|
24
|
+
data = {}
|
|
25
|
+
if sf is not None:
|
|
26
|
+
data["sf"] = sf
|
|
27
|
+
return factory.get("/admin/", data)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StripPrefixTests(SimpleTestCase):
|
|
31
|
+
def test_strips_known_prefixes(self):
|
|
32
|
+
self.assertEqual(strip_search_prefix("^title"), "title")
|
|
33
|
+
self.assertEqual(strip_search_prefix("=isbn"), "isbn")
|
|
34
|
+
self.assertEqual(strip_search_prefix("@title"), "title")
|
|
35
|
+
|
|
36
|
+
def test_no_prefix_unchanged(self):
|
|
37
|
+
self.assertEqual(strip_search_prefix("author__name"), "author__name")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LabelTests(SimpleTestCase):
|
|
41
|
+
def test_label_uses_verbose_name(self):
|
|
42
|
+
# verbose_name="ISBN" is title-cased by .capitalize(), same as any
|
|
43
|
+
# other field label in this module.
|
|
44
|
+
label = label_for_search_field(Book, "isbn")
|
|
45
|
+
self.assertEqual(label, "Isbn")
|
|
46
|
+
|
|
47
|
+
def test_label_follows_relation(self):
|
|
48
|
+
label = label_for_search_field(Book, "author__name")
|
|
49
|
+
self.assertIn("›", label)
|
|
50
|
+
|
|
51
|
+
def test_label_strips_prefix(self):
|
|
52
|
+
self.assertEqual(
|
|
53
|
+
label_for_search_field(Book, "=title"),
|
|
54
|
+
label_for_search_field(Book, "title"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def test_label_fallback_for_unknown_field(self):
|
|
58
|
+
label = label_for_search_field(Book, "does_not_exist")
|
|
59
|
+
self.assertEqual(label, "Does not exist")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ResolveSearchFieldsTests(SimpleTestCase):
|
|
63
|
+
def setUp(self):
|
|
64
|
+
self.fields = ("title", "isbn", "author__name")
|
|
65
|
+
|
|
66
|
+
def test_all_when_no_sf(self):
|
|
67
|
+
self.assertEqual(resolve_search_fields(self.fields, _request()), self.fields)
|
|
68
|
+
self.assertEqual(resolve_search_fields(self.fields, _request("")), self.fields)
|
|
69
|
+
|
|
70
|
+
def test_restricts_to_selected(self):
|
|
71
|
+
out = resolve_search_fields(self.fields, _request("title"))
|
|
72
|
+
self.assertEqual(out, ("title",))
|
|
73
|
+
|
|
74
|
+
def test_ignores_unknown_field(self):
|
|
75
|
+
out = resolve_search_fields(self.fields, _request("hacker__field"))
|
|
76
|
+
self.assertEqual(out, self.fields)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BuildChoicesTests(SimpleTestCase):
|
|
80
|
+
def test_choices_include_all_fields_with_selection(self):
|
|
81
|
+
admin_obj = MagicMock()
|
|
82
|
+
admin_obj.model = Book
|
|
83
|
+
admin_obj._original_search_fields = ("title", "isbn", "author__name")
|
|
84
|
+
choices = build_search_field_choices(admin_obj, _request("isbn"))
|
|
85
|
+
values = [c["value"] for c in choices]
|
|
86
|
+
self.assertEqual(values, ["title", "isbn", "author__name"])
|
|
87
|
+
selected = [c["value"] for c in choices if c["selected"]]
|
|
88
|
+
self.assertEqual(selected, ["isbn"])
|
|
89
|
+
for c in choices:
|
|
90
|
+
self.assertTrue(c["label"])
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Integration tests for django_admin_search_field.install.
|
|
2
|
+
|
|
3
|
+
Uses the real BookAdmin (registered in tests/testapp/admin.py) with the
|
|
4
|
+
global monkey-patch applied, plus a template-resolution check confirming the
|
|
5
|
+
packaged admin/search_form.html actually overrides Django's default one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from django.contrib import admin
|
|
9
|
+
from django.contrib.auth import get_user_model
|
|
10
|
+
from django.template import loader
|
|
11
|
+
from django.test import RequestFactory, TestCase
|
|
12
|
+
|
|
13
|
+
from django_admin_search_field.install import install_search_field_selector
|
|
14
|
+
from tests.testapp.models import Book
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _request(sf=None):
|
|
18
|
+
factory = RequestFactory()
|
|
19
|
+
data = {}
|
|
20
|
+
if sf is not None:
|
|
21
|
+
data["sf"] = sf
|
|
22
|
+
return factory.get("/admin/testapp/book/", data)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InstallIdempotencyTests(TestCase):
|
|
26
|
+
def test_install_is_idempotent(self):
|
|
27
|
+
install_search_field_selector()
|
|
28
|
+
patched_get_search_fields = admin.ModelAdmin.get_search_fields
|
|
29
|
+
install_search_field_selector()
|
|
30
|
+
self.assertIs(admin.ModelAdmin.get_search_fields, patched_get_search_fields)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ModelAdminIntegrationTests(TestCase):
|
|
34
|
+
def setUp(self):
|
|
35
|
+
install_search_field_selector()
|
|
36
|
+
self.model_admin = admin.site._registry[Book]
|
|
37
|
+
|
|
38
|
+
def test_get_search_fields_all_by_default(self):
|
|
39
|
+
req = _request()
|
|
40
|
+
fields = self.model_admin.get_search_fields(req)
|
|
41
|
+
self.assertIn("title", fields)
|
|
42
|
+
self.assertIn("author__name", fields)
|
|
43
|
+
|
|
44
|
+
def test_get_search_fields_restricted_by_sf(self):
|
|
45
|
+
req = _request("title")
|
|
46
|
+
fields = self.model_admin.get_search_fields(req)
|
|
47
|
+
self.assertEqual(tuple(fields), ("title",))
|
|
48
|
+
# The full list stays available for the template.
|
|
49
|
+
self.assertIn("author__name", self.model_admin._original_search_fields)
|
|
50
|
+
|
|
51
|
+
def test_changelist_instance_exposes_choices(self):
|
|
52
|
+
User = get_user_model()
|
|
53
|
+
user = User.objects.create_superuser(username="admin_sf", email="a@b.co", password="x")
|
|
54
|
+
req = _request("title")
|
|
55
|
+
req.user = user
|
|
56
|
+
cl = self.model_admin.get_changelist_instance(req)
|
|
57
|
+
self.assertTrue(getattr(cl, "search_field_choices", None))
|
|
58
|
+
self.assertEqual(cl.search_field_selected, "title")
|
|
59
|
+
self.assertEqual(cl.search_field_var, "sf")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TemplateOverrideTests(TestCase):
|
|
63
|
+
def test_packaged_template_wins_over_django_default(self):
|
|
64
|
+
template = loader.get_template("admin/search_form.html")
|
|
65
|
+
origin_name = template.template.origin.name
|
|
66
|
+
self.assertIn("django_admin_search_field", origin_name)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ChangelistRenderTests(TestCase):
|
|
70
|
+
"""Full-stack check: hit the real changelist view and inspect the HTML."""
|
|
71
|
+
|
|
72
|
+
def setUp(self):
|
|
73
|
+
install_search_field_selector()
|
|
74
|
+
User = get_user_model()
|
|
75
|
+
self.user = User.objects.create_superuser(username="root", email="root@example.com", password="x")
|
|
76
|
+
self.client.force_login(self.user)
|
|
77
|
+
|
|
78
|
+
def test_combobox_rendered_with_all_options(self):
|
|
79
|
+
response = self.client.get("/admin/testapp/book/")
|
|
80
|
+
self.assertEqual(response.status_code, 200)
|
|
81
|
+
html = response.content.decode()
|
|
82
|
+
self.assertIn('id="search-field-select"', html)
|
|
83
|
+
self.assertIn(">Title<", html)
|
|
84
|
+
self.assertIn(">Author › Name<", html)
|
|
85
|
+
|
|
86
|
+
def test_selected_field_marked_in_combobox(self):
|
|
87
|
+
response = self.client.get("/admin/testapp/book/", {"sf": "title"})
|
|
88
|
+
self.assertEqual(response.status_code, 200)
|
|
89
|
+
html = response.content.decode()
|
|
90
|
+
self.assertIn('value="title" selected', html)
|