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.test.selenium import screenshot_cases
from django.urls import reverse
from django.utils.translation import gettext
from .admin import InnerInline
from .admin import 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'
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, "")
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,
(
'
'
"Author-book relationships
"
),
html=True,
)
# 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, '
')
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, '
Title1
', 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, '
New label
', 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,
'
'
'
'
"
The two titles must be the same
",
)
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,
'
Callable in QuestionInline")
def test_model_error_inline_with_readonly_field(self):
poll = Poll.objects.create(name="Test poll")
data = {
"question_set-TOTAL_FORMS": 1,
"question_set-INITIAL_FORMS": 0,
"question_set-MAX_NUM_FORMS": 0,
"_save": "Save",
"question_set-0-text": "Question",
"question_set-0-poll": poll.pk,
}
response = self.client.post(
reverse("admin:admin_inlines_poll_change", args=(poll.pk,)),
data,
)
self.assertContains(response, "Always invalid model.")
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, "Awesome stacked help text is awesome.", 4)
self.assertContains(
response,
'',
1,
)
# ReadOnly fields
response = self.client.get(reverse("admin:admin_inlines_capofamiglia_add"))
self.assertContains(
response,
'',
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,
'',
)
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, '
')
self.assertInHTML(
'',
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(
'
', 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, '
'
)
# The div containing the position field is hidden.
self.assertInHTML(
'
',
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(
'
',
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(
'
'
'
Name
'
'
Position
'
"
Delete?
",
response.rendered_content,
)
# The non-field error must be spanned on 3 (visible) columns.
self.assertInHTML(
'
'
'
A non-field error
',
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, '', html=True
)
self.assertContains(
response,
'',
html=True,
)
self.assertContains(
response,
'',
html=True,
)
self.assertContains(
response, '', html=True
)
self.assertContains(
response,
'',
html=True,
)
self.assertContains(
response,
'',
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 = (
''
)
# The total number of forms will remain the same in either case
total_forms_hidden = (
''
)
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 = (
''
)
total_forms = (
''
)
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 = (
''
)
total_forms = (
''
)
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,
'',
html=True,
)
self.assertContains(
response,
'',
html=True,
)
def test_inline_nonauto_noneditable_inherited_pk(self):
response = self.client.get(reverse("admin:admin_inlines_author_add"))
self.assertContains(
response,
'',
html=True,
)
self.assertContains(
response,
'',
html=True,
)
def test_inline_editable_pk(self):
response = self.client.get(reverse("admin:admin_inlines_author_add"))
self.assertContains(
response,
'',
html=True,
count=1,
)
self.assertContains(
response,
'',
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,
'
', html=True
)
self.assertContains(
response,
'' % 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,
'
Inner2s
',
html=True,
)
# One form for existing instance and three extra for new
self.assertContains(
response,
'',
html=True,
)
self.assertContains(
response,
'' % 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,
'
Inner2s
',
html=True,
)
# One form for existing instance only, no new
self.assertContains(
response,
'',
html=True,
)
self.assertContains(
response,
'' % 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,
'
Inner2s
',
html=True,
)
self.assertContains(
response,
'
Inner2s
',
html=True,
)
# One form for existing instance only, three for new
self.assertContains(
response,
'',
html=True,
)
self.assertContains(
response,
'' % self.inner2.id,
html=True,
)
self.assertContains(response, 'id="id_inner2_set-0-DELETE"')
# TabularInline
self.assertContains(
response, '
',
html=True,
)
# Model with verbose name.
self.assertContains(
response,
(
'
'
"Childs with verbose names
"
),
html=True,
)
self.assertContains(response, "Add another Childs with verbose name")
self.assertNotContains(
response,
'
'
"Model with verbose name onlys
",
html=True,
)
self.assertNotContains(response, "Add another Model with verbose name only")
# Model with verbose name plural.
self.assertContains(
response,
(
'
'
"Childs with verbose name plurals
"
),
html=True,
)
self.assertContains(response, "Add another Childs with verbose name plural")
self.assertNotContains(
response,
'
'
"Model with verbose name plural only
",
html=True,
)
# Model with both verbose names.
self.assertContains(
response,
(
'
'
"Childs with both verbose namess
"
),
html=True,
)
self.assertContains(response, "Add another Childs with both verbose names")
self.assertNotContains(
response,
'
'
"Model with both - plural name
",
html=True,
)
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,
(
'
',
html=True,
)
# Model with verbose name.
self.assertContains(
response,
(
'
'
"Childs with verbose name - plural name
"
),
html=True,
)
self.assertContains(response, "Add another Childs with verbose name - name")
self.assertNotContains(
response,
'
'
"Model with verbose name onlys
",
html=True,
)
# Model with verbose name plural.
self.assertContains(
response,
(
'
'
"Childs with verbose name plural - plural name
"
),
html=True,
)
self.assertContains(
response,
"Add another Childs with verbose name plural - name",
)
self.assertNotContains(
response,
'
'
"Model with verbose name plural only
",
html=True,
)
# Model with both verbose names.
self.assertContains(
response,
'
'
"Childs with both - plural name
",
html=True,
)
self.assertContains(response, "Add another Childs with both - name")
self.assertNotContains(
response,
'
'
"Model with both - plural name
",
html=True,
)
self.assertNotContains(response, "Add another Model with both - name")
@override_settings(ROOT_URLCONF="admin_inlines.urls")
class TestInlineWithFieldsets(TestDataMixin, TestCase):
def setUp(self):
self.client.force_login(self.superuser)
def test_inline_headings(self):
response = self.client.get(reverse("admin:admin_inlines_photographer_add"))
# Page main title.
self.assertContains(response, "
Add photographer
", html=True)
# Headings for the toplevel fieldsets. The first one has no name.
self.assertContains(response, '