from django.contrib.admin.utils import quote
from django.contrib.auth import get_permission_codename
from django.contrib.auth.models import Group, Permission
from django.test import TestCase, override_settings
from django.urls import NoReverseMatch, reverse
from django.utils import timezone
from wagtail.admin.utils import get_user_display_name
from wagtail.locks import WorkflowLock
from wagtail.models import GroupApprovalTask, Workflow, WorkflowTask
from wagtail.test.testapp.models import (
Advert,
DraftStateModel,
FullFeaturedSnippet,
LockableModel,
)
from wagtail.test.utils import WagtailTestUtils
class BaseLockingTestCase(WagtailTestUtils, TestCase):
model = LockableModel
def setUp(self):
self.user = self.login()
self.snippet = self.model.objects.create(text="I'm a lockable snippet!")
@property
def model_name(self):
return self.model._meta.verbose_name
def get_url(self, name, args=None):
args = args if args is not None else [quote(self.snippet.pk)]
return reverse(self.snippet.snippet_viewset.get_url_name(name), args=args)
def lock_snippet(self, user=None):
self.snippet.locked = True
self.snippet.locked_by = user
self.snippet.locked_at = timezone.now()
self.snippet.save()
def refresh_snippet(self):
self.snippet.refresh_from_db()
def set_permissions(self, permission_names, user=None):
if user is None:
user = self.user
user.is_superuser = False
permissions = [
Permission.objects.get(
content_type__app_label="wagtailadmin",
codename="access_admin",
)
]
for name in permission_names:
permissions.append(
Permission.objects.get(
content_type__app_label="tests",
codename=get_permission_codename(name, self.model._meta),
)
)
user.user_permissions.set(permissions)
user.save()
class DraftStateModelTestCase:
model = DraftStateModel
def refresh_snippet(self):
self.snippet.refresh_from_db()
self.snippet = self.snippet.get_latest_revision_as_object()
class TestLocking(BaseLockingTestCase):
def test_lock_post(self):
response = self.client.post(self.get_url("lock"))
self.refresh_snippet()
# Check response
self.assertRedirects(response, self.get_url("edit"))
# Check that the snippet is locked
self.assertTrue(self.snippet.locked)
self.assertEqual(self.snippet.locked_by, self.user)
self.assertIsNotNone(self.snippet.locked_at)
def test_lock_get(self):
response = self.client.get(self.get_url("lock"))
self.refresh_snippet()
# Check response
self.assertEqual(response.status_code, 405)
# Check that the snippet is still unlocked
self.assertFalse(self.snippet.locked)
self.assertIsNone(self.snippet.locked_by)
self.assertIsNone(self.snippet.locked_at)
def test_lock_post_already_locked(self):
# Lock the snippet
self.lock_snippet(self.user)
response = self.client.post(self.get_url("lock"))
self.refresh_snippet()
# Check response
self.assertRedirects(response, self.get_url("edit"))
# Check that the snippet is still locked
self.assertTrue(self.snippet.locked)
self.assertEqual(self.snippet.locked_by, self.user)
self.assertIsNotNone(self.snippet.locked_at)
def test_lock_post_with_good_redirect(self):
next_url = self.get_url("list", args=[])
response = self.client.post(self.get_url("lock"), {"next": next_url})
self.refresh_snippet()
# Check response
self.assertRedirects(response, next_url)
# Check that the snippet is locked
self.assertTrue(self.snippet.locked)
self.assertEqual(self.snippet.locked_by, self.user)
self.assertIsNotNone(self.snippet.locked_at)
def test_lock_post_with_bad_redirect(self):
response = self.client.post(
self.get_url("lock"),
{"next": "http://www.google.co.uk"},
)
self.refresh_snippet()
# Check response
self.assertRedirects(response, self.get_url("edit"))
# Check that the snippet is locked
self.assertTrue(self.snippet.locked)
self.assertEqual(self.snippet.locked_by, self.user)
self.assertIsNotNone(self.snippet.locked_at)
def test_lock_post_bad_snippet(self):
response = self.client.post(self.get_url("edit", args=[quote(9999999)]))
self.refresh_snippet()
# Check response
self.assertEqual(response.status_code, 404)
# Check that the snippet is still unlocked
self.assertFalse(self.snippet.locked)
self.assertIsNone(self.snippet.locked_by)
self.assertIsNone(self.snippet.locked_at)
def test_lock_post_not_enabled_snippet(self):
self.snippet = Advert.objects.create(text="I'm a non-lockable snippet!")
with self.assertRaises(NoReverseMatch):
self.client.post(self.get_url("lock"))
def test_lock_post_bad_permissions(self):
# Remove privileges from user
self.set_permissions([])
response = self.client.post(self.get_url("lock"))
self.refresh_snippet()
# Check response
self.assertRedirects(response, reverse("wagtailadmin_home"))
# Check that the snippet is still unlocked
self.assertFalse(self.snippet.locked)
self.assertIsNone(self.snippet.locked_by)
self.assertIsNone(self.snippet.locked_at)
def test_unlock_post(self):
# Lock the snippet
self.lock_snippet(self.user)
response = self.client.post(self.get_url("unlock"))
self.refresh_snippet()
# Check response
self.assertRedirects(response, self.get_url("edit"))
# Check that the snippet is unlocked
self.assertFalse(self.snippet.locked)
self.assertIsNone(self.snippet.locked_by)
self.assertIsNone(self.snippet.locked_at)
def test_unlock_get(self):
# Lock the snippet
self.lock_snippet(self.user)
response = self.client.get(self.get_url("unlock"))
self.refresh_snippet()
# Check response
self.assertEqual(response.status_code, 405)
# Check that the snippet is still locked
self.assertTrue(self.snippet.locked)
self.assertEqual(self.snippet.locked_by, self.user)
self.assertIsNotNone(self.snippet.locked_at)
def test_unlock_post_already_unlocked(self):
response = self.client.post(self.get_url("unlock"))
self.refresh_snippet()
# Check response
self.assertRedirects(response, self.get_url("edit"))
# Check that the snippet is still unlocked
self.assertFalse(self.snippet.locked)
self.assertIsNone(self.snippet.locked_by)
self.assertIsNone(self.snippet.locked_at)
def test_unlock_post_with_good_redirect(self):
# Lock the snippet
self.lock_snippet(self.user)
next_url = self.get_url("list", args=[])
response = self.client.post(self.get_url("unlock"), {"next": next_url})
self.refresh_snippet()
# Check response
self.assertRedirects(response, next_url)
# Check that the snippet is unlocked
self.assertFalse(self.snippet.locked)
self.assertIsNone(self.snippet.locked_by)
self.assertIsNone(self.snippet.locked_at)
def test_unlock_post_with_bad_redirect(self):
# Lock the snippet
self.lock_snippet(self.user)
response = self.client.post(
self.get_url("unlock"),
{"next": "http://www.google.co.uk"},
)
self.refresh_snippet()
# Check response
self.assertRedirects(response, self.get_url("edit"))
# Check that the snippet is unlocked
self.assertFalse(self.snippet.locked)
self.assertIsNone(self.snippet.locked_by)
self.assertIsNone(self.snippet.locked_at)
def test_unlock_post_bad_snippet(self):
# Lock the snippet
self.lock_snippet(self.user)
response = self.client.post(self.get_url("unlock", args=[quote(9999999)]))
self.refresh_snippet()
# Check response
self.assertEqual(response.status_code, 404)
# Check that the snippet is still locked
self.assertTrue(self.snippet.locked)
self.assertEqual(self.snippet.locked_by, self.user)
self.assertIsNotNone(self.snippet.locked_at)
def test_unlock_post_not_enabled_snippet(self):
self.snippet = Advert.objects.create(text="I'm a non-lockable snippet!")
with self.assertRaises(NoReverseMatch):
self.client.post(self.get_url("unlock"))
def test_unlock_post_bad_permissions(self):
# Remove privileges from user
self.user.is_superuser = False
self.user.groups.add(Group.objects.get(name="Editors"))
self.user.save()
# Lock the snippet
self.lock_snippet(self.create_user("user2"))
response = self.client.post(self.get_url("unlock"))
self.refresh_snippet()
# Check response
self.assertEqual(response.status_code, 302)
# Check that the snippet is still locked
self.assertTrue(self.snippet.locked)
self.assertIsNotNone(self.snippet.locked_at)
def test_unlock_post_own_snippet_with_bad_permissions(self):
"""User can unlock a snippet they have locked without the unlock permission."""
# Remove privileges from user
self.user.is_superuser = False
self.user.groups.add(Group.objects.get(name="Editors"))
self.user.save()
# Lock the snippet
self.lock_snippet(self.user)
next_url = reverse("wagtailadmin_home")
response = self.client.post(self.get_url("unlock"), {"next": next_url})
self.refresh_snippet()
# Check response
self.assertRedirects(response, next_url)
# Check that the snippet is not locked
self.assertFalse(self.snippet.locked)
self.assertIsNone(self.snippet.locked_by)
self.assertIsNone(self.snippet.locked_at)
class TestLockingWithDraftState(DraftStateModelTestCase, TestLocking):
pass
class TestEditLockedSnippet(BaseLockingTestCase):
save_button_label = "Save"
def test_edit_post_locked_by_another_user(self):
"""A user cannot edit a snippet that is locked by another user."""
# Lock the snippet
self.lock_snippet(self.create_user("user2"))
# Try to edit the snippet
response = self.client.post(
self.get_url("edit"),
{"text": "Edited while locked"},
)
self.refresh_snippet()
# Should show lock message
self.assertContains(
response,
f"The {self.model_name} could not be saved as it is locked",
)
# Check that the snippet is still locked
self.assertTrue(self.snippet.locked)
# Check that the snippet is not edited
self.assertEqual(self.snippet.text, "I'm a lockable snippet!")
def test_edit_post_locked_by_self(self):
"""A user can edit a snippet that is locked by themselves."""
# Lock the snippet
self.lock_snippet(self.user)
# Try to edit the snippet
response = self.client.post(
self.get_url("edit"),
{"text": "Edited while locked"},
follow=True,
)
self.refresh_snippet()
# Should not show error message
self.assertNotContains(
response,
f"The {self.model_name} could not be saved as it is locked",
)
# Check that the snippet is still locked
self.assertTrue(self.snippet.locked)
# Check that the snippet is edited
self.assertEqual(self.snippet.text, "Edited while locked")
@override_settings(WAGTAILADMIN_GLOBAL_EDIT_LOCK=True)
def test_edit_post_locked_by_self_with_global_lock_enabled(self):
"""A user cannot edit a snippet that is locked by themselves if the setting is enabled."""
# Lock the snippet
self.lock_snippet(self.user)
# Try to edit the snippet
response = self.client.post(
self.get_url("edit"),
{"text": "Edited while locked"},
)
self.refresh_snippet()
# Should show lock message
self.assertContains(
response,
f"The {self.model_name} could not be saved as it is locked",
)
# Check that the snippet is still locked
self.assertTrue(self.snippet.locked)
# Check that the snippet is not edited
self.assertEqual(self.snippet.text, "I'm a lockable snippet!")
def test_edit_get_locked_by_self(self):
"""A user can edit and unlock a snippet that is locked by themselves."""
cases = [
(["change", "unlock"]),
(["change"]), # Can unlock even without unlock permission
]
for permissions in cases:
with self.subTest(
"User can edit and unlock an object they have locked",
permissions=permissions,
):
# Lock the snippet
self.lock_snippet(self.user)
# Use the specified permissions
self.set_permissions(permissions)
# Get the edit page
response = self.client.get(self.get_url("edit"))
html = response.content.decode()
unlock_url = self.get_url("unlock")
# Should show lock message
self.assertContains(
response,
"'I'm a lockable snippet!' was locked by you on",
)
# Should show Save action menu item
self.assertContains(
response,
f'{self.save_button_label}',
html=True,
)
# Should not show Locked action menu item
self.assertTagInHTML(
'',
html,
count=0,
allow_extra_attrs=True,
)
# Should show lock information in the side panel
self.assertContains(
response,
f"Only you can make changes while the {self.model_name} is locked",
)
# Should show unlock toggle in the side panel
self.assertTagInHTML(
f'',
html,
count=1,
allow_extra_attrs=True,
)
# Should show unlock button in the message
self.assertTagInHTML(
f'',
html,
count=1,
allow_extra_attrs=True,
)
def test_edit_get_locked_by_another_user_has_unlock_permission(self):
"""A user needs to unlock a snippet that's locked by another user in order to edit it."""
user = self.create_user("user2")
# Lock the snippet
self.lock_snippet(user)
# Use edit and unlock permissions
self.set_permissions(["change", "unlock"])
# Get the edit page
response = self.client.get(self.get_url("edit"))
html = response.content.decode()
unlock_url = self.get_url("unlock")
display_name = get_user_display_name(user)
# Should show lock message
self.assertContains(
response,
f"'I'm a lockable snippet!' was locked by {user} on",
)
# Should show lock information in the side panel
self.assertContains(
response,
f"Only {display_name} can make changes while the {self.model_name} is locked",
)
# Should not show Save action menu item
self.assertNotContains(
response,
f'{self.save_button_label}',
html=True,
)
# Should show Locked action menu item
self.assertTagInHTML(
'',
html,
count=1,
allow_extra_attrs=True,
)
# Should show unlock toggle in the side panel
self.assertTagInHTML(
f'',
html,
count=1,
allow_extra_attrs=True,
)
# Should show unlock button in the message
self.assertTagInHTML(
f'',
html,
count=1,
allow_extra_attrs=True,
)
def test_edit_get_locked_by_another_user_no_unlock_permission(self):
"""
A different user cannot unlock the object without the unlock permission.
"""
user = self.create_user("user2")
# Lock the snippet
self.lock_snippet(user)
# Use edit permission only
self.set_permissions(["change"])
# Get the edit page
response = self.client.get(self.get_url("edit"))
html = response.content.decode()
unlock_url = self.get_url("unlock")
display_name = get_user_display_name(user)
# Should show lock message
self.assertContains(
response,
f"'I'm a lockable snippet!' was locked by {user} on",
)
# Should show lock information in the side panel
self.assertContains(
response,
f"Only {display_name} can make changes while the {self.model_name} is locked",
)
# Should not show instruction to unlock
self.assertNotContains(response, "Unlock")
# Should not show Save action menu item
self.assertNotContains(
response,
f'{self.save_button_label}',
html=True,
)
# Should show Locked action menu item
self.assertTagInHTML(
'',
html,
count=1,
allow_extra_attrs=True,
)
# Should not show unlock toggle in the side panel
self.assertTagInHTML(
f'',
html,
count=0,
allow_extra_attrs=True,
)
# Should not show unlock button in the message
self.assertTagInHTML(
f'',
html,
count=0,
allow_extra_attrs=True,
)
def test_edit_get_unlocked_no_lock_permission(self):
"""A user cannot lock an object without the lock permission."""
# Use edit permission only
self.set_permissions(["change"])
# Get the edit page
response = self.client.get(self.get_url("edit"))
html = response.content.decode()
lock_url = self.get_url("lock")
# Should not show lock message
self.assertNotContains(
response,
"'I'm a lockable snippet!' was locked",
)
# Should show unlocked information in the side panel
self.assertContains(
response,
f"Anyone can edit this {self.model_name}",
)
# Should not show info to lock the object in the side panel
self.assertNotContains(
response,
"lock it to prevent others from editing",
)
# Should show Save action menu item
self.assertContains(
response,
f'{self.save_button_label}',
html=True,
)
# Should not show Locked action menu item
self.assertTagInHTML(
'',
html,
count=0,
allow_extra_attrs=True,
)
# Should not show lock toggle in the side panel
self.assertTagInHTML(
f'',
html,
count=0,
allow_extra_attrs=True,
)
# Should not show lock button in the message
self.assertTagInHTML(
f'',
html,
count=0,
allow_extra_attrs=True,
)
def test_edit_get_unlocked_has_lock_permission(self):
"""A user can lock an object with the lock permission."""
# Use edit and lock permissions
self.set_permissions(["change", "lock"])
# Get the edit page
response = self.client.get(self.get_url("edit"))
html = response.content.decode()
lock_url = self.get_url("lock")
# Should not show lock message
self.assertNotContains(
response,
"'I'm a lockable snippet!' was locked",
)
# Should show unlocked information in the side panel
self.assertContains(
response,
f"Anyone can edit this {self.model_name} – lock it to prevent others from editing",
)
# Should show Save action menu item
self.assertContains(
response,
f'{self.save_button_label}',
html=True,
)
# Should not show Locked action menu item
self.assertTagInHTML(
'',
html,
count=0,
allow_extra_attrs=True,
)
# Should show lock toggle in the side panel
self.assertTagInHTML(
f'',
html,
count=1,
allow_extra_attrs=True,
)
class TestEditLockedDraftStateSnippet(DraftStateModelTestCase, TestEditLockedSnippet):
save_button_label = "Save draft"
class TestWorkflowLock(BaseLockingTestCase):
model = FullFeaturedSnippet
def setUp(self):
super().setUp()
self.snippet.save_revision()
self.moderator = self.create_user("moderator")
self.moderators = Group.objects.get(name="Moderators")
self.moderator.groups.add(self.moderators)
self.set_permissions(["change"], user=self.user)
self.set_permissions(["change", "publish"], user=self.moderator)
def test_when_locked_by_workflow(self):
workflow = Workflow.objects.create(name="test_workflow")
task = GroupApprovalTask.objects.create(name="test_task")
task.groups.add(self.moderators)
WorkflowTask.objects.create(workflow=workflow, task=task, sort_order=1)
workflow.start(self.snippet, self.user)
lock = self.snippet.get_lock()
self.assertIsInstance(lock, WorkflowLock)
self.assertTrue(lock.for_user(self.user))
self.assertFalse(lock.for_user(self.moderator))
self.assertEqual(
lock.get_message(self.user),
"This full-featured snippet is currently awaiting moderation. "
"Only reviewers for this task can edit the full-featured snippet.",
)
self.assertIsNone(lock.get_message(self.moderator))
# When visiting a snippet in a workflow with multiple tasks, the message
# displayed to users changes to show the current task the snippet is on
# Add a second task to the workflow
other_task = GroupApprovalTask.objects.create(name="another_task")
WorkflowTask.objects.create(workflow=workflow, task=other_task, sort_order=2)
lock = self.snippet.get_lock()
self.assertEqual(
lock.get_message(self.user),
"This full-featured snippet is awaiting 'test_task' in the "
"'test_workflow' workflow. Only reviewers for this task "
"can edit the full-featured snippet.",
)