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 contextlib import suppress
|
||||||
|
|
||||||
from constance.admin import Config
|
from constance.admin import Config
|
||||||
from constance.admin import ConstanceAdmin as BaseConstanceAdmin
|
from constance.admin import ConstanceAdmin as BaseConstanceAdmin
|
||||||
from django.apps import apps
|
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.contrib.gis.admin import GISModelAdmin
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from modeltranslation.translator import NotRegistered, translator
|
from modeltranslation.translator import NotRegistered, translator
|
||||||
from modeltranslation.utils import get_translation_fields
|
from modeltranslation.utils import get_translation_fields
|
||||||
from mptt.admin import DraggableMPTTAdmin
|
from mptt.admin import DraggableMPTTAdmin
|
||||||
|
|
||||||
from evibes.settings import CONSTANCE_CONFIG
|
from core.filters import AutocompleteFieldListFilter
|
||||||
|
from core.forms import OrderForm, OrderProductForm, VendorForm
|
||||||
from .forms import OrderForm, OrderProductForm, VendorForm
|
from core.models import (
|
||||||
from .models import (
|
|
||||||
Address,
|
Address,
|
||||||
Attribute,
|
Attribute,
|
||||||
AttributeGroup,
|
AttributeGroup,
|
||||||
|
|
@ -35,64 +34,7 @@ from .models import (
|
||||||
Vendor,
|
Vendor,
|
||||||
Wishlist,
|
Wishlist,
|
||||||
)
|
)
|
||||||
|
from evibes.settings import CONSTANCE_CONFIG
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FieldsetsMixin:
|
class FieldsetsMixin:
|
||||||
|
|
@ -307,8 +249,8 @@ class ProductAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin):
|
||||||
"stocks__vendor",
|
"stocks__vendor",
|
||||||
"created",
|
"created",
|
||||||
"modified",
|
"modified",
|
||||||
("brand", RelatedAutocompleteFilter),
|
("brand", AutocompleteFieldListFilter),
|
||||||
("category", RelatedAutocompleteFilter),
|
("category", AutocompleteFieldListFilter),
|
||||||
)
|
)
|
||||||
search_fields = (
|
search_fields = (
|
||||||
"name",
|
"name",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
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 (
|
from django.db.models import (
|
||||||
Avg,
|
Avg,
|
||||||
Case,
|
Case,
|
||||||
|
|
@ -16,6 +17,7 @@ from django.db.models import (
|
||||||
When,
|
When,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.forms import Media
|
||||||
from django.utils.http import urlsafe_base64_decode
|
from django.utils.http import urlsafe_base64_decode
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_filters import (
|
from django_filters import (
|
||||||
|
|
@ -503,3 +505,78 @@ class AddressFilter(FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Address
|
model = Address
|
||||||
fields = ["uuid", "user_uuid", "user_email", "order_by"]
|
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 {
|
.autocomplete-filter-widget {
|
||||||
width: 100% !important;
|
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) {
|
(function ($) {
|
||||||
let required_queryset = [];
|
'use strict';
|
||||||
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(){
|
$(document).ready(function () {
|
||||||
django.jQuery(".ajax-autocomplete-select-widget-wrapper select").on('select2:unselect', function(e){
|
$('.autocomplete-filter-widget').each(function () {
|
||||||
let qs_target_value = django.jQuery(this).parent().data("qs-target-value");
|
let $widget = $(this);
|
||||||
let querystring_value = django.jQuery(this).closest("form").find('input[name$="querystring_value"]').val();
|
let $input = $widget.find('.autocomplete-input');
|
||||||
handle_querystring_and_redirect(querystring_value, qs_target_value, "");
|
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){
|
// Handle selection
|
||||||
let selection = django.jQuery(e.target).val() || "";
|
$input.on('select2:select', function (e) {
|
||||||
let qs_target_value = django.jQuery(this).parent().data("qs-target-value");
|
let data = e.params.data;
|
||||||
let querystring_value = django.jQuery(this).closest("form").find('input[name$="querystring_value"]').val();
|
$hiddenInput.val(data.id);
|
||||||
if(selection.length > 0){
|
updateUrl();
|
||||||
handle_querystring_and_redirect(querystring_value, qs_target_value, selection);
|
});
|
||||||
|
|
||||||
|
// 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