diff options
| author | Johannes Hoppe <info@johanneshoppe.com> | 2017-05-10 14:48:57 +0200 |
|---|---|---|
| committer | Tim Graham <timograham@gmail.com> | 2017-09-18 13:48:02 -0400 |
| commit | 94cd8efc50c717cd00244f4b2233f971a53b205e (patch) | |
| tree | ab1f13a12121907a561962181eaceeab793cb838 /tests/admin_views | |
| parent | 01a294f8f014a32e288958701540ea47dcb9fc14 (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/admin_views')
| -rw-r--r-- | tests/admin_views/admin.py | 41 | ||||
| -rw-r--r-- | tests/admin_views/customadmin.py | 2 | ||||
| -rw-r--r-- | tests/admin_views/models.py | 16 | ||||
| -rw-r--r-- | tests/admin_views/test_autocomplete_view.py | 231 | ||||
| -rw-r--r-- | tests/admin_views/tests.py | 30 |
5 files changed, 312 insertions, 8 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 |
