Skip to content

Commit 8335d59

Browse files
committed
Fixed #30289 -- Prevented admin inlines for a ManyToManyField's implicit through model from being editable if the user only has the view permission.
1 parent e245046 commit 8335d59

File tree

4 files changed

+85
-29
lines changed

4 files changed

+85
-29
lines changed

django/contrib/admin/options.py

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2111,46 +2111,50 @@ def get_queryset(self, request):
21112111
queryset = queryset.none()
21122112
return queryset
21132113

2114+
def _has_any_perms_for_target_model(self, request, perms):
2115+
"""
2116+
This method is called only when the ModelAdmin's model is for an
2117+
ManyToManyField's implicit through model (if self.opts.auto_created).
2118+
Return True if the user has any of the given permissions ('add',
2119+
'change', etc.) for the model that points to the through model.
2120+
"""
2121+
opts = self.opts
2122+
# Find the target model of an auto-created many-to-many relationship.
2123+
for field in opts.fields:
2124+
if field.remote_field and field.remote_field.model != self.parent_model:
2125+
opts = field.remote_field.model._meta
2126+
break
2127+
return any(
2128+
request.user.has_perm('%s.%s' % (opts.app_label, get_permission_codename(perm, opts)))
2129+
for perm in perms
2130+
)
2131+
21142132
def has_add_permission(self, request, obj):
21152133
if self.opts.auto_created:
2116-
# We're checking the rights to an auto-created intermediate model,
2117-
# which doesn't have its own individual permissions. The user needs
2118-
# to have the view permission for the related model in order to
2119-
# be able to do anything with the intermediate model.
2120-
return self.has_view_permission(request, obj)
2134+
# Auto-created intermediate models don't have their own
2135+
# permissions. The user needs to have the change permission for the
2136+
# related model in order to be able to do anything with the
2137+
# intermediate model.
2138+
return self._has_any_perms_for_target_model(request, ['change'])
21212139
return super().has_add_permission(request)
21222140

21232141
def has_change_permission(self, request, obj=None):
21242142
if self.opts.auto_created:
2125-
# We're checking the rights to an auto-created intermediate model,
2126-
# which doesn't have its own individual permissions. The user needs
2127-
# to have the view permission for the related model in order to
2128-
# be able to do anything with the intermediate model.
2129-
return self.has_view_permission(request, obj)
2143+
# Same comment as has_add_permission().
2144+
return self._has_any_perms_for_target_model(request, ['change'])
21302145
return super().has_change_permission(request)
21312146

21322147
def has_delete_permission(self, request, obj=None):
21332148
if self.opts.auto_created:
2134-
# We're checking the rights to an auto-created intermediate model,
2135-
# which doesn't have its own individual permissions. The user needs
2136-
# to have the view permission for the related model in order to
2137-
# be able to do anything with the intermediate model.
2138-
return self.has_view_permission(request, obj)
2149+
# Same comment as has_add_permission().
2150+
return self._has_any_perms_for_target_model(request, ['change'])
21392151
return super().has_delete_permission(request, obj)
21402152

21412153
def has_view_permission(self, request, obj=None):
21422154
if self.opts.auto_created:
2143-
opts = self.opts
2144-
# The model was auto-created as intermediary for a many-to-many
2145-
# Many-relationship; find the target model.
2146-
for field in opts.fields:
2147-
if field.remote_field and field.remote_field.model != self.parent_model:
2148-
opts = field.remote_field.model._meta
2149-
break
2150-
return (
2151-
request.user.has_perm('%s.%s' % (opts.app_label, get_permission_codename('view', opts))) or
2152-
request.user.has_perm('%s.%s' % (opts.app_label, get_permission_codename('change', opts)))
2153-
)
2155+
# Same comment as has_add_permission(). The 'change' permission
2156+
# also implies the 'view' permission.
2157+
return self._has_any_perms_for_target_model(request, ['view', 'change'])
21542158
return super().has_view_permission(request)
21552159

21562160

docs/releases/2.1.8.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ Django 2.1.8 fixes a bug in 2.1.7.
99
Bugfixes
1010
========
1111

12-
*
12+
* Prevented admin inlines for a ``ManyToManyField``\'s implicit through model
13+
from being editable if the user only has the view permission
14+
(:ticket:`30289`).

tests/admin_inlines/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ def __str__(self):
3737
class Book(models.Model):
3838
name = models.CharField(max_length=50)
3939

40+
def __str__(self):
41+
return self.name
42+
4043

4144
class Author(models.Model):
4245
name = models.CharField(max_length=50)

tests/admin_inlines/tests.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,10 +595,10 @@ def setUpTestData(cls):
595595
cls.user.user_permissions.add(permission)
596596

597597
author = Author.objects.create(pk=1, name='The Author')
598-
book = author.books.create(name='The inline Book')
598+
cls.book = author.books.create(name='The inline Book')
599599
cls.author_change_url = reverse('admin:admin_inlines_author_change', args=(author.id,))
600600
# Get the ID of the automatically created intermediate model for the Author-Book m2m
601-
author_book_auto_m2m_intermediate = Author.books.through.objects.get(author=author, book=book)
601+
author_book_auto_m2m_intermediate = Author.books.through.objects.get(author=author, book=cls.book)
602602
cls.author_book_auto_m2m_intermediate_id = author_book_auto_m2m_intermediate.pk
603603

604604
cls.holder = Holder2.objects.create(dummy=13)
@@ -636,6 +636,25 @@ def test_inline_change_fk_noperm(self):
636636
self.assertNotContains(response, 'Add another Inner2')
637637
self.assertNotContains(response, 'id="id_inner2_set-TOTAL_FORMS"')
638638

639+
def test_inline_add_m2m_view_only_perm(self):
640+
permission = Permission.objects.get(codename='view_book', content_type=self.book_ct)
641+
self.user.user_permissions.add(permission)
642+
response = self.client.get(reverse('admin:admin_inlines_author_add'))
643+
# View-only inlines. (It could be nicer to hide the empty, non-editable
644+
# inlines on the add page.)
645+
self.assertIs(response.context['inline_admin_formset'].has_view_permission, True)
646+
self.assertIs(response.context['inline_admin_formset'].has_add_permission, False)
647+
self.assertIs(response.context['inline_admin_formset'].has_change_permission, False)
648+
self.assertIs(response.context['inline_admin_formset'].has_delete_permission, False)
649+
self.assertContains(response, '<h2>Author-book relationships</h2>')
650+
self.assertContains(
651+
response,
652+
'<input type="hidden" name="Author_books-TOTAL_FORMS" value="0" '
653+
'id="id_Author_books-TOTAL_FORMS">',
654+
html=True,
655+
)
656+
self.assertNotContains(response, 'Add another Author-Book Relationship')
657+
639658
def test_inline_add_m2m_add_perm(self):
640659
permission = Permission.objects.get(codename='add_book', content_type=self.book_ct)
641660
self.user.user_permissions.add(permission)
@@ -665,11 +684,39 @@ def test_inline_change_m2m_add_perm(self):
665684
self.assertNotContains(response, 'id="id_Author_books-TOTAL_FORMS"')
666685
self.assertNotContains(response, 'id="id_Author_books-0-DELETE"')
667686

687+
def test_inline_change_m2m_view_only_perm(self):
688+
permission = Permission.objects.get(codename='view_book', content_type=self.book_ct)
689+
self.user.user_permissions.add(permission)
690+
response = self.client.get(self.author_change_url)
691+
# View-only inlines.
692+
self.assertIs(response.context['inline_admin_formset'].has_view_permission, True)
693+
self.assertIs(response.context['inline_admin_formset'].has_add_permission, False)
694+
self.assertIs(response.context['inline_admin_formset'].has_change_permission, False)
695+
self.assertIs(response.context['inline_admin_formset'].has_delete_permission, False)
696+
self.assertContains(response, '<h2>Author-book relationships</h2>')
697+
self.assertContains(
698+
response,
699+
'<input type="hidden" name="Author_books-TOTAL_FORMS" value="1" '
700+
'id="id_Author_books-TOTAL_FORMS">',
701+
html=True,
702+
)
703+
# The field in the inline is read-only.
704+
self.assertContains(response, '<p>%s</p>' % self.book)
705+
self.assertNotContains(
706+
response,
707+
'<input type="checkbox" name="Author_books-0-DELETE" id="id_Author_books-0-DELETE">',
708+
html=True,
709+
)
710+
668711
def test_inline_change_m2m_change_perm(self):
669712
permission = Permission.objects.get(codename='change_book', content_type=self.book_ct)
670713
self.user.user_permissions.add(permission)
671714
response = self.client.get(self.author_change_url)
672715
# We have change perm on books, so we can add/change/delete inlines
716+
self.assertIs(response.context['inline_admin_formset'].has_view_permission, True)
717+
self.assertIs(response.context['inline_admin_formset'].has_add_permission, True)
718+
self.assertIs(response.context['inline_admin_formset'].has_change_permission, True)
719+
self.assertIs(response.context['inline_admin_formset'].has_delete_permission, True)
673720
self.assertContains(response, '<h2>Author-book relationships</h2>')
674721
self.assertContains(response, 'Add another Author-book relationship')
675722
self.assertContains(response, '<input type="hidden" id="id_Author_books-TOTAL_FORMS" '

0 commit comments

Comments
 (0)