diff --git a/apps/core/middleware.py b/apps/core/middleware.py index 1afe820..5d18b2c 100644 --- a/apps/core/middleware.py +++ b/apps/core/middleware.py @@ -2,6 +2,8 @@ from __future__ import annotations import secrets +from django.contrib.messages import get_messages + from .consent import ConsentService @@ -40,3 +42,25 @@ class SecurityHeadersMiddleware: ) response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" return response + + +class AdminMessageGuardMiddleware: + ADMIN_PREFIXES = ("/cms/", "/django-admin/") + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # The public site has no legitimate use of Django's shared flash queue. + # Drain any stale admin messages before frontend rendering can see them. + if not request.path.startswith(self.ADMIN_PREFIXES): + storage = get_messages(request) + list(storage) + storage._queued_messages = [] + storage._loaded_data = [] + for sub_storage in getattr(storage, "storages", []): + sub_storage._queued_messages = [] + sub_storage._loaded_data = [] + sub_storage.used = True + storage.used = True + return self.get_response(request) diff --git a/apps/core/tests/test_message_handling.py b/apps/core/tests/test_message_handling.py new file mode 100644 index 0000000..db49de0 --- /dev/null +++ b/apps/core/tests/test_message_handling.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import pytest +from django.contrib import messages +from django.contrib.auth.models import AnonymousUser +from django.contrib.messages import get_messages +from django.contrib.messages.storage.fallback import FallbackStorage +from django.contrib.sessions.middleware import SessionMiddleware +from django.http import HttpResponse +from django.shortcuts import render +from django.test import RequestFactory, override_settings +from django.urls import include, path + +from apps.core.middleware import AdminMessageGuardMiddleware + + +def admin_message_test_view(request): + messages.success(request, "Page 'Test page' has been updated.") + messages.success(request, "Page 'Test page' has been updated.") + messages.success(request, "Page 'Test page' has been published.") + return render(request, "wagtailadmin/base.html", {}) + + +urlpatterns = [ + path("cms/__tests__/admin-messages/", admin_message_test_view), + path("", include("config.urls")), +] + + +def _build_request(rf: RequestFactory, path: str): + request = rf.get(path) + SessionMiddleware(lambda req: None).process_request(request) + request.session.save() + request.user = AnonymousUser() + setattr(request, "_messages", FallbackStorage(request)) + return request + + +@pytest.mark.django_db +def test_admin_message_guard_clears_stale_messages_on_frontend(rf): + request = _build_request(rf, "/articles/test/") + messages.success(request, "Page 'Test page' has been updated.") + + response = AdminMessageGuardMiddleware(lambda req: HttpResponse("ok"))(request) + + assert response.status_code == 200 + assert list(get_messages(request)) == [] + + +@pytest.mark.django_db +def test_admin_message_guard_preserves_admin_messages(rf): + request = _build_request(rf, "/cms/pages/1/edit/") + messages.success(request, "Page 'Test page' has been updated.") + + response = AdminMessageGuardMiddleware(lambda req: HttpResponse("ok"))(request) + remaining = list(get_messages(request)) + + assert response.status_code == 200 + assert len(remaining) == 1 + assert remaining[0].message == "Page 'Test page' has been updated." + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="apps.core.tests.test_message_handling") +def test_wagtail_admin_template_deduplicates_consecutive_messages(client, django_user_model): + admin = django_user_model.objects.create_superuser( + username="admin-messages", + email="admin-messages@example.com", + password="admin-pass", + ) + client.force_login(admin) + + response = client.get("/cms/__tests__/admin-messages/") + content = response.content.decode() + + assert response.status_code == 200 + assert content.count("has been updated.") == 1 + assert content.count("has been published.") == 1 diff --git a/config/settings/base.py b/config/settings/base.py index 2b745d8..8d6a2f4 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -67,6 +67,7 @@ MIDDLEWARE = [ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", + "apps.core.middleware.AdminMessageGuardMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django_htmx.middleware.HtmxMiddleware", "wagtail.contrib.redirects.middleware.RedirectMiddleware", diff --git a/templates/wagtailadmin/base.html b/templates/wagtailadmin/base.html new file mode 100644 index 0000000..973d7df --- /dev/null +++ b/templates/wagtailadmin/base.html @@ -0,0 +1,47 @@ +{% extends "wagtailadmin/admin_base.html" %} +{% load wagtailadmin_tags wagtailcore_tags i18n %} + +{% block furniture %} + + {% sidebar_props %} + + {% keyboard_shortcuts_dialog %} +
+
+
+
    + {% if messages %} + {% for message in messages %} + {% message_level_tag message as level_tag %} + {% ifchanged level_tag message.extra_tags message %} +
  • + {% if level_tag == "error" %} + {% icon name="warning" classname="messages-icon" %} + {% elif message.extra_tags == "lock" %} + {% icon name="lock" classname="messages-icon" %} + {% elif message.extra_tags == "unlock" %} + {% icon name="lock-open" classname="messages-icon" %} + {% else %} + {% icon name=level_tag classname="messages-icon" %} + {% endif %} + {{ message }} +
  • + {% endifchanged %} + {% endfor %} + {% endif %} +
+ + + +
+ + {% block content %}{% endblock %} +
+
+{% endblock %}