diff options
| author | Tim Graham <timograham@gmail.com> | 2019-03-30 13:58:33 -0400 |
|---|---|---|
| committer | Tim Graham <timograham@gmail.com> | 2019-03-30 17:56:50 -0400 |
| commit | aafdf62921f880f37d7091ed7ac8bc948cd5a9a5 (patch) | |
| tree | fda2aab9ab68eb2ad48aa3577518398817b25c9b | |
| parent | 6bfad83c2a36cca58c2360e7393becc55eb366dd (diff) | |
[2.1.x] Fixed #30289 -- Prevented admin inlines for a ManyToManyField's implicit through model from being editable if the user only has the view permission.
Backport of 8335d59200e4c64dfe3348ea93989d95e0107439 from master.
| -rw-r--r-- | django/contrib/admin/options.py | 56 | ||||
| -rw-r--r-- | docs/releases/2.1.8.txt | 4 | ||||
| -rw-r--r-- | tests/admin_inlines/models.py | 3 | ||||
| -rw-r--r-- | tests/admin_inlines/tests.py | 51 |
4 files changed, 85 insertions, 29 deletions
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index e19268237d..0743a0293c 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -2127,47 +2127,51 @@ class InlineModelAdmin(BaseModelAdmin): queryset = queryset.none() return queryset + def _has_any_perms_for_target_model(self, request, perms): + """ + This method is called only when the ModelAdmin's model is for an + ManyToManyField's implicit through model (if self.opts.auto_created). + Return True if the user has any of the given permissions ('add', + 'change', etc.) for the model that points to the through model. + """ + opts = self.opts + # Find the target model of an auto-created many-to-many relationship. + for field in opts.fields: + if field.remote_field and field.remote_field.model != self.parent_model: + opts = field.remote_field.model._meta + break + return any( + request.user.has_perm('%s.%s' % (opts.app_label, get_permission_codename(perm, opts))) + for perm in perms + ) + def has_add_permission(self, request, obj=None): # RemovedInDjango30Warning: obj becomes a mandatory argument. if self.opts.auto_created: - # We're checking the rights to an auto-created intermediate model, - # which doesn't have its own individual permissions. The user needs - # to have the view permission for the related model in order to - # be able to do anything with the intermediate model. - return self.has_view_permission(request, obj) + # Auto-created intermediate models don't have their own + # permissions. The user needs to have the change permission for the + # related model in order to be able to do anything with the + # intermediate model. + return self._has_any_perms_for_target_model(request, ['change']) return super().has_add_permission(request) def has_change_permission(self, request, obj=None): if self.opts.auto_created: - # We're checking the rights to an auto-created intermediate model, - # which doesn't have its own individual permissions. The user needs - # to have the view permission for the related model in order to - # be able to do anything with the intermediate model. - return self.has_view_permission(request, obj) + # Same comment as has_add_permission(). + return self._has_any_perms_for_target_model(request, ['change']) return super().has_change_permission(request) def has_delete_permission(self, request, obj=None): if self.opts.auto_created: - # We're checking the rights to an auto-created intermediate model, - # which doesn't have its own individual permissions. The user needs - # to have the view permission for the related model in order to - # be able to do anything with the intermediate model. - return self.has_view_permission(request, obj) + # Same comment as has_add_permission(). + return self._has_any_perms_for_target_model(request, ['change']) return super().has_delete_permission(request, obj) def has_view_permission(self, request, obj=None): if self.opts.auto_created: - opts = self.opts - # The model was auto-created as intermediary for a many-to-many - # Many-relationship; find the target model. - for field in opts.fields: - if field.remote_field and field.remote_field.model != self.parent_model: - opts = field.remote_field.model._meta - break - return ( - request.user.has_perm('%s.%s' % (opts.app_label, get_permission_codename('view', opts))) or - request.user.has_perm('%s.%s' % (opts.app_label, get_permission_codename('change', opts))) - ) + # Same comment as has_add_permission(). The 'change' permission + # also implies the 'view' permission. + return self._has_any_perms_for_target_model(request, ['view', 'change']) return super().has_view_permission(request) diff --git a/docs/releases/2.1.8.txt b/docs/releases/2.1.8.txt index dc6d2f7b44..b8774c6f46 100644 --- a/docs/releases/2.1.8.txt +++ b/docs/releases/2.1.8.txt @@ -9,4 +9,6 @@ Django 2.1.8 fixes a bug in 2.1.7. Bugfixes ======== -* +* Prevented admin inlines for a ``ManyToManyField``\'s implicit through model + from being editable if the user only has the view permission + (:ticket:`30289`). diff --git a/tests/admin_inlines/models.py b/tests/admin_inlines/models.py index cb1ec39ae5..24b37b69f6 100644 --- a/tests/admin_inlines/models.py +++ b/tests/admin_inlines/models.py @@ -37,6 +37,9 @@ class Child(models.Model): class Book(models.Model): name = models.CharField(max_length=50) + def __str__(self): + return self.name + class Author(models.Model): name = models.CharField(max_length=50) diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py index 749b3dd75f..62e0c8adcd 100644 --- a/tests/admin_inlines/tests.py +++ b/tests/admin_inlines/tests.py @@ -581,10 +581,10 @@ class TestInlinePermissions(TestCase): self.user.user_permissions.add(permission) author = Author.objects.create(pk=1, name='The Author') - book = author.books.create(name='The inline Book') + self.book = author.books.create(name='The inline Book') self.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=book) + author_book_auto_m2m_intermediate = Author.books.through.objects.get(author=author, book=self.book) self.author_book_auto_m2m_intermediate_id = author_book_auto_m2m_intermediate.pk holder = Holder2.objects.create(dummy=13) @@ -621,6 +621,25 @@ class TestInlinePermissions(TestCase): 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) @@ -650,11 +669,39 @@ class TestInlinePermissions(TestCase): 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" ' |
