Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ htmlcov/
.cache
nosetests.xml
coverage.xml
coverage.json
*.cover
*.py,cover
.hypothesis/
Expand Down
23 changes: 22 additions & 1 deletion caching/private_api/decorators.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import logging
import os
from functools import wraps

from rest_framework.request import Request
from rest_framework.response import Response

from caching.private_api.management import CacheManagement, CacheMissError
from common.request_caching import get_request_caching

logger = logging.getLogger(__name__)


class CacheCheckResultedInMissError(Exception): ...
Expand Down Expand Up @@ -56,12 +60,14 @@ def wrapped_view(*args, **kwargs) -> Response:

request = args[1]
is_public = not (_check_if_valid_non_public_request(request=request))
request_caching_disabled = get_request_caching()

return _retrieve_response_from_cache_or_calculate(
view_function,
timeout,
is_reserved_namespace,
is_public,
request_caching_disabled,
*args,
**kwargs,
)
Expand All @@ -76,7 +82,13 @@ def _check_if_valid_non_public_request(request) -> bool:


def _retrieve_response_from_cache_or_calculate(
view_function, timeout, is_reserved_namespace, is_public, *args, **kwargs
view_function,
timeout,
is_reserved_namespace,
is_public,
request_caching_disabled: str | None,
*args,
**kwargs,
) -> Response:
"""Gets the response from the cache, otherwise recalculates from the view

Expand All @@ -90,6 +102,9 @@ def _retrieve_response_from_cache_or_calculate(
will take place.

Args:
request_caching_disabled: if this is True,
we must recalculate the view, bypassing caching.
This input variable is derived from the request context.
view_function: The view associated with the endpoint
timeout: The number of seconds after which the response is expired
and evicted from the cache
Expand All @@ -104,6 +119,12 @@ def _retrieve_response_from_cache_or_calculate(

"""
request: Request = args[1]

if request_caching_disabled:
return _calculate_response_from_view(
view_function, *args, is_public=is_public, **kwargs
)

if not is_public:
return _calculate_response_from_view(
view_function, *args, is_public=is_public, **kwargs
Expand Down
168 changes: 168 additions & 0 deletions changelog/2026-02-27/CDD-1379.page-previews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# CDD-1379 - Page Previews

**Date:** 2026-02-27

**Ticket:** https://ukhsa.atlassian.net/browse/CDD-1379?search_id=055fe61d-bee9-48d9-80bc-ffb0f1c26b76&referrer=quick-find

**Authors:** Jean-Pierre Fouche

**Impact:** Affects all pages - broad testing required

**Testing:** Comprehensive unit tests supplied. UAT needed.


## Summary

Allow editors of headless composite pages to click a **Preview** button that immediately redirects them to the external frontend application, rather than opening the built-in Wagtail iframe preview. Preview URLs include a short-lived signed token so the frontend can safely fetch draft content from the CMS.

Additionally, allow users to select to set "Embargo Date" by selecting a virtual date in order to preview otherwise embargoed data.

## Workflow

- Editors see a custom "Preview" button in the CMS if the page type allows previews.
- When clicked, the CMS generates a short-lived, signed token and redirects the editor’s browser to the frontend preview URL with this token.
- The frontend uses the token to securely fetch the latest draft content from the CMS via the drafts API endpoint.
- The API validates the token (including expiry and page ID) before returning draft content.
- Preview enablement is controlled by a flag on each page type.
- The system avoids Wagtail’s built-in iframe preview, using external redirects and API calls for a secure, modern preview experience.
- Security is enforced by short token lifetimes, HMAC signing, and requiring tokens in Authorization headers for API access.
- Should the user wish to set "Embargo Date" to view embargoed data, a datetime picker is available next to the Preview button. Upon selecting a datetime, the embargo time is sent to the frontend both as a querystring parameter (for display purposes) and as a key-value in the encrypted token payload, which is sent to the CMS. Conditional upon a valid auth token, the CMS is able to `set_embargo_time`, using a "virtual clock". All embargo queries are now designed to use the virtual clock, rather than timezone.now(). The function `virtual_clock.set_embargo_time` falls back to timezone.now().

## Deployment

IMPORTANT: We MUST set the environment variables defined below, particularly,

```bash
FRONTEND_URL
PAGE_PREVIEWS_ENABLED
```
See the section on environment variables, below.

## Architecture

### Component Flow Diagram

```mermaid
sequenceDiagram
participant Browser as Editor Browser
participant CMS as Wagtail Admin (CMS)
participant API as Django CMS API
participant FE as Next.js Frontend

Browser->>CMS: Load page editor
CMS-->>Browser: Render Preview button + Embargo Date picker
Note right of Browser: Preview button and datetime picker visible
Browser->>CMS: Select embargo datetime or 'now' in picker
Browser->>CMS: Click Preview
Browser->>API: GET /admin/redirect-to-frontend/{page_id}?et=<embargo_time>
API-->>API: Build signed token (includes embargo_time in payload)
API-->>Browser: 302 Location: /preview?slug=...&t=...&et=<embargo_time>
Browser->>FE: Follow 302 to frontend preview URL with et parameter
FE-->>FE: Check for 'et' parameter
FE-->>Browser: Display embargo time warning message (formatted datetime)
FE->>API: GET /api/drafts/{id} (Authorization: Bearer <token>)
API-->>API: Validate token (includes embargo_time)
API-->>API: Call virtual_clock.set_embargo_time(embargo_time)<br/>(fallback to timezone.now() if invalid)
Note right of API: Virtual clock set for this request
API-->>API: Query embargo data using<br/>virtual_clock.get_embargo_time()
API-->>FE: draft JSON (with embargoed data visible)
FE-->>Browser: Rendered preview page with Embargo Date data
```

### Embargo Date Picker

This feature allows editors to preview content and data at a virtual point in time, largely without changing the logic within components. This works by substituting calls to timezone.now() within embargo components with a call to `virtual_clock.get_embargo_time`.

- This works only if with `PAGE_PREVIEWS_ENABLED=true`
- In the CMS, the editor selects either a specific datetime or `now` from the Embargo Date picker.
- On Preview, the redirect API includes the embargo time as `et` in the frontend redirect querystring.
- At the time of writing, the frontend reads `et` and shows a warning/banner that the page is being viewed in Embargo Date mode, including a formatted date and time.
- The API validates the preview token before applying any virtual time.
- If the token is valid and contains embargo time information, the backend calls `virtual_clock.set_embargo_time(embargo_time_value: object, *, token: str)` for that request.
- If no valid embargo time is present, or the token is invalid, the clock logic falls back to `timezone.now()`.
- All embargo-aware components resolve the effective datetime through `virtual_clock.get_embargo_time()`, ensuring consistent behavior across queries.
- By means of a call to `_with_embargo_time(data: dict, embargo_time: int | None)`, the page is rendered, along with an additional field, `"embargo_time"`. The consumer has the option to inspect this for its own purposes.

### Caching

- Page previews allows for caching on demand. This is effected by inspecting the `Cache-Control` headers passed in a request.
- Should `Cache-Control: no-store` be present in an API call (this functionality is restricted to the metrics api), there will be a cache "miss", under which all responses will be calculated afresh, bypassing the cache.
- This functionality endures for the duration of the request and is isolated to the request. No other requests will be affected as the configuration is scoped within the **execution context** (this supports async) of the request.
- This functionality has been implemented using [ContextVar](https://docs.python.org/3/library/contextvars.html) and keeps the codebase interfaces largely untouched. This keeps caching concerns orthogonal to the application logic, following the original caching design.

```mermaid
sequenceDiagram
participant Client as FrontEnd Next.js
participant MiddlewareConfig as Middleware Config (default.py)
participant RequestScopedCachingConfigMiddleware as (class: RequestScopedCachingConfigMiddleware)
participant _is_custom_api_request as RequestScopedCachingConfigMiddleware._is_custom_api_request
participant _set_no_cache_if_header_is_valid as RequestScopedCachingConfigMiddleware._set_no_cache_if_header_is_valid
participant request_caching
participant get_cache_control_header as shared.get_cache_control_header
participant decorator as decorator
participant View

Client->>MiddlewareConfig: request via URL
MiddlewareConfig->>RequestScopedCachingConfigMiddleware: configure middleware handler
RequestScopedCachingConfigMiddleware->>_is_custom_api_request: check request is /api path
_is_custom_api_request->>_set_no_cache_if_header_is_valid: check header and set no cache
_set_no_cache_if_header_is_valid->>request_caching: get_cache_control_header, disable_request_caching
request_caching->>get_cache_control_header: get Cache-Control header
get_cache_control_header-->request_caching: "Cache-Control: no-store, disable_request_caching"
request_caching->>request_caching: (context var)
View->>decorator:[conditionally] fetch cached
decorator->>request_caching: get_request_caching and then fetch cached or uncached
View-->Client: return payload
alt get_request_caching disabled
decorator->>request_caching: get_request_caching and then fetch uncached
else get_request_caching None
decorator->>request_caching: get_request_caching and then fetch according to system configured caching i.e. decorator.is_caching_v2_enabled()
end

```

### Dependency Diagram for Buttons Setup

```mermaid
graph TD
wagtail_hooks.frontend_preview_button["wagtail_hooks.frontend_preview_button</br>(page header)<br/>"] --> | and | wagtail_hooks.add_frontend_preview_action
wagtail_hooks.add_frontend_preview_action["wagtail_hooks.add_frontend_preview_action</br>(action menu)"] --> | conditionally call | wagtail_hooks._build_frontend_redirect_url
wagtail_hooks.add_frontend_preview_action --> | conditionally call | wagtail_hooks._build_frontend_redirect_url
wagtail_hooks._build_frontend_redirect_url --> | calls | reverse
wagtail_hooks.register_admin_urls --> | registers | FrontendRedirectViewURL
reverse --> | looks up | FrontendRedirectViewURL
ViewLiveButton --> | calls | FrontendRedirectViewURL
FrontendRedirectViewURL --> | resolves to | FrontendRedirectView
PreviewButton --> | calls via JavaScript with embargo_time qs parameter | FrontendRedirectViewURL
```

- In `wagtail_hooks.py`, the `frontend_preview_button` method and `add_frontend_preview_action` conditionally call `wagtail_hooks._build_frontend_redirect_url` (based upon "View Live" or "Preview" actions) and by passing `route` (as 'preview' or 'nocache') in a querystring parameter.
- `build_frontend_redirect_url` calls Django the built-in, `reverse()`, to resolve the URL for the `FrontendRedirectView` API endpoint.
- The `FrontendRedirectView` is registered by `wagtail_hooks.register_admin_urls` using a built-in wagtail hook, which is a called method that is appropriately decorated.
- Django takes care of resolving `FrontendRedirectViewURL` to `FrontendRedirectView`, ensuring that the view is executed when the API endpoint is hit.
- The general workflow starts with a button click (`Preview` or `View Live`). The button calls the admin url to hit the Redirect API which is backed by the (FrontendRedirectView). The Redirect API generates the browser with HTTP redirect (which you can see in the Location header) and generates a querystring with an HMAC token, which expires shortly. The browser obeys the redirect and loads the Front End with the URL. The Front End builds its components tree, making use of API calls back to the CMS with the HMAC token, which is passed in the header, `cms-drafts-auth`. Authentication is required in the following scenarios:

- routes to `/preview*`
- routes to `/nocache*`


### Security

- **Token TTL**: 30-second expiry limits exposure window. This is configurable - we are estimating a max 30 second window for the round-trip transaction for the CMS to obtain a redirect URL and for the front-end to fetch its data.
- **HMAC signing**: Tokens cryptographically signed, cannot be forged
- **Salt isolation**: Preview tokens use dedicated salt, separate from session tokens. The salt is deterministically generated along with the Django `SECRET_KEY`. Note: every worker instance must use the same salt, so as to avoid user-request inconsistencies.
- **Bearer vs querystring**: Token transmitted in Authorization header to API (reduces logging exposure), though initially passed via querystring in redirect (acceptable for short-lived tokens)
- **Prevention of replay attacks**: Each token includes `iat` timestamp and specific `page_id`, limiting reuse scope
- **URL Validation**: The CMS Redirect API checks the input and validates generated redirect urls and parameters against an approved list. Currently, it allows only the configured front end url to be generated. Also, the `route` querystring parameter can only be `preview` or `nocache`. No other routes are permitted. This protects against unwanted manipulation.

## Environment Variables

Set these up in an environment file (such as env.local)
(These are defined with default values in default.py and local.py)

```bash
PAGE_PREVIEWS_ENABLED = False # Allows the server to disable or enable page previews
FRONTEND_URL = 'http://localhost:3000' # The base URL for the front-end application. Allows the CMS to send the browser to the frontend on the click of a button. This variable has no default value - you must provide a value in a .env file.
PAGE_PREVIEWS_TOKEN_TTL_SECONDS = 30 # The front end receives a presigned url. This setting defines the token expiry window. It is recommended to keep this as low as possible, and can possibly be set to as low as 30 seconds, the time it takes for the front end to render the page. Default is 30 seconds. It is recommended to set this to a higher threshold in development environments (e.g. 86400, which is 24 hours).
```

5 changes: 0 additions & 5 deletions cms/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,6 @@ class CommonPage(UKHSAPage):

objects = CommonPageManager()

Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
@classmethod
def is_previewable(cls) -> bool:
"""Returns False. Since this is a headless CMS the preview panel is not supported"""
return False


class CommonPageRelatedLink(UKHSAPageRelatedLink):
page = ParentalKey(
Expand Down
5 changes: 0 additions & 5 deletions cms/composite/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,6 @@ class CompositePage(UKHSAPage):

objects = CompositePageManager()

@classmethod
def is_previewable(cls) -> bool:
"""Returns False. Since this is a headless CMS the preview panel is not supported"""
return False

@property
def last_updated_at(self) -> datetime.datetime:
"""Takes the most recent update of this page and any of its children
Expand Down
7 changes: 5 additions & 2 deletions cms/dashboard/management/commands/build_cms_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import os
from urllib.parse import urlparse

from django.core.management.base import BaseCommand
from wagtail.models import Page, Site
Expand Down Expand Up @@ -49,9 +50,11 @@ def handle(self, *args, **options):
wagtail_root_page.add_child(instance=root_page)
wagtail_root_page.save_revision().publish()

frontend_url = os.environ.get("FRONTEND_URL", "localhost")

Site.objects.create(
hostname=os.environ.get("FRONTEND_URL", "localhost"),
port=443,
hostname=urlparse(frontend_url).hostname or "localhost",
port=urlparse(frontend_url).port or 443,
site_name=WAGTAIL_SITE_NAME,
root_page=root_page,
is_default_site=True,
Expand Down
41 changes: 41 additions & 0 deletions cms/dashboard/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import datetime
import logging
from decimal import Decimal
from typing import override
from urllib.parse import urlsplit

from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
Expand All @@ -13,6 +17,8 @@

from cms import seo

logger = logging.getLogger(__name__)

HEADING_2: str = "h2"
HEADING_3: str = "h3"
HEADING_4: str = "h4"
Expand Down Expand Up @@ -52,6 +58,13 @@ class UKHSAPage(Page):

"""

Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
# Overridable setting for disabling previews
# on derived UKHSAPage objects.
# When disabled, the CMS will not allow page previews.
# Page previews allow the user to view draft content
# and pre-release, "embargoed" data.
custom_preview_enabled: bool = True
Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.

body = RichTextField(features=AVAILABLE_RICH_TEXT_FEATURES)
seo_change_frequency = models.IntegerField(
verbose_name="SEO change frequency",
Expand Down Expand Up @@ -107,6 +120,34 @@ class UKHSAPage(Page):
class Meta:
abstract = True

Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
@override
def is_previewable(self) -> bool:
"""Disable built-in Wagtail preview for all headless dashboard pages."""
return False

@override
def get_url(self, request=None, current_site=None) -> str | None:
"""Return an absolute frontend URL for headless dashboard pages.
We want admin "View live" actions to target the frontend host consistently,
using the configured FRONTEND_URL
"""
resolved_url = super().get_url(request=request, current_site=current_site)
if not resolved_url:
return resolved_url

base_url = getattr(settings, "FRONTEND_URL", "")
base_parts = urlsplit(base_url)
if base_parts.scheme not in {"http", "https"} or not base_parts.netloc:
logger.error(
"FRONTEND_URL is not an absolute http(s) URL; "
"falling back to relative page URL"
)
return resolved_url

resolved_parts = urlsplit(str(resolved_url))
page_path = resolved_parts.path or str(resolved_url)
return f"{base_url.rstrip('/')}/{page_path.lstrip('/')}"

def _raise_error_if_slug_not_unique(self) -> None:
"""Compares the provided slug against all pages to confirm the slug's `uniqueness`
this is against all pages and not just siblings, which is the default behavior of wagtail.
Expand Down
Loading
Loading