Skip to content

Commit 25995b0

Browse files
authored
feat: command searchable models (#1559)
1 parent 9d4bfb3 commit 25995b0

File tree

4 files changed

+141
-17
lines changed

4 files changed

+141
-17
lines changed

docs/configuration/command.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,27 @@ UNFOLD = {
2828

2929
Command results use infinite scrolling with a default page size of 100 results. When the last item becomes visible in the viewport, a new page of results is automatically loaded and appended to the existing list, allowing continuous browsing through search results.
3030

31+
## Search only specific models
32+
33+
- `search_models` accepts `list` or `tuple` of allowed models which can be searched
34+
35+
```python
36+
UNFOLD = {
37+
# ...
38+
"COMMAND": {
39+
"search_models": ["example.sample"], # List or tuple
40+
# "search_models": "example.utils.search_models_callback"
41+
},
42+
# ...
43+
}
44+
45+
# utils.py
46+
def search_models_callback(request):
47+
return [
48+
"example.sample",
49+
]
50+
```
51+
3152
## Custom search callback
3253

3354
The search callback feature provides a way to define a custom hook that can inject additional content into search results. This is particularly useful when you want to search for results from external sources or services beyond the Django admin interface.

src/unfold/sites.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,23 @@ def _search_apps(
210210
return results
211211

212212
def _search_models(
213-
self, request: HttpRequest, app_list: list[dict[str, Any]], search_term: str
213+
self,
214+
request: HttpRequest,
215+
app_list: list[dict[str, Any]],
216+
search_term: str,
217+
allowed_models: Optional[list[str]] = None,
214218
) -> list[SearchResult]:
215219
results = []
216220

217221
for app in app_list:
218222
for model in app["models"]:
223+
# Skip models which are not allowed
224+
if isinstance(allowed_models, (list, tuple)):
225+
if model["model"]._meta.label.lower() not in [
226+
m.lower() for m in allowed_models
227+
]:
228+
continue
229+
219230
admin_instance = self._registry.get(model["model"])
220231
search_fields = admin_instance.get_search_fields(request)
221232

@@ -278,26 +289,41 @@ def search(
278289
results = cache_results
279290
else:
280291
results = self._search_apps(app_list, search_term)
281-
search_models = self._get_config("COMMAND", request).get("search_models")
282-
search_callback = self._get_config("COMMAND", request).get(
283-
"search_callback"
284-
)
285292

286293
if extended_search:
287-
if search_callback:
294+
if search_callback := self._get_config("COMMAND", request).get(
295+
"search_callback"
296+
):
288297
results.extend(
289298
self._get_value(search_callback, request, search_term)
290299
)
291300

292-
if search_models is True:
293-
results.extend(self._search_models(request, app_list, search_term))
301+
search_models = self._get_value(
302+
self._get_config("COMMAND", request).get("search_models"), request
303+
)
304+
305+
if search_models is True or isinstance(search_models, (list, tuple)):
306+
allowed_models = (
307+
search_models
308+
if isinstance(search_models, (list, tuple))
309+
else None
310+
)
311+
312+
results.extend(
313+
self._search_models(
314+
request, app_list, search_term, allowed_models
315+
)
316+
)
294317

295318
cache.set(cache_key, results, timeout=CACHE_TIMEOUT)
296319

297320
execution_time = time.time() - start_time
298-
299321
paginator = Paginator(results, PER_PAGE)
300322

323+
show_history = self._get_value(
324+
self._get_config("COMMAND", request).get("show_history"), request
325+
)
326+
301327
return TemplateResponse(
302328
request,
303329
template=template_name,
@@ -306,9 +332,7 @@ def search(
306332
"results": paginator.page(request.GET.get("page", 1)),
307333
"page_counter": (int(request.GET.get("page", 1)) - 1) * PER_PAGE,
308334
"execution_time": execution_time,
309-
"command_show_history": self._get_config("COMMAND", request).get(
310-
"show_history"
311-
),
335+
"command_show_history": show_history,
312336
},
313337
headers={
314338
"HX-Trigger": "search",

src/unfold/static/unfold/js/app.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,25 @@ function searchCommand() {
137137
return;
138138
}
139139

140-
this.items = document
141-
.getElementById("command-results-list")
142-
.querySelectorAll("li");
143-
this.totalItems = this.items.length;
140+
const commandResultsList = document.getElementById(
141+
"command-results-list"
142+
);
143+
if (commandResultsList) {
144+
this.items = commandResultsList.querySelectorAll("li");
145+
this.totalItems = this.items.length;
146+
} else {
147+
this.items = undefined;
148+
this.totalItems = 0;
149+
}
144150

145151
if (event.target.id === "command-results") {
146152
this.currentIndex = 0;
147-
this.totalItems = this.items.length;
153+
154+
if (this.items) {
155+
this.totalItems = this.items.length;
156+
} else {
157+
this.totalItems = 0;
158+
}
148159
}
149160

150161
this.hasResults = this.totalItems > 0;

tests/test_command.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,71 @@ def test_command_search_extended_model_with_permission(
108108
)
109109
assert response.status_code == HTTPStatus.OK
110110
assert "sample-test-tag-with-permission" in response.content.decode()
111+
112+
113+
@pytest.mark.django_db
114+
@override_settings(
115+
CACHES={"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}
116+
)
117+
def test_command_allowed_models(admin_client, admin_user, tag_factory):
118+
tag_factory(name="another-test-tag")
119+
120+
with override_settings(
121+
UNFOLD={
122+
**CONFIG_DEFAULTS,
123+
**{
124+
"COMMAND": {
125+
"search_models": False,
126+
}
127+
},
128+
}
129+
):
130+
response = admin_client.get(
131+
reverse("admin:search") + "?s=another-test-tag&extended=1"
132+
)
133+
assert "another-test-tag" not in response.content.decode()
134+
135+
with override_settings(
136+
UNFOLD={
137+
**CONFIG_DEFAULTS,
138+
**{
139+
"COMMAND": {
140+
"search_models": True,
141+
}
142+
},
143+
}
144+
):
145+
response = admin_client.get(
146+
reverse("admin:search") + "?s=another-test-tag&extended=1"
147+
)
148+
assert "another-test-tag" in response.content.decode()
149+
150+
with override_settings(
151+
UNFOLD={
152+
**CONFIG_DEFAULTS,
153+
**{
154+
"COMMAND": {
155+
"search_models": [],
156+
}
157+
},
158+
}
159+
):
160+
response = admin_client.get(
161+
reverse("admin:search") + "?s=another-test-tag&extended=1"
162+
)
163+
assert "another-test-tag" not in response.content.decode()
164+
165+
with override_settings(
166+
UNFOLD={
167+
**CONFIG_DEFAULTS,
168+
**{
169+
"COMMAND": {
170+
"search_models": ["example.tag"],
171+
}
172+
},
173+
}
174+
):
175+
response = admin_client.get(
176+
reverse("admin:search") + "?s=another-test-tag&extended=1"
177+
)
178+
assert "another-test-tag" in response.content.decode()

0 commit comments

Comments
 (0)