summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorJohannes Hoppe <info@johanneshoppe.com>2017-05-10 14:48:57 +0200
committerTim Graham <timograham@gmail.com>2017-09-18 13:48:02 -0400
commit94cd8efc50c717cd00244f4b2233f971a53b205e (patch)
treeab1f13a12121907a561962181eaceeab793cb838 /tests
parent01a294f8f014a32e288958701540ea47dcb9fc14 (diff)
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.
Diffstat (limited to 'tests')
-rw-r--r--tests/admin_views/admin.py41
-rw-r--r--tests/admin_views/customadmin.py2
-rw-r--r--tests/admin_views/models.py16
-rw-r--r--tests/admin_views/test_autocomplete_view.py231
-rw-r--r--tests/admin_views/tests.py30
-rw-r--r--tests/admin_widgets/models.py1
-rw-r--r--tests/admin_widgets/test_autocomplete_widget.py133
-rw-r--r--tests/modeladmin/models.py9
-rw-r--r--tests/modeladmin/test_checks.py100
-rw-r--r--tests/modeladmin/tests.py32
10 files changed, 580 insertions, 15 deletions
diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py
index 6139f0460f..2c58baea7a 100644
--- a/tests/admin_views/admin.py
+++ b/tests/admin_views/admin.py
@@ -97,6 +97,7 @@ class ArticleAdmin(admin.ModelAdmin):
)
list_editable = ('section',)
list_filter = ('date', 'section')
+ autocomplete_fields = ('section',)
view_on_site = False
fieldsets = (
('Some fields', {
@@ -497,6 +498,10 @@ class PizzaAdmin(admin.ModelAdmin):
readonly_fields = ('toppings',)
+class StudentAdmin(admin.ModelAdmin):
+ search_fields = ('name',)
+
+
class WorkHourAdmin(admin.ModelAdmin):
list_display = ('datum', 'employee')
list_filter = ('employee',)
@@ -603,6 +608,16 @@ class AlbumAdmin(admin.ModelAdmin):
list_filter = ['title']
+class QuestionAdmin(admin.ModelAdmin):
+ ordering = ['-posted']
+ search_fields = ['question']
+ autocomplete_fields = ['related_questions']
+
+
+class AnswerAdmin(admin.ModelAdmin):
+ autocomplete_fields = ['question']
+
+
class PrePopulatedPostLargeSlugAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ('title',)
@@ -664,12 +679,17 @@ class CustomTemplateFilterColorAdmin(admin.ModelAdmin):
class RelatedPrepopulatedInline1(admin.StackedInline):
fieldsets = (
(None, {
- 'fields': (('pubdate', 'status'), ('name', 'slug1', 'slug2',),)
+ 'fields': (
+ ('fk', 'm2m'),
+ ('pubdate', 'status'),
+ ('name', 'slug1', 'slug2',),
+ ),
}),
)
formfield_overrides = {models.CharField: {'strip': False}}
model = RelatedPrepopulated
extra = 1
+ autocomplete_fields = ['fk', 'm2m']
prepopulated_fields = {'slug1': ['name', 'pubdate'],
'slug2': ['status', 'name']}
@@ -677,12 +697,19 @@ class RelatedPrepopulatedInline1(admin.StackedInline):
class RelatedPrepopulatedInline2(admin.TabularInline):
model = RelatedPrepopulated
extra = 1
+ autocomplete_fields = ['fk', 'm2m']
prepopulated_fields = {'slug1': ['name', 'pubdate'],
'slug2': ['status', 'name']}
+class RelatedPrepopulatedInline3(admin.TabularInline):
+ model = RelatedPrepopulated
+ extra = 0
+ autocomplete_fields = ['fk', 'm2m']
+
+
class MainPrepopulatedAdmin(admin.ModelAdmin):
- inlines = [RelatedPrepopulatedInline1, RelatedPrepopulatedInline2]
+ inlines = [RelatedPrepopulatedInline1, RelatedPrepopulatedInline2, RelatedPrepopulatedInline3]
fieldsets = (
(None, {
'fields': (('pubdate', 'status'), ('name', 'slug1', 'slug2', 'slug3'))
@@ -894,7 +921,10 @@ site = admin.AdminSite(name="admin")
site.site_url = '/my-site-url/'
site.register(Article, ArticleAdmin)
site.register(CustomArticle, CustomArticleAdmin)
-site.register(Section, save_as=True, inlines=[ArticleInline], readonly_fields=['name_property'])
+site.register(
+ Section, save_as=True, inlines=[ArticleInline],
+ readonly_fields=['name_property'], search_fields=['name'],
+)
site.register(ModelWithStringPrimaryKey)
site.register(Color)
site.register(Thing, ThingAdmin)
@@ -956,6 +986,7 @@ site.register(InlineReferer, InlineRefererAdmin)
site.register(ReferencedByGenRel)
site.register(GenRelReference)
site.register(ParentWithUUIDPK)
+site.register(RelatedPrepopulated, search_fields=['name'])
site.register(RelatedWithUUIDPKModel)
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
@@ -973,8 +1004,8 @@ site.register(Pizza, PizzaAdmin)
site.register(ReadablePizza)
site.register(Topping, ToppingAdmin)
site.register(Album, AlbumAdmin)
-site.register(Question)
-site.register(Answer, date_hierarchy='question__posted')
+site.register(Question, QuestionAdmin)
+site.register(Answer, AnswerAdmin, date_hierarchy='question__posted')
site.register(Answer2, date_hierarchy='question__expires')
site.register(PrePopulatedPost, PrePopulatedPostAdmin)
site.register(ComplexSortedPerson, ComplexSortedPersonAdmin)
diff --git a/tests/admin_views/customadmin.py b/tests/admin_views/customadmin.py
index 2fbbb78595..5ee8c0c159 100644
--- a/tests/admin_views/customadmin.py
+++ b/tests/admin_views/customadmin.py
@@ -49,7 +49,7 @@ class CustomPwdTemplateUserAdmin(UserAdmin):
site = Admin2(name="admin2")
site.register(models.Article, base_admin.ArticleAdmin)
-site.register(models.Section, inlines=[base_admin.ArticleInline])
+site.register(models.Section, inlines=[base_admin.ArticleInline], search_fields=['name'])
site.register(models.Thing, base_admin.ThingAdmin)
site.register(models.Fabric, base_admin.FabricAdmin)
site.register(models.ChapterXtra1, base_admin.ChapterXtra1Admin)
diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py
index dd4921d1ce..fc229cf3bd 100644
--- a/tests/admin_views/models.py
+++ b/tests/admin_views/models.py
@@ -600,6 +600,10 @@ class Question(models.Model):
question = models.CharField(max_length=20)
posted = models.DateField(default=datetime.date.today)
expires = models.DateTimeField(null=True, blank=True)
+ related_questions = models.ManyToManyField('self')
+
+ def __str__(self):
+ return self.question
class Answer(models.Model):
@@ -746,6 +750,8 @@ class MainPrepopulated(models.Model):
class RelatedPrepopulated(models.Model):
parent = models.ForeignKey(MainPrepopulated, models.CASCADE)
name = models.CharField(max_length=75)
+ fk = models.ForeignKey('self', models.CASCADE, blank=True, null=True)
+ m2m = models.ManyToManyField('self', blank=True)
pubdate = models.DateField()
status = models.CharField(
max_length=20,
@@ -906,7 +912,6 @@ class InlineReference(models.Model):
)
-# Models for #23604 and #23915
class Recipe(models.Model):
rname = models.CharField(max_length=20, unique=True)
@@ -957,3 +962,12 @@ class ParentWithUUIDPK(models.Model):
class RelatedWithUUIDPKModel(models.Model):
parent = models.ForeignKey(ParentWithUUIDPK, on_delete=models.SET_NULL, null=True, blank=True)
+
+
+class Author(models.Model):
+ pass
+
+
+class Authorship(models.Model):
+ book = models.ForeignKey(Book, models.CASCADE)
+ author = models.ForeignKey(Author, models.CASCADE)
diff --git a/tests/admin_views/test_autocomplete_view.py b/tests/admin_views/test_autocomplete_view.py
new file mode 100644
index 0000000000..8396ceb5d1
--- /dev/null
+++ b/tests/admin_views/test_autocomplete_view.py
@@ -0,0 +1,231 @@
+import json
+
+from django.contrib import admin
+from django.contrib.admin import site
+from django.contrib.admin.tests import AdminSeleniumTestCase
+from django.contrib.admin.views.autocomplete import AutocompleteJsonView
+from django.contrib.auth.models import Permission, User
+from django.contrib.contenttypes.models import ContentType
+from django.http import Http404
+from django.test import RequestFactory, override_settings
+from django.urls import reverse, reverse_lazy
+
+from .admin import AnswerAdmin, QuestionAdmin
+from .models import Answer, Author, Authorship, Book, Question
+from .tests import AdminViewBasicTestCase
+
+PAGINATOR_SIZE = AutocompleteJsonView.paginate_by
+
+
+class AuthorAdmin(admin.ModelAdmin):
+ search_fields = ['id']
+
+
+class AuthorshipInline(admin.TabularInline):
+ model = Authorship
+ autocomplete_fields = ['author']
+
+
+class BookAdmin(admin.ModelAdmin):
+ inlines = [AuthorshipInline]
+
+
+site.register(Question, QuestionAdmin)
+site.register(Answer, AnswerAdmin)
+site.register(Author, AuthorAdmin)
+site.register(Book, BookAdmin)
+
+
+class AutocompleteJsonViewTests(AdminViewBasicTestCase):
+ as_view_args = {'model_admin': QuestionAdmin(Question, site)}
+ factory = RequestFactory()
+ url = reverse_lazy('admin:admin_views_question_autocomplete')
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = User.objects.create_user(
+ username='user', password='secret',
+ email='user@example.com', is_staff=True,
+ )
+ super().setUpTestData()
+
+ def test_success(self):
+ q = Question.objects.create(question='Is this a question?')
+ request = self.factory.get(self.url, {'term': 'is'})
+ request.user = self.superuser
+ response = AutocompleteJsonView.as_view(**self.as_view_args)(request)
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(data, {
+ 'results': [{'id': str(q.pk), 'text': q.question}],
+ 'pagination': {'more': False},
+ })
+
+ def test_must_be_logged_in(self):
+ response = self.client.get(self.url, {'term': ''})
+ self.assertEqual(response.status_code, 200)
+ self.client.logout()
+ response = self.client.get(self.url, {'term': ''})
+ self.assertEqual(response.status_code, 302)
+
+ def test_has_change_permission_required(self):
+ """
+ Users require the change permission for the related model to the
+ autocomplete view for it.
+ """
+ request = self.factory.get(self.url, {'term': 'is'})
+ self.user.is_staff = True
+ self.user.save()
+ request.user = self.user
+ response = AutocompleteJsonView.as_view(**self.as_view_args)(request)
+ self.assertEqual(response.status_code, 403)
+ self.assertJSONEqual(response.content.decode('utf-8'), {'error': '403 Forbidden'})
+ # Add the change permission and retry.
+ p = Permission.objects.get(
+ content_type=ContentType.objects.get_for_model(Question),
+ codename='change_question',
+ )
+ self.user.user_permissions.add(p)
+ request.user = User.objects.get(pk=self.user.pk)
+ response = AutocompleteJsonView.as_view(**self.as_view_args)(request)
+ self.assertEqual(response.status_code, 200)
+
+ def test_search_use_distinct(self):
+ """
+ Searching across model relations use QuerySet.distinct() to avoid
+ duplicates.
+ """
+ q1 = Question.objects.create(question='question 1')
+ q2 = Question.objects.create(question='question 2')
+ q2.related_questions.add(q1)
+ q3 = Question.objects.create(question='question 3')
+ q3.related_questions.add(q1)
+ request = self.factory.get(self.url, {'term': 'question'})
+ request.user = self.superuser
+
+ class DistinctQuestionAdmin(QuestionAdmin):
+ search_fields = ['related_questions__question', 'question']
+
+ model_admin = DistinctQuestionAdmin(Question, site)
+ response = AutocompleteJsonView.as_view(model_admin=model_admin)(request)
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(len(data['results']), 3)
+
+ def test_missing_search_fields(self):
+ class EmptySearchAdmin(QuestionAdmin):
+ search_fields = []
+
+ model_admin = EmptySearchAdmin(Question, site)
+ msg = 'EmptySearchAdmin must have search_fields for the autocomplete_view.'
+ with self.assertRaisesMessage(Http404, msg):
+ model_admin.autocomplete_view(self.factory.get(self.url))
+
+ def test_get_paginator(self):
+ """Search results are paginated."""
+ Question.objects.bulk_create(Question(question=str(i)) for i in range(PAGINATOR_SIZE + 10))
+ model_admin = QuestionAdmin(Question, site)
+ model_admin.ordering = ['pk']
+ # The first page of results.
+ request = self.factory.get(self.url, {'term': ''})
+ request.user = self.superuser
+ response = AutocompleteJsonView.as_view(model_admin=model_admin)(request)
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(data, {
+ 'results': [{'id': str(q.pk), 'text': q.question} for q in Question.objects.all()[:PAGINATOR_SIZE]],
+ 'pagination': {'more': True},
+ })
+ # The second page of results.
+ request = self.factory.get(self.url, {'term': '', 'page': '2'})
+ request.user = self.superuser
+ response = AutocompleteJsonView.as_view(model_admin=model_admin)(request)
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(data, {
+ 'results': [{'id': str(q.pk), 'text': q.question} for q in Question.objects.all()[PAGINATOR_SIZE:]],
+ 'pagination': {'more': False},
+ })
+
+
+@override_settings(ROOT_URLCONF='admin_views.urls')
+class SeleniumTests(AdminSeleniumTestCase):
+ available_apps = ['admin_views'] + AdminSeleniumTestCase.available_apps
+
+ def setUp(self):
+ self.superuser = User.objects.create_superuser(
+ username='super', password='secret', email='super@example.com',
+ )
+ self.admin_login(username='super', password='secret', login_url=reverse('admin:index'))
+
+ def test_select(self):
+ from selenium.webdriver.common.keys import Keys
+ from selenium.webdriver.support.ui import Select
+ self.selenium.get(self.live_server_url + reverse('admin:admin_views_answer_add'))
+ elem = self.selenium.find_element_by_css_selector('.select2-selection')
+ elem.click() # Open the autocomplete dropdown.
+ results = self.selenium.find_element_by_css_selector('.select2-results')
+ self.assertTrue(results.is_displayed())
+ option = self.selenium.find_element_by_css_selector('.select2-results__option')
+ self.assertEqual(option.text, 'No results found')
+ elem.click() # Close the autocomplete dropdown.
+ q1 = Question.objects.create(question='Who am I?')
+ Question.objects.bulk_create(Question(question=str(i)) for i in range(PAGINATOR_SIZE + 10))
+ elem.click() # Reopen the dropdown now that some objects exist.
+ result_container = self.selenium.find_element_by_css_selector('.select2-results')
+ self.assertTrue(result_container.is_displayed())
+ results = result_container.find_elements_by_css_selector('.select2-results__option')
+ # PAGINATOR_SIZE results and "Loading more results".
+ self.assertEqual(len(results), PAGINATOR_SIZE + 1)
+ search = self.selenium.find_element_by_css_selector('.select2-search__field')
+ # Load next page of results by scrolling to the bottom of the list.
+ for _ in range(len(results)):
+ search.send_keys(Keys.ARROW_DOWN)
+ results = result_container.find_elements_by_css_selector('.select2-results__option')
+ # All objects and "Loading more results".
+ self.assertEqual(len(results), PAGINATOR_SIZE + 11)
+ # Limit the results with the search field.
+ search.send_keys('Who')
+ results = result_container.find_elements_by_css_selector('.select2-results__option')
+ self.assertEqual(len(results), 1)
+ # Select the result.
+ search.send_keys(Keys.RETURN)
+ select = Select(self.selenium.find_element_by_id('id_question'))
+ self.assertEqual(select.first_selected_option.get_attribute('value'), str(q1.pk))
+
+ def test_select_multiple(self):
+ from selenium.webdriver.common.keys import Keys
+ from selenium.webdriver.support.ui import Select
+ self.selenium.get(self.live_server_url + reverse('admin:admin_views_question_add'))
+ elem = self.selenium.find_element_by_css_selector('.select2-selection')
+ elem.click() # Open the autocomplete dropdown.
+ results = self.selenium.find_element_by_css_selector('.select2-results')
+ self.assertTrue(results.is_displayed())
+ option = self.selenium.find_element_by_css_selector('.select2-results__option')
+ self.assertEqual(option.text, 'No results found')
+ elem.click() # Close the autocomplete dropdown.
+ Question.objects.create(question='Who am I?')
+ Question.objects.bulk_create(Question(question=str(i)) for i in range(PAGINATOR_SIZE + 10))
+ elem.click() # Reopen the dropdown now that some objects exist.
+ result_container = self.selenium.find_element_by_css_selector('.select2-results')
+ self.assertTrue(result_container.is_displayed())
+ results = result_container.find_elements_by_css_selector('.select2-results__option')
+ self.assertEqual(len(results), PAGINATOR_SIZE + 1)
+ search = self.selenium.find_element_by_css_selector('.select2-search__field')
+ # Load next page of results by scrolling to the bottom of the list.
+ for _ in range(len(results)):
+ search.send_keys(Keys.ARROW_DOWN)
+ results = result_container.find_elements_by_css_selector('.select2-results__option')
+ self.assertEqual(len(results), 31)
+ # Limit the results with the search field.
+ search.send_keys('Who')
+ results = result_container.find_elements_by_css_selector('.select2-results__option')
+ self.assertEqual(len(results), 1)
+ # Select the result.
+ search.send_keys(Keys.RETURN)
+ # Reopen the dropdown and add the first result to the selection.
+ elem.click()
+ search.send_keys(Keys.ARROW_DOWN)
+ search.send_keys(Keys.RETURN)
+ select = Select(self.selenium.find_element_by_id('id_related_questions'))
+ self.assertEqual(len(select.all_selected_options), 2)
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index d259e58e58..10e1303659 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -3996,6 +3996,7 @@ class SeleniumTests(AdminSeleniumTestCase):
"""
self.admin_login(username='super', password='secret', login_url=reverse('admin:index'))
self.selenium.get(self.live_server_url + reverse('admin:admin_views_mainprepopulated_add'))
+ self.wait_for('.select2')
# Main form ----------------------------------------------------------
self.selenium.find_element_by_id('id_pubdate').send_keys('2012-02-18')
@@ -4019,9 +4020,18 @@ class SeleniumTests(AdminSeleniumTestCase):
slug2 = self.selenium.find_element_by_id('id_relatedprepopulated_set-0-slug2').get_attribute('value')
self.assertEqual(slug1, 'here-stacked-inline-2011-12-17')
self.assertEqual(slug2, 'option-one-here-stacked-inline')
+ initial_select2_inputs = self.selenium.find_elements_by_class_name('select2-selection')
+ # Inline formsets have empty/invisible forms.
+ # 4 visible select2 inputs and 6 hidden inputs.
+ num_initial_select2_inputs = len(initial_select2_inputs)
+ self.assertEqual(num_initial_select2_inputs, 10)
# Add an inline
self.selenium.find_elements_by_link_text('Add another Related prepopulated')[0].click()
+ self.assertEqual(
+ len(self.selenium.find_elements_by_class_name('select2-selection')),
+ num_initial_select2_inputs + 2
+ )
self.selenium.find_element_by_id('id_relatedprepopulated_set-1-pubdate').send_keys('1999-01-25')
self.get_select_option('#id_relatedprepopulated_set-1-status', 'option two').click()
self.selenium.find_element_by_id('id_relatedprepopulated_set-1-name').send_keys(
@@ -4049,6 +4059,10 @@ class SeleniumTests(AdminSeleniumTestCase):
# Add an inline
self.selenium.find_elements_by_link_text('Add another Related prepopulated')[1].click()
+ self.assertEqual(
+ len(self.selenium.find_elements_by_class_name('select2-selection')),
+ num_initial_select2_inputs + 4
+ )
self.selenium.find_element_by_id('id_relatedprepopulated_set-2-1-pubdate').send_keys('1981-08-22')
self.get_select_option('#id_relatedprepopulated_set-2-1-status', 'option one').click()
self.selenium.find_element_by_id('id_relatedprepopulated_set-2-1-name').send_keys(
@@ -4058,7 +4072,14 @@ class SeleniumTests(AdminSeleniumTestCase):
slug2 = self.selenium.find_element_by_id('id_relatedprepopulated_set-2-1-slug2').get_attribute('value')
self.assertEqual(slug1, 'tabular-inline-ignored-characters-1981-08-22')
self.assertEqual(slug2, 'option-one-tabular-inline-ignored-characters')
-
+ # Add an inline without an initial inline.
+ # The button is outside of the browser frame.
+ self.selenium.execute_script("window.scrollTo(0, document.body.scrollHeight);")
+ self.selenium.find_elements_by_link_text('Add another Related prepopulated')[2].click()
+ self.assertEqual(
+ len(self.selenium.find_elements_by_class_name('select2-selection')),
+ num_initial_select2_inputs + 6
+ )
# Save and check that everything is properly stored in the database
self.selenium.find_element_by_xpath('//input[@value="Save"]').click()
self.wait_page_loaded()
@@ -4232,6 +4253,10 @@ class SeleniumTests(AdminSeleniumTestCase):
self.selenium.switch_to.window(self.selenium.window_handles[0])
select = Select(self.selenium.find_element_by_id('id_form-0-section'))
self.assertEqual(select.first_selected_option.text, '<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
diff --git a/tests/admin_widgets/models.py b/tests/admin_widgets/models.py
index 422f8b0286..bd00b114d3 100644
--- a/tests/admin_widgets/models.py
+++ b/tests/admin_widgets/models.py
@@ -27,6 +27,7 @@ class Band(models.Model):
class Album(models.Model):
band = models.ForeignKey(Band, models.CASCADE)
+ featuring = models.ManyToManyField(Band, related_name='featured')
name = models.CharField(max_length=100)
cover_art = models.FileField(upload_to='albums')
backside_art = MyFileField(upload_to='albums_back', null=True)
diff --git a/tests/admin_widgets/test_autocomplete_widget.py b/tests/admin_widgets/test_autocomplete_widget.py
new file mode 100644
index 0000000000..fd79ef9369
--- /dev/null
+++ b/tests/admin_widgets/test_autocomplete_widget.py
@@ -0,0 +1,133 @@
+from django import forms
+from django.contrib.admin.widgets import AutocompleteSelect
+from django.forms import ModelChoiceField
+from django.test import TestCase, override_settings
+from django.utils import translation
+
+from .models import Album, Band
+
+
+class AlbumForm(forms.ModelForm):
+ class Meta:
+ model = Album
+ fields = ['band', 'featuring']
+ widgets = {
+ 'band': AutocompleteSelect(
+ Album._meta.get_field('band').remote_field,
+ attrs={'class': 'my-class'},
+ ),
+ 'featuring': AutocompleteSelect(
+ Album._meta.get_field('featuring').remote_field,
+ )
+ }
+
+
+class NotRequiredBandForm(forms.Form):
+ band = ModelChoiceField(
+ queryset=Album.objects.all(),
+ widget=AutocompleteSelect(Album._meta.get_field('band').remote_field),
+ required=False,
+ )
+
+
+class RequiredBandForm(forms.Form):
+ band = ModelChoiceField(
+ queryset=Album.objects.all(),
+ widget=AutocompleteSelect(Album._meta.get_field('band').remote_field),
+ required=True,
+ )
+
+
+@override_settings(ROOT_URLCONF='admin_widgets.urls')
+class AutocompleteMixinTests(TestCase):
+ empty_option = '<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)
diff --git a/tests/modeladmin/models.py b/tests/modeladmin/models.py
index 861a2dbb9d..c0d3c772c9 100644
--- a/tests/modeladmin/models.py
+++ b/tests/modeladmin/models.py
@@ -14,6 +14,15 @@ class Band(models.Model):
return self.name
+class Song(models.Model):
+ name = models.CharField(max_length=100)
+ band = models.ForeignKey(Band, models.CASCADE)
+ featuring = models.ManyToManyField(Band, related_name='featured')
+
+ def __str__(self):
+ return self.name
+
+
class Concert(models.Model):
main_band = models.ForeignKey(Band, models.CASCADE, related_name='main_concerts')
opening_band = models.ForeignKey(Band, models.CASCADE, related_name='opening_concerts', blank=True)
diff --git a/tests/modeladmin/test_checks.py b/tests/modeladmin/test_checks.py
index acca6b18a2..eaca153bd8 100644
--- a/tests/modeladmin/test_checks.py
+++ b/tests/modeladmin/test_checks.py
@@ -6,14 +6,16 @@ from django.core.checks import Error
from django.forms.models import BaseModelFormSet
from django.test import SimpleTestCase
-from .models import Band, ValidationTestInlineModel, ValidationTestModel
+from .models import Band, Song, ValidationTestInlineModel, ValidationTestModel
class CheckTestCase(SimpleTestCase):
- def assertIsInvalid(self, model_admin, model, msg, id=None, hint=None, invalid_obj=None):
+ def assertIsInvalid(self, model_admin, model, msg, id=None, hint=None, invalid_obj=None, admin_site=None):
+ if admin_site is None:
+ admin_site = AdminSite()
invalid_obj = invalid_obj or model_admin
- admin_obj = model_admin(model, AdminSite())
+ admin_obj = model_admin(model, admin_site)
self.assertEqual(admin_obj.check(), [Error(msg, hint=hint, obj=invalid_obj, id=id)])
def assertIsInvalidRegexp(self, model_admin, model, msg, id=None, hint=None, invalid_obj=None):
@@ -30,8 +32,10 @@ class CheckTestCase(SimpleTestCase):
self.assertEqual(error.id, id)
self.assertRegex(error.msg, msg)
- def assertIsValid(self, model_admin, model):
- admin_obj = model_admin(model, AdminSite())
+ def assertIsValid(self, model_admin, model, admin_site=None):
+ if admin_site is None:
+ admin_site = AdminSite()
+ admin_obj = model_admin(model, admin_site)
self.assertEqual(admin_obj.check(), [])
@@ -1153,3 +1157,89 @@ class ListDisplayEditableTests(CheckTestCase):
"'list_display_links'.",
id='admin.E123',
)
+
+
+class AutocompleteFieldsTests(CheckTestCase):
+ def test_autocomplete_e036(self):
+ class Admin(ModelAdmin):
+ autocomplete_fields = 'name'
+
+ self.assertIsInvalid(
+ Admin, Band,
+ msg="The value of 'autocomplete_fields' must be a list or tuple.",
+ id='admin.E036',
+ invalid_obj=Admin,
+ )
+
+ def test_autocomplete_e037(self):
+ class Admin(ModelAdmin):
+ autocomplete_fields = ('nonexistent',)
+
+ self.assertIsInvalid(
+ Admin, ValidationTestModel,
+ msg=(
+ "The value of 'autocomplete_fields[0]' refers to 'nonexistent', "
+ "which is not an attribute of 'modeladmin.ValidationTestModel'."
+ ),
+ id='admin.E037',
+ invalid_obj=Admin,
+ )
+
+ def test_autocomplete_e38(self):
+ class Admin(ModelAdmin):
+ autocomplete_fields = ('name',)
+
+ self.assertIsInvalid(
+ Admin, ValidationTestModel,
+ msg=(
+ "The value of 'autocomplete_fields[0]' must be a foreign "
+ "key or a many-to-many field."
+ ),
+ id='admin.E038',
+ invalid_obj=Admin,
+ )
+
+ def test_autocomplete_e039(self):
+ class Admin(ModelAdmin):
+ autocomplete_fields = ('band',)
+
+ self.assertIsInvalid(
+ Admin, Song,
+ msg=(
+ 'An admin for model "Band" has to be registered '
+ 'to be referenced by Admin.autocomplete_fields.'
+ ),
+ id='admin.E039',
+ invalid_obj=Admin,
+ )
+
+ def test_autocomplete_e040(self):
+ class NoSearchFieldsAdmin(ModelAdmin):
+ pass
+
+ class AutocompleteAdmin(ModelAdmin):
+ autocomplete_fields = ('featuring',)
+
+ site = AdminSite()
+ site.register(Band, NoSearchFieldsAdmin)
+ self.assertIsInvalid(
+ AutocompleteAdmin, Song,
+ msg=(
+ 'NoSearchFieldsAdmin must define "search_fields", because '
+ 'it\'s referenced by AutocompleteAdmin.autocomplete_fields.'
+ ),
+ id='admin.E040',
+ invalid_obj=AutocompleteAdmin,
+ admin_site=site,
+ )
+
+ def test_autocomplete_is_valid(self):
+ class SearchFieldsAdmin(ModelAdmin):
+ search_fields = 'name'
+
+ class AutocompleteAdmin(ModelAdmin):
+ autocomplete_fields = ('featuring',)
+
+ site = AdminSite()
+ site.register(Band, SearchFieldsAdmin)
+ self.assertIsValid(AutocompleteAdmin, Song, admin_site=site)
diff --git a/tests/modeladmin/tests.py b/tests/modeladmin/tests.py
index 25b9dfed69..67bed3d697 100644
--- a/tests/modeladmin/tests.py
+++ b/tests/modeladmin/tests.py
@@ -7,14 +7,17 @@ from django.contrib.admin.options import (
get_content_type_for_model,
)
from django.contrib.admin.sites import AdminSite
-from django.contrib.admin.widgets import AdminDateWidget, AdminRadioSelect
+from django.contrib.admin.widgets import (
+ AdminDateWidget, AdminRadioSelect, AutocompleteSelect,
+ AutocompleteSelectMultiple,
+)
from django.contrib.auth.models import User
from django.db import models
from django.forms.widgets import Select
from django.test import SimpleTestCase, TestCase
from django.test.utils import isolate_apps
-from .models import Band, Concert
+from .models import Band, Concert, Song
class MockRequest:
@@ -638,6 +641,31 @@ class ModelAdminTests(TestCase):
self.assertEqual(fetched.change_message, str(message))
self.assertEqual(fetched.object_repr, str(self.band))
+ def test_get_autocomplete_fields(self):
+ class NameAdmin(ModelAdmin):
+ search_fields = ['name']
+
+ class SongAdmin(ModelAdmin):
+ autocomplete_fields = ['featuring']
+ fields = ['featuring', 'band']
+
+ class OtherSongAdmin(SongAdmin):
+ def get_autocomplete_fields(self, request):
+ return ['band']
+
+ self.site.register(Band, NameAdmin)
+ try:
+ # Uses autocomplete_fields if not overridden.
+ model_admin = SongAdmin(Song, self.site)
+ form = model_admin.get_form(request)()
+ self.assertIsInstance(form.fields['featuring'].widget.widget, AutocompleteSelectMultiple)
+ # Uses overridden get_autocomplete_fields
+ model_admin = OtherSongAdmin(Song, self.site)
+ form = model_admin.get_form(request)()
+ self.assertIsInstance(form.fields['band'].widget.widget, AutocompleteSelect)
+ finally:
+ self.site.unregister(Band)
+
class ModelAdminPermissionTests(SimpleTestCase):