Skip to content

Commit d0590ca

Browse files
Headless draft preview: snapshot + token endpoint on the CMS
FrontendPageMixin disabled preview entirely (no Django templates), so editors had no way to see a draft before publishing. Re-enable it headlessly on content pages: serve_preview serializes the draft exactly as the content API would (same _serialize_page, portal-tag injection included), parks it in a PreviewSnapshot row, and redirects the editor's preview iframe/tab to the frontend. The unguessable uuid pk is the whole authorization for GET /api/content/preview/<token> — 1h TTL, pruned on write. Index pages stay non-previewable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fbe9379 commit d0590ca

5 files changed

Lines changed: 157 additions & 14 deletions

File tree

cms/content/api.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from django.conf import settings
2929
from django.views.decorators.http import require_GET
3030

31-
from content.models import PlacePage, StaticPage, TagPage
31+
from content.models import PlacePage, PreviewSnapshot, StaticPage, TagPage
3232
from core.api import _json, pagination
3333

3434
CONTENT_TYPE_PAGES = {
@@ -127,6 +127,19 @@ def content_detail(request, content_type, slug):
127127
)
128128

129129

130+
@require_GET
131+
def content_preview(request, token):
132+
"""GET /api/content/preview/<token>
133+
134+
Draft snapshot minted by the editor's Preview button
135+
(ContentPageBase.serve_preview). Token-gated: the unguessable uuid with a
136+
short TTL is the whole authorization, so no auth header is required."""
137+
snapshot = PreviewSnapshot.fresh().filter(token=token).first()
138+
if snapshot is None:
139+
return _json({"detail": "Preview expired or not found"}, status=404)
140+
return _json(snapshot.data)
141+
142+
130143
@require_GET
131144
def content_list(request, content_type):
132145
"""GET /api/content/<type>/list?language=xx&offset=n&limit=n
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import uuid
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("content", "0003_import_legacy_content"),
9+
]
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="PreviewSnapshot",
14+
fields=[
15+
(
16+
"token",
17+
models.UUIDField(
18+
default=uuid.uuid4,
19+
editable=False,
20+
primary_key=True,
21+
serialize=False,
22+
),
23+
),
24+
("data", models.JSONField()),
25+
("created_at", models.DateTimeField(auto_now_add=True)),
26+
],
27+
),
28+
]

cms/content/models.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,15 @@
1919
no extra join table, and the slugs are not translatable content.
2020
"""
2121

22+
import uuid
23+
from datetime import timedelta
24+
2225
from django.conf import settings
2326
from django.contrib.postgres.fields import ArrayField
2427
from django.core.exceptions import ValidationError
2528
from django.db import DatabaseError, models, transaction
29+
from django.shortcuts import redirect
30+
from django.utils import timezone
2631
from wagtail.admin.panels import FieldPanel
2732
from wagtail.fields import StreamField
2833
from wagtail.models import Page
@@ -39,7 +44,8 @@ class FrontendPageMixin:
3944
4045
- ``preview_modes = []`` disables the editor Preview panel and the "View
4146
draft" button; there is no Django template, so previewing raised
42-
TemplateDoesNotExist 500s.
47+
TemplateDoesNotExist 500s. ContentPageBase re-enables it headlessly
48+
via snapshot + frontend redirect (serve_preview below).
4349
- URL generation is redirected at the single choke point Wagtail
4450
documents for custom routing, ``get_url_parts``, so every derived link
4551
("View live" in the editor header/listings/flash messages, usage
@@ -77,12 +83,56 @@ def url(self):
7783
return self.get_url()
7884

7985

86+
class PreviewSnapshot(models.Model):
87+
"""A draft page serialized exactly as the content API would serve it,
88+
parked for the frontend preview route. The row IS the capability: the
89+
unguessable pk is the whole grant (short TTL, pruned on write), so the
90+
fetch endpoint needs no auth."""
91+
92+
TTL = timedelta(hours=1)
93+
94+
token = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
95+
data = models.JSONField()
96+
created_at = models.DateTimeField(auto_now_add=True)
97+
98+
@classmethod
99+
def fresh(cls):
100+
return cls.objects.filter(created_at__gte=timezone.now() - cls.TTL)
101+
102+
@classmethod
103+
def prune(cls):
104+
cls.objects.filter(created_at__lt=timezone.now() - cls.TTL).delete()
105+
106+
80107
class ContentPageBase(FrontendPageMixin, Page):
81108
"""Shared shape of tag/place pages: subtitle + StreamField body."""
82109

83110
subtitle = models.CharField(max_length=255, blank=True, default="")
84111
body = StreamField(ContentStreamBlock(), blank=True)
85112

113+
# Content type key in the public API (content/api.py CONTENT_TYPE_PAGES).
114+
api_content_type: str
115+
116+
# Re-enable the Preview panel / "View draft" button the mixin disables:
117+
# previews are headless too — serve_preview parks a serialized snapshot
118+
# and hands the editor's iframe/tab to the frontend, which fetches it
119+
# back by token (GET /api/content/preview/<token>).
120+
preview_modes = [("frontend", "Preview on site")]
121+
122+
def serve_preview(self, request, mode_name):
123+
# Local import: content.api imports these models.
124+
from content.api import _serialize_page
125+
126+
PreviewSnapshot.prune()
127+
snapshot = PreviewSnapshot.objects.create(
128+
data={
129+
"content": _serialize_page(self, self.api_content_type),
130+
"available_languages": [self.locale.language_code],
131+
"type": self.api_content_type,
132+
}
133+
)
134+
return redirect(f"{settings.FRONTEND_URL}/preview/{snapshot.token}")
135+
86136
content_panels = Page.content_panels + [
87137
FieldPanel("subtitle"),
88138
FieldPanel("body"),
@@ -154,6 +204,7 @@ class StaticPage(ContentPageBase):
154204
/api/content/static/slug/<slug>; a hardcoded Next.js route with the same
155205
path takes precedence, so pages can migrate into the CMS one at a time."""
156206

207+
api_content_type = "static"
157208
parent_page_types = ["content.StaticIndexPage"]
158209
subpage_types: list[str] = []
159210

@@ -174,6 +225,7 @@ class TagPage(ContentPageBase):
174225
help_text="Slug of the Districtr map module this tag page features.",
175226
)
176227

228+
api_content_type = "tags"
177229
parent_page_types = ["content.TagsIndexPage"]
178230
subpage_types: list[str] = []
179231

@@ -230,6 +282,7 @@ class PlacePage(ContentPageBase):
230282
help_text="Slugs of the Districtr map modules this place page features.",
231283
)
232284

285+
api_content_type = "places"
233286
parent_page_types = ["content.PlacesIndexPage"]
234287
subpage_types: list[str] = []
235288

cms/content/tests.py

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
from django.core.management import call_command as django_call_command
2121
from django.core.management.base import CommandError
2222
from django.db import connection
23-
from django.test import SimpleTestCase, TestCase, override_settings
23+
from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
24+
from django.utils import timezone
2425
from wagtail.models import GroupPagePermission, Locale, Page, Revision, Site
2526
from wagtail.permission_policies.pages import PagePermissionPolicy
2627

2728
from content.models import (
2829
PlacePage,
2930
PlacesIndexPage,
31+
PreviewSnapshot,
3032
StaticIndexPage,
3133
StaticPage,
3234
TagPage,
@@ -623,22 +625,22 @@ def test_index_page_urls(self):
623625
# No frontend listing for static pages: not routable, no "View live".
624626
self.assertIsNone(self.static_index.url)
625627

626-
def test_preview_disabled(self):
628+
def test_index_preview_disabled(self):
627629
# No Wagtail template exists; previewing raised TemplateDoesNotExist.
628-
for page in (
629-
self.tag,
630-
self.place,
631-
self.static,
632-
self.tags_index,
633-
self.places_index,
634-
self.static_index,
635-
):
630+
# Content pages preview headlessly instead (PreviewTests); index
631+
# pages have nothing to preview.
632+
for page in (self.tags_index, self.places_index, self.static_index):
636633
self.assertEqual(page.preview_modes, [])
637634
self.assertFalse(page.is_previewable())
638635

636+
def test_content_pages_are_previewable(self):
637+
for page in (self.tag, self.place, self.static):
638+
self.assertTrue(page.is_previewable())
639+
639640
def test_admin_editor_shows_frontend_live_url(self):
640641
# The page editor (where Preview/"View live" 500'd) renders, offers
641-
# no preview panel, and links "View live" at the frontend URL.
642+
# the headless preview panel, and links "View live" at the frontend
643+
# URL.
642644
self.tag.save_revision(clean=False).publish()
643645
get_user_model().objects.create_superuser(
644646
username="root@districtr.org",
@@ -652,7 +654,7 @@ def test_admin_editor_shows_frontend_live_url(self):
652654
self.assertEqual(response.status_code, 200)
653655
html = response.content.decode()
654656
self.assertIn("https://beta.districtr.org/portal/fair-maps", html)
655-
self.assertNotIn('data-side-panel-toggle="preview"', html)
657+
self.assertIn('data-side-panel-toggle="preview"', html)
656658

657659

658660
# ---------------------------------------------------------------------------
@@ -1175,3 +1177,49 @@ def test_json_report(self):
11751177
)
11761178
self.assertTrue(entry["text_ok"])
11771179
self.assertEqual(entry["docs"]["published"]["output_blocks"]["plan_gallery"], 1)
1180+
1181+
1182+
@override_settings(FRONTEND_URL="https://beta.districtr.org")
1183+
class PreviewTests(TestCase):
1184+
"""Headless preview: serve_preview parks a serialized snapshot and
1185+
redirects to the frontend, which fetches it back by token."""
1186+
1187+
@classmethod
1188+
def setUpTestData(cls):
1189+
en = Locale.objects.get(language_code="en")
1190+
static_index = StaticIndexPage.objects.get(locale=en)
1191+
cls.draft = StaticPage(title="Draft About", slug="about-draft", live=False)
1192+
static_index.add_child(instance=cls.draft)
1193+
1194+
def test_serve_preview_snapshots_and_redirects(self):
1195+
response = self.draft.serve_preview(RequestFactory().get("/"), "frontend")
1196+
self.assertEqual(response.status_code, 302)
1197+
prefix = "https://beta.districtr.org/preview/"
1198+
self.assertTrue(response["Location"].startswith(prefix))
1199+
1200+
token = response["Location"].removeprefix(prefix)
1201+
snapshot = PreviewSnapshot.objects.get(token=token)
1202+
self.assertEqual(snapshot.data["type"], "static")
1203+
self.assertEqual(snapshot.data["content"]["title"], "Draft About")
1204+
self.assertEqual(snapshot.data["available_languages"], ["en"])
1205+
1206+
def test_preview_endpoint_serves_fresh_and_404s_expired(self):
1207+
response = self.draft.serve_preview(RequestFactory().get("/"), "frontend")
1208+
token = response["Location"].rsplit("/", 1)[-1]
1209+
url = f"/api/content/preview/{token}"
1210+
1211+
payload = self.client.get(url).json()
1212+
self.assertEqual(payload["content"]["slug"], "about-draft")
1213+
1214+
PreviewSnapshot.objects.filter(token=token).update(
1215+
created_at=timezone.now() - PreviewSnapshot.TTL * 2
1216+
)
1217+
self.assertEqual(self.client.get(url).status_code, 404)
1218+
1219+
def test_prune_on_mint_drops_stale_snapshots(self):
1220+
stale = PreviewSnapshot.objects.create(data={})
1221+
PreviewSnapshot.objects.filter(token=stale.token).update(
1222+
created_at=timezone.now() - PreviewSnapshot.TTL * 2
1223+
)
1224+
self.draft.serve_preview(RequestFactory().get("/"), "frontend")
1225+
self.assertFalse(PreviewSnapshot.objects.filter(token=stale.token).exists())

cms/content/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
app_name = "content"
66

77
urlpatterns = [
8+
path("preview/<uuid:token>", api.content_preview, name="preview"),
89
path("<str:content_type>/slug/<slug:slug>", api.content_detail, name="detail"),
910
path("<str:content_type>/list", api.content_list, name="list"),
1011
]

0 commit comments

Comments
 (0)