1
0
mirror of https://github.com/django/django.git synced 2025-10-24 22:26:08 +00:00

Fixed #14370 -- Allowed using a Select2 widget for ForeignKey and ManyToManyField in the admin.

Thanks Florian Apolloner and Tim Graham for review and
contributing to the patch.
This commit is contained in:
Johannes Hoppe
2017-05-10 14:48:57 +02:00
committed by Tim Graham
parent 01a294f8f0
commit 94cd8efc50
21 changed files with 1213 additions and 23 deletions

View File

@@ -66,6 +66,7 @@ class BaseModelAdminChecks:
def check(self, admin_obj, **kwargs):
errors = []
errors.extend(self._check_autocomplete_fields(admin_obj))
errors.extend(self._check_raw_id_fields(admin_obj))
errors.extend(self._check_fields(admin_obj))
errors.extend(self._check_fieldsets(admin_obj))
@@ -80,6 +81,61 @@ class BaseModelAdminChecks:
errors.extend(self._check_readonly_fields(admin_obj))
return errors
def _check_autocomplete_fields(self, obj):
"""
Check that `autocomplete_fields` is a list or tuple of model fields.
"""
if not isinstance(obj.autocomplete_fields, (list, tuple)):
return must_be('a list or tuple', option='autocomplete_fields', obj=obj, id='admin.E036')
else:
return list(chain.from_iterable([
self._check_autocomplete_fields_item(obj, obj.model, field_name, 'autocomplete_fields[%d]' % index)
for index, field_name in enumerate(obj.autocomplete_fields)
]))
def _check_autocomplete_fields_item(self, obj, model, field_name, label):
"""
Check that an item in `autocomplete_fields` is a ForeignKey or a
ManyToManyField and that the item has a related ModelAdmin with
search_fields defined.
"""
try:
field = model._meta.get_field(field_name)
except FieldDoesNotExist:
return refer_to_missing_field(field=field_name, option=label, model=model, obj=obj, id='admin.E037')
else:
if not (field.many_to_many or field.many_to_one):
return must_be(
'a foreign key or a many-to-many field',
option=label, obj=obj, id='admin.E038'
)
related_admin = obj.admin_site._registry.get(field.remote_field.model)
if related_admin is None:
return [
checks.Error(
'An admin for model "%s" has to be registered '
'to be referenced by %s.autocomplete_fields.' % (
field.remote_field.model.__name__,
type(obj).__name__,
),
obj=obj.__class__,
id='admin.E039',
)
]
elif not related_admin.search_fields:
return [
checks.Error(
'%s must define "search_fields", because it\'s '
'referenced by %s.autocomplete_fields.' % (
related_admin.__class__.__name__,
type(obj).__name__,
),
obj=obj.__class__,
id='admin.E040',
)
]
return []
def _check_raw_id_fields(self, obj):
""" Check that `raw_id_fields` only contains field names that are listed
on the model. """

View File

@@ -19,6 +19,10 @@ from django.contrib.admin.utils import (
get_deleted_objects, lookup_needs_distinct, model_format_dict,
model_ngettext, quote, unquote,
)
from django.contrib.admin.views.autocomplete import AutocompleteJsonView
from django.contrib.admin.widgets import (
AutocompleteSelect, AutocompleteSelectMultiple,
)
from django.contrib.auth import get_permission_codename
from django.core.exceptions import (
FieldDoesNotExist, FieldError, PermissionDenied, ValidationError,
@@ -94,6 +98,7 @@ csrf_protect_m = method_decorator(csrf_protect)
class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
"""Functionality common to both ModelAdmin and InlineAdmin."""
autocomplete_fields = ()
raw_id_fields = ()
fields = None
exclude = None
@@ -213,7 +218,10 @@ class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
Get a form Field for a ForeignKey.
"""
db = kwargs.get('using')
if db_field.name in self.raw_id_fields:
if db_field.name in self.get_autocomplete_fields(request):
kwargs['widget'] = AutocompleteSelect(db_field.remote_field, using=db)
elif db_field.name in self.raw_id_fields:
kwargs['widget'] = widgets.ForeignKeyRawIdWidget(db_field.remote_field, self.admin_site, using=db)
elif db_field.name in self.radio_fields:
kwargs['widget'] = widgets.AdminRadioSelect(attrs={
@@ -238,7 +246,10 @@ class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
return None
db = kwargs.get('using')
if db_field.name in self.raw_id_fields:
autocomplete_fields = self.get_autocomplete_fields(request)
if db_field.name in autocomplete_fields:
kwargs['widget'] = AutocompleteSelectMultiple(db_field.remote_field, using=db)
elif db_field.name in self.raw_id_fields:
kwargs['widget'] = widgets.ManyToManyRawIdWidget(db_field.remote_field, self.admin_site, using=db)
elif db_field.name in list(self.filter_vertical) + list(self.filter_horizontal):
kwargs['widget'] = widgets.FilteredSelectMultiple(
@@ -252,12 +263,20 @@ class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
kwargs['queryset'] = queryset
form_field = db_field.formfield(**kwargs)
if isinstance(form_field.widget, SelectMultiple) and not isinstance(form_field.widget, CheckboxSelectMultiple):
if (isinstance(form_field.widget, SelectMultiple) and
not isinstance(form_field.widget, (CheckboxSelectMultiple, AutocompleteSelectMultiple))):
msg = _('Hold down "Control", or "Command" on a Mac, to select more than one.')
help_text = form_field.help_text
form_field.help_text = format_lazy('{} {}', help_text, msg) if help_text else msg
return form_field
def get_autocomplete_fields(self, request):
"""
Return a list of ForeignKey and/or ManyToMany fields which should use
an autocomplete widget.
"""
return self.autocomplete_fields
def get_view_on_site_url(self, obj=None):
if obj is None or not self.view_on_site:
return None
@@ -561,6 +580,7 @@ class ModelAdmin(BaseModelAdmin):
urlpatterns = [
url(r'^$', wrap(self.changelist_view), name='%s_%s_changelist' % info),
url(r'^add/$', wrap(self.add_view), name='%s_%s_add' % info),
url(r'^autocomplete/$', wrap(self.autocomplete_view), name='%s_%s_autocomplete' % info),
url(r'^(.+)/history/$', wrap(self.history_view), name='%s_%s_history' % info),
url(r'^(.+)/delete/$', wrap(self.delete_view), name='%s_%s_delete' % info),
url(r'^(.+)/change/$', wrap(self.change_view), name='%s_%s_change' % info),
@@ -1527,6 +1547,9 @@ class ModelAdmin(BaseModelAdmin):
return self.render_change_form(request, context, add=add, change=not add, obj=obj, form_url=form_url)
def autocomplete_view(self, request):
return AutocompleteJsonView.as_view(model_admin=self)(request)
def add_view(self, request, form_url='', extra_context=None):
return self.changeform_view(request, None, form_url, extra_context)

View File

@@ -0,0 +1,261 @@
select.admin-autocomplete {
width: 20em;
}
.select2-container--admin-autocomplete.select2-container {
min-height: 30px;
}
.select2-container--admin-autocomplete .select2-selection--single,
.select2-container--admin-autocomplete .select2-selection--multiple {
min-height: 30px;
padding: 0;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection,
.select2-container--admin-autocomplete.select2-container--open .select2-selection {
border-color: #999;
min-height: 30px;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single,
.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single {
padding: 0;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple,
.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple {
padding: 0;
}
.select2-container--admin-autocomplete .select2-selection--single {
background-color: #fff;
border: 1px solid #ccc;
border-radius: 4px;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered {
color: #444;
line-height: 30px;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder {
color: #999;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow {
height: 26px;
position: absolute;
top: 1px;
right: 1px;
width: 20px;
}
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b {
border-color: #888 transparent transparent transparent;
border-style: solid;
border-width: 5px 4px 0 4px;
height: 0;
left: 50%;
margin-left: -4px;
margin-top: -2px;
position: absolute;
top: 50%;
width: 0;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear {
float: left;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow {
left: 1px;
right: auto;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single {
background-color: #eee;
cursor: default;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear {
display: none;
}
.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent #888 transparent;
border-width: 0 4px 5px 4px;
}
.select2-container--admin-autocomplete .select2-selection--multiple {
background-color: white;
border: 1px solid #ccc;
border-radius: 4px;
cursor: text;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered {
box-sizing: border-box;
list-style: none;
margin: 0;
padding: 0 5px;
width: 100%;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li {
list-style: none;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder {
color: #999;
margin-top: 5px;
float: left;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold;
margin-top: 5px;
margin-right: 10px;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice {
background-color: #e4e4e4;
border: 1px solid #ccc;
border-radius: 4px;
cursor: default;
float: left;
margin-right: 5px;
margin-top: 5px;
padding: 0 5px;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove {
color: #999;
cursor: pointer;
display: inline-block;
font-weight: bold;
margin-right: 2px;
}
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover {
color: #333;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline {
float: right;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
margin-left: 5px;
margin-right: auto;
}
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
margin-left: 2px;
margin-right: auto;
}
.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple {
border: solid #999 1px;
outline: 0;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple {
background-color: #eee;
cursor: default;
}
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove {
display: none;
}
.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field {
border: 1px solid #ccc;
}
.select2-container--admin-autocomplete .select2-search--inline .select2-search__field {
background: transparent;
border: none;
outline: 0;
box-shadow: none;
-webkit-appearance: textfield;
}
.select2-container--admin-autocomplete .select2-results > .select2-results__options {
max-height: 200px;
overflow-y: auto;
}
.select2-container--admin-autocomplete .select2-results__option[role=group] {
padding: 0;
}
.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] {
color: #999;
}
.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] {
background-color: #ddd;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option {
padding-left: 1em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group {
padding-left: 0;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option {
margin-left: -1em;
padding-left: 2em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -2em;
padding-left: 3em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -3em;
padding-left: 4em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -4em;
padding-left: 5em;
}
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -5em;
padding-left: 6em;
}
.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] {
background-color: #79aec8;
color: white;
}
.select2-container--admin-autocomplete .select2-results__group {
cursor: default;
display: block;
padding: 6px;
}

View File

@@ -108,6 +108,12 @@
this.value = newId;
}
});
selects.next().find('.select2-selection__rendered').each(function() {
// The element can have a clear button as a child.
// Use the lastChild to modify only the displayed value.
this.lastChild.textContent = newRepr;
this.title = newRepr;
});
win.close();
}

View File

@@ -0,0 +1,38 @@
(function($) {
'use strict';
var init = function($element, options) {
var settings = $.extend({
ajax: {
data: function(params) {
return {
term: params.term,
page: params.page
};
}
}
}, options);
$element.select2(settings);
};
$.fn.djangoAdminSelect2 = function(options) {
var settings = $.extend({}, options);
$.each(this, function(i, element) {
var $element = $(element);
init($element, settings);
});
return this;
};
$(function() {
$('.admin-autocomplete').djangoAdminSelect2();
});
$(document).on('formset:added', (function() {
return function(event, $newFormset) {
var $widget = $newFormset.find('.admin-autocomplete');
// Exclude already initialized Select2 inputs.
$widget = $widget.not('.select2-hidden-accessible');
return init($widget);
};
})(this));
}(django.jQuery));

View File

@@ -0,0 +1,52 @@
from django.http import Http404, JsonResponse
from django.views.generic.list import BaseListView
class AutocompleteJsonView(BaseListView):
"""Handle AutocompleteWidget's AJAX requests for data."""
paginate_by = 20
model_admin = None
def get(self, request, *args, **kwargs):
"""
Return a JsonResponse with search results of the form:
{
results: [{id: "123" text: "foo"}],
pagination: {more: true}
}
"""
if not self.model_admin.get_search_fields(request):
raise Http404(
'%s must have search_fields for the autocomplete_view.' %
type(self.model_admin).__name__
)
if not self.has_perm(request):
return JsonResponse({'error': '403 Forbidden'}, status=403)
self.term = request.GET.get('term', '')
self.paginator_class = self.model_admin.paginator
self.object_list = self.get_queryset()
context = self.get_context_data()
return JsonResponse({
'results': [
{'id': str(obj.pk), 'text': str(obj)}
for obj in context['object_list']
],
'pagination': {'more': context['page_obj'].has_next()},
})
def get_paginator(self, *args, **kwargs):
"""Use the ModelAdmin's paginator."""
return self.model_admin.get_paginator(self.request, *args, **kwargs)
def get_queryset(self):
"""Return queryset based on ModelAdmin.get_search_results()."""
qs = self.model_admin.get_queryset(self.request)
qs, search_use_distinct = self.model_admin.get_search_results(self.request, qs, self.term)
if search_use_distinct:
qs = qs.distinct()
return qs
def has_perm(self, request, obj=None):
"""Check if user has permission to access the related model."""
return self.model_admin.has_change_permission(request, obj=obj)

View File

@@ -2,6 +2,7 @@
Form Widget classes specific to the Django admin site.
"""
import copy
import json
from django import forms
from django.conf import settings
@@ -11,7 +12,7 @@ from django.urls.exceptions import NoReverseMatch
from django.utils.html import smart_urlquote
from django.utils.safestring import mark_safe
from django.utils.text import Truncator
from django.utils.translation import gettext as _
from django.utils.translation import get_language, gettext as _
class FilteredSelectMultiple(forms.SelectMultiple):
@@ -380,3 +381,115 @@ class AdminIntegerFieldWidget(forms.NumberInput):
class AdminBigIntegerFieldWidget(AdminIntegerFieldWidget):
class_name = 'vBigIntegerField'
# Mapping of lower case language codes [returned by Django's get_language()]
# to language codes supported by select2.
# See django/contrib/admin/static/admin/js/vendor/select2/i18n/*
SELECT2_TRANSLATIONS = {x.lower(): x for x in [
'ar', 'az', 'bg', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es', 'et',
'eu', 'fa', 'fi', 'fr', 'gl', 'he', 'hi', 'hr', 'hu', 'id', 'is',
'it', 'ja', 'km', 'ko', 'lt', 'lv', 'mk', 'ms', 'nb', 'nl', 'pl',
'pt-BR', 'pt', 'ro', 'ru', 'sk', 'sr-Cyrl', 'sr', 'sv', 'th',
'tr', 'uk', 'vi', 'zh-CN', 'zh-TW',
]}
class AutocompleteMixin:
"""
Select widget mixin that loads options from AutocompleteJsonView via AJAX.
Renders the necessary data attributes for select2 and adds the static form
media.
"""
url_name = 'admin:%s_%s_autocomplete'
def __init__(self, rel, attrs=None, choices=(), using=None):
self.rel = rel
self.db = using
self.choices = choices
if attrs is not None:
self.attrs = attrs.copy()
else:
self.attrs = {}
def get_url(self):
model = self.rel.model
return reverse(self.url_name % (model._meta.app_label, model._meta.model_name))
def build_attrs(self, base_attrs, extra_attrs=None):
"""
Set select2's AJAX attributes.
Attributes can be set using the html5 data attribute.
Nested attributes require a double dash as per
https://select2.org/configuration/data-attributes#nested-subkey-options
"""
attrs = super().build_attrs(base_attrs, extra_attrs=extra_attrs)
attrs.setdefault('class', '')
attrs.update({
'data-ajax--cache': 'true',
'data-ajax--type': 'GET',
'data-ajax--url': self.get_url(),
'data-theme': 'admin-autocomplete',
'data-allow-clear': json.dumps(not self.is_required),
'data-placeholder': '', # Allows clearing of the input.
'class': attrs['class'] + 'admin-autocomplete',
})
return attrs
def optgroups(self, name, value, attr=None):
"""Return selected options based on the ModelChoiceIterator."""
default = (None, [], 0)
groups = [default]
has_selected = False
selected_choices = {
str(v) for v in value
if str(v) not in self.choices.field.empty_values
}
if not self.is_required and not self.allow_multiple_selected:
default[1].append(self.create_option(name, '', '', False, 0))
choices = (
(obj.pk, self.choices.field.label_from_instance(obj))
for obj in self.choices.queryset.using(self.db).filter(pk__in=selected_choices)
)
for option_value, option_label in choices:
selected = (
str(option_value) in value and
(has_selected is False or self.allow_multiple_selected)
)
if selected is True and has_selected is False:
has_selected = True
index = len(default[1])
subgroup = default[1]
subgroup.append(self.create_option(name, option_value, option_label, selected_choices, index))
return groups
@property
def media(self):
extra = '' if settings.DEBUG else '.min'
i18n_name = SELECT2_TRANSLATIONS.get(get_language())
i18n_file = ('admin/js/vendor/select2/i18n/%s.js' % i18n_name,) if i18n_name else ()
return forms.Media(
js=(
'admin/js/vendor/jquery/jquery%s.js' % extra,
'admin/js/vendor/select2/select2.full%s.js' % extra,
) + i18n_file + (
'admin/js/jquery.init.js',
'admin/js/autocomplete.js',
),
css={
'screen': (
'admin/css/vendor/select2/select2%s.css' % extra,
'admin/css/autocomplete.css',
),
},
)
class AutocompleteSelect(AutocompleteMixin, forms.Select):
pass
class AutocompleteSelectMultiple(AutocompleteMixin, forms.SelectMultiple):
pass