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:
parent
01a294f8f0
commit
94cd8efc50
|
@ -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. """
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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``
|
||||
~~~~~~~~~~~~~~
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
|
|
@ -27,6 +27,7 @@ attr
|
|||
auth
|
||||
autoclobber
|
||||
autocommit
|
||||
autocomplete
|
||||
autocompletion
|
||||
autodetect
|
||||
autodetectable
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
||||
|
|
Loading…
Reference in New Issue