From 1a28c44add84d19cd962b33bcc71c78576d4f030 Mon Sep 17 00:00:00 2001
From: Andy Chosak <andy@chosak.org>
Date: Thu, 31 Mar 2022 14:47:35 -0400
Subject: [PATCH] Fix bulk publishing of pages without revisions

Currently bulk publishing raises an error if you include pages that
exist in the database without any revisions. This commit ensures
that a revision exists before the page is published.

Fixes issue 8187.
---
 CHANGELOG.txt                                 |  7 +++
 docs/releases/2.15.5.rst                      | 15 +++++
 docs/releases/2.16.2.md                       |  1 +
 .../test_bulk_actions/test_bulk_publish.py    | 59 +++++++++++++++++--
 .../admin/views/pages/bulk_actions/publish.py | 23 ++++++--
 5 files changed, 96 insertions(+), 9 deletions(-)
 create mode 100644 docs/releases/2.15.5.rst

diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 57c032f8f1..dfabefa959 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -7,6 +7,7 @@ Changelog
  * Update Jinja2 template support for Jinja2 3.x (Seb Brown)
  * Fix: Update django-treebeard dependency to 4.5.1 or above (Serafeim Papastefanos)
  * Fix: Fix permission error when sorting pages having page type restrictions (Thijs Kramer)
+ * Fix: Allow bulk publishing of pages without revisions (Andy Chosak)
 
 
 2.16.1 (11.02.2022)
@@ -78,6 +79,12 @@ Changelog
  * Fix: Ensure that programmatic page moves are correctly logged as 'move' and not 'reorder' in some cases (Andy Babic)
 
 
+2.15.5 (xx.xx.xxxx) - IN DEVELOPMENT
+~~~~~~~~~~~~~~~~~~~
+
+ * Fix: Allow bulk publishing of pages without revisions (Andy Chosak)
+
+
 2.15.4 (11.02.2022)
 ~~~~~~~~~~~~~~~~~~~
 
diff --git a/docs/releases/2.15.5.rst b/docs/releases/2.15.5.rst
new file mode 100644
index 0000000000..b5374b9dbd
--- /dev/null
+++ b/docs/releases/2.15.5.rst
@@ -0,0 +1,15 @@
+============================
+Wagtail 2.15.5 release notes
+============================
+
+.. contents::
+    :local:
+    :depth: 1
+
+
+What's new
+==========
+
+Bug fixes
+~~~~~~~~~
+ * Allow bulk publishing of pages without revisions (Andy Chosak)
diff --git a/docs/releases/2.16.2.md b/docs/releases/2.16.2.md
index 153a4418a0..e88071d07f 100644
--- a/docs/releases/2.16.2.md
+++ b/docs/releases/2.16.2.md
@@ -13,3 +13,4 @@
  * Update django-treebeard dependency to 4.5.1 or above (Serafeim Papastefanos)
  * Update Jinja2 template support for Jinja2 3.x (Seb Brown)
  * Fix permission error when sorting pages having page type restrictions (Thijs Kramer)
+ * Allow bulk publishing of pages without revisions (Andy Chosak)
diff --git a/wagtail/admin/tests/pages/test_bulk_actions/test_bulk_publish.py b/wagtail/admin/tests/pages/test_bulk_actions/test_bulk_publish.py
index c1b7392699..2b1d94418e 100644
--- a/wagtail/admin/tests/pages/test_bulk_actions/test_bulk_publish.py
+++ b/wagtail/admin/tests/pages/test_bulk_actions/test_bulk_publish.py
@@ -17,11 +17,13 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
     def setUp(self):
         self.root_page = Page.objects.get(id=2)
 
-        # Add child pages
+        # Add child pages which will have already been published before we
+        # bulk publish them.
         self.child_pages = [
             SimplePage(title=f"Hello world!-{i}", slug=f"hello-world-{i}", content=f"Hello world {i}!", live=False)
             for i in range(1, 5)
         ]
+
         self.pages_to_be_published = self.child_pages[:3]
         self.pages_not_to_be_published = self.child_pages[3:]
 
@@ -29,10 +31,33 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
             self.root_page.add_child(instance=child_page)
 
         for i, child_page in enumerate(self.child_pages):
-            child_page.content = f"Hello updated world {i}!"
+            child_page.content = f"Hello published world {i}!"
             child_page.save_revision()
 
-        self.url = reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'publish', )) + '?'
+        # Add an additional child page which will be bulk published from a
+        # draft-only state.
+        draft_page = SimplePage(
+            title="Hello world!-5",
+            slug="hello-world-5",
+            content="Hello published world 5!",
+            live=False,
+        )
+
+        self.root_page.add_child(instance=draft_page)
+        self.child_pages.append(draft_page)
+        self.pages_to_be_published.append(draft_page)
+
+        self.url = (
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "publish",
+                ),
+            )
+            + "?"
+        )
         for child_page in self.pages_to_be_published:
             self.url += f'id={child_page.id}&'
         self.redirect_url = reverse('wagtailadmin_explore', args=(self.root_page.id, ))
@@ -105,7 +130,7 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
         for child_page in self.pages_to_be_published:
             published_page = SimplePage.objects.get(id=child_page.id)
             self.assertTrue(published_page.live)
-            self.assertIn("Hello updated", published_page.content)
+            self.assertIn("Hello published", published_page.content)
 
         # Check that the child pages not to be published remain
         for child_page in self.pages_not_to_be_published:
@@ -191,6 +216,8 @@ class TestBulkPublishIncludingDescendants(TestCase, WagtailTestUtils):
             child_page.content = f"Hello updated world {i}!"
             child_page.save_revision()
 
+        # Add grandchild pages which will have already been published before
+        # we bulk publish them.
         # map of the form { page: [child_pages] } to be added
         self.grandchildren_pages = {
             self.pages_to_be_published[0]: [
@@ -209,7 +236,29 @@ class TestBulkPublishIncludingDescendants(TestCase, WagtailTestUtils):
                 grandchild_page.content = grandchild_page.content.replace("Hello world", "Hello grandchild")
                 grandchild_page.save_revision()
 
-        self.url = reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'publish', )) + '?'
+        # Add an additional grandchild page which will be bulk published from
+        # a draft-only state.
+        draft_page = SimplePage(
+            title="Hello world!-d",
+            slug="hello-world-d",
+            content="Hello grandchild d!",
+            live=False,
+        )
+
+        self.pages_to_be_published[1].add_child(instance=draft_page)
+        self.grandchildren_pages[self.pages_to_be_published[1]].append(draft_page)
+
+        self.url = (
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "publish",
+                ),
+            )
+            + "?"
+        )
         for child_page in self.pages_to_be_published:
             self.url += f'&id={child_page.id}'
 
diff --git a/wagtail/admin/views/pages/bulk_actions/publish.py b/wagtail/admin/views/pages/bulk_actions/publish.py
index c058969cc2..59c915600c 100644
--- a/wagtail/admin/views/pages/bulk_actions/publish.py
+++ b/wagtail/admin/views/pages/bulk_actions/publish.py
@@ -34,14 +34,29 @@ class PublishBulkAction(PageBulkAction):
     def execute_action(cls, objects, include_descendants=False, user=None, **kwargs):
         num_parent_objects, num_child_objects = 0, 0
         for page in objects:
-            page.get_latest_revision().publish(user=user)
+            revision = page.get_latest_revision() or page.specific.save_revision(
+                user=user
+            )
+            revision.publish(user=user)
             num_parent_objects += 1
 
             if include_descendants:
-                for draft_descendant_page in page.get_descendants().not_live().defer_streamfields().specific():
-                    if user is None or draft_descendant_page.permissions_for_user(user).can_publish():
-                        draft_descendant_page.get_latest_revision().publish(user=user)
+                for draft_descendant_page in (
+                    page.get_descendants().not_live().defer_streamfields().specific()
+                ):
+                    if (
+                        user is None
+                        or draft_descendant_page.permissions_for_user(
+                            user
+                        ).can_publish()
+                    ):
+                        draft_descendant_revision = (
+                            draft_descendant_page.get_latest_revision()
+                            or draft_descendant_page.save_revision(user=user)
+                        )
+                        draft_descendant_revision.publish(user=user)
                         num_child_objects += 1
+
         return num_parent_objects, num_child_objects
 
     def get_success_message(self, num_parent_objects, num_child_objects):