Repository: wagtail/wagtail

Issue:
Cannot edit a page/snippet after cancelling a workflow until the next reload
<!--
Found a bug? Please fill out the sections below. 👍
-->

### Issue Summary

If you are not a superuser and you submit a page/snippet to a workflow and cancel the workflow, the page will be in a locked state and you will not be able to edit it until you reload the page. In addition, an error message saying "The page could not be saved as it is locked" is shown.

There are some nuances to this issue:

- In older releases, the page was not locked but the error message is shown, and it says "The page cannot be saved due to validation errors". I haven't verified how far back this goes, but this existed in 4.0-prerelease, so I'd assume it's been there since workflows were introduced.

  <details>

  <summary>Screenshot</summary>

  <img width="860" alt="image" src="https://user-images.githubusercontent.com/6379424/215728232-46bcd71f-c1e3-4b1a-b48c-112bb0f42d87.png">

  </details>

- Then, in 315d9a938a, the error message was changed to "The page could not be saved as it is locked" when the `Lock` objects were introduced.

  <details>

  <summary>Screenshot</summary>

  <img width="860" alt="image" src="https://user-images.githubusercontent.com/6379424/215733212-b1ea2622-1867-4f18-b229-123edade8a37.png">

  </details>



- In 40c1227edcfb516f052bc26325370d17bd4cebb0, the action menu uses the `Lock` objects to determine the locked state. This results in the page getting "locked" and you cannot edit the page. If you reload the page, the editor loads normally.

  <details>

  <summary>Screenshot</summary>

  <img width="860" alt="image" src="https://user-images.githubusercontent.com/6379424/215736105-3b7e8aa9-19ef-4690-b748-653f6fac0dc0.png">


  </details> 


I believe this is because of the reuse of a stale lock object. We get the lock object at the start of the view's request-response cycle. When the workflow is cancelled, the lock object is still referencing the stale `WorkflowLock`. The view doesn't return a redirect response, so the stale lock object is reused in the context to render the template.

### Steps to Reproduce

1. Spin up bakerydemo
2. Add a user with the Editor role
3. Log in as the editor user
4. Submit a page to a workflow
5. Cancel the workflow
6. See result

Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?

The page shouldn't be locked, and the error message shouldn't be shown, because the "Cancel workflow" action is a valid action in this case.


Discussion:
Fixed in #9994

Resolving patch:
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
@@ -802,7 +802,12 @@ def form_invalid(self, form):
             self.workflow_state.cancel(user=self.request.user)
             self.add_cancel_workflow_confirmation_message()
 
-        if self.locked_for_user:
+            # Refresh the lock object as now WorkflowLock no longer applies
+            self.lock = self.page.get_lock()
+            self.locked_for_user = self.lock is not None and self.lock.for_user(
+                self.request.user
+            )
+        elif self.locked_for_user:
             messages.error(
                 self.request, _("The page could not be saved as it is locked")
             )


Test patch:
diff --git a/wagtail/admin/tests/test_workflows.py b/wagtail/admin/tests/test_workflows.py
--- a/wagtail/admin/tests/test_workflows.py
+++ b/wagtail/admin/tests/test_workflows.py
@@ -876,17 +876,45 @@ def create_workflow_and_tasks(self):
         WorkflowTask.objects.create(workflow=workflow, task=task_2, sort_order=2)
         return workflow, task_1, task_2
 
-    def submit(self):
+    def post(self, action, data=None, **kwargs):
         post_data = {
             "title": str(self.page.title),
             "slug": str(self.page.slug),
             "content": str(self.page.content),
-            "action-submit": "True",
+            f"action-{action}": "True",
         }
+        if data:
+            post_data.update(data)
         return self.client.post(
-            reverse("wagtailadmin_pages:edit", args=(self.page.id,)), post_data
+            reverse("wagtailadmin_pages:edit", args=(self.page.id,)),
+            post_data,
+            **kwargs,
+        )
+
+    def submit(self):
+        return self.post("submit")
+
+    def workflow_action(self, action, data=None, **kwargs):
+        return self.client.post(
+            reverse(
+                "wagtailadmin_pages:workflow_action",
+                args=(
+                    self.page.id,
+                    action,
+                    self.page.current_workflow_task_state.id,
+                ),
+            ),
+            data,
+            follow=True,
+            **kwargs,
         )
 
+    def approve(self, data=None, **kwargs):
+        return self.workflow_action("approve", data, **kwargs)
+
+    def reject(self, data=None, **kwargs):
+        return self.workflow_action("reject", data, **kwargs)
+
     def test_submit_for_approval_creates_states(self):
         """Test that WorkflowState and TaskState objects are correctly created when a Page is submitted for approval"""
 
@@ -921,6 +949,160 @@ def test_submit_for_approval_changes_status_in_status_side_panel_meta(self):
         )
         self.assertNotContains(response, "Draft")
 
+    def test_workflow_action_menu_items(self):
+        edit_url = reverse("wagtailadmin_pages:edit", args=(self.page.id,))
+
+        # Initial view as the submitter, should only see save and submit buttons
+        response = self.client.get(edit_url)
+        self.assertContains(response, "Save draft")
+        self.assertContains(response, "Submit to test_workflow")
+        self.assertNotContains(response, "Cancel workflow")
+        self.assertNotContains(response, "Restart workflow")
+        self.assertNotContains(response, "Approve")
+        self.assertNotContains(response, "Request changes")
+        self.assertNotContains(
+            response,
+            '<button type="submit" class="button action-save " disabled>',
+        )
+
+        # submit for approval
+        self.post("submit")
+
+        # After submit, as a submitter, should only see cancel and locked buttons
+        response = self.client.get(edit_url)
+        self.assertNotContains(response, "Save draft")
+        self.assertNotContains(response, "Submit to test_workflow")
+        self.assertContains(response, "Cancel workflow")
+        self.assertNotContains(response, "Restart workflow")
+        self.assertNotContains(response, "Approve")
+        self.assertNotContains(response, "Request changes")
+        self.assertContains(
+            response,
+            '<button type="submit" class="button action-save " disabled>',
+        )
+
+        # After submit, as a moderator, should only see save, approve, and reject buttons
+        self.login(self.moderator)
+        response = self.client.get(edit_url)
+        self.assertContains(response, "Save draft")
+        self.assertNotContains(response, "Submit to test_workflow")
+        self.assertNotContains(response, "Cancel workflow")
+        self.assertNotContains(response, "Restart workflow")
+        self.assertContains(response, "Approve")
+        self.assertContains(response, "Request changes")
+        self.assertNotContains(
+            response,
+            '<button type="submit" class="button action-save " disabled>',
+        )
+
+        self.reject()
+
+        # After reject, as a submitter, should only see save, cancel, and restart buttons
+        self.login(self.submitter)
+        response = self.client.get(edit_url)
+        self.assertContains(response, "Save draft")
+        self.assertNotContains(response, "Submit to test_workflow")
+        self.assertContains(response, "Cancel workflow")
+        self.assertContains(response, "Restart workflow")
+        self.assertNotContains(response, "Approve")
+        self.assertNotContains(response, "Request changes")
+        self.assertNotContains(
+            response,
+            '<button type="submit" class="button action-save " disabled>',
+        )
+
+        # After cancel, as a submitter, should only see save and submit buttons
+        response = self.post("cancel-workflow", follow=True)
+        self.assertContains(response, "Save draft")
+        self.assertContains(response, "Submit to test_workflow")
+        self.assertNotContains(response, "Cancel workflow")
+        self.assertNotContains(response, "Restart workflow")
+        self.assertNotContains(response, "Approve")
+        self.assertNotContains(response, "Request changes")
+        self.assertNotContains(
+            response,
+            '<button type="submit" class="button action-save " disabled>',
+        )
+
+    def test_workflow_action_menu_items_when_reverting(self):
+        old_revision = self.page.latest_revision
+        revert_url = reverse(
+            "wagtailadmin_pages:revisions_revert", args=(self.page.id, old_revision.id)
+        )
+
+        # Initial view as the submitter, should only see save button
+        response = self.client.get(revert_url)
+        self.assertContains(response, "Replace current draft")
+        self.assertNotContains(response, "Submit to test_workflow")
+        self.assertNotContains(response, "Cancel workflow")
+        self.assertNotContains(response, "Restart workflow")
+        self.assertNotContains(response, "Approve")
+        self.assertNotContains(response, "Request changes")
+        self.assertNotContains(
+            response,
+            '<button type="submit" class="button action-save warning" disabled>',
+        )
+
+        # submit for approval
+        self.post("submit")
+
+        # After submit, as a submitter, should only see locked button
+        response = self.client.get(revert_url)
+        self.assertNotContains(response, "Replace current draft")
+        self.assertNotContains(response, "Submit to test_workflow")
+        self.assertNotContains(response, "Cancel workflow")
+        self.assertNotContains(response, "Restart workflow")
+        self.assertNotContains(response, "Approve")
+        self.assertNotContains(response, "Request changes")
+        self.assertContains(
+            response,
+            '<button type="submit" class="button action-save warning" disabled>',
+        )
+
+        # After submit, as a moderator, should only see save button
+        self.login(self.moderator)
+        response = self.client.get(revert_url)
+        self.assertContains(response, "Replace current draft")
+        self.assertNotContains(response, "Submit to test_workflow")
+        self.assertNotContains(response, "Cancel workflow")
+        self.assertNotContains(response, "Restart workflow")
+        self.assertNotContains(response, "Approve")
+        self.assertNotContains(response, "Request changes")
+        self.assertNotContains(
+            response,
+            '<button type="submit" class="button action-save warning" disabled>',
+        )
+
+        self.reject()
+
+        # After reject, as a submitter, should only see save button
+        self.login(self.submitter)
+        response = self.client.get(revert_url)
+        self.assertContains(response, "Replace current draft")
+        self.assertNotContains(response, "Submit to test_workflow")
+        self.assertNotContains(response, "Cancel workflow")
+        self.assertNotContains(response, "Restart workflow")
+        self.assertNotContains(response, "Approve")
+        self.assertNotContains(response, "Request changes")
+        self.assertNotContains(
+            response,
+            '<button type="submit" class="button action-save warning" disabled>',
+        )
+
+        # After cancel, as a submitter, should only see save button
+        self.post("cancel-workflow")
+        response = self.client.get(revert_url)
+        self.assertContains(response, "Replace current draft")
+        self.assertNotContains(response, "Submit to test_workflow")
+        self.assertNotContains(response, "Cancel workflow")
+        self.assertNotContains(response, "Restart workflow")
+        self.assertNotContains(response, "Approve")
+        self.assertNotContains(response, "Request changes")
+        self.assertNotContains(
+            response,
+            '<button type="submit" class="button action-save warning" disabled>',
+        )
+
     @override_settings(WAGTAILADMIN_BASE_URL="http://admin.example.com")
     def test_submit_sends_mail(self):
         self.submit()
@@ -1052,8 +1234,10 @@ def test_cancel_workflow(self):
             "content": str(self.page.content),
             "action-cancel-workflow": "True",
         }
-        self.client.post(
-            reverse("wagtailadmin_pages:edit", args=(self.page.id,)), post_data
+        response = self.client.post(
+            reverse("wagtailadmin_pages:edit", args=(self.page.id,)),
+            post_data,
+            follow=True,
         )
         workflow_state.refresh_from_db()
 
@@ -1063,6 +1247,19 @@ def test_cancel_workflow(self):
             workflow_state.current_task_state.status, TaskState.STATUS_CANCELLED
         )
 
+        self.assertNotContains(
+            response,
+            '<button type="submit" class="button action-save " disabled>',
+        )
+        self.assertNotContains(
+            response,
+            "The page could not be saved as it is locked",
+        )
+        self.assertNotContains(
+            response,
+            "The page could not be saved due to validation errors",
+        )
+
     def test_email_headers(self):
         # Submit
         self.submit()