cache queries and revalidate with incremental static regeneration
This commit is contained in:
@@ -6,3 +6,6 @@ class DnsCmsConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
from dnscms import signals # noqa: F401
|
||||
from dnscms.revalidation import register_signal_handlers
|
||||
|
||||
register_signal_handlers()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Purge the frontend cache when content changes.
|
||||
|
||||
The frontend caches all GraphQL responses under a single "cms" cache tag;
|
||||
any content change POSTs to its /api/revalidate endpoint for a global purge.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import urllib.request
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WEBHOOK_TIMEOUT_SECONDS = 5
|
||||
|
||||
# Non-page models exposed over GraphQL; pages are covered by wagtail signals
|
||||
REVALIDATING_MODELS = [
|
||||
"contacts.ContactEntity",
|
||||
"events.EventCategory",
|
||||
"events.EventOrganizer",
|
||||
"images.CustomImage",
|
||||
"openinghours.OpeningHoursSet",
|
||||
]
|
||||
|
||||
_warned_unconfigured = False
|
||||
|
||||
|
||||
def _send_webhook(url, secret, reason):
|
||||
request = urllib.request.Request(
|
||||
url, data=b"", method="POST", headers={"X-Revalidate-Secret": secret}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=WEBHOOK_TIMEOUT_SECONDS) as response:
|
||||
logger.info("frontend revalidation ok (%s): HTTP %s", reason, response.status)
|
||||
except Exception:
|
||||
logger.exception("frontend revalidation failed (%s)", reason)
|
||||
|
||||
|
||||
def trigger_frontend_revalidation(reason):
|
||||
global _warned_unconfigured
|
||||
secret = settings.REVALIDATE_WEBHOOK_SECRET
|
||||
if not secret:
|
||||
if not _warned_unconfigured:
|
||||
logger.warning("REVALIDATE_WEBHOOK_SECRET is not set, skipping frontend revalidation")
|
||||
_warned_unconfigured = True
|
||||
return
|
||||
url = f"{settings.FRONTEND_BASE_URL}/api/revalidate"
|
||||
# After commit so the frontend can't re-render from pre-commit data,
|
||||
# in a thread so publishing never blocks on the frontend
|
||||
transaction.on_commit(
|
||||
lambda: threading.Thread(
|
||||
target=_send_webhook, args=(url, secret, reason), daemon=True
|
||||
).start()
|
||||
)
|
||||
|
||||
|
||||
def _on_page_published(sender, instance, **kwargs):
|
||||
trigger_frontend_revalidation(f"page_published: {instance.slug}")
|
||||
|
||||
|
||||
def _on_page_unpublished(sender, instance, **kwargs):
|
||||
trigger_frontend_revalidation(f"page_unpublished: {instance.slug}")
|
||||
|
||||
|
||||
def _on_page_moved(sender, instance, **kwargs):
|
||||
trigger_frontend_revalidation(f"post_page_move: {instance.slug}")
|
||||
|
||||
|
||||
def _on_page_deleted(sender, instance, **kwargs):
|
||||
trigger_frontend_revalidation(f"page_deleted: {instance.slug}")
|
||||
|
||||
|
||||
def _on_model_changed(sender, instance, **kwargs):
|
||||
trigger_frontend_revalidation(f"{sender._meta.label}: {instance}")
|
||||
|
||||
|
||||
def register_signal_handlers():
|
||||
from wagtail.models import Page
|
||||
from wagtail.signals import page_published, page_unpublished, post_page_move
|
||||
|
||||
page_published.connect(_on_page_published, dispatch_uid="revalidation.page_published")
|
||||
page_unpublished.connect(_on_page_unpublished, dispatch_uid="revalidation.page_unpublished")
|
||||
post_page_move.connect(_on_page_moved, dispatch_uid="revalidation.post_page_move")
|
||||
# Wagtail sends no signal on page deletion; MTI deletes of any page type
|
||||
# also emit post_delete for the base Page row
|
||||
post_delete.connect(_on_page_deleted, sender=Page, dispatch_uid="revalidation.page_deleted")
|
||||
|
||||
for label in REVALIDATING_MODELS:
|
||||
post_save.connect(
|
||||
_on_model_changed, sender=label, dispatch_uid=f"revalidation.save.{label}"
|
||||
)
|
||||
post_delete.connect(
|
||||
_on_model_changed, sender=label, dispatch_uid=f"revalidation.delete.{label}"
|
||||
)
|
||||
@@ -200,6 +200,10 @@ BASE_URL = WAGTAIL_BASE_URL
|
||||
# redirect "View Live" clicks on the CMS host over to the headless frontend.
|
||||
FRONTEND_BASE_URL = os.environ.get("FRONTEND_BASE_URL", "http://localhost:3000").rstrip("/")
|
||||
|
||||
# Shared secret for the frontend cache purge webhook (see dnscms/revalidation.py).
|
||||
# Unset means no webhooks are sent.
|
||||
REVALIDATE_WEBHOOK_SECRET = os.environ.get("REVALIDATE_WEBHOOK_SECRET", "")
|
||||
|
||||
WAGTAIL_HEADLESS_PREVIEW = {
|
||||
"CLIENT_URLS": {"default": f"{FRONTEND_BASE_URL}/api/preview"},
|
||||
"SERVE_BASE_URL": FRONTEND_BASE_URL,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import logging
|
||||
import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
from dnscms import revalidation
|
||||
from events.models import EventCategory
|
||||
from tests.conftest import GenericPageFactory
|
||||
|
||||
WEBHOOK_URL = "https://frontend.example.com/api/revalidate"
|
||||
|
||||
|
||||
class InlineThread:
|
||||
def __init__(self, target=None, args=(), kwargs=None, daemon=None):
|
||||
self._target = target
|
||||
self._args = args
|
||||
self._kwargs = kwargs or {}
|
||||
|
||||
def start(self):
|
||||
self._target(*self._args, **self._kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def webhook_calls(monkeypatch, settings):
|
||||
settings.REVALIDATE_WEBHOOK_SECRET = "s3cret"
|
||||
settings.FRONTEND_BASE_URL = "https://frontend.example.com"
|
||||
calls = []
|
||||
monkeypatch.setattr(revalidation.threading, "Thread", InlineThread)
|
||||
monkeypatch.setattr(
|
||||
revalidation, "_send_webhook", lambda url, secret, reason: calls.append((url, secret))
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
def test_page_publish_triggers_webhook(
|
||||
home_page, webhook_calls, django_capture_on_commit_callbacks
|
||||
):
|
||||
page = GenericPageFactory(parent=home_page)
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
page.save_revision().publish()
|
||||
|
||||
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
|
||||
|
||||
|
||||
def test_page_unpublish_triggers_webhook(
|
||||
home_page, webhook_calls, django_capture_on_commit_callbacks
|
||||
):
|
||||
page = GenericPageFactory(parent=home_page)
|
||||
page.save_revision().publish()
|
||||
webhook_calls.clear()
|
||||
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
page.unpublish()
|
||||
|
||||
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
|
||||
|
||||
|
||||
def test_page_delete_triggers_webhook(
|
||||
home_page, webhook_calls, django_capture_on_commit_callbacks
|
||||
):
|
||||
# live=False isolates the post_delete path: deleting a live page would
|
||||
# also fire page_unpublished (a harmless duplicate purge in production)
|
||||
page = GenericPageFactory(parent=home_page, live=False)
|
||||
webhook_calls.clear()
|
||||
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
page.delete()
|
||||
|
||||
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_snippet_save_triggers_webhook(webhook_calls, django_capture_on_commit_callbacks):
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
EventCategory.objects.create(name="Konsert", slug="konsert")
|
||||
|
||||
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
|
||||
|
||||
|
||||
def test_unset_secret_skips_and_warns_once(settings, monkeypatch, caplog):
|
||||
settings.REVALIDATE_WEBHOOK_SECRET = ""
|
||||
monkeypatch.setattr(revalidation, "_warned_unconfigured", False)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="dnscms.revalidation"):
|
||||
revalidation.trigger_frontend_revalidation("test")
|
||||
revalidation.trigger_frontend_revalidation("test")
|
||||
|
||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
def test_send_webhook_failure_is_swallowed(monkeypatch, caplog):
|
||||
def raise_urlerror(request, timeout):
|
||||
raise urllib.error.URLError("connection refused")
|
||||
|
||||
monkeypatch.setattr(revalidation.urllib.request, "urlopen", raise_urlerror)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="dnscms.revalidation"):
|
||||
revalidation._send_webhook(WEBHOOK_URL, "s3cret", "test")
|
||||
|
||||
assert any("frontend revalidation failed" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_send_webhook_request_shape(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
seen["request"] = request
|
||||
seen["timeout"] = timeout
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(revalidation.urllib.request, "urlopen", fake_urlopen)
|
||||
revalidation._send_webhook(WEBHOOK_URL, "s3cret", "test")
|
||||
|
||||
request = seen["request"]
|
||||
assert request.get_method() == "POST"
|
||||
assert request.get_header("X-revalidate-secret") == "s3cret"
|
||||
assert seen["timeout"] == revalidation.WEBHOOK_TIMEOUT_SECONDS
|
||||
Reference in New Issue
Block a user