Repository: wagtail/wagtail

Issue:
Draft page revisions no longer copied in Wagtail 4+
### Issue Summary

Page copy behavior changed in an unexpected way between Wagtail 3.x and 4.x. I'm opening this issue because this change doesn't seem to be documented anywhere, and so I'm not sure if it was intentional or inadvertent.

In Wagtail 3.x and below, copying a page creates a new page copy from the latest revision of the copied page. In Wagtail 4+, copying a page creates a new page copy from the latest _published_ revision of the copied page (or, the first revision if the page was never published). This effectively means that there's no way to copy draft revisions in Wagtail 4+.

This change significantly impacts the workflow of Wagtail content editors who assume that copying a page will use its latest revision. On Wagtail 4+, editors can edit a page, be looking at an edit view with some latest unpublished content, copy the page they are looking at, and find that the page copy is unexpectedly for some older published version. There's no indication to editors that they aren't copying the latest version. Editors who work with a "save early and often" strategy who want to copy a page before publishing for the first time will find that all their saves except the first one are get lost when the page gets copied.

### Steps to Reproduce

The easiest way to demonstrate how the behavior has changed over time is to run through these steps starting with Wagtail 3 and then trying with more recent versions:

1. Create a new virtual environment and `pip install "wagtail=>3,<4"` to install Wagtail 3.0.3.
    - To test more recent versions after going through these steps with Wagtail 3, try `pip install "wagtail>=4,<5"` and `pip install wagtail==5.0rc1`, making sure to `./manage.py migrate` to update models after upgrading.
1. `wagtail start myproject && cd myproject`
3. Edit home/models.py as follows:
   ```py
   from wagtail.admin.panels import FieldPanel
   from wagtail.fields import RichTextField
   from wagtail.models import Page
   
   class HomePage(Page):
       body = RichTextField(blank=True)
   
       content_panels = Page.content_panels + [
           FieldPanel("body"),
        ]
   ```
4. Edit home/templates/home/home_page.html as follows:
   ```html
   {% load wagtailcore_tags %}
   {{ page.body | richtext }}
   ```  
3. `./manage.py makemigrations && ./manage.py migrate && ./manage.py createsuperuser`
5. `./manage.py runserver` and navigate to http://localhost:8000/admin/, logging in with the superuser you just created.

Let's see what happens when we copy a page that's never been published but has multiple revisions.

6. [Add a new HomePage](http://localhost:8000/admin/pages/add/home/homepage/3/), give it some title and set body content to "A", and then click "Save draft".
7. Now change the body content to "B" and click "Save draft" again. 
8. In the three-dot menu in the header, click "Copy". Give the page a new title and slug and click "Copy this page".
9. [Edit the page you just copied](http://localhost:8000/admin/pages/5/edit/) and note that, as expected, page content says "B" -- the copied page got the most recent revision of the page. If you go to [the history of the copied page](http://localhost:8000/admin/pages/5/history/), you only see events for "Created" and "Copied from Home". You don't see the revision history.
    - On Wagtail 4+, **the content says "A" instead** - the first revision was copied, not the most recent one.

Let's see what happens when we copy a page that has been published and has a new unpublished revision.

11. [Go back to editing the first page you created.](http://localhost:8000/admin/pages/4/edit/) Change body content to "C" and select "Publish" from the footer menu.
12. [Go back to editing that page again.](http://localhost:8000/admin/pages/4/edit/) Now change body content to "D" and click "Save draft".
13. In the three-dot menu in the header, click "Copy". Note that the copy page offers a checkbox "Publish copied page: This page is live. Would you like to publish its copy as well?". This is accurate; the page is live, but it has an unpublished draft. There's no indication here which content is going to get copied or published. Leave the checkbox unpublished for now. Give the page a new title and slug and click "Copy this page".
14. [Edit the page you just copied](http://localhost:8000/admin/pages/6/edit/) and note that the content says "D" - again the latest revision was copied, not the published version. If you look at [the history of the copied page](http://localhost:8000/admin/pages/6/history/), you only see events for "Created" and "Copied from Home". So to the editor it appears that the most recent revision was copied and there's no history before that, i.e. all previous revisions are unrecoverable.
    - On Wagtail4+, **the content says "C" instead** - the published live revision was copied, not the most recent draft. There was no indication to the user that this was going to happen.

What if we try to publish the page when we copy it?

16. [Go back and copy your original page again](http://localhost:8000/admin/pages/4/copy/?next=%2Fadmin%2Fpages%2F4%2Fedit%2F) (still saying "D" with an unpublished revision) but this time check the "Publish copied page" checkbox. 
17. [Edit the page you just copied](http://localhost:8000/admin/pages/6/edit/) and note that the content says "D" - again the latest revision was copied, not the published version. If you click on the "LIVE" link at the top-right and [view the page](http://localhost:8000/test4/), it says "C" - so the page draft version and live versions both exist. And indeed [browsing the tree](http://localhost:8000/admin/pages/3/) I see both "View draft" and "View live" links. But, [looking at the page history](http://localhost:8000/admin/pages/7/history/), I can't see or recover those different versions. And in fact there's no way to edit the currently-live version of the page.
    - On Wagtail4+, **the content again says "C" instead** - the published live revision was copied, not the most recent draft. The page still has "View draft" and "View live" links, and is still in "LIVE + DRAFT" mode, but both versions say "C" - the "D" content is seemingly lost to the editor. 

### Technical Explanation

This change in behavior was introduced in #8482; since it is not mentioned on that PR as a deliberate choice, I suspect this was unintentional.

The specific issue is [this change](https://github.com/wagtail/wagtail/pull/8482/commits/7114e0d64f1bf3b278fc75a12166520a99ceeb97#diff-3238f8b64dedf1d4ed1538d66d664cd017eb872b05544cac5179ab07ba78e8a3L247). Prior to this PR, `Page.get_latest_revision()` would query the list of page revisions in the DB and return the latest one. After this PR, the page's `Page.latest_revision` attribute is returned instead. The logic tries to keep that attribute updated when a new page revision is saved but, crucially, [its value is not copied when the page is](https://github.com/wagtail/wagtail/pull/8482/commits/7114e0d64f1bf3b278fc75a12166520a99ceeb97#diff-3238f8b64dedf1d4ed1538d66d664cd017eb872b05544cac5179ab07ba78e8a3R561) and [the page copy logic doesn't set it](https://github.com/laymonage/wagtail/blob/34f0c0c23dbbcd11c4018c954416ee2170b917ee/wagtail/actions/copy_page.py#L114).

The page copy logic [does copy revisions](https://github.com/laymonage/wagtail/blob/8801a49da8d868b667832c3d49d26649f7101064/wagtail/actions/copy_page.py#L188), although there's no way for a page editor to access them, because the action log doesn't get populated with them. If you have access to the database and can look up the ID of the copied revisions, it is possible to compare and recover old revisions ([for example](http://localhost:8000/admin/pages/7/revisions/14/revert/)), but regular Wagtail users can't do this.

### Discussion

One fix to restore the old behavior would be to have the page copy logic set `Page.latest_revision` based on the most recent revision that was copied with the page. Additionally, the page action log could be populated with each old revision that got copied.

Whether these changes make sense depend on the answers to these questions:

1. What is the intended behavior when copying pages: should the latest revision be copied, or the latest published revision?
2. Should page revisions be copied when pages are, and, if so, for what purpose and with what visibility to the user?

My two cents, based on feedback from our content editors, is that the expectation is that copying a page should always copy the most recent page revision. If for some reason only the most recent live revision is copied, then at a minimum the copy view should warn the user about this. Or, perhaps that view could have a checkbox asking the user which version they want to copy.

For pages that have never been published, I think the most recent revision should always be copied. (I see https://github.com/wagtail/wagtail/pull/7755 which touches on this a bit and includes a comment suggesting that copying _for translation_ might necessitate some different logic; I think that even in the translation use case you might still want to copy/translate unpublished content, for example to schedule simultaneous publishing of multiple languages. I'm less familiar with translation workflows so wonder whether my proposed fix above might break these somehow.)

I also tend to think that the revision history should continue to be copied over but that it should be logged and visible to the user. (If it's not visible or useable in any way, why is it being copied?) I wonder if there might be some complications here, though, for example: would it be confusing to have entries in the log that didn't actually happen. Perhaps the log event messages/author could be appropriate modified to reflect that these revisions were copied.

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes

### Technical details

-   Python version: 3.11.1
-   Django version: 4.0.10
-   Wagtail version: 4.x, 5.0rc1



Discussion:
As a content editor, I wanted to add my two cents here if it's valuable. (If this issue is just an unintended bug, then what I'm saying here is largely moot, except as an explanation of need for a fix.)

There are a lot of cases where it makes sense to copy and re-copy drafts in progress, especially on larger websites. For one example, a project for a new section of a website may include building multiple child pages with a roughly parallel structure and sequence of blocks, easiest to replicate via a copy -- but that structure may also be complex enough to necessitate frequent saving before the draft is "ready," whether to ensure no loss of ongoing work or to pick up the next business day after the drafting has begun. While content is still in an experimental/prototype phase, that parallel structure may be tweaked, and again it may be easiest (and most consistent) to re-copy a source draft rather than replicate those tweaks manually across multiple pages.

As a content editor, I tend to lose track of the "initial revision/publication" version of a given page almost immediately, because in order to see that instead of the latest draft, I have to go through a page's version history and find the revision in question. It is worth emphasizing that this means that, if I select "Edit this page" or find a specific page and bring up the corresponding editor, what I will always see by default is the **latest revision, whether published or unpublished**. Having a button associated with that page labeled "Copy" thus creates an intuitive linear expectation for a content editor: that I'm going to copy the latest revision, whether it's published or unpublished.
Thanks for the report @chosak (and @csebianlander for the supporting info)! Have verified that I'm seeing the same behaviour from 4.0.x onward - this is definitely an unintended bug.

I think the problem may be due to the [`wagtail.actions.copy_page`](https://github.com/wagtail/wagtail/blob/main/wagtail/actions/copy_page.py) logic not being updated when the `latest_revision` field was introduced - `latest_revision` is listed in `Page.default_exclude_fields_in_copy` which means that it will be blanked in the copied page. After copying the revision records across, it [creates a new revision](https://github.com/wagtail/wagtail/blob/f1560625aa0dacd11bff010a641170d20258f4d4/wagtail/actions/copy_page.py#L234-L247) which would previously have been based on the latest revision as determined by timestamp, but now takes it from the `latest_revision` field of the copy, which is blank here. As a result, it falls back on the real database record (which corresponds to the published revision if the page is live, or the very first version if not).

Working on a test case and fix for this now. (cc @laymonage)

Resolving patch:
diff --git a/wagtail/actions/copy_page.py b/wagtail/actions/copy_page.py
--- a/wagtail/actions/copy_page.py
+++ b/wagtail/actions/copy_page.py
@@ -187,6 +187,7 @@ def _copy_page(
         # Copy revisions
         if self.copy_revisions:
             for revision in page.revisions.all():
+                use_as_latest_revision = revision.pk == page.latest_revision_id
                 revision.pk = None
                 revision.submitted_for_moderation = False
                 revision.approved_go_live_at = None
@@ -230,6 +231,9 @@ def _copy_page(
 
                 # Save
                 revision.save()
+                # If this revision was designated the latest revision, update the page copy to point to the copied revision
+                if use_as_latest_revision:
+                    page_copy.latest_revision = revision
 
         # Create a new revision
         # This code serves a few purposes:
@@ -245,11 +249,25 @@ def _copy_page(
         latest_revision_as_page_revision = latest_revision.save_revision(
             user=self.user, changed=False, clean=False
         )
+
+        # save_revision should have updated this in the database - update the in-memory copy for consistency
+        page_copy.latest_revision = latest_revision_as_page_revision
+
         if self.keep_live:
             page_copy.live_revision = latest_revision_as_page_revision
             page_copy.last_published_at = latest_revision_as_page_revision.created_at
             page_copy.first_published_at = latest_revision_as_page_revision.created_at
-            page_copy.save(clean=False)
+            # The call to save_revision above will have updated several fields of the page record, including
+            # draft_title and latest_revision. These changes are not reflected in page_copy, so we must only
+            # update the specific fields set above to avoid overwriting them.
+            page_copy.save(
+                clean=False,
+                update_fields=[
+                    "live_revision",
+                    "last_published_at",
+                    "first_published_at",
+                ],
+            )
 
         if page_copy.live:
             page_published.send(


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
@@ -1015,6 +1015,10 @@ def test_copy_method_with_keep_live_will_update_live_revision(self):
                 new_about_us.last_published_at, datetime.datetime(2017, 1, 1, 12, 0, 0)
             )
 
+        new_about_us.refresh_from_db()
+        self.assertEqual(new_about_us.title, "New about us")
+        self.assertEqual(new_about_us.draft_title, "New about us")
+
     def test_copy_method_without_keep_live_will_not_update_live_revision(self):
         about_us = SimplePage.objects.get(url_path="/home/about-us/")
         revision = about_us.save_revision()
@@ -1031,6 +1035,32 @@ def test_copy_method_without_keep_live_will_not_update_live_revision(self):
         # has not been published
         self.assertIsNone(new_about_us.first_published_at)
         self.assertIsNone(new_about_us.last_published_at)
+        new_about_us.refresh_from_db()
+        self.assertEqual(new_about_us.title, "New about us")
+        self.assertEqual(new_about_us.draft_title, "New about us")
+
+    def test_copy_method_copies_latest_revision(self):
+        about_us = SimplePage.objects.get(url_path="/home/about-us/")
+
+        # make another revision
+        about_us.content = "We are even better than before"
+        about_us.save_revision()
+        about_us.refresh_from_db()
+
+        self.assertEqual(
+            about_us.get_latest_revision_as_object().content,
+            "We are even better than before",
+        )
+
+        new_about_us = about_us.copy(
+            keep_live=False,
+            update_attrs={"title": "New about us", "slug": "new-about-us"},
+        )
+        new_about_us_draft = new_about_us.get_latest_revision_as_object()
+        self.assertEqual(new_about_us_draft.content, "We are even better than before")
+        new_about_us.refresh_from_db()
+        self.assertEqual(new_about_us.title, "New about us")
+        self.assertEqual(new_about_us.draft_title, "New about us")
 
     @freeze_time("2017-01-01 12:00:00")
     def test_publish_with_future_go_live_does_not_set_live_revision(self):