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.
This commit is contained in:
Egor Pavlovich Gorbunov 2025-07-01 20:14:09 +03:00
parent 2bf396c744
commit 8a07fc69b1
6 changed files with 254 additions and 138 deletions

View file

@ -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",

View file

@ -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(),
}

View file

@ -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;
}

View file

@ -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)
}
}
}
if (selection.length > 0) {
required_queryset.push(qs_target_value + "=" + selection);
}
window.location.href = "?" + required_queryset.join("&");
}
(function ($) {
'use strict';
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, "");
$(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'
});
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);
// 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();
}
});
});
})(django.jQuery);

View file

@ -0,0 +1,24 @@
{% load i18n %}
<div class="autocomplete-filter-widget">
<h3>{% trans "By" %} {{ field_verbose_name }}</h3>
<div class="autocomplete-filter-controls">
<select class="autocomplete-input"
data-related-url="{{ related_url }}"
placeholder="{{ placeholder }}">
{% if lookup_val %}
{% for choice in choices %}
{% if choice.selected %}
<option value="{{ choice.value }}" selected>{{ choice.display }}</option>
{% endif %}
{% endfor %}
{% endif %}
</select>
<input type="hidden"
class="autocomplete-hidden"
data-lookup-kwarg="{{ lookup_kwarg }}"
value="{{ lookup_val }}">
{% if lookup_val %}
<a href="#" class="autocomplete-clear">{{ clear_text }}</a>
{% endif %}
</div>
</div>

View file

@ -1,39 +0,0 @@
{% extends "admin/filter.html" %}
{% load i18n static %}
{% block choices %}
<div class="filter-autocomplete">
<select
name="{{ spec.lookup_kwarg }}"
id="id_{{ spec.lookup_kwarg }}"
class="admin-autocomplete"
style="width:90%;"
data-ajax--url="{{ spec.autocomplete_url }}"
data-app-label="{{ spec.app_label }}"
data-model-name="{{ spec.model_name }}"
data-field-name="{{ spec.field_name }}"
data-allow-clear="true"
data-placeholder="{% blocktrans with label=spec.field_label %}Search by {{ label }}…{% endblocktrans %}">
{% if spec.lookup_val %}
<option value="{{ spec.lookup_val }}" selected>{{ spec.initial_text }}</option>
{% endif %}
</select>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function () {
const select = document.getElementById('id_{{ spec.lookup_kwarg }}');
if (typeof django !== 'undefined' && django.jQuery) {
django.jQuery(select).djangoAdminSelect2();
}
});
</script>
</div>
{% endblock %}
{% block media %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'admin/css/vendor/select2/select2.css' %}"/>
<link rel="stylesheet" href="{% static 'admin/css/autocomplete.css' %}"/>
<script src="{% static 'admin/js/vendor/select2/select2.full.js' %}"></script>
<script src="{% static 'admin/js/autocomplete.js' %}"></script>
{% endblock %}