diff --git a/src/core/migrations/0173_lock_created_at.py b/src/core/migrations/0173_lock_created_at.py new file mode 100644 index 0000000000000000000000000000000000000000..67fb7844401eb71e7f65fecc1a5525370a9e0852 --- /dev/null +++ b/src/core/migrations/0173_lock_created_at.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.3 on 2024-12-25 14:36 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0172_assembly_location_state'), + ] + + operations = [ + migrations.AddField( + model_name='lock', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/src/core/models/locks.py b/src/core/models/locks.py index 2815cacdddfe01ee564d43c522d8847477b8776e..51673fcf0319f35eff33b97a9ec6c545888e6896 100644 --- a/src/core/models/locks.py +++ b/src/core/models/locks.py @@ -13,6 +13,7 @@ class Lock(models.Model): content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.UUIDField() content_object = GenericForeignKey('content_type', 'object_id') + created_at = models.DateTimeField(auto_now_add=True) timeout = models.DateTimeField() lock_holder = models.ForeignKey(PlatformUser, on_delete=models.CASCADE, related_name='locks') diff --git a/src/core/models/pages.py b/src/core/models/pages.py index b8b3164ab8a665baf2ba8b15c74c466600179f24..6b5a1bd33bbdbde847bd1d8b31952b91c4e7269c 100644 --- a/src/core/models/pages.py +++ b/src/core/models/pages.py @@ -196,14 +196,14 @@ class StaticPageManager(models.Manager): def get_editable_page(self, user: PlatformUser, conference: Conference, language: str, slug: str, check=False) -> tuple['StaticPage | None', bool]: """ - Request to edit the Static Page with slug `slug` in the context of `user` and `conference` and in the language `langguage`. + Request to edit the Static Page with slug `slug` in the context of `user` and `conference` and in the language `language`. Returns a tuple `(static_page, exists)` with `static_page` the `StaticPage` instance (or `None`) and `exists` a boolean iff the `StaticPage` already exists in the database and the user has sufficient permission to create it. `static_page` will be `None` iff the user is not allowed to edit this Page (even if it does not exist). Otherwise, it will be a `StaticPage` instance for the Page. - If the Page does not yet exist in the database, the returned `StaticPage` is not stored in the database (`save()` needs to be called by the callee). + If the Page does not yet exist in the database, the returned `StaticPage` is not stored in the database (`save()` needs to be called by the caller). In this case, `exists` will be `False`. """ diff --git a/src/hub/settings/base.py b/src/hub/settings/base.py index c93dee613d7a3f41802203b86f4ff25613e0b52a..ddfc524ffb7179449d24ed8bfc128177a73b91cd 100644 --- a/src/hub/settings/base.py +++ b/src/hub/settings/base.py @@ -616,6 +616,8 @@ _handle_hostpattern_list(DEREFERRER_COUNT_ACCESS, env('PLAINUI_DEREFERER_COUNTLI STATIC_PAGE_LOCALIZED_BY_DEFAULT = env.bool('STATICPAGES_LOCALIZED_BY_DEFAULT', False) # Time after which a non-refreshed static page lock will expire (in seconds) STATIC_PAGE_LOCK_TIMEOUT = env.int('STATICPAGES_LOCK_TIMEOUT', 5 * 60) +# Time after a which a lock will no longer be extended and will therefore expire (in seconds) +STATIC_PAGE_LOCK_MAX_DURATION = env.int('STATIC_PAGE_LOCK_MAX_DURATION', 30 * 60) PLAINUI_THEME_SUPPORT = env('PLAINUI_THEME_SUPPORT') diff --git a/src/plainui/tests/test_views.py b/src/plainui/tests/test_views.py index d04f35c6685bd0e845aad02298de82cb62c105c8..373612288a64e6f8ff33971397c0008aeffa76c6 100644 --- a/src/plainui/tests/test_views.py +++ b/src/plainui/tests/test_views.py @@ -667,6 +667,7 @@ class ViewsTest(ViewsTestBase): self.assertEqual(resp.context_data['page'], sp) self.assertEqual(resp.context_data['page_slug'], sp.slug) self.assertEqual(resp.context_data['writeable'], False) + self.assertEqual(resp.context_data['lock_id'], '') self.assertEqual(resp.context_data['revision'], None) self.assertIsInstance(resp.context_data['form'], StaticPageBodyForm) self.assertEqual(resp.context_data['form']['title'].value(), r2.title) @@ -701,6 +702,7 @@ class ViewsTest(ViewsTestBase): self.assertEqual(resp.context_data['page'], sp) self.assertEqual(resp.context_data['page_slug'], sp.slug) self.assertEqual(resp.context_data['writeable'], False) + self.assertEqual(resp.context_data['lock_id'], '') self.assertEqual(resp.context_data['revision'], None) self.assertIsInstance(resp.context_data['form'], StaticPageBodyForm) self.assertEqual(resp.context_data['form']['title'].value(), r2.title) @@ -757,6 +759,7 @@ class ViewsTest(ViewsTestBase): self.assertEqual(resp.context_data['page_slug'], 'test-doesnotexist') self.assertEqual(resp.context_data['writeable'], True) self.assertEqual(resp.context_data['revision'], None) + self.assertEqual(resp.context_data['lock_id'], '') # no lock self.assertIsInstance(resp.context_data['form'], StaticPageBodyForm) self.assertEqual(resp.context_data['form']['title'].value(), 'test-doesnotexist') self.assertEqual(resp.context_data['form']['body'].value(), '') @@ -1010,7 +1013,7 @@ class ViewsTest(ViewsTestBase): lock.refresh_from_db() self.assertEqual(lock.timeout, lock_timeout) - @override_settings(RATELIMIT_ENABLE=False) + @override_settings(RATELIMIT_ENABLE=False, STATIC_PAGE_LOCK_MAX_DURATION=60) def test_StaticPageLockKeepalive(self): user2 = PlatformUser(username='user2') user2.save() @@ -1037,6 +1040,13 @@ class ViewsTest(ViewsTestBase): self.assertGreater(lock.timeout, lock_timeout) lock_timeout = lock.timeout + # does not refresh lock after max duration has expired + with freeze_time(lock.created_at + timedelta(seconds=61)): + resp = self.client.post(reverse('plainui:static_page_refresh_lock'), {'page_slug': sp.slug, 'lock_id': str(lock.pk)}) + self.assertEqual(resp.status_code, 200) + lock.refresh_from_db() + self.assertEqual(lock.timeout, lock_timeout) + # does not refresh lock when called for nonexistent page resp = self.client.post(reverse('plainui:static_page_refresh_lock'), {'page_slug': 'something_else', 'lock_id': str(lock.pk)}) self.assertEqual(resp.status_code, 200) diff --git a/src/plainui/views/static_pages.py b/src/plainui/views/static_pages.py index 924a27684fd016d4f34d19c0d3edeb4bf52fcf09..da24b7c840664314753379342040dd9ada55615a 100644 --- a/src/plainui/views/static_pages.py +++ b/src/plainui/views/static_pages.py @@ -152,7 +152,7 @@ class StaticPageEditView(ConferenceRequiredMixin, TemplateView): return redirect(reverse('plainui:static_page', kwargs={'page_slug': page_slug})) lock = '' - if page_exists: + if page_exists and writeable: lock, created = Lock.objects.select_for_update().get_or_create( content_type=ContentType.objects.get_for_model(StaticPage), object_id=static_page.pk, @@ -444,6 +444,7 @@ class StaticPageGlobalHistoryView(ConferenceRequiredMixin, TemplateView): class StaticPageLockKeepalive(ConferenceRequiredMixin, View): def post(self, request): LOCK_TIMEOUT = settings.STATIC_PAGE_LOCK_TIMEOUT + LOCK_MAX_TIMEOUT = settings.STATIC_PAGE_LOCK_MAX_DURATION page_slug = request.POST['page_slug'] lock_id = request.POST['lock_id'] @@ -458,6 +459,7 @@ class StaticPageLockKeepalive(ConferenceRequiredMixin, View): content_type=ContentType.objects.get_for_model(StaticPage), object_id=static_page.pk, lock_holder=self.request.user, + created_at__gte=self.now - timedelta(seconds=LOCK_MAX_TIMEOUT), ).update(timeout=self.now + timedelta(seconds=LOCK_TIMEOUT)) return HttpResponse('')