-
Notifications
You must be signed in to change notification settings - Fork 4
task/CDD 1379 page previews #3077
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jeanpierrefouche-ukhsa
wants to merge
2
commits into
main
Choose a base branch
from
task/CDD-1379-page-previews
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,7 @@ htmlcov/ | |
| .cache | ||
| nosetests.xml | ||
| coverage.xml | ||
| coverage.json | ||
| *.cover | ||
| *.py,cover | ||
| .hypothesis/ | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). | ||
| ``` | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.