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:
parent
2bf396c744
commit
8a07fc69b1
6 changed files with 254 additions and 138 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
24
core/templates/admin/autocomplete_filter.html
Normal file
24
core/templates/admin/autocomplete_filter.html
Normal 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>
|
||||
|
|
@ -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 %}
|
||||
Loading…
Reference in a new issue