from django.contrib.admin import ModelAdmin, TabularInline from django.contrib.admin.helpers import InlineAdminForm from django.contrib.admin.tests import AdminSeleniumTestCase from django.contrib.auth.models import Permission, User from django.contrib.contenttypes.models import ContentType from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse from .admin import InnerInline, site as admin_site from .models import ( Author, BinaryTree, Book, BothVerboseNameProfile, Chapter, Child, ChildModel1, ChildModel2, Fashionista, FootNote, Holder, Holder2, Holder3, Holder4, Inner, Inner2, Inner3, Inner4Stacked, Inner4Tabular, Novel, OutfitItem, Parent, ParentModelWithCustomPk, Person, Poll, Profile, ProfileCollection, Question, ShowInlineParent, Sighting, SomeChildModel, SomeParentModel, Teacher, VerboseNamePluralProfile, VerboseNameProfile, ) INLINE_CHANGELINK_HTML = 'class="inlinechangelink">Change</a>' class TestDataMixin: @classmethod def setUpTestData(cls): cls.superuser = User.objects.create_superuser(username='super', email='super@example.com', password='secret') @override_settings(ROOT_URLCONF='admin_inlines.urls') class TestInline(TestDataMixin, TestCase): factory = RequestFactory() @classmethod def setUpTestData(cls): super().setUpTestData() cls.holder = Holder.objects.create(dummy=13) Inner.objects.create(dummy=42, holder=cls.holder) cls.parent = SomeParentModel.objects.create(name='a') SomeChildModel.objects.create(name='b', position='0', parent=cls.parent) SomeChildModel.objects.create(name='c', position='1', parent=cls.parent) cls.view_only_user = User.objects.create_user( username='user', password='pwd', is_staff=True, ) parent_ct = ContentType.objects.get_for_model(SomeParentModel) child_ct = ContentType.objects.get_for_model(SomeChildModel) permission = Permission.objects.get( codename='view_someparentmodel', content_type=parent_ct, ) cls.view_only_user.user_permissions.add(permission) permission = Permission.objects.get( codename='view_somechildmodel', content_type=child_ct, ) cls.view_only_user.user_permissions.add(permission) def setUp(self): self.client.force_login(self.superuser) def test_can_delete(self): """ can_delete should be passed to inlineformset factory. """ response = self.client.get( reverse('admin:admin_inlines_holder_change', args=(self.holder.id,)) ) inner_formset = response.context['inline_admin_formsets'][0].formset expected = InnerInline.can_delete actual = inner_formset.can_delete self.assertEqual(expected, actual, 'can_delete must be equal') def test_readonly_stacked_inline_label(self): """Bug #13174.""" holder = Holder.objects.create(dummy=42) Inner.objects.create(holder=holder, dummy=42, readonly='') response = self.client.get( reverse('admin:admin_inlines_holder_change', args=(holder.id,)) ) self.assertContains(response, '<label>Inner readonly label:</label>') def test_many_to_many_inlines(self): "Autogenerated many-to-many inlines are displayed correctly (#13407)" response = self.client.get(reverse('admin:admin_inlines_author_add')) # The heading for the m2m inline block uses the right text self.assertContains(response, '<h2>Author-book relationships</h2>') # The "add another" label is correct self.assertContains(response, 'Add another Author-book relationship') # The '+' is dropped from the autogenerated form prefix (Author_books+) self.assertContains(response, 'id="id_Author_books-TOTAL_FORMS"') def test_inline_primary(self): person = Person.objects.create(firstname='Imelda') item = OutfitItem.objects.create(name='Shoes') # Imelda likes shoes, but can't carry her own bags. data = { 'shoppingweakness_set-TOTAL_FORMS': 1, 'shoppingweakness_set-INITIAL_FORMS': 0, 'shoppingweakness_set-MAX_NUM_FORMS': 0, '_save': 'Save', 'person': person.id, 'max_weight': 0, 'shoppingweakness_set-0-item': item.id, } response = self.client.post(reverse('admin:admin_inlines_fashionista_add'), data) self.assertEqual(response.status_code, 302) self.assertEqual(len(Fashionista.objects.filter(person__firstname='Imelda')), 1) def test_tabular_inline_column_css_class(self): """ Field names are included in the context to output a field-specific CSS class name in the column headers. """ response = self.client.get(reverse('admin:admin_inlines_poll_add')) text_field, call_me_field = list(response.context['inline_admin_formset'].fields()) # Editable field. self.assertEqual(text_field['name'], 'text') self.assertContains(response, '<th class="column-text required">') # Read-only field. self.assertEqual(call_me_field['name'], 'call_me') self.assertContains(response, '<th class="column-call_me">') def test_custom_form_tabular_inline_label(self): """ A model form with a form field specified (TitleForm.title1) should have its label rendered in the tabular inline. """ response = self.client.get(reverse('admin:admin_inlines_titlecollection_add')) self.assertContains(response, '<th class="column-title1 required">Title1</th>', html=True) def test_custom_form_tabular_inline_extra_field_label(self): response = self.client.get(reverse('admin:admin_inlines_outfititem_add')) _, extra_field = list(response.context['inline_admin_formset'].fields()) self.assertEqual(extra_field['label'], 'Extra field') def test_non_editable_custom_form_tabular_inline_extra_field_label(self): response = self.client.get(reverse('admin:admin_inlines_chapter_add')) _, extra_field = list(response.context['inline_admin_formset'].fields()) self.assertEqual(extra_field['label'], 'Extra field') def test_custom_form_tabular_inline_overridden_label(self): """ SomeChildModelForm.__init__() overrides the label of a form field. That label is displayed in the TabularInline. """ response = self.client.get(reverse('admin:admin_inlines_someparentmodel_add')) field = list(response.context['inline_admin_formset'].fields())[0] self.assertEqual(field['label'], 'new label') self.assertContains(response, '<th class="column-name required">New label</th>', html=True) def test_tabular_non_field_errors(self): """ non_field_errors are displayed correctly, including the correct value for colspan. """ data = { 'title_set-TOTAL_FORMS': 1, 'title_set-INITIAL_FORMS': 0, 'title_set-MAX_NUM_FORMS': 0, '_save': 'Save', 'title_set-0-title1': 'a title', 'title_set-0-title2': 'a different title', } response = self.client.post(reverse('admin:admin_inlines_titlecollection_add'), data) # Here colspan is "4": two fields (title1 and title2), one hidden field and the delete checkbox. self.assertContains( response, '<tr class="row-form-errors"><td colspan="4"><ul class="errorlist nonfield">' '<li>The two titles must be the same</li></ul></td></tr>' ) def test_no_parent_callable_lookup(self): """Admin inline `readonly_field` shouldn't invoke parent ModelAdmin callable""" # Identically named callable isn't present in the parent ModelAdmin, # rendering of the add view shouldn't explode response = self.client.get(reverse('admin:admin_inlines_novel_add')) # View should have the child inlines section self.assertContains( response, '<div class="js-inline-admin-formset inline-group" id="chapter_set-group"' ) def test_callable_lookup(self): """Admin inline should invoke local callable when its name is listed in readonly_fields""" response = self.client.get(reverse('admin:admin_inlines_poll_add')) # Add parent object view should have the child inlines section self.assertContains( response, '<div class="js-inline-admin-formset inline-group" id="question_set-group"' ) # The right callable should be used for the inline readonly_fields # column cells self.assertContains(response, '<p>Callable in QuestionInline</p>') def test_help_text(self): """ The inlines' model field help texts are displayed when using both the stacked and tabular layouts. """ response = self.client.get(reverse('admin:admin_inlines_holder4_add')) self.assertContains(response, '<div class="help">Awesome stacked help text is awesome.</div>', 4) self.assertContains( response, '<img src="/static/admin/img/icon-unknown.svg" ' 'class="help help-tooltip" width="10" height="10" ' 'alt="(Awesome tabular help text is awesome.)" ' 'title="Awesome tabular help text is awesome.">', 1 ) # ReadOnly fields response = self.client.get(reverse('admin:admin_inlines_capofamiglia_add')) self.assertContains( response, '<img src="/static/admin/img/icon-unknown.svg" ' 'class="help help-tooltip" width="10" height="10" ' 'alt="(Help text for ReadOnlyInline)" ' 'title="Help text for ReadOnlyInline">', 1 ) def test_tabular_model_form_meta_readonly_field(self): """ Tabular inlines use ModelForm.Meta.help_texts and labels for read-only fields. """ response = self.client.get(reverse('admin:admin_inlines_someparentmodel_add')) self.assertContains( response, '<img src="/static/admin/img/icon-unknown.svg" ' 'class="help help-tooltip" width="10" height="10" ' 'alt="(Help text from ModelForm.Meta)" ' 'title="Help text from ModelForm.Meta">' ) self.assertContains(response, 'Label from ModelForm.Meta') def test_inline_hidden_field_no_column(self): """#18263 -- Make sure hidden fields don't get a column in tabular inlines""" parent = SomeParentModel.objects.create(name='a') SomeChildModel.objects.create(name='b', position='0', parent=parent) SomeChildModel.objects.create(name='c', position='1', parent=parent) response = self.client.get(reverse('admin:admin_inlines_someparentmodel_change', args=(parent.pk,))) self.assertNotContains(response, '<td class="field-position">') self.assertInHTML( '<input id="id_somechildmodel_set-1-position" ' 'name="somechildmodel_set-1-position" type="hidden" value="1">', response.rendered_content, ) def test_tabular_inline_hidden_field_with_view_only_permissions(self): """ Content of hidden field is not visible in tabular inline when user has view-only permission. """ self.client.force_login(self.view_only_user) url = reverse( 'tabular_inline_hidden_field_admin:admin_inlines_someparentmodel_change', args=(self.parent.pk,), ) response = self.client.get(url) self.assertInHTML('<th class="column-position hidden">Position</th>', response.rendered_content) self.assertInHTML('<td class="field-position hidden"><p>0</p></td>', response.rendered_content) self.assertInHTML('<td class="field-position hidden"><p>1</p></td>', response.rendered_content) def test_stacked_inline_hidden_field_with_view_only_permissions(self): """ Content of hidden field is not visible in stacked inline when user has view-only permission. """ self.client.force_login(self.view_only_user) url = reverse( 'stacked_inline_hidden_field_in_group_admin:admin_inlines_someparentmodel_change', args=(self.parent.pk,), ) response = self.client.get(url) # The whole line containing name + position fields is not hidden. self.assertContains(response, '<div class="form-row field-name field-position">') # The div containing the position field is hidden. self.assertInHTML( '<div class="fieldBox field-position hidden">' '<label class="inline">Position:</label>' '<div class="readonly">0</div></div>', response.rendered_content, ) self.assertInHTML( '<div class="fieldBox field-position hidden">' '<label class="inline">Position:</label>' '<div class="readonly">1</div></div>', response.rendered_content, ) def test_stacked_inline_single_hidden_field_in_line_with_view_only_permissions(self): """ Content of hidden field is not visible in stacked inline when user has view-only permission and the field is grouped on a separate line. """ self.client.force_login(self.view_only_user) url = reverse( 'stacked_inline_hidden_field_on_single_line_admin:admin_inlines_someparentmodel_change', args=(self.parent.pk,), ) response = self.client.get(url) # The whole line containing position field is hidden. self.assertInHTML( '<div class="form-row hidden field-position">' '<div><label>Position:</label>' '<div class="readonly">0</div></div></div>', response.rendered_content, ) self.assertInHTML( '<div class="form-row hidden field-position">' '<div><label>Position:</label>' '<div class="readonly">1</div></div></div>', response.rendered_content, ) def test_tabular_inline_with_hidden_field_non_field_errors_has_correct_colspan(self): """ In tabular inlines, when a form has non-field errors, those errors are rendered in a table line with a single cell spanning the whole table width. Colspan must be equal to the number of visible columns. """ parent = SomeParentModel.objects.create(name='a') child = SomeChildModel.objects.create(name='b', position='0', parent=parent) url = reverse( 'tabular_inline_hidden_field_admin:admin_inlines_someparentmodel_change', args=(parent.id,), ) data = { 'name': parent.name, 'somechildmodel_set-TOTAL_FORMS': 1, 'somechildmodel_set-INITIAL_FORMS': 1, 'somechildmodel_set-MIN_NUM_FORMS': 0, 'somechildmodel_set-MAX_NUM_FORMS': 1000, '_save': 'Save', 'somechildmodel_set-0-id': child.id, 'somechildmodel_set-0-parent': parent.id, 'somechildmodel_set-0-name': child.name, 'somechildmodel_set-0-position': 1, } response = self.client.post(url, data) # Form has 3 visible columns and 1 hidden column. self.assertInHTML( '<thead><tr><th class="original"></th>' '<th class="column-name required">Name</th>' '<th class="column-position required hidden">Position</th>' '<th>Delete?</th></tr></thead>', response.rendered_content, ) # The non-field error must be spanned on 3 (visible) columns. self.assertInHTML( '<tr class="row-form-errors"><td colspan="3">' '<ul class="errorlist nonfield"><li>A non-field error</li></ul></td></tr>', response.rendered_content, ) def test_non_related_name_inline(self): """ Multiple inlines with related_name='+' have correct form prefixes. """ response = self.client.get(reverse('admin:admin_inlines_capofamiglia_add')) self.assertContains(response, '<input type="hidden" name="-1-0-id" id="id_-1-0-id">', html=True) self.assertContains( response, '<input type="hidden" name="-1-0-capo_famiglia" id="id_-1-0-capo_famiglia">', html=True ) self.assertContains( response, '<input id="id_-1-0-name" type="text" class="vTextField" name="-1-0-name" maxlength="100">', html=True ) self.assertContains(response, '<input type="hidden" name="-2-0-id" id="id_-2-0-id">', html=True) self.assertContains( response, '<input type="hidden" name="-2-0-capo_famiglia" id="id_-2-0-capo_famiglia">', html=True ) self.assertContains( response, '<input id="id_-2-0-name" type="text" class="vTextField" name="-2-0-name" maxlength="100">', html=True ) @override_settings(USE_THOUSAND_SEPARATOR=True) def test_localize_pk_shortcut(self): """ The "View on Site" link is correct for locales that use thousand separators. """ holder = Holder.objects.create(pk=123456789, dummy=42) inner = Inner.objects.create(pk=987654321, holder=holder, dummy=42, readonly='') response = self.client.get(reverse('admin:admin_inlines_holder_change', args=(holder.id,))) inner_shortcut = 'r/%s/%s/' % (ContentType.objects.get_for_model(inner).pk, inner.pk) self.assertContains(response, inner_shortcut) def test_custom_pk_shortcut(self): """ The "View on Site" link is correct for models with a custom primary key field. """ parent = ParentModelWithCustomPk.objects.create(my_own_pk="foo", name="Foo") child1 = ChildModel1.objects.create(my_own_pk="bar", name="Bar", parent=parent) child2 = ChildModel2.objects.create(my_own_pk="baz", name="Baz", parent=parent) response = self.client.get(reverse('admin:admin_inlines_parentmodelwithcustompk_change', args=('foo',))) child1_shortcut = 'r/%s/%s/' % (ContentType.objects.get_for_model(child1).pk, child1.pk) child2_shortcut = 'r/%s/%s/' % (ContentType.objects.get_for_model(child2).pk, child2.pk) self.assertContains(response, child1_shortcut) self.assertContains(response, child2_shortcut) def test_create_inlines_on_inherited_model(self): """ An object can be created with inlines when it inherits another class. """ data = { 'name': 'Martian', 'sighting_set-TOTAL_FORMS': 1, 'sighting_set-INITIAL_FORMS': 0, 'sighting_set-MAX_NUM_FORMS': 0, 'sighting_set-0-place': 'Zone 51', '_save': 'Save', } response = self.client.post(reverse('admin:admin_inlines_extraterrestrial_add'), data) self.assertEqual(response.status_code, 302) self.assertEqual(Sighting.objects.filter(et__name='Martian').count(), 1) def test_custom_get_extra_form(self): bt_head = BinaryTree.objects.create(name="Tree Head") BinaryTree.objects.create(name="First Child", parent=bt_head) # The maximum number of forms should respect 'get_max_num' on the # ModelAdmin max_forms_input = ( '<input id="id_binarytree_set-MAX_NUM_FORMS" ' 'name="binarytree_set-MAX_NUM_FORMS" type="hidden" value="%d">' ) # The total number of forms will remain the same in either case total_forms_hidden = ( '<input id="id_binarytree_set-TOTAL_FORMS" ' 'name="binarytree_set-TOTAL_FORMS" type="hidden" value="2">' ) response = self.client.get(reverse('admin:admin_inlines_binarytree_add')) self.assertInHTML(max_forms_input % 3, response.rendered_content) self.assertInHTML(total_forms_hidden, response.rendered_content) response = self.client.get(reverse('admin:admin_inlines_binarytree_change', args=(bt_head.id,))) self.assertInHTML(max_forms_input % 2, response.rendered_content) self.assertInHTML(total_forms_hidden, response.rendered_content) def test_min_num(self): """ min_num and extra determine number of forms. """ class MinNumInline(TabularInline): model = BinaryTree min_num = 2 extra = 3 modeladmin = ModelAdmin(BinaryTree, admin_site) modeladmin.inlines = [MinNumInline] min_forms = ( '<input id="id_binarytree_set-MIN_NUM_FORMS" ' 'name="binarytree_set-MIN_NUM_FORMS" type="hidden" value="2">' ) total_forms = ( '<input id="id_binarytree_set-TOTAL_FORMS" ' 'name="binarytree_set-TOTAL_FORMS" type="hidden" value="5">' ) request = self.factory.get(reverse('admin:admin_inlines_binarytree_add')) request.user = User(username='super', is_superuser=True) response = modeladmin.changeform_view(request) self.assertInHTML(min_forms, response.rendered_content) self.assertInHTML(total_forms, response.rendered_content) def test_custom_min_num(self): bt_head = BinaryTree.objects.create(name="Tree Head") BinaryTree.objects.create(name="First Child", parent=bt_head) class MinNumInline(TabularInline): model = BinaryTree extra = 3 def get_min_num(self, request, obj=None, **kwargs): if obj: return 5 return 2 modeladmin = ModelAdmin(BinaryTree, admin_site) modeladmin.inlines = [MinNumInline] min_forms = ( '<input id="id_binarytree_set-MIN_NUM_FORMS" ' 'name="binarytree_set-MIN_NUM_FORMS" type="hidden" value="%d">' ) total_forms = ( '<input id="id_binarytree_set-TOTAL_FORMS" ' 'name="binarytree_set-TOTAL_FORMS" type="hidden" value="%d">' ) request = self.factory.get(reverse('admin:admin_inlines_binarytree_add')) request.user = User(username='super', is_superuser=True) response = modeladmin.changeform_view(request) self.assertInHTML(min_forms % 2, response.rendered_content) self.assertInHTML(total_forms % 5, response.rendered_content) request = self.factory.get(reverse('admin:admin_inlines_binarytree_change', args=(bt_head.id,))) request.user = User(username='super', is_superuser=True) response = modeladmin.changeform_view(request, object_id=str(bt_head.id)) self.assertInHTML(min_forms % 5, response.rendered_content) self.assertInHTML(total_forms % 8, response.rendered_content) def test_inline_nonauto_noneditable_pk(self): response = self.client.get(reverse('admin:admin_inlines_author_add')) self.assertContains( response, '<input id="id_nonautopkbook_set-0-rand_pk" ' 'name="nonautopkbook_set-0-rand_pk" type="hidden">', html=True ) self.assertContains( response, '<input id="id_nonautopkbook_set-2-0-rand_pk" ' 'name="nonautopkbook_set-2-0-rand_pk" type="hidden">', html=True ) def test_inline_nonauto_noneditable_inherited_pk(self): response = self.client.get(reverse('admin:admin_inlines_author_add')) self.assertContains( response, '<input id="id_nonautopkbookchild_set-0-nonautopkbook_ptr" ' 'name="nonautopkbookchild_set-0-nonautopkbook_ptr" type="hidden">', html=True ) self.assertContains( response, '<input id="id_nonautopkbookchild_set-2-nonautopkbook_ptr" ' 'name="nonautopkbookchild_set-2-nonautopkbook_ptr" type="hidden">', html=True ) def test_inline_editable_pk(self): response = self.client.get(reverse('admin:admin_inlines_author_add')) self.assertContains( response, '<input class="vIntegerField" id="id_editablepkbook_set-0-manual_pk" ' 'name="editablepkbook_set-0-manual_pk" type="number">', html=True, count=1 ) self.assertContains( response, '<input class="vIntegerField" id="id_editablepkbook_set-2-0-manual_pk" ' 'name="editablepkbook_set-2-0-manual_pk" type="number">', html=True, count=1 ) def test_stacked_inline_edit_form_contains_has_original_class(self): holder = Holder.objects.create(dummy=1) holder.inner_set.create(dummy=1) response = self.client.get(reverse('admin:admin_inlines_holder_change', args=(holder.pk,))) self.assertContains( response, '<div class="inline-related has_original" id="inner_set-0">', count=1 ) self.assertContains( response, '<div class="inline-related" id="inner_set-1">', count=1 ) def test_inlines_show_change_link_registered(self): "Inlines `show_change_link` for registered models when enabled." holder = Holder4.objects.create(dummy=1) item1 = Inner4Stacked.objects.create(dummy=1, holder=holder) item2 = Inner4Tabular.objects.create(dummy=1, holder=holder) items = ( ('inner4stacked', item1.pk), ('inner4tabular', item2.pk), ) response = self.client.get(reverse('admin:admin_inlines_holder4_change', args=(holder.pk,))) self.assertTrue(response.context['inline_admin_formset'].opts.has_registered_model) for model, pk in items: url = reverse('admin:admin_inlines_%s_change' % model, args=(pk,)) self.assertContains(response, '<a href="%s" %s' % (url, INLINE_CHANGELINK_HTML)) def test_inlines_show_change_link_unregistered(self): "Inlines `show_change_link` disabled for unregistered models." parent = ParentModelWithCustomPk.objects.create(my_own_pk="foo", name="Foo") ChildModel1.objects.create(my_own_pk="bar", name="Bar", parent=parent) ChildModel2.objects.create(my_own_pk="baz", name="Baz", parent=parent) response = self.client.get(reverse('admin:admin_inlines_parentmodelwithcustompk_change', args=('foo',))) self.assertFalse(response.context['inline_admin_formset'].opts.has_registered_model) self.assertNotContains(response, INLINE_CHANGELINK_HTML) def test_tabular_inline_show_change_link_false_registered(self): "Inlines `show_change_link` disabled by default." poll = Poll.objects.create(name="New poll") Question.objects.create(poll=poll) response = self.client.get(reverse('admin:admin_inlines_poll_change', args=(poll.pk,))) self.assertTrue(response.context['inline_admin_formset'].opts.has_registered_model) self.assertNotContains(response, INLINE_CHANGELINK_HTML) def test_noneditable_inline_has_field_inputs(self): """Inlines without change permission shows field inputs on add form.""" response = self.client.get(reverse('admin:admin_inlines_novelreadonlychapter_add')) self.assertContains( response, '<input type="text" name="chapter_set-0-name" ' 'class="vTextField" maxlength="40" id="id_chapter_set-0-name">', html=True ) def test_inlines_plural_heading_foreign_key(self): response = self.client.get(reverse('admin:admin_inlines_holder4_add')) self.assertContains(response, '<h2>Inner4 stackeds</h2>', html=True) self.assertContains(response, '<h2>Inner4 tabulars</h2>', html=True) def test_inlines_singular_heading_one_to_one(self): response = self.client.get(reverse('admin:admin_inlines_person_add')) self.assertContains(response, '<h2>Author</h2>', html=True) # Tabular. self.assertContains(response, '<h2>Fashionista</h2>', html=True) # Stacked. def test_inlines_based_on_model_state(self): parent = ShowInlineParent.objects.create(show_inlines=False) data = { 'show_inlines': 'on', '_save': 'Save', } change_url = reverse( 'admin:admin_inlines_showinlineparent_change', args=(parent.id,), ) response = self.client.post(change_url, data) self.assertEqual(response.status_code, 302) parent.refresh_from_db() self.assertIs(parent.show_inlines, True) @override_settings(ROOT_URLCONF='admin_inlines.urls') class TestInlineMedia(TestDataMixin, TestCase): def setUp(self): self.client.force_login(self.superuser) def test_inline_media_only_base(self): holder = Holder(dummy=13) holder.save() Inner(dummy=42, holder=holder).save() change_url = reverse('admin:admin_inlines_holder_change', args=(holder.id,)) response = self.client.get(change_url) self.assertContains(response, 'my_awesome_admin_scripts.js') def test_inline_media_only_inline(self): holder = Holder3(dummy=13) holder.save() Inner3(dummy=42, holder=holder).save() change_url = reverse('admin:admin_inlines_holder3_change', args=(holder.id,)) response = self.client.get(change_url) self.assertEqual( response.context['inline_admin_formsets'][0].media._js, [ 'admin/js/vendor/jquery/jquery.min.js', 'my_awesome_inline_scripts.js', 'custom_number.js', 'admin/js/jquery.init.js', 'admin/js/inlines.js', ] ) self.assertContains(response, 'my_awesome_inline_scripts.js') def test_all_inline_media(self): holder = Holder2(dummy=13) holder.save() Inner2(dummy=42, holder=holder).save() change_url = reverse('admin:admin_inlines_holder2_change', args=(holder.id,)) response = self.client.get(change_url) self.assertContains(response, 'my_awesome_admin_scripts.js') self.assertContains(response, 'my_awesome_inline_scripts.js') @override_settings(ROOT_URLCONF='admin_inlines.urls') class TestInlineAdminForm(TestCase): def test_immutable_content_type(self): """Regression for #9362 The problem depends only on InlineAdminForm and its "original" argument, so we can safely set the other arguments to None/{}. We just need to check that the content_type argument of Child isn't altered by the internals of the inline form.""" sally = Teacher.objects.create(name='Sally') john = Parent.objects.create(name='John') joe = Child.objects.create(name='Joe', teacher=sally, parent=john) iaf = InlineAdminForm(None, None, {}, {}, joe) parent_ct = ContentType.objects.get_for_model(Parent) self.assertEqual(iaf.original.content_type, parent_ct) @override_settings(ROOT_URLCONF='admin_inlines.urls') class TestInlineProtectedOnDelete(TestDataMixin, TestCase): def setUp(self): self.client.force_login(self.superuser) def test_deleting_inline_with_protected_delete_does_not_validate(self): lotr = Novel.objects.create(name='Lord of the rings') chapter = Chapter.objects.create(novel=lotr, name='Many Meetings') foot_note = FootNote.objects.create(chapter=chapter, note='yadda yadda') change_url = reverse('admin:admin_inlines_novel_change', args=(lotr.id,)) response = self.client.get(change_url) data = { 'name': lotr.name, 'chapter_set-TOTAL_FORMS': 1, 'chapter_set-INITIAL_FORMS': 1, 'chapter_set-MAX_NUM_FORMS': 1000, '_save': 'Save', 'chapter_set-0-id': chapter.id, 'chapter_set-0-name': chapter.name, 'chapter_set-0-novel': lotr.id, 'chapter_set-0-DELETE': 'on' } response = self.client.post(change_url, data) self.assertContains(response, "Deleting chapter %s would require deleting " "the following protected related objects: foot note %s" % (chapter, foot_note)) @override_settings(ROOT_URLCONF='admin_inlines.urls') class TestInlinePermissions(TestCase): """ Make sure the admin respects permissions for objects that are edited inline. Refs #8060. """ @classmethod def setUpTestData(cls): cls.user = User(username='admin', is_staff=True, is_active=True) cls.user.set_password('secret') cls.user.save() cls.author_ct = ContentType.objects.get_for_model(Author) cls.holder_ct = ContentType.objects.get_for_model(Holder2) cls.book_ct = ContentType.objects.get_for_model(Book) cls.inner_ct = ContentType.objects.get_for_model(Inner2) # User always has permissions to add and change Authors, and Holders, # the main (parent) models of the inlines. Permissions on the inlines # vary per test. permission = Permission.objects.get(codename='add_author', content_type=cls.author_ct) cls.user.user_permissions.add(permission) permission = Permission.objects.get(codename='change_author', content_type=cls.author_ct) cls.user.user_permissions.add(permission) permission = Permission.objects.get(codename='add_holder2', content_type=cls.holder_ct) cls.user.user_permissions.add(permission) permission = Permission.objects.get(codename='change_holder2', content_type=cls.holder_ct) cls.user.user_permissions.add(permission) author = Author.objects.create(pk=1, name='The Author') cls.book = author.books.create(name='The inline Book') cls.author_change_url = reverse('admin:admin_inlines_author_change', args=(author.id,)) # Get the ID of the automatically created intermediate model for the Author-Book m2m author_book_auto_m2m_intermediate = Author.books.through.objects.get(author=author, book=cls.book) cls.author_book_auto_m2m_intermediate_id = author_book_auto_m2m_intermediate.pk cls.holder = Holder2.objects.create(dummy=13) cls.inner2 = Inner2.objects.create(dummy=42, holder=cls.holder) def setUp(self): self.holder_change_url = reverse('admin:admin_inlines_holder2_change', args=(self.holder.id,)) self.client.force_login(self.user) def test_inline_add_m2m_noperm(self): response = self.client.get(reverse('admin:admin_inlines_author_add')) # No change permission on books, so no inline self.assertNotContains(response, '<h2>Author-book relationships</h2>') self.assertNotContains(response, 'Add another Author-Book Relationship') self.assertNotContains(response, 'id="id_Author_books-TOTAL_FORMS"') def test_inline_add_fk_noperm(self): response = self.client.get(reverse('admin:admin_inlines_holder2_add')) # No permissions on Inner2s, so no inline self.assertNotContains(response, '<h2>Inner2s</h2>') self.assertNotContains(response, 'Add another Inner2') self.assertNotContains(response, 'id="id_inner2_set-TOTAL_FORMS"') def test_inline_change_m2m_noperm(self): response = self.client.get(self.author_change_url) # No change permission on books, so no inline self.assertNotContains(response, '<h2>Author-book relationships</h2>') self.assertNotContains(response, 'Add another Author-Book Relationship') self.assertNotContains(response, 'id="id_Author_books-TOTAL_FORMS"') def test_inline_change_fk_noperm(self): response = self.client.get(self.holder_change_url) # No permissions on Inner2s, so no inline self.assertNotContains(response, '<h2>Inner2s</h2>') self.assertNotContains(response, 'Add another Inner2') self.assertNotContains(response, 'id="id_inner2_set-TOTAL_FORMS"') def test_inline_add_m2m_view_only_perm(self): permission = Permission.objects.get(codename='view_book', content_type=self.book_ct) self.user.user_permissions.add(permission) response = self.client.get(reverse('admin:admin_inlines_author_add')) # View-only inlines. (It could be nicer to hide the empty, non-editable # inlines on the add page.) self.assertIs(response.context['inline_admin_formset'].has_view_permission, True) self.assertIs(response.context['inline_admin_formset'].has_add_permission, False) self.assertIs(response.context['inline_admin_formset'].has_change_permission, False) self.assertIs(response.context['inline_admin_formset'].has_delete_permission, False) self.assertContains(response, '<h2>Author-book relationships</h2>') self.assertContains( response, '<input type="hidden" name="Author_books-TOTAL_FORMS" value="0" ' 'id="id_Author_books-TOTAL_FORMS">', html=True, ) self.assertNotContains(response, 'Add another Author-Book Relationship') def test_inline_add_m2m_add_perm(self): permission = Permission.objects.get(codename='add_book', content_type=self.book_ct) self.user.user_permissions.add(permission) response = self.client.get(reverse('admin:admin_inlines_author_add')) # No change permission on Books, so no inline self.assertNotContains(response, '<h2>Author-book relationships</h2>') self.assertNotContains(response, 'Add another Author-Book Relationship') self.assertNotContains(response, 'id="id_Author_books-TOTAL_FORMS"') def test_inline_add_fk_add_perm(self): permission = Permission.objects.get(codename='add_inner2', content_type=self.inner_ct) self.user.user_permissions.add(permission) response = self.client.get(reverse('admin:admin_inlines_holder2_add')) # Add permission on inner2s, so we get the inline self.assertContains(response, '<h2>Inner2s</h2>') self.assertContains(response, 'Add another Inner2') self.assertContains(response, '<input type="hidden" id="id_inner2_set-TOTAL_FORMS" ' 'value="3" name="inner2_set-TOTAL_FORMS">', html=True) def test_inline_change_m2m_add_perm(self): permission = Permission.objects.get(codename='add_book', content_type=self.book_ct) self.user.user_permissions.add(permission) response = self.client.get(self.author_change_url) # No change permission on books, so no inline self.assertNotContains(response, '<h2>Author-book relationships</h2>') self.assertNotContains(response, 'Add another Author-Book Relationship') self.assertNotContains(response, 'id="id_Author_books-TOTAL_FORMS"') self.assertNotContains(response, 'id="id_Author_books-0-DELETE"') def test_inline_change_m2m_view_only_perm(self): permission = Permission.objects.get(codename='view_book', content_type=self.book_ct) self.user.user_permissions.add(permission) response = self.client.get(self.author_change_url) # View-only inlines. self.assertIs(response.context['inline_admin_formset'].has_view_permission, True) self.assertIs(response.context['inline_admin_formset'].has_add_permission, False) self.assertIs(response.context['inline_admin_formset'].has_change_permission, False) self.assertIs(response.context['inline_admin_formset'].has_delete_permission, False) self.assertContains(response, '<h2>Author-book relationships</h2>') self.assertContains( response, '<input type="hidden" name="Author_books-TOTAL_FORMS" value="1" ' 'id="id_Author_books-TOTAL_FORMS">', html=True, ) # The field in the inline is read-only. self.assertContains(response, '<p>%s</p>' % self.book) self.assertNotContains( response, '<input type="checkbox" name="Author_books-0-DELETE" id="id_Author_books-0-DELETE">', html=True, ) def test_inline_change_m2m_change_perm(self): permission = Permission.objects.get(codename='change_book', content_type=self.book_ct) self.user.user_permissions.add(permission) response = self.client.get(self.author_change_url) # We have change perm on books, so we can add/change/delete inlines self.assertIs(response.context['inline_admin_formset'].has_view_permission, True) self.assertIs(response.context['inline_admin_formset'].has_add_permission, True) self.assertIs(response.context['inline_admin_formset'].has_change_permission, True) self.assertIs(response.context['inline_admin_formset'].has_delete_permission, True) self.assertContains(response, '<h2>Author-book relationships</h2>') self.assertContains(response, 'Add another Author-book relationship') self.assertContains(response, '<input type="hidden" id="id_Author_books-TOTAL_FORMS" ' 'value="4" name="Author_books-TOTAL_FORMS">', html=True) self.assertContains( response, '<input type="hidden" id="id_Author_books-0-id" value="%i" ' 'name="Author_books-0-id">' % self.author_book_auto_m2m_intermediate_id, html=True ) self.assertContains(response, 'id="id_Author_books-0-DELETE"') def test_inline_change_fk_add_perm(self): permission = Permission.objects.get(codename='add_inner2', content_type=self.inner_ct) self.user.user_permissions.add(permission) response = self.client.get(self.holder_change_url) # Add permission on inner2s, so we can add but not modify existing self.assertContains(response, '<h2>Inner2s</h2>') self.assertContains(response, 'Add another Inner2') # 3 extra forms only, not the existing instance form self.assertContains( response, '<input type="hidden" id="id_inner2_set-TOTAL_FORMS" value="3" ' 'name="inner2_set-TOTAL_FORMS">', html=True ) self.assertNotContains( response, '<input type="hidden" id="id_inner2_set-0-id" value="%i" name="inner2_set-0-id">' % self.inner2.id, html=True ) def test_inline_change_fk_change_perm(self): permission = Permission.objects.get(codename='change_inner2', content_type=self.inner_ct) self.user.user_permissions.add(permission) response = self.client.get(self.holder_change_url) # Change permission on inner2s, so we can change existing but not add new self.assertContains(response, '<h2>Inner2s</h2>', count=2) # Just the one form for existing instances self.assertContains( response, '<input type="hidden" id="id_inner2_set-TOTAL_FORMS" value="1" name="inner2_set-TOTAL_FORMS">', html=True ) self.assertContains( response, '<input type="hidden" id="id_inner2_set-0-id" value="%i" name="inner2_set-0-id">' % self.inner2.id, html=True ) # max-num 0 means we can't add new ones self.assertContains( response, '<input type="hidden" id="id_inner2_set-MAX_NUM_FORMS" value="0" name="inner2_set-MAX_NUM_FORMS">', html=True ) # TabularInline self.assertContains(response, '<th class="column-dummy required">Dummy</th>', html=True) self.assertContains( response, '<input type="number" name="inner2_set-2-0-dummy" value="%s" ' 'class="vIntegerField" id="id_inner2_set-2-0-dummy">' % self.inner2.dummy, html=True, ) def test_inline_change_fk_add_change_perm(self): permission = Permission.objects.get(codename='add_inner2', content_type=self.inner_ct) self.user.user_permissions.add(permission) permission = Permission.objects.get(codename='change_inner2', content_type=self.inner_ct) self.user.user_permissions.add(permission) response = self.client.get(self.holder_change_url) # Add/change perm, so we can add new and change existing self.assertContains(response, '<h2>Inner2s</h2>') # One form for existing instance and three extra for new self.assertContains( response, '<input type="hidden" id="id_inner2_set-TOTAL_FORMS" value="4" name="inner2_set-TOTAL_FORMS">', html=True ) self.assertContains( response, '<input type="hidden" id="id_inner2_set-0-id" value="%i" name="inner2_set-0-id">' % self.inner2.id, html=True ) def test_inline_change_fk_change_del_perm(self): permission = Permission.objects.get(codename='change_inner2', content_type=self.inner_ct) self.user.user_permissions.add(permission) permission = Permission.objects.get(codename='delete_inner2', content_type=self.inner_ct) self.user.user_permissions.add(permission) response = self.client.get(self.holder_change_url) # Change/delete perm on inner2s, so we can change/delete existing self.assertContains(response, '<h2>Inner2s</h2>') # One form for existing instance only, no new self.assertContains( response, '<input type="hidden" id="id_inner2_set-TOTAL_FORMS" value="1" name="inner2_set-TOTAL_FORMS">', html=True ) self.assertContains( response, '<input type="hidden" id="id_inner2_set-0-id" value="%i" name="inner2_set-0-id">' % self.inner2.id, html=True ) self.assertContains(response, 'id="id_inner2_set-0-DELETE"') def test_inline_change_fk_all_perms(self): permission = Permission.objects.get(codename='add_inner2', content_type=self.inner_ct) self.user.user_permissions.add(permission) permission = Permission.objects.get(codename='change_inner2', content_type=self.inner_ct) self.user.user_permissions.add(permission) permission = Permission.objects.get(codename='delete_inner2', content_type=self.inner_ct) self.user.user_permissions.add(permission) response = self.client.get(self.holder_change_url) # All perms on inner2s, so we can add/change/delete self.assertContains(response, '<h2>Inner2s</h2>', count=2) # One form for existing instance only, three for new self.assertContains( response, '<input type="hidden" id="id_inner2_set-TOTAL_FORMS" value="4" name="inner2_set-TOTAL_FORMS">', html=True ) self.assertContains( response, '<input type="hidden" id="id_inner2_set-0-id" value="%i" name="inner2_set-0-id">' % self.inner2.id, html=True ) self.assertContains(response, 'id="id_inner2_set-0-DELETE"') # TabularInline self.assertContains(response, '<th class="column-dummy required">Dummy</th>', html=True) self.assertContains( response, '<input type="number" name="inner2_set-2-0-dummy" value="%s" ' 'class="vIntegerField" id="id_inner2_set-2-0-dummy">' % self.inner2.dummy, html=True, ) @override_settings(ROOT_URLCONF='admin_inlines.urls') class TestReadOnlyChangeViewInlinePermissions(TestCase): @classmethod def setUpTestData(cls): cls.user = User.objects.create_user('testing', password='password', is_staff=True) cls.user.user_permissions.add( Permission.objects.get(codename='view_poll', content_type=ContentType.objects.get_for_model(Poll)) ) cls.user.user_permissions.add( *Permission.objects.filter( codename__endswith="question", content_type=ContentType.objects.get_for_model(Question) ).values_list('pk', flat=True) ) cls.poll = Poll.objects.create(name="Survey") cls.add_url = reverse('admin:admin_inlines_poll_add') cls.change_url = reverse('admin:admin_inlines_poll_change', args=(cls.poll.id,)) def setUp(self): self.client.force_login(self.user) def test_add_url_not_allowed(self): response = self.client.get(self.add_url) self.assertEqual(response.status_code, 403) response = self.client.post(self.add_url, {}) self.assertEqual(response.status_code, 403) def test_post_to_change_url_not_allowed(self): response = self.client.post(self.change_url, {}) self.assertEqual(response.status_code, 403) def test_get_to_change_url_is_allowed(self): response = self.client.get(self.change_url) self.assertEqual(response.status_code, 200) def test_main_model_is_rendered_as_read_only(self): response = self.client.get(self.change_url) self.assertContains( response, '<div class="readonly">%s</div>' % self.poll.name, html=True ) input = '<input type="text" name="name" value="%s" class="vTextField" maxlength="40" required id="id_name">' self.assertNotContains( response, input % self.poll.name, html=True ) def test_inlines_are_rendered_as_read_only(self): question = Question.objects.create(text="How will this be rendered?", poll=self.poll) response = self.client.get(self.change_url) self.assertContains( response, '<td class="field-text"><p>%s</p></td>' % question.text, html=True ) self.assertNotContains(response, 'id="id_question_set-0-text"') self.assertNotContains(response, 'id="id_related_objs-0-DELETE"') def test_submit_line_shows_only_close_button(self): response = self.client.get(self.change_url) self.assertContains( response, '<a href="/admin/admin_inlines/poll/" class="closelink">Close</a>', html=True ) delete_link = '<p class="deletelink-box"><a href="/admin/admin_inlines/poll/%s/delete/" class="deletelink">Delete</a></p>' # noqa self.assertNotContains( response, delete_link % self.poll.id, html=True ) self.assertNotContains(response, '<input type="submit" value="Save and add another" name="_addanother">') self.assertNotContains(response, '<input type="submit" value="Save and continue editing" name="_continue">') def test_inline_delete_buttons_are_not_shown(self): Question.objects.create(text="How will this be rendered?", poll=self.poll) response = self.client.get(self.change_url) self.assertNotContains( response, '<input type="checkbox" name="question_set-0-DELETE" id="id_question_set-0-DELETE">', html=True ) def test_extra_inlines_are_not_shown(self): response = self.client.get(self.change_url) self.assertNotContains(response, 'id="id_question_set-0-text"') @override_settings(ROOT_URLCONF='admin_inlines.urls') class TestVerboseNameInlineForms(TestDataMixin, TestCase): factory = RequestFactory() def test_verbose_name_inline(self): class NonVerboseProfileInline(TabularInline): model = Profile verbose_name = 'Non-verbose childs' class VerboseNameProfileInline(TabularInline): model = VerboseNameProfile verbose_name = 'Childs with verbose name' class VerboseNamePluralProfileInline(TabularInline): model = VerboseNamePluralProfile verbose_name = 'Childs with verbose name plural' class BothVerboseNameProfileInline(TabularInline): model = BothVerboseNameProfile verbose_name = 'Childs with both verbose names' modeladmin = ModelAdmin(ProfileCollection, admin_site) modeladmin.inlines = [ NonVerboseProfileInline, VerboseNameProfileInline, VerboseNamePluralProfileInline, BothVerboseNameProfileInline, ] obj = ProfileCollection.objects.create() url = reverse('admin:admin_inlines_profilecollection_change', args=(obj.pk,)) request = self.factory.get(url) request.user = self.superuser response = modeladmin.changeform_view(request) self.assertNotContains(response, 'Add another Profile') # Non-verbose model. self.assertContains(response, '<h2>Non-verbose childss</h2>') self.assertContains(response, 'Add another Non-verbose child') self.assertNotContains(response, '<h2>Profiles</h2>') # Model with verbose name. self.assertContains(response, '<h2>Childs with verbose names</h2>') self.assertContains(response, 'Add another Childs with verbose name') self.assertNotContains(response, '<h2>Model with verbose name onlys</h2>') self.assertNotContains(response, 'Add another Model with verbose name only') # Model with verbose name plural. self.assertContains(response, '<h2>Childs with verbose name plurals</h2>') self.assertContains(response, 'Add another Childs with verbose name plural') self.assertNotContains(response, '<h2>Model with verbose name plural only</h2>') # Model with both verbose names. self.assertContains(response, '<h2>Childs with both verbose namess</h2>') self.assertContains(response, 'Add another Childs with both verbose names') self.assertNotContains(response, '<h2>Model with both - plural name</h2>') self.assertNotContains(response, 'Add another Model with both - name') def test_verbose_name_plural_inline(self): class NonVerboseProfileInline(TabularInline): model = Profile verbose_name_plural = 'Non-verbose childs' class VerboseNameProfileInline(TabularInline): model = VerboseNameProfile verbose_name_plural = 'Childs with verbose name' class VerboseNamePluralProfileInline(TabularInline): model = VerboseNamePluralProfile verbose_name_plural = 'Childs with verbose name plural' class BothVerboseNameProfileInline(TabularInline): model = BothVerboseNameProfile verbose_name_plural = 'Childs with both verbose names' modeladmin = ModelAdmin(ProfileCollection, admin_site) modeladmin.inlines = [ NonVerboseProfileInline, VerboseNameProfileInline, VerboseNamePluralProfileInline, BothVerboseNameProfileInline, ] obj = ProfileCollection.objects.create() url = reverse('admin:admin_inlines_profilecollection_change', args=(obj.pk,)) request = self.factory.get(url) request.user = self.superuser response = modeladmin.changeform_view(request) # Non-verbose model. self.assertContains(response, '<h2>Non-verbose childs</h2>') self.assertContains(response, 'Add another Profile') self.assertNotContains(response, '<h2>Profiles</h2>') # Model with verbose name. self.assertContains(response, '<h2>Childs with verbose name</h2>') self.assertContains(response, 'Add another Model with verbose name only') self.assertNotContains(response, '<h2>Model with verbose name onlys</h2>') # Model with verbose name plural. self.assertContains(response, '<h2>Childs with verbose name plural</h2>') self.assertContains(response, 'Add another Profile') self.assertNotContains(response, '<h2>Model with verbose name plural only</h2>') # Model with both verbose names. self.assertContains(response, '<h2>Childs with both verbose names</h2>') self.assertContains(response, 'Add another Model with both - name') self.assertNotContains(response, '<h2>Model with both - plural name</h2>') def test_both_verbose_names_inline(self): class NonVerboseProfileInline(TabularInline): model = Profile verbose_name = 'Non-verbose childs - name' verbose_name_plural = 'Non-verbose childs - plural name' class VerboseNameProfileInline(TabularInline): model = VerboseNameProfile verbose_name = 'Childs with verbose name - name' verbose_name_plural = 'Childs with verbose name - plural name' class VerboseNamePluralProfileInline(TabularInline): model = VerboseNamePluralProfile verbose_name = 'Childs with verbose name plural - name' verbose_name_plural = 'Childs with verbose name plural - plural name' class BothVerboseNameProfileInline(TabularInline): model = BothVerboseNameProfile verbose_name = 'Childs with both - name' verbose_name_plural = 'Childs with both - plural name' modeladmin = ModelAdmin(ProfileCollection, admin_site) modeladmin.inlines = [ NonVerboseProfileInline, VerboseNameProfileInline, VerboseNamePluralProfileInline, BothVerboseNameProfileInline, ] obj = ProfileCollection.objects.create() url = reverse('admin:admin_inlines_profilecollection_change', args=(obj.pk,)) request = self.factory.get(url) request.user = self.superuser response = modeladmin.changeform_view(request) self.assertNotContains(response, 'Add another Profile') # Non-verbose model. self.assertContains(response, '<h2>Non-verbose childs - plural name</h2>') self.assertContains(response, 'Add another Non-verbose childs - name') self.assertNotContains(response, '<h2>Profiles</h2>') # Model with verbose name. self.assertContains(response, '<h2>Childs with verbose name - plural name</h2>') self.assertContains(response, 'Add another Childs with verbose name - name') self.assertNotContains(response, '<h2>Model with verbose name onlys</h2>') # Model with verbose name plural. self.assertContains( response, '<h2>Childs with verbose name plural - plural name</h2>', ) self.assertContains( response, 'Add another Childs with verbose name plural - name', ) self.assertNotContains(response, '<h2>Model with verbose name plural only</h2>') # Model with both verbose names. self.assertContains(response, '<h2>Childs with both - plural name</h2>') self.assertContains(response, 'Add another Childs with both - name') self.assertNotContains(response, '<h2>Model with both - plural name</h2>') self.assertNotContains(response, 'Add another Model with both - name') @override_settings(ROOT_URLCONF='admin_inlines.urls') class SeleniumTests(AdminSeleniumTestCase): available_apps = ['admin_inlines'] + AdminSeleniumTestCase.available_apps def setUp(self): User.objects.create_superuser(username='super', password='secret', email='super@example.com') def test_add_stackeds(self): """ The "Add another XXX" link correctly adds items to the stacked formset. """ self.admin_login(username='super', password='secret') self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_holder4_add')) inline_id = '#inner4stacked_set-group' def rows_length(): return len(self.selenium.find_elements_by_css_selector('%s .dynamic-inner4stacked_set' % inline_id)) self.assertEqual(rows_length(), 3) add_button = self.selenium.find_element_by_link_text( 'Add another Inner4 stacked') add_button.click() self.assertEqual(rows_length(), 4) def test_delete_stackeds(self): self.admin_login(username='super', password='secret') self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_holder4_add')) inline_id = '#inner4stacked_set-group' def rows_length(): return len(self.selenium.find_elements_by_css_selector('%s .dynamic-inner4stacked_set' % inline_id)) self.assertEqual(rows_length(), 3) add_button = self.selenium.find_element_by_link_text( 'Add another Inner4 stacked') add_button.click() add_button.click() self.assertEqual(rows_length(), 5, msg="sanity check") for delete_link in self.selenium.find_elements_by_css_selector('%s .inline-deletelink' % inline_id): delete_link.click() with self.disable_implicit_wait(): self.assertEqual(rows_length(), 0) def test_delete_invalid_stacked_inlines(self): from selenium.common.exceptions import NoSuchElementException self.admin_login(username='super', password='secret') self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_holder4_add')) inline_id = '#inner4stacked_set-group' def rows_length(): return len(self.selenium.find_elements_by_css_selector('%s .dynamic-inner4stacked_set' % inline_id)) self.assertEqual(rows_length(), 3) add_button = self.selenium.find_element_by_link_text( 'Add another Inner4 stacked') add_button.click() add_button.click() self.assertEqual(len(self.selenium.find_elements_by_css_selector('#id_inner4stacked_set-4-dummy')), 1) # Enter some data and click 'Save'. self.selenium.find_element_by_name('dummy').send_keys('1') self.selenium.find_element_by_name('inner4stacked_set-0-dummy').send_keys('100') self.selenium.find_element_by_name('inner4stacked_set-1-dummy').send_keys('101') self.selenium.find_element_by_name('inner4stacked_set-2-dummy').send_keys('222') self.selenium.find_element_by_name('inner4stacked_set-3-dummy').send_keys('103') self.selenium.find_element_by_name('inner4stacked_set-4-dummy').send_keys('222') with self.wait_page_loaded(): self.selenium.find_element_by_xpath('//input[@value="Save"]').click() self.assertEqual(rows_length(), 5, msg="sanity check") errorlist = self.selenium.find_element_by_css_selector( '%s .dynamic-inner4stacked_set .errorlist li' % inline_id ) self.assertEqual('Please correct the duplicate values below.', errorlist.text) delete_link = self.selenium.find_element_by_css_selector('#inner4stacked_set-4 .inline-deletelink') delete_link.click() self.assertEqual(rows_length(), 4) with self.disable_implicit_wait(), self.assertRaises(NoSuchElementException): self.selenium.find_element_by_css_selector('%s .dynamic-inner4stacked_set .errorlist li' % inline_id) with self.wait_page_loaded(): self.selenium.find_element_by_xpath('//input[@value="Save"]').click() # The objects have been created in the database. self.assertEqual(Inner4Stacked.objects.all().count(), 4) def test_delete_invalid_tabular_inlines(self): from selenium.common.exceptions import NoSuchElementException self.admin_login(username='super', password='secret') self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_holder4_add')) inline_id = '#inner4tabular_set-group' def rows_length(): return len(self.selenium.find_elements_by_css_selector('%s .dynamic-inner4tabular_set' % inline_id)) self.assertEqual(rows_length(), 3) add_button = self.selenium.find_element_by_link_text( 'Add another Inner4 tabular') add_button.click() add_button.click() self.assertEqual(len(self.selenium.find_elements_by_css_selector('#id_inner4tabular_set-4-dummy')), 1) # Enter some data and click 'Save'. self.selenium.find_element_by_name('dummy').send_keys('1') self.selenium.find_element_by_name('inner4tabular_set-0-dummy').send_keys('100') self.selenium.find_element_by_name('inner4tabular_set-1-dummy').send_keys('101') self.selenium.find_element_by_name('inner4tabular_set-2-dummy').send_keys('222') self.selenium.find_element_by_name('inner4tabular_set-3-dummy').send_keys('103') self.selenium.find_element_by_name('inner4tabular_set-4-dummy').send_keys('222') with self.wait_page_loaded(): self.selenium.find_element_by_xpath('//input[@value="Save"]').click() self.assertEqual(rows_length(), 5, msg="sanity check") # Non-field errorlist is in its own <tr> just before # tr#inner4tabular_set-3: errorlist = self.selenium.find_element_by_css_selector( '%s #inner4tabular_set-3 + .row-form-errors .errorlist li' % inline_id ) self.assertEqual('Please correct the duplicate values below.', errorlist.text) delete_link = self.selenium.find_element_by_css_selector('#inner4tabular_set-4 .inline-deletelink') delete_link.click() self.assertEqual(rows_length(), 4) with self.disable_implicit_wait(), self.assertRaises(NoSuchElementException): self.selenium.find_element_by_css_selector('%s .dynamic-inner4tabular_set .errorlist li' % inline_id) with self.wait_page_loaded(): self.selenium.find_element_by_xpath('//input[@value="Save"]').click() # The objects have been created in the database. self.assertEqual(Inner4Tabular.objects.all().count(), 4) def test_add_inlines(self): """ The "Add another XXX" link correctly adds items to the inline form. """ self.admin_login(username='super', password='secret') self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_profilecollection_add')) # There's only one inline to start with and it has the correct ID. self.assertEqual(len(self.selenium.find_elements_by_css_selector( '.dynamic-profile_set')), 1) self.assertEqual(self.selenium.find_elements_by_css_selector( '.dynamic-profile_set')[0].get_attribute('id'), 'profile_set-0') self.assertEqual(len(self.selenium.find_elements_by_css_selector( '.dynamic-profile_set#profile_set-0 input[name=profile_set-0-first_name]')), 1) self.assertEqual(len(self.selenium.find_elements_by_css_selector( '.dynamic-profile_set#profile_set-0 input[name=profile_set-0-last_name]')), 1) # Add an inline self.selenium.find_element_by_link_text('Add another Profile').click() # The inline has been added, it has the right id, and it contains the # correct fields. self.assertEqual(len(self.selenium.find_elements_by_css_selector('.dynamic-profile_set')), 2) self.assertEqual(self.selenium.find_elements_by_css_selector( '.dynamic-profile_set')[1].get_attribute('id'), 'profile_set-1') self.assertEqual(len(self.selenium.find_elements_by_css_selector( '.dynamic-profile_set#profile_set-1 input[name=profile_set-1-first_name]')), 1) self.assertEqual(len(self.selenium.find_elements_by_css_selector( '.dynamic-profile_set#profile_set-1 input[name=profile_set-1-last_name]')), 1) # Let's add another one to be sure self.selenium.find_element_by_link_text('Add another Profile').click() self.assertEqual(len(self.selenium.find_elements_by_css_selector('.dynamic-profile_set')), 3) self.assertEqual(self.selenium.find_elements_by_css_selector( '.dynamic-profile_set')[2].get_attribute('id'), 'profile_set-2') self.assertEqual(len(self.selenium.find_elements_by_css_selector( '.dynamic-profile_set#profile_set-2 input[name=profile_set-2-first_name]')), 1) self.assertEqual(len(self.selenium.find_elements_by_css_selector( '.dynamic-profile_set#profile_set-2 input[name=profile_set-2-last_name]')), 1) # Enter some data and click 'Save' self.selenium.find_element_by_name('profile_set-0-first_name').send_keys('0 first name 1') self.selenium.find_element_by_name('profile_set-0-last_name').send_keys('0 last name 2') self.selenium.find_element_by_name('profile_set-1-first_name').send_keys('1 first name 1') self.selenium.find_element_by_name('profile_set-1-last_name').send_keys('1 last name 2') self.selenium.find_element_by_name('profile_set-2-first_name').send_keys('2 first name 1') self.selenium.find_element_by_name('profile_set-2-last_name').send_keys('2 last name 2') with self.wait_page_loaded(): self.selenium.find_element_by_xpath('//input[@value="Save"]').click() # The objects have been created in the database self.assertEqual(ProfileCollection.objects.all().count(), 1) self.assertEqual(Profile.objects.all().count(), 3) def test_add_inline_link_absent_for_view_only_parent_model(self): from selenium.common.exceptions import NoSuchElementException user = User.objects.create_user('testing', password='password', is_staff=True) user.user_permissions.add( Permission.objects.get(codename='view_poll', content_type=ContentType.objects.get_for_model(Poll)) ) user.user_permissions.add( *Permission.objects.filter( codename__endswith="question", content_type=ContentType.objects.get_for_model(Question) ).values_list('pk', flat=True) ) self.admin_login(username='testing', password='password') poll = Poll.objects.create(name="Survey") change_url = reverse('admin:admin_inlines_poll_change', args=(poll.id,)) self.selenium.get(self.live_server_url + change_url) with self.disable_implicit_wait(): with self.assertRaises(NoSuchElementException): self.selenium.find_element_by_link_text('Add another Question') def test_delete_inlines(self): self.admin_login(username='super', password='secret') self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_profilecollection_add')) # Add a few inlines self.selenium.find_element_by_link_text('Add another Profile').click() self.selenium.find_element_by_link_text('Add another Profile').click() self.selenium.find_element_by_link_text('Add another Profile').click() self.selenium.find_element_by_link_text('Add another Profile').click() self.assertEqual(len(self.selenium.find_elements_by_css_selector( '#profile_set-group table tr.dynamic-profile_set')), 5) self.assertEqual(len(self.selenium.find_elements_by_css_selector( 'form#profilecollection_form tr.dynamic-profile_set#profile_set-0')), 1) self.assertEqual(len(self.selenium.find_elements_by_css_selector( 'form#profilecollection_form tr.dynamic-profile_set#profile_set-1')), 1) self.assertEqual(len(self.selenium.find_elements_by_css_selector( 'form#profilecollection_form tr.dynamic-profile_set#profile_set-2')), 1) self.assertEqual(len(self.selenium.find_elements_by_css_selector( 'form#profilecollection_form tr.dynamic-profile_set#profile_set-3')), 1) self.assertEqual(len(self.selenium.find_elements_by_css_selector( 'form#profilecollection_form tr.dynamic-profile_set#profile_set-4')), 1) # Click on a few delete buttons self.selenium.find_element_by_css_selector( 'form#profilecollection_form tr.dynamic-profile_set#profile_set-1 td.delete a').click() self.selenium.find_element_by_css_selector( 'form#profilecollection_form tr.dynamic-profile_set#profile_set-2 td.delete a').click() # The rows are gone and the IDs have been re-sequenced self.assertEqual(len(self.selenium.find_elements_by_css_selector( '#profile_set-group table tr.dynamic-profile_set')), 3) self.assertEqual(len(self.selenium.find_elements_by_css_selector( 'form#profilecollection_form tr.dynamic-profile_set#profile_set-0')), 1) self.assertEqual(len(self.selenium.find_elements_by_css_selector( 'form#profilecollection_form tr.dynamic-profile_set#profile_set-1')), 1) self.assertEqual(len(self.selenium.find_elements_by_css_selector( 'form#profilecollection_form tr.dynamic-profile_set#profile_set-2')), 1) def test_collapsed_inlines(self): # Collapsed inlines have SHOW/HIDE links. self.admin_login(username='super', password='secret') self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_author_add')) # One field is in a stacked inline, other in a tabular one. test_fields = ['#id_nonautopkbook_set-0-title', '#id_nonautopkbook_set-2-0-title'] show_links = self.selenium.find_elements_by_link_text('SHOW') self.assertEqual(len(show_links), 3) for show_index, field_name in enumerate(test_fields, 0): self.wait_until_invisible(field_name) show_links[show_index].click() self.wait_until_visible(field_name) hide_links = self.selenium.find_elements_by_link_text('HIDE') self.assertEqual(len(hide_links), 2) for hide_index, field_name in enumerate(test_fields, 0): self.wait_until_visible(field_name) hide_links[hide_index].click() self.wait_until_invisible(field_name) def test_added_stacked_inline_with_collapsed_fields(self): self.admin_login(username='super', password='secret') self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_teacher_add')) self.selenium.find_element_by_link_text('Add another Child').click() test_fields = ['#id_child_set-0-name', '#id_child_set-1-name'] show_links = self.selenium.find_elements_by_link_text('SHOW') self.assertEqual(len(show_links), 2) for show_index, field_name in enumerate(test_fields, 0): self.wait_until_invisible(field_name) show_links[show_index].click() self.wait_until_visible(field_name) hide_links = self.selenium.find_elements_by_link_text('HIDE') self.assertEqual(len(hide_links), 2) for hide_index, field_name in enumerate(test_fields, 0): self.wait_until_visible(field_name) hide_links[hide_index].click() self.wait_until_invisible(field_name) def assertBorder(self, element, border): width, style, color = border.split(' ') border_properties = [ 'border-bottom-%s', 'border-left-%s', 'border-right-%s', 'border-top-%s', ] for prop in border_properties: prop = prop % 'width' self.assertEqual(element.value_of_css_property(prop), width) for prop in border_properties: prop = prop % 'style' self.assertEqual(element.value_of_css_property(prop), style) # Convert hex color to rgb. self.assertRegex(color, '#[0-9a-f]{6}') r, g, b = int(color[1:3], 16), int(color[3:5], 16), int(color[5:], 16) # The value may be expressed as either rgb() or rgba() depending on the # browser. colors = [ 'rgb(%d, %d, %d)' % (r, g, b), 'rgba(%d, %d, %d, 1)' % (r, g, b), ] for prop in border_properties: prop = prop % 'color' self.assertIn(element.value_of_css_property(prop), colors) def test_inline_formset_error_input_border(self): self.admin_login(username='super', password='secret') self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_holder5_add')) self.wait_until_visible('#id_dummy') self.selenium.find_element_by_id('id_dummy').send_keys(1) fields = ['id_inner5stacked_set-0-dummy', 'id_inner5tabular_set-0-dummy'] show_links = self.selenium.find_elements_by_link_text('SHOW') for show_index, field_name in enumerate(fields): show_links[show_index].click() self.wait_until_visible('#' + field_name) self.selenium.find_element_by_id(field_name).send_keys(1) # Before save all inputs have default border for inline in ('stacked', 'tabular'): for field_name in ('name', 'select', 'text'): element_id = 'id_inner5%s_set-0-%s' % (inline, field_name) self.assertBorder( self.selenium.find_element_by_id(element_id), '1px solid #cccccc', ) self.selenium.find_element_by_xpath('//input[@value="Save"]').click() # Test the red border around inputs by css selectors stacked_selectors = ['.errors input', '.errors select', '.errors textarea'] for selector in stacked_selectors: self.assertBorder( self.selenium.find_element_by_css_selector(selector), '1px solid #ba2121', ) tabular_selectors = [ 'td ul.errorlist + input', 'td ul.errorlist + select', 'td ul.errorlist + textarea' ] for selector in tabular_selectors: self.assertBorder( self.selenium.find_element_by_css_selector(selector), '1px solid #ba2121', ) def test_inline_formset_error(self): self.admin_login(username='super', password='secret') self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_holder5_add')) stacked_inline_formset_selector = 'div#inner5stacked_set-group fieldset.module.collapse' tabular_inline_formset_selector = 'div#inner5tabular_set-group fieldset.module.collapse' # Inlines without errors, both inlines collapsed self.selenium.find_element_by_xpath('//input[@value="Save"]').click() self.assertEqual( len(self.selenium.find_elements_by_css_selector(stacked_inline_formset_selector + '.collapsed')), 1 ) self.assertEqual( len(self.selenium.find_elements_by_css_selector(tabular_inline_formset_selector + '.collapsed')), 1 ) show_links = self.selenium.find_elements_by_link_text('SHOW') self.assertEqual(len(show_links), 2) # Inlines with errors, both inlines expanded test_fields = ['#id_inner5stacked_set-0-dummy', '#id_inner5tabular_set-0-dummy'] for show_index, field_name in enumerate(test_fields): show_links[show_index].click() self.wait_until_visible(field_name) self.selenium.find_element_by_id(field_name[1:]).send_keys(1) hide_links = self.selenium.find_elements_by_link_text('HIDE') self.assertEqual(len(hide_links), 2) for hide_index, field_name in enumerate(test_fields): hide_link = hide_links[hide_index] self.selenium.execute_script('window.scrollTo(0, %s);' % hide_link.location['y']) hide_link.click() self.wait_until_invisible(field_name) with self.wait_page_loaded(): self.selenium.find_element_by_xpath('//input[@value="Save"]').click() with self.disable_implicit_wait(): self.assertEqual( len(self.selenium.find_elements_by_css_selector(stacked_inline_formset_selector + '.collapsed')), 0 ) self.assertEqual( len(self.selenium.find_elements_by_css_selector(tabular_inline_formset_selector + '.collapsed')), 0 ) self.assertEqual( len(self.selenium.find_elements_by_css_selector(stacked_inline_formset_selector)), 1 ) self.assertEqual( len(self.selenium.find_elements_by_css_selector(tabular_inline_formset_selector)), 1 ) def test_inlines_verbose_name(self): """ The item added by the "Add another XXX" link must use the correct verbose_name in the inline form. """ self.admin_login(username='super', password='secret') # Hide sidebar. self.selenium.get(self.live_server_url + reverse('admin:admin_inlines_course_add')) toggle_button = self.selenium.find_element_by_css_selector('#toggle-nav-sidebar') toggle_button.click() # Each combination of horizontal/vertical filter with stacked/tabular # inlines. tests = [ 'admin:admin_inlines_course_add', 'admin:admin_inlines_courseproxy_add', 'admin:admin_inlines_courseproxy1_add', 'admin:admin_inlines_courseproxy2_add', ] css_selector = '.dynamic-class_set#class_set-%s h2' for url_name in tests: with self.subTest(url=url_name): self.selenium.get(self.live_server_url + reverse(url_name)) # First inline shows the verbose_name. available, chosen = self.selenium.find_elements_by_css_selector(css_selector % 0) self.assertEqual(available.text, 'AVAILABLE ATTENDANT') self.assertEqual(chosen.text, 'CHOSEN ATTENDANT') # Added inline should also have the correct verbose_name. self.selenium.find_element_by_link_text('Add another Class').click() available, chosen = self.selenium.find_elements_by_css_selector(css_selector % 1) self.assertEqual(available.text, 'AVAILABLE ATTENDANT') self.assertEqual(chosen.text, 'CHOSEN ATTENDANT') # Third inline should also have the correct verbose_name. self.selenium.find_element_by_link_text('Add another Class').click() available, chosen = self.selenium.find_elements_by_css_selector(css_selector % 2) self.assertEqual(available.text, 'AVAILABLE ATTENDANT') self.assertEqual(chosen.text, 'CHOSEN ATTENDANT')