Skip to content

Commit a6c0e64

Browse files
committed
[change] Migrate from django-admin-autocomplete-filter to dalf #582
Replace the unmaintained and license-incompatible django-admin-autocomplete-filter with django-admin-list-filter (dalf), an actively maintained MIT-licensed alternative that uses Django's native admin autocomplete infrastructure. Changes: - Updated dependencies in setup.py to use dalf>=0.7.0,<1.0.0 - Rewrote AutocompleteFilter to extend DALFRelatedFieldAjax - Simplified AutocompleteJsonView to use Django's native implementation - Added robust error handling for invalid filter parameters (e.g., invalid UUIDs) - Updated admin classes to inherit from DALFModelAdmin - Modified filter usage from custom classes to tuple format - Updated documentation and templates - Fixed reverse relation support in autocomplete view Fixes #582
1 parent bb843d9 commit a6c0e64

File tree

15 files changed

+201
-176
lines changed

15 files changed

+201
-176
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
*.min.js
22
*.min.css
3+

docs/developer/admin-theme.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Make sure ``openwisp_utils.admin_theme`` is listed in ``INSTALLED_APPS``
2222
"django.contrib.staticfiles",
2323
"openwisp_utils.admin_theme", # <----- add this
2424
# add when using autocomplete filter
25-
"admin_auto_filters", # <----- add this
25+
"dalf", # <----- add this (django-admin-list-filter)
2626
"django.contrib.sites",
2727
# admin
2828
"django.contrib.admin",

docs/developer/admin-utilities.rst

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -185,37 +185,40 @@ following example:
185185
---------------------------------------------------------
186186

187187
The ``admin_theme`` sub app of this package provides an auto complete
188-
filter that uses the *django-autocomplete* widget to load filter data
188+
filter that uses Django's native autocomplete widget to load filter data
189189
asynchronously.
190190

191+
This filter is powered by `django-admin-list-filter (dalf)
192+
<https://github.com/vigo/django-admin-list-filter>`_, a lightweight and
193+
actively maintained library that uses Django's built-in admin autocomplete
194+
infrastructure.
195+
191196
This filter can be helpful when the number of objects is too large to load
192197
all at once which may cause the slow loading of the page.
193198

194199
.. code-block:: python
195200
196201
from django.contrib import admin
202+
from dalf.admin import DALFModelAdmin
197203
from openwisp_utils.admin_theme.filters import AutocompleteFilter
198204
from my_app.models import MyModel, MyOtherModel
199205
200206
201-
class MyAutoCompleteFilter(AutocompleteFilter):
202-
field_name = "field"
203-
parameter_name = "field_id"
204-
title = _("My Field")
205-
206-
207207
@admin.register(MyModel)
208-
class MyModelAdmin(admin.ModelAdmin):
209-
list_filter = [MyAutoCompleteFilter, ...]
208+
class MyModelAdmin(DALFModelAdmin):
209+
list_filter = [
210+
("field", AutocompleteFilter),
211+
# ... other filters
212+
]
210213
211214
212215
@admin.register(MyOtherModel)
213216
class MyOtherModelAdmin(admin.ModelAdmin):
214-
search_fields = ["id"]
217+
# The related model must have search_fields defined
218+
search_fields = ["name", "id"]
215219
216-
To customize or know more about it, please refer to the
217-
`django-admin-autocomplete-filter documentation
218-
<https://github.com/farhan0581/django-admin-autocomplete-filter#usage>`_.
220+
For more details, see the `django-admin-list-filter documentation
221+
<https://github.com/vigo/django-admin-list-filter>`_.
219222

220223
Customizing the Submit Row in OpenWISP Admin
221224
--------------------------------------------

docs/user/settings.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,10 @@ default ``'openwisp_utils.admin_theme.views.AutocompleteJsonView'``
263263

264264
Dotted path to the ``AutocompleteJsonView`` used by the
265265
``openwisp_utils.admin_theme.filters.AutocompleteFilter``.
266+
267+
.. note::
268+
269+
With the migration to `django-admin-list-filter (dalf)
270+
<https://github.com/vigo/django-admin-list-filter>`_, this setting is
271+
deprecated as DALF uses Django's native admin autocomplete
272+
infrastructure.

openwisp_utils/admin_theme/filters.py

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
from admin_auto_filters.filters import AutocompleteFilter as BaseAutocompleteFilter
1+
from dalf.admin import DALFRelatedFieldAjax
22
from django.contrib import messages
33
from django.contrib.admin.filters import FieldListFilter, SimpleListFilter
44
from django.contrib.admin.utils import NotRelationField, get_model_from_relation
55
from django.core.exceptions import ImproperlyConfigured, ValidationError
66
from django.db.models.fields import CharField, UUIDField
7-
from django.urls import reverse
87
from django.utils.translation import gettext_lazy as _
98

109

@@ -28,9 +27,9 @@ def choices(self, changelist):
2827
yield all_choice
2928

3029
def value(self):
31-
"""Returns the querystring for this filter
30+
"""Return the querystring for this filter.
3231
33-
If no querystring was supllied, will return None.
32+
If no querystring was supplied, will return None.
3433
"""
3534
return self.used_parameters.get(self.parameter_name)
3635

@@ -92,29 +91,70 @@ def expected_parameters(self):
9291
return [self.lookup_kwarg, self.lookup_kwarg_isnull]
9392

9493

95-
class AutocompleteFilter(BaseAutocompleteFilter):
96-
template = "admin/auto_filter.html"
97-
widget_attrs = {
98-
"data-dropdown-css-class": "ow2-autocomplete-dropdown",
99-
"data-empty-label": "-",
100-
}
101-
102-
class Media:
103-
css = {
104-
"screen": ("admin/css/ow-auto-filter.css",),
105-
}
106-
js = BaseAutocompleteFilter.Media.js + ("admin/js/ow-auto-filter.js",)
94+
class AutocompleteFilter(DALFRelatedFieldAjax):
95+
"""AutocompleteFilter for Django admin using DALF.
96+
97+
This filter provides autocomplete functionality for foreign key and
98+
many-to-many relationships using Django's native admin autocomplete
99+
infrastructure.
100+
101+
Usage:
102+
.. code-block:: python
107103
108-
def get_autocomplete_url(self, request, model_admin):
109-
return reverse("admin:ow-auto-filter")
104+
class MyFilter(AutocompleteFilter):
105+
title = "My Field"
106+
field_name = "my_field"
107+
parameter_name = "my_field__id"
108+
"""
110109

111-
def __init__(self, *args, **kwargs):
110+
template = "admin/auto_filter.html"
111+
112+
def __init__(self, field, request, params, model, model_admin, field_path):
112113
try:
113-
return super().__init__(*args, **kwargs)
114-
except ValidationError:
115-
None
114+
super().__init__(field, request, params, model, model_admin, field_path)
115+
except (ValidationError, ValueError) as e:
116+
# If there's a validation error (e.g., invalid UUID), initialize without error
117+
# but store the error to display later
118+
self._init_error = e
119+
# Initialize basic attributes manually to prevent AttributeError
120+
self.field = field
121+
self.field_path = field_path
122+
self.title = getattr(field, "verbose_name", field_path)
123+
self.used_parameters = {}
124+
# Required for Django's filter protocol
125+
try:
126+
from django.contrib.admin.filters import FieldListFilter
127+
128+
# Call the grandparent's __init__ to set up basic filter infrastructure
129+
FieldListFilter.__init__(
130+
self, field, request, params, model, model_admin, field_path
131+
)
132+
except Exception:
133+
pass
134+
135+
def expected_parameters(self):
136+
"""Return expected parameters for this filter."""
137+
if hasattr(self, "_init_error"):
138+
return []
139+
return super().expected_parameters()
140+
141+
def choices(self, changelist):
142+
"""Return choices for this filter."""
143+
if hasattr(self, "_init_error"):
144+
# Return empty choices if initialization failed
145+
return []
146+
return super().choices(changelist)
116147

117148
def queryset(self, request, queryset):
149+
# If there was an initialization error, show it and return unfiltered queryset
150+
if hasattr(self, "_init_error"):
151+
if isinstance(self._init_error, ValidationError):
152+
error_msg = " ".join(self._init_error.messages)
153+
else:
154+
error_msg = str(self._init_error)
155+
messages.error(request, error_msg)
156+
return queryset
157+
118158
try:
119159
return super().queryset(request, queryset)
120160
except ValidationError as e:

openwisp_utils/admin_theme/static/admin/js/ow-auto-filter.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use strict";
22
django.jQuery(document).ready(function () {
3-
// unbinding default event handlers of admin_auto_filters
3+
// unbinding default event handlers of autocomplete filters (DALF)
44
django.jQuery("#changelist-filter select, #grp-filters select").off("change");
55
django.jQuery("#changelist-filter select, #grp-filters select").off("clear");
66

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
{% load i18n %}
2-
<div class="ow-filter auto-filter">
3-
{% include 'django-admin-autocomplete-filter/autocomplete-filter.html' %}
4-
<div class="auto-filter-choices"></div>
2+
<div class="ow-filter ow-autocomplete-filter">
3+
{% with params=choices|last %}
4+
<div class="filter-title">
5+
<h3>{% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %}</h3>
6+
<input class="djal-selected-value" type="hidden" value="{{ params.selected_value|default_if_none:'' }}" />
7+
<input class="djal-selected-text" type="hidden" value="{{ params.selected_text|default_if_none:'' }}" />
8+
<select
9+
class="django-admin-list-filter-ajax"
10+
data-ajax-url="{{ params.ajax_url }}"
11+
data-app-label="{{ params.app_label }}"
12+
data-model-name="{{ params.model_name }}"
13+
data-lookup-kwarg="{{ params.lookup_kwarg }}"
14+
data-theme="admin-autocomplete"
15+
data-field-name="{{ params.field_name }}"></select>
16+
</div>
17+
<div class="filter-options">
18+
<!-- Hidden anchor for filter button to detect changes -->
19+
<a name="{{ params.lookup_kwarg }}" parameter_name="{{ params.lookup_kwarg }}"></a>
20+
</div>
21+
{% endwith %}
522
</div>

openwisp_utils/admin_theme/templatetags/ow_tags.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ def ow_create_filter(cl, spec, total_filters):
1111
choices = list(spec.choices(cl))
1212
selected_choice = None
1313
for choice in choices:
14-
if choice["selected"]:
15-
selected_choice = choice["display"]
14+
if choice.get("selected", False):
15+
selected_choice = choice.get("display", "")
1616
return tpl.render(
1717
{
1818
"title": spec.title,
Lines changed: 10 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,14 @@
1-
from admin_auto_filters.views import AutocompleteJsonView as BaseAutocompleteJsonView
2-
from django.core.exceptions import PermissionDenied
3-
from django.http import JsonResponse
1+
from django.contrib.admin.views.autocomplete import (
2+
AutocompleteJsonView as DjangoAutocompleteJsonView,
3+
)
44

55

6-
class AutocompleteJsonView(BaseAutocompleteJsonView):
7-
admin_site = None
6+
class AutocompleteJsonView(DjangoAutocompleteJsonView):
87

9-
def get_empty_label(self):
10-
return "-"
11-
12-
def get_allow_null(self):
13-
return True
14-
15-
def get(self, request, *args, **kwargs):
16-
(
17-
self.term,
18-
self.model_admin,
19-
self.source_field,
20-
_,
21-
) = self.process_request(request)
22-
23-
if not self.has_perm(request):
24-
raise PermissionDenied
25-
26-
self.support_reverse_relation()
27-
self.object_list = self.get_queryset()
28-
context = self.get_context_data()
29-
# Add option for filtering objects with None field.
30-
results = []
31-
empty_label = self.get_empty_label()
32-
if (
33-
getattr(self.source_field, "null", False)
34-
and self.get_allow_null()
35-
and not getattr(self.source_field, "_get_limit_choices_to_mocked", False)
36-
and not self.term
37-
or self.term == empty_label
38-
):
39-
# The select2 library requires data in a specific format
40-
# https://select2.org/data-sources/formats.
41-
# select2 does not render option with blank "id" (i.e. '').
42-
# Therefore, "null" is used here for "id".
43-
results += [{"id": "null", "text": empty_label}]
44-
results += [
45-
{"id": str(obj.pk), "text": self.display_text(obj)}
46-
for obj in context["object_list"]
47-
]
48-
return JsonResponse(
49-
{
50-
"results": results,
51-
"pagination": {"more": context["page_obj"].has_next()},
52-
}
53-
)
54-
55-
def support_reverse_relation(self):
8+
def get_queryset(self):
9+
"""Override to support reverse relations without get_limit_choices_to()."""
10+
# Handle reverse relations that don't have get_limit_choices_to
5611
if not hasattr(self.source_field, "get_limit_choices_to"):
57-
self.source_field._get_limit_choices_to_mocked = True
58-
59-
def get_choices_mock():
60-
return {}
61-
62-
self.source_field.get_limit_choices_to = get_choices_mock
12+
# Mock the method for reverse relations
13+
self.source_field.get_limit_choices_to = lambda: {}
14+
return super().get_queryset()

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
install_requires=[
2828
"django-model-utils>=4.5,<5.1",
2929
"django-minify-compress-staticfiles~=1.1.0",
30-
"django-admin-autocomplete-filter~=0.7.1",
30+
"dalf>=0.7.0,<1.0.0",
3131
"swapper~=1.4.0",
3232
# allow wider range here to avoid interfering with other modules
3333
"urllib3>=2.0.0,<3.0.0",

0 commit comments

Comments
 (0)