Repository: wagtail/wagtail (Python)

Issue:
Page/snippet cannot proceed in a workflow if it's locked by someone outside of the `GroupApprovalTask` group
<!--
Found a bug? Please fill out the sections below. 👍
-->

### Issue Summary

When a `GroupApprovalTask` is started while the page/snippet is locked by someone outside of the task's group, the object cannot proceed in the workflow as the lock is not properly released.

This is due to a logic error regression in d85db866ccdc8e936f475bb39a533b2f2d671565. The `LockableMixin` check should be done against `workflow_state.content_object`, not `workflow_state`.

<!--
A summary of the issue.
-->

### Steps to Reproduce

1. Spin up bakerydemo
2. Assign lock permission to the Editors group
3. Do not assign unlock permission to the Moderators group
4. As an editor, lock a page and then submit it to the moderators approval workflow
5. Log in as a moderator, and the page will be stuck in the locked state, leaving you unable to approve/reject it until someone unlocks it



Discussion:

Patch:
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 2f4e06df3d7f..93b526bc75ff 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -24,6 +24,7 @@ Changelog
  * Fix: Image and Document multiple upload update forms now correctly use the progress button (longrunning) behaviour when clicked (Loveth Omokaro)
  * Fix: Prevent audit log report from failing on missing models (Andy Chosak)
  * Fix: Ensure that the privacy collection privacy edit button is styled as a button (Jatin Kumar)
+ * Fix: Fix page/snippet cannot proceed a `GroupApprovalTask` if it's locked by someone outside of the group (Sage Abdullah)
  * Docs: Add code block to make it easier to understand contribution docs (Suyash Singh)
  * Docs: Add new "Icons" page for icons customisation and reuse across the admin interface (Coen van der Kamp)
  * Docs: Fix broken formatting for MultiFieldPanel / FieldRowPanel permission kwarg docs (Matt Westcott)
@@ -58,6 +59,7 @@ Changelog
  * Fix: Fix image uploads on storage backends that require file pointer to be at the start of the file (Matt Westcott)
  * Fix: Fix "Edit this page" missing from userbar (Satvik Vashisht)
  * Fix: Prevent audit log report from failing on missing models (Andy Chosak)
+ * Fix: Fix page/snippet cannot proceed a `GroupApprovalTask` if it's locked by someone outside of the group (Sage Abdullah)
 
 
 4.2 (06.02.2023)
diff --git a/docs/releases/4.2.1.md b/docs/releases/4.2.1.md
index 2394dae1a102..69898c68d718 100644
--- a/docs/releases/4.2.1.md
+++ b/docs/releases/4.2.1.md
@@ -17,3 +17,4 @@ depth: 1
  * Fix image uploads on storage backends that require file pointer to be at the start of the file (Matt Westcott)
  * Fix "Edit this page" missing from userbar (Satvik Vashisht)
  * Prevent audit log report from failing on missing models (Andy Chosak)
+ * Fix page/snippet cannot proceed a `GroupApprovalTask` if it's locked by someone outside of the group (Sage Abdullah)
diff --git a/docs/releases/5.0.md b/docs/releases/5.0.md
index bf452e8d633a..8d0720caab2f 100644
--- a/docs/releases/5.0.md
+++ b/docs/releases/5.0.md
@@ -37,6 +37,7 @@ depth: 1
  * Image and Document multiple upload update forms now correctly use the progress button (longrunning) behaviour when clicked (Loveth Omokaro)
  * Prevent audit log report from failing on missing models (Andy Chosak)
  * Ensure that the privacy collection privacy edit button is styled as a button (Jatin Kumar)
+ * Fix page/snippet cannot proceed a `GroupApprovalTask` if it's locked by someone outside of the group (Sage Abdullah)
 
 ### Documentation
 
diff --git a/wagtail/models/__init__.py b/wagtail/models/__init__.py
index fa409c0a5690..2af3fe3b5c0a 100644
--- a/wagtail/models/__init__.py
+++ b/wagtail/models/__init__.py
@@ -3854,7 +3854,7 @@ class GroupApprovalTask(Task):
 
     def start(self, workflow_state, user=None):
         if (
-            isinstance(workflow_state, LockableMixin)
+            isinstance(workflow_state.content_object, LockableMixin)
             and workflow_state.content_object.locked_by
         ):
             # If the person who locked the object isn't in one of the groups, unlock the object


Test patch:
diff --git a/wagtail/tests/test_workflow.py b/wagtail/tests/test_workflow.py
index b59bec4b1f4d..d4a385bb4d5a 100644
--- a/wagtail/tests/test_workflow.py
+++ b/wagtail/tests/test_workflow.py
@@ -7,6 +7,7 @@
 from django.core.exceptions import ValidationError
 from django.db.utils import IntegrityError
 from django.test import TestCase, override_settings
+from django.utils import timezone
 from freezegun import freeze_time
 
 from wagtail.models import (
@@ -21,6 +22,7 @@
     WorkflowTask,
 )
 from wagtail.test.testapp.models import FullFeaturedSnippet, ModeratedModel, SimplePage
+from wagtail.test.utils.wagtail_tests import WagtailTestUtils
 
 
 class TestWorkflowModels(TestCase):
@@ -116,7 +118,7 @@ def test_get_workflow_from_closest_ancestor(self):
         self.assertTrue(workflow_2.all_pages().filter(id=goodbye_page.id).exists())
 
 
-class TestPageWorkflows(TestCase):
+class TestPageWorkflows(WagtailTestUtils, TestCase):
     fixtures = ["test.json"]
 
     @classmethod
@@ -416,6 +418,37 @@ def test_tasks_with_state(self):
             )[0],
         )
 
+    def test_start_workflow_group_approval_task_locked(self):
+        self.object.locked = True
+        self.object.locked_at = timezone.now()
+        self.object.locked_by = self.create_user("user1")
+        self.object.save()
+
+        # Create a workflow with one group approval task for the moderators group
+        moderators = Group.objects.get(name="Moderators")
+        workflow = Workflow.objects.create(name="test_workflow_foo")
+        task_1 = GroupApprovalTask.objects.create(name="test_task_1")
+        task_1.groups.add(moderators)
+        WorkflowTask.objects.create(workflow=workflow, task=task_1, sort_order=1)
+
+        # The object was locked by a non-moderator
+        self.assertFalse(self.object.locked_by.groups.filter(id=moderators.id).exists())
+
+        # Start the workflow as another user
+        self.object.save_revision()
+        workflow_state = workflow.start(self.object, self.create_user("user2"))
+
+        self.assertEqual(workflow_state.workflow, workflow)
+        self.assertEqual(workflow_state.content_object, self.object)
+        self.assertEqual(workflow_state.status, "in_progress")
+
+        self.object.refresh_from_db()
+
+        # The lock should be removed as otherwise the object would be stuck
+        self.assertFalse(self.object.locked)
+        self.assertIsNone(self.object.locked_at)
+        self.assertIsNone(self.object.locked_by)
+
 
 class TestSnippetWorkflows(TestPageWorkflows):
     fixtures = None
@@ -428,3 +461,21 @@ def setUpTestData(cls):
 
 class TestSnippetWorkflowsNotLockable(TestSnippetWorkflows):
     model = ModeratedModel
+
+    def test_start_workflow_group_approval_task_locked(self):
+        # Test normal GroupApprovalTask.start() as the object is not lockable
+
+        # Create a workflow with one group approval task for the moderators group
+        moderators = Group.objects.get(name="Moderators")
+        workflow = Workflow.objects.create(name="test_workflow_foo")
+        task_1 = GroupApprovalTask.objects.create(name="test_task_1")
+        task_1.groups.add(moderators)
+        WorkflowTask.objects.create(workflow=workflow, task=task_1, sort_order=1)
+
+        # Start the workflow
+        self.object.save_revision()
+        workflow_state = workflow.start(self.object, self.create_user("user2"))
+
+        self.assertEqual(workflow_state.workflow, workflow)
+        self.assertEqual(workflow_state.content_object, self.object)
+        self.assertEqual(workflow_state.status, "in_progress")