From 94cd8efc50c717cd00244f4b2233f971a53b205e Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Wed, 10 May 2017 14:48:57 +0200 Subject: [PATCH] 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. --- django/contrib/admin/checks.py | 56 ++++ django/contrib/admin/options.py | 29 +- .../admin/static/admin/css/autocomplete.css | 261 ++++++++++++++++++ .../admin/js/admin/RelatedObjectLookups.js | 6 + .../admin/static/admin/js/autocomplete.js | 38 +++ django/contrib/admin/views/autocomplete.py | 52 ++++ django/contrib/admin/widgets.py | 115 +++++++- docs/ref/checks.txt | 9 + docs/ref/contrib/admin/index.txt | 69 ++++- docs/releases/2.0.txt | 5 +- docs/spelling_wordlist | 1 + tests/admin_views/admin.py | 41 ++- tests/admin_views/customadmin.py | 2 +- tests/admin_views/models.py | 16 +- tests/admin_views/test_autocomplete_view.py | 231 ++++++++++++++++ tests/admin_views/tests.py | 30 +- tests/admin_widgets/models.py | 1 + .../admin_widgets/test_autocomplete_widget.py | 133 +++++++++ tests/modeladmin/models.py | 9 + tests/modeladmin/test_checks.py | 100 ++++++- tests/modeladmin/tests.py | 32 ++- 21 files changed, 1213 insertions(+), 23 deletions(-) create mode 100644 django/contrib/admin/static/admin/css/autocomplete.css create mode 100644 django/contrib/admin/static/admin/js/autocomplete.js create mode 100644 django/contrib/admin/views/autocomplete.py create mode 100644 tests/admin_views/test_autocomplete_view.py create mode 100644 tests/admin_widgets/test_autocomplete_widget.py diff --git a/django/contrib/admin/checks.py b/django/contrib/admin/checks.py index 830a190ff0b..a9398db7e7a 100644 --- a/django/contrib/admin/checks.py +++ b/django/contrib/admin/checks.py @@ -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. """ diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index a1c469c91e2..7a4ff947a82 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -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) diff --git a/django/contrib/admin/static/admin/css/autocomplete.css b/django/contrib/admin/static/admin/css/autocomplete.css new file mode 100644 index 00000000000..c1a332d9baa --- /dev/null +++ b/django/contrib/admin/static/admin/css/autocomplete.css @@ -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; +} diff --git a/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js b/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js index 3fb1e525504..3d4d916654a 100644 --- a/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js +++ b/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js @@ -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(); } diff --git a/django/contrib/admin/static/admin/js/autocomplete.js b/django/contrib/admin/static/admin/js/autocomplete.js new file mode 100644 index 00000000000..15321f974da --- /dev/null +++ b/django/contrib/admin/static/admin/js/autocomplete.js @@ -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)); diff --git a/django/contrib/admin/views/autocomplete.py b/django/contrib/admin/views/autocomplete.py new file mode 100644 index 00000000000..5d826dd44ee --- /dev/null +++ b/django/contrib/admin/views/autocomplete.py @@ -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) diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py index 0f5f3c7dabf..b9d45a10eed 100644 --- a/django/contrib/admin/widgets.py +++ b/django/contrib/admin/widgets.py @@ -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 diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index b4d1fd40894..0ca1d176f45 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -527,6 +527,15 @@ with the admin site: * **admin.E034**: The value of ``readonly_fields`` must be a list or tuple. * **admin.E035**: The value of ``readonly_fields[n]`` is not a callable, an attribute of ````, or an attribute of ````. +* **admin.E036**: The value of ``autocomplete_fields`` must be a list or tuple. +* **admin.E037**: The value of ``autocomplete_fields[n]`` refers to + ````, which is not an attribute of ````. +* **admin.E038**: The value of ``autocomplete_fields[n]`` must be a foreign + key or a many-to-many field. +* **admin.E039**: An admin for model ```` has to be registered to be + referenced by ``.autocomplete_fields``. +* **admin.E040**: ```` must define ``search_fields``, because + it's referenced by ``.autocomplete_fields``. ``ModelAdmin`` ~~~~~~~~~~~~~~ diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index 51f9e70b732..965150cc3a4 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -519,11 +519,13 @@ subclass:: If you want to use a custom widget with a relation field (i.e. :class:`~django.db.models.ForeignKey` or :class:`~django.db.models.ManyToManyField`), make sure you haven't - included that field's name in ``raw_id_fields`` or ``radio_fields``. + included that field's name in ``raw_id_fields``, ``radio_fields``, or + ``autocomplete_fields``. ``formfield_overrides`` won't let you change the widget on relation - fields that have ``raw_id_fields`` or ``radio_fields`` set. That's - because ``raw_id_fields`` and ``radio_fields`` imply custom widgets of + fields that have ``raw_id_fields``, ``radio_fields``, or + ``autocomplete_fields`` set. That's because ``raw_id_fields``, + ``radio_fields``, and ``autocomplete_fields`` imply custom widgets of their own. .. attribute:: ModelAdmin.inlines @@ -1071,6 +1073,58 @@ subclass:: Don't include a field in ``radio_fields`` unless it's a ``ForeignKey`` or has ``choices`` set. +.. attribute:: ModelAdmin.autocomplete_fields + + .. versionadded:: 2.0 + + ``autocomplete_fields`` is a list of ``ForeignKey`` and/or + ``ManyToManyField`` fields you would like to change to `Select2 + `_ autocomplete inputs. + + By default, the admin uses a select-box interface (``) for @@ -1431,6 +1485,15 @@ templates used by the :class:`ModelAdmin` views: pre- or post-save operations for objects related to the parent. Note that at this point the parent object and its form have already been saved. +.. method:: ModelAdmin.get_autocomplete_fields(request) + + .. versionadded:: 2.0 + + The ``get_readonly_fields()`` method is given the ``HttpRequest`` and is + expected to return a ``list`` or ``tuple`` of field names that will be + displayed with an autocomplete widget as described above in the + :attr:`ModelAdmin.autocomplete_fields` section. + .. method:: ModelAdmin.get_readonly_fields(request, obj=None) The ``get_readonly_fields`` method is given the ``HttpRequest`` and the diff --git a/docs/releases/2.0.txt b/docs/releases/2.0.txt index fd9cf8dfd3d..b45e4948000 100644 --- a/docs/releases/2.0.txt +++ b/docs/releases/2.0.txt @@ -66,7 +66,10 @@ Minor features :mod:`django.contrib.admin` ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* The new :attr:`.ModelAdmin.autocomplete_fields` attribute and + :meth:`.ModelAdmin.get_autocomplete_fields` method allow using an + `Select2 `_ search widget for ``ForeignKey`` and + ``ManyToManyField``. :mod:`django.contrib.admindocs` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 4cfdc659063..08b70f4ac3e 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -27,6 +27,7 @@ attr auth autoclobber autocommit +autocomplete autocompletion autodetect autodetectable diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py index 6139f0460f7..2c58baea7ad 100644 --- a/tests/admin_views/admin.py +++ b/tests/admin_views/admin.py @@ -97,6 +97,7 @@ class ArticleAdmin(admin.ModelAdmin): ) list_editable = ('section',) list_filter = ('date', 'section') + autocomplete_fields = ('section',) view_on_site = False fieldsets = ( ('Some fields', { @@ -497,6 +498,10 @@ class PizzaAdmin(admin.ModelAdmin): readonly_fields = ('toppings',) +class StudentAdmin(admin.ModelAdmin): + search_fields = ('name',) + + class WorkHourAdmin(admin.ModelAdmin): list_display = ('datum', 'employee') list_filter = ('employee',) @@ -603,6 +608,16 @@ class AlbumAdmin(admin.ModelAdmin): list_filter = ['title'] +class QuestionAdmin(admin.ModelAdmin): + ordering = ['-posted'] + search_fields = ['question'] + autocomplete_fields = ['related_questions'] + + +class AnswerAdmin(admin.ModelAdmin): + autocomplete_fields = ['question'] + + class PrePopulatedPostLargeSlugAdmin(admin.ModelAdmin): prepopulated_fields = { 'slug': ('title',) @@ -664,12 +679,17 @@ class CustomTemplateFilterColorAdmin(admin.ModelAdmin): class RelatedPrepopulatedInline1(admin.StackedInline): fieldsets = ( (None, { - 'fields': (('pubdate', 'status'), ('name', 'slug1', 'slug2',),) + 'fields': ( + ('fk', 'm2m'), + ('pubdate', 'status'), + ('name', 'slug1', 'slug2',), + ), }), ) formfield_overrides = {models.CharField: {'strip': False}} model = RelatedPrepopulated extra = 1 + autocomplete_fields = ['fk', 'm2m'] prepopulated_fields = {'slug1': ['name', 'pubdate'], 'slug2': ['status', 'name']} @@ -677,12 +697,19 @@ class RelatedPrepopulatedInline1(admin.StackedInline): class RelatedPrepopulatedInline2(admin.TabularInline): model = RelatedPrepopulated extra = 1 + autocomplete_fields = ['fk', 'm2m'] prepopulated_fields = {'slug1': ['name', 'pubdate'], 'slug2': ['status', 'name']} +class RelatedPrepopulatedInline3(admin.TabularInline): + model = RelatedPrepopulated + extra = 0 + autocomplete_fields = ['fk', 'm2m'] + + class MainPrepopulatedAdmin(admin.ModelAdmin): - inlines = [RelatedPrepopulatedInline1, RelatedPrepopulatedInline2] + inlines = [RelatedPrepopulatedInline1, RelatedPrepopulatedInline2, RelatedPrepopulatedInline3] fieldsets = ( (None, { 'fields': (('pubdate', 'status'), ('name', 'slug1', 'slug2', 'slug3')) @@ -894,7 +921,10 @@ site = admin.AdminSite(name="admin") site.site_url = '/my-site-url/' site.register(Article, ArticleAdmin) site.register(CustomArticle, CustomArticleAdmin) -site.register(Section, save_as=True, inlines=[ArticleInline], readonly_fields=['name_property']) +site.register( + Section, save_as=True, inlines=[ArticleInline], + readonly_fields=['name_property'], search_fields=['name'], +) site.register(ModelWithStringPrimaryKey) site.register(Color) site.register(Thing, ThingAdmin) @@ -956,6 +986,7 @@ site.register(InlineReferer, InlineRefererAdmin) site.register(ReferencedByGenRel) site.register(GenRelReference) site.register(ParentWithUUIDPK) +site.register(RelatedPrepopulated, search_fields=['name']) site.register(RelatedWithUUIDPKModel) # We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2. @@ -973,8 +1004,8 @@ site.register(Pizza, PizzaAdmin) site.register(ReadablePizza) site.register(Topping, ToppingAdmin) site.register(Album, AlbumAdmin) -site.register(Question) -site.register(Answer, date_hierarchy='question__posted') +site.register(Question, QuestionAdmin) +site.register(Answer, AnswerAdmin, date_hierarchy='question__posted') site.register(Answer2, date_hierarchy='question__expires') site.register(PrePopulatedPost, PrePopulatedPostAdmin) site.register(ComplexSortedPerson, ComplexSortedPersonAdmin) diff --git a/tests/admin_views/customadmin.py b/tests/admin_views/customadmin.py index 2fbbb785959..5ee8c0c1597 100644 --- a/tests/admin_views/customadmin.py +++ b/tests/admin_views/customadmin.py @@ -49,7 +49,7 @@ class CustomPwdTemplateUserAdmin(UserAdmin): site = Admin2(name="admin2") site.register(models.Article, base_admin.ArticleAdmin) -site.register(models.Section, inlines=[base_admin.ArticleInline]) +site.register(models.Section, inlines=[base_admin.ArticleInline], search_fields=['name']) site.register(models.Thing, base_admin.ThingAdmin) site.register(models.Fabric, base_admin.FabricAdmin) site.register(models.ChapterXtra1, base_admin.ChapterXtra1Admin) diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py index dd4921d1ce1..fc229cf3bd3 100644 --- a/tests/admin_views/models.py +++ b/tests/admin_views/models.py @@ -600,6 +600,10 @@ class Question(models.Model): question = models.CharField(max_length=20) posted = models.DateField(default=datetime.date.today) expires = models.DateTimeField(null=True, blank=True) + related_questions = models.ManyToManyField('self') + + def __str__(self): + return self.question class Answer(models.Model): @@ -746,6 +750,8 @@ class MainPrepopulated(models.Model): class RelatedPrepopulated(models.Model): parent = models.ForeignKey(MainPrepopulated, models.CASCADE) name = models.CharField(max_length=75) + fk = models.ForeignKey('self', models.CASCADE, blank=True, null=True) + m2m = models.ManyToManyField('self', blank=True) pubdate = models.DateField() status = models.CharField( max_length=20, @@ -906,7 +912,6 @@ class InlineReference(models.Model): ) -# Models for #23604 and #23915 class Recipe(models.Model): rname = models.CharField(max_length=20, unique=True) @@ -957,3 +962,12 @@ class ParentWithUUIDPK(models.Model): class RelatedWithUUIDPKModel(models.Model): parent = models.ForeignKey(ParentWithUUIDPK, on_delete=models.SET_NULL, null=True, blank=True) + + +class Author(models.Model): + pass + + +class Authorship(models.Model): + book = models.ForeignKey(Book, models.CASCADE) + author = models.ForeignKey(Author, models.CASCADE) diff --git a/tests/admin_views/test_autocomplete_view.py b/tests/admin_views/test_autocomplete_view.py new file mode 100644 index 00000000000..8396ceb5d1d --- /dev/null +++ b/tests/admin_views/test_autocomplete_view.py @@ -0,0 +1,231 @@ +import json + +from django.contrib import admin +from django.contrib.admin import site +from django.contrib.admin.tests import AdminSeleniumTestCase +from django.contrib.admin.views.autocomplete import AutocompleteJsonView +from django.contrib.auth.models import Permission, User +from django.contrib.contenttypes.models import ContentType +from django.http import Http404 +from django.test import RequestFactory, override_settings +from django.urls import reverse, reverse_lazy + +from .admin import AnswerAdmin, QuestionAdmin +from .models import Answer, Author, Authorship, Book, Question +from .tests import AdminViewBasicTestCase + +PAGINATOR_SIZE = AutocompleteJsonView.paginate_by + + +class AuthorAdmin(admin.ModelAdmin): + search_fields = ['id'] + + +class AuthorshipInline(admin.TabularInline): + model = Authorship + autocomplete_fields = ['author'] + + +class BookAdmin(admin.ModelAdmin): + inlines = [AuthorshipInline] + + +site.register(Question, QuestionAdmin) +site.register(Answer, AnswerAdmin) +site.register(Author, AuthorAdmin) +site.register(Book, BookAdmin) + + +class AutocompleteJsonViewTests(AdminViewBasicTestCase): + as_view_args = {'model_admin': QuestionAdmin(Question, site)} + factory = RequestFactory() + url = reverse_lazy('admin:admin_views_question_autocomplete') + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user( + username='user', password='secret', + email='user@example.com', is_staff=True, + ) + super().setUpTestData() + + def test_success(self): + q = Question.objects.create(question='Is this a question?') + request = self.factory.get(self.url, {'term': 'is'}) + request.user = self.superuser + response = AutocompleteJsonView.as_view(**self.as_view_args)(request) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content.decode('utf-8')) + self.assertEqual(data, { + 'results': [{'id': str(q.pk), 'text': q.question}], + 'pagination': {'more': False}, + }) + + def test_must_be_logged_in(self): + response = self.client.get(self.url, {'term': ''}) + self.assertEqual(response.status_code, 200) + self.client.logout() + response = self.client.get(self.url, {'term': ''}) + self.assertEqual(response.status_code, 302) + + def test_has_change_permission_required(self): + """ + Users require the change permission for the related model to the + autocomplete view for it. + """ + request = self.factory.get(self.url, {'term': 'is'}) + self.user.is_staff = True + self.user.save() + request.user = self.user + response = AutocompleteJsonView.as_view(**self.as_view_args)(request) + self.assertEqual(response.status_code, 403) + self.assertJSONEqual(response.content.decode('utf-8'), {'error': '403 Forbidden'}) + # Add the change permission and retry. + p = Permission.objects.get( + content_type=ContentType.objects.get_for_model(Question), + codename='change_question', + ) + self.user.user_permissions.add(p) + request.user = User.objects.get(pk=self.user.pk) + response = AutocompleteJsonView.as_view(**self.as_view_args)(request) + self.assertEqual(response.status_code, 200) + + def test_search_use_distinct(self): + """ + Searching across model relations use QuerySet.distinct() to avoid + duplicates. + """ + q1 = Question.objects.create(question='question 1') + q2 = Question.objects.create(question='question 2') + q2.related_questions.add(q1) + q3 = Question.objects.create(question='question 3') + q3.related_questions.add(q1) + request = self.factory.get(self.url, {'term': 'question'}) + request.user = self.superuser + + class DistinctQuestionAdmin(QuestionAdmin): + search_fields = ['related_questions__question', 'question'] + + model_admin = DistinctQuestionAdmin(Question, site) + response = AutocompleteJsonView.as_view(model_admin=model_admin)(request) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content.decode('utf-8')) + self.assertEqual(len(data['results']), 3) + + def test_missing_search_fields(self): + class EmptySearchAdmin(QuestionAdmin): + search_fields = [] + + model_admin = EmptySearchAdmin(Question, site) + msg = 'EmptySearchAdmin must have search_fields for the autocomplete_view.' + with self.assertRaisesMessage(Http404, msg): + model_admin.autocomplete_view(self.factory.get(self.url)) + + def test_get_paginator(self): + """Search results are paginated.""" + Question.objects.bulk_create(Question(question=str(i)) for i in range(PAGINATOR_SIZE + 10)) + model_admin = QuestionAdmin(Question, site) + model_admin.ordering = ['pk'] + # The first page of results. + request = self.factory.get(self.url, {'term': ''}) + request.user = self.superuser + response = AutocompleteJsonView.as_view(model_admin=model_admin)(request) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content.decode('utf-8')) + self.assertEqual(data, { + 'results': [{'id': str(q.pk), 'text': q.question} for q in Question.objects.all()[:PAGINATOR_SIZE]], + 'pagination': {'more': True}, + }) + # The second page of results. + request = self.factory.get(self.url, {'term': '', 'page': '2'}) + request.user = self.superuser + response = AutocompleteJsonView.as_view(model_admin=model_admin)(request) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content.decode('utf-8')) + self.assertEqual(data, { + 'results': [{'id': str(q.pk), 'text': q.question} for q in Question.objects.all()[PAGINATOR_SIZE:]], + 'pagination': {'more': False}, + }) + + +@override_settings(ROOT_URLCONF='admin_views.urls') +class SeleniumTests(AdminSeleniumTestCase): + available_apps = ['admin_views'] + AdminSeleniumTestCase.available_apps + + def setUp(self): + self.superuser = User.objects.create_superuser( + username='super', password='secret', email='super@example.com', + ) + self.admin_login(username='super', password='secret', login_url=reverse('admin:index')) + + def test_select(self): + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support.ui import Select + self.selenium.get(self.live_server_url + reverse('admin:admin_views_answer_add')) + elem = self.selenium.find_element_by_css_selector('.select2-selection') + elem.click() # Open the autocomplete dropdown. + results = self.selenium.find_element_by_css_selector('.select2-results') + self.assertTrue(results.is_displayed()) + option = self.selenium.find_element_by_css_selector('.select2-results__option') + self.assertEqual(option.text, 'No results found') + elem.click() # Close the autocomplete dropdown. + q1 = Question.objects.create(question='Who am I?') + Question.objects.bulk_create(Question(question=str(i)) for i in range(PAGINATOR_SIZE + 10)) + elem.click() # Reopen the dropdown now that some objects exist. + result_container = self.selenium.find_element_by_css_selector('.select2-results') + self.assertTrue(result_container.is_displayed()) + results = result_container.find_elements_by_css_selector('.select2-results__option') + # PAGINATOR_SIZE results and "Loading more results". + self.assertEqual(len(results), PAGINATOR_SIZE + 1) + search = self.selenium.find_element_by_css_selector('.select2-search__field') + # Load next page of results by scrolling to the bottom of the list. + for _ in range(len(results)): + search.send_keys(Keys.ARROW_DOWN) + results = result_container.find_elements_by_css_selector('.select2-results__option') + # All objects and "Loading more results". + self.assertEqual(len(results), PAGINATOR_SIZE + 11) + # Limit the results with the search field. + search.send_keys('Who') + results = result_container.find_elements_by_css_selector('.select2-results__option') + self.assertEqual(len(results), 1) + # Select the result. + search.send_keys(Keys.RETURN) + select = Select(self.selenium.find_element_by_id('id_question')) + self.assertEqual(select.first_selected_option.get_attribute('value'), str(q1.pk)) + + def test_select_multiple(self): + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support.ui import Select + self.selenium.get(self.live_server_url + reverse('admin:admin_views_question_add')) + elem = self.selenium.find_element_by_css_selector('.select2-selection') + elem.click() # Open the autocomplete dropdown. + results = self.selenium.find_element_by_css_selector('.select2-results') + self.assertTrue(results.is_displayed()) + option = self.selenium.find_element_by_css_selector('.select2-results__option') + self.assertEqual(option.text, 'No results found') + elem.click() # Close the autocomplete dropdown. + Question.objects.create(question='Who am I?') + Question.objects.bulk_create(Question(question=str(i)) for i in range(PAGINATOR_SIZE + 10)) + elem.click() # Reopen the dropdown now that some objects exist. + result_container = self.selenium.find_element_by_css_selector('.select2-results') + self.assertTrue(result_container.is_displayed()) + results = result_container.find_elements_by_css_selector('.select2-results__option') + self.assertEqual(len(results), PAGINATOR_SIZE + 1) + search = self.selenium.find_element_by_css_selector('.select2-search__field') + # Load next page of results by scrolling to the bottom of the list. + for _ in range(len(results)): + search.send_keys(Keys.ARROW_DOWN) + results = result_container.find_elements_by_css_selector('.select2-results__option') + self.assertEqual(len(results), 31) + # Limit the results with the search field. + search.send_keys('Who') + results = result_container.find_elements_by_css_selector('.select2-results__option') + self.assertEqual(len(results), 1) + # Select the result. + search.send_keys(Keys.RETURN) + # Reopen the dropdown and add the first result to the selection. + elem.click() + search.send_keys(Keys.ARROW_DOWN) + search.send_keys(Keys.RETURN) + select = Select(self.selenium.find_element_by_id('id_related_questions')) + self.assertEqual(len(select.all_selected_options), 2) diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index d259e58e58d..10e1303659d 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -3996,6 +3996,7 @@ class SeleniumTests(AdminSeleniumTestCase): """ self.admin_login(username='super', password='secret', login_url=reverse('admin:index')) self.selenium.get(self.live_server_url + reverse('admin:admin_views_mainprepopulated_add')) + self.wait_for('.select2') # Main form ---------------------------------------------------------- self.selenium.find_element_by_id('id_pubdate').send_keys('2012-02-18') @@ -4019,9 +4020,18 @@ class SeleniumTests(AdminSeleniumTestCase): slug2 = self.selenium.find_element_by_id('id_relatedprepopulated_set-0-slug2').get_attribute('value') self.assertEqual(slug1, 'here-stacked-inline-2011-12-17') self.assertEqual(slug2, 'option-one-here-stacked-inline') + initial_select2_inputs = self.selenium.find_elements_by_class_name('select2-selection') + # Inline formsets have empty/invisible forms. + # 4 visible select2 inputs and 6 hidden inputs. + num_initial_select2_inputs = len(initial_select2_inputs) + self.assertEqual(num_initial_select2_inputs, 10) # Add an inline self.selenium.find_elements_by_link_text('Add another Related prepopulated')[0].click() + self.assertEqual( + len(self.selenium.find_elements_by_class_name('select2-selection')), + num_initial_select2_inputs + 2 + ) self.selenium.find_element_by_id('id_relatedprepopulated_set-1-pubdate').send_keys('1999-01-25') self.get_select_option('#id_relatedprepopulated_set-1-status', 'option two').click() self.selenium.find_element_by_id('id_relatedprepopulated_set-1-name').send_keys( @@ -4049,6 +4059,10 @@ class SeleniumTests(AdminSeleniumTestCase): # Add an inline self.selenium.find_elements_by_link_text('Add another Related prepopulated')[1].click() + self.assertEqual( + len(self.selenium.find_elements_by_class_name('select2-selection')), + num_initial_select2_inputs + 4 + ) self.selenium.find_element_by_id('id_relatedprepopulated_set-2-1-pubdate').send_keys('1981-08-22') self.get_select_option('#id_relatedprepopulated_set-2-1-status', 'option one').click() self.selenium.find_element_by_id('id_relatedprepopulated_set-2-1-name').send_keys( @@ -4058,7 +4072,14 @@ class SeleniumTests(AdminSeleniumTestCase): slug2 = self.selenium.find_element_by_id('id_relatedprepopulated_set-2-1-slug2').get_attribute('value') self.assertEqual(slug1, 'tabular-inline-ignored-characters-1981-08-22') self.assertEqual(slug2, 'option-one-tabular-inline-ignored-characters') - + # Add an inline without an initial inline. + # The button is outside of the browser frame. + self.selenium.execute_script("window.scrollTo(0, document.body.scrollHeight);") + self.selenium.find_elements_by_link_text('Add another Related prepopulated')[2].click() + self.assertEqual( + len(self.selenium.find_elements_by_class_name('select2-selection')), + num_initial_select2_inputs + 6 + ) # Save and check that everything is properly stored in the database self.selenium.find_element_by_xpath('//input[@value="Save"]').click() self.wait_page_loaded() @@ -4232,6 +4253,10 @@ class SeleniumTests(AdminSeleniumTestCase): self.selenium.switch_to.window(self.selenium.window_handles[0]) select = Select(self.selenium.find_element_by_id('id_form-0-section')) self.assertEqual(select.first_selected_option.text, 'edited section') + # Rendered select2 input. + select2_display = self.selenium.find_element_by_class_name('select2-selection__rendered') + # Clear button (×\n) is included in text. + self.assertEqual(select2_display.text, '×\nedited section') # Add popup self.selenium.find_element_by_id('add_id_form-0-section').click() @@ -4243,6 +4268,9 @@ class SeleniumTests(AdminSeleniumTestCase): self.selenium.switch_to.window(self.selenium.window_handles[0]) select = Select(self.selenium.find_element_by_id('id_form-0-section')) self.assertEqual(select.first_selected_option.text, 'new section') + select2_display = self.selenium.find_element_by_class_name('select2-selection__rendered') + # Clear button (×\n) is included in text. + self.assertEqual(select2_display.text, '×\nnew section') def test_inline_uuid_pk_edit_with_popup(self): from selenium.webdriver.support.ui import Select diff --git a/tests/admin_widgets/models.py b/tests/admin_widgets/models.py index 422f8b0286b..bd00b114d3e 100644 --- a/tests/admin_widgets/models.py +++ b/tests/admin_widgets/models.py @@ -27,6 +27,7 @@ class Band(models.Model): class Album(models.Model): band = models.ForeignKey(Band, models.CASCADE) + featuring = models.ManyToManyField(Band, related_name='featured') name = models.CharField(max_length=100) cover_art = models.FileField(upload_to='albums') backside_art = MyFileField(upload_to='albums_back', null=True) diff --git a/tests/admin_widgets/test_autocomplete_widget.py b/tests/admin_widgets/test_autocomplete_widget.py new file mode 100644 index 00000000000..fd79ef93690 --- /dev/null +++ b/tests/admin_widgets/test_autocomplete_widget.py @@ -0,0 +1,133 @@ +from django import forms +from django.contrib.admin.widgets import AutocompleteSelect +from django.forms import ModelChoiceField +from django.test import TestCase, override_settings +from django.utils import translation + +from .models import Album, Band + + +class AlbumForm(forms.ModelForm): + class Meta: + model = Album + fields = ['band', 'featuring'] + widgets = { + 'band': AutocompleteSelect( + Album._meta.get_field('band').remote_field, + attrs={'class': 'my-class'}, + ), + 'featuring': AutocompleteSelect( + Album._meta.get_field('featuring').remote_field, + ) + } + + +class NotRequiredBandForm(forms.Form): + band = ModelChoiceField( + queryset=Album.objects.all(), + widget=AutocompleteSelect(Album._meta.get_field('band').remote_field), + required=False, + ) + + +class RequiredBandForm(forms.Form): + band = ModelChoiceField( + queryset=Album.objects.all(), + widget=AutocompleteSelect(Album._meta.get_field('band').remote_field), + required=True, + ) + + +@override_settings(ROOT_URLCONF='admin_widgets.urls') +class AutocompleteMixinTests(TestCase): + empty_option = '' + maxDiff = 1000 + + def test_build_attrs(self): + form = AlbumForm() + attrs = form['band'].field.widget.get_context(name='my_field', value=None, attrs={})['widget']['attrs'] + self.assertEqual(attrs, { + 'class': 'my-classadmin-autocomplete', + 'data-ajax--cache': 'true', + 'data-ajax--type': 'GET', + 'data-ajax--url': '/admin_widgets/band/autocomplete/', + 'data-theme': 'admin-autocomplete', + 'data-allow-clear': 'false', + 'data-placeholder': '' + }) + + def test_build_attrs_not_required_field(self): + form = NotRequiredBandForm() + attrs = form['band'].field.widget.build_attrs({}) + self.assertJSONEqual(attrs['data-allow-clear'], True) + + def test_build_attrs_required_field(self): + form = RequiredBandForm() + attrs = form['band'].field.widget.build_attrs({}) + self.assertJSONEqual(attrs['data-allow-clear'], False) + + def test_get_url(self): + rel = Album._meta.get_field('band').remote_field + w = AutocompleteSelect(rel) + url = w.get_url() + self.assertEqual(url, '/admin_widgets/band/autocomplete/') + + def test_render_options(self): + beatles = Band.objects.create(name='The Beatles', style='rock') + who = Band.objects.create(name='The Who', style='rock') + # With 'band', a ForeignKey. + form = AlbumForm(initial={'band': beatles.pk}) + output = form.as_table() + selected_option = '' % beatles.pk + option = '' % who.pk + self.assertIn(selected_option, output) + self.assertNotIn(option, output) + # With 'featuring', a ManyToManyField. + form = AlbumForm(initial={'featuring': [beatles.pk, who.pk]}) + output = form.as_table() + selected_option = '' % beatles.pk + option = '' % who.pk + self.assertIn(selected_option, output) + self.assertIn(option, output) + + def test_render_options_required_field(self): + """Empty option is present if the field isn't required.""" + form = NotRequiredBandForm() + output = form.as_table() + self.assertIn(self.empty_option, output) + + def test_render_options_not_required_field(self): + """Empty option isn't present if the field isn't required.""" + form = RequiredBandForm() + output = form.as_table() + self.assertNotIn(self.empty_option, output) + + def test_media(self): + rel = Album._meta.get_field('band').remote_field + base_files = ( + 'admin/js/vendor/jquery/jquery.min.js', + 'admin/js/vendor/select2/select2.full.min.js', + # Language file is inserted here. + 'admin/js/jquery.init.js', + 'admin/js/autocomplete.js', + ) + languages = ( + ('de', 'de'), + # Language with code 00 does not exist. + ('00', None), + # Language files are case sensitive. + ('sr-cyrl', 'sr-Cyrl'), + ('zh-cn', 'zh-CN'), + ) + for lang, select_lang in languages: + with self.subTest(lang=lang): + if select_lang: + expected_files = ( + base_files[:2] + + (('admin/js/vendor/select2/i18n/%s.js' % select_lang),) + + base_files[2:] + ) + else: + expected_files = base_files + with translation.override(lang): + self.assertEqual(AutocompleteSelect(rel).media._js, expected_files) diff --git a/tests/modeladmin/models.py b/tests/modeladmin/models.py index 861a2dbb9df..c0d3c772c93 100644 --- a/tests/modeladmin/models.py +++ b/tests/modeladmin/models.py @@ -14,6 +14,15 @@ class Band(models.Model): return self.name +class Song(models.Model): + name = models.CharField(max_length=100) + band = models.ForeignKey(Band, models.CASCADE) + featuring = models.ManyToManyField(Band, related_name='featured') + + def __str__(self): + return self.name + + class Concert(models.Model): main_band = models.ForeignKey(Band, models.CASCADE, related_name='main_concerts') opening_band = models.ForeignKey(Band, models.CASCADE, related_name='opening_concerts', blank=True) diff --git a/tests/modeladmin/test_checks.py b/tests/modeladmin/test_checks.py index acca6b18a2b..eaca153bd8d 100644 --- a/tests/modeladmin/test_checks.py +++ b/tests/modeladmin/test_checks.py @@ -6,14 +6,16 @@ from django.core.checks import Error from django.forms.models import BaseModelFormSet from django.test import SimpleTestCase -from .models import Band, ValidationTestInlineModel, ValidationTestModel +from .models import Band, Song, ValidationTestInlineModel, ValidationTestModel class CheckTestCase(SimpleTestCase): - def assertIsInvalid(self, model_admin, model, msg, id=None, hint=None, invalid_obj=None): + def assertIsInvalid(self, model_admin, model, msg, id=None, hint=None, invalid_obj=None, admin_site=None): + if admin_site is None: + admin_site = AdminSite() invalid_obj = invalid_obj or model_admin - admin_obj = model_admin(model, AdminSite()) + admin_obj = model_admin(model, admin_site) self.assertEqual(admin_obj.check(), [Error(msg, hint=hint, obj=invalid_obj, id=id)]) def assertIsInvalidRegexp(self, model_admin, model, msg, id=None, hint=None, invalid_obj=None): @@ -30,8 +32,10 @@ class CheckTestCase(SimpleTestCase): self.assertEqual(error.id, id) self.assertRegex(error.msg, msg) - def assertIsValid(self, model_admin, model): - admin_obj = model_admin(model, AdminSite()) + def assertIsValid(self, model_admin, model, admin_site=None): + if admin_site is None: + admin_site = AdminSite() + admin_obj = model_admin(model, admin_site) self.assertEqual(admin_obj.check(), []) @@ -1153,3 +1157,89 @@ class ListDisplayEditableTests(CheckTestCase): "'list_display_links'.", id='admin.E123', ) + + +class AutocompleteFieldsTests(CheckTestCase): + def test_autocomplete_e036(self): + class Admin(ModelAdmin): + autocomplete_fields = 'name' + + self.assertIsInvalid( + Admin, Band, + msg="The value of 'autocomplete_fields' must be a list or tuple.", + id='admin.E036', + invalid_obj=Admin, + ) + + def test_autocomplete_e037(self): + class Admin(ModelAdmin): + autocomplete_fields = ('nonexistent',) + + self.assertIsInvalid( + Admin, ValidationTestModel, + msg=( + "The value of 'autocomplete_fields[0]' refers to 'nonexistent', " + "which is not an attribute of 'modeladmin.ValidationTestModel'." + ), + id='admin.E037', + invalid_obj=Admin, + ) + + def test_autocomplete_e38(self): + class Admin(ModelAdmin): + autocomplete_fields = ('name',) + + self.assertIsInvalid( + Admin, ValidationTestModel, + msg=( + "The value of 'autocomplete_fields[0]' must be a foreign " + "key or a many-to-many field." + ), + id='admin.E038', + invalid_obj=Admin, + ) + + def test_autocomplete_e039(self): + class Admin(ModelAdmin): + autocomplete_fields = ('band',) + + self.assertIsInvalid( + Admin, Song, + msg=( + 'An admin for model "Band" has to be registered ' + 'to be referenced by Admin.autocomplete_fields.' + ), + id='admin.E039', + invalid_obj=Admin, + ) + + def test_autocomplete_e040(self): + class NoSearchFieldsAdmin(ModelAdmin): + pass + + class AutocompleteAdmin(ModelAdmin): + autocomplete_fields = ('featuring',) + + site = AdminSite() + site.register(Band, NoSearchFieldsAdmin) + self.assertIsInvalid( + AutocompleteAdmin, Song, + msg=( + 'NoSearchFieldsAdmin must define "search_fields", because ' + 'it\'s referenced by AutocompleteAdmin.autocomplete_fields.' + ), + id='admin.E040', + invalid_obj=AutocompleteAdmin, + admin_site=site, + ) + + def test_autocomplete_is_valid(self): + class SearchFieldsAdmin(ModelAdmin): + search_fields = 'name' + + class AutocompleteAdmin(ModelAdmin): + autocomplete_fields = ('featuring',) + + site = AdminSite() + site.register(Band, SearchFieldsAdmin) + self.assertIsValid(AutocompleteAdmin, Song, admin_site=site) diff --git a/tests/modeladmin/tests.py b/tests/modeladmin/tests.py index 25b9dfed69f..67bed3d6975 100644 --- a/tests/modeladmin/tests.py +++ b/tests/modeladmin/tests.py @@ -7,14 +7,17 @@ from django.contrib.admin.options import ( get_content_type_for_model, ) from django.contrib.admin.sites import AdminSite -from django.contrib.admin.widgets import AdminDateWidget, AdminRadioSelect +from django.contrib.admin.widgets import ( + AdminDateWidget, AdminRadioSelect, AutocompleteSelect, + AutocompleteSelectMultiple, +) from django.contrib.auth.models import User from django.db import models from django.forms.widgets import Select from django.test import SimpleTestCase, TestCase from django.test.utils import isolate_apps -from .models import Band, Concert +from .models import Band, Concert, Song class MockRequest: @@ -638,6 +641,31 @@ class ModelAdminTests(TestCase): self.assertEqual(fetched.change_message, str(message)) self.assertEqual(fetched.object_repr, str(self.band)) + def test_get_autocomplete_fields(self): + class NameAdmin(ModelAdmin): + search_fields = ['name'] + + class SongAdmin(ModelAdmin): + autocomplete_fields = ['featuring'] + fields = ['featuring', 'band'] + + class OtherSongAdmin(SongAdmin): + def get_autocomplete_fields(self, request): + return ['band'] + + self.site.register(Band, NameAdmin) + try: + # Uses autocomplete_fields if not overridden. + model_admin = SongAdmin(Song, self.site) + form = model_admin.get_form(request)() + self.assertIsInstance(form.fields['featuring'].widget.widget, AutocompleteSelectMultiple) + # Uses overridden get_autocomplete_fields + model_admin = OtherSongAdmin(Song, self.site) + form = model_admin.get_form(request)() + self.assertIsInstance(form.fields['band'].widget.widget, AutocompleteSelect) + finally: + self.site.unregister(Band) + class ModelAdminPermissionTests(SimpleTestCase):