Repository: wagtail/wagtail

Issue:
PagePermissionTester can_view_revisions only controls "History" link in the More buttons.

![Screenshot 2023-09-13 at 5 59 30 PM](https://github.com/wagtail/wagtail/assets/5952/f11b2b29-b6a5-437d-9bc8-6e019d25768d)


### Issue Summary

I don't want a certain class of users to see the Site/Page history pages so I am monkey patching the "can_view_revisions" method of PagePermissionTester. When I set it to False, that removes the "history" link from the [page explorer's "More" actions](https://github.com/wagtail/wagtail/blob/ba9f7c898f6d9080daa6dd87100b96e8c6651355/wagtail/admin/wagtail_hooks.py#L349) - but it does not remove the "View History" link from the workflow side panel, nor from the links that open side panels. 

For the "View History" link, the template checks [if the history_url exists](https://github.com/wagtail/wagtail/blob/ba9f7c898f6d9080daa6dd87100b96e8c6651355/wagtail/admin/templates/wagtailadmin/shared/side_panels/includes/status/workflow.html#L111), so one could add to that if - or make the [get_context_data](https://github.com/wagtail/wagtail/blob/ba9f7c898f6d9080daa6dd87100b96e8c6651355/wagtail/admin/ui/side_panels.py#L255) check before adding history_url. 

I am not sure where the clock icon + link to the page history comes from in the header. That will require some investigation.

### Steps to Reproduce

In a new or existing project, monkey patch can_view_revisions to return false. For example: 

```
def patched_can_view_revisions(self):
    # BEGIN PATCH 
    return False
    # END PATCH
    return not self.page_is_root

from wagtail.models import PagePermissionTester
PagePermissionTester.can_view_revisions = patched_can_view_revisions
```

I have an entire app for patches so that's where I put it. But for reproducing the issue, it is probably sufficient to place this in a file that you then import from an `apps.py` file in one of your site's apps.  I don't see any apps.py files in Bakery demo but perhaps add one just to be sure the patch gets applied?

Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?

I don't have a Snippet model that uses RevisionMixin, so I am not sure if this is a consideration for Snippet history or not. 

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: (yes / no) No - but but given the code referenced above, I am fairly sure this will be reproducible on current main branch. 

### Technical details

-   Python version: Python 3.10.11
-   Django version: 3.2.20
-   Wagtail version: 5.1.1
-   Browser version: any. I have tried Chrome and Firefox



Discussion:
Thank you for the report!

> I am not sure where the clock icon + link to the page history comes from in the header. That will require some investigation.

That comes from these templates:

https://github.com/wagtail/wagtail/blob/27a8f85e1ec798eacc4f1c00a4885c7c433f1407/wagtail/admin/templates/wagtailadmin/shared/headers/page_edit_header.html#L20-L31

https://github.com/wagtail/wagtail/blob/27a8f85e1ec798eacc4f1c00a4885c7c433f1407/wagtail/admin/templates/wagtailadmin/pages/page_listing_header.html#L19-L30

I agree that the check should utilise the existing method in the `PagePermissionTester` to be consistent, but ideally the checks should also be done on the view itself, as otherwise the view would still be accessible via the URL. That said, before we make changes that are `PagePermissionTester`-related, personally I'd like us to flesh out how it sits with our permission policies.

> I don't have a Snippet model that uses `RevisionMixin`, so I am not sure if this is a consideration for Snippet history or not.

For snippets, historically (no pun intended) the history view has existed since before `RevisionMixin` was added, and the view would just show you a list of log entries for the snippet. Given that snippet doesn't have a `PermissionTester` class, this issue doesn't really apply.

That said, I was exploring whether we should add a generic `PermissionTester` in #10578 and I wrote some thoughts on the permission policies situation in the PR description. If we proceed with the generic approach, we'd probably want to rename the method to something like `can_view_history` for the history view specifically, and either remove `can_view_revisions` or use it for e.g. previewing revisions.
Due to the comment above I will remove the unconfirmed tag.

Resolving patch:
diff --git a/wagtail/admin/ui/side_panels.py b/wagtail/admin/ui/side_panels.py
--- a/wagtail/admin/ui/side_panels.py
+++ b/wagtail/admin/ui/side_panels.py
@@ -237,9 +237,13 @@ def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         if self.object.pk:
             self.usage_url = reverse("wagtailadmin_pages:usage", args=(self.object.pk,))
-            self.history_url = reverse(
-                "wagtailadmin_pages:history", args=(self.object.pk,)
-            )
+
+            permissions = self.object.permissions_for_user(self.request.user)
+            if permissions.can_view_revisions():
+                self.history_url = reverse(
+                    "wagtailadmin_pages:history",
+                    args=(self.object.pk,),
+                )
 
     def get_status_templates(self, context):
         templates = super().get_status_templates(context)
diff --git a/wagtail/admin/views/pages/edit.py b/wagtail/admin/views/pages/edit.py
--- a/wagtail/admin/views/pages/edit.py
+++ b/wagtail/admin/views/pages/edit.py
@@ -840,6 +840,11 @@ def form_invalid(self, form):
     def get_preview_url(self):
         return reverse("wagtailadmin_pages:preview_on_edit", args=[self.page.id])
 
+    def get_history_url(self):
+        permissions = self.page.permissions_for_user(self.request.user)
+        if permissions.can_view_revisions():
+            return reverse("wagtailadmin_pages:history", args=[self.page.id])
+
     def get_side_panels(self):
         side_panels = [
             PageStatusSidePanel(
@@ -890,6 +895,7 @@ def get_context_data(self, **kwargs):
                 "side_panels": side_panels,
                 "form": self.form,
                 "next": self.next_url,
+                "history_url": self.get_history_url(),
                 "has_unsaved_changes": self.has_unsaved_changes,
                 "page_locked": self.locked_for_user,
                 "workflow_state": self.workflow_state
diff --git a/wagtail/admin/views/pages/listing.py b/wagtail/admin/views/pages/listing.py
--- a/wagtail/admin/views/pages/listing.py
+++ b/wagtail/admin/views/pages/listing.py
@@ -228,6 +228,11 @@ def get_index_url(self):
     def get_results_url(self):
         return reverse("wagtailadmin_explore_results", args=[self.parent_page.id])
 
+    def get_history_url(self):
+        permissions = self.parent_page.permissions_for_user(self.request.user)
+        if permissions.can_view_revisions():
+            return reverse("wagtailadmin_pages:history", args=[self.parent_page.id])
+
     def get_table_kwargs(self):
         kwargs = super().get_table_kwargs()
         kwargs["use_row_ordering_attributes"] = self.show_ordering_column
@@ -243,6 +248,11 @@ def get_table_kwargs(self):
             }
         return kwargs
 
+    def get_page_title(self):
+        return _("Exploring %(title)s") % {
+            "title": self.parent_page.get_admin_display_title()
+        }
+
     def get_context_data(self, **kwargs):
         self.show_ordering_column = self.ordering == "ord"
         if self.show_ordering_column:
@@ -273,6 +283,7 @@ def get_context_data(self, **kwargs):
                 "ordering": self.ordering,
                 "index_url": self.get_index_url(),
                 "results_url": self.get_results_url(),
+                "history_url": self.get_history_url(),
                 "search_form": self.search_form,
                 "is_searching": self.is_searching,
                 "is_searching_whole_tree": self.is_searching_whole_tree,
@@ -297,6 +308,10 @@ class IndexView(BaseIndexView):
     template_name = "wagtailadmin/pages/index.html"
 
     def get_side_panels(self):
+        # Don't show side panels on the root page
+        if self.parent_page.is_root():
+            return MediaContainer()
+
         side_panels = [
             PageStatusSidePanel(
                 self.parent_page.get_latest_revision_as_object(),


Test patch:
diff --git a/wagtail/admin/tests/pages/test_edit_page.py b/wagtail/admin/tests/pages/test_edit_page.py
--- a/wagtail/admin/tests/pages/test_edit_page.py
+++ b/wagtail/admin/tests/pages/test_edit_page.py
@@ -31,6 +31,7 @@
     EVENT_AUDIENCE_CHOICES,
     Advert,
     AdvertPlacement,
+    CustomPermissionPage,
     EventCategory,
     EventPage,
     EventPageCarouselItem,
@@ -171,6 +172,15 @@ def test_page_edit(self):
             response, reverse("wagtailadmin_pages:usage", args=(self.event_page.id,))
         )
 
+        # test that the link to the history view is shown,
+        # one in the header dropdown button, one beside the side panel toggles,
+        # one in the status side panel
+        self.assertContains(
+            response,
+            reverse("wagtailadmin_pages:history", args=(self.event_page.id,)),
+            count=3,
+        )
+
         # test that AdminURLFinder returns the edit view for the page
         url_finder = AdminURLFinder(self.user)
         expected_url = "/admin/pages/%d/edit/" % self.event_page.id
@@ -190,6 +200,18 @@ def test_usage_count_information_shown(self):
             response, reverse("wagtailadmin_pages:usage", args=(self.event_page.id,))
         )
 
+    def test_edit_custom_permissions(self):
+        page = CustomPermissionPage(title="Page with custom perms", slug="custom-perms")
+        self.root_page.add_child(instance=page)
+        response = self.client.get(reverse("wagtailadmin_pages:edit", args=(page.id,)))
+        self.assertEqual(response.status_code, 200)
+        # Respecting PagePermissionTester.can_view_revisions(),
+        # should not contain a link to the history view
+        self.assertNotContains(
+            response,
+            reverse("wagtailadmin_pages:history", args=(page.id,)),
+        )
+
     @override_settings(WAGTAIL_WORKFLOW_ENABLED=False)
     def test_workflow_buttons_not_shown_when_workflow_disabled(self):
         response = self.client.get(
diff --git a/wagtail/admin/tests/pages/test_explorer_view.py b/wagtail/admin/tests/pages/test_explorer_view.py
--- a/wagtail/admin/tests/pages/test_explorer_view.py
+++ b/wagtail/admin/tests/pages/test_explorer_view.py
@@ -8,7 +8,12 @@
 from wagtail import hooks
 from wagtail.admin.widgets import Button
 from wagtail.models import GroupPagePermission, Locale, Page, Workflow
-from wagtail.test.testapp.models import SimplePage, SingleEventPage, StandardIndex
+from wagtail.test.testapp.models import (
+    CustomPermissionPage,
+    SimplePage,
+    SingleEventPage,
+    StandardIndex,
+)
 from wagtail.test.utils import WagtailTestUtils
 from wagtail.test.utils.timestamps import local_datetime
 from wagtail.utils.deprecation import RemovedInWagtail70Warning
@@ -68,6 +73,16 @@ def test_explore(self):
 
         self.assertContains(response, "1-3 of 3")
 
+        # Should contain a link to the history view
+        # one in the header dropdown button, one beside the side panel toggles,
+        # one in the status side panel
+        # (root_page is a site root, not the Root page, so it should be shown)
+        self.assertContains(
+            response,
+            reverse("wagtailadmin_pages:history", args=(self.root_page.id,)),
+            count=3,
+        )
+
     def test_explore_results(self):
         explore_results_url = reverse(
             "wagtailadmin_explore_results", args=(self.root_page.id,)
@@ -99,6 +114,11 @@ def test_explore_root(self):
         self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
         self.assertEqual(Page.objects.get(id=1), response.context["parent_page"])
         self.assertIn(self.root_page, response.context["pages"])
+        # Should not contain a link to the history view
+        self.assertNotContains(
+            response,
+            reverse("wagtailadmin_pages:history", args=(1,)),
+        )
 
     def test_explore_root_shows_icon(self):
         response = self.client.get(reverse("wagtailadmin_explore_root"))
@@ -480,6 +500,18 @@ def test_search_whole_tree(self):
         self.assertEqual(page_ids, [self.old_page.id])
         self.assertContains(response, "Search within 'New page (simple page)'")
 
+    def test_explore_custom_permissions(self):
+        page = CustomPermissionPage(title="Page with custom perms", slug="custom-perms")
+        self.root_page.add_child(instance=page)
+        response = self.client.get(reverse("wagtailadmin_explore", args=(page.id,)))
+        self.assertEqual(response.status_code, 200)
+        # Respecting PagePermissionTester.can_view_revisions(),
+        # should not contain a link to the history view
+        self.assertNotContains(
+            response,
+            reverse("wagtailadmin_pages:history", args=(page.id,)),
+        )
+
 
 class TestBreadcrumb(WagtailTestUtils, TestCase):
     fixtures = ["test.json"]
diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py
--- a/wagtail/test/testapp/models.py
+++ b/wagtail/test/testapp/models.py
@@ -2193,7 +2193,8 @@ def __str__(self):
 
 
 class CustomPermissionTester(PagePermissionTester):
-    pass
+    def can_view_revisions(self):
+        return False
 
 
 class CustomPermissionPage(Page):