Repository: wagtail/wagtail

Issue:
`public()` filter method does not remove alias pages of private pages from queryset
<!--
Found a bug? Please fill out the sections below. 👍
-->

### Issue Summary

<!--
A summary of the issue.
-->
The `public()` filter method of the page queryset does not remove alias pages of private pages from the queryset.

### Steps to Reproduce

1. Checkout the latest version of the bakerydemo. 
2. Edit the `get_context` method of the `BlogIndex` in `bakerydemo/blog/models.py` to only show public pages, by adding the `.public()` filter method:
   ```python
    def get_context(self, request):
        context = super(BlogIndexPage, self).get_context(request)
        context["posts"] = (
            BlogPage.objects.descendant_of(self).live().order_by("-date_published").public()
        )
        return context

   ```
3. Have a look at what is listed on the blog index: http://localhost:8000/blog/
4. Set one of the pages to be private, e.g. by defining a password: http://localhost:8000/admin/pages/77/edit/
5. Check the index again to see that the page you set to private is not displayed anymore. 
6. Now create an alias copy of the page you set to private: http://localhost:8000/admin/pages/77/copy/?next=%2Fadmin%2Fpages%2F61%2F
7. Check the blog index and see that the alias page is listed on the index. 
8. Try to visit the alias page and see that it is protected like the original page (I have added `alias-` to the beginning of the slug when creating it): http://localhost:8000/blog/alias-desserts-benefits/


Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?

- I would expect the alias page to not be listed in the index because the index uses the `public()` filter. 
- I first ran into this in the context of translated pages, which are first created as alias pages. 
- I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: (yes / ~~no~~) With bakerydemo. 


### Technical details

-   Python version: 3.9.9
-   Django version: 4.1.7
-   Wagtail version: 4.2 (discovered on 2.16)




Discussion:
Hi! @tbrlpld Yes, this seems to be an issue and I checked it against the lastest bakerydemo version (66aa55551b007a1310d8f75f6fd34ac8d42e1aa4) and Wagtail version: 4.2a.


https://user-images.githubusercontent.com/89455838/219939276-2b68199b-46b3-40af-bee8-791afe7a40d0.mp4



As, to what I can see in the code, set_privacy is not called during copy or it is checked anywhere if the page is private or not. This will require further investigation from my side, and if I find a fix I will submit the PR.
Thanks @GeekGawd for trying to reproduce. Your video seems so show some thing a bit different from what I am describing above. E.g. you are copying the page outright instead of creating an alias. Also,, to check the effect of the `public()` method of the queryset you would want to look at the blog index page instead of the blog post pages.

Hope this helps to avoid confusion.
Ohhhh! I will update the video and my comment to avoid confusion. Thanks! @tbrlpld 
I think there are a couple of sub-questions to be answered here:

* What is the desired behaviour when creating an alias of a page that has privacy settings defined directly on it?
* What is the desired behaviour when creating an alias of a page that doesn't have privacy settings defined directly on it, but inherits them from an ancestor page?
* Do we implement that behaviour by changing the logic of `public()` to follow the alias and reading the settings at the other end, or by copying the settings to the alias?

The first two can be answered by reasoning that aliases are intended to be a "lightweight" variation of copying pages, so it makes sense to match that behaviour - and the desired behaviour for copying pages is given by the open issue #6212 and corresponding PR #6406 (which had fallen off my radar for reviewing, but is back on it now :-) ). Namely: the copied (or aliased) page should receive the same settings as the source page if they are defined directly on it, but NOT if they're only inherited.

I haven't checked whether the fix in #6406 also indirectly fixes this issue - it's quite possible that it does, if the code paths for copying pages and creating aliases share the appropriate bit of logic so that the privacy setting record is copied across in both cases. Either way, though, I think that copying the privacy setting across _is_ the correct fix, rather than modifying the behaviour of `public()`:

* Having to follow the alias relation within `public()` would add extra complexity and database queries (probably not as much as if it was taking account of inherited privacy settings too, but still more than we want)
* If we're having to fix this for ordinary page copies too, it makes sense to handle aliases in the same way
* If we _didn't_ copy the privacy setting across at this point, we'd presumably have to do so later on when turning the alias into a real page copy (as we do when translating the page), so we're just pushing the problem further down the line.

Thanks @gasman 

> Namely: the copied (or aliased) page should receive the same settings as the source page if they are defined directly on it, but NOT if they're only inherited.

Yup, that sounds reasonable. 

One question that remains for me is: how to we handle later updates to the privacy settings on the original page? Consider this situation

1. Original page is private.
2. An alias of the original page is created. 
3. Following the proposed fix, the same protection rule from the original page is created for the alias page. 
4. Original page is made public
5. What happens to the alias page?

It seems to me that in this scenario, we would want different behaviours for alias and copied pages. Copied pages are independent pages that only matched the original page at creation time and are independent thereafter. Alias pages however should follow the original page until they are "ejected". So I would assume that we would need to keep track of a pages aliases when modifying privacy settings on a page. 
Hi! @gasman I have made a PR. Much of the discussion and the code was already done in #6406 . The files had changed since the last time, so it needed to be written to another file. Also, it only handled restriction_type `password`, I have also changed that to work with all restriction types and written test cases for it. If you get the time do check it out.

Resolving patch:
diff --git a/wagtail/actions/create_alias.py b/wagtail/actions/create_alias.py
--- a/wagtail/actions/create_alias.py
+++ b/wagtail/actions/create_alias.py
@@ -211,10 +211,10 @@ def _create_alias(
             'Page alias created: "%s" id=%d from=%d', alias.title, alias.id, page.id
         )
 
+        from wagtail.models import Page, PageViewRestriction
+
         # Copy child pages
         if recursive:
-            from wagtail.models import Page
-
             numchild = 0
 
             for child_page in page.get_children().specific().iterator():
@@ -240,6 +240,23 @@ def _create_alias(
                 alias.numchild = numchild
                 alias.save(clean=False, update_fields=["numchild"])
 
+        # Copy across any view restrictions defined directly on the page,
+        # unless the destination page already has view restrictions defined
+        if parent:
+            parent_page_restriction = parent.get_view_restrictions()
+        else:
+            parent_page_restriction = page.get_parent().get_view_restrictions()
+
+        if not parent_page_restriction.exists():
+            for view_restriction in page.view_restrictions.all():
+                view_restriction_copy = PageViewRestriction(
+                    restriction_type=view_restriction.restriction_type,
+                    password=view_restriction.password,
+                    page=alias,
+                )
+                view_restriction_copy.save(user=self.user)
+                view_restriction_copy.groups.set(view_restriction.groups.all())
+
         return alias
 
     def execute(self, skip_permission_checks=False):


Test patch:
diff --git a/wagtail/tests/test_page_model.py b/wagtail/tests/test_page_model.py
--- a/wagtail/tests/test_page_model.py
+++ b/wagtail/tests/test_page_model.py
@@ -2642,6 +2642,90 @@ def test_create_alias_with_excluded_parental_and_child_relations(self):
             # reset excluded fields for future tests
             EventPage.exclude_fields_in_copy = []
 
+    def test_alias_page_copies_restriction(self):
+        """Test that view restrictions attached to a page are copied along with the page"""
+
+        homepage = Page.objects.get(url_path="/home/")
+        child_page_1 = SimplePage(
+            title="Child Page 1", slug="child-page-1", content="hello child page 1"
+        )
+        homepage.add_child(instance=child_page_1)
+
+        # Add PageViewRestriction to child_page_1
+        group = Group.objects.create(name="Test Group")
+        restriction = PageViewRestriction.objects.create(
+            page=child_page_1, restriction_type=PageViewRestriction.GROUPS
+        )
+        restriction.groups.add(group)
+
+        child_page_2 = child_page_1.create_alias(update_slug="child-page-2")
+
+        # check that the copied page child_page_2 has a view restriction
+        copied_restriction = PageViewRestriction.objects.get(page=child_page_2)
+        # check that the copied restriction has the same groups as the original
+        self.assertEqual(
+            list(copied_restriction.groups.values_list("id", flat=True)), [group.pk]
+        )
+
+    def test_alias_page_does_not_copy_restrictions_from_parent(self):
+        """Test that view restrictions on a page's ancestor are NOT copied along with the page"""
+
+        homepage = Page.objects.get(url_path="/home/")
+
+        origin_parent = SimplePage(
+            title="Parent 1", slug="parent-1", content="hello parent 1"
+        )
+        homepage.add_child(instance=origin_parent)
+        PageViewRestriction.objects.create(page=origin_parent, password="hello")
+
+        destination_parent = SimplePage(
+            title="Parent 2", slug="parent-2", content="hello parent 2"
+        )
+        homepage.add_child(instance=destination_parent)
+
+        child_page_1 = SimplePage(
+            title="Child Page 1", slug="child-page-1", content="hello child page 1"
+        )
+        origin_parent.add_child(instance=child_page_1)
+
+        child_page_2 = child_page_1.create_alias(
+            parent=destination_parent,
+            update_slug="child-page-2",
+        )
+        # check that the copied page child_page_2 does not have a view restriction
+        self.assertFalse(PageViewRestriction.objects.filter(page=child_page_2).exists())
+
+    def test_alias_page_does_not_copy_restrictions_when_new_parent_has_one_already(
+        self,
+    ):
+        """Test that view restrictions on a page's ancestor are NOT copied along with the page"""
+
+        homepage = Page.objects.get(url_path="/home/")
+
+        origin_parent = SimplePage(
+            title="Parent 1", slug="parent-1", content="hello parent 1"
+        )
+        homepage.add_child(instance=origin_parent)
+
+        destination_parent = SimplePage(
+            title="Parent 2", slug="parent-2", content="hello parent 2"
+        )
+        homepage.add_child(instance=destination_parent)
+        PageViewRestriction.objects.create(page=destination_parent, password="hello")
+
+        child_page_1 = SimplePage(
+            title="Child Page 1", slug="child-page-1", content="hello child page 1"
+        )
+        origin_parent.add_child(instance=child_page_1)
+        PageViewRestriction.objects.create(page=child_page_1, password="hello")
+
+        child_page_2 = child_page_1.create_alias(
+            parent=destination_parent,
+            update_slug="child-page-2",
+        )
+        # check that the copied page child_page_2 does not have a view restriction
+        self.assertFalse(PageViewRestriction.objects.filter(page=child_page_2).exists())
+
 
 class TestUpdateAliases(TestCase):
     fixtures = ["test.json"]