Repository: wagtail/wagtail

Issue:
Snippets title-column always links to "edit" page
### Issue Summary

Even if a user does not have the "change" permission, the `SnippetViewSet` still renders a link to the `EditView`, which then results in a permission error for this user. For example, we often use `SnippetViewSet` and use inspect_view_enabled = True and only allow `inspect`.

Falling back to the inspect view, if it has been enabled, and not defining a link if none of the others are available, seems a good way to solve it.

### Steps to Reproduce

1. Add a wagtail_hooks.py and register a `SnippetViewSet`
2. Create a custom `ModelPermissionPolicy` and make user_has_permission return false if `action==change`
3. Redefined the `permission_policy` property in your `SnippetViewSet` to use your `ModelPermissionPolicy`

### Technical details

-   Django version: 5.0.3
-   Wagtail version: 6.0.1
-   Browser version: You can use Chrome 123 to find this out.



Discussion:

Resolving patch:
diff --git a/wagtail/admin/views/generic/models.py b/wagtail/admin/views/generic/models.py
--- a/wagtail/admin/views/generic/models.py
+++ b/wagtail/admin/views/generic/models.py
@@ -320,11 +320,11 @@ def columns(self):
         return columns
 
     def get_edit_url(self, instance):
-        if self.edit_url_name:
+        if self.edit_url_name and self.user_has_permission("change"):
             return reverse(self.edit_url_name, args=(quote(instance.pk),))
 
     def get_copy_url(self, instance):
-        if self.copy_url_name:
+        if self.copy_url_name and self.user_has_permission("add"):
             return reverse(self.copy_url_name, args=(quote(instance.pk),))
 
     def get_inspect_url(self, instance):
@@ -332,15 +332,11 @@ def get_inspect_url(self, instance):
             return reverse(self.inspect_url_name, args=(quote(instance.pk),))
 
     def get_delete_url(self, instance):
-        if self.delete_url_name:
+        if self.delete_url_name and self.user_has_permission("delete"):
             return reverse(self.delete_url_name, args=(quote(instance.pk),))
 
     def get_add_url(self):
-        if self.permission_policy and not self.permission_policy.user_has_permission(
-            self.request.user, "add"
-        ):
-            return None
-        if self.add_url_name:
+        if self.add_url_name and self.user_has_permission("add"):
             return self._set_locale_query_param(reverse(self.add_url_name))
 
     @cached_property
@@ -374,16 +370,11 @@ def header_buttons(self):
 
     def get_list_more_buttons(self, instance):
         buttons = []
-        edit_url = self.get_edit_url(instance)
-        can_edit = (
-            not self.permission_policy
-            or self.permission_policy.user_has_permission(self.request.user, "change")
-        )
-        if edit_url and can_edit:
+        if edit_url := self.get_edit_url(instance):
             buttons.append(
                 ListingButton(
                     _("Edit"),
-                    url=self.get_edit_url(instance),
+                    url=edit_url,
                     icon_name="edit",
                     attrs={
                         "aria-label": _("Edit '%(title)s'") % {"title": str(instance)}
@@ -391,9 +382,7 @@ def get_list_more_buttons(self, instance):
                     priority=10,
                 )
             )
-        copy_url = self.get_copy_url(instance)
-        can_copy = self.permission_policy.user_has_permission(self.request.user, "add")
-        if copy_url and can_copy:
+        if copy_url := self.get_copy_url(instance):
             buttons.append(
                 ListingButton(
                     _("Copy"),
@@ -405,8 +394,7 @@ def get_list_more_buttons(self, instance):
                     priority=20,
                 )
             )
-        inspect_url = self.get_inspect_url(instance)
-        if inspect_url:
+        if inspect_url := self.get_inspect_url(instance):
             buttons.append(
                 ListingButton(
                     _("Inspect"),
@@ -419,12 +407,7 @@ def get_list_more_buttons(self, instance):
                     priority=20,
                 )
             )
-        delete_url = self.get_delete_url(instance)
-        can_delete = (
-            not self.permission_policy
-            or self.permission_policy.user_has_permission(self.request.user, "delete")
-        )
-        if delete_url and can_delete:
+        if delete_url := self.get_delete_url(instance):
             buttons.append(
                 ListingButton(
                     _("Delete"),
@@ -439,17 +422,20 @@ def get_list_more_buttons(self, instance):
         return buttons
 
     def get_list_buttons(self, instance):
-        buttons = self.get_list_more_buttons(instance)
-        return [
-            ButtonWithDropdown(
-                buttons=buttons,
-                icon_name="dots-horizontal",
-                attrs={
-                    "aria-label": _("More options for '%(title)s'")
-                    % {"title": str(instance)},
-                },
+        more_buttons = self.get_list_more_buttons(instance)
+        buttons = []
+        if more_buttons:
+            buttons.append(
+                ButtonWithDropdown(
+                    buttons=more_buttons,
+                    icon_name="dots-horizontal",
+                    attrs={
+                        "aria-label": _("More options for '%(title)s'")
+                        % {"title": str(instance)},
+                    },
+                )
             )
-        ]
+        return buttons
 
     @cached_property
     def add_item_label(self):
@@ -462,10 +448,7 @@ def add_item_label(self):
     def get_context_data(self, *args, **kwargs):
         context = super().get_context_data(*args, **kwargs)
 
-        context["can_add"] = (
-            self.permission_policy is None
-            or self.permission_policy.user_has_permission(self.request.user, "add")
-        )
+        context["can_add"] = self.user_has_permission("add")
         if context["can_add"]:
             context["add_url"] = context["header_action_url"] = self.add_url
             context["header_action_label"] = self.add_item_label
@@ -874,11 +857,7 @@ def get_success_message(self):
         )
 
     def get_success_buttons(self):
-        return [
-            messages.button(
-                reverse(self.edit_url_name, args=(quote(self.object.pk),)), _("Edit")
-            )
-        ]
+        return [messages.button(self.get_edit_url(), _("Edit"))]
 
     def get_error_message(self):
         if self.error_message is None:
@@ -917,10 +896,7 @@ def get_context_data(self, **kwargs):
         context["side_panels"] = side_panels
         context["media"] += side_panels.media
         context["submit_button_label"] = self.submit_button_label
-        context["can_delete"] = (
-            self.permission_policy is None
-            or self.permission_policy.user_has_permission(self.request.user, "delete")
-        )
+        context["can_delete"] = self.user_has_permission("delete")
         if context["can_delete"]:
             context["delete_url"] = self.get_delete_url()
             context["delete_item_label"] = self.delete_item_label
@@ -1149,24 +1125,12 @@ def get_fields_context(self):
         return [self.get_context_for_field(field_name) for field_name in self.fields]
 
     def get_edit_url(self):
-        if not self.edit_url_name or (
-            self.permission_policy
-            and not self.permission_policy.user_has_permission(
-                self.request.user, "change"
-            )
-        ):
-            return None
-        return reverse(self.edit_url_name, args=(quote(self.object.pk),))
+        if self.edit_url_name and self.user_has_permission("change"):
+            return reverse(self.edit_url_name, args=(quote(self.object.pk),))
 
     def get_delete_url(self):
-        if not self.delete_url_name or (
-            self.permission_policy
-            and not self.permission_policy.user_has_permission(
-                self.request.user, "delete"
-            )
-        ):
-            return None
-        return reverse(self.delete_url_name, args=(quote(self.object.pk),))
+        if self.delete_url_name and self.user_has_permission("delete"):
+            return reverse(self.delete_url_name, args=(quote(self.object.pk),))
 
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
diff --git a/wagtail/admin/views/generic/permissions.py b/wagtail/admin/views/generic/permissions.py
--- a/wagtail/admin/views/generic/permissions.py
+++ b/wagtail/admin/views/generic/permissions.py
@@ -19,21 +19,24 @@ class PermissionCheckedMixin:
     any_permission_required = None
 
     def dispatch(self, request, *args, **kwargs):
-        if self.permission_policy is not None:
-            if self.permission_required is not None:
-                if not self.user_has_permission(self.permission_required):
-                    raise PermissionDenied
+        if self.permission_required is not None:
+            if not self.user_has_permission(self.permission_required):
+                raise PermissionDenied
 
-            if self.any_permission_required is not None:
-                if not self.user_has_any_permission(self.any_permission_required):
-                    raise PermissionDenied
+        if self.any_permission_required is not None:
+            if not self.user_has_any_permission(self.any_permission_required):
+                raise PermissionDenied
 
         return super().dispatch(request, *args, **kwargs)
 
     def user_has_permission(self, permission):
-        return self.permission_policy.user_has_permission(self.request.user, permission)
+        return not self.permission_policy or (
+            self.permission_policy.user_has_permission(self.request.user, permission)
+        )
 
     def user_has_any_permission(self, permissions):
-        return self.permission_policy.user_has_any_permission(
-            self.request.user, permissions
+        return not self.permission_policy or (
+            self.permission_policy.user_has_any_permission(
+                self.request.user, permissions
+            )
         )
diff --git a/wagtail/snippets/views/snippets.py b/wagtail/snippets/views/snippets.py
--- a/wagtail/snippets/views/snippets.py
+++ b/wagtail/snippets/views/snippets.py
@@ -206,16 +206,17 @@ def get_list_buttons(self, instance):
                 )
                 hook(more_buttons, instance, self.request.user, {})
 
-        list_buttons.append(
-            ButtonWithDropdown(
-                buttons=more_buttons,
-                icon_name="dots-horizontal",
-                attrs={
-                    "aria-label": _("More options for '%(title)s'")
-                    % {"title": str(instance)},
-                },
+        if more_buttons:
+            list_buttons.append(
+                ButtonWithDropdown(
+                    buttons=more_buttons,
+                    icon_name="dots-horizontal",
+                    attrs={
+                        "aria-label": _("More options for '%(title)s'")
+                        % {"title": str(instance)},
+                    },
+                )
             )
-        )
 
         return list_buttons
 


Test patch:
diff --git a/wagtail/admin/tests/viewsets/test_model_viewset.py b/wagtail/admin/tests/viewsets/test_model_viewset.py
--- a/wagtail/admin/tests/viewsets/test_model_viewset.py
+++ b/wagtail/admin/tests/viewsets/test_model_viewset.py
@@ -1464,6 +1464,33 @@ def test_simple(self):
             self.assertEqual(rendered_button.attrs.get("aria-label"), aria_label)
             self.assertEqual(rendered_button.attrs.get("href"), url)
 
+    def test_title_cell_not_link_to_edit_view_when_no_edit_permission(self):
+        self.user.is_superuser = False
+        self.user.save()
+        admin_permission = Permission.objects.get(
+            content_type__app_label="wagtailadmin",
+            codename="access_admin",
+        )
+        add_permission = Permission.objects.get(
+            content_type__app_label=self.object._meta.app_label,
+            codename=get_permission_codename("add", self.object._meta),
+        )
+        self.user.user_permissions.add(admin_permission, add_permission)
+
+        response = self.client.get(reverse("feature_complete_toy:index"))
+        self.assertEqual(response.status_code, 200)
+        soup = self.get_soup(response.content)
+        title_wrapper = soup.select_one("#listing-results td.title .title-wrapper")
+        self.assertIsNotNone(title_wrapper)
+        self.assertIsNone(title_wrapper.select_one("a"))
+        self.assertEqual(title_wrapper.text.strip(), self.object.name)
+
+        # There should be no edit link at all on the page
+        self.assertNotContains(
+            response,
+            reverse("feature_complete_toy:edit", args=[quote(self.object.pk)]),
+        )
+
     def test_copy_disabled(self):
         response = self.client.get(reverse("fctoy_alt1:index"))
 
@@ -1508,6 +1535,27 @@ def test_copy_disabled(self):
             self.assertEqual(rendered_button.attrs.get("aria-label"), aria_label)
             self.assertEqual(rendered_button.attrs.get("href"), url)
 
+    def test_dropdown_not_rendered_when_no_child_buttons_exist(self):
+        self.user.is_superuser = False
+        self.user.save()
+        admin_permission = Permission.objects.get(
+            content_type__app_label="wagtailadmin",
+            codename="access_admin",
+        )
+        add_permission = Permission.objects.get(
+            content_type__app_label=self.object._meta.app_label,
+            codename=get_permission_codename("add", self.object._meta),
+        )
+        self.user.user_permissions.add(admin_permission, add_permission)
+
+        # The alt3 viewset doesn't have "copy" and "inspect" views enabled,
+        # so when only "add" permission is granted, the dropdown should have
+        # no items and thus not be rendered at all
+        response = self.client.get(reverse("fctoy-alt3:index"))
+        soup = self.get_soup(response.content)
+        actions = soup.select_one("tbody tr td ul.actions")
+        self.assertIsNone(actions)
+
 
 class TestCopyView(WagtailTestUtils, TestCase):
     def setUp(self):
diff --git a/wagtail/snippets/tests/test_snippets.py b/wagtail/snippets/tests/test_snippets.py
--- a/wagtail/snippets/tests/test_snippets.py
+++ b/wagtail/snippets/tests/test_snippets.py
@@ -319,6 +319,23 @@ def register_snippet_listing_button_item(buttons, snippet, user, context):
         self.assertEqual(response.status_code, 200)
         self.assertTemplateUsed(response, "wagtailadmin/shared/buttons.html")
 
+    def test_dropdown_not_rendered_when_no_child_buttons_exist(self):
+        Advert.objects.create(text="My Lovely advert")
+
+        def remove_all_buttons(buttons, snippet, user):
+            buttons[:] = []
+            self.assertEqual(len(buttons), 0)
+
+        with hooks.register_temporarily(
+            "construct_snippet_listing_buttons",
+            remove_all_buttons,
+        ):
+            response = self.get()
+
+        soup = self.get_soup(response.content)
+        actions = soup.select_one("tbody tr td ul.actions")
+        self.assertIsNone(actions)
+
     def test_use_latest_draft_as_title(self):
         snippet = DraftStateModel.objects.create(text="Draft-enabled Foo, Published")
         snippet.save_revision().publish()
diff --git a/wagtail/test/testapp/views.py b/wagtail/test/testapp/views.py
--- a/wagtail/test/testapp/views.py
+++ b/wagtail/test/testapp/views.py
@@ -278,6 +278,7 @@ class ToyViewSetGroup(ModelViewSetGroup):
             index_view_class=FeatureCompleteToyIndexView,
             list_display=["name", "strid", "release_date"],
             ordering=["strid"],
+            copy_view_enabled=False,
         ),
     )