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.
@@ -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,5 @@
1
+ Django>=4.2
2
+
3
+ [dev]
4
+ pytest>=8.0
5
+ pytest-django>=4.8
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)