From f8564055b1114161f7fb662eb1093b03439f0bcd Mon Sep 17 00:00:00 2001 From: Sage Abdullah Date: Fri, 19 Jul 2024 11:30:48 +0100 Subject: [PATCH] Add WAGTAIL_EDITING_SESSION_PING_INTERVAL setting --- .../src/controllers/SessionController.test.js | 22 +++ client/src/controllers/SessionController.ts | 4 + docs/reference/settings.md | 10 ++ .../shared/editing_sessions/module.html | 1 + wagtail/admin/tests/test_editing_sessions.py | 158 +++++++++++++++++- wagtail/admin/ui/editing_sessions.py | 8 + 6 files changed, 201 insertions(+), 2 deletions(-) diff --git a/client/src/controllers/SessionController.test.js b/client/src/controllers/SessionController.test.js index 2417729634..b0eaca01f4 100644 --- a/client/src/controllers/SessionController.test.js +++ b/client/src/controllers/SessionController.test.js @@ -67,6 +67,17 @@ describe('SessionController', () => { handlePing.mockClear(); jest.advanceTimersByTime(123456); expect(handlePing).toHaveBeenCalledTimes(6); + + // Setting it to 0 should stop the interval + handlePing.mockClear(); + element.setAttribute('data-w-session-interval-value', '0'); + await Promise.resolve(); + + jest.advanceTimersByTime(20000); + expect(handlePing).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(20000); + expect(handlePing).toHaveBeenCalledTimes(0); + handlePing.mockClear(); }); it('should allow setting a custom interval value on init and changing it afterwards', async () => { @@ -100,6 +111,17 @@ describe('SessionController', () => { handlePing.mockClear(); jest.advanceTimersByTime(123456); expect(handlePing).toHaveBeenCalledTimes(8); + + // Setting it to >= 2**31 should stop the interval + handlePing.mockClear(); + element.setAttribute('data-w-session-interval-value', `${2 ** 31}`); + await Promise.resolve(); + + jest.advanceTimersByTime(20000); + expect(handlePing).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(20000); + expect(handlePing).toHaveBeenCalledTimes(0); + handlePing.mockClear(); }); }); diff --git a/client/src/controllers/SessionController.ts b/client/src/controllers/SessionController.ts index e8175ebf60..918cc9249c 100644 --- a/client/src/controllers/SessionController.ts +++ b/client/src/controllers/SessionController.ts @@ -128,6 +128,10 @@ export class SessionController extends Controller { */ addInterval(): void { this.clearInterval(); + // Values outside this range will be ignored by window.setInterval, + // making it fire all the time. + if (this.intervalValue <= 0 || this.intervalValue >= 2 ** 31) return; + this.interval = window.setInterval(this.ping, this.intervalValue); } diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 70cc0dd0af..4feed64c9f 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -259,6 +259,16 @@ WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL = 500 The interval (in milliseconds) is to check for changes made in the page editor before updating the preview. The default value is `500`. +(wagtail_editing_session_ping_interval)= + +### `WAGTAIL_EDITING_SESSION_PING_INTERVAL` + +```python +WAGTAIL_EDITING_SESSION_PING_INTERVAL = 10000 +``` + +The interval (in milliseconds) to ping the server during an editing session. This is used to indicate that the session is active, as well as to display the list of other sessions that are currently editing the same content. The default value is `10000` (10 seconds). In order to effectively display the sessions list, this value needs to be set to under 1 minute. If set to 0, the interval will be disabled. + (wagtailadmin_global_edit_lock)= ### `WAGTAILADMIN_GLOBAL_EDIT_LOCK` diff --git a/wagtail/admin/templates/wagtailadmin/shared/editing_sessions/module.html b/wagtail/admin/templates/wagtailadmin/shared/editing_sessions/module.html index 2675919573..23c6afdbee 100644 --- a/wagtail/admin/templates/wagtailadmin/shared/editing_sessions/module.html +++ b/wagtail/admin/templates/wagtailadmin/shared/editing_sessions/module.html @@ -29,6 +29,7 @@ data-w-swap-defer-value="true" data-w-action-continue-value="true" data-w-action-url-value="{{ release_url }}" + data-w-session-interval-value="{{ ping_interval }}" data-w-session-w-dialog-outlet="[data-edit-form] [data-controller='w-dialog']#w-overwrite-changes-dialog" data-action="w-session:ping->w-swap#submit visibilitychange@document->w-session#dispatchVisibilityState w-session:visible->w-session#ping w-session:visible->w-session#addInterval w-session:hidden->w-session#clearInterval w-session:hidden->w-action#sendBeacon w-unsaved:add@document->w-session#setUnsavedChanges w-unsaved:clear@document->w-session#setUnsavedChanges w-swap:json->w-session#updateSessionData" > diff --git a/wagtail/admin/tests/test_editing_sessions.py b/wagtail/admin/tests/test_editing_sessions.py index 31a9b5a9d0..8212d87dee 100644 --- a/wagtail/admin/tests/test_editing_sessions.py +++ b/wagtail/admin/tests/test_editing_sessions.py @@ -1,16 +1,22 @@ import datetime from django.conf import settings +from django.contrib.admin.utils import quote from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType -from django.test import TestCase +from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone from freezegun import freeze_time from wagtail.admin.models import EditingSession from wagtail.models import GroupPagePermission, Page -from wagtail.test.testapp.models import Advert, SimplePage +from wagtail.test.testapp.models import ( + Advert, + AdvertWithCustomPrimaryKey, + FullFeaturedSnippet, + SimplePage, +) from wagtail.test.utils import WagtailTestUtils if settings.USE_TZ: @@ -1180,3 +1186,151 @@ class TestReleaseView(WagtailTestUtils, TestCase): self.assertTrue( EditingSession.objects.filter(id=self.other_session.id).exists() ) + + +class TestModuleInEditView(WagtailTestUtils, TestCase): + url_name = "wagtailadmin_pages:edit" + model = Page + + def setUp(self): + self.user = self.create_superuser( + "bob", password="password", first_name="Bob", last_name="Testuser" + ) + self.login(user=self.user) + self.content_type = ContentType.objects.get_for_model(self.model) + + self.object = self.create_object() + + self.session = EditingSession.objects.create( + user=self.user, + content_type=self.content_type, + object_id=self.object.pk, + last_seen_at=TIMESTAMP_1, + ) + self.old_session = EditingSession.objects.create( + user=self.user, + content_type=self.content_type, + object_id=self.object.pk, + last_seen_at=TIMESTAMP_PAST, + ) + + def create_object(self): + root_page = Page.get_first_root_node() + page = SimplePage(title="Foo", slug="foo", content="bar") + root_page.add_child(instance=page) + page.save_revision() + return page + + def get(self): + return self.client.get(reverse(self.url_name, args=(quote(self.object.pk),))) + + def assertRevisionInput(self, soup): + revision_input = soup.select_one('input[name="revision_id"]') + self.assertIsNotNone(revision_input) + self.assertEqual(revision_input.get("type"), "hidden") + self.assertEqual( + revision_input.get("value"), + str(self.object.latest_revision.id), + ) + + @freeze_time(TIMESTAMP_NOW) + def test_edit_view_with_default_interval(self): + self.assertEqual(EditingSession.objects.all().count(), 2) + response = self.get() + self.assertEqual(response.status_code, 200) + + # Should perform a cleanup of the EditingSessions + self.assertTrue(EditingSession.objects.filter(id=self.session.id).exists()) + self.assertFalse(EditingSession.objects.filter(id=self.old_session.id).exists()) + + # Should create a new EditingSession for the current user + self.assertEqual(EditingSession.objects.all().count(), 2) + new_session = EditingSession.objects.exclude(id=self.session.id).get( + content_type=self.content_type, + object_id=self.object.pk, + ) + self.assertEqual(new_session.user, self.user) + + # Should load the EditingSessionsModule with the default interval (10s) + soup = self.get_soup(response.content) + module = soup.select_one('form[data-controller~="w-session"]') + self.assertIsNotNone(module) + self.assertEqual(module.get("data-w-session-interval-value"), "10000") + + # Should show the revision_id input + self.assertRevisionInput(module) + + @freeze_time(TIMESTAMP_NOW) + @override_settings(WAGTAIL_EDITING_SESSION_PING_INTERVAL=30000) + def test_edit_view_with_custom_interval(self): + self.assertEqual(EditingSession.objects.all().count(), 2) + response = self.get() + self.assertEqual(response.status_code, 200) + + # Should perform a cleanup of the EditingSessions + self.assertTrue(EditingSession.objects.filter(id=self.session.id).exists()) + self.assertFalse(EditingSession.objects.filter(id=self.old_session.id).exists()) + + # Should create a new EditingSession for the current user + self.assertEqual(EditingSession.objects.all().count(), 2) + new_session = EditingSession.objects.exclude(id=self.session.id).get( + content_type=self.content_type, + object_id=self.object.pk, + ) + self.assertEqual(new_session.user, self.user) + + # Should load the EditingSessionsModule + soup = self.get_soup(response.content) + module = soup.select_one('form[data-controller~="w-session"]') + self.assertIsNotNone(module) + self.assertEqual( + module.get("data-w-swap-src-value"), + reverse( + "wagtailadmin_editing_sessions:ping", + args=( + self.content_type.app_label, + self.content_type.model, + quote(self.object.pk), + new_session.id, + ), + ), + ) + self.assertEqual( + module.get("data-w-action-url-value"), + reverse( + "wagtailadmin_editing_sessions:release", + args=(new_session.id,), + ), + ) + + # Should use the custom interval (30s) + self.assertEqual(module.get("data-w-session-interval-value"), "30000") + self.assertRevisionInput(module) + + +class TestModuleInEditViewWithRevisableSnippet(TestModuleInEditView): + model = FullFeaturedSnippet + + @property + def url_name(self): + return self.model.snippet_viewset.get_url_name("edit") + + def create_object(self): + obj = self.model.objects.create(text="Shodan") + obj.save_revision() + return obj + + +class TestModuleInEditViewWithNonRevisableSnippet(TestModuleInEditView): + model = AdvertWithCustomPrimaryKey + + @property + def url_name(self): + return self.model.snippet_viewset.get_url_name("edit") + + def create_object(self): + return self.model.objects.create(text="GLaDOS", advert_id="m0n5t3r!/#") + + def assertRevisionInput(self, soup): + revision_input = soup.select_one('input[name="revision_id"]') + self.assertIsNone(revision_input) diff --git a/wagtail/admin/ui/editing_sessions.py b/wagtail/admin/ui/editing_sessions.py index 90047495c2..b41fb36d6e 100644 --- a/wagtail/admin/ui/editing_sessions.py +++ b/wagtail/admin/ui/editing_sessions.py @@ -1,3 +1,5 @@ +from django.conf import settings + from wagtail.admin.ui.components import Component @@ -23,10 +25,16 @@ class EditingSessionsModule(Component): self.revision_id = revision_id def get_context_data(self, parent_context): + ping_interval = getattr( + settings, + "WAGTAIL_EDITING_SESSION_PING_INTERVAL", + 10000, + ) return { "current_session": self.current_session, "ping_url": self.ping_url, "release_url": self.release_url, + "ping_interval": str(ping_interval), # avoid the need to | unlocalize "sessions_list": self.sessions_list, "content_type": self.content_type, "revision_id": self.revision_id,