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

View File

@ -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 ``<ModelAdmin class>``, or an attribute of ``<model>``.
* **admin.E036**: The value of ``autocomplete_fields`` must be a list or tuple.
* **admin.E037**: The value of ``autocomplete_fields[n]`` refers to
``<field name>``, which is not an attribute of ``<model>``.
* **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 ``<model>`` has to be registered to be
referenced by ``<modeladmin>.autocomplete_fields``.
* **admin.E040**: ``<modeladmin>`` must define ``search_fields``, because
it's referenced by ``<other_modeladmin>.autocomplete_fields``.
``ModelAdmin``
~~~~~~~~~~~~~~

View File

@ -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
<https://select2.org/>`_ autocomplete inputs.
By default, the admin uses a select-box interface (``<select>``) for fields
that are . Sometimes you don't want to incur the overhead of selecting all
the related instances to display in the dropdown.
The Select2 input looks similar to the default input but comes with a
search feature that loads the options asynchronously. This is faster and
more user-friendly if the related model has many instances.
You must define :attr:`~ModelAdmin.search_fields` on the related object's
``ModelAdmin`` because the autocomplete search uses it.
Ordering and pagination of the results are controlled by the related
``ModelAdmin``'s :meth:`~ModelAdmin.get_ordering` and
:meth:`~ModelAdmin.get_paginator` methods.
In the following example, ``ChoiceAdmin`` has an autocomplete field for the
``ForeignKey`` to the ``Question``. The results are filtered by the
``question_text`` field and ordered by the ``date_created`` field::
class QuestionAdmin(admin.ModelAdmin):
ordering = ['date_created']
search_fields = ['question_text']
class ChoiceAdmin(admin.ModelAdmin):
autocomplete_fields = ['question']
.. admonition:: Performance considerations for large datasets
Ordering using :attr:`ModelAdmin.ordering` may cause performance
problems as sorting on a large queryset will be slow.
Also, if your search fields include fields that aren't indexed by the
database, you might encounter poor performance on extremely large
tables.
For those cases, it's a good idea to write your own
:func:`ModelAdmin.get_search_results` implementation using a
full-text indexed search.
You may also want to change the ``Paginator`` on very large tables
as the default paginator always performs a ``count()`` query.
For example, you could override the default implementation of the
``Paginator.count`` property.
.. attribute:: ModelAdmin.raw_id_fields
By default, Django's admin uses a select-box interface (<select>) 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

View File

@ -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 <https://select2.org>`_ search widget for ``ForeignKey`` and
``ManyToManyField``.
:mod:`django.contrib.admindocs`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -27,6 +27,7 @@ attr
auth
autoclobber
autocommit
autocomplete
autocompletion
autodetect
autodetectable

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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, '<i>edited section</i>')
# 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, '×\n<i>edited section</i>')
# 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

View File

@ -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)

View File

@ -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 = '<option value=""></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 = '<option value="%s" selected>The Beatles</option>' % beatles.pk
option = '<option value="%s">The Who</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 = '<option value="%s" selected>The Beatles</option>' % beatles.pk
option = '<option value="%s" selected>The Who</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)

View File

@ -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)

View File

@ -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)

View File

@ -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):