From c5fe0cb6c624a04cf37ee7dde5041e23c2c5b2b9 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Tue, 1 Jul 2025 16:28:07 +0300 Subject: [PATCH] Features: 1) Add `AjaxAutocompleteListFilter` to enable autocomplete functionality for admin list filters; 2) Introduce `AjaxAutocompleteSelectWidget` for enhanced UI integration; 3) Update `ProductAdmin` to use new autocomplete list filter; 4) Add template and styles for `AutocompleteListFilter`. Fixes: None; Extra: Add supporting JS and CSS for autocomplete list filter functionality. --- core/admin.py | 89 ++++++++++++++++++- .../admin/css/autocomplete_list_filter.css | 3 + .../admin/js/autocomplete_list_filter.js | 32 +++++++ .../filter/autocomplete_list_filter.html | 9 ++ 4 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 core/static/admin/css/autocomplete_list_filter.css create mode 100644 core/static/admin/js/autocomplete_list_filter.js create mode 100644 core/templates/admin/filter/autocomplete_list_filter.html diff --git a/core/admin.py b/core/admin.py index d46527f7..f0531846 100644 --- a/core/admin.py +++ b/core/admin.py @@ -3,9 +3,12 @@ from contextlib import suppress from constance.admin import Config from constance.admin import ConstanceAdmin as BaseConstanceAdmin from django.apps import apps -from django.contrib.admin import ModelAdmin, TabularInline, action, register, site +from django.contrib.admin import ModelAdmin, RelatedFieldListFilter, TabularInline, action, register, site +from django.contrib.admin.widgets import AutocompleteSelect from django.contrib.gis.admin import GISModelAdmin from django.db.models import Model +from django.db.models.fields.related_descriptors import ManyToManyDescriptor, ReverseManyToOneDescriptor +from django.forms import CharField, Form, HiddenInput, ModelChoiceField from django.utils.translation import gettext_lazy as _ from modeltranslation.translator import NotRegistered, translator from modeltranslation.utils import get_translation_fields @@ -112,6 +115,88 @@ class ActivationActionsMixin: return actions +class AjaxAutocompleteSelectWidget(AutocompleteSelect): + def __init__(self, *args, **kwargs): + self.qs_target_value = kwargs.pop('qs_target_value') + self.model_admin = kwargs.pop('model_admin') + self.model = kwargs.pop('model') + self.field_name = kwargs.pop('field_name') + kwargs.update(admin_site=self.model_admin.admin_site) + kwargs.update(field=getattr(self.model, self.field_name).field) + super().__init__(*args, **kwargs) + + def render(self, name, value, attrs=None, renderer=None): + rendered = super().render(name, value, attrs, renderer) + + return (f'
' + f'{rendered}
') + + +class AjaxAutocompleteListFilter(RelatedFieldListFilter): + title = _('list filter') + parameter_name = '%s__%s__exact' + template = 'djaa_list_filter/admin/filter/autocomplete_list_filter.html' + + def __init__(self, field, request, params, model, model_admin, field_path): + super().__init__(field, request, params, model, model_admin, field_path) + + qs_target_value = self.parameter_name % (field.name, model._meta.pk.name) + queryset = self.get_queryset_for_field(model, field.name) + widget = AjaxAutocompleteSelectWidget( + model_admin=model_admin, model=model, field_name=field.name, qs_target_value=qs_target_value + ) + + class AutocompleteForm(Form): + autocomplete_field = ModelChoiceField(queryset=queryset, widget=widget, required=False) + querystring_value = CharField(widget=HiddenInput()) + + autocomplete_field_initial_value = request.GET.get(qs_target_value, None) + initial_values = {"querystring_value": request.GET.urlencode()} + if autocomplete_field_initial_value: + initial_values.update(autocomplete_field=autocomplete_field_initial_value) + self.autocomplete_form = AutocompleteForm(initial=initial_values, prefix=field.name) + + def get_queryset_for_field(self, model, name): + field_desc = getattr(model, name) + if isinstance(field_desc, ManyToManyDescriptor): + related_model = field_desc.rel.related_model if field_desc.reverse else field_desc.rel.model + elif isinstance(field_desc, ReverseManyToOneDescriptor): + related_model = field_desc.rel.related_model + else: + return field_desc.get_queryset() + return related_model.objects.get_queryset() + + +class AjaxAutocompleteListFilterModelAdmin(ModelAdmin): + def get_list_filter(self, request): + list_filter = list(super().get_list_filter(request)) + autocomplete_list_filter = self.get_autocomplete_list_filter() + if autocomplete_list_filter: + for field in autocomplete_list_filter: + list_filter.insert(0, (field, AjaxAutocompleteListFilter)) + return list_filter + + def get_autocomplete_list_filter(self): + return list(getattr(self, 'autocomplete_list_filter', [])) + + class Media: + js = [ + 'admin/js/vendor/jquery/jquery.js', + 'admin/js/vendor/select2/select2.full.js', + 'admin/js/vendor/select2/i18n/tr.js', + 'admin/js/jquery.init.js', + 'admin/js/autocomplete.js', + 'djaa_list_filter/admin/js/autocomplete_list_filter.js', + ] + css = { + 'screen': [ + 'admin/css/vendor/select2/select2.css', + 'admin/css/autocomplete.css', + 'djaa_list_filter/admin/css/autocomplete_list_filter.css', + ] + } + + class AttributeValueInline(TabularInline): model = AttributeValue extra = 0 @@ -230,7 +315,7 @@ class BrandAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): @register(Product) -class ProductAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): +class ProductAdmin(FieldsetsMixin, ActivationActionsMixin, AjaxAutocompleteListFilterModelAdmin): model = Product # type: ignore list_display = ( "name", diff --git a/core/static/admin/css/autocomplete_list_filter.css b/core/static/admin/css/autocomplete_list_filter.css new file mode 100644 index 00000000..960a1540 --- /dev/null +++ b/core/static/admin/css/autocomplete_list_filter.css @@ -0,0 +1,3 @@ +.ajax-autocomplete-select-widget-wrapper .select2-container--admin-autocomplete { + width: 100% !important; +} \ No newline at end of file diff --git a/core/static/admin/js/autocomplete_list_filter.js b/core/static/admin/js/autocomplete_list_filter.js new file mode 100644 index 00000000..1cafd912 --- /dev/null +++ b/core/static/admin/js/autocomplete_list_filter.js @@ -0,0 +1,32 @@ +function handle_querystring_and_redirect(querystring_value, qs_target_value, selection) { + let required_queryset = []; + if (querystring_value.length > 0) { + for(const field_eq_value of querystring_value.split("&")){ + let [field, value] = field_eq_value.split("="); + if (field !== qs_target_value){ + required_queryset.push(field_eq_value) + } + } + } + if (selection.length > 0) { + required_queryset.push(qs_target_value + "=" + selection); + } + window.location.href = "?" + required_queryset.join("&"); +} + +django.jQuery(document).ready(function(){ + django.jQuery(".ajax-autocomplete-select-widget-wrapper select").on('select2:unselect', function(e){ + let qs_target_value = django.jQuery(this).parent().data("qs-target-value"); + let querystring_value = django.jQuery(this).closest("form").find('input[name$="querystring_value"]').val(); + handle_querystring_and_redirect(querystring_value, qs_target_value, ""); + }); + + django.jQuery(".ajax-autocomplete-select-widget-wrapper select").on('change', function(e, choice){ + let selection = django.jQuery(e.target).val() || ""; + let qs_target_value = django.jQuery(this).parent().data("qs-target-value"); + let querystring_value = django.jQuery(this).closest("form").find('input[name$="querystring_value"]').val(); + if(selection.length > 0){ + handle_querystring_and_redirect(querystring_value, qs_target_value, selection); + } + }); +}); \ No newline at end of file diff --git a/core/templates/admin/filter/autocomplete_list_filter.html b/core/templates/admin/filter/autocomplete_list_filter.html new file mode 100644 index 00000000..434fcddd --- /dev/null +++ b/core/templates/admin/filter/autocomplete_list_filter.html @@ -0,0 +1,9 @@ +{% load i18n static %} +

{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}

+ +
+ +{{ spec.autocomplete_form.querystring_value }} +
\ No newline at end of file