From 8a07fc69b108ab249e1da9e164608e4246c65a67 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Tue, 1 Jul 2025 20:14:09 +0300 Subject: [PATCH] Features: 1) Add `AutocompleteFieldListFilter` for streamlined admin autocomplete functionality; 2) Provide new template `admin/autocomplete_filter.html` for enhanced UI display; 3) Introduce optimized Select2-based filtering in `autocomplete_filter.js`. Fixes: 1) Replace deprecated `RelatedAutocompleteFilter` with `AutocompleteFieldListFilter`; 2) Fix validation and handling of lookup values for admin filters. Extra: 1) Update CSS for consistent styling of autocomplete widgets; 2) Remove unused `autocomplete_list_filter` assets and references; 3) Refactor JS for better maintainability and performance improvements. --- core/admin.py | 74 ++---------- core/filters.py | 79 +++++++++++- .../admin/css/autocomplete_list_filter.css | 62 +++++++++- .../admin/js/autocomplete_list_filter.js | 114 +++++++++++++----- core/templates/admin/autocomplete_filter.html | 24 ++++ .../admin/autocomplete_list_filter.html | 39 ------ 6 files changed, 254 insertions(+), 138 deletions(-) create mode 100644 core/templates/admin/autocomplete_filter.html delete mode 100644 core/templates/admin/autocomplete_list_filter.html diff --git a/core/admin.py b/core/admin.py index d6564b16..9a71aace 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,21 +1,20 @@ + 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 FieldListFilter, ModelAdmin, TabularInline, action, register, site +from django.contrib.admin import ModelAdmin, TabularInline, action, register, site from django.contrib.gis.admin import GISModelAdmin from django.db.models import Model -from django.urls import reverse from django.utils.translation import gettext_lazy as _ from modeltranslation.translator import NotRegistered, translator from modeltranslation.utils import get_translation_fields from mptt.admin import DraggableMPTTAdmin -from evibes.settings import CONSTANCE_CONFIG - -from .forms import OrderForm, OrderProductForm, VendorForm -from .models import ( +from core.filters import AutocompleteFieldListFilter +from core.forms import OrderForm, OrderProductForm, VendorForm +from core.models import ( Address, Attribute, AttributeGroup, @@ -35,64 +34,7 @@ from .models import ( Vendor, Wishlist, ) - - -class RelatedAutocompleteFilter(FieldListFilter): - template = "admin/autocomplete_list_filter.html" - limit = 10 - - def __init__(self, field, request, params, model, model_admin, field_path): - super().__init__(field, request, params, model, model_admin, field_path) - self.lookup_kwarg = f"{field_path}__{field.target_field.name}__exact" - self.lookup_val = params.get(self.lookup_kwarg, "") - - remote = field.remote_field.model._meta - self.app_label = remote.app_label - self.model_name = remote.model_name - self.field_name = field.name - self.field_label = field.verbose_name.lower() - - base = reverse("admin:autocomplete") - self.autocomplete_url = ( - f"{base}" - f"?app_label={self.app_label}" - f"&model_name={self.model_name}" - f"&field_name={self.field_name}" - f"&limit={self.limit}" - ) - - if self.lookup_val: - try: - obj = field.remote_field.model._default_manager.get(pk=self.lookup_val) - self.initial_text = str(obj) - except field.remote_field.model.DoesNotExist: - self.initial_text = "" - else: - self.initial_text = "" - - def expected_parameters(self): - key = f"{self.field_path}__{self.field.target_field.name}__exact" - return [key] - - def has_output(self): - return True - - def choices(self, changelist): - yield { - "selected": bool(self.lookup_val), - "query_string": changelist.get_query_string( - ({self.lookup_kwarg: self.lookup_val} if self.lookup_val else {}), [] - ), - "display": _("—"), - "autocomplete_url": self.autocomplete_url, - "lookup_kwarg": self.lookup_kwarg, - "lookup_val": self.lookup_val, - "app_label": self.app_label, - "model_name": self.model_name, - "field_name": self.field_name, - "field_label": self.field_label, - "initial_text": self.initial_text, - } +from evibes.settings import CONSTANCE_CONFIG class FieldsetsMixin: @@ -307,8 +249,8 @@ class ProductAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): "stocks__vendor", "created", "modified", - ("brand", RelatedAutocompleteFilter), - ("category", RelatedAutocompleteFilter), + ("brand", AutocompleteFieldListFilter), + ("category", AutocompleteFieldListFilter), ) search_fields = ( "name", diff --git a/core/filters.py b/core/filters.py index 609aee41..8fb72b8d 100644 --- a/core/filters.py +++ b/core/filters.py @@ -2,7 +2,8 @@ import json import logging import uuid -from django.core.exceptions import BadRequest +from django.contrib.admin import FieldListFilter +from django.core.exceptions import BadRequest, ValidationError from django.db.models import ( Avg, Case, @@ -16,6 +17,7 @@ from django.db.models import ( When, ) from django.db.models.functions import Coalesce +from django.forms import Media from django.utils.http import urlsafe_base64_decode from django.utils.translation import gettext_lazy as _ from django_filters import ( @@ -503,3 +505,78 @@ class AddressFilter(FilterSet): class Meta: model = Address fields = ["uuid", "user_uuid", "user_email", "order_by"] + + +class AutocompleteFieldListFilter(FieldListFilter): + + template = "admin/autocomplete_filter.html" + + def __init__(self, field, request, params, model, model_admin, field_path): + self.lookup_kwarg = f"{field_path}__exact" + self.lookup_val = request.GET.get(self.lookup_kwarg) + self.field = field + self.field_path = field_path + self.model = model + self.related_model = field.related_model + super().__init__(field, request, params, model, model_admin, field_path) + + def has_output(self): + return True + + def choices(self, changelist): + choices = [] + if self.lookup_val: + try: + obj = self.related_model.objects.get(pk=self.lookup_val) + choices.append( + { + "selected": True, + "query_string": changelist.get_query_string(remove=[self.lookup_kwarg]), + "display": str(obj), + "value": obj.pk, + } + ) + except (self.related_model.DoesNotExist, ValidationError): + pass + return choices + + def queryset(self, request, queryset): + if self.lookup_val: + try: + return queryset.filter(**{self.lookup_kwarg: self.lookup_val}) + except (ValueError, ValidationError): + pass + return queryset + + def expected_parameters(self): + return [self.lookup_kwarg] + + @property + def media(self): + extra = "" if self.used_parameters else ".min" + js = [ + f"admin/js/vendor/jquery/jquery{extra}.js", + f"admin/js/vendor/select2/select2.full{extra}.js", + "admin/js/autocomplete_filter.js", + ] + css = { + "screen": [ + f"admin/css/vendor/select2/select2{extra}.css", + "admin/css/autocomplete_filter.css", + ] + } + return Media(js=js, css=css) + + def get_context_data(self): + field_name = self.field_path.replace("__", "_") + related_url = f'/admin/{self.related_model._meta.app_label}/{self.related_model._meta.model_name}/autocomplete/' + + return { + 'field_name': field_name, + 'lookup_kwarg': self.lookup_kwarg, + 'lookup_val': self.lookup_val or '', + 'related_url': related_url, + 'placeholder': _('Search %s...') % self.related_model._meta.verbose_name, + 'clear_text': _('Clear'), + 'field_verbose_name': self.field.verbose_name or self.field.name.replace('_', ' ').title(), + } diff --git a/core/static/admin/css/autocomplete_list_filter.css b/core/static/admin/css/autocomplete_list_filter.css index 960a1540..058e529a 100644 --- a/core/static/admin/css/autocomplete_list_filter.css +++ b/core/static/admin/css/autocomplete_list_filter.css @@ -1,3 +1,61 @@ -.ajax-autocomplete-select-widget-wrapper .select2-container--admin-autocomplete { - width: 100% !important; +.autocomplete-filter-widget { + margin-bottom: 10px; + padding: 8px; + border: 1px solid #ddd; + background: #f9f9f9; + border-radius: 4px; +} + +.autocomplete-filter-widget h3 { + margin: 0 0 8px 0; + font-size: 11px; + text-transform: uppercase; + font-weight: bold; + color: #666; +} + +.autocomplete-filter-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.autocomplete-input { + flex: 1; +} + +.autocomplete-clear { + background: #ba2121; + color: white; + border: none; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + text-decoration: none; + display: inline-block; +} + +.autocomplete-clear:hover { + background: #a41e1e; + color: white; +} + +.select2-container { + font-size: 12px; +} + +.select2-container .select2-selection--single { + height: 28px; + border: 1px solid #ccc; +} + +.select2-container .select2-selection--single .select2-selection__rendered { + line-height: 26px; + padding-left: 8px; +} + +.select2-container .select2-selection--single .select2-selection__arrow { + height: 26px; + right: 4px; } \ 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 index 1cafd912..a7976b6d 100644 --- a/core/static/admin/js/autocomplete_list_filter.js +++ b/core/static/admin/js/autocomplete_list_filter.js @@ -1,32 +1,86 @@ -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) +(function ($) { + 'use strict'; + + $(document).ready(function () { + $('.autocomplete-filter-widget').each(function () { + let $widget = $(this); + let $input = $widget.find('.autocomplete-input'); + let $hiddenInput = $widget.find('.autocomplete-hidden'); + let $clearBtn = $widget.find('.autocomplete-clear'); + let relatedUrl = $input.data('related-url'); + let placeholder = $input.attr('placeholder'); + + // Initialize Select2 + $input.select2({ + ajax: { + url: relatedUrl, + dataType: 'json', + delay: 250, + data: function (params) { + return { + term: params.term, + page: params.page || 1 + }; + }, + processResults: function (data, params) { + params.page = params.page || 1; + return { + results: data.results.map(function (item) { + return { + id: item.id, + text: item.text + }; + }), + pagination: { + more: data.pagination && data.pagination.more + } + }; + }, + cache: true + }, + placeholder: placeholder, + allowClear: true, + minimumInputLength: 1, + width: '250px' + }); + + // Handle selection + $input.on('select2:select', function (e) { + let data = e.params.data; + $hiddenInput.val(data.id); + updateUrl(); + }); + + // Handle clearing + $input.on('select2:clear', function (e) { + $hiddenInput.val(''); + updateUrl(); + }); + + // Clear button functionality + $clearBtn.on('click', function (e) { + e.preventDefault(); + $input.val(null).trigger('change'); + $hiddenInput.val(''); + updateUrl(); + }); + + function updateUrl() { + let currentUrl = new URL(window.location); + let lookupKwarg = $hiddenInput.data('lookup-kwarg'); + let value = $hiddenInput.val(); + + if (value) { + currentUrl.searchParams.set(lookupKwarg, value); + } else { + currentUrl.searchParams.delete(lookupKwarg); + } + + // Reset to first page when filtering + currentUrl.searchParams.delete('p'); + + window.location.href = currentUrl.toString(); } - } - } - 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 +})(django.jQuery); diff --git a/core/templates/admin/autocomplete_filter.html b/core/templates/admin/autocomplete_filter.html new file mode 100644 index 00000000..780cdb5b --- /dev/null +++ b/core/templates/admin/autocomplete_filter.html @@ -0,0 +1,24 @@ +{% load i18n %} +
+

{% trans "By" %} {{ field_verbose_name }}

+
+ + + {% if lookup_val %} + {{ clear_text }} + {% endif %} +
+
\ No newline at end of file diff --git a/core/templates/admin/autocomplete_list_filter.html b/core/templates/admin/autocomplete_list_filter.html deleted file mode 100644 index bcf8e602..00000000 --- a/core/templates/admin/autocomplete_list_filter.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "admin/filter.html" %} -{% load i18n static %} - -{% block choices %} -
- - -
-{% endblock %} - -{% block media %} -{{ block.super }} - - - - - -{% endblock %} \ No newline at end of file