Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
*.min.js
*.min.css

2 changes: 1 addition & 1 deletion docs/developer/admin-theme.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Make sure ``openwisp_utils.admin_theme`` is listed in ``INSTALLED_APPS``
"django.contrib.staticfiles",
"openwisp_utils.admin_theme", # <----- add this
# add when using autocomplete filter
"admin_auto_filters", # <----- add this
"dalf", # <----- add this (django-admin-list-filter)
"django.contrib.sites",
# admin
"django.contrib.admin",
Expand Down
29 changes: 16 additions & 13 deletions docs/developer/admin-utilities.rst
Original file line number Diff line number Diff line change
Expand Up @@ -185,37 +185,40 @@ following example:
---------------------------------------------------------

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

This filter is powered by `django-admin-list-filter (dalf)
<https://github.com/vigo/django-admin-list-filter>`_, a lightweight and
actively maintained library that uses Django's built-in admin autocomplete
infrastructure.

This filter can be helpful when the number of objects is too large to load
all at once which may cause the slow loading of the page.

.. code-block:: python

from django.contrib import admin
from dalf.admin import DALFModelAdmin
from openwisp_utils.admin_theme.filters import AutocompleteFilter
from my_app.models import MyModel, MyOtherModel


class MyAutoCompleteFilter(AutocompleteFilter):
field_name = "field"
parameter_name = "field_id"
title = _("My Field")


@admin.register(MyModel)
class MyModelAdmin(admin.ModelAdmin):
list_filter = [MyAutoCompleteFilter, ...]
class MyModelAdmin(DALFModelAdmin):
list_filter = [
("field", AutocompleteFilter),
# ... other filters
]


@admin.register(MyOtherModel)
class MyOtherModelAdmin(admin.ModelAdmin):
search_fields = ["id"]
# The related model must have search_fields defined
search_fields = ["name", "id"]

To customize or know more about it, please refer to the
`django-admin-autocomplete-filter documentation
<https://github.com/farhan0581/django-admin-autocomplete-filter#usage>`_.
For more details, see the `django-admin-list-filter documentation
<https://github.com/vigo/django-admin-list-filter>`_.

Customizing the Submit Row in OpenWISP Admin
--------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions docs/user/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,10 @@ default ``'openwisp_utils.admin_theme.views.AutocompleteJsonView'``

Dotted path to the ``AutocompleteJsonView`` used by the
``openwisp_utils.admin_theme.filters.AutocompleteFilter``.

.. note::

With the migration to `django-admin-list-filter (dalf)
<https://github.com/vigo/django-admin-list-filter>`_, this setting is
deprecated as DALF uses Django's native admin autocomplete
infrastructure.
5 changes: 5 additions & 0 deletions openwisp_utils/admin_theme/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ def openwisp_info(self, request, *args, **kwargs):
self.metric_collection.manage_form(request, context)
return render(request, "admin/openwisp_info.html", context)

def autocomplete_view(self, request):
"""Override to use custom AutocompleteJsonView that supports reverse relations."""
autocomplete_view = import_string(app_settings.AUTOCOMPLETE_FILTER_VIEW)
return autocomplete_view.as_view(admin_site=self)(request)

def get_urls(self):
autocomplete_view = import_string(app_settings.AUTOCOMPLETE_FILTER_VIEW)
return [
Expand Down
84 changes: 62 additions & 22 deletions openwisp_utils/admin_theme/filters.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from admin_auto_filters.filters import AutocompleteFilter as BaseAutocompleteFilter
from dalf.admin import DALFRelatedFieldAjax
from django.contrib import messages
from django.contrib.admin.filters import FieldListFilter, SimpleListFilter
from django.contrib.admin.utils import NotRelationField, get_model_from_relation
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db.models.fields import CharField, UUIDField
from django.urls import reverse
from django.utils.translation import gettext_lazy as _


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

def value(self):
"""Returns the querystring for this filter
"""Return the querystring for this filter.

If no querystring was supllied, will return None.
If no querystring was supplied, will return None.
"""
return self.used_parameters.get(self.parameter_name)

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


class AutocompleteFilter(BaseAutocompleteFilter):
template = "admin/auto_filter.html"
widget_attrs = {
"data-dropdown-css-class": "ow2-autocomplete-dropdown",
"data-empty-label": "-",
}

class Media:
css = {
"screen": ("admin/css/ow-auto-filter.css",),
}
js = BaseAutocompleteFilter.Media.js + ("admin/js/ow-auto-filter.js",)
class AutocompleteFilter(DALFRelatedFieldAjax):
"""AutocompleteFilter for Django admin using DALF.

This filter provides autocomplete functionality for foreign key and
many-to-many relationships using Django's native admin autocomplete
infrastructure.

Usage:
.. code-block:: python

def get_autocomplete_url(self, request, model_admin):
return reverse("admin:ow-auto-filter")
class MyFilter(AutocompleteFilter):
title = "My Field"
field_name = "my_field"
parameter_name = "my_field__id"
"""

def __init__(self, *args, **kwargs):
template = "admin/auto_filter.html"

def __init__(self, field, request, params, model, model_admin, field_path):
try:
return super().__init__(*args, **kwargs)
except ValidationError:
None
super().__init__(field, request, params, model, model_admin, field_path)
except (ValidationError, ValueError) as e:
# If there's a validation error (e.g., invalid UUID), initialize without error
# but store the error to display later
self._init_error = e
# Initialize basic attributes manually to prevent AttributeError
self.field = field
self.field_path = field_path
self.title = getattr(field, "verbose_name", field_path)
self.used_parameters = {}
# Required for Django's filter protocol
try:
from django.contrib.admin.filters import FieldListFilter

# Call the grandparent's __init__ to set up basic filter infrastructure
FieldListFilter.__init__(
self, field, request, params, model, model_admin, field_path
)
except Exception:
pass

def expected_parameters(self):
"""Return expected parameters for this filter."""
if hasattr(self, "_init_error"):
return []
return super().expected_parameters()

def choices(self, changelist):
"""Return choices for this filter."""
if hasattr(self, "_init_error"):
# Return empty choices if initialization failed
return []
return super().choices(changelist)

def queryset(self, request, queryset):
# If there was an initialization error, show it and return unfiltered queryset
if hasattr(self, "_init_error"):
if isinstance(self._init_error, ValidationError):
error_msg = " ".join(self._init_error.messages)
else:
error_msg = str(self._init_error)
messages.error(request, error_msg)
return queryset

try:
return super().queryset(request, queryset)
except ValidationError as e:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";
django.jQuery(document).ready(function () {
// unbinding default event handlers of admin_auto_filters
// unbinding default event handlers of autocomplete filters (DALF)
django.jQuery("#changelist-filter select, #grp-filters select").off("change");
django.jQuery("#changelist-filter select, #grp-filters select").off("clear");

Expand Down
23 changes: 20 additions & 3 deletions openwisp_utils/admin_theme/templates/admin/auto_filter.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
{% load i18n %}
<div class="ow-filter auto-filter">
{% include 'django-admin-autocomplete-filter/autocomplete-filter.html' %}
<div class="auto-filter-choices"></div>
<div class="ow-filter ow-autocomplete-filter">
{% with params=choices|last %}
<div class="filter-title">
<h3>{% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %}</h3>
<input class="djal-selected-value" type="hidden" value="{{ params.selected_value|default_if_none:'' }}" />
<input class="djal-selected-text" type="hidden" value="{{ params.selected_text|default_if_none:'' }}" />
<select
class="django-admin-list-filter-ajax"
data-ajax-url="{{ params.ajax_url }}"
data-app-label="{{ params.app_label }}"
data-model-name="{{ params.model_name }}"
data-lookup-kwarg="{{ params.lookup_kwarg }}"
data-theme="admin-autocomplete"
data-field-name="{{ params.field_name }}"></select>
</div>
<div class="filter-options">
<!-- Hidden anchor for filter button to detect changes -->
<a name="{{ params.lookup_kwarg }}" parameter_name="{{ params.lookup_kwarg }}"></a>
</div>
{% endwith %}
</div>
4 changes: 2 additions & 2 deletions openwisp_utils/admin_theme/templatetags/ow_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ def ow_create_filter(cl, spec, total_filters):
choices = list(spec.choices(cl))
selected_choice = None
for choice in choices:
if choice["selected"]:
selected_choice = choice["display"]
if choice.get("selected", False):
selected_choice = choice.get("display", "")
return tpl.render(
{
"title": spec.title,
Expand Down
71 changes: 13 additions & 58 deletions openwisp_utils/admin_theme/views.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,17 @@
from admin_auto_filters.views import AutocompleteJsonView as BaseAutocompleteJsonView
from django.core.exceptions import PermissionDenied
from django.http import JsonResponse
from django.contrib.admin.views.autocomplete import (
AutocompleteJsonView as DjangoAutocompleteJsonView,
)
from django.db.models.fields.reverse_related import ManyToOneRel


class AutocompleteJsonView(BaseAutocompleteJsonView):
admin_site = None
class AutocompleteJsonView(DjangoAutocompleteJsonView):

def get_empty_label(self):
return "-"

def get_allow_null(self):
return True

def get(self, request, *args, **kwargs):
(
self.term,
self.model_admin,
self.source_field,
_,
) = self.process_request(request)

if not self.has_perm(request):
raise PermissionDenied

self.support_reverse_relation()
self.object_list = self.get_queryset()
context = self.get_context_data()
# Add option for filtering objects with None field.
results = []
empty_label = self.get_empty_label()
if (
getattr(self.source_field, "null", False)
and self.get_allow_null()
and not getattr(self.source_field, "_get_limit_choices_to_mocked", False)
and not self.term
or self.term == empty_label
def get_queryset(self):
"""Override to support reverse relations without get_limit_choices_to()."""
# Handle reverse relations (ManyToOneRel) that don't have get_limit_choices_to
if isinstance(self.source_field, ManyToOneRel) or not hasattr(
self.source_field, "get_limit_choices_to"
):
# The select2 library requires data in a specific format
# https://select2.org/data-sources/formats.
# select2 does not render option with blank "id" (i.e. '').
# Therefore, "null" is used here for "id".
results += [{"id": "null", "text": empty_label}]
results += [
{"id": str(obj.pk), "text": self.display_text(obj)}
for obj in context["object_list"]
]
return JsonResponse(
{
"results": results,
"pagination": {"more": context["page_obj"].has_next()},
}
)

def support_reverse_relation(self):
if not hasattr(self.source_field, "get_limit_choices_to"):
self.source_field._get_limit_choices_to_mocked = True

def get_choices_mock():
return {}

self.source_field.get_limit_choices_to = get_choices_mock
# Mock the method for reverse relations
self.source_field.get_limit_choices_to = lambda: {}
return super().get_queryset()
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
install_requires=[
"django-model-utils>=4.5,<5.1",
"django-minify-compress-staticfiles~=1.1.0",
"django-admin-autocomplete-filter~=0.7.1",
"dalf>=0.7.0,<1.0.0",
"swapper~=1.4.0",
# allow wider range here to avoid interfering with other modules
"urllib3>=2.0.0,<3.0.0",
Expand Down
2 changes: 1 addition & 1 deletion tests/openwisp2/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"django.contrib.sites",
# admin
"django.contrib.admin",
"admin_auto_filters",
"dalf",
# rest framework
"rest_framework",
"drf_yasg",
Expand Down
Loading
Loading