Repository: wagtail/wagtail

Issue:
Wagtail 2.15 Bulk Publish for Inherited Page
Found a bug? Please fill out the sections below. 👍

### Issue Summary
Assume we have two classes:
class A(Page):
  name = models.CharField(max_length=255)

class B(A):
  nickname = models.CharField(max_length=255)

When do bulk action of publishing the pages with B, an error will occur:
![image](https://user-images.githubusercontent.com/81170508/140770606-161a016f-52c2-4bda-b45d-0cd78ba99310.png)

The existing solution I think is to customize bulk action according to https://docs.wagtail.io/en/stable/extending/custom_bulk_actions.html#custom-bulk-actions
But I would like to know if there is other solution?


Discussion:
Use case:

1. Simple page model

```python
class MyPage(Page):
    pass
```

2. create a draft using the above
3. add required field
```python
class MyPage(Page):
    subtitle = models.CharField(max_length=255)

    content_panels = Page.content_panels + [FieldPanel("subtitle")]
```
4. try to publish via bulk actions 💥 
<details>
<summary>Traceback</summary>

```
Traceback (most recent call last):
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/core/handlers/exception.py", line 34, in inner
    response = get_response(request)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/core/handlers/base.py", line 115, in _get_response
    response = self.process_exception_by_middleware(e, request)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/core/handlers/base.py", line 113, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/views/decorators/cache.py", line 44, in _wrapped_view_func
    response = view_func(request, *args, **kwargs)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/wagtail/admin/urls/__init__.py", line 125, in wrapper
    return view_func(request, *args, **kwargs)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/wagtail/admin/auth.py", line 174, in decorated_view
    response = view_func(request, *args, **kwargs)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/wagtail/admin/views/bulk_action/dispatcher.py", line 11, in index
    return action_class(request, model).dispatch(request)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/views/generic/base.py", line 97, in dispatch
    return handler(request, *args, **kwargs)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/views/generic/edit.py", line 142, in post
    return self.form_valid(form)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/wagtail/admin/views/bulk_action/base_bulk_action.py", line 135, in form_valid
    num_parent_objects, num_child_objects = self.execute_action(objects, **self.get_execution_context())
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/wagtail/admin/views/pages/bulk_actions/publish.py", line 38, in execute_action
    revision.publish(user=user)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/wagtail/core/models/__init__.py", line 2595, in publish
    page.save()
  File "/usr/lib/python3.6/contextlib.py", line 52, in inner
    return func(*args, **kwds)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/wagtail/core/models/__init__.py", line 482, in save
    self.full_clean()
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/wagtail/core/models/__init__.py", line 441, in full_clean
    super().full_clean(*args, **kwargs)
  File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/models/base.py", line 1222, in full_clean
    raise ValidationError(errors)

Exception Type: ValidationError at /admin/bulk/wagtailcore/page/publish/
Exception Value: {'subtitle': ['This field cannot be blank.']}
```
</details>
If the steps to reproduce are indeed as @zerolab describes, then this is _arguably_ logically correct, and could happen anywhere that a page is published without going through the edit form (e.g. a moderator approving it from the dashboard or edit bird, and possibly also scheduled publishing). It arises because draft / submitted revisions are saved as JSON in wagtailcore_pagerevisions, where the schema change doesn't affect them. To be absolutely correct, the developer making the change would have to provide a data migration to update the revisions too (although, to be fair, we don't provide any documentation for that...) - since they don't, the revision no longer passes validation. And since this is developer error rather than one made by an editor, it's appropriate to throw a developer-facing error. Having said that, catching ValidationError and reporting a friendly error back to the editor ("the following pages could not be published because the validation requirements have changed since they were last saved") wouldn't hurt.

I'm not sure if that's the same bug that @aliceni81 is describing, though, since it's unrelated to page inheritance - I'd like to check whether the original bug can be replicated without any after-the-fact schema changes.
It seems the bug is more basic than either of the above cases - it doesn't require inheritance, or having a draft previously saved before adding the required field:

Starting from a new project with home/models.py edited to:
```
from django.db import models

from wagtail.core.models import Page
from wagtail.admin.edit_handlers import FieldPanel


class HomePage(Page):
    pass


class MyPage(Page):
    subtitle = models.CharField(max_length=255)
    content_panels = Page.content_panels + [FieldPanel("subtitle")]
```
run

    ./manage.py makemigrations
    ./manage.py migrate
    ./manage.py createsuperuser
    ./manage.py runserver

Log in, create a "My Page" as a child of the homepage, enter a title and subtitle, and save as draft.
Then, from the Home explorer listing, use the bulk action to publish that new page - the `{'subtitle': ['This field cannot be blank.']}` validation error occurs.

```
Environment:


Request Method: POST
Request URL: http://localhost:8000/admin/bulk/wagtailcore/page/publish/?next=%2Fadmin%2Fpages%2F3%2F&id=4

Django Version: 3.2.8
Python Version: 3.8.0
Installed Applications:
['home',
 'search',
 'wagtail.contrib.forms',
 'wagtail.contrib.redirects',
 'wagtail.embeds',
 'wagtail.sites',
 'wagtail.users',
 'wagtail.snippets',
 'wagtail.documents',
 'wagtail.images',
 'wagtail.search',
 'wagtail.admin',
 'wagtail.core',
 'modelcluster',
 'taggit',
 'django.contrib.admin',
 'django.contrib.auth',
 'django.contrib.contenttypes',
 'django.contrib.sessions',
 'django.contrib.messages',
 'django.contrib.staticfiles']
Installed Middleware:
['django.contrib.sessions.middleware.SessionMiddleware',
 'django.middleware.common.CommonMiddleware',
 'django.middleware.csrf.CsrfViewMiddleware',
 'django.contrib.auth.middleware.AuthenticationMiddleware',
 'django.contrib.messages.middleware.MessageMiddleware',
 'django.middleware.clickjacking.XFrameOptionsMiddleware',
 'django.middleware.security.SecurityMiddleware',
 'wagtail.contrib.redirects.middleware.RedirectMiddleware']



Traceback (most recent call last):
  File "/Users/matthew/.virtualenvs/wagtail/lib/python3.8/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/Users/matthew/.virtualenvs/wagtail/lib/python3.8/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/Users/matthew/.virtualenvs/wagtail/lib/python3.8/site-packages/django/views/decorators/cache.py", line 44, in _wrapped_view_func
    response = view_func(request, *args, **kwargs)
  File "/Users/matthew/Development/tbx/wagtail/devscript/wagtail/wagtail/admin/urls/__init__.py", line 125, in wrapper
    return view_func(request, *args, **kwargs)
  File "/Users/matthew/Development/tbx/wagtail/devscript/wagtail/wagtail/admin/auth.py", line 195, in decorated_view
    return view_func(request, *args, **kwargs)
  File "/Users/matthew/Development/tbx/wagtail/devscript/wagtail/wagtail/admin/views/bulk_action/dispatcher.py", line 11, in index
    return action_class(request, model).dispatch(request)
  File "/Users/matthew/.virtualenvs/wagtail/lib/python3.8/site-packages/django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "/Users/matthew/.virtualenvs/wagtail/lib/python3.8/site-packages/django/views/generic/edit.py", line 142, in post
    return self.form_valid(form)
  File "/Users/matthew/Development/tbx/wagtail/devscript/wagtail/wagtail/admin/views/bulk_action/base_bulk_action.py", line 135, in form_valid
    num_parent_objects, num_child_objects = self.execute_action(objects, **self.get_execution_context())
  File "/Users/matthew/Development/tbx/wagtail/devscript/wagtail/wagtail/admin/views/pages/bulk_actions/publish.py", line 38, in execute_action
    revision.publish(user=user)
  File "/Users/matthew/Development/tbx/wagtail/devscript/wagtail/wagtail/core/models/__init__.py", line 2595, in publish
    page.save()
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/contextlib.py", line 75, in inner
    return func(*args, **kwds)
  File "/Users/matthew/Development/tbx/wagtail/devscript/wagtail/wagtail/core/models/__init__.py", line 482, in save
    self.full_clean()
  File "/Users/matthew/Development/tbx/wagtail/devscript/wagtail/wagtail/core/models/__init__.py", line 441, in full_clean
    super().full_clean(*args, **kwargs)
  File "/Users/matthew/.virtualenvs/wagtail/lib/python3.8/site-packages/django/db/models/base.py", line 1238, in full_clean
    raise ValidationError(errors)

Exception Type: ValidationError at /admin/bulk/wagtailcore/page/publish/
Exception Value: {'subtitle': ['This field cannot be blank.']}
```
Even worse :-( If the specific page type _doesn't_ contain any required fields, then [this line](https://github.com/wagtail/wagtail/blob/33445188e76692419897cf3fd22dbbc64b029d90/wagtail/admin/views/pages/bulk_actions/publish.py#L37) causes it to save and publish a new revision derived from the basic `Page` object, meaning that all other fields are blanked. Looks like we're missing a `page.specific` here, and our tests are only testing basic Page instances.
Ugh, great sleuthing @gasman. Was just testing the previous case.

One option would be to use the specific qs in https://github.com/wagtail/wagtail/blob/33445188e76692419897cf3fd22dbbc64b029d90/wagtail/admin/views/pages/bulk_actions/page_bulk_action.py#L16 
@gasman We just had a troubling experience with bulk publishing that may warrant reopening this? (We are on 2.15.1 that includes #7702.)

We used the bulk publishing interface to publish a parent page and its four children. Each of them lost data from at least one field, some many more than that. Some model fields, some StreamField blocks.

Here's a summary of the edits that were lost:

- Parent page
  - Show in menus (core `Page` BooleanField)
  - Custom menu name (CharField on abstract page model that concrete page model inherits)
  - Override secondary navigation (BooleanField on abstract page model that concrete page model inherits)
  - Clearance number (CharField on class mixed in to the abstract page model)
  - Slug (core `Page` SlugField)
  - The addition of an ImageBlock within the body StreamField (first non-model field)
- Child page 1
  - Show in menus
  - Custom menu name
  - Clearance number
- Products
  - Show in menus
  - Clearance number
  - The removal of a Content Card List block from the body StreamField
- Resources
  - Clearance number
- Stakeholders Engagement Program
  - Clearance number
  - The addition of an ImageBlock within the body StreamField

One commonality is that these **all** appeared to be changes that were made in revisions _subsequent to the first save of the page_. Is it possible that bulk publish is publishing the first revision, not the latest?

I looked at your PR and wondered if it's related to these lines, but I don't totally understand the intricacies of revisions.

```python
revision = page.save_revision(user=user)
revision.publish(user=user)
```

If you like, I can try to reproduce on a fresh installation of Wagtail tomorrow, but if my theory is correct, it should be as simple as:
1. Create a page and save its first draft
2. Go back in and check its `show_in_menus` checkbox and save another draft
3. Publish it via the bulk publishing mechanism
4. See that the `show_in_menus` field has reverted to unchecked
@Scotchester I can confirm I'm seeing the same thing when testing on bakerydemo:

* Create a blog page with "Initial revision" as its title and the content of all required fields; save as draft
* Change all fields to "updated revision"; save as draft again
* From the explorer, perform a bulk publish action of just that one page
* It gets published as "initial revision"

I suspect it's picking up the page content from the page's database record, rather than from the latest revision - for a page that's never been published, this will contain the initial state on creation.

Resolving patch:
diff --git a/wagtail/admin/views/pages/bulk_actions/page_bulk_action.py b/wagtail/admin/views/pages/bulk_actions/page_bulk_action.py
--- a/wagtail/admin/views/pages/bulk_actions/page_bulk_action.py
+++ b/wagtail/admin/views/pages/bulk_actions/page_bulk_action.py
@@ -28,6 +28,12 @@ def get_all_objects_in_listing_query(self, parent_id):
 
         return listing_objects
 
+    def object_context(self, obj):
+        context = super().object_context(obj)
+        # Make 'item' into the specific instance, so that custom get_admin_display_title methods are respected
+        context['item'] = context['item'].specific_deferred
+        return context
+
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context['items_with_no_access'] = [
diff --git a/wagtail/admin/views/pages/bulk_actions/publish.py b/wagtail/admin/views/pages/bulk_actions/publish.py
--- a/wagtail/admin/views/pages/bulk_actions/publish.py
+++ b/wagtail/admin/views/pages/bulk_actions/publish.py
@@ -34,16 +34,13 @@ def get_execution_context(self):
     def execute_action(cls, objects, include_descendants=False, user=None, **kwargs):
         num_parent_objects, num_child_objects = 0, 0
         for page in objects:
-            page = page.specific
-            revision = page.save_revision(user=user)
-            revision.publish(user=user)
+            page.get_latest_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():
-                        revision = draft_descendant_page.save_revision(user=user)
-                        revision.publish(user=user)
+                        draft_descendant_page.get_latest_revision().publish(user=user)
                         num_child_objects += 1
         return num_parent_objects, num_child_objects
 


Test patch:
diff --git a/wagtail/admin/tests/pages/test_bulk_actions/test_bulk_move.py b/wagtail/admin/tests/pages/test_bulk_actions/test_bulk_move.py
--- a/wagtail/admin/tests/pages/test_bulk_actions/test_bulk_move.py
+++ b/wagtail/admin/tests/pages/test_bulk_actions/test_bulk_move.py
@@ -71,11 +71,9 @@ def test_bulk_move(self):
 
         self.assertInHTML('<p>Are you sure you want to move these pages?</p>', html)
 
-        for child_page in self.pages_to_be_moved:
-            self.assertInHTML('<li><a href="{edit_page_url}" target="_blank" rel="noopener noreferrer">{page_title}</a></li>'.format(
-                edit_page_url=reverse('wagtailadmin_pages:edit', args=[child_page.id]),
-                page_title=child_page.title
-            ), html)
+        self.assertInHTML('<li><a href="{edit_page_url}" target="_blank" rel="noopener noreferrer">Hello world! (simple page)</a></li>'.format(
+            edit_page_url=reverse('wagtailadmin_pages:edit', args=[self.test_page_b.id]),
+        ), html)
 
     def test_bulk_move_bad_permissions(self):
         # Remove privileges from user
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
--- a/wagtail/admin/tests/pages/test_bulk_actions/test_bulk_publish.py
+++ b/wagtail/admin/tests/pages/test_bulk_actions/test_bulk_publish.py
@@ -28,6 +28,10 @@ def setUp(self):
         for child_page in self.child_pages:
             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.save_revision()
+
         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}&'
@@ -46,6 +50,9 @@ def test_publish_view(self):
         self.assertEqual(response.status_code, 200)
         self.assertTemplateUsed(response, 'wagtailadmin/pages/bulk_actions/confirm_bulk_publish.html')
 
+        # Page titles shown on the confirmation page should use SimplePage's custom get_admin_display_title method
+        self.assertContains(response, "Hello world!-1 (simple page)")
+
     def test_publish_view_invalid_page_id(self):
         """
         This tests that the publish view returns an error if the page id is invalid
@@ -96,7 +103,9 @@ def test_publish_view_post(self):
 
         # Check that the child pages were published
         for child_page in self.pages_to_be_published:
-            self.assertTrue(Page.objects.get(id=child_page.id).live)
+            published_page = SimplePage.objects.get(id=child_page.id)
+            self.assertTrue(published_page.live)
+            self.assertIn("Hello updated", published_page.content)
 
         # Check that the child pages not to be published remain
         for child_page in self.pages_not_to_be_published:
@@ -178,6 +187,10 @@ def setUp(self):
         for child_page in self.child_pages:
             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.save_revision()
+
         # map of the form { page: [child_pages] } to be added
         self.grandchildren_pages = {
             self.pages_to_be_published[0]: [
@@ -191,6 +204,11 @@ def setUp(self):
             for grandchild_page in grandchild_pages:
                 child_page.add_child(instance=grandchild_page)
 
+        for child_page, grandchild_pages in self.grandchildren_pages.items():
+            for grandchild_page in grandchild_pages:
+                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', )) + '?'
         for child_page in self.pages_to_be_published:
             self.url += f'&id={child_page.id}'
@@ -222,7 +240,9 @@ def test_publish_include_children_view_post(self):
 
         # Check that the child pages were published
         for child_page in self.pages_to_be_published:
-            self.assertTrue(Page.objects.get(id=child_page.id).live)
+            published_child_page = SimplePage.objects.get(id=child_page.id)
+            self.assertTrue(published_child_page.live)
+            self.assertIn("Hello updated", published_child_page.content)
 
         # Check that the child pages not to be published remain
         for child_page in self.pages_not_to_be_published:
@@ -230,7 +250,9 @@ def test_publish_include_children_view_post(self):
 
         for grandchild_pages in self.grandchildren_pages.values():
             for grandchild_page in grandchild_pages:
-                self.assertTrue(Page.objects.get(id=grandchild_page.id).live)
+                published_grandchild_page = SimplePage.objects.get(id=grandchild_page.id)
+                self.assertTrue(published_grandchild_page.live)
+                self.assertIn("Hello grandchild", published_grandchild_page.content)
 
     def test_publish_not_include_children_view_post(self):
         """
@@ -244,7 +266,9 @@ def test_publish_not_include_children_view_post(self):
 
         # Check that the child pages were published
         for child_page in self.pages_to_be_published:
-            self.assertTrue(Page.objects.get(id=child_page.id).live)
+            published_child_page = SimplePage.objects.get(id=child_page.id)
+            self.assertTrue(published_child_page.live)
+            self.assertIn("Hello updated", published_child_page.content)
 
         # Check that the descendant pages were not published
         for grandchild_pages in self.grandchildren_pages.values():