From c78e3b67b2231ea9805d7421a805d5844fcb1560 Mon Sep 17 00:00:00 2001 From: Jean Pierre Fouche <261405437+jeanpierrefouche-ukhsa@users.noreply.github.com> Date: Tue, 19 May 2026 21:15:17 +0100 Subject: [PATCH 1/2] task/CDD-1379-page-previews Enable page previews to the configured frontend URL - with set embargo date - support for per-request disabling of caching via Cache-Control headers from the client --- .gitignore | 1 + caching/private_api/decorators.py | 23 +- .../2026-02-27/CDD-1379.page-previews.md | 143 +++ cms/common/models.py | 5 - cms/composite/models.py | 5 - .../management/commands/build_cms_site.py | 7 +- cms/dashboard/models.py | 41 + cms/dashboard/serializers.py | 34 - .../static/css/embargo_time_picker.css | 214 ++++ .../static/css/vendor/flatpickr.min.css | 14 + .../static/js/embargo_time_picker.js | 252 ++++ .../js/embargo_time_preview_controls.js | 48 + .../static/js/vendor/flatpickr.min.js | 3 + cms/dashboard/templates/icons/preview.svg | 25 + cms/dashboard/templates/icons/rocket.svg | 41 + .../templates/wagtailadmin/base.html | 4 + .../pages/action_menu/frontend_preview.html | 34 + .../pages/action_menu/frontend_viewlive.html | 6 + cms/dashboard/views.py | 294 +++++ cms/dashboard/viewsets.py | 132 +- cms/dashboard/wagtail_hooks.py | 300 +++++ cms/home/models/landing_page.py | 5 - cms/topic/models.py | 5 - common/request_caching.py | 32 + common/virtual_clock.py | 100 ++ docs/environment_variables.md | 30 + metrics/api/middleware.py | 146 +++ metrics/api/settings/default.py | 45 +- metrics/api/settings/local.py | 17 +- metrics/api/settings/public_api.py | 1 + .../data/managers/api_models/time_series.py | 5 +- metrics/data/managers/core_models/headline.py | 8 +- .../data/managers/core_models/time_series.py | 63 +- .../weather_health_alerts/access.py | 4 +- requirements-prod.txt | 2 +- scripts/_quality.sh | 2 +- tests/integration/cms/test_api.py | 46 +- tests/system/test_build_cms_site.py | 12 +- .../caching/private_api/test_decorators.py | 48 +- tests/unit/cms/dashboard/test_models.py | 227 +++- .../dashboard/test_preview_views_and_hooks.py | 800 ++++++++++++ tests/unit/cms/dashboard/test_serializers.py | 50 - tests/unit/cms/dashboard/test_viewsets.py | 475 ++++++- .../unit/cms/dashboard/test_virtual_clock.py | 179 +++ .../unit/cms/dashboard/test_wagtail_hooks.py | 1087 +++++++++++++++-- .../unit/metrics/api/test_middleware copy.py | 70 ++ tests/unit/metrics/api/test_middleware.py | 276 +++++ .../metrics/api/test_settings_preview_salt.py | 110 ++ .../managers/core_models/test_time_series.py | 18 + validation/shared.py | 114 ++ 50 files changed, 5269 insertions(+), 334 deletions(-) create mode 100644 changelog/2026-02-27/CDD-1379.page-previews.md create mode 100644 cms/dashboard/static/css/embargo_time_picker.css create mode 100644 cms/dashboard/static/css/vendor/flatpickr.min.css create mode 100644 cms/dashboard/static/js/embargo_time_picker.js create mode 100644 cms/dashboard/static/js/embargo_time_preview_controls.js create mode 100644 cms/dashboard/static/js/vendor/flatpickr.min.js create mode 100644 cms/dashboard/templates/icons/preview.svg create mode 100644 cms/dashboard/templates/icons/rocket.svg create mode 100644 cms/dashboard/templates/wagtailadmin/pages/action_menu/frontend_preview.html create mode 100644 cms/dashboard/templates/wagtailadmin/pages/action_menu/frontend_viewlive.html create mode 100644 common/request_caching.py create mode 100644 common/virtual_clock.py create mode 100644 metrics/api/middleware.py create mode 100644 tests/unit/cms/dashboard/test_preview_views_and_hooks.py delete mode 100644 tests/unit/cms/dashboard/test_serializers.py create mode 100644 tests/unit/cms/dashboard/test_virtual_clock.py create mode 100644 tests/unit/metrics/api/test_middleware copy.py create mode 100644 tests/unit/metrics/api/test_middleware.py create mode 100644 tests/unit/metrics/api/test_settings_preview_salt.py diff --git a/.gitignore b/.gitignore index af21fc680..44d7148f4 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +coverage.json *.cover *.py,cover .hypothesis/ diff --git a/caching/private_api/decorators.py b/caching/private_api/decorators.py index e647fc7f0..a611269e3 100644 --- a/caching/private_api/decorators.py +++ b/caching/private_api/decorators.py @@ -1,3 +1,4 @@ +import logging import os from functools import wraps @@ -5,6 +6,9 @@ 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): ... @@ -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, ) @@ -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 @@ -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 @@ -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 diff --git a/changelog/2026-02-27/CDD-1379.page-previews.md b/changelog/2026-02-27/CDD-1379.page-previews.md new file mode 100644 index 000000000..6cb404c45 --- /dev/null +++ b/changelog/2026-02-27/CDD-1379.page-previews.md @@ -0,0 +1,143 @@ +# 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/preview-to-frontend/{page_id}?et= + API-->>API: Build signed token (includes embargo_time in payload) + API-->>Browser: 302 Location: /preview?slug=...&t=...&et= + 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 ) + API-->>API: Validate token (includes embargo_time) + API-->>API: Call virtual_clock.set_embargo_time(embargo_time)
(fallback to timezone.now() if invalid) + Note right of API: Virtual clock set for this request + API-->>API: Query embargo data using
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 `context` (broadly speaking, the thread) 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 + +``` + + +### 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 + +## 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). +``` + diff --git a/cms/common/models.py b/cms/common/models.py index 2e5c62429..6e9aed30f 100644 --- a/cms/common/models.py +++ b/cms/common/models.py @@ -53,11 +53,6 @@ class CommonPage(UKHSAPage): objects = CommonPageManager() - @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( diff --git a/cms/composite/models.py b/cms/composite/models.py index eb823996d..7a3ab9089 100644 --- a/cms/composite/models.py +++ b/cms/composite/models.py @@ -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 diff --git a/cms/dashboard/management/commands/build_cms_site.py b/cms/dashboard/management/commands/build_cms_site.py index 8f630d78a..fb68896b4 100644 --- a/cms/dashboard/management/commands/build_cms_site.py +++ b/cms/dashboard/management/commands/build_cms_site.py @@ -4,6 +4,7 @@ """ import os +from urllib.parse import urlparse from django.core.management.base import BaseCommand from wagtail.models import Page, Site @@ -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, diff --git a/cms/dashboard/models.py b/cms/dashboard/models.py index f94bb4368..ec3aedc84 100644 --- a/cms/dashboard/models.py +++ b/cms/dashboard/models.py @@ -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 @@ -13,6 +17,8 @@ from cms import seo +logger = logging.getLogger(__name__) + HEADING_2: str = "h2" HEADING_3: str = "h3" HEADING_4: str = "h4" @@ -52,6 +58,13 @@ class UKHSAPage(Page): """ + # 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 + body = RichTextField(features=AVAILABLE_RICH_TEXT_FEATURES) seo_change_frequency = models.IntegerField( verbose_name="SEO change frequency", @@ -107,6 +120,34 @@ class UKHSAPage(Page): class Meta: abstract = True + @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. diff --git a/cms/dashboard/serializers.py b/cms/dashboard/serializers.py index de75db794..2e752f70d 100644 --- a/cms/dashboard/serializers.py +++ b/cms/dashboard/serializers.py @@ -1,41 +1,7 @@ -from collections import OrderedDict - -from rest_framework import serializers from wagtail.api.v2.views import PageSerializer -from wagtail.models import Page from cms.dashboard.fields import ListablePageParentField -PAGE_HAS_NO_DRAFTS = ( - "Page has no unpublished changes. Use the `api/pages/` for live pages instead." -) - - -class CMSDraftPagesSerializer(PageSerializer): - class Meta: - model = Page - fields = "__all__" - - def to_representation(self, instance: Page) -> OrderedDict: - """Provides a representation of the serialized instance - - Notes: - This provides some additional logic to ensure - the `instance` being serialized has unpublished changes. - If not, then the serializer is invalidated - - Args: - instance: The `Page` model being serialized - - Returns: - A dict representation of the serialized instance - - """ - if not instance.has_unpublished_changes: - raise serializers.ValidationError({"error_message": PAGE_HAS_NO_DRAFTS}) - - return super().to_representation(instance=instance) - class ListablePageSerializer(PageSerializer): parent = ListablePageParentField(read_only=True) diff --git a/cms/dashboard/static/css/embargo_time_picker.css b/cms/dashboard/static/css/embargo_time_picker.css new file mode 100644 index 000000000..75ebe9956 --- /dev/null +++ b/cms/dashboard/static/css/embargo_time_picker.css @@ -0,0 +1,214 @@ +#embargo-time-modal { + padding: 0; + border: 0; + background: transparent; + z-index: 9999; + max-width: none; + max-height: none; + width: 100vw; + height: 100vh; +} + +#embargo-time-modal[open] { + display: flex; + align-items: center; + justify-content: center; +} + +#embargo-time-modal::backdrop { + background: rgba(0, 0, 0, 0.3); +} + +.embargo-modal-content { + background: #fff; + padding: 2em; + border-radius: 8px; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2); + min-width: 300px; +} + +.embargo-modal-title { + margin: 0 0 1em 0; + font-size: 1.19em; + font-weight: 700; + color: #111; +} + +.embargo-modal-buttons { + display: flex; + gap: 0.5em; + margin-top: 1.5em; +} + +.embargo-modal-content .embargo-modal-btn { + margin: 0.5em 0.5em 0 0; + font-size: 0.85em; + padding: 0.3em 1.1em; + height: 2.1em; + line-height: 1.2; + min-width: 0; +} + +.embargo-modal-content .embargo-modal-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.embargo-picker-input, +.flatpickr-calendar .embargo-native-header-hidden { + display: none; +} + +.flatpickr-calendar .embargo-native-header-hidden, +.flatpickr-calendar .embargo-native-header-hidden .flatpickr-month { + display: none; +} + +/* Prevent accidental text highlighting on Embargo Date controls. */ +#embargo-time-btn-preview, +#embargo-time-btn-preview *, +.embargo-modal-btn, +.embargo-modal-btn *, +.flatpickr-calendar .embargo-nav-btn, +.flatpickr-calendar .embargo-nav-btn *, +.flatpickr-calendar .embargo-nav-label, +.flatpickr-calendar .embargo-month-display, +.flatpickr-calendar .embargo-year-btn, +.flatpickr-calendar .embargo-year-display { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.flatpickr-calendar .embargo-custom-nav { + position: relative; + display: grid; + grid-template-columns: auto minmax(8em, 1fr) auto; + align-items: center; + gap: 6px; + min-height: 1.8em; + width: 100%; + margin-bottom: 8px; + white-space: nowrap; +} + +.flatpickr-calendar .embargo-custom-nav-group { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + justify-self: start; +} + +.flatpickr-calendar .embargo-custom-nav-group-end { + justify-self: end; +} + +.flatpickr-calendar .embargo-nav-btn { + border: 0; + background: transparent; + color: #111; + cursor: pointer; + font-size: 1em; + line-height: 1; + padding: 0 4px; + height: 1.8em; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.flatpickr-calendar .embargo-nav-btn:hover { + color: #0b57d0; +} + +.flatpickr-calendar .embargo-nav-label { + position: absolute; + left: 50%; + transform: translateX(-50%); + min-width: 8em; + text-align: center; + line-height: 1.8em; + color: #111; + font-weight: 600; + white-space: nowrap; + pointer-events: none; + z-index: 1; +} + +.flatpickr-calendar .flatpickr-monthDropdown-months { + font-size: 0.95em; + padding: 0 2px; + margin: 0; + height: 1.8em; +} + +.flatpickr-calendar .embargo-month-display { + min-width: 6.4em; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + height: 1.8em; + text-align: center; + line-height: 1.8em; +} + +.flatpickr-calendar .numInputWrapper { + overflow: visible; + padding-bottom: 8px; +} + +.flatpickr-calendar .numInput.cur-year { + font-size: 0.95em; + width: 3.5em; + height: auto; + line-height: 1.2; + padding-bottom: 0; +} + +.flatpickr-calendar .embargo-year-controls { + display: inline-flex !important; + align-items: center !important; + flex-wrap: nowrap !important; + flex: 0 0 auto; + gap: 3px; + margin: 0; + position: static; +} + +.flatpickr-calendar .embargo-year-btn { + border: 1px solid #d9d9d9; + background: #fff; + border-radius: 4px; + width: 1.8em; + height: 1.8em; + line-height: 1; + padding: 0; + cursor: pointer; +} + +.flatpickr-calendar .embargo-year-btn:hover { + background: #f5f5f5; +} + +.flatpickr-calendar .embargo-year-display { + min-width: 2.6em; + display: flex; + align-items: center; + justify-content: center; + height: 1.8em; + text-align: center; + line-height: 1.8em; +} + +.flatpickr-calendar .arrowUp, +.flatpickr-calendar .arrowDown { + height: 0.7em; +} + +.flatpickr-calendar .flatpickr-prev-month, +.flatpickr-calendar .flatpickr-next-month { + display: none !important; +} + diff --git a/cms/dashboard/static/css/vendor/flatpickr.min.css b/cms/dashboard/static/css/vendor/flatpickr.min.css new file mode 100644 index 000000000..922ee1728 --- /dev/null +++ b/cms/dashboard/static/css/vendor/flatpickr.min.css @@ -0,0 +1,14 @@ +/* Vendor source: https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css */ +.flatpickr-calendar{background:transparent;opacity:0;display:none;text-align:center;visibility:hidden;padding:0;-webkit-animation:none;animation:none;direction:ltr;border:0;font-size:14px;line-height:24px;border-radius:5px;position:absolute;width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-touch-action:manipulation;touch-action:manipulation;background:#fff;-webkit-box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08);box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;max-height:640px;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1);animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none !important;box-shadow:none !important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid #e6e6e6}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:'';height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative;display:inline-block}.flatpickr-months{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-months .flatpickr-month{background:transparent;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9);height:34px;line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;cursor:pointer;position:absolute;top:0;height:34px;padding:10px;z-index:3;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{/* + /*rtl:begin:ignore*/left:0/* + /*rtl:end:ignore*/}/* + /*rtl:begin:ignore*/ +/* + /*rtl:end:ignore*/ +.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,.flatpickr-months .flatpickr-next-month.flatpickr-next-month{/* + /*rtl:begin:ignore*/right:0/* + /*rtl:end:ignore*/}/* + /*rtl:begin:ignore*/ +/* + /*rtl:end:ignore*/ +.flatpickr-months .flatpickr-prev-month:hover,.flatpickr-months .flatpickr-next-month:hover{color:#959ea9}.flatpickr-months .flatpickr-prev-month:hover svg,.flatpickr-months .flatpickr-next-month:hover svg{fill:#f64747}.flatpickr-months .flatpickr-prev-month svg,.flatpickr-months .flatpickr-next-month svg{width:14px;height:14px}.flatpickr-months .flatpickr-prev-month svg path,.flatpickr-months .flatpickr-next-month svg path{-webkit-transition:fill .1s;transition:fill .1s;fill:inherit}.numInputWrapper{position:relative;height:auto}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-outer-spin-button,.numInputWrapper input::-webkit-inner-spin-button{margin:0;-webkit-appearance:none}.numInputWrapper span{position:absolute;right:0;width:14px;padding:0 4px 0 2px;height:50%;line-height:50%;opacity:0;cursor:pointer;border:1px solid rgba(57,57,57,0.15);-webkit-box-sizing:border-box;box-sizing:border-box}.numInputWrapper span:hover{background:rgba(0,0,0,0.1)}.numInputWrapper span:active{background:rgba(0,0,0,0.2)}.numInputWrapper span:after{display:block;content:"";position:absolute}.numInputWrapper span.arrowUp{top:0;border-bottom:0}.numInputWrapper span.arrowUp:after{border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:4px solid rgba(57,57,57,0.6);top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,0.6);top:40%}.numInputWrapper span svg{width:inherit;height:auto}.numInputWrapper span svg path{fill:rgba(0,0,0,0.5)}.numInputWrapper:hover{background:rgba(0,0,0,0.05)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{font-size:135%;line-height:inherit;font-weight:300;color:inherit;position:absolute;width:75%;left:12.5%;padding:7.48px 0 0 0;line-height:1;height:34px;display:inline-block;text-align:center;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.flatpickr-current-month span.cur-month{font-family:inherit;font-weight:700;color:inherit;display:inline-block;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .numInputWrapper{width:6ch;width:7ch\0;display:inline-block}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:rgba(0,0,0,0.9)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:rgba(0,0,0,0.9)}.flatpickr-current-month input.cur-year{background:transparent;-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;cursor:text;padding:0 0 0 .5ch;margin:0;display:inline-block;font-size:inherit;font-family:inherit;font-weight:300;line-height:inherit;height:auto;border:0;border-radius:0;vertical-align:initial;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{font-size:100%;color:rgba(0,0,0,0.5);background:transparent;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;background:transparent;border:none;border-radius:0;box-sizing:border-box;color:inherit;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:300;height:auto;line-height:inherit;margin:-1px 0 0 0;outline:none;padding:0 0 0 .5ch;position:relative;vertical-align:initial;-webkit-box-sizing:border-box;-webkit-appearance:menulist;-moz-appearance:menulist;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:focus,.flatpickr-current-month .flatpickr-monthDropdown-months:active{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{background:transparent;text-align:center;overflow:hidden;width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:28px}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}span.flatpickr-weekday{cursor:default;font-size:90%;background:transparent;color:rgba(0,0,0,0.54);line-height:1;margin:0;text-align:center;display:block;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;font-weight:bolder}.dayContainer,.flatpickr-weeks{padding:1px 0 0 0}.flatpickr-days{position:relative;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start;width:307.875px}.flatpickr-days:focus{outline:0}.dayContainer{padding:0;outline:0;text-align:left;width:307.875px;min-width:307.875px;max-width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;display:inline-block;display:-ms-flexbox;display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-wrap:wrap;-ms-flex-pack:justify;-webkit-justify-content:space-around;justify-content:space-around;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1}.dayContainer + .dayContainer{-webkit-box-shadow:-1px 0 0 #e6e6e6;box-shadow:-1px 0 0 #e6e6e6}.flatpickr-day{background:none;border:1px solid transparent;border-radius:150px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#393939;cursor:pointer;font-weight:400;width:14.2857143%;-webkit-flex-basis:14.2857143%;-ms-flex-preferred-size:14.2857143%;flex-basis:14.2857143%;max-width:39px;height:39px;line-height:39px;margin:0;display:inline-block;position:relative;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;text-align:center}.flatpickr-day.inRange,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.today.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day:hover,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.nextMonthDay:hover,.flatpickr-day:focus,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.nextMonthDay:focus{cursor:pointer;outline:0;background:#e6e6e6;border-color:#e6e6e6}.flatpickr-day.today{border-color:#959ea9}.flatpickr-day.today:hover,.flatpickr-day.today:focus{border-color:#959ea9;background:#959ea9;color:#fff}.flatpickr-day.selected,.flatpickr-day.startRange,.flatpickr-day.endRange,.flatpickr-day.selected.inRange,.flatpickr-day.startRange.inRange,.flatpickr-day.endRange.inRange,.flatpickr-day.selected:focus,.flatpickr-day.startRange:focus,.flatpickr-day.endRange:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange:hover,.flatpickr-day.endRange:hover,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.endRange.nextMonthDay{background:#569ff7;-webkit-box-shadow:none;box-shadow:none;color:#fff;border-color:#569ff7}.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange,.flatpickr-day.endRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange,.flatpickr-day.endRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)){-webkit-box-shadow:-10px 0 0 #569ff7;box-shadow:-10px 0 0 #569ff7}.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange,.flatpickr-day.endRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;-webkit-box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.prevMonthDay,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.notAllowed.nextMonthDay{color:rgba(57,57,57,0.3);background:transparent;border-color:transparent;cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{cursor:not-allowed;color:rgba(57,57,57,0.1)}.flatpickr-day.week.selected{border-radius:0;-webkit-box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7;box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{padding:0 12px;-webkit-box-shadow:1px 0 0 #e6e6e6;box-shadow:1px 0 0 #e6e6e6}.flatpickr-weekwrapper .flatpickr-weekday{float:none;width:100%;line-height:28px}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{display:block;width:100%;max-width:none;color:rgba(57,57,57,0.3);background:transparent;cursor:default;border:none}.flatpickr-innerContainer{display:block;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.flatpickr-rContainer{display:inline-block;padding:0;-webkit-box-sizing:border-box;box-sizing:border-box}.flatpickr-time{text-align:center;outline:0;display:block;height:0;line-height:40px;max-height:40px;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-time:after{content:"";display:table;clear:both}.flatpickr-time .numInputWrapper{-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;width:40%;height:40px;float:left}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:#393939}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:#393939}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{background:transparent;-webkit-box-shadow:none;box-shadow:none;border:0;border-radius:0;text-align:center;margin:0;padding:0;height:inherit;line-height:inherit;color:#393939;font-size:14px;position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-time input.flatpickr-hour{font-weight:bold}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{outline:0;border:0}.flatpickr-time .flatpickr-time-separator,.flatpickr-time .flatpickr-am-pm{height:inherit;float:left;line-height:inherit;color:#393939;font-weight:bold;width:2%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.flatpickr-time .flatpickr-am-pm{outline:0;width:18%;cursor:pointer;text-align:center;font-weight:400}.flatpickr-time input:hover,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time .flatpickr-am-pm:focus{background:#eee}.flatpickr-input[readonly]{cursor:pointer}@-webkit-keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}@keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}} \ No newline at end of file diff --git a/cms/dashboard/static/js/embargo_time_picker.js b/cms/dashboard/static/js/embargo_time_picker.js new file mode 100644 index 000000000..3c92136e7 --- /dev/null +++ b/cms/dashboard/static/js/embargo_time_picker.js @@ -0,0 +1,252 @@ +// Generic date picker launcher used by the embargo preview controls. + +const createDateTimePickerLauncher = ({ + modal, + calendarContainer, + okBtn, + nowBtn, + cancelBtn, +} = {}) => { + if (!modal || !calendarContainer || !okBtn || !nowBtn || !cancelBtn) { + return async () => null; + } + + let previouslyFocusedElement = null; + + const openModal = () => { + if (!modal) { + return; + } + + if (typeof modal.showModal === 'function') { + modal.showModal(); + return; + } + + modal.setAttribute('open', 'open'); + }; + + const closeModal = () => { + if (modal) { + if (typeof modal.close === 'function' && modal.open) { + modal.close(); + } else { + modal.removeAttribute('open'); + } + } + // Restore focus to the element that opened the modal + if (previouslyFocusedElement) { + previouslyFocusedElement.focus(); + previouslyFocusedElement = null; + } + }; + + const setOkButtonEnabled = (isEnabled) => { + if (!okBtn) return; + okBtn.disabled = !isEnabled; + }; + + const setupYearControls = (instance) => { + const calendar = instance.calendarContainer; + const monthsContainer = calendar.querySelector('.flatpickr-months'); + const innerContainer = calendar.querySelector('.flatpickr-innerContainer'); + if (!monthsContainer || !innerContainer) return; + + monthsContainer.classList.add('embargo-native-header-hidden'); + + let navRow = calendar.querySelector('.embargo-custom-nav'); + if (!navRow) { + navRow = document.createElement('div'); + navRow.className = 'embargo-custom-nav'; + innerContainer.before(navRow); + } + + const monthName = instance.l10n?.months?.longhand?.[instance.currentMonth] || ''; + const yearNumber = Number.isInteger(instance.currentYear) ? String(instance.currentYear) : ''; + const monthYearLabel = `${monthName} ${yearNumber}`.trim() || 'Month Year'; + navRow.innerHTML = ''; + + const leftControls = document.createElement('div'); + leftControls.className = 'embargo-custom-nav-group'; + const rightControls = document.createElement('div'); + rightControls.className = 'embargo-custom-nav-group'; + rightControls.classList.add('embargo-custom-nav-group-end'); + + const monthDisplay = document.createElement('div'); + monthDisplay.className = 'embargo-nav-label'; + monthDisplay.textContent = monthYearLabel; + + const prevYearBtn = document.createElement('button'); + prevYearBtn.type = 'button'; + prevYearBtn.className = 'embargo-nav-btn'; + prevYearBtn.textContent = '<<'; + prevYearBtn.title = 'Previous year'; + prevYearBtn.setAttribute('aria-label', 'Previous year'); + prevYearBtn.onclick = (e) => { + e.preventDefault(); + instance.changeYear(instance.currentYear - 1); + setupYearControls(instance); + }; + + const prevMonthBtn = document.createElement('button'); + prevMonthBtn.type = 'button'; + prevMonthBtn.className = 'embargo-nav-btn'; + prevMonthBtn.textContent = '<'; + prevMonthBtn.title = 'Previous month'; + prevMonthBtn.setAttribute('aria-label', 'Previous month'); + prevMonthBtn.onclick = (e) => { + e.preventDefault(); + instance.changeMonth(-1); + setupYearControls(instance); + }; + + const nextMonthBtn = document.createElement('button'); + nextMonthBtn.type = 'button'; + nextMonthBtn.className = 'embargo-nav-btn'; + nextMonthBtn.textContent = '>'; + nextMonthBtn.title = 'Next month'; + nextMonthBtn.setAttribute('aria-label', 'Next month'); + nextMonthBtn.onclick = (e) => { + e.preventDefault(); + instance.changeMonth(1); + setupYearControls(instance); + }; + + const nextYearBtn = document.createElement('button'); + nextYearBtn.type = 'button'; + nextYearBtn.className = 'embargo-nav-btn'; + nextYearBtn.textContent = '>>'; + nextYearBtn.title = 'Next year'; + nextYearBtn.setAttribute('aria-label', 'Next year'); + nextYearBtn.onclick = (e) => { + e.preventDefault(); + instance.changeYear(instance.currentYear + 1); + setupYearControls(instance); + }; + + leftControls.appendChild(prevYearBtn); + leftControls.appendChild(prevMonthBtn); + rightControls.appendChild(nextMonthBtn); + rightControls.appendChild(nextYearBtn); + + navRow.appendChild(leftControls); + navRow.appendChild(monthDisplay); + navRow.appendChild(rightControls); + }; + + return ({ defaultValue = 'now' } = {}) => new Promise((resolve) => { + previouslyFocusedElement = document.activeElement; + calendarContainer.innerHTML = ''; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'embargo-picker-input'; + calendarContainer.appendChild(input); + + const parsedDefaultDate = defaultValue === 'now' + ? new Date() + : new Date(Number(defaultValue) * 1000); + + let picker = null; + let isResolved = false; + + const cleanup = () => { + okBtn.removeEventListener('click', onOk); + nowBtn.removeEventListener('click', onNow); + cancelBtn.removeEventListener('click', onCancel); + modal.removeEventListener('cancel', onModalCancel); + document.removeEventListener('keydown', onKeyDown); + if (picker) { + picker.destroy(); + } + calendarContainer.innerHTML = ''; + }; + + const finish = (value) => { + if (isResolved) return; + isResolved = true; + cleanup(); + closeModal(); + resolve(value); + }; + + const onOk = (e) => { + e.preventDefault(); + const selected = picker?.selectedDates?.[0]; + if (!selected) return; + + const midnight = new Date(selected.getFullYear(), selected.getMonth(), selected.getDate()); + finish(Math.floor(midnight.getTime() / 1000).toString()); + }; + + const onNow = (e) => { + e.preventDefault(); + finish('now'); + }; + + const onCancel = (e) => { + e.preventDefault(); + finish(null); + }; + + const onModalCancel = (e) => { + e.preventDefault(); + finish(null); + }; + + const onKeyDown = (e) => { + if (e.key === 'Escape' && modal.open) { + e.preventDefault(); + finish(null); + } + }; + + okBtn.addEventListener('click', onOk); + nowBtn.addEventListener('click', onNow); + cancelBtn.addEventListener('click', onCancel); + modal.addEventListener('cancel', onModalCancel); + document.addEventListener('keydown', onKeyDown); + + picker = flatpickr(input, { + dateFormat: 'Y-m-d', + defaultDate: parsedDefaultDate, + inline: true, + monthSelectorType: 'static', + onReady: (_selectedDates, _dateStr, instance) => { + // Require an explicit day click each time the modal opens. + instance.clear(); + setOkButtonEnabled(false); + setupYearControls(instance); + }, + onChange: (selectedDates) => { + setOkButtonEnabled(selectedDates.length > 0); + }, + onMonthChange: (_selectedDates, _dateStr, instance) => { + setupYearControls(instance); + }, + onYearChange: (_selectedDates, _dateStr, instance) => { + setupYearControls(instance); + }, + }); + + openModal(); + // Move focus to OK button (or first focusable element in modal) + setTimeout(() => { + const firstFocusableElement = modal.querySelector('button'); + if (firstFocusableElement) { + firstFocusableElement.focus(); + } + }, 0); + }); +}; + +document.addEventListener('DOMContentLoaded', function () { + globalThis.launchDateTimePicker = createDateTimePickerLauncher({ + modal: document.getElementById('embargo-time-modal'), + calendarContainer: document.getElementById('embargo-calendar-container'), + okBtn: document.getElementById('embargo-ok-btn'), + nowBtn: document.getElementById('embargo-now-btn'), + cancelBtn: document.getElementById('embargo-cancel-btn'), + }); +}); + diff --git a/cms/dashboard/static/js/embargo_time_preview_controls.js b/cms/dashboard/static/js/embargo_time_preview_controls.js new file mode 100644 index 000000000..7e8cfd781 --- /dev/null +++ b/cms/dashboard/static/js/embargo_time_preview_controls.js @@ -0,0 +1,48 @@ +// Embargo preview controls wired to the generic date-time picker launcher. + +document.addEventListener('DOMContentLoaded', function () { + let embargoTime = 'now'; + + const embargoBtn = document.getElementById('embargo-time-btn-preview'); + const labelSpan = document.getElementById('embargo-time-btn-label'); + const previewBtn = document.querySelector('#preview-btn'); + + const syncButton = () => { + let display = 'now'; + if (embargoTime && embargoTime !== 'now') { + const dt = new Date(Number(embargoTime) * 1000); + display = new Intl.DateTimeFormat('en-GB', { + weekday: 'short', day: '2-digit', month: 'short', year: 'numeric', + }).format(dt); + } + if (embargoBtn) embargoBtn.title = `Set value: ${display}`; + if (labelSpan) labelSpan.textContent = display; + }; + + syncButton(); + + if (embargoBtn) { + embargoBtn.addEventListener('click', async (e) => { + e.preventDefault(); + const selectedValue = await globalThis.launchDateTimePicker?.({ defaultValue: embargoTime }); + if (selectedValue === null || selectedValue === undefined) { + return; + } + embargoTime = selectedValue; + syncButton(); + }); + } + + if (previewBtn) { + previewBtn.addEventListener('click', (e) => { + e.preventDefault(); + const baseUrl = previewBtn.dataset.url; + if (!baseUrl) return; + + let url = baseUrl; + const sep = url.includes('?') ? '&' : '?'; + url += sep + 'embargo_time=' + encodeURIComponent(embargoTime || 'now'); + window.open(url, '_blank', 'noopener'); + }); + } +}); diff --git a/cms/dashboard/static/js/vendor/flatpickr.min.js b/cms/dashboard/static/js/vendor/flatpickr.min.js new file mode 100644 index 000000000..2b7e288a7 --- /dev/null +++ b/cms/dashboard/static/js/vendor/flatpickr.min.js @@ -0,0 +1,3 @@ +/*! Vendor source: https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.js */ +/* flatpickr v4.6.13,, @license MIT */ +!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e="undefined"!=typeof globalThis?globalThis:e||self).flatpickr=n()}(this,(function(){"use strict";var e=function(){return(e=Object.assign||function(e){for(var n,t=1,a=arguments.length;t",noCalendar:!1,now:new Date,onChange:[],onClose:[],onDayCreate:[],onDestroy:[],onKeyDown:[],onMonthChange:[],onOpen:[],onParseConfig:[],onReady:[],onValueUpdate:[],onYearChange:[],onPreCalendarPosition:[],plugins:[],position:"auto",positionElement:void 0,prevArrow:"",shorthandCurrentMonth:!1,showMonths:1,static:!1,time_24hr:!1,weekNumbers:!1,wrap:!1},i={weekdays:{shorthand:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],longhand:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},months:{shorthand:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],longhand:["January","February","March","April","May","June","July","August","September","October","November","December"]},daysInMonth:[31,28,31,30,31,30,31,31,30,31,30,31],firstDayOfWeek:0,ordinal:function(e){var n=e%100;if(n>3&&n<21)return"th";switch(n%10){case 1:return"st";case 2:return"nd";case 3:return"rd";default:return"th"}},rangeSeparator:" to ",weekAbbreviation:"Wk",scrollTitle:"Scroll to increment",toggleTitle:"Click to toggle",amPM:["AM","PM"],yearAriaLabel:"Year",monthAriaLabel:"Month",hourAriaLabel:"Hour",minuteAriaLabel:"Minute",time_24hr:!1},o=function(e,n){return void 0===n&&(n=2),("000"+e).slice(-1*n)},r=function(e){return!0===e?1:0};function l(e,n){var t;return function(){var a=this,i=arguments;clearTimeout(t),t=setTimeout((function(){return e.apply(a,i)}),n)}}var c=function(e){return e instanceof Array?e:[e]};function s(e,n,t){if(!0===t)return e.classList.add(n);e.classList.remove(n)}function d(e,n,t){var a=window.document.createElement(e);return n=n||"",t=t||"",a.className=n,void 0!==t&&(a.textContent=t),a}function u(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function f(e,n){return n(e)?e:e.parentNode?f(e.parentNode,n):void 0}function m(e,n){var t=d("div","numInputWrapper"),a=d("input","numInput "+e),i=d("span","arrowUp"),o=d("span","arrowDown");if(-1===navigator.userAgent.indexOf("MSIE 9.0")?a.type="number":(a.type="text",a.pattern="\\d*"),void 0!==n)for(var r in n)a.setAttribute(r,n[r]);return t.appendChild(a),t.appendChild(i),t.appendChild(o),t}function g(e){try{return"function"==typeof e.composedPath?e.composedPath()[0]:e.target}catch(n){return e.target}}var p=function(){},h=function(e,n,t){return t.months[n?"shorthand":"longhand"][e]},v={D:p,F:function(e,n,t){e.setMonth(t.months.longhand.indexOf(n))},G:function(e,n){e.setHours((e.getHours()>=12?12:0)+parseFloat(n))},H:function(e,n){e.setHours(parseFloat(n))},J:function(e,n){e.setDate(parseFloat(n))},K:function(e,n,t){e.setHours(e.getHours()%12+12*r(new RegExp(t.amPM[1],"i").test(n)))},M:function(e,n,t){e.setMonth(t.months.shorthand.indexOf(n))},S:function(e,n){e.setSeconds(parseFloat(n))},U:function(e,n){return new Date(1e3*parseFloat(n))},W:function(e,n,t){var a=parseInt(n),i=new Date(e.getFullYear(),0,2+7*(a-1),0,0,0,0);return i.setDate(i.getDate()-i.getDay()+t.firstDayOfWeek),i},Y:function(e,n){e.setFullYear(parseFloat(n))},Z:function(e,n){return new Date(n)},d:function(e,n){e.setDate(parseFloat(n))},h:function(e,n){e.setHours((e.getHours()>=12?12:0)+parseFloat(n))},i:function(e,n){e.setMinutes(parseFloat(n))},j:function(e,n){e.setDate(parseFloat(n))},l:p,m:function(e,n){e.setMonth(parseFloat(n)-1)},n:function(e,n){e.setMonth(parseFloat(n)-1)},s:function(e,n){e.setSeconds(parseFloat(n))},u:function(e,n){return new Date(parseFloat(n))},w:p,y:function(e,n){e.setFullYear(2e3+parseFloat(n))}},D={D:"",F:"",G:"(\\d\\d|\\d)",H:"(\\d\\d|\\d)",J:"(\\d\\d|\\d)\\w+",K:"",M:"",S:"(\\d\\d|\\d)",U:"(.+)",W:"(\\d\\d|\\d)",Y:"(\\d{4})",Z:"(.+)",d:"(\\d\\d|\\d)",h:"(\\d\\d|\\d)",i:"(\\d\\d|\\d)",j:"(\\d\\d|\\d)",l:"",m:"(\\d\\d|\\d)",n:"(\\d\\d|\\d)",s:"(\\d\\d|\\d)",u:"(.+)",w:"(\\d\\d|\\d)",y:"(\\d{2})"},w={Z:function(e){return e.toISOString()},D:function(e,n,t){return n.weekdays.shorthand[w.w(e,n,t)]},F:function(e,n,t){return h(w.n(e,n,t)-1,!1,n)},G:function(e,n,t){return o(w.h(e,n,t))},H:function(e){return o(e.getHours())},J:function(e,n){return void 0!==n.ordinal?e.getDate()+n.ordinal(e.getDate()):e.getDate()},K:function(e,n){return n.amPM[r(e.getHours()>11)]},M:function(e,n){return h(e.getMonth(),!0,n)},S:function(e){return o(e.getSeconds())},U:function(e){return e.getTime()/1e3},W:function(e,n,t){return t.getWeek(e)},Y:function(e){return o(e.getFullYear(),4)},d:function(e){return o(e.getDate())},h:function(e){return e.getHours()%12?e.getHours()%12:12},i:function(e){return o(e.getMinutes())},j:function(e){return e.getDate()},l:function(e,n){return n.weekdays.longhand[e.getDay()]},m:function(e){return o(e.getMonth()+1)},n:function(e){return e.getMonth()+1},s:function(e){return e.getSeconds()},u:function(e){return e.getTime()},w:function(e){return e.getDay()},y:function(e){return String(e.getFullYear()).substring(2)}},b=function(e){var n=e.config,t=void 0===n?a:n,o=e.l10n,r=void 0===o?i:o,l=e.isMobile,c=void 0!==l&&l;return function(e,n,a){var i=a||r;return void 0===t.formatDate||c?n.split("").map((function(n,a,o){return w[n]&&"\\"!==o[a-1]?w[n](e,i,t):"\\"!==n?n:""})).join(""):t.formatDate(e,n,i)}},C=function(e){var n=e.config,t=void 0===n?a:n,o=e.l10n,r=void 0===o?i:o;return function(e,n,i,o){if(0===e||e){var l,c=o||r,s=e;if(e instanceof Date)l=new Date(e.getTime());else if("string"!=typeof e&&void 0!==e.toFixed)l=new Date(e);else if("string"==typeof e){var d=n||(t||a).dateFormat,u=String(e).trim();if("today"===u)l=new Date,i=!0;else if(t&&t.parseDate)l=t.parseDate(e,d);else if(/Z$/.test(u)||/GMT$/.test(u))l=new Date(e);else{for(var f=void 0,m=[],g=0,p=0,h="";g=0?new Date:new Date(w.config.minDate.getTime()),t=E(w.config);n.setHours(t.hours,t.minutes,t.seconds,n.getMilliseconds()),w.selectedDates=[n],w.latestSelectedDateObj=n}void 0!==e&&"blur"!==e.type&&function(e){e.preventDefault();var n="keydown"===e.type,t=g(e),a=t;void 0!==w.amPM&&t===w.amPM&&(w.amPM.textContent=w.l10n.amPM[r(w.amPM.textContent===w.l10n.amPM[0])]);var i=parseFloat(a.getAttribute("min")),l=parseFloat(a.getAttribute("max")),c=parseFloat(a.getAttribute("step")),s=parseInt(a.value,10),d=e.delta||(n?38===e.which?1:-1:0),u=s+c*d;if(void 0!==a.value&&2===a.value.length){var f=a===w.hourElement,m=a===w.minuteElement;ul&&(u=a===w.hourElement?u-l-r(!w.amPM):i,m&&L(void 0,1,w.hourElement)),w.amPM&&f&&(1===c?u+s===23:Math.abs(u-s)>c)&&(w.amPM.textContent=w.l10n.amPM[r(w.amPM.textContent===w.l10n.amPM[0])]),a.value=o(u)}}(e);var a=w._input.value;O(),ye(),w._input.value!==a&&w._debouncedChange()}function O(){if(void 0!==w.hourElement&&void 0!==w.minuteElement){var e,n,t=(parseInt(w.hourElement.value.slice(-2),10)||0)%24,a=(parseInt(w.minuteElement.value,10)||0)%60,i=void 0!==w.secondElement?(parseInt(w.secondElement.value,10)||0)%60:0;void 0!==w.amPM&&(e=t,n=w.amPM.textContent,t=e%12+12*r(n===w.l10n.amPM[1]));var o=void 0!==w.config.minTime||w.config.minDate&&w.minDateHasTime&&w.latestSelectedDateObj&&0===M(w.latestSelectedDateObj,w.config.minDate,!0),l=void 0!==w.config.maxTime||w.config.maxDate&&w.maxDateHasTime&&w.latestSelectedDateObj&&0===M(w.latestSelectedDateObj,w.config.maxDate,!0);if(void 0!==w.config.maxTime&&void 0!==w.config.minTime&&w.config.minTime>w.config.maxTime){var c=y(w.config.minTime.getHours(),w.config.minTime.getMinutes(),w.config.minTime.getSeconds()),s=y(w.config.maxTime.getHours(),w.config.maxTime.getMinutes(),w.config.maxTime.getSeconds()),d=y(t,a,i);if(d>s&&d=12)]),void 0!==w.secondElement&&(w.secondElement.value=o(t)))}function N(e){var n=g(e),t=parseInt(n.value)+(e.delta||0);(t/1e3>1||"Enter"===e.key&&!/[^\d]/.test(t.toString()))&&ee(t)}function P(e,n,t,a){return n instanceof Array?n.forEach((function(n){return P(e,n,t,a)})):e instanceof Array?e.forEach((function(e){return P(e,n,t,a)})):(e.addEventListener(n,t,a),void w._handlers.push({remove:function(){return e.removeEventListener(n,t,a)}}))}function Y(){De("onChange")}function j(e,n){var t=void 0!==e?w.parseDate(e):w.latestSelectedDateObj||(w.config.minDate&&w.config.minDate>w.now?w.config.minDate:w.config.maxDate&&w.config.maxDate=0&&M(e,w.selectedDates[1])<=0)}(n)&&!be(n)&&o.classList.add("inRange"),w.weekNumbers&&1===w.config.showMonths&&"prevMonthDay"!==e&&a%7==6&&w.weekNumbers.insertAdjacentHTML("beforeend",""+w.config.getWeek(n)+""),De("onDayCreate",o),o}function W(e){e.focus(),"range"===w.config.mode&&oe(e)}function B(e){for(var n=e>0?0:w.config.showMonths-1,t=e>0?w.config.showMonths:-1,a=n;a!=t;a+=e)for(var i=w.daysContainer.children[a],o=e>0?0:i.children.length-1,r=e>0?i.children.length:-1,l=o;l!=r;l+=e){var c=i.children[l];if(-1===c.className.indexOf("hidden")&&ne(c.dateObj))return c}}function J(e,n){var t=k(),a=te(t||document.body),i=void 0!==e?e:a?t:void 0!==w.selectedDateElem&&te(w.selectedDateElem)?w.selectedDateElem:void 0!==w.todayDateElem&&te(w.todayDateElem)?w.todayDateElem:B(n>0?1:-1);void 0===i?w._input.focus():a?function(e,n){for(var t=-1===e.className.indexOf("Month")?e.dateObj.getMonth():w.currentMonth,a=n>0?w.config.showMonths:-1,i=n>0?1:-1,o=t-w.currentMonth;o!=a;o+=i)for(var r=w.daysContainer.children[o],l=t-w.currentMonth===o?e.$i+n:n<0?r.children.length-1:0,c=r.children.length,s=l;s>=0&&s0?c:-1);s+=i){var d=r.children[s];if(-1===d.className.indexOf("hidden")&&ne(d.dateObj)&&Math.abs(e.$i-s)>=Math.abs(n))return W(d)}w.changeMonth(i),J(B(i),0)}(i,n):W(i)}function K(e,n){for(var t=(new Date(e,n,1).getDay()-w.l10n.firstDayOfWeek+7)%7,a=w.utils.getDaysInMonth((n-1+12)%12,e),i=w.utils.getDaysInMonth(n,e),o=window.document.createDocumentFragment(),r=w.config.showMonths>1,l=r?"prevMonthDay hidden":"prevMonthDay",c=r?"nextMonthDay hidden":"nextMonthDay",s=a+1-t,u=0;s<=a;s++,u++)o.appendChild(R("flatpickr-day "+l,new Date(e,n-1,s),0,u));for(s=1;s<=i;s++,u++)o.appendChild(R("flatpickr-day",new Date(e,n,s),0,u));for(var f=i+1;f<=42-t&&(1===w.config.showMonths||u%7!=0);f++,u++)o.appendChild(R("flatpickr-day "+c,new Date(e,n+1,f%i),0,u));var m=d("div","dayContainer");return m.appendChild(o),m}function U(){if(void 0!==w.daysContainer){u(w.daysContainer),w.weekNumbers&&u(w.weekNumbers);for(var e=document.createDocumentFragment(),n=0;n1||"dropdown"!==w.config.monthSelectorType)){var e=function(e){return!(void 0!==w.config.minDate&&w.currentYear===w.config.minDate.getFullYear()&&ew.config.maxDate.getMonth())};w.monthsDropdownContainer.tabIndex=-1,w.monthsDropdownContainer.innerHTML="";for(var n=0;n<12;n++)if(e(n)){var t=d("option","flatpickr-monthDropdown-month");t.value=new Date(w.currentYear,n).getMonth().toString(),t.textContent=h(n,w.config.shorthandCurrentMonth,w.l10n),t.tabIndex=-1,w.currentMonth===n&&(t.selected=!0),w.monthsDropdownContainer.appendChild(t)}}}function $(){var e,n=d("div","flatpickr-month"),t=window.document.createDocumentFragment();w.config.showMonths>1||"static"===w.config.monthSelectorType?e=d("span","cur-month"):(w.monthsDropdownContainer=d("select","flatpickr-monthDropdown-months"),w.monthsDropdownContainer.setAttribute("aria-label",w.l10n.monthAriaLabel),P(w.monthsDropdownContainer,"change",(function(e){var n=g(e),t=parseInt(n.value,10);w.changeMonth(t-w.currentMonth),De("onMonthChange")})),q(),e=w.monthsDropdownContainer);var a=m("cur-year",{tabindex:"-1"}),i=a.getElementsByTagName("input")[0];i.setAttribute("aria-label",w.l10n.yearAriaLabel),w.config.minDate&&i.setAttribute("min",w.config.minDate.getFullYear().toString()),w.config.maxDate&&(i.setAttribute("max",w.config.maxDate.getFullYear().toString()),i.disabled=!!w.config.minDate&&w.config.minDate.getFullYear()===w.config.maxDate.getFullYear());var o=d("div","flatpickr-current-month");return o.appendChild(e),o.appendChild(a),t.appendChild(o),n.appendChild(t),{container:n,yearElement:i,monthElement:e}}function V(){u(w.monthNav),w.monthNav.appendChild(w.prevMonthNav),w.config.showMonths&&(w.yearElements=[],w.monthElements=[]);for(var e=w.config.showMonths;e--;){var n=$();w.yearElements.push(n.yearElement),w.monthElements.push(n.monthElement),w.monthNav.appendChild(n.container)}w.monthNav.appendChild(w.nextMonthNav)}function z(){w.weekdayContainer?u(w.weekdayContainer):w.weekdayContainer=d("div","flatpickr-weekdays");for(var e=w.config.showMonths;e--;){var n=d("div","flatpickr-weekdaycontainer");w.weekdayContainer.appendChild(n)}return G(),w.weekdayContainer}function G(){if(w.weekdayContainer){var e=w.l10n.firstDayOfWeek,t=n(w.l10n.weekdays.shorthand);e>0&&e\n "+t.join("")+"\n \n "}}function Z(e,n){void 0===n&&(n=!0);var t=n?e:e-w.currentMonth;t<0&&!0===w._hidePrevMonthArrow||t>0&&!0===w._hideNextMonthArrow||(w.currentMonth+=t,(w.currentMonth<0||w.currentMonth>11)&&(w.currentYear+=w.currentMonth>11?1:-1,w.currentMonth=(w.currentMonth+12)%12,De("onYearChange"),q()),U(),De("onMonthChange"),Ce())}function Q(e){return w.calendarContainer.contains(e)}function X(e){if(w.isOpen&&!w.config.inline){var n=g(e),t=Q(n),a=!(n===w.input||n===w.altInput||w.element.contains(n)||e.path&&e.path.indexOf&&(~e.path.indexOf(w.input)||~e.path.indexOf(w.altInput)))&&!t&&!Q(e.relatedTarget),i=!w.config.ignoredFocusElements.some((function(e){return e.contains(n)}));a&&i&&(w.config.allowInput&&w.setDate(w._input.value,!1,w.config.altInput?w.config.altFormat:w.config.dateFormat),void 0!==w.timeContainer&&void 0!==w.minuteElement&&void 0!==w.hourElement&&""!==w.input.value&&void 0!==w.input.value&&_(),w.close(),w.config&&"range"===w.config.mode&&1===w.selectedDates.length&&w.clear(!1))}}function ee(e){if(!(!e||w.config.minDate&&ew.config.maxDate.getFullYear())){var n=e,t=w.currentYear!==n;w.currentYear=n||w.currentYear,w.config.maxDate&&w.currentYear===w.config.maxDate.getFullYear()?w.currentMonth=Math.min(w.config.maxDate.getMonth(),w.currentMonth):w.config.minDate&&w.currentYear===w.config.minDate.getFullYear()&&(w.currentMonth=Math.max(w.config.minDate.getMonth(),w.currentMonth)),t&&(w.redraw(),De("onYearChange"),q())}}function ne(e,n){var t;void 0===n&&(n=!0);var a=w.parseDate(e,void 0,n);if(w.config.minDate&&a&&M(a,w.config.minDate,void 0!==n?n:!w.minDateHasTime)<0||w.config.maxDate&&a&&M(a,w.config.maxDate,void 0!==n?n:!w.maxDateHasTime)>0)return!1;if(!w.config.enable&&0===w.config.disable.length)return!0;if(void 0===a)return!1;for(var i=!!w.config.enable,o=null!==(t=w.config.enable)&&void 0!==t?t:w.config.disable,r=0,l=void 0;r=l.from.getTime()&&a.getTime()<=l.to.getTime())return i}return!i}function te(e){return void 0!==w.daysContainer&&(-1===e.className.indexOf("hidden")&&-1===e.className.indexOf("flatpickr-disabled")&&w.daysContainer.contains(e))}function ae(e){var n=e.target===w._input,t=w._input.value.trimEnd()!==Me();!n||!t||e.relatedTarget&&Q(e.relatedTarget)||w.setDate(w._input.value,!0,e.target===w.altInput?w.config.altFormat:w.config.dateFormat)}function ie(e){var n=g(e),t=w.config.wrap?p.contains(n):n===w._input,a=w.config.allowInput,i=w.isOpen&&(!a||!t),o=w.config.inline&&t&&!a;if(13===e.keyCode&&t){if(a)return w.setDate(w._input.value,!0,n===w.altInput?w.config.altFormat:w.config.dateFormat),w.close(),n.blur();w.open()}else if(Q(n)||i||o){var r=!!w.timeContainer&&w.timeContainer.contains(n);switch(e.keyCode){case 13:r?(e.preventDefault(),_(),fe()):me(e);break;case 27:e.preventDefault(),fe();break;case 8:case 46:t&&!w.config.allowInput&&(e.preventDefault(),w.clear());break;case 37:case 39:if(r||t)w.hourElement&&w.hourElement.focus();else{e.preventDefault();var l=k();if(void 0!==w.daysContainer&&(!1===a||l&&te(l))){var c=39===e.keyCode?1:-1;e.ctrlKey?(e.stopPropagation(),Z(c),J(B(1),0)):J(void 0,c)}}break;case 38:case 40:e.preventDefault();var s=40===e.keyCode?1:-1;w.daysContainer&&void 0!==n.$i||n===w.input||n===w.altInput?e.ctrlKey?(e.stopPropagation(),ee(w.currentYear-s),J(B(1),0)):r||J(void 0,7*s):n===w.currentYearElement?ee(w.currentYear-s):w.config.enableTime&&(!r&&w.hourElement&&w.hourElement.focus(),_(e),w._debouncedChange());break;case 9:if(r){var d=[w.hourElement,w.minuteElement,w.secondElement,w.amPM].concat(w.pluginElements).filter((function(e){return e})),u=d.indexOf(n);if(-1!==u){var f=d[u+(e.shiftKey?-1:1)];e.preventDefault(),(f||w._input).focus()}}else!w.config.noCalendar&&w.daysContainer&&w.daysContainer.contains(n)&&e.shiftKey&&(e.preventDefault(),w._input.focus())}}if(void 0!==w.amPM&&n===w.amPM)switch(e.key){case w.l10n.amPM[0].charAt(0):case w.l10n.amPM[0].charAt(0).toLowerCase():w.amPM.textContent=w.l10n.amPM[0],O(),ye();break;case w.l10n.amPM[1].charAt(0):case w.l10n.amPM[1].charAt(0).toLowerCase():w.amPM.textContent=w.l10n.amPM[1],O(),ye()}(t||Q(n))&&De("onKeyDown",e)}function oe(e,n){if(void 0===n&&(n="flatpickr-day"),1===w.selectedDates.length&&(!e||e.classList.contains(n)&&!e.classList.contains("flatpickr-disabled"))){for(var t=e?e.dateObj.getTime():w.days.firstElementChild.dateObj.getTime(),a=w.parseDate(w.selectedDates[0],void 0,!0).getTime(),i=Math.min(t,w.selectedDates[0].getTime()),o=Math.max(t,w.selectedDates[0].getTime()),r=!1,l=0,c=0,s=i;si&&sl)?l=s:s>a&&(!c||s ."+n)).forEach((function(n){var i,o,s,d=n.dateObj.getTime(),u=l>0&&d0&&d>c;if(u)return n.classList.add("notAllowed"),void["inRange","startRange","endRange"].forEach((function(e){n.classList.remove(e)}));r&&!u||(["startRange","inRange","endRange","notAllowed"].forEach((function(e){n.classList.remove(e)})),void 0!==e&&(e.classList.add(t<=w.selectedDates[0].getTime()?"startRange":"endRange"),at&&d===a&&n.classList.add("endRange"),d>=l&&(0===c||d<=c)&&(o=a,s=t,(i=d)>Math.min(o,s)&&i0||t.getMinutes()>0||t.getSeconds()>0),w.selectedDates&&(w.selectedDates=w.selectedDates.filter((function(e){return ne(e)})),w.selectedDates.length||"min"!==e||F(t),ye()),w.daysContainer&&(ue(),void 0!==t?w.currentYearElement[e]=t.getFullYear().toString():w.currentYearElement.removeAttribute(e),w.currentYearElement.disabled=!!a&&void 0!==t&&a.getFullYear()===t.getFullYear())}}function ce(){return w.config.wrap?p.querySelector("[data-input]"):p}function se(){"object"!=typeof w.config.locale&&void 0===I.l10ns[w.config.locale]&&w.config.errorHandler(new Error("flatpickr: invalid locale "+w.config.locale)),w.l10n=e(e({},I.l10ns.default),"object"==typeof w.config.locale?w.config.locale:"default"!==w.config.locale?I.l10ns[w.config.locale]:void 0),D.D="("+w.l10n.weekdays.shorthand.join("|")+")",D.l="("+w.l10n.weekdays.longhand.join("|")+")",D.M="("+w.l10n.months.shorthand.join("|")+")",D.F="("+w.l10n.months.longhand.join("|")+")",D.K="("+w.l10n.amPM[0]+"|"+w.l10n.amPM[1]+"|"+w.l10n.amPM[0].toLowerCase()+"|"+w.l10n.amPM[1].toLowerCase()+")",void 0===e(e({},v),JSON.parse(JSON.stringify(p.dataset||{}))).time_24hr&&void 0===I.defaultConfig.time_24hr&&(w.config.time_24hr=w.l10n.time_24hr),w.formatDate=b(w),w.parseDate=C({config:w.config,l10n:w.l10n})}function de(e){if("function"!=typeof w.config.position){if(void 0!==w.calendarContainer){De("onPreCalendarPosition");var n=e||w._positionElement,t=Array.prototype.reduce.call(w.calendarContainer.children,(function(e,n){return e+n.offsetHeight}),0),a=w.calendarContainer.offsetWidth,i=w.config.position.split(" "),o=i[0],r=i.length>1?i[1]:null,l=n.getBoundingClientRect(),c=window.innerHeight-l.bottom,d="above"===o||"below"!==o&&ct,u=window.pageYOffset+l.top+(d?-t-2:n.offsetHeight+2);if(s(w.calendarContainer,"arrowTop",!d),s(w.calendarContainer,"arrowBottom",d),!w.config.inline){var f=window.pageXOffset+l.left,m=!1,g=!1;"center"===r?(f-=(a-l.width)/2,m=!0):"right"===r&&(f-=a-l.width,g=!0),s(w.calendarContainer,"arrowLeft",!m&&!g),s(w.calendarContainer,"arrowCenter",m),s(w.calendarContainer,"arrowRight",g);var p=window.document.body.offsetWidth-(window.pageXOffset+l.right),h=f+a>window.document.body.offsetWidth,v=p+a>window.document.body.offsetWidth;if(s(w.calendarContainer,"rightMost",h),!w.config.static)if(w.calendarContainer.style.top=u+"px",h)if(v){var D=function(){for(var e=null,n=0;nw.currentMonth+w.config.showMonths-1)&&"range"!==w.config.mode;if(w.selectedDateElem=t,"single"===w.config.mode)w.selectedDates=[a];else if("multiple"===w.config.mode){var o=be(a);o?w.selectedDates.splice(parseInt(o),1):w.selectedDates.push(a)}else"range"===w.config.mode&&(2===w.selectedDates.length&&w.clear(!1,!1),w.latestSelectedDateObj=a,w.selectedDates.push(a),0!==M(a,w.selectedDates[0],!0)&&w.selectedDates.sort((function(e,n){return e.getTime()-n.getTime()})));if(O(),i){var r=w.currentYear!==a.getFullYear();w.currentYear=a.getFullYear(),w.currentMonth=a.getMonth(),r&&(De("onYearChange"),q()),De("onMonthChange")}if(Ce(),U(),ye(),i||"range"===w.config.mode||1!==w.config.showMonths?void 0!==w.selectedDateElem&&void 0===w.hourElement&&w.selectedDateElem&&w.selectedDateElem.focus():W(t),void 0!==w.hourElement&&void 0!==w.hourElement&&w.hourElement.focus(),w.config.closeOnSelect){var l="single"===w.config.mode&&!w.config.enableTime,c="range"===w.config.mode&&2===w.selectedDates.length&&!w.config.enableTime;(l||c)&&fe()}Y()}}w.parseDate=C({config:w.config,l10n:w.l10n}),w._handlers=[],w.pluginElements=[],w.loadedPlugins=[],w._bind=P,w._setHoursFromDate=F,w._positionCalendar=de,w.changeMonth=Z,w.changeYear=ee,w.clear=function(e,n){void 0===e&&(e=!0);void 0===n&&(n=!0);w.input.value="",void 0!==w.altInput&&(w.altInput.value="");void 0!==w.mobileInput&&(w.mobileInput.value="");w.selectedDates=[],w.latestSelectedDateObj=void 0,!0===n&&(w.currentYear=w._initialDate.getFullYear(),w.currentMonth=w._initialDate.getMonth());if(!0===w.config.enableTime){var t=E(w.config),a=t.hours,i=t.minutes,o=t.seconds;A(a,i,o)}w.redraw(),e&&De("onChange")},w.close=function(){w.isOpen=!1,w.isMobile||(void 0!==w.calendarContainer&&w.calendarContainer.classList.remove("open"),void 0!==w._input&&w._input.classList.remove("active"));De("onClose")},w.onMouseOver=oe,w._createElement=d,w.createDay=R,w.destroy=function(){void 0!==w.config&&De("onDestroy");for(var e=w._handlers.length;e--;)w._handlers[e].remove();if(w._handlers=[],w.mobileInput)w.mobileInput.parentNode&&w.mobileInput.parentNode.removeChild(w.mobileInput),w.mobileInput=void 0;else if(w.calendarContainer&&w.calendarContainer.parentNode)if(w.config.static&&w.calendarContainer.parentNode){var n=w.calendarContainer.parentNode;if(n.lastChild&&n.removeChild(n.lastChild),n.parentNode){for(;n.firstChild;)n.parentNode.insertBefore(n.firstChild,n);n.parentNode.removeChild(n)}}else w.calendarContainer.parentNode.removeChild(w.calendarContainer);w.altInput&&(w.input.type="text",w.altInput.parentNode&&w.altInput.parentNode.removeChild(w.altInput),delete w.altInput);w.input&&(w.input.type=w.input._type,w.input.classList.remove("flatpickr-input"),w.input.removeAttribute("readonly"));["_showTimeInput","latestSelectedDateObj","_hideNextMonthArrow","_hidePrevMonthArrow","__hideNextMonthArrow","__hidePrevMonthArrow","isMobile","isOpen","selectedDateElem","minDateHasTime","maxDateHasTime","days","daysContainer","_input","_positionElement","innerContainer","rContainer","monthNav","todayDateElem","calendarContainer","weekdayContainer","prevMonthNav","nextMonthNav","monthsDropdownContainer","currentMonthElement","currentYearElement","navigationCurrentMonth","selectedDateElem","config"].forEach((function(e){try{delete w[e]}catch(e){}}))},w.isEnabled=ne,w.jumpToDate=j,w.updateValue=ye,w.open=function(e,n){void 0===n&&(n=w._positionElement);if(!0===w.isMobile){if(e){e.preventDefault();var t=g(e);t&&t.blur()}return void 0!==w.mobileInput&&(w.mobileInput.focus(),w.mobileInput.click()),void De("onOpen")}if(w._input.disabled||w.config.inline)return;var a=w.isOpen;w.isOpen=!0,a||(w.calendarContainer.classList.add("open"),w._input.classList.add("active"),De("onOpen"),de(n));!0===w.config.enableTime&&!0===w.config.noCalendar&&(!1!==w.config.allowInput||void 0!==e&&w.timeContainer.contains(e.relatedTarget)||setTimeout((function(){return w.hourElement.select()}),50))},w.redraw=ue,w.set=function(e,n){if(null!==e&&"object"==typeof e)for(var a in Object.assign(w.config,e),e)void 0!==ge[a]&&ge[a].forEach((function(e){return e()}));else w.config[e]=n,void 0!==ge[e]?ge[e].forEach((function(e){return e()})):t.indexOf(e)>-1&&(w.config[e]=c(n));w.redraw(),ye(!0)},w.setDate=function(e,n,t){void 0===n&&(n=!1);void 0===t&&(t=w.config.dateFormat);if(0!==e&&!e||e instanceof Array&&0===e.length)return w.clear(n);pe(e,t),w.latestSelectedDateObj=w.selectedDates[w.selectedDates.length-1],w.redraw(),j(void 0,n),F(),0===w.selectedDates.length&&w.clear(!1);ye(n),n&&De("onChange")},w.toggle=function(e){if(!0===w.isOpen)return w.close();w.open(e)};var ge={locale:[se,G],showMonths:[V,S,z],minDate:[j],maxDate:[j],positionElement:[ve],clickOpens:[function(){!0===w.config.clickOpens?(P(w._input,"focus",w.open),P(w._input,"click",w.open)):(w._input.removeEventListener("focus",w.open),w._input.removeEventListener("click",w.open))}]};function pe(e,n){var t=[];if(e instanceof Array)t=e.map((function(e){return w.parseDate(e,n)}));else if(e instanceof Date||"number"==typeof e)t=[w.parseDate(e,n)];else if("string"==typeof e)switch(w.config.mode){case"single":case"time":t=[w.parseDate(e,n)];break;case"multiple":t=e.split(w.config.conjunction).map((function(e){return w.parseDate(e,n)}));break;case"range":t=e.split(w.l10n.rangeSeparator).map((function(e){return w.parseDate(e,n)}))}else w.config.errorHandler(new Error("Invalid date supplied: "+JSON.stringify(e)));w.selectedDates=w.config.allowInvalidPreload?t:t.filter((function(e){return e instanceof Date&&ne(e,!1)})),"range"===w.config.mode&&w.selectedDates.sort((function(e,n){return e.getTime()-n.getTime()}))}function he(e){return e.slice().map((function(e){return"string"==typeof e||"number"==typeof e||e instanceof Date?w.parseDate(e,void 0,!0):e&&"object"==typeof e&&e.from&&e.to?{from:w.parseDate(e.from,void 0),to:w.parseDate(e.to,void 0)}:e})).filter((function(e){return e}))}function ve(){w._positionElement=w.config.positionElement||w._input}function De(e,n){if(void 0!==w.config){var t=w.config[e];if(void 0!==t&&t.length>0)for(var a=0;t[a]&&a1||"static"===w.config.monthSelectorType?w.monthElements[n].textContent=h(t.getMonth(),w.config.shorthandCurrentMonth,w.l10n)+" ":w.monthsDropdownContainer.value=t.getMonth().toString(),e.value=t.getFullYear().toString()})),w._hidePrevMonthArrow=void 0!==w.config.minDate&&(w.currentYear===w.config.minDate.getFullYear()?w.currentMonth<=w.config.minDate.getMonth():w.currentYearw.config.maxDate.getMonth():w.currentYear>w.config.maxDate.getFullYear()))}function Me(e){var n=e||(w.config.altInput?w.config.altFormat:w.config.dateFormat);return w.selectedDates.map((function(e){return w.formatDate(e,n)})).filter((function(e,n,t){return"range"!==w.config.mode||w.config.enableTime||t.indexOf(e)===n})).join("range"!==w.config.mode?w.config.conjunction:w.l10n.rangeSeparator)}function ye(e){void 0===e&&(e=!0),void 0!==w.mobileInput&&w.mobileFormatStr&&(w.mobileInput.value=void 0!==w.latestSelectedDateObj?w.formatDate(w.latestSelectedDateObj,w.mobileFormatStr):""),w.input.value=Me(w.config.dateFormat),void 0!==w.altInput&&(w.altInput.value=Me(w.config.altFormat)),!1!==e&&De("onValueUpdate")}function xe(e){var n=g(e),t=w.prevMonthNav.contains(n),a=w.nextMonthNav.contains(n);t||a?Z(t?-1:1):w.yearElements.indexOf(n)>=0?n.select():n.classList.contains("arrowUp")?w.changeYear(w.currentYear+1):n.classList.contains("arrowDown")&&w.changeYear(w.currentYear-1)}return function(){w.element=w.input=p,w.isOpen=!1,function(){var n=["wrap","weekNumbers","allowInput","allowInvalidPreload","clickOpens","time_24hr","enableTime","noCalendar","altInput","shorthandCurrentMonth","inline","static","enableSeconds","disableMobile"],i=e(e({},JSON.parse(JSON.stringify(p.dataset||{}))),v),o={};w.config.parseDate=i.parseDate,w.config.formatDate=i.formatDate,Object.defineProperty(w.config,"enable",{get:function(){return w.config._enable},set:function(e){w.config._enable=he(e)}}),Object.defineProperty(w.config,"disable",{get:function(){return w.config._disable},set:function(e){w.config._disable=he(e)}});var r="time"===i.mode;if(!i.dateFormat&&(i.enableTime||r)){var l=I.defaultConfig.dateFormat||a.dateFormat;o.dateFormat=i.noCalendar||r?"H:i"+(i.enableSeconds?":S":""):l+" H:i"+(i.enableSeconds?":S":"")}if(i.altInput&&(i.enableTime||r)&&!i.altFormat){var s=I.defaultConfig.altFormat||a.altFormat;o.altFormat=i.noCalendar||r?"h:i"+(i.enableSeconds?":S K":" K"):s+" h:i"+(i.enableSeconds?":S":"")+" K"}Object.defineProperty(w.config,"minDate",{get:function(){return w.config._minDate},set:le("min")}),Object.defineProperty(w.config,"maxDate",{get:function(){return w.config._maxDate},set:le("max")});var d=function(e){return function(n){w.config["min"===e?"_minTime":"_maxTime"]=w.parseDate(n,"H:i:S")}};Object.defineProperty(w.config,"minTime",{get:function(){return w.config._minTime},set:d("min")}),Object.defineProperty(w.config,"maxTime",{get:function(){return w.config._maxTime},set:d("max")}),"time"===i.mode&&(w.config.noCalendar=!0,w.config.enableTime=!0);Object.assign(w.config,o,i);for(var u=0;u-1?w.config[m]=c(f[m]).map(T).concat(w.config[m]):void 0===i[m]&&(w.config[m]=f[m])}i.altInputClass||(w.config.altInputClass=ce().className+" "+w.config.altInputClass);De("onParseConfig")}(),se(),function(){if(w.input=ce(),!w.input)return void w.config.errorHandler(new Error("Invalid input element specified"));w.input._type=w.input.type,w.input.type="text",w.input.classList.add("flatpickr-input"),w._input=w.input,w.config.altInput&&(w.altInput=d(w.input.nodeName,w.config.altInputClass),w._input=w.altInput,w.altInput.placeholder=w.input.placeholder,w.altInput.disabled=w.input.disabled,w.altInput.required=w.input.required,w.altInput.tabIndex=w.input.tabIndex,w.altInput.type="text",w.input.setAttribute("type","hidden"),!w.config.static&&w.input.parentNode&&w.input.parentNode.insertBefore(w.altInput,w.input.nextSibling));w.config.allowInput||w._input.setAttribute("readonly","readonly");ve()}(),function(){w.selectedDates=[],w.now=w.parseDate(w.config.now)||new Date;var e=w.config.defaultDate||("INPUT"!==w.input.nodeName&&"TEXTAREA"!==w.input.nodeName||!w.input.placeholder||w.input.value!==w.input.placeholder?w.input.value:null);e&&pe(e,w.config.dateFormat);w._initialDate=w.selectedDates.length>0?w.selectedDates[0]:w.config.minDate&&w.config.minDate.getTime()>w.now.getTime()?w.config.minDate:w.config.maxDate&&w.config.maxDate.getTime()0&&(w.latestSelectedDateObj=w.selectedDates[0]);void 0!==w.config.minTime&&(w.config.minTime=w.parseDate(w.config.minTime,"H:i"));void 0!==w.config.maxTime&&(w.config.maxTime=w.parseDate(w.config.maxTime,"H:i"));w.minDateHasTime=!!w.config.minDate&&(w.config.minDate.getHours()>0||w.config.minDate.getMinutes()>0||w.config.minDate.getSeconds()>0),w.maxDateHasTime=!!w.config.maxDate&&(w.config.maxDate.getHours()>0||w.config.maxDate.getMinutes()>0||w.config.maxDate.getSeconds()>0)}(),w.utils={getDaysInMonth:function(e,n){return void 0===e&&(e=w.currentMonth),void 0===n&&(n=w.currentYear),1===e&&(n%4==0&&n%100!=0||n%400==0)?29:w.l10n.daysInMonth[e]}},w.isMobile||function(){var e=window.document.createDocumentFragment();if(w.calendarContainer=d("div","flatpickr-calendar"),w.calendarContainer.tabIndex=-1,!w.config.noCalendar){if(e.appendChild((w.monthNav=d("div","flatpickr-months"),w.yearElements=[],w.monthElements=[],w.prevMonthNav=d("span","flatpickr-prev-month"),w.prevMonthNav.innerHTML=w.config.prevArrow,w.nextMonthNav=d("span","flatpickr-next-month"),w.nextMonthNav.innerHTML=w.config.nextArrow,V(),Object.defineProperty(w,"_hidePrevMonthArrow",{get:function(){return w.__hidePrevMonthArrow},set:function(e){w.__hidePrevMonthArrow!==e&&(s(w.prevMonthNav,"flatpickr-disabled",e),w.__hidePrevMonthArrow=e)}}),Object.defineProperty(w,"_hideNextMonthArrow",{get:function(){return w.__hideNextMonthArrow},set:function(e){w.__hideNextMonthArrow!==e&&(s(w.nextMonthNav,"flatpickr-disabled",e),w.__hideNextMonthArrow=e)}}),w.currentYearElement=w.yearElements[0],Ce(),w.monthNav)),w.innerContainer=d("div","flatpickr-innerContainer"),w.config.weekNumbers){var n=function(){w.calendarContainer.classList.add("hasWeeks");var e=d("div","flatpickr-weekwrapper");e.appendChild(d("span","flatpickr-weekday",w.l10n.weekAbbreviation));var n=d("div","flatpickr-weeks");return e.appendChild(n),{weekWrapper:e,weekNumbers:n}}(),t=n.weekWrapper,a=n.weekNumbers;w.innerContainer.appendChild(t),w.weekNumbers=a,w.weekWrapper=t}w.rContainer=d("div","flatpickr-rContainer"),w.rContainer.appendChild(z()),w.daysContainer||(w.daysContainer=d("div","flatpickr-days"),w.daysContainer.tabIndex=-1),U(),w.rContainer.appendChild(w.daysContainer),w.innerContainer.appendChild(w.rContainer),e.appendChild(w.innerContainer)}w.config.enableTime&&e.appendChild(function(){w.calendarContainer.classList.add("hasTime"),w.config.noCalendar&&w.calendarContainer.classList.add("noCalendar");var e=E(w.config);w.timeContainer=d("div","flatpickr-time"),w.timeContainer.tabIndex=-1;var n=d("span","flatpickr-time-separator",":"),t=m("flatpickr-hour",{"aria-label":w.l10n.hourAriaLabel});w.hourElement=t.getElementsByTagName("input")[0];var a=m("flatpickr-minute",{"aria-label":w.l10n.minuteAriaLabel});w.minuteElement=a.getElementsByTagName("input")[0],w.hourElement.tabIndex=w.minuteElement.tabIndex=-1,w.hourElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getHours():w.config.time_24hr?e.hours:function(e){switch(e%24){case 0:case 12:return 12;default:return e%12}}(e.hours)),w.minuteElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getMinutes():e.minutes),w.hourElement.setAttribute("step",w.config.hourIncrement.toString()),w.minuteElement.setAttribute("step",w.config.minuteIncrement.toString()),w.hourElement.setAttribute("min",w.config.time_24hr?"0":"1"),w.hourElement.setAttribute("max",w.config.time_24hr?"23":"12"),w.hourElement.setAttribute("maxlength","2"),w.minuteElement.setAttribute("min","0"),w.minuteElement.setAttribute("max","59"),w.minuteElement.setAttribute("maxlength","2"),w.timeContainer.appendChild(t),w.timeContainer.appendChild(n),w.timeContainer.appendChild(a),w.config.time_24hr&&w.timeContainer.classList.add("time24hr");if(w.config.enableSeconds){w.timeContainer.classList.add("hasSeconds");var i=m("flatpickr-second");w.secondElement=i.getElementsByTagName("input")[0],w.secondElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getSeconds():e.seconds),w.secondElement.setAttribute("step",w.minuteElement.getAttribute("step")),w.secondElement.setAttribute("min","0"),w.secondElement.setAttribute("max","59"),w.secondElement.setAttribute("maxlength","2"),w.timeContainer.appendChild(d("span","flatpickr-time-separator",":")),w.timeContainer.appendChild(i)}w.config.time_24hr||(w.amPM=d("span","flatpickr-am-pm",w.l10n.amPM[r((w.latestSelectedDateObj?w.hourElement.value:w.config.defaultHour)>11)]),w.amPM.title=w.l10n.toggleTitle,w.amPM.tabIndex=-1,w.timeContainer.appendChild(w.amPM));return w.timeContainer}());s(w.calendarContainer,"rangeMode","range"===w.config.mode),s(w.calendarContainer,"animate",!0===w.config.animate),s(w.calendarContainer,"multiMonth",w.config.showMonths>1),w.calendarContainer.appendChild(e);var i=void 0!==w.config.appendTo&&void 0!==w.config.appendTo.nodeType;if((w.config.inline||w.config.static)&&(w.calendarContainer.classList.add(w.config.inline?"inline":"static"),w.config.inline&&(!i&&w.element.parentNode?w.element.parentNode.insertBefore(w.calendarContainer,w._input.nextSibling):void 0!==w.config.appendTo&&w.config.appendTo.appendChild(w.calendarContainer)),w.config.static)){var l=d("div","flatpickr-wrapper");w.element.parentNode&&w.element.parentNode.insertBefore(l,w.element),l.appendChild(w.element),w.altInput&&l.appendChild(w.altInput),l.appendChild(w.calendarContainer)}w.config.static||w.config.inline||(void 0!==w.config.appendTo?w.config.appendTo:window.document.body).appendChild(w.calendarContainer)}(),function(){w.config.wrap&&["open","close","toggle","clear"].forEach((function(e){Array.prototype.forEach.call(w.element.querySelectorAll("[data-"+e+"]"),(function(n){return P(n,"click",w[e])}))}));if(w.isMobile)return void function(){var e=w.config.enableTime?w.config.noCalendar?"time":"datetime-local":"date";w.mobileInput=d("input",w.input.className+" flatpickr-mobile"),w.mobileInput.tabIndex=1,w.mobileInput.type=e,w.mobileInput.disabled=w.input.disabled,w.mobileInput.required=w.input.required,w.mobileInput.placeholder=w.input.placeholder,w.mobileFormatStr="datetime-local"===e?"Y-m-d\\TH:i:S":"date"===e?"Y-m-d":"H:i:S",w.selectedDates.length>0&&(w.mobileInput.defaultValue=w.mobileInput.value=w.formatDate(w.selectedDates[0],w.mobileFormatStr));w.config.minDate&&(w.mobileInput.min=w.formatDate(w.config.minDate,"Y-m-d"));w.config.maxDate&&(w.mobileInput.max=w.formatDate(w.config.maxDate,"Y-m-d"));w.input.getAttribute("step")&&(w.mobileInput.step=String(w.input.getAttribute("step")));w.input.type="hidden",void 0!==w.altInput&&(w.altInput.type="hidden");try{w.input.parentNode&&w.input.parentNode.insertBefore(w.mobileInput,w.input.nextSibling)}catch(e){}P(w.mobileInput,"change",(function(e){w.setDate(g(e).value,!1,w.mobileFormatStr),De("onChange"),De("onClose")}))}();var e=l(re,50);w._debouncedChange=l(Y,300),w.daysContainer&&!/iPhone|iPad|iPod/i.test(navigator.userAgent)&&P(w.daysContainer,"mouseover",(function(e){"range"===w.config.mode&&oe(g(e))}));P(w._input,"keydown",ie),void 0!==w.calendarContainer&&P(w.calendarContainer,"keydown",ie);w.config.inline||w.config.static||P(window,"resize",e);void 0!==window.ontouchstart?P(window.document,"touchstart",X):P(window.document,"mousedown",X);P(window.document,"focus",X,{capture:!0}),!0===w.config.clickOpens&&(P(w._input,"focus",w.open),P(w._input,"click",w.open));void 0!==w.daysContainer&&(P(w.monthNav,"click",xe),P(w.monthNav,["keyup","increment"],N),P(w.daysContainer,"click",me));if(void 0!==w.timeContainer&&void 0!==w.minuteElement&&void 0!==w.hourElement){var n=function(e){return g(e).select()};P(w.timeContainer,["increment"],_),P(w.timeContainer,"blur",_,{capture:!0}),P(w.timeContainer,"click",H),P([w.hourElement,w.minuteElement],["focus","click"],n),void 0!==w.secondElement&&P(w.secondElement,"focus",(function(){return w.secondElement&&w.secondElement.select()})),void 0!==w.amPM&&P(w.amPM,"click",(function(e){_(e)}))}w.config.allowInput&&P(w._input,"blur",ae)}(),(w.selectedDates.length||w.config.noCalendar)&&(w.config.enableTime&&F(w.config.noCalendar?w.latestSelectedDateObj:void 0),ye(!1)),S();var n=/^((?!chrome|android).)*safari/i.test(navigator.userAgent);!w.isMobile&&n&&de(),De("onReady")}(),w}function T(e,n){for(var t=Array.prototype.slice.call(e).filter((function(e){return e instanceof HTMLElement})),a=[],i=0;i + + + + + + + + + diff --git a/cms/dashboard/templates/icons/rocket.svg b/cms/dashboard/templates/icons/rocket.svg new file mode 100644 index 000000000..06eda0ab5 --- /dev/null +++ b/cms/dashboard/templates/icons/rocket.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + diff --git a/cms/dashboard/templates/wagtailadmin/base.html b/cms/dashboard/templates/wagtailadmin/base.html index 21cbf7b19..241cf0845 100644 --- a/cms/dashboard/templates/wagtailadmin/base.html +++ b/cms/dashboard/templates/wagtailadmin/base.html @@ -8,3 +8,7 @@ {% block branding_favicon %} {% endblock %} + +{% block extra_js %} + {{ block.super }} +{% endblock %} diff --git a/cms/dashboard/templates/wagtailadmin/pages/action_menu/frontend_preview.html b/cms/dashboard/templates/wagtailadmin/pages/action_menu/frontend_preview.html new file mode 100644 index 000000000..7dac201a8 --- /dev/null +++ b/cms/dashboard/templates/wagtailadmin/pages/action_menu/frontend_preview.html @@ -0,0 +1,34 @@ +{% load static wagtailadmin_tags %} +{% if url %} + {% if icon_name %}{% icon name=icon_name %}{% endif %}{{ label }} + {% if label != "View Live" %} + + {% endif %} +{% else %} + + {% if label != "View Live" %} + + {% endif %} +{% endif %} + + +
+

Select embargo time

+
+
+ + + +
+
+
+ +{% if label != "View Live" %} + + + + + + + +{% endif %} \ No newline at end of file diff --git a/cms/dashboard/templates/wagtailadmin/pages/action_menu/frontend_viewlive.html b/cms/dashboard/templates/wagtailadmin/pages/action_menu/frontend_viewlive.html new file mode 100644 index 000000000..030419cba --- /dev/null +++ b/cms/dashboard/templates/wagtailadmin/pages/action_menu/frontend_viewlive.html @@ -0,0 +1,6 @@ +{% load wagtailadmin_tags %} +{% if url %} + {% if icon_name %}{% icon name=icon_name %}{% endif %}{{ label }} +{% else %} + +{% endif %} diff --git a/cms/dashboard/views.py b/cms/dashboard/views.py index f86f7b284..14d821182 100644 --- a/cms/dashboard/views.py +++ b/cms/dashboard/views.py @@ -1,6 +1,300 @@ +from datetime import timedelta +from urllib.parse import urlencode, urlsplit, urlunsplit + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.core.handlers.wsgi import WSGIRequest +from django.core.signing import dumps from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect +from django.utils import timezone +from django.views import View from wagtail.admin.views.chooser import BrowseView +from wagtail.models import Page + +import config +from common.virtual_clock import parse_embargo_time_value + + +class MissingPreviewFrontendHostConfigurationError(ImproperlyConfigured): + """Required frontend preview host settings are not configured.""" + + def __init__(self) -> None: + super().__init__("FRONTEND_URL must define an absolute http(s) URL") + + +class InvalidPreviewFrontendUrlError(ImproperlyConfigured): + """Preview frontend URL must be an absolute HTTP(S) URL.""" + + def __init__(self) -> None: + super().__init__("Preview redirects must use an absolute http(s) frontend URL") + + +class PreviewFrontendHostNotAllowedError(ImproperlyConfigured): + """Preview frontend redirect host is outside the trusted allow-list.""" + + def __init__(self) -> None: + super().__init__( + "Preview redirect host is not included in the configured allow-list" + ) + + +class PreviewToFrontendRedirectView(View): + """Generate a signed preview token and redirect to the frontend. + + This view is intentionally simple: it performs a permission check on the + requested page, builds a small payload, signs it using Django's signing + utilities and then redirects the browser to the frontend with the token as + a query parameter. The frontend is responsible for validating the token + and fetching any draft content. + + Preview settings are read at request time from Django settings. + + Security note: + This endpoint redirects, so it is a security-sensitive sink. + The implementation is designed to prevent HTTP-forging/open-redirect + style abuse where an attacker controls the redirect destination. + In particular: + - `pk` is constrained by route regex (`[0-9]+`) and must resolve to + an existing page. + - `embargo_time` is untrusted user input but only accepted as `now` + or Unix epoch seconds; invalid values are dropped. + - `slug` and `page_id` are derived server-side from the resolved page, + not accepted from query parameters. + - Final redirect host is validated against a static allow-list from + trusted settings before `redirect(...)` is called. + """ + + @staticmethod + def _get_allowed_frontend_hosts() -> set[str]: + """Return statically configured hosts allowed for preview redirects. + + Security rationale: + Preview redirects eventually call Django's redirect helper. If the + destination host can be influenced by request data or misconfigured + template values, an attacker can trigger an open redirect and move + users to a malicious domain that impersonates trusted properties. + To reduce this risk, we only allow hosts defined in deployment + configuration and reject everything else. + + Returns: + set[str]: Allowed host:port values derived from trusted settings. + + Raises: + ImproperlyConfigured: If no absolute http(s) frontend host can be + derived from trusted settings. + """ + configured_urls = {config.FRONTEND_URL} + allowed_hosts = { + parts.netloc + for configured_url in configured_urls + if configured_url + and (parts := urlsplit(configured_url)).scheme in {"http", "https"} + and parts.netloc + } + + if not allowed_hosts: + raise MissingPreviewFrontendHostConfigurationError + + return allowed_hosts + + @classmethod + def validate_frontend_redirect_url(cls, *, frontend_url: str) -> str: + """Validate the final preview redirect against a trusted host allow-list. + + Security rationale: + This method prevents open redirect vulnerabilities in the preview + flow. Even though the URL is built server-side, its shape can still + be affected by deploy-time configuration. Enforcing an explicit host + allow-list ensures redirects stay on known frontend domains and do + not send editors to attacker-controlled sites. + + Args: + frontend_url: Fully built URL that will be used for redirect. + + Returns: + str: The same URL when it passes validation. + + Raises: + ImproperlyConfigured: If the URL is not absolute http(s) or the + host is not in the configured allow-list. + """ + parts = urlsplit(frontend_url) + if parts.scheme not in {"http", "https"} or not parts.netloc: + raise InvalidPreviewFrontendUrlError + + allowed_hosts = cls._get_allowed_frontend_hosts() + if parts.netloc not in allowed_hosts: + raise PreviewFrontendHostNotAllowedError + + return frontend_url + + @staticmethod + def build_preview_url( + *, + raw_url: str, + slug: str, + token: str, + page_id: int, + embargo_time_value: str | None = None, + ) -> str: + """Return preview URL with query params. + + When `embargo_time_value` is supplied it is appended as the `et` query parameter. + This value is expected to be either a Unix epoch-integer string or the string `now`. + """ + parts = urlsplit(raw_url) + params = {"t": token, "page_id": page_id} + if embargo_time_value is not None: + params["et"] = embargo_time_value + query = urlencode(params) + return urlunsplit( + ( + parts.scheme, + parts.netloc, + f"{parts.path.rstrip('/')}/{slug}", + query, + parts.fragment, + ) + ) + + @staticmethod + def build_route_slug(*, page: Page) -> str: + """ + Return the full route path of the page if available, + otherwise the page slug. + """ + try: + _, _, page_path = page.get_url_parts(request=None) + return (page_path or "").strip("/") or str(page.slug) + except (AttributeError, TypeError, ValueError): + return str(page.slug) + + @classmethod + def build_frontend_route_url(cls, *, base_url: str, route_slug: str) -> str: + """Build and validate a public frontend route URL for a page. + + This helper is shared between the CMS view and Wagtail hooks to avoid + drift in URL-shaping behavior. + + Args: + base_url: Frontend base URL from settings. + route_slug: Page route slug/path relative to base URL. + + Returns: + str: Absolute frontend URL ending with a trailing slash. + + Raises: + ImproperlyConfigured: If the resulting URL is not on the trusted + frontend host allow-list. + """ + route_path = route_slug.strip("/") + frontend_url = ( + f"{base_url.rstrip('/')}/nocache/{route_path}" + if route_path + else f"{base_url.rstrip('/')}/nocache" + ) + + # Build it with nocache path - we always view uncached from the CMS + frontend_url = f"{frontend_url.rstrip('/')}" + + return cls.validate_frontend_redirect_url(frontend_url=frontend_url) + + @classmethod + def build_frontend_preview_base_url(cls, *, base_url: str) -> str: + """Build and validate the frontend preview endpoint URL. + + Args: + base_url: Frontend base URL from settings. + + Returns: + str: Absolute frontend `/preview` endpoint URL. + + Raises: + ImproperlyConfigured: If the resulting URL is not on the trusted + frontend host allow-list. + """ + frontend_url = f"{base_url.rstrip('/')}/preview" + return cls.validate_frontend_redirect_url(frontend_url=frontend_url) + + def get(self, request, pk): + """Generate a short-lived preview token and redirect to the frontend. + + Security rationale: + The redirect target is always validated before returning the + response. This blocks open-redirect scenarios where a hostile + or incorrect URL could send CMS users to an untrusted domain. + + Input handling summary for audit/review: + - `pk` arrives via a digits-only route and is resolved via + `get_object_or_404`, so invalid IDs do not proceed. + - `embargo_time` is treated as untrusted and parsed as either + `now` or Unix epoch seconds; invalid values are discarded. + - `slug` and `page_id` used in the redirect URL are derived from + the resolved page object, not direct request parameters. + - Redirect destination is accepted only when host/scheme validation + succeeds via `validate_frontend_redirect_url`. + + Args: + request: The HTTP request object + pk: Primary key of the Page to preview + + Returns: + HttpResponseRedirect: Redirect to frontend preview URL with signed token + + Raises: + Http404: If page with given pk does not exist + PermissionDenied: If user lacks edit permission for the page + """ + page = get_object_or_404(Page, pk=pk).specific + + perms = page.permissions_for_user(request.user) + if not perms.can_edit(): + raise PermissionDenied + + embargo_time_value = request.GET.get("embargo_time") + parsed_embargo_time = None + if embargo_time_value is not None: + embargo_time_value = embargo_time_value.strip() + if not embargo_time_value: + embargo_time_value = None + else: + parsed_embargo_time = parse_embargo_time_value(embargo_time_value) + if parsed_embargo_time is None: + embargo_time_value = None + elif embargo_time_value.lower() != "now": + embargo_time_value = str(int(embargo_time_value)) + + payload = { + "page_id": page.pk, + "iat": int(timezone.now().timestamp()), + "exp": int( + ( + timezone.now() + + timedelta(seconds=int(settings.PAGE_PREVIEWS_TOKEN_TTL_SECONDS)) + ).timestamp() + ), + } + + if parsed_embargo_time is not None: + payload["embargo_time"] = int(parsed_embargo_time.timestamp()) + + token = dumps(payload, salt=settings.PAGE_PREVIEWS_TOKEN_SALT) + + route_slug = self.build_route_slug(page=page) + + frontend_url = self.build_frontend_preview_base_url( + base_url=config.FRONTEND_URL + ) + frontend_url = self.build_preview_url( + raw_url=frontend_url, + slug=route_slug, + token=token, + page_id=page.pk, + embargo_time_value=embargo_time_value, + ) + + return redirect(frontend_url) class LinkBrowseView(BrowseView): diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 949c50d18..b0fcfb896 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -1,21 +1,31 @@ -from django.urls import path -from django.urls.resolvers import RoutePattern +from typing import override + +from django.conf import settings from drf_spectacular.utils import extend_schema +from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response from wagtail.api.v2.views import PagesAPIViewSet from caching.private_api.decorators import cache_response -from cms.dashboard.serializers import CMSDraftPagesSerializer, ListablePageSerializer +from cms.dashboard.serializers import ListablePageSerializer +from validation.shared import ( + get_cms_auth_bearer_token, + get_cms_auth_payload, + validate_preview_hmac_token, +) @extend_schema(tags=["cms"]) -class CMSPagesAPIViewSet(PagesAPIViewSet): +class BaseCMSPagesAPIViewSet(PagesAPIViewSet): + """Shared CMS pages API behavior for published and draft endpoints.""" + permission_classes = [] base_serializer_class = ListablePageSerializer listing_default_fields = PagesAPIViewSet.listing_default_fields + ["show_in_menus"] detail_only_fields = [] + @override def get_queryset(self): """Returns the queryset as per the individual models @@ -41,42 +51,118 @@ def get_queryset(self): queryset = super().get_queryset() return queryset.specific() - @cache_response() def listing_view(self, request: Request) -> Response: """This endpoint returns a list of published pages from the CMS (Wagtail). The payload includes page `title`, `id` and `meta` data about each page. """ return super().listing_view(request=request) - @cache_response() def detail_view(self, request: Request, pk: int) -> Response: """This end point returns a page from the CMS based on a Page `ID`.""" return super().detail_view(request=request, pk=pk) @extend_schema(tags=["cms"]) -class CMSDraftPagesViewSet(PagesAPIViewSet): - base_serializer_class = CMSDraftPagesSerializer - permission_classes = [] +class CMSPagesAPIViewSet(BaseCMSPagesAPIViewSet): + """Cached API viewset for published CMS pages.""" + @cache_response() + def listing_view(self, request: Request) -> Response: + """Return the cached published page listing response.""" + return super().listing_view(request) + + @cache_response() def detail_view(self, request: Request, pk: int) -> Response: - """This endpoint returns a page including any unpublished changes in its payload. + """Return the cached published page detail response.""" + return super().detail_view(request, pk) + + +@extend_schema(tags=["cms"]) +class CMSDraftPagesViewSet(BaseCMSPagesAPIViewSet): + """API viewset for authenticated draft page preview responses.""" + + INVALID_TOKEN_DETAIL = {"detail": "The token was invalid"} + + @staticmethod + def _with_embargo_time(data: dict, embargo_time: int | None) -> dict: + """Add embargo_time field to serialized page data. - **Note:** this only returns `published` pages with `unpublished` changes. + Args: + data: The serialized page response data. + embargo_time: Unix timestamp or None if no embargo is set. + + Returns: + A copy of data with the embargo_time field added. """ - instance = self.get_object() - instance = instance.get_latest_revision_as_object() - serializer = self.get_serializer(instance) - return Response(serializer.data) + response_data = dict(data) + response_data["embargo_time"] = embargo_time + return response_data - @classmethod - def get_urlpatterns(cls) -> list[RoutePattern]: - """This returns a list of URL patterns for the viewset. + @override + def detail_view(self, request: Request, pk: int) -> Response: + """Retrieve a draft page by ID with HMAC token validation. - Notes: - Only the detail `/{id}` path is included. + This endpoint serves pre-signed preview tokens from the CMS admin. + It validates the token, applies any embargo time, and returns either + the latest draft revision (if available) or the published page. + + Args: + request: The HTTP request containing the authentication token + in the x-cms-auth header. + pk: The primary key of the page to retrieve. + Returns: + Response with: + - 200: Page detail with embargo_time field set. + - 401: Invalid or missing token. + - 403: PAGE_PREVIEWS_ENABLED is False. + - 404: Page not found. + - 501: Embargo Date (embargo_time) not supported. """ - return [ - path("/", cls.as_view({"get": "detail_view"}), name="detail"), - ] + # Check if previews are enabled + page_previews_enabled = getattr(settings, "PAGE_PREVIEWS_ENABLED", False) + if not page_previews_enabled: + return Response( + { + "detail": "Page previews are disabled. Contact your site administrator for more information." + }, + status=status.HTTP_403_FORBIDDEN, + ) + + # Require Bearer token in x-cms-auth header + token = get_cms_auth_bearer_token(request.headers) + if token is None: + return Response( + self.INVALID_TOKEN_DETAIL, status=status.HTTP_401_UNAUTHORIZED + ) + + # Validate token once and optionally receive decoded payload. + validation_result = validate_preview_hmac_token( + token, + page_id=pk, + include_payload=True, + ) + if not validation_result: + return Response( + self.INVALID_TOKEN_DETAIL, status=status.HTTP_401_UNAUTHORIZED + ) + payload = ( + validation_result + if isinstance(validation_result, dict) + else (get_cms_auth_payload(token) or {}) + ) + + # Middleware already sets request-scoped embargo time when applicable. + embargo_time = payload.get("embargo_time") + + # Get page instance + instance = self.get_queryset().filter(pk=pk).first() + if instance is None: + return Response(status=status.HTTP_404_NOT_FOUND) + + # Return latest draft if available, else published + latest_revision = instance.get_latest_revision() + if latest_revision: + instance = latest_revision.as_object() + serializer = self.get_serializer(instance) + return Response(self._with_embargo_time(serializer.data, embargo_time)) diff --git a/cms/dashboard/wagtail_hooks.py b/cms/dashboard/wagtail_hooks.py index e20b3d7fc..27859f067 100644 --- a/cms/dashboard/wagtail_hooks.py +++ b/cms/dashboard/wagtail_hooks.py @@ -1,14 +1,80 @@ +import logging +import re +from typing import Any + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.core.handlers.wsgi import WSGIRequest from django.templatetags.static import static +from django.urls import NoReverseMatch, re_path, reverse from django.utils.html import format_html from django.utils.safestring import SafeString from draftjs_exporter.dom import DOM from wagtail import hooks +from wagtail.admin.action_menu import ActionMenuItem from wagtail.admin.menu import MenuItem from wagtail.admin.site_summary import PagesSummaryItem, SummaryItem +from wagtail.admin.widgets import Button from wagtail.models import Page from wagtail.whitelist import check_url +from cms.dashboard.views import PreviewToFrontendRedirectView + +VIEW_LIVE_LABEL = "View Live" +PREVIEW_LABEL = "Preview" +logger = logging.getLogger(__name__) + + +class FrontendPreviewAction(ActionMenuItem): + """Primary action-menu item that links editors to the frontend preview flow.""" + + name = "action-preview" + icon_name = "preview" + template_name = "wagtailadmin/pages/action_menu/frontend_preview.html" + + def __init__(self, url: str, label: str, order: int | None = None): + """Store the target URL and label for the custom action item.""" + super().__init__(order=order) + self._url = url + self.label = label + + def get_url(self, parent_context): + """Return the precomputed frontend URL for this action item.""" + return self._url + + +def _get_preview_button_label(page: Page) -> str | None: + """Return the appropriate preview action label for the given page state.""" + if not page.custom_preview_enabled: + return None + + has_draft = page.has_unpublished_changes + has_live = page.live + + if has_draft: + return PREVIEW_LABEL + + if has_live: + return VIEW_LIVE_LABEL + + return None + + +def _build_view_live_url(page: Page) -> str | None: + """Build the absolute frontend URL used by the View Live action.""" + base_url = getattr(settings, "FRONTEND_URL", "") + route_path = PreviewToFrontendRedirectView.build_route_slug(page=page) + try: + return PreviewToFrontendRedirectView.build_frontend_route_url( + base_url=base_url, + route_slug=route_path, + ) + except ImproperlyConfigured: + logger.exception( + "FRONTEND_URL is not a supported http(s) URL. Hiding View Live action" + ) + return None + @hooks.register("insert_global_admin_css") def global_admin_css() -> SafeString: @@ -70,6 +136,8 @@ def update_summary_items( "icons/text.svg", "icons/percentage.svg", "icons/weather.svg", + "icons/preview.svg", + "icons/rocket.svg", ] @@ -88,12 +156,75 @@ def register_icons(icons: list[str]) -> list[str]: return icons + ADDITIONAL_CUSTOM_ICONS +@hooks.register("register_page_header_buttons") +def frontend_preview_button( + page: Page, + user: Any, + next_url: str | None, + view_name: str, +) -> list[Button]: + """Add a preview button to the page header that redirects to the frontend. + + The admin view will create a signed token and then redirect to the + frontend. We reverse the admin URL by name. If reversing fails, no + preview button is rendered. + + Args: + page: The page being edited. + user: The current user. + next_url: The next URL after action. + view_name: The current view name (e.g., 'edit'). + + Returns: + List of Button instances for the page header, or empty list if not + in edit view. + """ + if view_name != "edit": + return [] + + # Hide preview and view live buttons if PAGE_PREVIEWS_ENABLED is False + if not settings.PAGE_PREVIEWS_ENABLED: + return [] + + button_label = _get_preview_button_label(page=page) + if button_label is None: + return [] + + # If the label is 'View Live', use the route-style live URL + if button_label == VIEW_LIVE_LABEL: + live_url = _build_view_live_url(page=page) + if live_url: + return [ + Button( + label=button_label, + url=live_url, + priority=10, + attrs={"target": "_blank", "rel": "noopener noreferrer"}, + ) + ] + # Otherwise, use the preview redirect + admin_url = _build_frontend_preview_url(page=page) + if not admin_url: + return [] + + return [ + Button( + label=button_label, + url=admin_url, + priority=10, + attrs={"target": "_blank", "rel": "noopener noreferrer"}, + ) + ] + + def link_entity_with_href(props: dict): + """Render a rich-text link entity while preserving a resolved href value.""" link_props = _build_link_props(props=props) return DOM.create_element("a", link_props, props["children"]) def _build_link_props(props: dict) -> dict[str, str | int]: + """Build rich-text link attributes, resolving page links to hrefs when possible.""" link_props = {} page_id = props.get("id") @@ -112,6 +243,7 @@ def _build_link_props(props: dict) -> dict[str, str | int]: def _get_page_url(page_id: int) -> str: + """Return the full URL for a linked page or an empty string if unavailable.""" try: page = Page.objects.get(id=page_id).specific except Page.DoesNotExist: @@ -119,8 +251,176 @@ def _get_page_url(page_id: int) -> str: return page.full_url +@hooks.register("register_admin_urls") +def register_admin_urls(): + """Register admin URLs for CMS dashboard views. + + We register an admin redirect endpoint + (`/admin/preview-to-frontend//`) that signs a short-lived + preview token and redirects the user to the external frontend. + The redirect logic is implemented in `cms.dashboard.views`. + """ + return [ + re_path( + r"^preview-to-frontend/(?P[0-9]+)/$", + PreviewToFrontendRedirectView.as_view(), + name="cms_preview_to_frontend", + ), + ] + + @hooks.register("register_rich_text_features", order=1) def register_link_props(features): + """Extend Wagtail rich-text link conversion to include resolved href values.""" rule = features.converter_rules_by_converter["contentstate"]["link"] rule["to_database_format"]["entity_decorators"]["LINK"] = link_entity_with_href features.register_converter_rule("contentstate", "link", rule) + + +@hooks.register("construct_page_action_menu") +def add_frontend_preview_action( + menu_items: list[Any], + request: WSGIRequest | None, + context: dict[str, Any], +) -> None: + """Insert a top-level Preview action that redirects to the frontend. + + We add this to the page action menu so it appears as a primary action in + the page editor header (rather than being buried in a dropdown). If the + page has no primary key (create view) we skip adding it. + + Args: + menu_items: List of menu items to modify in place. + request: The current HTTP request. + context: Context dictionary containing the page being edited. + + Note: + This method is conservative and returns early if inputs or settings are + not suitable for showing a frontend preview action. Unexpected + runtime errors are logged and ignored to avoid breaking the editor UI. + """ + try: + page = context.get("page") + if not page or not getattr(page, "pk", None): + logger.debug("Skipping frontend preview action: page missing or unsaved") + return + + if not settings.PAGE_PREVIEWS_ENABLED: + logger.debug( + "Skipping frontend preview action: PAGE_PREVIEWS_ENABLED is False." + ) + return + + button_label = _get_preview_button_label(page=page) + if button_label is None: + logger.debug( + "Skipping frontend preview action: no preview/view-live label for page %s", + page.pk, + ) + return + + action_url = None + if button_label == VIEW_LIVE_LABEL: + action_url = _build_view_live_url(page=page) or _build_frontend_preview_url( + page=page + ) + elif button_label == PREVIEW_LABEL: + action_url = _build_frontend_preview_url(page=page) + + if action_url: + preview_item = FrontendPreviewAction( + url=action_url, label=button_label, order=0 + ) + menu_items.insert(0, preview_item) + except (AttributeError, TypeError, ValueError, LookupError, RuntimeError): + logger.debug( + "Failed to construct frontend preview action; editor UI will continue" + ) + + +def _build_frontend_preview_url(page: Page) -> str | None: + """Build the admin redirect URL used by the top-level Preview action.""" + try: + return reverse("cms_preview_to_frontend", args=[page.pk]) + except NoReverseMatch: + logger.debug( + "Preview admin URL cannot be reversed; preview actions will be hidden" + ) + return None + + +def _replace_view_live_button_href(message_html: str, target_url: str) -> str: + """Replace the first View live anchor href in a Wagtail flash message.""" + return re.sub( + r'(]*)(>\s*View live\s*)', + rf'\1{target_url}\3\4 target="_blank" rel="noreferrer"\5', + message_html, + count=1, + flags=re.IGNORECASE, + ) + + +def _rewrite_view_live_button_in_messages(messages_list: list, live_url: str) -> bool: + """Rewrite the first matching View live button in the supplied message list.""" + for message in reversed(messages_list): + message_html = str(getattr(message, "message", "")) + if "View live" not in message_html: + continue + + updated_html = _replace_view_live_button_href( + message_html=message_html, + target_url=live_url, + ) + if updated_html != message_html: + # nosec B703 - updated_html is Wagtail's own flash message HTML with only + # the href replaced by a settings-derived URL; no user input is interpolated. + # There's no injection vector. Bandit simply can't infer that statically, hence the false positive. + message.message = SafeString(updated_html) # nosec B703 + return True + + return False + + +def _rewrite_post_publish_view_live_button_url( + request: WSGIRequest, page: Page +) -> None: + """Rewrite Wagtail's post-publish View live button to the frontend host.""" + if not settings.PAGE_PREVIEWS_ENABLED: + return + + if not getattr(page, "live", False): + return + + live_url = _build_view_live_url(page=page) + if not live_url: + return + + storage = getattr(request, "_messages", None) + if not storage: + return + + for messages_attr in ("_queued_messages", "_loaded_messages"): + messages_list = getattr(storage, messages_attr, None) + if not messages_list: + continue + + if _rewrite_view_live_button_in_messages(messages_list, live_url): + return + + +@hooks.register("after_edit_page") +def rewrite_post_publish_view_live_button_after_edit( + request: WSGIRequest, + page: Page, +) -> None: + """Apply frontend View live URL rewriting after editing a page.""" + _rewrite_post_publish_view_live_button_url(request=request, page=page) + + +@hooks.register("after_create_page") +def rewrite_post_publish_view_live_button_after_create( + request: WSGIRequest, + page: Page, +) -> None: + """Apply frontend View live URL rewriting after creating a page.""" + _rewrite_post_publish_view_live_button_url(request=request, page=page) diff --git a/cms/home/models/landing_page.py b/cms/home/models/landing_page.py index 034582118..3e055ec72 100644 --- a/cms/home/models/landing_page.py +++ b/cms/home/models/landing_page.py @@ -73,11 +73,6 @@ class LandingPage(UKHSAPage): objects = LandingPageManager() - @classmethod - def is_previewable(cls): - """Returns False. This is a headline CMS, preview panel is not supported .""" - return False - def get_url_parts(self, request=None) -> tuple[int, str, str]: """Builds the full URL for the home page diff --git a/cms/topic/models.py b/cms/topic/models.py index 9e687cc38..d2a47db3b 100644 --- a/cms/topic/models.py +++ b/cms/topic/models.py @@ -128,11 +128,6 @@ def __init__(self, *args, **kwargs): self._core_timeseries_manager = core_timeseries_manager self._core_headline_manager = core_headline_manager - @classmethod - def is_previewable(cls) -> bool: - """Returns False. Since this is a headless CMS the preview panel is not supported""" - return False - @property def selected_topics(self) -> set[str]: """Extracts a set of the selected topics from all the headline & chart blocks in the `body` diff --git a/common/request_caching.py b/common/request_caching.py new file mode 100644 index 000000000..dfe8f66b8 --- /dev/null +++ b/common/request_caching.py @@ -0,0 +1,32 @@ +"""Support request-scoped cache for per-request cache disablement.""" + +import contextvars +import logging + +""" +If set to True. _disable_request_caching_ctx indicates that we should disable caching for the duration of this request. +""" +_disable_request_caching_ctx: contextvars.ContextVar[bool | None] = ( + contextvars.ContextVar("_request_caching_disabled_ctx", default=None) +) + +_logger = logging.getLogger(__name__) + + +def disable_request_caching(): + _disable_request_caching_ctx.set(True) + + +def get_request_caching() -> bool | None: + """Return the request_caching for the current request context. + Falls back to None. + """ + request_caching_disabled = _disable_request_caching_ctx.get() + if isinstance(request_caching_disabled, bool): + return True # Disable caching for this request + return None # Don't do anything - leave all caching in place + + +def clear_request_caching() -> None: + """Clear the request caching for the current request context.""" + _disable_request_caching_ctx.set(None) diff --git a/common/virtual_clock.py b/common/virtual_clock.py new file mode 100644 index 000000000..d61928ba4 --- /dev/null +++ b/common/virtual_clock.py @@ -0,0 +1,100 @@ +"""Support request-scoped "Embargo Date" for embargoed data views. + +This module provides a virtual clock that allows authorised preview users to +view data as if the current time were set to the future (or past). +It is used to support embargo previews, by enabling the consumer to view data +that is presently restricted. +""" + +import contextvars +import datetime +import logging +import typing as t + +from django.conf import settings +from django.utils import timezone + +from validation.shared import validate_preview_hmac_token + +_embargo_time_ctx: contextvars.ContextVar[datetime.datetime | None] = ( + contextvars.ContextVar("embargo_time", default=None) +) +_logger = logging.getLogger(__name__) + + +class EmbargoDateNotSupportedError(Exception): + """Raised when preview Embargo Date is requested on a server that disables it.""" + + +EMBARGO_DATE_NOT_SUPPORTED_MESSAGE = '"Embargo Date" is not supported on this server.' + + +def parse_embargo_time_value(embargo_time_value: t.Any) -> datetime.datetime | None: + """Parse embargo time value into a timezone-aware datetime. + + Accepted values: + - "now" (case-insensitive) + - Unix epoch seconds as string/int/float + """ + if isinstance(embargo_time_value, str): + candidate = embargo_time_value.strip() + if candidate.lower() == "now": + return timezone.now() + try: + epoch_seconds = int(candidate) + except (TypeError, ValueError): + return None + elif isinstance(embargo_time_value, (int, float)) and not isinstance( + embargo_time_value, bool + ): + epoch_seconds = int(embargo_time_value) + else: + return None + + try: + return datetime.datetime.fromtimestamp(epoch_seconds, tz=datetime.UTC) + except (OverflowError, OSError, ValueError): + return None + + +def set_embargo_time(embargo_time_value: object, *, token: str) -> bool: + """Set embargo time for current request context after validation. + + The value must be either `now` or valid epoch seconds. + """ + page_previews_enabled = getattr(settings, "PAGE_PREVIEWS_ENABLED", False) + if not page_previews_enabled: + _embargo_time_ctx.set(timezone.now()) + _logger.error(EMBARGO_DATE_NOT_SUPPORTED_MESSAGE) + raise EmbargoDateNotSupportedError(EMBARGO_DATE_NOT_SUPPORTED_MESSAGE) + + if not validate_preview_hmac_token(token): + return False + + embargo_time = parse_embargo_time_value(embargo_time_value) + if embargo_time is None: + return False + + _embargo_time_ctx.set(embargo_time) + return True + + +def get_embargo_time() -> datetime.datetime: + """Return the embargo_time for the current request context. + Falls back to timezone.now(). + """ + embargo_time = _embargo_time_ctx.get() + if isinstance(embargo_time, datetime.datetime): + return embargo_time + + return timezone.now() + + +def get_embargo_time_context() -> contextvars.ContextVar[datetime.datetime | None]: + """Return the shared request-scoped embargo time context variable.""" + return _embargo_time_ctx + + +def clear_embargo_time() -> None: + """Clear the embargo_time for the current request context.""" + _embargo_time_ctx.set(None) diff --git a/docs/environment_variables.md b/docs/environment_variables.md index 027d9e23e..df0428a4e 100644 --- a/docs/environment_variables.md +++ b/docs/environment_variables.md @@ -130,6 +130,36 @@ See the [django docs](https://docs.djangoproject.com/en/4.2/ref/settings/#std-se --- +### CMS Page previews configuration + +Wagtail CMS supports page previews in a configured frontend. We use a headless CMS with a custom frontend renderer, so built-in Wagtail previews are disabled and replaced by our own preview flow. The following environment variables are supported: + +#### `FRONTEND_URL` + +Required for preview redirects to work. The backend builds preview redirects from this absolute frontend base URL and appends the fixed `/preview` path plus query parameters. Sonar code scanner prefers that we keep 'http' out of the codebase. Ensure that you have `export FRONTEND_URL='http://localhost:3000'` configured in your `.env` file when testing previews locally. + +The backend always appends (or overrides) the query params `slug`, `t`, and `page_id` in the final redirect URL. When Embargo Date is set, it also appends `et` (Unix epoch integer or `now`). + +Example generated redirect URL: +- `http://localhost:3000/preview?slug=weather-health-alerts&t=&page_id=17&et=now` + +Note: preview draft fetches use a slug route (`/api/drafts/{slug}/`), while token validation enforces the signed `page_id` claim against the resolved page. + +Preview token signing salt is derived automatically from Django's `SECRET_KEY` as a 120-character opaque string. It is process-stable across workers that share the same application secret and is not configured through an environment variable. + +#### `PAGE_PREVIEWS_TOKEN_TTL_SECONDS` + +Preview token time-to-live (TTL) in seconds. If omitted, the backend default is 30 seconds. + +Example: +- `PAGE_PREVIEWS_TOKEN_TTL_SECONDS=300` + +Local development override: +- When running with `APIENV=LOCAL`, local settings default this to 86400 seconds (24 hours). +- Setting `PAGE_PREVIEWS_TOKEN_TTL_SECONDS` in your local `.env` overrides both the 30-second default and the 86400-second local-development default. + +--- + ### Email configuration #### `FEEDBACK_EMAIL_RECIPIENT_ADDRESS` diff --git a/metrics/api/middleware.py b/metrics/api/middleware.py new file mode 100644 index 000000000..d164b89a6 --- /dev/null +++ b/metrics/api/middleware.py @@ -0,0 +1,146 @@ +"""Middleware for establishing request-scoped embargo time on API requests. + +This module inspects CMS preview authentication on all custom API routes under +`/api/*`. + +Embargo time is set for the lifetime of the current request only when all of +the following are true: the request targets `/api/*`, the CMS auth bearer token +is present and valid, the token payload contains an `embargo_time` value, page +previews are enabled on the server, and the embargo time value can be validated +and applied. + +Embargo time is not set when the request is outside `/api/*`, when there is no +CMS auth header, or when a valid preview token does not include an +`embargo_time` value. In those cases the request continues normally and the API +response is generated against the server's current time. + +If an auth header is present but invalid, or if the embargo time cannot be +validated and applied, the request is rejected with `401`. If the request asks +for Embargo Date with an `embargo_time` value but the server has page previews +disabled, the request is rejected with `HTTP 501 (Not Implemented)`. +""" + +from django.conf import settings +from django.http import HttpRequest, HttpResponse, JsonResponse + +from common.request_caching import disable_request_caching +from common.virtual_clock import EMBARGO_DATE_NOT_SUPPORTED_MESSAGE, set_embargo_time +from validation.shared import ( + CMS_AUTH_HEADER, + get_cache_control_header, + get_cms_auth_bearer_token, + get_cms_auth_payload, + validate_preview_hmac_token, +) + + +class EmbargoMiddleware: + """Set request-scoped embargo time when a valid CMS auth token is present. + + Notes: + - Applies only to custom API request paths (`/api/*`). + - Invalid/missing headers do not block requests. + - Context is always cleared after the request completes. + """ + + INVALID_TOKEN_DETAIL = {"detail": "The token was invalid"} + + def __init__(self, get_response): + """Store the downstream callable for middleware execution.""" + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + """Apply embargo-time context for eligible API requests before dispatch.""" + if self._is_custom_api_request(request=request): + rejection_response = self._set_embargo_time_if_header_is_valid( + request=request + ) + if rejection_response is not None: + return rejection_response + + return self.get_response(request) + + @staticmethod + def _is_custom_api_request(*, request: HttpRequest) -> bool: + """Return whether the request targets a custom API route.""" + path_info = getattr(request, "path_info", "") + path = path_info if isinstance(path_info, str) else getattr(request, "path", "") + if not isinstance(path, str): + path = "" + return path.startswith("/api/") + + @classmethod + def _set_embargo_time_if_header_is_valid( + cls, *, request: HttpRequest + ) -> HttpResponse | None: + """Validate preview headers and set request-scoped embargo time when allowed.""" + token = get_cms_auth_bearer_token(request.headers) + if token is None: + has_auth_header = bool(request.headers.get(CMS_AUTH_HEADER, "")) + if has_auth_header: + return JsonResponse(cls.INVALID_TOKEN_DETAIL, status=401) + return None + + is_valid = validate_preview_hmac_token(token) + if not is_valid: + return JsonResponse(cls.INVALID_TOKEN_DETAIL, status=401) + + payload = get_cms_auth_payload(token) or {} + + embargo_time = payload.get("embargo_time") + if embargo_time is None: + return None + + if not getattr(settings, "PAGE_PREVIEWS_ENABLED", False): + return JsonResponse( + {"detail": EMBARGO_DATE_NOT_SUPPORTED_MESSAGE}, + status=501, + ) + + was_set = set_embargo_time(embargo_time, token=token) + if not was_set: + return JsonResponse(cls.INVALID_TOKEN_DETAIL, status=401) + + return None + + +class RequestScopedCachingConfigMiddleware: + """Set request-scoped caching disabled when Cache-Control no-store HTTP header is present. + + Notes: + - Applies only to custom API request paths (`/api/*`). + - Invalid/missing headers do not block requests. + - Context is always cleared after the request completes. + """ + + def __init__(self, get_response): + """Store the downstream callable for middleware execution.""" + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + """Apply request_no_cache context for eligible API requests before dispatch.""" + if self._is_custom_api_request(request=request): + self._set_no_cache_if_header_is_valid(request=request) + return self.get_response(request) + + @staticmethod + def _is_custom_api_request(*, request: HttpRequest) -> bool: + """Return whether the request targets a custom API route.""" + path_info = getattr(request, "path_info", "") + path = path_info if isinstance(path_info, str) else getattr(request, "path", "") + if not isinstance(path, str): + path = "" + return path.startswith("/api/") + + @classmethod + def _set_no_cache_if_header_is_valid( + cls, *, request: HttpRequest + ) -> HttpResponse | None: + """Validate Cache-Control headers and set request-scoped request_no_cache if allowed.""" + cache_control = get_cache_control_header(request.headers) + if cache_control is None: + return None + + disable_request_caching() + + return None diff --git a/metrics/api/settings/default.py b/metrics/api/settings/default.py index ef8c84d97..39f3191b2 100644 --- a/metrics/api/settings/default.py +++ b/metrics/api/settings/default.py @@ -10,7 +10,10 @@ https://docs.djangoproject.com/en/3.2/ref/settings/ """ +import hmac import os +from base64 import urlsafe_b64encode +from hashlib import sha512 from pathlib import Path import config @@ -90,6 +93,8 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "metrics.api.middleware.EmbargoMiddleware", + "metrics.api.middleware.RequestScopedCachingConfigMiddleware", ] APPEND_SLASH = True @@ -296,16 +301,52 @@ # Base URL to use when referring to full URLs within the Wagtail admin backend - # e.g. in notification emails. Don't include '/admin' or a trailing slash -WAGTAILADMIN_BASE_URL = "http://example.com" +WAGTAILADMIN_BASE_URL = "http://example.com" # NOSONAR # Controls the maximum number of results which can be requested from the pages API. # Set to None for no limit. WAGTAILAPI_LIMIT_MAX = None - CSRF_TRUSTED_ORIGINS = ["https://*.ukhsa-dashboard.data.gov.uk"] CSRF_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +PAGE_PREVIEWS_ENABLED = os.environ.get("PAGE_PREVIEWS_ENABLED", "false").lower() in { + "1", + "true", + "yes", + "on", +} +FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:3000") +PAGE_PREVIEWS_TOKEN_TTL_SECONDS = int( + os.environ.get("PAGE_PREVIEWS_TOKEN_TTL_SECONDS", "30") +) + + +def _build_page_previews_token_salt() -> str: + """Build a process-stable 120-character signing salt for preview tokens. + + The value is derived from Django's SECRET_KEY so all workers with the same + application secret produce the same opaque salt without requiring a + separate environment variable. + """ + secret_key = SECRET_KEY.encode("utf-8") + namespace_primary = b"page-previews-token-salt:v1" + namespace_secondary = b"page-previews-token-salt:v1:secondary" + primary = ( + urlsafe_b64encode(hmac.new(secret_key, namespace_primary, sha512).digest()) + .decode("ascii") + .rstrip("=") + ) + secondary = ( + urlsafe_b64encode(hmac.new(secret_key, namespace_secondary, sha512).digest()) + .decode("ascii") + .rstrip("=") + ) + return (primary + secondary)[:120] + + +PAGE_PREVIEWS_TOKEN_SALT = _build_page_previews_token_salt() diff --git a/metrics/api/settings/local.py b/metrics/api/settings/local.py index 8d0cf9636..e40b016b7 100644 --- a/metrics/api/settings/local.py +++ b/metrics/api/settings/local.py @@ -1,6 +1,10 @@ import os -from metrics.api.settings import INSTALLED_APPS, MIDDLEWARE, ROOT_LEVEL_BASE_DIR +from metrics.api.settings import ( + INSTALLED_APPS, + MIDDLEWARE, + ROOT_LEVEL_BASE_DIR, +) DATA_UPLOAD_MAX_NUMBER_FIELDS = None @@ -32,3 +36,14 @@ ] INTERNAL_IPS = ["127.0.0.1"] + +# Local-only override to ease frontend preview testing +PAGE_PREVIEWS_TOKEN_TTL_SECONDS = int( + os.environ.get("PAGE_PREVIEWS_TOKEN_TTL_SECONDS", "86400") +) +PAGE_PREVIEWS_ENABLED = os.environ.get("PAGE_PREVIEWS_ENABLED", "true").lower() in { + "1", + "true", + "yes", + "on", +} diff --git a/metrics/api/settings/public_api.py b/metrics/api/settings/public_api.py index 6ad6666e6..e078d6439 100644 --- a/metrics/api/settings/public_api.py +++ b/metrics/api/settings/public_api.py @@ -18,5 +18,6 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "metrics.api.middleware.EmbargoMiddleware", "public_api.middleware.NoIndexNoFollowMiddleware", # Add noindex, nofollow robots-tag middleware for non-public ] diff --git a/metrics/data/managers/api_models/time_series.py b/metrics/data/managers/api_models/time_series.py index b4a79032b..7342a1ba0 100644 --- a/metrics/data/managers/api_models/time_series.py +++ b/metrics/data/managers/api_models/time_series.py @@ -9,7 +9,8 @@ from django.db import models from django.db.models.functions.window import Rank -from django.utils import timezone + +from common.virtual_clock import get_embargo_time class APITimeSeriesQuerySet(models.QuerySet): @@ -247,7 +248,7 @@ def _exclude_data_under_embargo(*, queryset: models.QuerySet) -> models.QuerySet The filtered queryset which excludes emargoed data """ - current_time = timezone.now() + current_time = get_embargo_time() return queryset.filter( models.Q(embargo__lte=current_time) | models.Q(embargo=None) ) diff --git a/metrics/data/managers/core_models/headline.py b/metrics/data/managers/core_models/headline.py index f33bf913c..2494e9a85 100644 --- a/metrics/data/managers/core_models/headline.py +++ b/metrics/data/managers/core_models/headline.py @@ -10,8 +10,8 @@ from typing import Optional, Self from django.db import models -from django.utils import timezone +from common.virtual_clock import get_embargo_time from metrics.api.permissions.fluent_permissions import ( validate_permissions_for_non_public, ) @@ -66,7 +66,7 @@ def _filter_for_any_optional_fields( queryset: Self, geography: str, geography_type: str, - geography_code: str, + geography_code: str | None, stratum: str, sex: str, age: str, @@ -280,7 +280,7 @@ def _exclude_data_under_embargo(*, queryset: models.QuerySet) -> models.QuerySet The filtered queryset which excludes embargoed data """ - current_time = timezone.now() + current_time = get_embargo_time() return queryset.filter( models.Q(embargo__lte=current_time) | models.Q(embargo=None) ) @@ -301,7 +301,7 @@ def find_latest_released_embargo_for_metrics( or None if no data could be found. """ - current_time = timezone.now() + current_time = get_embargo_time() try: return ( self.filter(metric__name__in=metrics, embargo__lte=current_time) diff --git a/metrics/data/managers/core_models/time_series.py b/metrics/data/managers/core_models/time_series.py index aed8df7f6..16a0a2af2 100644 --- a/metrics/data/managers/core_models/time_series.py +++ b/metrics/data/managers/core_models/time_series.py @@ -11,8 +11,8 @@ from django.db import models from django.db.models.query_utils import Q -from django.utils import timezone +from common.virtual_clock import get_embargo_time from metrics.api.permissions.fluent_permissions import ( is_public_data_only_enforced, validate_permissions_for_non_public, @@ -452,7 +452,7 @@ def _exclude_data_under_embargo(*, queryset: models.QuerySet) -> models.QuerySet The filtered queryset which excludes emargoed data """ - current_time = timezone.now() + current_time = get_embargo_time() return queryset.filter( models.Q(embargo__lte=current_time) | models.Q(embargo=None) ) @@ -500,7 +500,8 @@ def find_latest_released_embargo_for_metrics( or None if no data could be found. """ - current_time = timezone.now() + + current_time = get_embargo_time() try: return ( self.filter(metric__name__in=metrics, embargo__lte=current_time) @@ -515,6 +516,27 @@ def find_latest_released_embargo_for_metrics( class CoreTimeSeriesManager(models.Manager): """Custom model manager class for the `TimeSeries` model.""" + @staticmethod + def _has_access_to_non_public_data( + *, + topic: str, + metric: str, + geography: str | None, + geography_type: str | None, + theme: str, + sub_theme: str, + rbac_permissions: Iterable[RBACPermission], + ) -> bool: + return validate_permissions_for_non_public( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography_type=geography_type, + geography=geography, + rbac_permissions=rbac_permissions, + ) + def query_for_data( self, *, @@ -524,15 +546,9 @@ def query_for_data( fields_to_export: list[str] | None = None, date_to: datetime.date | None = None, field_to_order_by: str = "date", - geography: str | None = None, - geography_type: str | None = None, - stratum: str | None = None, - sex: str | None = None, - age: str | None = None, - theme: str = "", - sub_theme: str = "", metric_value_ranges: list[str | float | int] | None = None, rbac_permissions: Iterable[RBACPermission] | None = None, + **kwargs, ) -> CoreTimeSeriesQuerySet: """Filters for a 2-item object by the given params. Slices all values older than the `date_from`. @@ -611,8 +627,33 @@ def query_for_data( ]>` """ + allowed_kwargs = { + "geography", + "geography_type", + "stratum", + "sex", + "age", + "theme", + "sub_theme", + } + unexpected_kwargs = set(kwargs) - allowed_kwargs + if unexpected_kwargs: + unexpected = ", ".join(sorted(unexpected_kwargs)) + message = ( + "query_for_data() got unexpected keyword argument(s): " f"{unexpected}" + ) + raise TypeError(message) + + geography: str | None = kwargs.get("geography") + geography_type: str | None = kwargs.get("geography_type") + stratum: str | None = kwargs.get("stratum") + sex: str | None = kwargs.get("sex") + age: str | None = kwargs.get("age") + theme: str = kwargs.get("theme") or "" + sub_theme: str = kwargs.get("sub_theme") or "" + rbac_permissions: Iterable[RBACPermission] = rbac_permissions or [] - has_access_to_non_public_data: bool = validate_permissions_for_non_public( + has_access_to_non_public_data: bool = self._has_access_to_non_public_data( theme=theme, sub_theme=sub_theme, topic=topic, diff --git a/metrics/interfaces/weather_health_alerts/access.py b/metrics/interfaces/weather_health_alerts/access.py index 132468e3f..b9c22cc05 100644 --- a/metrics/interfaces/weather_health_alerts/access.py +++ b/metrics/interfaces/weather_health_alerts/access.py @@ -2,8 +2,8 @@ from collections.abc import Iterable from django.db.models.manager import Manager -from django.utils import timezone +from common.virtual_clock import get_embargo_time from metrics.data.models.core_models import CoreHeadline from metrics.domain.weather_health_alerts.state import WeatherHealthAlarmState @@ -149,7 +149,7 @@ def _parse_core_headline_as_alarm_state( refresh_date=None, ) - if core_headline.period_end <= timezone.now(): + if core_headline.period_end <= get_embargo_time(): # The last refresh is considered to be when the previous period_end expired # In this case, we fall back to the green/normal state of metric_value=1 refresh_date = core_headline.period_end diff --git a/requirements-prod.txt b/requirements-prod.txt index 4bb7e61c8..dc82650d0 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -83,7 +83,7 @@ tomli==2.4.1 typing_extensions==4.15.0 uritemplate==4.2.0 urllib3==2.7.0 -virtualenv==21.3.1 +virtualenv==21.3.0 wagtail==7.3.2 wagtail_trash==3.2.0 wagtail_modeladmin==2.3.0 diff --git a/scripts/_quality.sh b/scripts/_quality.sh index 6f5a8e02f..b2f864313 100644 --- a/scripts/_quality.sh +++ b/scripts/_quality.sh @@ -57,7 +57,7 @@ function _quality_format_check() { if [ -n "${changed_files}" ]; then echo "Some files appear to be un-formatted. Please rectify by running 'uhd quality format' and committing the changes." echo "${changed_files}" - exit 1 + return 1 else echo "No changes, the files are okay to be checked into the repo." fi diff --git a/tests/integration/cms/test_api.py b/tests/integration/cms/test_api.py index 7824a2f52..c04ef0790 100644 --- a/tests/integration/cms/test_api.py +++ b/tests/integration/cms/test_api.py @@ -1,9 +1,13 @@ -from http import HTTPStatus - import pytest +from datetime import timedelta +from http import HTTPStatus from rest_framework.response import Response from rest_framework.test import APIClient from wagtail.models import Page +from unittest import mock +from django.utils import timezone +from django.core.signing import dumps +from django.conf import settings class TestDraftPagesAPI: @@ -12,12 +16,11 @@ def path(self) -> str: return "/api/drafts" @pytest.mark.django_db - def test_request_returns_draft_with_unpublished_changes(self): + def test_draft_api_always_returns_latest_revision(self): """ - Given an APIClient - And a `Page` record which has unpublished changes - When the detail `GET /api/drafts/{id}/` endpoint is hit - Then an HTTP 200 OK response is returned + Given an APIClient and a Page record in GET /api/pages/{id} which may have unpublished changes + When the detail GET /api/drafts/{id}/ endpoint is hit + Then an HTTP 200 OK response is returned and the latest revision (published or draft) is returned """ # Given unpublished_title = "Unpublished title" @@ -28,13 +31,22 @@ def test_request_returns_draft_with_unpublished_changes(self): api_client = APIClient() + payload = { + "page_id": page.id, + "iat": int(timezone.now().timestamp()), + "exp": int((timezone.now() + timedelta(seconds=30)).timestamp()), + } + + token = dumps(payload, salt=settings.PAGE_PREVIEWS_TOKEN_SALT) + # When response_from_drafts_endpoint: Response = api_client.get( - path=f"{self.path}/{page.pk}/", + path=f"{self.path}/{page.id}/", + HTTP_X_CMS_AUTH=f"Bearer {token}", format="json", ) response_from_pages_endpoint: Response = api_client.get( - path=f"/api/pages/{page.pk}/", + path=f"/api/pages/{page.id}/", format="json", ) @@ -42,20 +54,8 @@ def test_request_returns_draft_with_unpublished_changes(self): assert response_from_drafts_endpoint.status_code == HTTPStatus.OK assert response_from_pages_endpoint.status_code == HTTPStatus.OK - # Get the more recent unpublished `title` from the `api/drafts/{id}` response + # The drafts endpoint should always return the latest revision (published or draft) title_field_from_drafts_endpoint: str = response_from_drafts_endpoint.data[ "title" ] - # Get the outdated but published `title` from the `api/pages/{id}` response - title_field_from_pages_endpoint: str = response_from_pages_endpoint.data[ - "title" - ] - - # Check the `title` from the `api/drafts/{id}` - # is the more recent unpublished value - # and not the older published value - assert ( - title_field_from_drafts_endpoint - == unpublished_title - != title_field_from_pages_endpoint - ) + assert title_field_from_drafts_endpoint == unpublished_title diff --git a/tests/system/test_build_cms_site.py b/tests/system/test_build_cms_site.py index 5bb8086a5..45695a9f5 100644 --- a/tests/system/test_build_cms_site.py +++ b/tests/system/test_build_cms_site.py @@ -164,8 +164,8 @@ def test_command_builds_site_with_correct_about_page(self, monkeypatch): Then the response contains the expected data """ # Given - domain = "my-prefix.dev.ukhsa-data-dashboard.gov.uk" - monkeypatch.setenv(name="FRONTEND_URL", value=domain) + frontend_url = "https://my-prefix.dev.ukhsa-data-dashboard.gov.uk" + monkeypatch.setenv(name="FRONTEND_URL", value=frontend_url) call_command("build_cms_site") about_page = CommonPage.objects.get(slug="about") @@ -179,7 +179,7 @@ def test_command_builds_site_with_correct_about_page(self, monkeypatch): response_data = response.data # Check the `html_url` has been constructed correctly - assert response_data["meta"]["html_url"] == f"https://{domain}/about/" + assert response_data["meta"]["html_url"] == f"{frontend_url}/about/" # Compare the response from the endpoint to the template used to build the page about_page_template = open_example_page_response(page_name="about") @@ -325,8 +325,8 @@ def test_command_builds_access_our_data_getting_started(self, monkeypatch): Then the response contains the expected data """ # Given - domain = "my-prefix.dev.ukhsa-data-dashboard.gov.uk" - monkeypatch.setenv(name="FRONTEND_URL", value=domain) + frontend_url = "https://my-prefix.dev.ukhsa-data-dashboard.gov.uk" + monkeypatch.setenv(name="FRONTEND_URL", value=frontend_url) call_command("build_cms_site") access_our_data_getting_started_page = CompositePage.objects.get( @@ -348,7 +348,7 @@ def test_command_builds_access_our_data_getting_started(self, monkeypatch): # Check the `html_url` has been constructed correctly assert ( response_data["meta"]["html_url"] - == f"https://{domain}/access-our-data/getting-started/" + == f"{frontend_url}/access-our-data/getting-started/" ) assert ( diff --git a/tests/unit/caching/private_api/test_decorators.py b/tests/unit/caching/private_api/test_decorators.py index ba9ba6328..9f7f1f2b8 100644 --- a/tests/unit/caching/private_api/test_decorators.py +++ b/tests/unit/caching/private_api/test_decorators.py @@ -18,7 +18,9 @@ class TestRetrieveResponseFromCacheOrCalculate: # Tests for cache hits + no force refreshing - def test_can_return_response_if_already_available(self): + def test_can_return_response_if_already_available_with_request_caching_configs( + self, + ): """ Given a mocked request and a `CacheManagement` object which returns the expected response @@ -28,26 +30,33 @@ def test_can_return_response_if_already_available(self): # Given mocked_request = mock.MagicMock(method="POST") mocked_cache_management = mock.Mock() + mocked_view_function = mock.Mock() # When - retrieved_response = _retrieve_response_from_cache_or_calculate( - mock.Mock(), # view_function - None, # timeout - False, # is_reserved_namespace - True, # is_public - mock.Mock(), - mocked_request, - cache_management=mocked_cache_management, - ) - - # Then - assert ( - retrieved_response - == mocked_cache_management.retrieve_item_from_cache.return_value - ) + for request_caching_disabled in [True, None]: + retrieved_response = _retrieve_response_from_cache_or_calculate( + mocked_view_function, # view_function + None, # timeout + False, # is_reserved_namespace + True, # is_public + request_caching_disabled, # request_caching_disabled + mock.Mock(), + mocked_request, + cache_management=mocked_cache_management, + ) + + # Then + match request_caching_disabled: + case None: + assert ( + retrieved_response + == mocked_cache_management.retrieve_item_from_cache.return_value + ) + case True: + assert mocked_view_function.assert_called_once @mock.patch(f"{MODULE_PATH}._calculate_response_and_save_in_cache") - def test_does_not_recalculate_response_if_already_available( + def test_does_not_recalculate_response_if_already_available_with_request_caching_configs( self, spy_calculate_response_and_save_in_cache: mock.MagicMock ): """ @@ -68,6 +77,7 @@ def test_does_not_recalculate_response_if_already_available( None, # timeout False, # is_reserved_namespace True, # is_public + None, # request_caching_disabled mock.Mock(), mocked_request, cache_management=mocked_cache_management, @@ -105,6 +115,7 @@ def test_recalculates_response_when_not_available( None, # timeout False, # is_reserved_namespace True, # is_public + None, # request_caching_disabled *mocked_args, **mocked_kwargs, ) @@ -157,6 +168,7 @@ def test_item_not_cached_when_caching_v2_enabled_is_set_to_true( None, # timeout False, # is_reserved_namespace True, # is_public + None, # request_caching_disabled mocked_args, mocked_request, cache_management=mock.Mock(), @@ -197,6 +209,7 @@ def test_item_not_cached_when_is_public_is_set_to_false( None, # timeout False, # is_reserved_namespace False, # is_public + None, # request_caching_disabled mocked_args, mocked_request, cache_management=mocked_cache_management, @@ -234,6 +247,7 @@ def test_item_returned_from_cache_when_is_public_is_set_to_true( None, # timeout False, # is_reserved_namespace True, # is_public + None, # request_caching_disabled mocked_args, mocked_request, cache_management=mocked_cache_management, diff --git a/tests/unit/cms/dashboard/test_models.py b/tests/unit/cms/dashboard/test_models.py index 1063b1daf..21fa888ec 100644 --- a/tests/unit/cms/dashboard/test_models.py +++ b/tests/unit/cms/dashboard/test_models.py @@ -1,11 +1,13 @@ -from unittest import mock - import pytest +from unittest import mock +from unittest.mock import patch from django.core.exceptions import ValidationError +from django.test import override_settings from cms.dashboard.models import UKHSAPage from tests.fakes.factories.cms.common_page_factory import FakeCommonPageFactory from tests.fakes.factories.cms.composite_page_factory import FakeCompositePageFactory +from tests.fakes.factories.cms.landing_page_factory import FakeLandingPageFactory from tests.fakes.factories.cms.metrics_documentation_factory import ( FakeMetricsDocumentationParentPageFactory, ) @@ -19,6 +21,88 @@ class TestUKHSAPage: + @pytest.mark.parametrize( + "fake_page", + [ + FakeTopicPageFactory.build_influenza_page_from_template(), + FakeCommonPageFactory.build_blank_page(), + FakeCompositePageFactory.build_page_from_template( + page_name="access_our_data_getting_started" + ), + FakeLandingPageFactory.build_blank_page(), + FakeMetricsDocumentationParentPageFactory.build_page_from_template(), + FakeWhatsNewChildEntryFactory.build_page_from_template(), + FakeWhatsNewParentPageFactory.build_page_from_template(), + ], + ) + def test_is_previewable_is_inherited_from_ukhsa_page(self, fake_page: UKHSAPage): + """ + Given a page model which inherits from UKHSAPage + When checking the is_previewable implementation + Then the method is inherited from UKHSAPage and matches current expected preview configuration + Note: the expected previewability of each page type is currently False + as we have implemented a custom preview view which does not rely on Wagtail's preview mechanism + """ + # Given + child_page = fake_page + current_expected_previewability = { + "FakeTopicPage": False, + "FakeCommonPage": False, + "FakeCompositePage": False, + "FakeLandingPage": False, + "FakeMetricsDocumentationParentPage": False, + "FakeWhatsNewChildEntry": False, + "FakeWhatsNewParentPage": False, + } + page_type = type(child_page).__name__ + + # When + page_is_previewable = child_page.is_previewable() + + # Then + assert page_type in current_expected_previewability + assert page_is_previewable is current_expected_previewability[page_type] + assert type(child_page).is_previewable is UKHSAPage.is_previewable + + @pytest.mark.parametrize( + "fake_page_with_expected_custom_preview", + [ + {FakeTopicPageFactory.build_influenza_page_from_template: True}, + {FakeCommonPageFactory.build_blank_page: True}, + { + lambda: FakeCompositePageFactory.build_page_from_template( + page_name="access_our_data_getting_started" + ): True + }, + {FakeLandingPageFactory.build_blank_page: True}, + {FakeMetricsDocumentationParentPageFactory.build_page_from_template: True}, + {FakeWhatsNewChildEntryFactory.build_page_from_template: True}, + {FakeWhatsNewParentPageFactory.build_page_from_template: True}, + ], + ) + def test_custom_preview_enabled_matches_expected_page_type_value( + self, fake_page_with_expected_custom_preview + ): + """ + Given a page model which inherits from UKHSAPage + When checking the custom_preview_enabled attribute + Then the inherited/customised value matches current expected per-page configuration + Note: the expected value of custom_preview_enabled for each page type + is based on whether the page type is currently configured to use the + custom preview view. + """ + # Given + page_factory, expected_custom_preview_enabled = next( + iter(fake_page_with_expected_custom_preview.items()) + ) + page = page_factory() + + # When + custom_preview_enabled = bool(getattr(page, "custom_preview_enabled", False)) + + # Then + assert custom_preview_enabled is expected_custom_preview_enabled + @pytest.mark.parametrize( "fake_page", [ @@ -34,9 +118,9 @@ class TestUKHSAPage: ) def test_show_in_menus_not_available_in_promote_panel(self, fake_page: UKHSAPage): """ - Given a blank page model which inherits from `UKHSAPage` - When `promote_panels` is called - Then `show_in_menus` is not available + Given a blank page model which inherits from UKHSAPage + When promote_panels is called + Then show_in_menus is not available """ # Given child_page = fake_page @@ -61,23 +145,27 @@ def test_show_in_menus_not_available_in_promote_panel(self, fake_page: UKHSAPage FakeWhatsNewParentPageFactory.build_page_from_template(), ], ) - def test_last_updated_at_references_last_published_at(self, fake_page: UKHSAPage): + @patch( + "cms.dashboard.models.UKHSAPage.last_published_at", + new_callable=mock.PropertyMock, + ) + def test_last_updated_at_references_last_published_at( + self, mock_last_published_at, fake_page: UKHSAPage + ): """ - Given a blank page model which inherits from `UKHSAPage` - which is **not** a `TopicPage` - When the `last_updated_at` property is called - Then the `last_published_at` field is referenced + Given a blank page model which inherits from UKHSAPage which is not a TopicPage + When the last_updated_at property is called + Then the last_published_at field is referenced """ # Given - mocked_last_published_at = mock.Mock() child_page = fake_page - child_page.last_published_at = mocked_last_published_at + mock_last_published_at.return_value = "mocked_timestamp" # When timestamp = child_page.last_updated_at # Then - assert timestamp == mocked_last_published_at + assert timestamp == "mocked_timestamp" @pytest.mark.parametrize( "fake_page", @@ -92,17 +180,16 @@ def test_last_updated_at_references_last_published_at(self, fake_page: UKHSAPage FakeWhatsNewParentPageFactory.build_page_from_template(), ], ) - @mock.patch.object(UKHSAPage, "_raise_error_if_slug_not_unique") + @patch.object(UKHSAPage, "_raise_error_if_slug_not_unique") def test_seo_title_field_is_required_by_clean_method_call( self, spy_raise_error_if_slug_not_unique: mock.MagicMock, fake_page: UKHSAPage, ): """ - Given a page model which inherits from `UKHSAPage` - And no `seo_title` field was set - When the `clean()` method is called - Then a `ValidationError` is raised + Given a page model which inherits from UKHSAPage and no seo_title field was set + When the clean() method is called + Then a ValidationError is raised """ # Given fake_page.seo_title = None @@ -110,3 +197,107 @@ def test_seo_title_field_is_required_by_clean_method_call( # When / Then with pytest.raises(ValidationError): fake_page.clean() + + @patch("wagtail.models.Page.get_url", return_value=None) + def test_get_url_returns_none_when_super_returns_none( + self, spy_page_get_url: mock.MagicMock + ): + """Given no routable URL from Wagtail, return None unchanged.""" + page = FakeCommonPageFactory.build_blank_page() + + url = page.get_url() + + assert url is None + + @override_settings(FRONTEND_URL="") + @patch("cms.dashboard.models.logger.error") + @patch("wagtail.models.Page.get_url", return_value="/weather-health-alerts/") + def test_get_url_returns_resolved_url_when_frontend_base_invalid( + self, + spy_page_get_url: mock.MagicMock, + spy_logger_error: mock.MagicMock, + ): + """Given invalid frontend base URL, fallback to resolved URL.""" + page = FakeCommonPageFactory.build_blank_page() + + url = page.get_url() + + assert url == "/weather-health-alerts/" + spy_logger_error.assert_called_once() + + @override_settings(FRONTEND_URL="http://localhost:3000") + @patch("wagtail.models.Page.get_url", return_value="/weather-health-alerts/") + def test_get_url_rewrites_relative_url_to_frontend_host( + self, + spy_page_get_url: mock.MagicMock, + ): + """Given a relative URL from Wagtail, rewrite to frontend host.""" + page = FakeCommonPageFactory.build_blank_page() + + url = page.get_url() + + assert url == "http://localhost:3000/weather-health-alerts/" + + @override_settings(FRONTEND_URL="http://localhost:3000") + @patch( + "wagtail.models.Page.get_url", + return_value="http://localhost:8000/weather-health-alerts/", + ) + def test_get_url_rewrites_absolute_url_to_frontend_host( + self, + spy_page_get_url: mock.MagicMock, + ): + """Given an absolute CMS URL from Wagtail, rewrite host to frontend.""" + page = FakeCommonPageFactory.build_blank_page() + + url = page.get_url() + + assert url == "http://localhost:3000/weather-health-alerts/" + + @patch("cms.dashboard.models.Page.objects") + def test_raise_error_if_slug_not_unique_raises_validation_error( + self, + spy_page_objects: mock.MagicMock, + ): + """Given a duplicate live slug exists, raise ValidationError.""" + page = FakeCommonPageFactory.build_blank_page() + page.slug = "weather-health-alerts" + page.id = 17 + + spy_page_objects.live.return_value.filter.return_value.exclude.return_value.exists.return_value = ( + True + ) + + with pytest.raises(ValidationError): + page._raise_error_if_slug_not_unique() + + def test_get_url_parts_builds_expected_tuple(self): + """Given site root path and page url_path, build expected URL parts tuple.""" + page = FakeCommonPageFactory.build_blank_page() + page.url_path = "/root/weather-health-alerts/" + + site_root = mock.Mock( + site_id=101, root_url="http://localhost:3000", root_path="/root/" + ) + with patch.object( + page, "_get_relevant_site_root_paths", return_value=(site_root,) + ): + result = page.get_url_parts(request=None) + + assert result == (101, "http://localhost:3000", "/weather-health-alerts/") + + def test_active_announcements_returns_values_list(self): + """Given announcement queryset chain, active_announcements returns list of dicts.""" + page = mock.Mock() + + expected = [{"id": 1, "title": "t", "body": "b", "banner_type": 2}] + values_qs = mock.Mock() + values_qs.values.return_value = expected + order_qs = mock.Mock() + order_qs.order_by.return_value = values_qs + filter_qs = mock.Mock() + filter_qs.filter.return_value = order_qs + + page.announcements = filter_qs + + assert UKHSAPage.active_announcements.fget(page) == expected diff --git a/tests/unit/cms/dashboard/test_preview_views_and_hooks.py b/tests/unit/cms/dashboard/test_preview_views_and_hooks.py new file mode 100644 index 000000000..73823bfb3 --- /dev/null +++ b/tests/unit/cms/dashboard/test_preview_views_and_hooks.py @@ -0,0 +1,800 @@ +import config +import datetime +import unittest.mock as mock +from urllib.parse import parse_qs, urlparse + +import pytest + +from common.virtual_clock import parse_embargo_time_value +from django.core.exceptions import ImproperlyConfigured, PermissionDenied +from django.http import JsonResponse +from django.urls import NoReverseMatch +from django.test import RequestFactory +from wagtail.admin.widgets import Button + +from cms.dashboard import wagtail_hooks +from cms.dashboard.views import ( + InvalidPreviewFrontendUrlError, + LinkBrowseView, + MissingPreviewFrontendHostConfigurationError, + PreviewToFrontendRedirectView, +) + +MODULE_PATH = "cms" + + +class TestPreviewConfigurationErrors: + def test_missing_preview_frontend_host_configuration_error_message(self): + """ + Given missing preview frontend host configuration + When the error is instantiated + Then the configured guidance message is returned + """ + error = MissingPreviewFrontendHostConfigurationError() + + assert str(error) == "FRONTEND_URL must define an absolute http(s) URL" + + def test_invalid_preview_frontend_url_error_message(self): + """ + Given an invalid preview frontend URL + When the error is instantiated + Then the absolute-URL guidance message is returned + """ + error = InvalidPreviewFrontendUrlError() + + assert ( + str(error) == "Preview redirects must use an absolute http(s) frontend URL" + ) + + +class TestEmbargoTime: + @pytest.mark.parametrize( + "embargo_time_value,expected_epoch", + [(1711456200, 1711456200), (1711456200.9, 1711456200)], + ) + def test_accepts_numeric_epoch_values(self, embargo_time_value, expected_epoch): + """ + Given a numeric embargo time value + When parse_embargo_time_value is called + Then the corresponding UTC datetime is returned + """ + actual = parse_embargo_time_value(embargo_time_value) + + assert actual == datetime.datetime.fromtimestamp( + expected_epoch, tz=datetime.UTC + ) + + @pytest.mark.parametrize("embargo_time_value", [True, False, object()]) + def test_returns_none_for_unsupported_types(self, embargo_time_value): + """ + Given an unsupported embargo time value type + When parse_embargo_time_value is called + Then None is returned + """ + assert parse_embargo_time_value(embargo_time_value) is None + + @mock.patch("common.virtual_clock.datetime.datetime") + def test_returns_none_when_timestamp_cannot_be_converted( + self, spy_datetime_class: mock.MagicMock + ): + """ + Given an epoch value that cannot be converted to datetime + When parse_embargo_time_value is called + Then None is returned + """ + spy_datetime_class.fromtimestamp.side_effect = OverflowError + + assert parse_embargo_time_value("1711456200") is None + + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") + def test_success_redirects_passes_through_embargo_time_now( + self, spy_get_object_or_404: mock.MagicMock, settings + ): + """ + Given a preview request with embargo_time set to now + When the preview redirect view is requested + Then the redirect query includes et=now + """ + spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) + request = RequestFactory().get( + "/cms-admin/preview-to-frontend/1/", + data={"embargo_time": "now"}, + ) + request.user = type("U", (), {"is_authenticated": True, "pk": 5})() + + view = PreviewToFrontendRedirectView() + config.FRONTEND_URL = "https://frontend.test" + response = view.get(request=request, pk=1) + location = ( + response.url if hasattr(response, "url") else response.get("Location") + ) + parsed_query = parse_qs(urlparse(location).query) + + assert parsed_query["et"] == ["now"] + + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") + def test_success_redirects_passes_through_embargo_time_epoch( + self, spy_get_object_or_404: mock.MagicMock, settings + ): + """ + Given a preview request with an epoch embargo_time value + When the preview redirect view is requested + Then the redirect query includes the same epoch value in et + """ + spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) + request = RequestFactory().get( + "/cms-admin/preview-to-frontend/1/", + data={"embargo_time": "1711456200"}, + ) + request.user = type("U", (), {"is_authenticated": True, "pk": 5})() + + view = PreviewToFrontendRedirectView() + config.FRONTEND_URL = "https://frontend.test" + response = view.get(request=request, pk=1) + location = ( + response.url if hasattr(response, "url") else response.get("Location") + ) + parsed_query = parse_qs(urlparse(location).query) + + assert parsed_query["et"] == ["1711456200"] + + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") + def test_success_redirects_drops_blank_embargo_time( + self, spy_get_object_or_404: mock.MagicMock, settings + ): + """ + Given a preview request with blank embargo_time + When the preview redirect view is requested + Then the redirect query does not include et + """ + spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) + request = RequestFactory().get( + "/cms-admin/preview-to-frontend/1/", + data={"embargo_time": " "}, + ) + request.user = type("U", (), {"is_authenticated": True, "pk": 5})() + + view = PreviewToFrontendRedirectView() + config.FRONTEND_URL = "https://frontend.test" + response = view.get(request=request, pk=1) + location = ( + response.url if hasattr(response, "url") else response.get("Location") + ) + parsed_query = parse_qs(urlparse(location).query) + + assert "et" not in parsed_query + + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") + def test_success_redirects_drops_unparseable_embargo_time( + self, spy_get_object_or_404: mock.MagicMock, settings + ): + """ + Given a preview request with unparseable embargo_time + When the preview redirect view is requested + Then the redirect query does not include et + """ + spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) + request = RequestFactory().get( + "/cms-admin/preview-to-frontend/1/", + data={"embargo_time": "not-a-time"}, + ) + request.user = type("U", (), {"is_authenticated": True, "pk": 5})() + + view = PreviewToFrontendRedirectView() + config.FRONTEND_URL = "https://frontend.test" + response = view.get(request=request, pk=1) + location = ( + response.url if hasattr(response, "url") else response.get("Location") + ) + parsed_query = parse_qs(urlparse(location).query) + + assert "et" not in parsed_query + + +class TestAddFrontendPreviewActionExceptions: + @mock.patch("cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView") + @mock.patch("cms.dashboard.wagtail_hooks.reverse", side_effect=NoReverseMatch) + def test_no_reverse_match_hides_preview_action(self, mock_reverse, mock_view): + """ + Given reverse raises NoReverseMatch + When add_frontend_preview_action is called + Then no preview action is added + """ + page = mock.MagicMock( + pk=1, + slug="slug", + custom_preview_enabled=True, + has_unpublished_changes=True, + live=False, + ) + menu_items = [] + # Patch build_route_slug to return a known slug + mock_view.build_route_slug.return_value = "slug" + with mock.patch( + "cms.dashboard.wagtail_hooks.settings", + PAGE_PREVIEWS_ENABLED=True, + ): + wagtail_hooks.add_frontend_preview_action(menu_items, None, {"page": page}) + assert menu_items == [] + + +class DummyPerms: + def __init__(self, can_edit): + self._can_edit = can_edit + + def can_edit(self): + return self._can_edit + + +class FakePage: + custom_preview_enabled = False + + def __init__( + self, pk=1, slug="foo", can_edit=True, has_unpublished_changes=True, live=False + ): + self.pk = pk + self.slug = slug + self._can_edit = can_edit + + self.has_unpublished_changes = has_unpublished_changes + self.live = live + self.specific_class = self.__class__ + + @property + def specific(self): + return self + + def permissions_for_user(self, user): + return DummyPerms(self._can_edit) + + +class TestFrontendPreviewButton: + def test_non_edit_view_returns_empty(self): + """ + Given a page and a non-edit view name + When frontend_preview_button is called + Then no preview buttons are returned + """ + page = FakePage() + buttons = wagtail_hooks.frontend_preview_button( + page=page, user=None, next_url=None, view_name="index" + ) + assert buttons == [] + + @mock.patch(f"{MODULE_PATH}.dashboard.wagtail_hooks.reverse") + def test_no_reverse_match_hides_preview_button( + self, spy_reverse: mock.MagicMock, settings + ): + """ + Given reverse raises NoReverseMatch and preview base URL is configured + When frontend_preview_button is called for an editable preview-enabled page + Then no Preview button is returned + """ + spy_reverse.side_effect = NoReverseMatch("no reverse") + page = mock.MagicMock( + pk=123, + slug="bar", + custom_preview_enabled=True, + has_unpublished_changes=True, + live=False, + ) + buttons = wagtail_hooks.frontend_preview_button( + page=page, user=None, next_url=None, view_name="edit" + ) + assert buttons == [] + + def test_live_non_draft_page_label_is_view_live(self): + """ + Given a live page with no unpublished changes + When frontend_preview_button is called + Then the button label is View Live + """ + page = mock.MagicMock( + pk=123, + slug="bar", + custom_preview_enabled=True, + has_unpublished_changes=False, + live=True, + ) + buttons = wagtail_hooks.frontend_preview_button( + page=page, user=None, next_url=None, view_name="edit" + ) + assert isinstance(buttons, list) and buttons + + assert buttons[0].label == "View Live" + + def test_page_with_no_draft_and_no_live_has_no_button(self): + """ + Given a preview-enabled page that is neither draft nor live + When frontend_preview_button is called + Then no buttons are returned + """ + page = mock.MagicMock( + pk=123, + slug="bar", + custom_preview_enabled=True, + has_unpublished_changes=False, + live=False, + ) + buttons = wagtail_hooks.frontend_preview_button( + page=page, user=None, next_url=None, view_name="edit" + ) + + assert buttons == [] + + def test_non_enabled_page_returns_empty(self): + """ + Given a page type without custom preview support + When frontend_preview_button is called + Then no buttons are returned + """ + page = FakePage(pk=123, slug="bar") + buttons = wagtail_hooks.frontend_preview_button( + page=page, user=None, next_url=None, view_name="edit" + ) + assert buttons == [] + + +class TestPreviewToFrontendRedirectView: + def test_build_route_slug_uses_nested_page_path_when_available(self): + """ + Given get_url_parts returns a nested page path + When build_route_slug is called + Then the nested path is converted into the route slug + """ + page = mock.MagicMock(slug="covid-19") + page.get_url_parts.return_value = ( + 1, + "https://frontend.test", + "/respiratory-viruses/covid-19/", + ) + route_slug = PreviewToFrontendRedirectView.build_route_slug(page=page) + assert route_slug == "respiratory-viruses/covid-19" + + @pytest.mark.parametrize("page_path", [None, "", "/", "///"]) + def test_build_route_slug_falls_back_to_slug_when_path_is_empty(self, page_path): + """ + Given get_url_parts returns an empty-like path + When build_route_slug is called + Then the page slug is used as the fallback route slug + """ + page = mock.MagicMock(slug="fallback-slug") + page.get_url_parts.return_value = (1, "https://frontend.test", page_path) + + route_slug = PreviewToFrontendRedirectView.build_route_slug(page=page) + + assert route_slug == "fallback-slug" + + @pytest.mark.parametrize("exception", [AttributeError(), TypeError(), ValueError()]) + def test_build_route_slug_falls_back_to_slug_on_expected_exceptions( + self, exception + ): + """ + Given get_url_parts raises an expected exception + When build_route_slug is called + Then the page slug is used as the fallback route slug + """ + page = mock.MagicMock(slug="fallback-slug") + page.get_url_parts.side_effect = exception + + route_slug = PreviewToFrontendRedirectView.build_route_slug(page=page) + + assert route_slug == "fallback-slug" + + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") + def test_permission_denied(self, spy_get_object_or_404: mock.MagicMock): + """ + Given a user who cannot edit the page + When the preview redirect view is requested + Then PermissionDenied is raised + """ + spy_get_object_or_404.return_value = FakePage(pk=1, slug="s", can_edit=False) + + request = RequestFactory().get("/cms-admin/preview-to-frontend/1/") + request.user = type("U", (), {"is_authenticated": True, "pk": 5})() + view = PreviewToFrontendRedirectView() + with pytest.raises(PermissionDenied): + view.get(request=request, pk=1) + + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") + def test_success_redirects(self, spy_get_object_or_404: mock.MagicMock, settings): + """ + Given an editable page and a frontend preview base URL + When the preview redirect view is requested + Then the response redirects to the frontend preview URL with a token + """ + spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) + request = RequestFactory().get("/cms-admin/preview-to-frontend/1/") + + request.user = type("U", (), {"is_authenticated": True, "pk": 5})() + view = PreviewToFrontendRedirectView() + config.FRONTEND_URL = "https://frontend.test" + response = view.get(request=request, pk=1) + location = ( + response.url if hasattr(response, "url") else response.get("Location") + ) + assert location.startswith("https://frontend.test/preview/cover?t=") + + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") + def test_success_redirects_with_nested_route_slug( + self, spy_get_object_or_404: mock.MagicMock, settings + ): + """ + Given an editable page with a nested frontend path + When the preview redirect view is requested + Then the response uses the nested route slug in the redirect URL + """ + page = FakePage(pk=1, slug="covid-19", can_edit=True) + page.get_url_parts = lambda request=None: ( + 1, + "https://frontend.test", + "/respiratory-viruses/covid-19/", + ) + spy_get_object_or_404.return_value = page + request = RequestFactory().get("/cms-admin/preview-to-frontend/1/") + + request.user = type("U", (), {"is_authenticated": True, "pk": 5})() + view = PreviewToFrontendRedirectView() + config.FRONTEND_URL = "https://frontend.test" + response = view.get(request=request, pk=1) + location = ( + response.url if hasattr(response, "url") else response.get("Location") + ) + assert location.startswith( + "https://frontend.test/preview/respiratory-viruses/covid-19?t=" + ) + + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") + def test_success_redirects_appends_page_id_to_query( + self, spy_get_object_or_404: mock.MagicMock, settings + ): + """ + Given an editable page and a preview base URL + When the preview redirect view is requested + Then the redirect query includes slug, token, and page_id + """ + spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) + request = RequestFactory().get("/cms-admin/preview-to-frontend/1/") + + request.user = type("U", (), {"is_authenticated": True, "pk": 5})() + view = PreviewToFrontendRedirectView() + config.FRONTEND_URL = "https://frontend.test" + response = view.get(request=request, pk=1) + location = ( + response.url if hasattr(response, "url") else response.get("Location") + ) + parsed_query = parse_qs(urlparse(location).query) + assert "t" in parsed_query + assert parsed_query["page_id"] == ["1"] + + def test_validate_frontend_redirect_url_allows_configured_frontend_host( + self, settings + ): + """ + Given a preview URL whose host matches the configured frontend allow-list + When the redirect URL is validated + Then the URL is accepted unchanged + """ + config.FRONTEND_URL = "https://frontend.test" + + validated_url = PreviewToFrontendRedirectView.validate_frontend_redirect_url( + frontend_url="https://frontend.test/preview?slug=cover&t=signed-token" + ) + + assert ( + validated_url == "https://frontend.test/preview?slug=cover&t=signed-token" + ) + + def test_validate_frontend_redirect_url_rejects_unconfigured_host(self, settings): + """ + Given a preview URL whose host is outside the configured frontend allow-list + When the redirect URL is validated + Then an ImproperlyConfigured error is raised + """ + config.FRONTEND_URL = "https://frontend.test" + + with pytest.raises(ImproperlyConfigured): + PreviewToFrontendRedirectView.validate_frontend_redirect_url( + frontend_url="https://malicious.test/preview?slug=cover&t=signed-token" + ) + + def test_validate_frontend_redirect_url_rejects_non_absolute_url(self, settings): + """ + Given a relative preview redirect URL + When the redirect URL is validated + Then an ImproperlyConfigured error is raised + """ + config.FRONTEND_URL = "https://frontend.test" + + with pytest.raises(ImproperlyConfigured): + PreviewToFrontendRedirectView.validate_frontend_redirect_url( + frontend_url="/preview?slug=cover&t=signed-token" + ) + + def test_validate_frontend_redirect_url_rejects_when_allow_list_is_empty( + self, settings + ): + """ + Given frontend settings that do not define absolute http(s) hosts + When the redirect URL is validated + Then an ImproperlyConfigured error is raised + """ + config.FRONTEND_URL = "" + with pytest.raises(ImproperlyConfigured): + PreviewToFrontendRedirectView.validate_frontend_redirect_url( + frontend_url="https://frontend.test/preview?slug=cover&t=signed-token" + ) + + def test_build_frontend_route_url_includes_route_slug_and_validates_host( + self, settings + ): + """ + Given a valid frontend base URL and non-empty route slug + When build_frontend_route_url is called + Then it returns the validated route URL with /nocache appended + """ + config.FRONTEND_URL = "https://frontend.test" + + route_url = PreviewToFrontendRedirectView.build_frontend_route_url( + base_url="https://frontend.test", + route_slug="respiratory-viruses/covid-19", + ) + + assert route_url == "https://frontend.test/nocache/respiratory-viruses/covid-19" + + def test_build_frontend_route_url_returns_base_root_when_slug_is_empty( + self, settings + ): + """ + Given a valid frontend base URL and empty route slug + When build_frontend_route_url is called + Then it returns the validated frontend root URL with /nocache appended + """ + config.FRONTEND_URL = "https://frontend.test" + + route_url = PreviewToFrontendRedirectView.build_frontend_route_url( + base_url="https://frontend.test", + route_slug="", + ) + + assert route_url == "https://frontend.test/nocache" + + def test_build_frontend_preview_base_url_appends_preview_path(self, settings): + """ + Given a valid frontend base URL + When build_frontend_preview_base_url is called + Then it returns the validated frontend preview endpoint URL + """ + config.FRONTEND_URL = "https://frontend.test" + + preview_url = PreviewToFrontendRedirectView.build_frontend_preview_base_url( + base_url="https://frontend.test" + ) + + assert preview_url == "https://frontend.test/preview" + + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") + def test_redirect_raises_when_base_url_is_not_absolute( + self, spy_get_object_or_404: mock.MagicMock, settings + ): + """ + Given a preview base URL that is not an absolute http(s) URL + When the preview redirect view is requested + Then an ImproperlyConfigured error is raised instead of redirecting + """ + spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) + request = RequestFactory().get("/cms-admin/preview-to-frontend/1/") + request.user = type("U", (), {"is_authenticated": True, "pk": 5})() + + view = PreviewToFrontendRedirectView() + config.FRONTEND_URL = "" + + with pytest.raises(ImproperlyConfigured): + view.get(request=request, pk=1) + + +class TestAddFrontendPreviewAction: + def test_missing_page_or_pk_returns_none(self): + """ + Given missing page context or an unsaved page without pk + When add_frontend_preview_action is called + Then menu items remain unchanged + """ + request = None + menu_items = [] + + wagtail_hooks.add_frontend_preview_action( + menu_items=menu_items, request=request, context={} + ) + menu_items_for_unsaved_page = [] + wagtail_hooks.add_frontend_preview_action( + menu_items=menu_items_for_unsaved_page, + request=request, + context={"page": FakePage(pk=None)}, + ) + assert menu_items == [] + assert menu_items_for_unsaved_page == [] + + def test_non_enabled_page_is_noop(self): + """ + Given a page type that is not preview-enabled + When `add_frontend_preview_action()` is called + Then the menu remains unchanged + """ + # Given + menu_items = [] + # request.user.pk == 5 + request = mock.MagicMock() + request.user = mock.MagicMock(pk=5) + context = {"page": FakePage(pk=1, slug="test")} + + # When + wagtail_hooks.add_frontend_preview_action( + menu_items=menu_items, + request=request, + context=context, + ) + + +class TestLinkBrowseView: + def test_intercept_request_switches_off_email_and_phone_links(self): + """ + Given chooser request query params + When LinkBrowseView intercepts the request + Then email and phone link flags are forced off + """ + request = RequestFactory().get( + "/cms-admin/choose-link/", + data={"allow_email_link": "true", "allow_phone_link": "true"}, + ) + + intercepted_request = ( + LinkBrowseView._intercept_request_and_switch_off_extra_links( + request=request + ) + ) + + assert intercepted_request.GET["allow_email_link"] is False + assert intercepted_request.GET["allow_phone_link"] is False + + @mock.patch(f"{MODULE_PATH}.dashboard.views.BrowseView.get") + @mock.patch.object( + LinkBrowseView, + "_intercept_request_and_switch_off_extra_links", + ) + def test_get_intercepts_then_delegates_to_super( + self, + spy_intercept_request_and_switch_off_extra_links: mock.MagicMock, + spy_browse_view_get: mock.MagicMock, + ): + """ + Given a chooser browse request + When LinkBrowseView.get is called + Then request interception runs before delegating to the parent view + """ + request = RequestFactory().get("/cms-admin/choose-link/") + intercepted_request = RequestFactory().get("/cms-admin/choose-link/") + spy_intercept_request_and_switch_off_extra_links.return_value = ( + intercepted_request + ) + expected_response = JsonResponse({"ok": True}) + spy_browse_view_get.return_value = expected_response + + response = LinkBrowseView().get(request=request, parent_page_id=17) + + spy_intercept_request_and_switch_off_extra_links.assert_called_once_with( + request=request + ) + spy_browse_view_get.assert_called_once_with( + request=intercepted_request, parent_page_id=17 + ) + assert response is expected_response + + @mock.patch(f"{MODULE_PATH}.dashboard.wagtail_hooks.logger.debug") + @mock.patch( + f"{MODULE_PATH}.dashboard.wagtail_hooks._get_preview_button_label", + side_effect=RuntimeError("boom"), + ) + def test_internal_errors_are_logged_and_ignored( + self, + spy_get_preview_button_label: mock.MagicMock, + spy_logger_exception: mock.MagicMock, + ): + """ + Given add_frontend_preview_action encounters an unexpected internal error + When the action is being constructed + Then the error is logged and the menu remains unchanged + """ + page = mock.MagicMock(pk=1, slug="preview-target") + menu_items = [] + + wagtail_hooks.add_frontend_preview_action( + menu_items=menu_items, + request=None, + context={"page": page}, + ) + + assert menu_items == [] + spy_get_preview_button_label.assert_called_once_with(page=page) + spy_logger_exception.assert_called_once_with( + "Failed to construct frontend preview action; editor UI will continue" + ) + + @pytest.mark.parametrize( + "custom_preview_enabled,has_unpublished_changes,live,should_have_action,expected_label", + [ + (False, True, False, False, None), + (True, True, False, True, "Preview"), + (True, False, True, True, "View Live"), + (True, False, False, False, None), + ], + ) + @mock.patch(f"{MODULE_PATH}.dashboard.wagtail_hooks.reverse") + def test_only_enabled_page_types_get_preview_actions( + self, + spy_reverse: mock.MagicMock, + custom_preview_enabled, + has_unpublished_changes, + live, + should_have_action, + expected_label, + ): + """ + Given a page type with configured `custom_preview_enabled` + When preview actions are constructed + Then only enabled types receive preview button and menu actions + + Patches: + `spy_reverse`: To provide a deterministic preview admin URL. + """ + + # Given + page = mock.MagicMock( + pk=42, + slug="preview-target", + custom_preview_enabled=custom_preview_enabled, + has_unpublished_changes=has_unpublished_changes, + live=live, + ) + + spy_reverse.side_effect = ( + lambda name, args=None: f"/admin/preview-to-frontend/{args[0]}/" + ) + + # When + buttons = wagtail_hooks.frontend_preview_button( + page=page, + user=None, + next_url=None, + view_name="edit", + ) + menu_items = [] + wagtail_hooks.add_frontend_preview_action( + menu_items=menu_items, + request=None, + context={"page": page}, + ) + + # Then + assert (len(buttons) > 0) is should_have_action + assert (len(menu_items) > 0) is should_have_action + if should_have_action: + assert buttons[0].label == expected_label + assert menu_items[0].label == expected_label + + +class TestCustomPreviewEnabled: + def test_defaults_to_false_when_attribute_missing(self): + """ + Given a page class without `custom_preview_enabled` + When the page attribute is checked + Then the page is not preview-enabled + """ + # Given + page = FakePage() + + # When + is_preview_enabled = bool(getattr(page, "custom_preview_enabled", False)) + + # Then + assert not is_preview_enabled diff --git a/tests/unit/cms/dashboard/test_serializers.py b/tests/unit/cms/dashboard/test_serializers.py deleted file mode 100644 index 7d5c3731f..000000000 --- a/tests/unit/cms/dashboard/test_serializers.py +++ /dev/null @@ -1,50 +0,0 @@ -from unittest import mock - -import pytest -from rest_framework.serializers import ValidationError -from wagtail.api.v2.views import PageSerializer -from wagtail.models import Page - -from cms.dashboard.serializers import CMSDraftPagesSerializer - - -class TestCMSDraftPagesSerializer: - def test_to_representation_raises_error_if_page_has_no_unpublished_changes(self): - """ - Given a mocked `Page` which has no unpublished changes - When `to_representation()` is called - from an instance of the `CMSDraftPagesSerializer` - Then a `ValidationError` is raised - """ - # Given - mocked_page = mock.Mock(spec_set=Page, has_unpublished_changes=False) - serializer = CMSDraftPagesSerializer(instance=mocked_page) - - # When / Then - with pytest.raises(ValidationError): - serializer.to_representation(instance=mocked_page) - - @mock.patch.object(PageSerializer, "to_representation") - def test_to_representation_calls_super_when_page_has_unpublished_changes( - self, spy_to_representation: mock.MagicMock - ): - """ - Given a mocked `Page` which has unpublished changes - When `to_representation()` is called - from an instance of the `CMSDraftPagesSerializer` - Then the `super().to_representation()` method is called - - Patches: - `spy_to_representation`: For the main assertion - - """ - # Given - mocked_page = mock.Mock(spec_set=Page, has_unpublished_changes=True) - serializer = CMSDraftPagesSerializer(instance=mocked_page) - - # When - representation = serializer.to_representation(instance=mocked_page) - - # Then - assert representation == spy_to_representation.return_value - spy_to_representation.assert_called_once_with(instance=mocked_page) diff --git a/tests/unit/cms/dashboard/test_viewsets.py b/tests/unit/cms/dashboard/test_viewsets.py index 0620091aa..11a8a07ca 100644 --- a/tests/unit/cms/dashboard/test_viewsets.py +++ b/tests/unit/cms/dashboard/test_viewsets.py @@ -1,44 +1,417 @@ -from cms.dashboard.serializers import CMSDraftPagesSerializer, ListablePageSerializer -from cms.dashboard.viewsets import CMSDraftPagesViewSet, CMSPagesAPIViewSet +import pytest +from unittest.mock import patch, MagicMock +from rest_framework.request import Request +from django.core.signing import BadSignature as DjangoBadSignature + +from cms.dashboard.serializers import ListablePageSerializer +from cms.dashboard.viewsets import ( + BaseCMSPagesAPIViewSet, + CMSDraftPagesViewSet, + CMSPagesAPIViewSet, + PagesAPIViewSet, +) class TestCMSDraftPagesViewSet: - def test_base_serializer_class_is_set_with_correct_serializer(self): + @staticmethod + def _build_request(*, auth_header: str | None = "Bearer faketoken") -> MagicMock: + request = MagicMock(spec=Request) + request.headers = {"x-cms-auth": auth_header} if auth_header is not None else {} + return request + + @staticmethod + def _mock_queryset_first(viewset: CMSDraftPagesViewSet, instance) -> None: + viewset.get_queryset = MagicMock( + return_value=MagicMock( + filter=MagicMock( + return_value=MagicMock(first=MagicMock(return_value=instance)) + ) + ) + ) + + @patch( + "cms.dashboard.viewsets.get_cms_auth_payload", + return_value={"page_id": 1, "embargo_time": 1711456200}, + ) + @patch( + "cms.dashboard.viewsets.validate_preview_hmac_token", + return_value=True, + ) + def test_detail_view_includes_embargo_time_with_latest_revision( + self, mock_validate, mock_get_payload, settings + ): + settings.PAGE_PREVIEWS_ENABLED = True + viewset = CMSDraftPagesViewSet() + request = self._build_request() + fake_instance = MagicMock() + fake_instance.get_latest_revision.return_value = MagicMock( + as_object=MagicMock(return_value="draft_page") + ) + self._mock_queryset_first(viewset, fake_instance) + viewset.get_serializer = MagicMock(return_value=MagicMock(data={"foo": "bar"})) + + response = viewset.detail_view(request=request, pk=1) + + assert response.status_code == 200 + assert response.data == {"foo": "bar", "embargo_time": 1711456200} + + @patch( + "cms.dashboard.viewsets.validate_preview_hmac_token", + return_value={"page_id": 1, "embargo_time": None}, + ) + def test_detail_view_includes_null_embargo_time_with_published_version( + self, mock_validate, settings + ): + settings.PAGE_PREVIEWS_ENABLED = True + viewset = CMSDraftPagesViewSet() + request = self._build_request() + fake_instance = MagicMock() + fake_instance.get_latest_revision.return_value = None + self._mock_queryset_first(viewset, fake_instance) + viewset.get_serializer = MagicMock( + return_value=MagicMock(data={"foo": "published"}) + ) + + response = viewset.detail_view(request=request, pk=1) + + assert response.status_code == 200 + assert response.data == {"foo": "published", "embargo_time": None} + + @patch("cms.dashboard.viewsets.validate_preview_hmac_token", return_value=False) + def test_detail_view_returns_401_if_page_id_mismatch(self, mock_validate): """ - Given an instance of the `CMSDraftPagesViewSet` - When the `base_serializer_class` attribute is called - Then the `CMSDraftPagesSerializer` class is returned + Given a draft page with a mismatched page_id in the token + When detail_view is called + Then a 401 response is returned """ # Given - draft_pages_viewset = CMSDraftPagesViewSet() + viewset = CMSDraftPagesViewSet() + request = self._build_request() + fake_instance = MagicMock() + fake_instance.pk = 1 + fake_instance.get_latest_revision.return_value = None + self._mock_queryset_first(viewset, fake_instance) + # When + response = viewset.detail_view(request=request, pk=1) + # Then + assert response.status_code == 401 + @patch("cms.dashboard.viewsets.get_cms_auth_payload", return_value={}) + @patch("cms.dashboard.viewsets.validate_preview_hmac_token", return_value=True) + def test_detail_view_returns_200_with_latest_revision( + self, mock_validate, mock_get_payload + ): + """ + Given a draft page with a valid token and a latest revision + When detail_view is called + Then a 200 response is returned with the latest revision data + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request() + fake_instance = MagicMock() + fake_instance.get_latest_revision.return_value = MagicMock( + as_object=MagicMock(return_value="draft_page") + ) + self._mock_queryset_first(viewset, fake_instance) + viewset.get_serializer = MagicMock(return_value=MagicMock(data={"foo": "bar"})) # When - base_serializer_class = draft_pages_viewset.base_serializer_class + response = viewset.detail_view(request=request, pk=1) + # Then + assert response.status_code == 200 + assert response.data == {"foo": "bar", "embargo_time": None} + + @patch("cms.dashboard.viewsets.get_cms_auth_payload", return_value={}) + @patch("cms.dashboard.viewsets.validate_preview_hmac_token", return_value=True) + def test_detail_view_returns_200_with_published_version( + self, mock_validate, mock_get_payload + ): + """ + Given a draft page with a valid token and no latest revision + When detail_view is called + Then a 200 response is returned with the published version data + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request() + fake_instance = MagicMock() + fake_instance.get_latest_revision.return_value = None + self._mock_queryset_first(viewset, fake_instance) + viewset.get_serializer = MagicMock( + return_value=MagicMock(data={"foo": "published"}) + ) + # When + response = viewset.detail_view(request=request, pk=1) + # Then + assert response.status_code == 200 + assert response.data == {"foo": "published", "embargo_time": None} + + @patch("cms.dashboard.viewsets.get_cms_auth_payload", return_value={}) + @patch("cms.dashboard.viewsets.validate_preview_hmac_token", return_value=True) + def test_detail_view_returns_404_if_page_not_found( + self, mock_validate, mock_get_payload + ): + """ + Given a valid token but the page is not found + When detail_view is called + Then a 404 response is returned + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request() + viewset.get_queryset = MagicMock( + return_value=MagicMock( + filter=MagicMock( + return_value=MagicMock(first=MagicMock(return_value=None)) + ) + ) + ) + # When + response = viewset.detail_view(request=request, pk=1) + # Then + assert response.status_code == 404 + + @patch("cms.dashboard.viewsets.validate_preview_hmac_token", return_value=False) + def test_detail_view_returns_401_if_exp_is_none(self, mock_validate): + """ + Given a token with no exp value + When detail_view is called + Then a 401 response is returned + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request() + # When + response = viewset.detail_view(request=request, pk=1) + # Then + assert response.status_code == 401 + + @patch("cms.dashboard.viewsets.validate_preview_hmac_token", return_value=False) + def test_detail_view_returns_401_if_expired(self, mock_validate): + """ + Given a token with an expired exp value + When detail_view is called + Then a 401 response is returned + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request() + # When + response = viewset.detail_view(request=request, pk=1) + # Then + assert response.status_code == 401 + + def test_detail_view_returns_401_if_no_auth(self): + """ + Given no authentication header + When detail_view is called + Then a 401 response is returned + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request(auth_header=None) + # When + response = viewset.detail_view(request=request, pk=1) + # Then + assert response.status_code == 401 + def test_detail_view_returns_401_if_auth_not_bearer(self): + """ + Given an authentication header that is not Bearer + When detail_view is called + Then a 401 response is returned + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request(auth_header="notbearer faketoken") + # When + response = viewset.detail_view(request=request, pk=1) # Then - assert base_serializer_class is CMSDraftPagesSerializer + assert response.status_code == 401 - def test_get_urlpatterns(self): + @patch("cms.dashboard.viewsets.validate_preview_hmac_token", return_value=False) + def test_detail_view_returns_401_for_expired_token(self, mock_validate): """ - Given an instance of the `CMSDraftPagesViewSet` - When `get_urlpatterns()` is called - Then only 1 detail-type url pattern is returned + Given a token with an expired exp value (in the past) + When detail_view is called + Then a 401 response is returned + """ + # Given + request = self._build_request() + viewset = CMSDraftPagesViewSet() + # When + response = viewset.detail_view(request=request, pk=1) + # Then + assert response.status_code == 401 + + @patch("cms.dashboard.viewsets.settings") + def test_detail_view_previews_disabled_returns_403(self, mock_settings): + """ + Given PAGE_PREVIEWS_ENABLED is False + When detail_view is called + Then a 403 response is returned with the appropriate message + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request() + mock_settings.PAGE_PREVIEWS_ENABLED = False + # When + response = viewset.detail_view(request=request, pk=1) + # Then + assert response.status_code == 403 + assert "Page previews are disabled" in response.data["detail"] + + @patch( + "cms.dashboard.viewsets.PagesAPIViewSet.get_object", return_value="parent_obj" + ) + def test_get_object_calls_super_when_forced_page_none(self, mock_parent): + """ + Given _forced_detail_page is None + When get_object is called + Then the parent get_object is called and its result is returned + """ + # Given + viewset = CMSDraftPagesViewSet() + viewset._forced_detail_page = None + # When + result = viewset.get_object() + # Then + assert result == "parent_obj" + mock_parent.assert_called_once() + + # (Duplicate imports removed) + + @patch("cms.dashboard.viewsets.get_cms_auth_payload", return_value={}) + @patch("cms.dashboard.viewsets.validate_preview_hmac_token", return_value=True) + def test_happy_path_returns_200(self, mock_validate, mock_get_payload): + """ + Given a valid token and a resolvable draft page + When detail_view is called + Then a 200 response with serialized page data is returned + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request() + fake_instance = MagicMock() + fake_instance.get_latest_revision.return_value = MagicMock( + as_object=MagicMock(return_value=fake_instance) + ) + fake_instance.title = "Test page" + viewset.get_queryset = MagicMock( + return_value=MagicMock( + filter=MagicMock( + return_value=MagicMock(first=MagicMock(return_value=fake_instance)) + ) + ) + ) + fake_serializer = MagicMock() + fake_serializer.data = {"title": fake_instance.title} + viewset.get_serializer = MagicMock(return_value=fake_serializer) + + # When + response = viewset.detail_view(request=request, pk=1) + + # Then + assert response.status_code == 200 + assert response.data["title"] == "Test page" + + def test_missing_auth_returns_401(self): + """ + Given a request with no auth header + When detail_view is called + Then a 401 response is returned + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request(auth_header=None) + + # When + response = viewset.detail_view(request=request, pk=1) + + # Then + assert response.status_code == 401 + + @patch("cms.dashboard.viewsets.validate_preview_hmac_token", return_value=False) + def test_invalid_token_returns_401(self, mock_validate): + """ + Given a request with an invalid bearer token + When detail_view is called + Then a 401 response is returned + """ + # Given + viewset = CMSDraftPagesViewSet() + request = self._build_request(auth_header="Bearer fake") + + # When + response = viewset.detail_view(request=request, pk=1) + + # Then + assert response.status_code == 401 + + @patch("cms.dashboard.viewsets.get_cms_auth_payload", return_value={}) + @patch("cms.dashboard.viewsets.validate_preview_hmac_token", return_value=True) + @patch.object(CMSDraftPagesViewSet, "get_queryset") + def test_resolve_returns_none_404( + self, mock_get_queryset, mock_validate, mock_get_payload, settings + ): + """ + Given a valid token but no matching page in the queryset + When detail_view is called + Then a 404 response is returned + """ + # Given + settings.PAGE_PREVIEWS_ENABLED = True + mock_get_queryset.return_value.filter.return_value.first.return_value = None + viewset = CMSDraftPagesViewSet() + request = self._build_request() + + # When + response = viewset.detail_view(request=request, pk=1) + + # Then + assert response.status_code == 404 + + @patch("cms.dashboard.viewsets.PagesAPIViewSet.listing_view") + def test_listing_view_calls_super(self, mock_super_listing): + """ + Given a CMS pages viewset and request + When listing_view is invoked through the wrapped method + Then the parent listing_view receives the same request and its response is returned + """ + # Given + viewset = CMSPagesAPIViewSet() + request = MagicMock(spec=Request) + expected_response = MagicMock() + mock_super_listing.return_value = expected_response + + # When + response = CMSPagesAPIViewSet.listing_view.__wrapped__(viewset, request=request) + + # Then + _, called_kwargs = mock_super_listing.call_args + assert called_kwargs["request"] is request + assert response == expected_response + + def test_base_serializer_class_is_set_with_correct_serializer(self): + """ + Given a CMS draft pages viewset instance + When base_serializer_class is accessed + Then ListablePageSerializer is returned """ # Given draft_pages_viewset = CMSDraftPagesViewSet() # When - urlpatterns = draft_pages_viewset.get_urlpatterns() + base_serializer_class = draft_pages_viewset.base_serializer_class # Then - assert len(urlpatterns) == 1 - assert urlpatterns[0].name == "detail" + assert base_serializer_class is ListablePageSerializer def test_permission_classes_has_no_api_key_constraint(self): """ - Given an instance of the `CMSDraftPagesViewSet` - When `permission_classes` is called - Then an empty list is returned + Given a CMS draft pages viewset instance + When permission_classes is accessed + Then no API key constraint is present """ # Given draft_pages_viewset = CMSDraftPagesViewSet() @@ -49,6 +422,26 @@ def test_permission_classes_has_no_api_key_constraint(self): # Then assert permission_classes == [] + @patch( + "cms.dashboard.viewsets.PagesAPIViewSet.get_queryset", + return_value=MagicMock(specific=MagicMock(return_value=[])), + ) + def test_cms_pages_api_viewset_get_queryset_calls_super(self, mock_super): + """ + Given the parent get_queryset returns a queryset supporting specific() + When get_queryset is called on CMSPagesAPIViewSet + Then the parent method is called and the specific queryset is returned + """ + # Given + viewset = CMSPagesAPIViewSet() + + # When + result = viewset.get_queryset() + + # Then + mock_super.assert_called_once() + assert result == [] + class TestCMSPagesAPIViewSet: def test_base_serializer_class_is_set_with_correct_serializer(self): @@ -80,3 +473,47 @@ def test_listing_default_fields_is_set_with_show_in_menus(self): # Then assert "show_in_menus" in listing_default_fields + + @patch("cms.dashboard.viewsets.PagesAPIViewSet.detail_view") + def test_base_detail_view_calls_super_with_pk(self, mock_super_detail_view): + """ + Given a base CMS pages viewset and request + When detail_view is called with a pk + Then the parent detail_view is called with the same request and pk + """ + # Given + viewset = BaseCMSPagesAPIViewSet() + request = MagicMock(spec=Request) + expected_response = MagicMock() + mock_super_detail_view.return_value = expected_response + + # When + response = viewset.detail_view(request=request, pk=123) + + # Then + _, called_kwargs = mock_super_detail_view.call_args + assert called_kwargs["request"] is request + assert called_kwargs["pk"] == 123 + assert response == expected_response + + @patch("cms.dashboard.viewsets.BaseCMSPagesAPIViewSet.detail_view") + def test_cms_pages_detail_view_calls_base(self, mock_base_detail_view): + """ + Given a CMS pages viewset and request + When detail_view is invoked through the wrapped method + Then the base detail_view receives request and pk, and its response is returned + """ + # Given + viewset = CMSPagesAPIViewSet() + request = MagicMock(spec=Request) + expected_response = MagicMock() + mock_base_detail_view.return_value = expected_response + + # When + response = CMSPagesAPIViewSet.detail_view.__wrapped__( + viewset, request=request, pk=123 + ) + + # Then + mock_base_detail_view.assert_called_once_with(request, 123) + assert response == expected_response diff --git a/tests/unit/cms/dashboard/test_virtual_clock.py b/tests/unit/cms/dashboard/test_virtual_clock.py new file mode 100644 index 000000000..e990f6393 --- /dev/null +++ b/tests/unit/cms/dashboard/test_virtual_clock.py @@ -0,0 +1,179 @@ +import datetime +from unittest import mock + +import pytest + +from common import virtual_clock + + +@pytest.fixture(autouse=True) +def clear_virtual_clock_context(): + virtual_clock.clear_embargo_time() + yield + virtual_clock.clear_embargo_time() + + +class TestParseEmbargoTimeValue: + @mock.patch("common.virtual_clock.timezone.now") + def test_returns_now_for_now_string(self, spy_now: mock.MagicMock): + """ + Given an embargo time value of now + When parse_embargo_time_value is called + Then the current timezone-aware datetime is returned + """ + expected = datetime.datetime(2026, 3, 31, 12, 0, tzinfo=datetime.UTC) + spy_now.return_value = expected + + actual = virtual_clock.parse_embargo_time_value(" now ") + + assert actual == expected + + @pytest.mark.parametrize( + "embargo_time_value,expected_epoch", + [ + (1711456200, 1711456200), + (1711456200.9, 1711456200), + ("1711456200", 1711456200), + ], + ) + def test_accepts_numeric_epoch_values(self, embargo_time_value, expected_epoch): + """ + Given a numeric embargo time value + When parse_embargo_time_value is called + Then the corresponding UTC datetime is returned + """ + actual = virtual_clock.parse_embargo_time_value(embargo_time_value) + + assert actual == datetime.datetime.fromtimestamp( + expected_epoch, tz=datetime.UTC + ) + + @pytest.mark.parametrize("embargo_time_value", [True, False, object()]) + def test_returns_none_for_unsupported_value_types(self, embargo_time_value): + """ + Given an unsupported embargo time value type + When parse_embargo_time_value is called + Then None is returned + """ + assert virtual_clock.parse_embargo_time_value(embargo_time_value) is None + + @mock.patch("common.virtual_clock.datetime.datetime") + def test_returns_none_when_timestamp_cannot_be_converted( + self, spy_datetime_class: mock.MagicMock + ): + """ + Given an epoch value that cannot be converted to a datetime + When parse_embargo_time_value is called + Then None is returned + """ + spy_datetime_class.fromtimestamp.side_effect = OverflowError + + assert virtual_clock.parse_embargo_time_value("1711456200") is None + spy_datetime_class.fromtimestamp.assert_called_once_with( + 1711456200, tz=datetime.UTC + ) + + +class TestSetEmbargoTime: + @mock.patch("common.virtual_clock._logger.error") + @mock.patch("common.virtual_clock.timezone.now") + def test_raises_when_page_previews_are_disabled( + self, + spy_now: mock.MagicMock, + spy_logger_error: mock.MagicMock, + settings, + ): + """ + Given page previews are disabled + When set_embargo_time is called + Then an Embargo Date not supported error is raised and the current time is stored + """ + expected = datetime.datetime(2026, 3, 31, 12, 0, tzinfo=datetime.UTC) + spy_now.return_value = expected + settings.PAGE_PREVIEWS_ENABLED = False + + with pytest.raises(virtual_clock.EmbargoDateNotSupportedError): + virtual_clock.set_embargo_time("now", token="token") + + assert virtual_clock.get_embargo_time() == expected + spy_logger_error.assert_called_once_with( + virtual_clock.EMBARGO_DATE_NOT_SUPPORTED_MESSAGE + ) + + @mock.patch("common.virtual_clock.validate_preview_hmac_token", return_value=False) + def test_returns_false_for_invalid_token( + self, spy_validate_preview_hmac_token: mock.MagicMock, settings + ): + """ + Given page previews are enabled and the preview token is invalid + When set_embargo_time is called + Then False is returned + """ + settings.PAGE_PREVIEWS_ENABLED = True + + actual = virtual_clock.set_embargo_time("now", token="token") + + assert actual is False + spy_validate_preview_hmac_token.assert_called_once_with("token") + + @mock.patch("common.virtual_clock.validate_preview_hmac_token", return_value=True) + def test_returns_false_for_unparseable_embargo_time( + self, spy_validate_preview_hmac_token: mock.MagicMock, settings + ): + """ + Given page previews are enabled and the token is valid + When set_embargo_time is called with an unparseable value + Then False is returned + """ + settings.PAGE_PREVIEWS_ENABLED = True + + actual = virtual_clock.set_embargo_time("not-a-time", token="token") + + assert actual is False + spy_validate_preview_hmac_token.assert_called_once_with("token") + + @mock.patch("common.virtual_clock.validate_preview_hmac_token", return_value=True) + def test_sets_embargo_time_for_valid_value( + self, spy_validate_preview_hmac_token: mock.MagicMock, settings + ): + """ + Given page previews are enabled and the token is valid + When set_embargo_time is called with a valid epoch value + Then the embargo time is stored for the current context + """ + settings.PAGE_PREVIEWS_ENABLED = True + expected = datetime.datetime.fromtimestamp(1711456200, tz=datetime.UTC) + + actual = virtual_clock.set_embargo_time("1711456200", token="token") + + assert actual is True + assert virtual_clock.get_embargo_time() == expected + spy_validate_preview_hmac_token.assert_called_once_with("token") + + +class TestGetAndClearEmbargoTime: + @mock.patch("common.virtual_clock.timezone.now") + def test_get_embargo_time_falls_back_to_now_after_clear( + self, spy_now: mock.MagicMock + ): + """ + Given a previously set embargo time that has been cleared + When get_embargo_time is called + Then the current timezone-aware datetime is returned + """ + stored = datetime.datetime.fromtimestamp(1711456200, tz=datetime.UTC) + fallback = datetime.datetime(2026, 4, 1, 9, 30, tzinfo=datetime.UTC) + virtual_clock._embargo_time_ctx.set(stored) + virtual_clock.clear_embargo_time() + spy_now.return_value = fallback + + actual = virtual_clock.get_embargo_time() + + assert actual == fallback + + +class TestEmbargoTimeContextAccessor: + def test_get_embargo_time_context_returns_shared_context_var(self): + context_var = virtual_clock.get_embargo_time_context() + + assert context_var is virtual_clock._embargo_time_ctx diff --git a/tests/unit/cms/dashboard/test_wagtail_hooks.py b/tests/unit/cms/dashboard/test_wagtail_hooks.py index 7d88f444f..89007966a 100644 --- a/tests/unit/cms/dashboard/test_wagtail_hooks.py +++ b/tests/unit/cms/dashboard/test_wagtail_hooks.py @@ -1,138 +1,1018 @@ +import pytest +import config from unittest import mock - +from unittest.mock import patch +from django.core.exceptions import ImproperlyConfigured +from django.urls import NoReverseMatch from draftjs_exporter.dom import DOM from wagtail.admin.site_summary import SummaryItem from wagtail.models import Page - from cms.dashboard import wagtail_hooks -from cms.dashboard.wagtail_hooks import _build_link_props, link_entity_with_href +from cms.dashboard.wagtail_hooks import ( + _build_link_props, + link_entity_with_href, +) +from django.test import override_settings -MODULE_PATH = "cms.dashboard.wagtail_hooks" +class TestWagtailHooksPagePreviews: + @patch("cms.dashboard.wagtail_hooks._rewrite_post_publish_view_live_button_url") + def test_after_edit_hook_calls_rewrite_helper(self, mock_rewrite): + """ + Given a request and page + When rewrite_post_publish_view_live_button_after_edit is called + Then it delegates to _rewrite_post_publish_view_live_button_url + """ + request = mock.Mock() + page = mock.Mock() -@mock.patch(f"{MODULE_PATH}.static") -def test_global_admin_css(mocked_static: mock.MagicMock): - """ - Given no input - When the wagtail hook `global_admin_css()` is called - Then the correct global admin CSS link is returned + wagtail_hooks.rewrite_post_publish_view_live_button_after_edit( + request=request, + page=page, + ) - Patches: - `mocked_static`: To isolate the return value - of the `static` function call and remove - the need of having to collect static files - for the test run + mock_rewrite.assert_called_once_with(request=request, page=page) - """ - # Given / When - returned_static_css_theme = "fake-css-theme-static-location" - mocked_static.return_value = returned_static_css_theme - global_admin_css_link: str = wagtail_hooks.global_admin_css() + @patch("cms.dashboard.wagtail_hooks._rewrite_post_publish_view_live_button_url") + def test_after_create_hook_calls_rewrite_helper(self, mock_rewrite): + """ + Given a request and page + When rewrite_post_publish_view_live_button_after_create is called + Then it delegates to _rewrite_post_publish_view_live_button_url + """ + request = mock.Mock() + page = mock.Mock() + + wagtail_hooks.rewrite_post_publish_view_live_button_after_create( + request=request, + page=page, + ) + + mock_rewrite.assert_called_once_with(request=request, page=page) + + @patch("cms.dashboard.wagtail_hooks._build_view_live_url") + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=False) + def test_rewrite_post_publish_view_live_button_url_returns_when_previews_disabled( + self, + _mock_settings, + mock_build_view_live_url, + ): + """ + Given page previews are disabled + When _rewrite_post_publish_view_live_button_url is called + Then live URL building is skipped + """ + request = mock.Mock() + page = mock.Mock(live=True) + + wagtail_hooks._rewrite_post_publish_view_live_button_url( + request=request, page=page + ) + + mock_build_view_live_url.assert_not_called() + + @patch("cms.dashboard.wagtail_hooks._build_view_live_url") + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_rewrite_post_publish_view_live_button_url_returns_when_page_not_live( + self, + _mock_settings, + mock_build_view_live_url, + ): + """ + Given previews are enabled but the page is not live + When _rewrite_post_publish_view_live_button_url is called + Then live URL building is skipped + """ + request = mock.Mock() + page = mock.Mock(live=False) + + wagtail_hooks._rewrite_post_publish_view_live_button_url( + request=request, page=page + ) + + mock_build_view_live_url.assert_not_called() + + @patch("cms.dashboard.wagtail_hooks._build_view_live_url", return_value=None) + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_rewrite_post_publish_view_live_button_url_returns_when_no_live_url( + self, + _mock_settings, + mock_build_view_live_url, + ): + """ + Given previews are enabled and the page is live + When _build_view_live_url returns no URL + Then the rewrite flow exits after attempting to build the live URL + """ + request = mock.Mock() + page = mock.Mock(live=True) + + wagtail_hooks._rewrite_post_publish_view_live_button_url( + request=request, page=page + ) - # Then - expected_value = ( - f'' + mock_build_view_live_url.assert_called_once_with(page=page) + + @patch( + "cms.dashboard.wagtail_hooks._build_view_live_url", + return_value="http://localhost:3000/x/", ) - assert global_admin_css_link == expected_value + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_rewrite_post_publish_view_live_button_url_returns_when_no_message_storage( + self, + _mock_settings, + _mock_build_view_live_url, + ): + """ + Given previews are enabled and request has no message storage + When _rewrite_post_publish_view_live_button_url is called + Then it exits without raising errors + """ + request = mock.Mock(spec=[]) + page = mock.Mock(live=True) + wagtail_hooks._rewrite_post_publish_view_live_button_url( + request=request, page=page + ) -def test_register_icons_returns_correct_list_of_icons(): - """ - Given a list of icons - When the wagtail hook `register_icons()` is called - Then a list of icons is returned including the original icons - as well as a list of additional custom icons - """ - # Given - original_icons = ["original_icon_1.svg", "original_icon_2.svg"] + @patch( + "cms.dashboard.wagtail_hooks._build_view_live_url", + return_value="http://localhost:3000/x/", + ) + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_rewrite_post_publish_view_live_button_url_continues_when_messages_list_empty( + self, + _mock_settings, + _mock_build_view_live_url, + ): + """ + Given previews are enabled and message storage contains no messages + When _rewrite_post_publish_view_live_button_url is called + Then it exits without modifying anything + """ + request = mock.Mock() + request._messages = mock.Mock(_queued_messages=[], _loaded_messages=[]) + page = mock.Mock(live=True) - # When - registered_icons = wagtail_hooks.register_icons(icons=original_icons) + wagtail_hooks._rewrite_post_publish_view_live_button_url( + request=request, page=page + ) - # Then - expected_icons = original_icons + wagtail_hooks.ADDITIONAL_CUSTOM_ICONS - assert registered_icons == expected_icons + @patch( + "cms.dashboard.wagtail_hooks._build_view_live_url", + return_value="http://localhost:3000/x/", + ) + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_rewrite_post_publish_view_live_button_url_continues_when_no_view_live_text( + self, + _mock_settings, + _mock_build_view_live_url, + ): + """ + Given previews are enabled and message text has no View live anchor + When _rewrite_post_publish_view_live_button_url is called + Then the original message text is left unchanged + """ + request = mock.Mock() + message = mock.Mock() + message.message = "Page has been published" + request._messages = mock.Mock(_queued_messages=[message], _loaded_messages=[]) + page = mock.Mock(live=True) + wagtail_hooks._rewrite_post_publish_view_live_button_url( + request=request, page=page + ) -def test_hide_default_menu_items(): - """ - Given a list of default core menu items (documents and images) - When wagtail hook `hide_default_menu_items` is called - Then a list of items is returned which excludes the `Docments` and `Images` items from - the core library - """ + assert message.message == "Page has been published" + + @patch("cms.dashboard.wagtail_hooks.logger.debug") + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + @patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + side_effect=RuntimeError("boom"), + ) + def test_add_frontend_preview_action_logs_and_swallows_unexpected_error( + self, + _mock_label, + _mock_settings, + spy_logger_exception, + ): + """ + Given add_frontend_preview_action encounters an unexpected runtime error + When the function is invoked + Then the exception is logged and not re-raised + """ + page = mock.Mock() + page.pk = 123 + menu_items = [] + context = {"page": page} - # Given - class FakeMenuItem: - def __init__(self, name): - self.name = name + wagtail_hooks.add_frontend_preview_action( + menu_items=menu_items, + request=None, + context=context, + ) - core_menu_items = [ - FakeMenuItem(name="images"), - FakeMenuItem(name="documents"), - FakeMenuItem("pages"), - ] + spy_logger_exception.assert_called_once() - # When - wagtail_hooks.hide_default_menu_items( - request=mock.Mock(), menu_items=core_menu_items + @patch("cms.dashboard.wagtail_hooks.logger.exception") + @patch( + "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_frontend_route_url", + side_effect=ImproperlyConfigured, ) + def test_build_view_live_url_invalid_base_returns_none( + self, _mock_route_url, spy_logger_exception + ): + """ + Given FRONTEND_URL is missing or non-absolute + When _build_view_live_url is called + Then None is returned to avoid generating a relative URL + """ + page = mock.Mock() + page.slug = "access-our-data" + assert wagtail_hooks._build_view_live_url(page) is None - # Then - assert len(core_menu_items) == 1 - assert core_menu_items[0].name == "pages" + spy_logger_exception.assert_called_once() + @patch("cms.dashboard.wagtail_hooks.reverse", return_value="/admin/preview/888") + def test__build_frontend_preview_url(self, mock_reverse): + """ + Given a page, + When _build_frontend_preview_url is called, + Then it returns the preview URL from reverse. + """ + page = mock.Mock() + page.pk = 888 + url = wagtail_hooks._build_frontend_preview_url(page=page) + assert url == "/admin/preview/888" + """ + Given label 'View Live' and live_url is falsy, + When frontend_preview_button is called and reverse raises NoReverseMatch, + Then it returns no buttons. + """ + page = mock.Mock() + page.pk = 123 + with mock.patch( + "cms.dashboard.wagtail_hooks.settings", + PAGE_PREVIEWS_ENABLED=True, + ): + with mock.patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + return_value="View Live", + ): + with mock.patch( + "cms.dashboard.wagtail_hooks._build_view_live_url", + return_value=None, + ): + with mock.patch( + "cms.dashboard.wagtail_hooks.reverse", + side_effect=NoReverseMatch(), + ): + with mock.patch( + "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_route_slug", + return_value="sluggy", + ): + with mock.patch( + "cms.dashboard.wagtail_hooks.Button", + side_effect=lambda **kwargs: type("Btn", (), kwargs)(), + ): + result = wagtail_hooks.frontend_preview_button( + page, user=None, next_url=None, view_name="edit" + ) + assert result == [] -def test_update_summary_items(): - """ - Given a list of three summary items (including documents and images) - When wagtail hook update_summary_items is called - Then the menu will be cleared and PageSummaryItem added - Leaving the summary items with a length of 1 - """ - # Given - core_summary_items = ["document", "image", "pages"] - mock_request = mock.Mock() + @patch( + "cms.dashboard.wagtail_hooks.FrontendPreviewAction", + side_effect=lambda **kwargs: type("FPA", (), kwargs)(), + ) + @patch( + "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_route_slug", + return_value="sluggy", + ) + @patch("cms.dashboard.wagtail_hooks.reverse", side_effect=NoReverseMatch()) + @patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", return_value="Preview" + ) + @patch( + "cms.dashboard.wagtail_hooks.settings", + PAGE_PREVIEWS_ENABLED=True, + ) + def test_add_frontend_preview_action_preview_branch_no_reverse_match( + self, mock_settings, mock_label, mock_reverse, mock_slug, mock_fpa + ): + """ + Given label 'Preview' and reverse raises NoReverseMatch, + When add_frontend_preview_action is called, + Then no preview action is inserted. + """ + page = mock.Mock() + page.pk = 456 + menu_items = [] + context = {"page": page} + wagtail_hooks.add_frontend_preview_action( + menu_items, request=None, context=context + ) + assert menu_items == [] + + @patch( + "cms.dashboard.wagtail_hooks.Button", + side_effect=lambda **kwargs: type("Btn", (), kwargs)(), + ) + @patch("cms.dashboard.wagtail_hooks.reverse", return_value="/admin/preview/42") + @patch("cms.dashboard.wagtail_hooks._build_view_live_url", return_value=None) + @patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + return_value="View Live", + ) + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_frontend_preview_button_view_live_fallback_preview( + self, mock_settings, mock_label, mock_build_url, mock_reverse, mock_button + ): + """ + Given label 'View Live' and live_url is falsy, + When frontend_preview_button is called and reverse succeeds, + Then it returns a Button with the preview URL from reverse. + """ + page = mock.Mock() + page.pk = 42 + result = wagtail_hooks.frontend_preview_button( + page, user=None, next_url=None, view_name="edit" + ) + assert len(result) == 1 + assert getattr(result[0], "url", None) == "/admin/preview/42" + + @patch( + "cms.dashboard.wagtail_hooks.Button", + side_effect=lambda **kwargs: type("Btn", (), kwargs)(), + ) + @patch("cms.dashboard.wagtail_hooks.reverse", return_value="/admin/preview/42") + @patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + return_value="View Live", + ) + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_frontend_preview_button_view_live_invalid_base_uses_preview_url( + self, mock_settings, mock_label, mock_reverse, mock_button + ): + """ + Given label 'View Live' but FRONTEND_URL is invalid + When frontend_preview_button is called + Then it falls back to the preview redirect URL + """ + page = mock.Mock() + page.pk = 42 + page.slug = "access-our-data" + with mock.patch( + "cms.dashboard.wagtail_hooks.settings", + PAGE_PREVIEWS_ENABLED=True, + FRONTEND_URL="", + ): + result = wagtail_hooks.frontend_preview_button( + page, user=None, next_url=None, view_name="edit" + ) + assert len(result) == 1 + assert getattr(result[0], "url", None) == "/admin/preview/42" - # When - wagtail_hooks.update_summary_items( - request=mock_request, summary_items=core_summary_items + @patch( + "cms.dashboard.wagtail_hooks.FrontendPreviewAction", + side_effect=lambda **kwargs: type("FPA", (), kwargs)(), + ) + @patch("cms.dashboard.wagtail_hooks.reverse", return_value="/admin/preview/99") + @patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", return_value="Preview" ) + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_add_frontend_preview_action_preview_branch( + self, mock_settings, mock_label, mock_reverse, mock_fpa + ): + """ + Given label 'Preview' and reverse succeeds, + When add_frontend_preview_action is called, + Then it inserts a FrontendPreviewAction with the preview URL from reverse. + """ + page = mock.Mock() + page.pk = 99 + menu_items = [] + context = {"page": page} + wagtail_hooks.add_frontend_preview_action( + menu_items, request=None, context=context + ) + assert len(menu_items) == 1 + assert getattr(menu_items[0], "url", None) == "/admin/preview/99" - # Then - assert len(core_summary_items) == 1 - assert core_summary_items[0].request == mock_request - assert isinstance(core_summary_items[0], SummaryItem) + @patch( + "cms.dashboard.wagtail_hooks.FrontendPreviewAction", + side_effect=lambda **kwargs: type("FPA", (), kwargs)(), + ) + @patch( + "cms.dashboard.wagtail_hooks.reverse", + side_effect=["/admin/preview/77", "/admin/preview/99"], + ) + @patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", return_value="Preview" + ) + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_add_frontend_preview_action_preview_branch_explicit( + self, mock_settings, mock_label, mock_reverse, mock_fpa + ): + """ + Given label 'Preview' and reverse succeeds, + When add_frontend_preview_action is called, + Then it inserts a FrontendPreviewAction with the preview URL from reverse (explicit branch coverage). + """ + page = mock.Mock() + page.pk = 77 + menu_items = [] + context = {"page": page} + wagtail_hooks.add_frontend_preview_action( + menu_items, request=None, context=context + ) + assert len(menu_items) == 1 + assert getattr(menu_items[0], "url", None) == "/admin/preview/77" + # Reset menu_items before the next call to avoid accumulation + menu_items = [] + wagtail_hooks.add_frontend_preview_action( + menu_items, request=None, context=context + ) + assert len(menu_items) == 1 + assert getattr(menu_items[0], "url", None) == "/admin/preview/99" + @patch("cms.dashboard.wagtail_hooks._build_frontend_preview_url") + @patch( + "cms.dashboard.wagtail_hooks.FrontendPreviewAction", + side_effect=lambda **kwargs: type("FPA", (), kwargs)(), + ) + @patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", return_value="Preview" + ) + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_add_frontend_preview_action_calls_url_builder_for_preview_label( + self, mock_settings, mock_label, mock_fpa, mock_build_url + ): + """ + Given Preview is the selected button label + When add_frontend_preview_action is called + Then the frontend preview URL builder is used to create the action URL + """ + page = mock.Mock() + page.pk = 123 + menu_items = [] + context = {"page": page} + mock_build_url.return_value = "/admin/preview/123" + + wagtail_hooks.add_frontend_preview_action( + menu_items, request=None, context=context + ) + + mock_build_url.assert_called_once_with(page=page) + assert len(menu_items) == 1 + assert getattr(menu_items[0], "url", None) == "/admin/preview/123" -@mock.patch(f"{MODULE_PATH}.link_entity_with_href") -def test_register_link_props(spy_link_entity_with_href: mock.MagicMock): + @patch( + "cms.dashboard.wagtail_hooks.Button", + side_effect=lambda **kwargs: type("Btn", (), kwargs)(), + ) + @patch( + "cms.dashboard.wagtail_hooks._build_view_live_url", return_value="https://live" + ) + @patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + return_value="View Live", + ) + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_frontend_preview_button_view_live_truthy_url( + self, mock_settings, mock_label, mock_build_url, mock_button + ): + """ + Given label 'View Live' and live_url is truthy, + When frontend_preview_button is called, + Then it returns a Button with the live_url. + """ + page = mock.Mock() + result = wagtail_hooks.frontend_preview_button( + page, user=None, next_url=None, view_name="edit" + ) + assert len(result) == 1 + assert getattr(result[0], "url", None) == "https://live" + + def test_frontend_preview_button_view_name_not_edit(self): + """ + Given view_name is not 'edit', + When frontend_preview_button is called, + Then it returns []. + """ + page = mock.Mock() + result = wagtail_hooks.frontend_preview_button( + page, user=None, next_url=None, view_name="not-edit" + ) + assert result == [] + + def test_frontend_preview_button_previews_disabled(self): + """ + Given PAGE_PREVIEWS_ENABLED is False, + When frontend_preview_button is called, + Then it returns []. + """ + page = mock.Mock() + with mock.patch( + "cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=False + ): + result = wagtail_hooks.frontend_preview_button( + page, user=None, next_url=None, view_name="edit" + ) + assert result == [] + + def test_get_preview_button_label_custom_preview_disabled(self): + """ + Given a page with custom_preview_enabled False, + When _get_preview_button_label is called, + Then it returns None. + """ + page = mock.Mock() + page.custom_preview_enabled = False + assert wagtail_hooks._get_preview_button_label(page) is None + + @patch( + "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_route_slug", + return_value="sluggy", + ) + @patch( + "cms.dashboard.wagtail_hooks.settings", + ) + @patch("cms.dashboard.wagtail_hooks.reverse", side_effect=NoReverseMatch()) + @patch("cms.dashboard.wagtail_hooks._build_view_live_url", return_value=None) + @patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + return_value="View Live", + ) + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_frontend_preview_button_view_live_falsy_url( + self, + mock_enabled, + mock_label, + mock_build_url, + mock_reverse, + mock_tpl, + mock_slug, + ): + """ + Given label 'View Live' and live_url is falsy, + When frontend_preview_button is called, + Then no preview button is returned. + """ + page = mock.Mock() + result = wagtail_hooks.frontend_preview_button( + page, user=None, next_url=None, view_name="edit" + ) + assert result == [] + + def test_add_frontend_preview_action_view_live_falsy_url(self): + """ + Given label 'View Live' and live_url is falsy, + When add_frontend_preview_action is called, + Then it returns early (None). + """ + page = mock.Mock() + page.pk = 1 + menu_items = [] + context = {"page": page} + with mock.patch( + "cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True + ): + with mock.patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + return_value="View Live", + ): + with mock.patch( + "cms.dashboard.wagtail_hooks._build_view_live_url", + return_value=None, + ): + wagtail_hooks.add_frontend_preview_action(menu_items, None, context) + + def test_get_preview_button_label_preview(self): + """ + Given a page with draft, + When _get_preview_button_label is called, + Then it returns 'Preview'. + """ + page = mock.Mock() + page.custom_preview_enabled = True + page.has_unpublished_changes = True + page.live = False + assert wagtail_hooks._get_preview_button_label(page) == "Preview" + + def test_get_preview_button_label_view_live(self): + """ + Given a page with live, + When _get_preview_button_label is called, + Then it returns 'View Live'. + """ + page = mock.Mock() + page.custom_preview_enabled = True + page.has_unpublished_changes = False + page.live = True + assert wagtail_hooks._get_preview_button_label(page) == "View Live" + + def test_get_preview_button_label_none(self): + """ + Given a page with neither draft nor live, + When _get_preview_button_label is called, + Then it returns None. + """ + page = mock.Mock() + page.custom_preview_enabled = True + page.has_unpublished_changes = False + page.live = False + assert wagtail_hooks._get_preview_button_label(page) is None + + def test_build_view_live_url_exception_and_slug(self): + """ + Given a page where get_url_parts raises AttributeError, + When _build_view_live_url is called, + Then it returns slug url or None. + """ + config.FRONTEND_URL = "http://localhost:3000" + page = mock.Mock() + page.get_url_parts.side_effect = AttributeError() + page.slug = "sluggy" + build_view_live_url = wagtail_hooks._build_view_live_url(page) + assert build_view_live_url == "http://localhost:3000/nocache/sluggy" + page.slug = "" + build_view_live_url = wagtail_hooks._build_view_live_url(page) + assert build_view_live_url == "http://localhost:3000/nocache" + + def test_build_view_live_url_success(self): + """ + Given a page where get_url_parts returns a path, + When _build_view_live_url is called, + Then it returns the correct live URL using the path. + """ + config.FRONTEND_URL = "http://localhost:3000" + page = mock.Mock() + page.get_url_parts.return_value = ("http", "domain", "/foo/bar/") + view_live_url = wagtail_hooks._build_view_live_url(page) + assert view_live_url == "http://localhost:3000/nocache/foo/bar" + + def test_frontend_preview_button_label_none(self): + """ + Given _get_preview_button_label returns None, + When frontend_preview_button is called, + Then it returns []. + """ + page = mock.Mock() + with mock.patch( + "cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True + ): + with mock.patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + return_value=None, + ): + result = wagtail_hooks.frontend_preview_button( + page, user=None, next_url=None, view_name="edit" + ) + assert result == [] + + def test_frontend_preview_button_view_live_no_url(self): + """ + Given label 'View Live' but no live_url, + When frontend_preview_button is called and reverse fails, + Then no button is returned. + """ + page = mock.Mock() + with mock.patch( + "cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True + ): + with mock.patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + return_value="View Live", + ): + with mock.patch( + "cms.dashboard.wagtail_hooks._build_view_live_url", + return_value=None, + ): + with mock.patch( + "cms.dashboard.wagtail_hooks.reverse", + side_effect=NoReverseMatch(), + ): + with mock.patch( + "cms.dashboard.wagtail_hooks.settings", + FRONTEND_URL="https://frontend.test", + ): + with mock.patch( + "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_route_slug", + return_value="sluggy", + ): + result = wagtail_hooks.frontend_preview_button( + page, user=None, next_url=None, view_name="edit" + ) + assert result == [] + + def test_register_admin_urls(self): + """ + Given nothing, + When register_admin_urls is called, + Then it returns a list with a re_path. + """ + result = wagtail_hooks.register_admin_urls() + assert isinstance(result, list) + assert result + + def test_add_frontend_preview_action_early_returns(self): + """ + Given missing or invalid page/context/settings, + When add_frontend_preview_action is called, + Then it returns early (None). + """ + # No page + menu_items = [] + context = {} + wagtail_hooks.add_frontend_preview_action(menu_items, None, context) + # No pk + page = mock.Mock() + page.pk = None + context = {"page": page} + wagtail_hooks.add_frontend_preview_action(menu_items, None, context) + # PAGE_PREVIEWS_ENABLED False + page.pk = 1 + with mock.patch( + "cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=False + ): + wagtail_hooks.add_frontend_preview_action(menu_items, None, {"page": page}) + # button_label None + with mock.patch( + "cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True + ): + with mock.patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + return_value=None, + ): + wagtail_hooks.add_frontend_preview_action( + menu_items, None, {"page": page} + ) + + def test_replace_view_live_button_href_rewrites_only_view_live_anchor(self): + """ + Given a publish success message containing a View live anchor + When _replace_view_live_button_href is called with a frontend target URL + Then only the anchor href is rewritten and safe link attributes are added + """ + message_html = ( + 'okView live' + ) + + result = wagtail_hooks._replace_view_live_button_href( + message_html=message_html, + target_url="http://localhost:3000/foo/", + ) + + assert 'href="http://localhost:3000/foo/"' in result + assert 'target="_blank"' in result + assert 'rel="noreferrer"' in result + + @patch("cms.dashboard.wagtail_hooks._build_view_live_url") + @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) + def test_rewrite_post_publish_view_live_button_url_updates_message( + self, + _mock_settings, + mock_build_view_live_url, + ): + """ + Given previews are enabled and a queued publish message contains View live + When _rewrite_post_publish_view_live_button_url is called + Then the backend live link is replaced with the frontend live link + """ + mock_build_view_live_url.return_value = ( + "http://localhost:3000/weather-health-alerts/" + ) + + request = mock.Mock() + message = mock.Mock() + message.message = ( + 'View live' + ) + request._messages = mock.Mock(_queued_messages=[message], _loaded_messages=[]) + + page = mock.Mock() + page.live = True + + wagtail_hooks._rewrite_post_publish_view_live_button_url( + request=request, + page=page, + ) + + assert "http://localhost:3000/weather-health-alerts/" in message.message + assert "http://localhost:8000/weather-health-alerts/" not in message.message + assert 'target="_blank"' in message.message + assert 'rel="noreferrer"' in message.message + + +class TestWagtailHooksGeneral: + @mock.patch("cms.dashboard.wagtail_hooks.static") + def test_global_admin_css(self, mocked_static): + """ + Given no input + When the wagtail hook `global_admin_css()` is called + Then the correct global admin CSS link is returned + + Patches: + `mocked_static`: To isolate the return value + of the `static` function call and remove + the need of having to collect static files + for the test run + + """ + # Given / When + returned_static_css_theme = "fake-css-theme-static-location" + mocked_static.return_value = returned_static_css_theme + global_admin_css_link: str = wagtail_hooks.global_admin_css() + + # Then + expected_value = f'' + assert global_admin_css_link == expected_value + + def test_register_icons_returns_correct_list_of_icons(self): + """ + Given a list of icons + When the wagtail hook `register_icons()` is called + Then a list of icons is returned including the original icons + as well as a list of additional custom icons + """ + # Given + original_icons = ["original_icon_1.svg", "original_icon_2.svg"] + + # When + registered_icons = wagtail_hooks.register_icons(icons=original_icons) + + # Then + expected_icons = original_icons + wagtail_hooks.ADDITIONAL_CUSTOM_ICONS + assert registered_icons == expected_icons + + @mock.patch("cms.dashboard.wagtail_hooks.static") + def test_hide_default_menu_items(self, mocked_static): + """ + Given a list of default core menu items (documents and images) + When wagtail hook `hide_default_menu_items` is called + Then a list of items is returned which excludes the `Docments` and `Images` items from + the core library + """ + + # Given + class FakeMenuItem: + def __init__(self, name): + self.name = name + + core_menu_items = [ + FakeMenuItem(name="images"), + FakeMenuItem(name="documents"), + FakeMenuItem("pages"), + ] + + # When + wagtail_hooks.hide_default_menu_items( + request=mock.Mock(), menu_items=core_menu_items + ) + + # Then + assert len(core_menu_items) == 1 + assert core_menu_items[0].name == "pages" + + def test_update_summary_items(self): + """ + Given a list of three summary items (including documents and images) + When wagtail hook update_summary_items is called + Then the menu will be cleared and PageSummaryItem added + Leaving the summary items with a length of 1 + """ + # Given + core_summary_items = ["document", "image", "pages"] + mock_request = mock.Mock() + + # When + wagtail_hooks.update_summary_items( + request=mock_request, summary_items=core_summary_items + ) + + # Then + assert len(core_summary_items) == 1 + assert core_summary_items[0].request == mock_request + assert isinstance(core_summary_items[0], SummaryItem) + + @mock.patch("cms.dashboard.wagtail_hooks.link_entity_with_href") + def test_register_link_props(self, spy_link_entity_with_href): + """ + Given no input + When the wagtail hook `register_link_props` is called + Then the `link_entity_with_href()` function + is set on the link entity decorators + via the `register_converter_rule()` call + """ + # Given + fake_rule = {"to_database_format": {"entity_decorators": {"LINK": {}}}} + fake_converter_rules = {"contentstate": {"link": fake_rule}} + spy_features = mock.MagicMock() + spy_features.converter_rules_by_converter = fake_converter_rules + + # When + wagtail_hooks.register_link_props(features=spy_features) + + # Then + assert ( + fake_rule["to_database_format"]["entity_decorators"]["LINK"] + == spy_link_entity_with_href + ) + + +@pytest.fixture(autouse=True) +def disable_wagtail_hooks(): """ - Given no input - When the wagtail hook `register_link_props` is called - Then the `link_entity_with_href()` function - is set on the link entity decorators - via the `register_converter_rule()` call + Replace wagtail.hooks.register with a no-op decorator so + functions behave like normal Python functions in tests. """ - # Given - fake_rule = {"to_database_format": {"entity_decorators": {"LINK": {}}}} - fake_converter_rules = {"contentstate": {"link": fake_rule}} - spy_features = mock.MagicMock() - spy_features.converter_rules_by_converter = fake_converter_rules + with patch("wagtail.hooks.register", lambda *a, **k: (lambda f: f)): + yield - # When - wagtail_hooks.register_link_props(features=spy_features) - # Then - assert ( - fake_rule["to_database_format"]["entity_decorators"]["LINK"] - == spy_link_entity_with_href +class TestAddFrontendPreviewActionBranches: + @pytest.mark.parametrize( + "pk,reverse_side_effect,slug,expected_url", + [ + # reverse raises NoReverseMatch + ( + 456, + NoReverseMatch(), + "sluggy", + "https://frontend.test/preview?slug=sluggy", + ), + # reverse returns preview url + (99, "/admin/preview/99", None, "/admin/preview/99"), + # explicit branch coverage, two calls + (77, "/admin/preview/77", None, "/admin/preview/77"), + (99, "/admin/preview/99", None, "/admin/preview/99"), + ], ) + def test_add_frontend_preview_action_branches( + self, pk, reverse_side_effect, slug, expected_url + ): + """ + Given a page preview scenario with reverse outcomes + When add_frontend_preview_action is called + Then the inserted action URL matches the expected branch outcome + """ + page = mock.Mock() + page.pk = pk + menu_items = [] + context = {"page": page} + settings_patch = { + "PAGE_PREVIEWS_ENABLED": True, + } + with mock.patch("cms.dashboard.wagtail_hooks.settings", **settings_patch): + with mock.patch( + "cms.dashboard.wagtail_hooks._get_preview_button_label", + return_value="Preview", + ): + with mock.patch( + "cms.dashboard.wagtail_hooks.reverse", + side_effect=reverse_side_effect, + ): + build_route_slug_patch = ( + mock.patch( + "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_route_slug", + return_value=slug, + ) + if slug + else mock.patch("builtins.id", lambda x: x) + ) + with build_route_slug_patch: + + def fpa_mock(**kwargs): + obj = type("FPA", (), {})() + obj._url = kwargs.get("url") + obj.label = kwargs.get("label") + obj.order = kwargs.get("order") + return obj + + with mock.patch( + "cms.dashboard.wagtail_hooks.FrontendPreviewAction", + side_effect=fpa_mock, + ): + with mock.patch( + "cms.dashboard.wagtail_hooks._build_frontend_preview_url", + return_value=expected_url, + ): + wagtail_hooks.add_frontend_preview_action( + menu_items, request=None, context=context + ) + assert len(menu_items) == 1 + actual_url = getattr(menu_items[0], "_url", None) + # Debug output removed after test confirmation + assert actual_url == expected_url class TestLinkEntityWithHref: @mock.patch.object(DOM, "create_element") - @mock.patch(f"{MODULE_PATH}._build_link_props") + @mock.patch("cms.dashboard.wagtail_hooks._build_link_props") def test_delegates_calls( self, spy_build_link_props: mock.MagicMock, @@ -185,7 +1065,7 @@ def test_build_link_props_with_valid_page_id( expected_props = {"linktype": "page", "id": page_id, "href": expected_url} assert link_props == expected_props - @mock.patch(f"{MODULE_PATH}.check_url") + @mock.patch("cms.dashboard.wagtail_hooks.check_url") def test_build_link_props_with_url(self, spy_check_url: mock.MagicMock): """ Given a URL for a `Page` object @@ -224,3 +1104,16 @@ def test_build_link_props_for_non_existent_page( # Then the correct link properties are returned expected_props = {"href": "", "id": 123, "linktype": "page"} assert link_props == expected_props + + +class TestFrontendPreviewAction: + def test_get_url_returns_url(self): + """ + Given a FrontendPreviewAction instance with a URL, + When get_url is called, + Then it returns the correct URL. + """ + action = wagtail_hooks.FrontendPreviewAction( + url="/test-url/", label="Test", order=1 + ) + assert action.get_url(parent_context=None) == "/test-url/" diff --git a/tests/unit/metrics/api/test_middleware copy.py b/tests/unit/metrics/api/test_middleware copy.py new file mode 100644 index 000000000..494bd2351 --- /dev/null +++ b/tests/unit/metrics/api/test_middleware copy.py @@ -0,0 +1,70 @@ +import json +from unittest import mock + +from metrics.api.middleware import RequestScopedCachingConfigMiddleware + +MODULE_PATH = "metrics.api.middleware" + + +class TestRequestScopedCachingConfigMiddleware: + @staticmethod + def _build_request(*, path: str, headers: dict | None = None, path_info=None): + request = mock.MagicMock() + request.path = path + request.path_info = path if path_info is None else path_info + request.headers = headers or {} + return request + + @mock.patch(f"{MODULE_PATH}.disable_request_caching") + def test_disables_request_caching_when_invalid_header( + self, + spy_disable_request_caching: mock.MagicMock, + ): + """ + Given a request + When TestRequestScopedCachingConfigMiddleware processes the request + Then it must call disable_request_caching + """ + request = self._build_request( + path="/api/pages/1/", + path_info=object(), + ) + request.path = object() + + get_response = mock.Mock(return_value={"ok": True}) + middleware = RequestScopedCachingConfigMiddleware(get_response=get_response) + + response = middleware(request) + RequestScopedCachingConfigMiddleware._set_no_cache_if_header_is_valid( + request=request + ) + + assert response == {"ok": True} + assert spy_disable_request_caching.assert_called_once + + @mock.patch(f"{MODULE_PATH}.get_cache_control_header") + def test_disables_request_caching_when_valid_header( + self, mock_get_cache_control_header: mock.MagicMock + ): + """ + Given a request + When TestRequestScopedCachingConfigMiddleware processes the request + Then it must call disable_request_caching + """ + request = self._build_request( + path="/api/pages/1/", + path_info=object(), + ) + request.path = object() + mock_get_cache_control_header.return_value = "no-store" + + get_response = mock.Mock(return_value={"ok": True}) + middleware = RequestScopedCachingConfigMiddleware(get_response=get_response) + + response = middleware(request) + result = RequestScopedCachingConfigMiddleware._set_no_cache_if_header_is_valid( + request=request + ) + + assert response == {"ok": True} + assert result is None diff --git a/tests/unit/metrics/api/test_middleware.py b/tests/unit/metrics/api/test_middleware.py new file mode 100644 index 000000000..176808c1b --- /dev/null +++ b/tests/unit/metrics/api/test_middleware.py @@ -0,0 +1,276 @@ +import json +from unittest import mock + +from metrics.api.middleware import EmbargoMiddleware + +MODULE_PATH = "metrics.api.middleware" + + +class TestEmbargoMiddleware: + @staticmethod + def _build_request(*, path: str, headers: dict | None = None, path_info=None): + request = mock.MagicMock() + request.path = path + request.path_info = path if path_info is None else path_info + request.headers = headers or {} + return request + + @staticmethod + def _assert_invalid_token_response(response) -> None: + assert response.status_code == 401 + assert json.loads(response.content) == {"detail": "The token was invalid"} + + @mock.patch(f"{MODULE_PATH}.set_embargo_time") + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + def test_skips_request_when_path_is_not_a_string( + self, + spy_validate_token: mock.MagicMock, + spy_set_embargo_time: mock.MagicMock, + ): + """ + Given a request whose path values are not strings + When EmbargoMiddleware processes the request + Then it bypasses token validation and embargo assignment + """ + request = self._build_request( + path="/api/pages/1/", + headers={"x-cms-auth": "Bearer validtoken"}, + path_info=object(), + ) + request.path = object() + + get_response = mock.Mock(return_value={"ok": True}) + middleware = EmbargoMiddleware(get_response=get_response) + + response = middleware(request) + + assert response == {"ok": True} + spy_validate_token.assert_not_called() + spy_set_embargo_time.assert_not_called() + + @mock.patch(f"{MODULE_PATH}.set_embargo_time") + @mock.patch(f"{MODULE_PATH}.get_cms_auth_payload") + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + def test_sets_embargo_time_when_valid_draft_header_present( + self, + spy_validate_token: mock.MagicMock, + spy_decode_token: mock.MagicMock, + spy_set_embargo_time: mock.MagicMock, + settings, + ): + """ + Given a request with a valid bearer token and embargo_time in payload + When EmbargoMiddleware processes the request + Then it validates the token and sets embargo time on the request context + """ + request = self._build_request( + path="/api/pages/1/", + headers={"x-cms-auth": "Bearer validtoken"}, + ) + settings.PAGE_PREVIEWS_ENABLED = True + spy_validate_token.return_value = True + spy_decode_token.return_value = {"embargo_time": 1} + + middleware = EmbargoMiddleware(get_response=mock.Mock(return_value={})) + middleware(request) + + spy_validate_token.assert_called_once_with("validtoken") + spy_set_embargo_time.assert_called_once_with( + 1, + token="validtoken", + ) + + @mock.patch(f"{MODULE_PATH}.set_embargo_time") + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + def test_silently_continues_when_header_missing( + self, + spy_validate_token: mock.MagicMock, + spy_set_embargo_time: mock.MagicMock, + ): + """ + Given a drafts request with no auth header + When EmbargoMiddleware processes the request + Then it continues without validation and without setting embargo time + """ + request = self._build_request(path="/api/drafts/1/") + + get_response = mock.Mock(return_value={"ok": True}) + middleware = EmbargoMiddleware(get_response=get_response) + + response = middleware(request) + + assert response == {"ok": True} + spy_validate_token.assert_not_called() + spy_set_embargo_time.assert_not_called() + + @mock.patch(f"{MODULE_PATH}.set_embargo_time") + @mock.patch(f"{MODULE_PATH}.get_cms_auth_payload") + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + def test_silently_continues_when_valid_token_has_no_embargo_time( + self, + spy_validate_token: mock.MagicMock, + spy_decode_token: mock.MagicMock, + spy_set_embargo_time: mock.MagicMock, + ): + """ + Given a drafts request with a valid token but no embargo_time claim + When EmbargoMiddleware processes the request + Then it continues and does not set embargo time + """ + request = self._build_request( + path="/api/drafts/1/", + headers={"x-cms-auth": "Bearer validtoken"}, + ) + spy_validate_token.return_value = True + spy_decode_token.return_value = {} + + get_response = mock.Mock(return_value={"ok": True}) + middleware = EmbargoMiddleware(get_response=get_response) + + response = middleware(request) + + assert response == {"ok": True} + spy_validate_token.assert_called_once_with("validtoken") + spy_set_embargo_time.assert_not_called() + + @mock.patch(f"{MODULE_PATH}.set_embargo_time") + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + def test_silently_continues_when_token_invalid( + self, + spy_validate_token: mock.MagicMock, + spy_set_embargo_time: mock.MagicMock, + ): + """ + Given an API request with an invalid bearer token + When EmbargoMiddleware processes the request + Then it returns an invalid-token 401 response + """ + request = self._build_request( + path="/api/alerts/v1/heat", + headers={"x-cms-auth": "Bearer invalidtoken"}, + ) + spy_validate_token.return_value = False + + get_response = mock.Mock(return_value={"ok": True}) + middleware = EmbargoMiddleware(get_response=get_response) + + response = middleware(request) + + self._assert_invalid_token_response(response) + spy_validate_token.assert_called_once_with("invalidtoken") + spy_set_embargo_time.assert_not_called() + + @mock.patch(f"{MODULE_PATH}.set_embargo_time") + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + def test_returns_401_when_auth_header_is_not_bearer( + self, + spy_validate_token: mock.MagicMock, + spy_set_embargo_time: mock.MagicMock, + ): + """ + Given an API request whose auth header is not Bearer format + When EmbargoMiddleware processes the request + Then it returns an invalid-token 401 response + """ + request = self._build_request( + path="/api/pages/1/", + headers={"x-cms-auth": "Token abc"}, + ) + + get_response = mock.Mock(return_value={"ok": True}) + middleware = EmbargoMiddleware(get_response=get_response) + + response = middleware(request) + + self._assert_invalid_token_response(response) + spy_validate_token.assert_not_called() + spy_set_embargo_time.assert_not_called() + + @mock.patch(f"{MODULE_PATH}.set_embargo_time") + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + def test_skips_non_api_routes_like_cms_admin( + self, + spy_validate_token: mock.MagicMock, + spy_set_embargo_time: mock.MagicMock, + ): + """ + Given a non-API route under cms-admin + When EmbargoMiddleware processes the request + Then it skips token validation and embargo assignment + """ + request = self._build_request( + path="/cms-admin/pages/123/edit/", + headers={"x-cms-auth": "Bearer validtoken"}, + ) + + get_response = mock.Mock(return_value={"ok": True}) + middleware = EmbargoMiddleware(get_response=get_response) + + response = middleware(request) + + assert response == {"ok": True} + spy_validate_token.assert_not_called() + spy_set_embargo_time.assert_not_called() + + @mock.patch(f"{MODULE_PATH}.set_embargo_time") + @mock.patch(f"{MODULE_PATH}.get_cms_auth_payload") + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + def test_returns_501_when_embargo_date_is_not_supported( + self, + spy_validate_token: mock.MagicMock, + spy_decode_token: mock.MagicMock, + spy_set_embargo_time: mock.MagicMock, + settings, + ): + """ + Given a valid token containing embargo_time while previews are disabled + When EmbargoMiddleware processes the request + Then it returns a 501 response indicating Embargo Date is unsupported + """ + request = self._build_request( + path="/api/pages/1/", + headers={"x-cms-auth": "Bearer validtoken"}, + ) + settings.PAGE_PREVIEWS_ENABLED = False + spy_validate_token.return_value = True + spy_decode_token.return_value = {"embargo_time": 1} + + middleware = EmbargoMiddleware(get_response=mock.Mock(return_value={})) + + response = middleware(request) + + assert response.status_code == 501 + assert json.loads(response.content) == { + "detail": '"Embargo Date" is not supported on this server.' + } + spy_set_embargo_time.assert_not_called() + + @mock.patch(f"{MODULE_PATH}.set_embargo_time", return_value=False) + @mock.patch(f"{MODULE_PATH}.get_cms_auth_payload") + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + def test_returns_401_when_embargo_time_is_rejected( + self, + spy_validate_token: mock.MagicMock, + spy_decode_token: mock.MagicMock, + spy_set_embargo_time: mock.MagicMock, + settings, + ): + """ + Given a valid token with embargo_time that is rejected by set_embargo_time + When EmbargoMiddleware processes the request + Then it returns an invalid-token 401 response + """ + request = self._build_request( + path="/api/pages/1/", + headers={"x-cms-auth": "Bearer validtoken"}, + ) + settings.PAGE_PREVIEWS_ENABLED = True + spy_validate_token.return_value = True + spy_decode_token.return_value = {"embargo_time": 1} + + middleware = EmbargoMiddleware(get_response=mock.Mock(return_value={})) + + response = middleware(request) + + self._assert_invalid_token_response(response) + spy_set_embargo_time.assert_called_once_with(1, token="validtoken") diff --git a/tests/unit/metrics/api/test_settings_preview_salt.py b/tests/unit/metrics/api/test_settings_preview_salt.py new file mode 100644 index 000000000..df6970876 --- /dev/null +++ b/tests/unit/metrics/api/test_settings_preview_salt.py @@ -0,0 +1,110 @@ +import importlib +import os +from unittest import mock + +import pytest + +import metrics.api.settings.default as default_settings + +FRONTEND_URL_ENV_VAR = "FRONTEND_URL" + + +class TestPagePreviewTokenSalt: + def test_build_page_previews_token_salt_is_stable_for_same_secret(self): + """ + Given a fixed SECRET_KEY + When page preview token salt is generated multiple times + Then the generated salt is stable and has the expected length + """ + # Given + secret_key = "stable-secret-key" + + first_salt = default_settings._build_page_previews_token_salt.__globals__[ + "SECRET_KEY" + ] + default_settings._build_page_previews_token_salt.__globals__["SECRET_KEY"] = ( + secret_key + ) + try: + # When + generated_salt = default_settings._build_page_previews_token_salt() + regenerated_salt = default_settings._build_page_previews_token_salt() + finally: + default_settings._build_page_previews_token_salt.__globals__[ + "SECRET_KEY" + ] = first_salt + + # Then + assert generated_salt == regenerated_salt + assert len(generated_salt) == 120 + + def test_build_page_previews_token_salt_changes_with_secret(self): + """ + Given two different SECRET_KEY values + When page preview token salt is generated for each value + Then each generated salt differs while preserving expected length + """ + # Given + original_secret = default_settings._build_page_previews_token_salt.__globals__[ + "SECRET_KEY" + ] + try: + default_settings._build_page_previews_token_salt.__globals__[ + "SECRET_KEY" + ] = "secret-one" + + # When + first_salt = default_settings._build_page_previews_token_salt() + + default_settings._build_page_previews_token_salt.__globals__[ + "SECRET_KEY" + ] = "secret-two" + second_salt = default_settings._build_page_previews_token_salt() + finally: + default_settings._build_page_previews_token_salt.__globals__[ + "SECRET_KEY" + ] = original_secret + + # Then + assert first_salt != second_salt + assert len(first_salt) == 120 + assert len(second_salt) == 120 + + +class TestPagePreviewFrontendBaseUrlSetting: + @mock.patch.dict( + os.environ, + {FRONTEND_URL_ENV_VAR: "https://preview-frontend.test"}, + clear=True, + ) + def test_uses_env_var_value_for_frontend_base_url(self): + """ + Given FRONTEND_URL is provided + When settings are reloaded + Then FRONTEND_URL is set from that env var + + Patches: + `os.environ`: Provides the required frontend base URL during settings reload. + """ + # Given + provided_base_url = "https://preview-frontend.test" + + # When + reloaded_settings = importlib.reload(default_settings) + + # Then + assert reloaded_settings.FRONTEND_URL == provided_base_url + + @mock.patch.dict(os.environ, {}, clear=True) + def test_defaults_to_localhost_when_frontend_base_url_env_var_missing(self): + """ + Given FRONTEND_URL is not provided + When settings are reloaded + Then FRONTEND_URL defaults to http://localhost:3000 + + Patches: + `os.environ`: Removes FRONTEND_URL before reloading settings. + """ + reloaded_settings = importlib.reload(default_settings) + + assert reloaded_settings.FRONTEND_URL == "http://localhost:3000" diff --git a/tests/unit/metrics/data/managers/core_models/test_time_series.py b/tests/unit/metrics/data/managers/core_models/test_time_series.py index 3087f6103..2551cf34b 100644 --- a/tests/unit/metrics/data/managers/core_models/test_time_series.py +++ b/tests/unit/metrics/data/managers/core_models/test_time_series.py @@ -1,9 +1,27 @@ from unittest import mock +import datetime + +import pytest from metrics.data.managers.core_models.time_series import CoreTimeSeriesManager class TestCoreTimeSeriesManager: + def test_query_for_data_raises_error_for_unexpected_kwargs(self): + """ + Given a query_for_data call that includes an unsupported keyword argument + When the manager attempts to execute the query + Then a TypeError is raised for the unexpected keyword argument + """ + # Given / When / Then + with pytest.raises(TypeError, match="unexpected keyword argument"): + CoreTimeSeriesManager().query_for_data( + topic="COVID-19", + metric="COVID-19_cases", + date_from=datetime.date(2023, 1, 1), + unsupported_field="value", + ) + @mock.patch.object(CoreTimeSeriesManager, "query_for_superseded_data") def test_delete_superseded_data( self, spy_query_for_superseded_data: mock.MagicMock diff --git a/validation/shared.py b/validation/shared.py index b4eeb86ba..0ea3783e4 100644 --- a/validation/shared.py +++ b/validation/shared.py @@ -1,3 +1,94 @@ +import logging +import typing as t + +from django.conf import settings +from django.core.signing import BadSignature, SignatureExpired, loads +from django.utils import timezone + +logger = logging.getLogger(__name__) + +CMS_AUTH_HEADER = "x-cms-auth" +CACHE_CONTROL_HEADER = "Cache-Control" +# we support only one level of disablement and no-store is the most resolute +CACHE_CONTROL_CACHE_DISABLED = "no-store" # must match exactly + + +def get_cms_auth_bearer_token( + headers: t.Mapping[str, str], +) -> str | None: + """Extract Bearer token from the x-cms-auth header. + + Returns None if the header is missing or not in Bearer format. + """ + auth_header = headers.get(CMS_AUTH_HEADER, "") + if not auth_header or not auth_header.lower().startswith("bearer "): + return None + + return auth_header.split(" ", 1)[1].strip() + + +def get_cms_auth_payload(token: str) -> dict | None: + """Decode CMS auth token payload. + + Returns None for invalid or malformed tokens. + """ + try: + payload = loads(token, salt=settings.PAGE_PREVIEWS_TOKEN_SALT) + except (BadSignature, SignatureExpired, ValueError, TypeError): + return None + + if not isinstance(payload, dict): + return None + + return payload + + +def validate_preview_hmac_payload( + payload: dict | None, *, page_id: int | None = None +) -> bool: + """Validate decoded CMS auth payload fields. + + Expects a decoded payload dict and checks required claims. + """ + if payload is None: + return False + + exp = payload.get("exp") + if exp is None or timezone.now().timestamp() > exp: + return False + + if page_id is not None: + payload_page_id = payload.get("page_id") + if payload_page_id is None or int(payload_page_id) != int(page_id): + return False + + return True + + +def validate_preview_hmac_token( + token: str, + *, + page_id: int | None = None, + include_payload: bool = False, +) -> bool | dict: + """ + Validate and decode a CMS auth token. Optionally checks page_id. + + Returns: + - bool by default + - decoded payload dict when include_payload=True and token is valid + """ + payload = get_cms_auth_payload(token) + is_valid = validate_preview_hmac_payload(payload, page_id=page_id) + if not is_valid: + return False + + if include_payload: + return payload or {} + + return True + + def format_child_and_parent_theme_name(name: str) -> str: """Naming of themes can sometimes use a `-` rather than `_` in their naming This formats these strings to ensure `-` is replaced with `_` for @@ -11,3 +102,26 @@ def format_child_and_parent_theme_name(name: str) -> str: """ return name.replace("-", "_").upper() + + +def get_cache_control_header( + headers: t.Mapping[str, str], +) -> str | None: + """Extract Cache-Control value from the header. + Returns None if the header is missing. + All elements in CACHE_CONTROL_HEADER must be matched + in request header + Example: CACHE_CONTROL_HEADER = 'private, no-cache' + cache_control_header = 'no-cache, private' + This will return True + + """ + cache_control_header = headers.get(CACHE_CONTROL_HEADER, "") + + required = {p.strip() for p in CACHE_CONTROL_CACHE_DISABLED.split(",")} + actual = {p.strip() for p in cache_control_header.split(",")} + + if not required.issubset(actual): + return None + + return cache_control_header From 2eb2ddb6ed0d2fb5aa26ec2794a6285a8b6bb160 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 09:24:35 +0000 Subject: [PATCH 2/2] task/CDD-1379-page-previews PR Review #3 - Apply auth to View Live - move shared.py to appropriate locations - django UTC timezone consistency in virtual_clock.py - remove redundant embargo context helper - tidy up typing import as it is used only once (from typing as t) - default to None if page previews not enabled - fix keyword args issue in virtual_clock.py - remove positional arg task/CDD-1379-page-previews update idna to 3.15 --- .../2026-02-27/CDD-1379.page-previews.md | 29 ++- .../js/embargo_time_preview_controls.js | 1 + cms/dashboard/views.py | 39 +++- cms/dashboard/viewsets.py | 2 +- cms/dashboard/wagtail_hooks.py | 81 +++----- common/page_previews.py | 86 ++++++++ common/request_caching.py | 29 +++ common/virtual_clock.py | 28 ++- metrics/api/middleware.py | 30 ++- requirements-prod.txt | 4 +- .../dashboard/test_preview_views_and_hooks.py | 147 ++++++++++---- .../unit/cms/dashboard/test_virtual_clock.py | 35 ++-- .../unit/cms/dashboard/test_wagtail_hooks.py | 184 +++++++++-------- .../unit/metrics/api/test_middleware copy.py | 70 ------- ...ddleware.py => test_middleware_embargo.py} | 6 +- ...iddleware_request_scoped_caching_config.py | 187 ++++++++++++++++++ validation/shared.py | 114 ----------- 17 files changed, 650 insertions(+), 422 deletions(-) create mode 100644 common/page_previews.py delete mode 100644 tests/unit/metrics/api/test_middleware copy.py rename tests/unit/metrics/api/{test_middleware.py => test_middleware_embargo.py} (98%) create mode 100644 tests/unit/metrics/api/test_middleware_request_scoped_caching_config.py diff --git a/changelog/2026-02-27/CDD-1379.page-previews.md b/changelog/2026-02-27/CDD-1379.page-previews.md index 6cb404c45..298e362cf 100644 --- a/changelog/2026-02-27/CDD-1379.page-previews.md +++ b/changelog/2026-02-27/CDD-1379.page-previews.md @@ -54,7 +54,7 @@ sequenceDiagram 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/preview-to-frontend/{page_id}?et= + Browser->>API: GET /admin/redirect-to-frontend/{page_id}?et= API-->>API: Build signed token (includes embargo_time in payload) API-->>Browser: 302 Location: /preview?slug=...&t=...&et= Browser->>FE: Follow 302 to frontend preview URL with et parameter @@ -87,7 +87,7 @@ This feature allows editors to preview content and data at a virtual point in ti - 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 `context` (broadly speaking, the thread) of the request. +- 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 @@ -121,6 +121,30 @@ sequenceDiagram ``` +### Dependency Diagram for Buttons Setup + +```mermaid +graph TD + wagtail_hooks.frontend_preview_button["wagtail_hooks.frontend_preview_button
(page header)
"] --> | and | wagtail_hooks.add_frontend_preview_action + wagtail_hooks.add_frontend_preview_action["wagtail_hooks.add_frontend_preview_action
(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 @@ -129,6 +153,7 @@ sequenceDiagram - **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 diff --git a/cms/dashboard/static/js/embargo_time_preview_controls.js b/cms/dashboard/static/js/embargo_time_preview_controls.js index 7e8cfd781..0de67aa10 100644 --- a/cms/dashboard/static/js/embargo_time_preview_controls.js +++ b/cms/dashboard/static/js/embargo_time_preview_controls.js @@ -42,6 +42,7 @@ document.addEventListener('DOMContentLoaded', function () { let url = baseUrl; const sep = url.includes('?') ? '&' : '?'; url += sep + 'embargo_time=' + encodeURIComponent(embargoTime || 'now'); + url += sep + 'route=preview' // preview route window.open(url, '_blank', 'noopener'); }); } diff --git a/cms/dashboard/views.py b/cms/dashboard/views.py index 14d821182..4c18b14ff 100644 --- a/cms/dashboard/views.py +++ b/cms/dashboard/views.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import Literal from urllib.parse import urlencode, urlsplit, urlunsplit from django.conf import settings @@ -39,7 +40,7 @@ def __init__(self) -> None: ) -class PreviewToFrontendRedirectView(View): +class FrontendRedirectView(View): """Generate a signed preview token and redirect to the frontend. This view is intentionally simple: it performs a permission check on the @@ -130,7 +131,7 @@ def validate_frontend_redirect_url(cls, *, frontend_url: str) -> str: return frontend_url @staticmethod - def build_preview_url( + def build_redirect_url( *, raw_url: str, slug: str, @@ -201,20 +202,28 @@ def build_frontend_route_url(cls, *, base_url: str, route_slug: str) -> str: return cls.validate_frontend_redirect_url(frontend_url=frontend_url) @classmethod - def build_frontend_preview_base_url(cls, *, base_url: str) -> str: - """Build and validate the frontend preview endpoint URL. + def build_frontend_base_url( + cls, *, base_url: str, route: Literal["preview", "nocache"] + ) -> str: + """Build and validate the frontend preview or + view live (nocache) endpoint URL. Args: base_url: Frontend base URL from settings. Returns: - str: Absolute frontend `/preview` endpoint URL. + str: Absolute frontend `/preview` or `/nocache` endpoint URL. Raises: ImproperlyConfigured: If the resulting URL is not on the trusted frontend host allow-list. """ - frontend_url = f"{base_url.rstrip('/')}/preview" + + if route not in {"preview", "nocache"}: + error_message = "route must be 'preview' or 'nocache'" + raise ValueError(error_message) + + frontend_url = f"{base_url.rstrip('/')}/{route}" return cls.validate_frontend_redirect_url(frontend_url=frontend_url) def get(self, request, pk): @@ -225,7 +234,8 @@ def get(self, request, pk): response. This blocks open-redirect scenarios where a hostile or incorrect URL could send CMS users to an untrusted domain. - Input handling summary for audit/review: + Input handling summary: + - `route` can be only 'preview' or 'nocache' and defaults to 'nocache' - `pk` arrives via a digits-only route and is resolved via `get_object_or_404`, so invalid IDs do not proceed. - `embargo_time` is treated as untrusted and parsed as either @@ -253,6 +263,14 @@ def get(self, request, pk): raise PermissionDenied embargo_time_value = request.GET.get("embargo_time") + route = request.GET.get("route", "nocache") + + if route not in {"preview", "nocache"}: + error_message = ( + "route querystring parameter must be either 'preview' or 'nocache'" + ) + raise ValueError(error_message) + parsed_embargo_time = None if embargo_time_value is not None: embargo_time_value = embargo_time_value.strip() @@ -283,10 +301,11 @@ def get(self, request, pk): route_slug = self.build_route_slug(page=page) - frontend_url = self.build_frontend_preview_base_url( - base_url=config.FRONTEND_URL + frontend_url = self.build_frontend_base_url( + base_url=config.FRONTEND_URL, route=route ) - frontend_url = self.build_preview_url( + + frontend_url = self.build_redirect_url( raw_url=frontend_url, slug=route_slug, token=token, diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index b0fcfb896..0196b2eb5 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -9,7 +9,7 @@ from caching.private_api.decorators import cache_response from cms.dashboard.serializers import ListablePageSerializer -from validation.shared import ( +from common.page_previews import ( get_cms_auth_bearer_token, get_cms_auth_payload, validate_preview_hmac_token, diff --git a/cms/dashboard/wagtail_hooks.py b/cms/dashboard/wagtail_hooks.py index 27859f067..816e766f0 100644 --- a/cms/dashboard/wagtail_hooks.py +++ b/cms/dashboard/wagtail_hooks.py @@ -3,7 +3,6 @@ from typing import Any from django.conf import settings -from django.core.exceptions import ImproperlyConfigured from django.core.handlers.wsgi import WSGIRequest from django.templatetags.static import static from django.urls import NoReverseMatch, re_path, reverse @@ -18,14 +17,15 @@ from wagtail.models import Page from wagtail.whitelist import check_url -from cms.dashboard.views import PreviewToFrontendRedirectView +from cms.dashboard.views import FrontendRedirectView VIEW_LIVE_LABEL = "View Live" PREVIEW_LABEL = "Preview" +ROUTES = {PREVIEW_LABEL: "preview", VIEW_LIVE_LABEL: "nocache"} logger = logging.getLogger(__name__) -class FrontendPreviewAction(ActionMenuItem): +class FrontendRedirectAction(ActionMenuItem): """Primary action-menu item that links editors to the frontend preview flow.""" name = "action-preview" @@ -60,20 +60,14 @@ def _get_preview_button_label(page: Page) -> str | None: return None -def _build_view_live_url(page: Page) -> str | None: - """Build the absolute frontend URL used by the View Live action.""" - base_url = getattr(settings, "FRONTEND_URL", "") - route_path = PreviewToFrontendRedirectView.build_route_slug(page=page) +def _build_frontend_redirect_url(page: Page, route: str) -> str | None: + """Build the admin redirect URL used by the top-level View Live action.""" try: - return PreviewToFrontendRedirectView.build_frontend_route_url( - base_url=base_url, - route_slug=route_path, - ) - except ImproperlyConfigured: - logger.exception( - "FRONTEND_URL is not a supported http(s) URL. Hiding View Live action" - ) - return None + return f"{reverse('redirect-to-frontend', args=[page.pk])}?route={route}" + except NoReverseMatch as e: + error_message = f"Could not reverse_lookup 'redirect-to-frontend'. Has this url been registered correctly in register_admin_urls? {e}" + logger.exception(error_message) + return None @hooks.register("insert_global_admin_css") @@ -190,27 +184,15 @@ def frontend_preview_button( if button_label is None: return [] - # If the label is 'View Live', use the route-style live URL - if button_label == VIEW_LIVE_LABEL: - live_url = _build_view_live_url(page=page) - if live_url: - return [ - Button( - label=button_label, - url=live_url, - priority=10, - attrs={"target": "_blank", "rel": "noopener noreferrer"}, - ) - ] - # Otherwise, use the preview redirect - admin_url = _build_frontend_preview_url(page=page) - if not admin_url: + url = _build_frontend_redirect_url(page=page, route=ROUTES[button_label]) + + if url is None: return [] return [ Button( label=button_label, - url=admin_url, + url=url, priority=10, attrs={"target": "_blank", "rel": "noopener noreferrer"}, ) @@ -256,16 +238,16 @@ def register_admin_urls(): """Register admin URLs for CMS dashboard views. We register an admin redirect endpoint - (`/admin/preview-to-frontend//`) that signs a short-lived + (`/admin/redirect-to-frontend//`) that signs a short-lived preview token and redirects the user to the external frontend. The redirect logic is implemented in `cms.dashboard.views`. """ return [ re_path( - r"^preview-to-frontend/(?P[0-9]+)/$", - PreviewToFrontendRedirectView.as_view(), - name="cms_preview_to_frontend", - ), + r"^redirect-to-frontend/(?P[0-9]+)/$", + FrontendRedirectView.as_view(), + name="redirect-to-frontend", + ) ] @@ -319,36 +301,19 @@ def add_frontend_preview_action( ) return - action_url = None - if button_label == VIEW_LIVE_LABEL: - action_url = _build_view_live_url(page=page) or _build_frontend_preview_url( - page=page - ) - elif button_label == PREVIEW_LABEL: - action_url = _build_frontend_preview_url(page=page) + action_url = _build_frontend_redirect_url(page=page, route=ROUTES[button_label]) if action_url: - preview_item = FrontendPreviewAction( + redirect_item = FrontendRedirectAction( url=action_url, label=button_label, order=0 ) - menu_items.insert(0, preview_item) + menu_items.insert(0, redirect_item) except (AttributeError, TypeError, ValueError, LookupError, RuntimeError): logger.debug( "Failed to construct frontend preview action; editor UI will continue" ) -def _build_frontend_preview_url(page: Page) -> str | None: - """Build the admin redirect URL used by the top-level Preview action.""" - try: - return reverse("cms_preview_to_frontend", args=[page.pk]) - except NoReverseMatch: - logger.debug( - "Preview admin URL cannot be reversed; preview actions will be hidden" - ) - return None - - def _replace_view_live_button_href(message_html: str, target_url: str) -> str: """Replace the first View live anchor href in a Wagtail flash message.""" return re.sub( @@ -391,7 +356,7 @@ def _rewrite_post_publish_view_live_button_url( if not getattr(page, "live", False): return - live_url = _build_view_live_url(page=page) + live_url = _build_frontend_redirect_url(page=page, route=ROUTES[VIEW_LIVE_LABEL]) if not live_url: return diff --git a/common/page_previews.py b/common/page_previews.py new file mode 100644 index 000000000..7854449da --- /dev/null +++ b/common/page_previews.py @@ -0,0 +1,86 @@ +import logging +import typing as t + +from django.conf import settings +from django.core.signing import BadSignature, SignatureExpired, loads +from django.utils import timezone + +logger = logging.getLogger(__name__) + +CMS_AUTH_HEADER = "x-cms-auth" + + +def get_cms_auth_bearer_token( + headers: t.Mapping[str, str], +) -> str | None: + """Extract Bearer token from the x-cms-auth header. + + Returns None if the header is missing or not in Bearer format. + """ + auth_header = headers.get(CMS_AUTH_HEADER, "") + if not auth_header or not auth_header.lower().startswith("bearer "): + return None + + return auth_header.split(" ", 1)[1].strip() + + +def get_cms_auth_payload(token: str) -> dict | None: + """Decode CMS auth token payload. + + Returns None for invalid or malformed tokens. + """ + try: + payload = loads(token, salt=settings.PAGE_PREVIEWS_TOKEN_SALT) + except (BadSignature, SignatureExpired, ValueError, TypeError): + return None + + if not isinstance(payload, dict): + return None + + return payload + + +def validate_preview_hmac_payload( + payload: dict | None, *, page_id: int | None = None +) -> bool: + """Validate decoded CMS auth payload fields. + + Expects a decoded payload dict and checks required claims. + """ + if payload is None: + return False + + exp = payload.get("exp") + if exp is None or timezone.now().timestamp() > exp: + return False + + if page_id is not None: + payload_page_id = payload.get("page_id") + if payload_page_id is None or int(payload_page_id) != int(page_id): + return False + + return True + + +def validate_preview_hmac_token( + token: str, + *, + page_id: int | None = None, + include_payload: bool = False, +) -> bool | dict: + """ + Validate and decode a CMS auth token. Optionally checks page_id. + + Returns: + - bool by default + - decoded payload dict when include_payload=True and token is valid + """ + payload = get_cms_auth_payload(token) + is_valid = validate_preview_hmac_payload(payload, page_id=page_id) + if not is_valid: + return False + + if include_payload: + return payload or {} + + return True diff --git a/common/request_caching.py b/common/request_caching.py index dfe8f66b8..6419df566 100644 --- a/common/request_caching.py +++ b/common/request_caching.py @@ -2,6 +2,12 @@ import contextvars import logging +import typing as t + +CACHE_CONTROL_HEADER = "Cache-Control" +# we support only one level of disablement and no-store is the most resolute +CACHE_CONTROL_CACHE_DISABLED = "no-store" # must match exactly + """ If set to True. _disable_request_caching_ctx indicates that we should disable caching for the duration of this request. @@ -30,3 +36,26 @@ def get_request_caching() -> bool | None: def clear_request_caching() -> None: """Clear the request caching for the current request context.""" _disable_request_caching_ctx.set(None) + + +def get_cache_control_header( + headers: t.Mapping[str, str], +) -> str | None: + """Extract Cache-Control value from the header. + Returns None if the header is missing. + All elements in CACHE_CONTROL_HEADER must be matched + in request header + Example: CACHE_CONTROL_HEADER = 'private, no-cache' + cache_control_header = 'no-cache, private' + This will return True + + """ + cache_control_header = headers.get(CACHE_CONTROL_HEADER, "") + + required = {p.strip() for p in CACHE_CONTROL_CACHE_DISABLED.split(",")} + actual = {p.strip() for p in cache_control_header.split(",")} + + if not required.issubset(actual): + return None + + return cache_control_header diff --git a/common/virtual_clock.py b/common/virtual_clock.py index d61928ba4..7aeb37801 100644 --- a/common/virtual_clock.py +++ b/common/virtual_clock.py @@ -7,17 +7,17 @@ """ import contextvars -import datetime import logging -import typing as t +from datetime import datetime +from typing import Any from django.conf import settings from django.utils import timezone -from validation.shared import validate_preview_hmac_token +from common.page_previews import validate_preview_hmac_token -_embargo_time_ctx: contextvars.ContextVar[datetime.datetime | None] = ( - contextvars.ContextVar("embargo_time", default=None) +_embargo_time_ctx: contextvars.ContextVar[datetime | None] = contextvars.ContextVar( + "embargo_time", default=None ) _logger = logging.getLogger(__name__) @@ -29,7 +29,7 @@ class EmbargoDateNotSupportedError(Exception): EMBARGO_DATE_NOT_SUPPORTED_MESSAGE = '"Embargo Date" is not supported on this server.' -def parse_embargo_time_value(embargo_time_value: t.Any) -> datetime.datetime | None: +def parse_embargo_time_value(embargo_time_value: Any) -> datetime | None: """Parse embargo time value into a timezone-aware datetime. Accepted values: @@ -52,19 +52,20 @@ def parse_embargo_time_value(embargo_time_value: t.Any) -> datetime.datetime | N return None try: - return datetime.datetime.fromtimestamp(epoch_seconds, tz=datetime.UTC) + dt = datetime.fromtimestamp(epoch_seconds) + return timezone.make_aware(dt, timezone.get_current_timezone()) except (OverflowError, OSError, ValueError): return None -def set_embargo_time(embargo_time_value: object, *, token: str) -> bool: +def set_embargo_time(*, embargo_time_value: object, token: str) -> bool: """Set embargo time for current request context after validation. The value must be either `now` or valid epoch seconds. """ page_previews_enabled = getattr(settings, "PAGE_PREVIEWS_ENABLED", False) if not page_previews_enabled: - _embargo_time_ctx.set(timezone.now()) + _embargo_time_ctx.set(None) _logger.error(EMBARGO_DATE_NOT_SUPPORTED_MESSAGE) raise EmbargoDateNotSupportedError(EMBARGO_DATE_NOT_SUPPORTED_MESSAGE) @@ -79,22 +80,17 @@ def set_embargo_time(embargo_time_value: object, *, token: str) -> bool: return True -def get_embargo_time() -> datetime.datetime: +def get_embargo_time() -> datetime: """Return the embargo_time for the current request context. Falls back to timezone.now(). """ embargo_time = _embargo_time_ctx.get() - if isinstance(embargo_time, datetime.datetime): + if isinstance(embargo_time, datetime): return embargo_time return timezone.now() -def get_embargo_time_context() -> contextvars.ContextVar[datetime.datetime | None]: - """Return the shared request-scoped embargo time context variable.""" - return _embargo_time_ctx - - def clear_embargo_time() -> None: """Clear the embargo_time for the current request context.""" _embargo_time_ctx.set(None) diff --git a/metrics/api/middleware.py b/metrics/api/middleware.py index d164b89a6..51137e461 100644 --- a/metrics/api/middleware.py +++ b/metrics/api/middleware.py @@ -23,15 +23,16 @@ from django.conf import settings from django.http import HttpRequest, HttpResponse, JsonResponse -from common.request_caching import disable_request_caching -from common.virtual_clock import EMBARGO_DATE_NOT_SUPPORTED_MESSAGE, set_embargo_time -from validation.shared import ( +from common.page_previews import ( CMS_AUTH_HEADER, - get_cache_control_header, get_cms_auth_bearer_token, get_cms_auth_payload, validate_preview_hmac_token, ) +from common.request_caching import disable_request_caching, get_cache_control_header +from common.virtual_clock import EMBARGO_DATE_NOT_SUPPORTED_MESSAGE, set_embargo_time + +INVALID_TOKEN_DETAIL = {"detail": "The token was invalid"} class EmbargoMiddleware: @@ -43,8 +44,6 @@ class EmbargoMiddleware: - Context is always cleared after the request completes. """ - INVALID_TOKEN_DETAIL = {"detail": "The token was invalid"} - def __init__(self, get_response): """Store the downstream callable for middleware execution.""" self.get_response = get_response @@ -78,12 +77,12 @@ def _set_embargo_time_if_header_is_valid( if token is None: has_auth_header = bool(request.headers.get(CMS_AUTH_HEADER, "")) if has_auth_header: - return JsonResponse(cls.INVALID_TOKEN_DETAIL, status=401) + return JsonResponse(INVALID_TOKEN_DETAIL, status=401) return None is_valid = validate_preview_hmac_token(token) if not is_valid: - return JsonResponse(cls.INVALID_TOKEN_DETAIL, status=401) + return JsonResponse(INVALID_TOKEN_DETAIL, status=401) payload = get_cms_auth_payload(token) or {} @@ -97,9 +96,9 @@ def _set_embargo_time_if_header_is_valid( status=501, ) - was_set = set_embargo_time(embargo_time, token=token) + was_set = set_embargo_time(embargo_time_value=embargo_time, token=token) if not was_set: - return JsonResponse(cls.INVALID_TOKEN_DETAIL, status=401) + return JsonResponse(INVALID_TOKEN_DETAIL, status=401) return None @@ -119,6 +118,17 @@ def __init__(self, get_response): def __call__(self, request: HttpRequest) -> HttpResponse: """Apply request_no_cache context for eligible API requests before dispatch.""" + token = get_cms_auth_bearer_token(request.headers) + + # exit if we don't have a token + if token is None: + return self.get_response(request) + + # response 401 (Unauthorized i.e. not authenticated) if token is invalid + is_valid = validate_preview_hmac_token(token) + if not is_valid: + return JsonResponse(INVALID_TOKEN_DETAIL, status=401) + if self._is_custom_api_request(request=request): self._set_no_cache_if_header_is_valid(request=request) return self.get_response(request) diff --git a/requirements-prod.txt b/requirements-prod.txt index dc82650d0..eecd16ede 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -31,7 +31,7 @@ grimp==3.14 gunicorn==25.3.0 html5lib==1.1 identify==2.6.19 -idna==3.13 +idna==3.15 importlib-metadata==9.0.0 inflection==0.5.1 iniconfig==2.3.0 @@ -83,7 +83,7 @@ tomli==2.4.1 typing_extensions==4.15.0 uritemplate==4.2.0 urllib3==2.7.0 -virtualenv==21.3.0 +virtualenv==21.3.1 wagtail==7.3.2 wagtail_trash==3.2.0 wagtail_modeladmin==2.3.0 diff --git a/tests/unit/cms/dashboard/test_preview_views_and_hooks.py b/tests/unit/cms/dashboard/test_preview_views_and_hooks.py index 73823bfb3..1dd98f502 100644 --- a/tests/unit/cms/dashboard/test_preview_views_and_hooks.py +++ b/tests/unit/cms/dashboard/test_preview_views_and_hooks.py @@ -17,7 +17,7 @@ InvalidPreviewFrontendUrlError, LinkBrowseView, MissingPreviewFrontendHostConfigurationError, - PreviewToFrontendRedirectView, + FrontendRedirectView, ) MODULE_PATH = "cms" @@ -73,7 +73,7 @@ def test_returns_none_for_unsupported_types(self, embargo_time_value): """ assert parse_embargo_time_value(embargo_time_value) is None - @mock.patch("common.virtual_clock.datetime.datetime") + @mock.patch("common.virtual_clock.datetime") def test_returns_none_when_timestamp_cannot_be_converted( self, spy_datetime_class: mock.MagicMock ): @@ -97,12 +97,12 @@ def test_success_redirects_passes_through_embargo_time_now( """ spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) request = RequestFactory().get( - "/cms-admin/preview-to-frontend/1/", + "/cms-admin/redirect-to-frontend/1/", data={"embargo_time": "now"}, ) request.user = type("U", (), {"is_authenticated": True, "pk": 5})() - view = PreviewToFrontendRedirectView() + view = FrontendRedirectView() config.FRONTEND_URL = "https://frontend.test" response = view.get(request=request, pk=1) location = ( @@ -123,12 +123,12 @@ def test_success_redirects_passes_through_embargo_time_epoch( """ spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) request = RequestFactory().get( - "/cms-admin/preview-to-frontend/1/", + "/cms-admin/redirect-to-frontend/1/", data={"embargo_time": "1711456200"}, ) request.user = type("U", (), {"is_authenticated": True, "pk": 5})() - view = PreviewToFrontendRedirectView() + view = FrontendRedirectView() config.FRONTEND_URL = "https://frontend.test" response = view.get(request=request, pk=1) location = ( @@ -149,12 +149,12 @@ def test_success_redirects_drops_blank_embargo_time( """ spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) request = RequestFactory().get( - "/cms-admin/preview-to-frontend/1/", + "/cms-admin/redirect-to-frontend/1/", data={"embargo_time": " "}, ) request.user = type("U", (), {"is_authenticated": True, "pk": 5})() - view = PreviewToFrontendRedirectView() + view = FrontendRedirectView() config.FRONTEND_URL = "https://frontend.test" response = view.get(request=request, pk=1) location = ( @@ -175,12 +175,12 @@ def test_success_redirects_drops_unparseable_embargo_time( """ spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) request = RequestFactory().get( - "/cms-admin/preview-to-frontend/1/", + "/cms-admin/redirect-to-frontend/1/", data={"embargo_time": "not-a-time"}, ) request.user = type("U", (), {"is_authenticated": True, "pk": 5})() - view = PreviewToFrontendRedirectView() + view = FrontendRedirectView() config.FRONTEND_URL = "https://frontend.test" response = view.get(request=request, pk=1) location = ( @@ -191,8 +191,8 @@ def test_success_redirects_drops_unparseable_embargo_time( assert "et" not in parsed_query -class TestAddFrontendPreviewActionExceptions: - @mock.patch("cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView") +class TestAddFrontendRedirectActionExceptions: + @mock.patch("cms.dashboard.wagtail_hooks.FrontendRedirectView") @mock.patch("cms.dashboard.wagtail_hooks.reverse", side_effect=NoReverseMatch) def test_no_reverse_match_hides_preview_action(self, mock_reverse, mock_view): """ @@ -335,7 +335,47 @@ def test_non_enabled_page_returns_empty(self): assert buttons == [] -class TestPreviewToFrontendRedirectView: +class TestFrontendRedirectView: + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") + def test_get_raises_error_for_unspported_routes(self, spy_get_object_or_404): + """ + Given an unsupported route querystring parameter + When get is called + Then ValueError must be raised + """ + + route = "hackerpath" + + spy_get_object_or_404.return_value = FakePage(pk=1, slug="s", can_edit=True) + + request = RequestFactory().get( + f"/cms-admin/redirect-to-frontend/1/?route={route}" + ) + request.user = type("U", (), {"is_authenticated": True, "pk": 5})() + view = FrontendRedirectView() + with pytest.raises(ValueError) as e: + view.get(request=request, pk=1) + + assert ( + str(e.value) + == "route querystring parameter must be either 'preview' or 'nocache'" + ) + + def test_build_frontend_base_url_raises_error_for_unspported_routes(self): + """ + Given an unsupported route hackerpath + When build_frontend_base_url is called + with an unsupported route e.g. hackerpath + Then ValueError must be raised + """ + base_url = "http://localhost:8000" + route = "hackerpath" + + with pytest.raises(ValueError) as e: + FrontendRedirectView.build_frontend_base_url(base_url=base_url, route=route) + + assert str(e.value) == "route must be 'preview' or 'nocache'" + def test_build_route_slug_uses_nested_page_path_when_available(self): """ Given get_url_parts returns a nested page path @@ -348,7 +388,7 @@ def test_build_route_slug_uses_nested_page_path_when_available(self): "https://frontend.test", "/respiratory-viruses/covid-19/", ) - route_slug = PreviewToFrontendRedirectView.build_route_slug(page=page) + route_slug = FrontendRedirectView.build_route_slug(page=page) assert route_slug == "respiratory-viruses/covid-19" @pytest.mark.parametrize("page_path", [None, "", "/", "///"]) @@ -361,7 +401,7 @@ def test_build_route_slug_falls_back_to_slug_when_path_is_empty(self, page_path) page = mock.MagicMock(slug="fallback-slug") page.get_url_parts.return_value = (1, "https://frontend.test", page_path) - route_slug = PreviewToFrontendRedirectView.build_route_slug(page=page) + route_slug = FrontendRedirectView.build_route_slug(page=page) assert route_slug == "fallback-slug" @@ -377,7 +417,7 @@ def test_build_route_slug_falls_back_to_slug_on_expected_exceptions( page = mock.MagicMock(slug="fallback-slug") page.get_url_parts.side_effect = exception - route_slug = PreviewToFrontendRedirectView.build_route_slug(page=page) + route_slug = FrontendRedirectView.build_route_slug(page=page) assert route_slug == "fallback-slug" @@ -390,30 +430,41 @@ def test_permission_denied(self, spy_get_object_or_404: mock.MagicMock): """ spy_get_object_or_404.return_value = FakePage(pk=1, slug="s", can_edit=False) - request = RequestFactory().get("/cms-admin/preview-to-frontend/1/") + request = RequestFactory().get("/cms-admin/redirect-to-frontend/1/") request.user = type("U", (), {"is_authenticated": True, "pk": 5})() - view = PreviewToFrontendRedirectView() + view = FrontendRedirectView() with pytest.raises(PermissionDenied): view.get(request=request, pk=1) + @pytest.mark.parametrize( + ("route", "expected"), + [ + ("preview", "https://frontend.test/preview/cover?t="), + ("nocache", "https://frontend.test/nocache/cover?t="), + ], + ) @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") - def test_success_redirects(self, spy_get_object_or_404: mock.MagicMock, settings): + def test_success_redirects( + self, spy_get_object_or_404: mock.MagicMock, route, expected + ): """ Given an editable page and a frontend preview base URL When the preview redirect view is requested Then the response redirects to the frontend preview URL with a token """ spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) - request = RequestFactory().get("/cms-admin/preview-to-frontend/1/") + request = RequestFactory().get( + f"/cms-admin/redirect-to-frontend/1/?route={route}" + ) request.user = type("U", (), {"is_authenticated": True, "pk": 5})() - view = PreviewToFrontendRedirectView() + view = FrontendRedirectView() config.FRONTEND_URL = "https://frontend.test" response = view.get(request=request, pk=1) location = ( response.url if hasattr(response, "url") else response.get("Location") ) - assert location.startswith("https://frontend.test/preview/cover?t=") + assert location.startswith(expected) @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") def test_success_redirects_with_nested_route_slug( @@ -431,10 +482,12 @@ def test_success_redirects_with_nested_route_slug( "/respiratory-viruses/covid-19/", ) spy_get_object_or_404.return_value = page - request = RequestFactory().get("/cms-admin/preview-to-frontend/1/") + request = RequestFactory().get( + "/cms-admin/redirect-to-frontend/1/?route=preview" + ) request.user = type("U", (), {"is_authenticated": True, "pk": 5})() - view = PreviewToFrontendRedirectView() + view = FrontendRedirectView() config.FRONTEND_URL = "https://frontend.test" response = view.get(request=request, pk=1) location = ( @@ -454,10 +507,10 @@ def test_success_redirects_appends_page_id_to_query( Then the redirect query includes slug, token, and page_id """ spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) - request = RequestFactory().get("/cms-admin/preview-to-frontend/1/") + request = RequestFactory().get("/cms-admin/redirect-to-frontend/1/") request.user = type("U", (), {"is_authenticated": True, "pk": 5})() - view = PreviewToFrontendRedirectView() + view = FrontendRedirectView() config.FRONTEND_URL = "https://frontend.test" response = view.get(request=request, pk=1) location = ( @@ -477,7 +530,7 @@ def test_validate_frontend_redirect_url_allows_configured_frontend_host( """ config.FRONTEND_URL = "https://frontend.test" - validated_url = PreviewToFrontendRedirectView.validate_frontend_redirect_url( + validated_url = FrontendRedirectView.validate_frontend_redirect_url( frontend_url="https://frontend.test/preview?slug=cover&t=signed-token" ) @@ -494,7 +547,7 @@ def test_validate_frontend_redirect_url_rejects_unconfigured_host(self, settings config.FRONTEND_URL = "https://frontend.test" with pytest.raises(ImproperlyConfigured): - PreviewToFrontendRedirectView.validate_frontend_redirect_url( + FrontendRedirectView.validate_frontend_redirect_url( frontend_url="https://malicious.test/preview?slug=cover&t=signed-token" ) @@ -507,7 +560,7 @@ def test_validate_frontend_redirect_url_rejects_non_absolute_url(self, settings) config.FRONTEND_URL = "https://frontend.test" with pytest.raises(ImproperlyConfigured): - PreviewToFrontendRedirectView.validate_frontend_redirect_url( + FrontendRedirectView.validate_frontend_redirect_url( frontend_url="/preview?slug=cover&t=signed-token" ) @@ -521,7 +574,7 @@ def test_validate_frontend_redirect_url_rejects_when_allow_list_is_empty( """ config.FRONTEND_URL = "" with pytest.raises(ImproperlyConfigured): - PreviewToFrontendRedirectView.validate_frontend_redirect_url( + FrontendRedirectView.validate_frontend_redirect_url( frontend_url="https://frontend.test/preview?slug=cover&t=signed-token" ) @@ -535,7 +588,7 @@ def test_build_frontend_route_url_includes_route_slug_and_validates_host( """ config.FRONTEND_URL = "https://frontend.test" - route_url = PreviewToFrontendRedirectView.build_frontend_route_url( + route_url = FrontendRedirectView.build_frontend_route_url( base_url="https://frontend.test", route_slug="respiratory-viruses/covid-19", ) @@ -552,27 +605,41 @@ def test_build_frontend_route_url_returns_base_root_when_slug_is_empty( """ config.FRONTEND_URL = "https://frontend.test" - route_url = PreviewToFrontendRedirectView.build_frontend_route_url( + route_url = FrontendRedirectView.build_frontend_route_url( base_url="https://frontend.test", route_slug="", ) assert route_url == "https://frontend.test/nocache" - def test_build_frontend_preview_base_url_appends_preview_path(self, settings): + def test_build_frontend_base_url_appends_preview_path(self, settings): """ Given a valid frontend base URL - When build_frontend_preview_base_url is called + When build_frontend_base_url is called Then it returns the validated frontend preview endpoint URL """ config.FRONTEND_URL = "https://frontend.test" - preview_url = PreviewToFrontendRedirectView.build_frontend_preview_base_url( - base_url="https://frontend.test" + preview_url = FrontendRedirectView.build_frontend_base_url( + base_url="https://frontend.test", route="preview" ) assert preview_url == "https://frontend.test/preview" + def test_build_frontend_base_url_appends_nocache_path(self, settings): + """ + Given a valid frontend base URL + When build_frontend_base_url is called + Then it returns the validated frontend preview endpoint URL + """ + config.FRONTEND_URL = "https://frontend.test" + + preview_url = FrontendRedirectView.build_frontend_base_url( + base_url="https://frontend.test", route="nocache" + ) + + assert preview_url == "https://frontend.test/nocache" + @mock.patch(f"{MODULE_PATH}.dashboard.views.get_object_or_404") def test_redirect_raises_when_base_url_is_not_absolute( self, spy_get_object_or_404: mock.MagicMock, settings @@ -583,17 +650,17 @@ def test_redirect_raises_when_base_url_is_not_absolute( Then an ImproperlyConfigured error is raised instead of redirecting """ spy_get_object_or_404.return_value = FakePage(pk=1, slug="cover", can_edit=True) - request = RequestFactory().get("/cms-admin/preview-to-frontend/1/") + request = RequestFactory().get("/cms-admin/redirect-to-frontend/1/") request.user = type("U", (), {"is_authenticated": True, "pk": 5})() - view = PreviewToFrontendRedirectView() + view = FrontendRedirectView() config.FRONTEND_URL = "" with pytest.raises(ImproperlyConfigured): view.get(request=request, pk=1) -class TestAddFrontendPreviewAction: +class TestAddFrontendRedirectAction: def test_missing_page_or_pk_returns_none(self): """ Given missing page context or an unsaved page without pk @@ -758,7 +825,7 @@ def test_only_enabled_page_types_get_preview_actions( ) spy_reverse.side_effect = ( - lambda name, args=None: f"/admin/preview-to-frontend/{args[0]}/" + lambda name, args=None: f"/admin/redirect-to-frontend/{args[0]}/" ) # When diff --git a/tests/unit/cms/dashboard/test_virtual_clock.py b/tests/unit/cms/dashboard/test_virtual_clock.py index e990f6393..be3248aab 100644 --- a/tests/unit/cms/dashboard/test_virtual_clock.py +++ b/tests/unit/cms/dashboard/test_virtual_clock.py @@ -57,7 +57,17 @@ def test_returns_none_for_unsupported_value_types(self, embargo_time_value): """ assert virtual_clock.parse_embargo_time_value(embargo_time_value) is None - @mock.patch("common.virtual_clock.datetime.datetime") + def test_boo(self): + """ + Given an epoch value that cannot be converted to a datetime + When parse_embargo_time_value is called + Then None is returned + """ + + result = virtual_clock.parse_embargo_time_value("1711456200") + assert isinstance(result, datetime.datetime) + + @mock.patch("common.virtual_clock.datetime") def test_returns_none_when_timestamp_cannot_be_converted( self, spy_datetime_class: mock.MagicMock ): @@ -69,9 +79,7 @@ def test_returns_none_when_timestamp_cannot_be_converted( spy_datetime_class.fromtimestamp.side_effect = OverflowError assert virtual_clock.parse_embargo_time_value("1711456200") is None - spy_datetime_class.fromtimestamp.assert_called_once_with( - 1711456200, tz=datetime.UTC - ) + spy_datetime_class.fromtimestamp.assert_called_once_with(1711456200) class TestSetEmbargoTime: @@ -93,7 +101,7 @@ def test_raises_when_page_previews_are_disabled( settings.PAGE_PREVIEWS_ENABLED = False with pytest.raises(virtual_clock.EmbargoDateNotSupportedError): - virtual_clock.set_embargo_time("now", token="token") + virtual_clock.set_embargo_time(embargo_time_value="now", token="token") assert virtual_clock.get_embargo_time() == expected spy_logger_error.assert_called_once_with( @@ -111,7 +119,7 @@ def test_returns_false_for_invalid_token( """ settings.PAGE_PREVIEWS_ENABLED = True - actual = virtual_clock.set_embargo_time("now", token="token") + actual = virtual_clock.set_embargo_time(embargo_time_value="now", token="token") assert actual is False spy_validate_preview_hmac_token.assert_called_once_with("token") @@ -127,7 +135,9 @@ def test_returns_false_for_unparseable_embargo_time( """ settings.PAGE_PREVIEWS_ENABLED = True - actual = virtual_clock.set_embargo_time("not-a-time", token="token") + actual = virtual_clock.set_embargo_time( + embargo_time_value="not-a-time", token="token" + ) assert actual is False spy_validate_preview_hmac_token.assert_called_once_with("token") @@ -144,7 +154,9 @@ def test_sets_embargo_time_for_valid_value( settings.PAGE_PREVIEWS_ENABLED = True expected = datetime.datetime.fromtimestamp(1711456200, tz=datetime.UTC) - actual = virtual_clock.set_embargo_time("1711456200", token="token") + actual = virtual_clock.set_embargo_time( + embargo_time_value="1711456200", token="token" + ) assert actual is True assert virtual_clock.get_embargo_time() == expected @@ -170,10 +182,3 @@ def test_get_embargo_time_falls_back_to_now_after_clear( actual = virtual_clock.get_embargo_time() assert actual == fallback - - -class TestEmbargoTimeContextAccessor: - def test_get_embargo_time_context_returns_shared_context_var(self): - context_var = virtual_clock.get_embargo_time_context() - - assert context_var is virtual_clock._embargo_time_ctx diff --git a/tests/unit/cms/dashboard/test_wagtail_hooks.py b/tests/unit/cms/dashboard/test_wagtail_hooks.py index 89007966a..7a3d11e53 100644 --- a/tests/unit/cms/dashboard/test_wagtail_hooks.py +++ b/tests/unit/cms/dashboard/test_wagtail_hooks.py @@ -50,12 +50,12 @@ def test_after_create_hook_calls_rewrite_helper(self, mock_rewrite): mock_rewrite.assert_called_once_with(request=request, page=page) - @patch("cms.dashboard.wagtail_hooks._build_view_live_url") + @patch("cms.dashboard.wagtail_hooks._build_frontend_redirect_url") @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=False) def test_rewrite_post_publish_view_live_button_url_returns_when_previews_disabled( self, _mock_settings, - mock_build_view_live_url, + mock_build_frontend_redirect_url, ): """ Given page previews are disabled @@ -69,14 +69,14 @@ def test_rewrite_post_publish_view_live_button_url_returns_when_previews_disable request=request, page=page ) - mock_build_view_live_url.assert_not_called() + mock_build_frontend_redirect_url.assert_not_called() - @patch("cms.dashboard.wagtail_hooks._build_view_live_url") + @patch("cms.dashboard.wagtail_hooks._build_frontend_redirect_url") @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) def test_rewrite_post_publish_view_live_button_url_returns_when_page_not_live( self, _mock_settings, - mock_build_view_live_url, + mock_build_frontend_redirect_url, ): """ Given previews are enabled but the page is not live @@ -90,18 +90,20 @@ def test_rewrite_post_publish_view_live_button_url_returns_when_page_not_live( request=request, page=page ) - mock_build_view_live_url.assert_not_called() + mock_build_frontend_redirect_url.assert_not_called() - @patch("cms.dashboard.wagtail_hooks._build_view_live_url", return_value=None) + @patch( + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", return_value=None + ) @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) def test_rewrite_post_publish_view_live_button_url_returns_when_no_live_url( self, _mock_settings, - mock_build_view_live_url, + mock_build_frontend_redirect_url, ): """ Given previews are enabled and the page is live - When _build_view_live_url returns no URL + When _build_frontend_redirect_url returns no URL Then the rewrite flow exits after attempting to build the live URL """ request = mock.Mock() @@ -111,17 +113,19 @@ def test_rewrite_post_publish_view_live_button_url_returns_when_no_live_url( request=request, page=page ) - mock_build_view_live_url.assert_called_once_with(page=page) + mock_build_frontend_redirect_url.assert_called_once_with( + page=page, route="nocache" + ) @patch( - "cms.dashboard.wagtail_hooks._build_view_live_url", + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", return_value="http://localhost:3000/x/", ) @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) def test_rewrite_post_publish_view_live_button_url_returns_when_no_message_storage( self, _mock_settings, - _mock_build_view_live_url, + _mock_build_frontend_redirect_url, ): """ Given previews are enabled and request has no message storage @@ -136,14 +140,14 @@ def test_rewrite_post_publish_view_live_button_url_returns_when_no_message_stora ) @patch( - "cms.dashboard.wagtail_hooks._build_view_live_url", + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", return_value="http://localhost:3000/x/", ) @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) def test_rewrite_post_publish_view_live_button_url_continues_when_messages_list_empty( self, _mock_settings, - _mock_build_view_live_url, + _mock_build_frontend_redirect_url, ): """ Given previews are enabled and message storage contains no messages @@ -159,14 +163,14 @@ def test_rewrite_post_publish_view_live_button_url_continues_when_messages_list_ ) @patch( - "cms.dashboard.wagtail_hooks._build_view_live_url", + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", return_value="http://localhost:3000/x/", ) @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) def test_rewrite_post_publish_view_live_button_url_continues_when_no_view_live_text( self, _mock_settings, - _mock_build_view_live_url, + _mock_build_frontend_redirect_url, ): """ Given previews are enabled and message text has no View live anchor @@ -217,34 +221,37 @@ def test_add_frontend_preview_action_logs_and_swallows_unexpected_error( @patch("cms.dashboard.wagtail_hooks.logger.exception") @patch( - "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_frontend_route_url", + "cms.dashboard.wagtail_hooks.FrontendRedirectView.build_frontend_route_url", side_effect=ImproperlyConfigured, ) - def test_build_view_live_url_invalid_base_returns_none( + def test_build_frontend_redirect_url_invalid_base_returns_none( self, _mock_route_url, spy_logger_exception ): """ Given FRONTEND_URL is missing or non-absolute - When _build_view_live_url is called + When _build_frontend_redirect_url is called Then None is returned to avoid generating a relative URL """ page = mock.Mock() page.slug = "access-our-data" - assert wagtail_hooks._build_view_live_url(page) is None + assert ( + wagtail_hooks._build_frontend_redirect_url(page=page, route="nocache") + is None + ) spy_logger_exception.assert_called_once() @patch("cms.dashboard.wagtail_hooks.reverse", return_value="/admin/preview/888") - def test__build_frontend_preview_url(self, mock_reverse): + def test__build_frontend_redirect_url(self, mock_reverse): """ Given a page, - When _build_frontend_preview_url is called, + When _build_frontend_redirect_url is called, Then it returns the preview URL from reverse. """ page = mock.Mock() page.pk = 888 - url = wagtail_hooks._build_frontend_preview_url(page=page) - assert url == "/admin/preview/888" + url = wagtail_hooks._build_frontend_redirect_url(page=page, route="preview") + assert url == "/admin/preview/888?route=preview" """ Given label 'View Live' and live_url is falsy, When frontend_preview_button is called and reverse raises NoReverseMatch, @@ -261,7 +268,7 @@ def test__build_frontend_preview_url(self, mock_reverse): return_value="View Live", ): with mock.patch( - "cms.dashboard.wagtail_hooks._build_view_live_url", + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", return_value=None, ): with mock.patch( @@ -269,7 +276,7 @@ def test__build_frontend_preview_url(self, mock_reverse): side_effect=NoReverseMatch(), ): with mock.patch( - "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_route_slug", + "cms.dashboard.wagtail_hooks.FrontendRedirectView.build_route_slug", return_value="sluggy", ): with mock.patch( @@ -282,11 +289,11 @@ def test__build_frontend_preview_url(self, mock_reverse): assert result == [] @patch( - "cms.dashboard.wagtail_hooks.FrontendPreviewAction", + "cms.dashboard.wagtail_hooks.FrontendRedirectAction", side_effect=lambda **kwargs: type("FPA", (), kwargs)(), ) @patch( - "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_route_slug", + "cms.dashboard.wagtail_hooks.FrontendRedirectView.build_route_slug", return_value="sluggy", ) @patch("cms.dashboard.wagtail_hooks.reverse", side_effect=NoReverseMatch()) @@ -319,7 +326,9 @@ def test_add_frontend_preview_action_preview_branch_no_reverse_match( side_effect=lambda **kwargs: type("Btn", (), kwargs)(), ) @patch("cms.dashboard.wagtail_hooks.reverse", return_value="/admin/preview/42") - @patch("cms.dashboard.wagtail_hooks._build_view_live_url", return_value=None) + @patch( + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", return_value=None + ) @patch( "cms.dashboard.wagtail_hooks._get_preview_button_label", return_value="View Live", @@ -330,16 +339,14 @@ def test_frontend_preview_button_view_live_fallback_preview( ): """ Given label 'View Live' and live_url is falsy, - When frontend_preview_button is called and reverse succeeds, - Then it returns a Button with the preview URL from reverse. + Then it returns no Button """ page = mock.Mock() page.pk = 42 result = wagtail_hooks.frontend_preview_button( page, user=None, next_url=None, view_name="edit" ) - assert len(result) == 1 - assert getattr(result[0], "url", None) == "/admin/preview/42" + assert len(result) == 0 @patch( "cms.dashboard.wagtail_hooks.Button", @@ -371,10 +378,10 @@ def test_frontend_preview_button_view_live_invalid_base_uses_preview_url( page, user=None, next_url=None, view_name="edit" ) assert len(result) == 1 - assert getattr(result[0], "url", None) == "/admin/preview/42" + assert getattr(result[0], "url", None) == "/admin/preview/42?route=nocache" @patch( - "cms.dashboard.wagtail_hooks.FrontendPreviewAction", + "cms.dashboard.wagtail_hooks.FrontendRedirectAction", side_effect=lambda **kwargs: type("FPA", (), kwargs)(), ) @patch("cms.dashboard.wagtail_hooks.reverse", return_value="/admin/preview/99") @@ -388,7 +395,7 @@ def test_add_frontend_preview_action_preview_branch( """ Given label 'Preview' and reverse succeeds, When add_frontend_preview_action is called, - Then it inserts a FrontendPreviewAction with the preview URL from reverse. + Then it inserts a FrontendRedirectAction with the preview URL from reverse. """ page = mock.Mock() page.pk = 99 @@ -398,10 +405,10 @@ def test_add_frontend_preview_action_preview_branch( menu_items, request=None, context=context ) assert len(menu_items) == 1 - assert getattr(menu_items[0], "url", None) == "/admin/preview/99" + assert getattr(menu_items[0], "url", None) == "/admin/preview/99?route=preview" @patch( - "cms.dashboard.wagtail_hooks.FrontendPreviewAction", + "cms.dashboard.wagtail_hooks.FrontendRedirectAction", side_effect=lambda **kwargs: type("FPA", (), kwargs)(), ) @patch( @@ -418,7 +425,7 @@ def test_add_frontend_preview_action_preview_branch_explicit( """ Given label 'Preview' and reverse succeeds, When add_frontend_preview_action is called, - Then it inserts a FrontendPreviewAction with the preview URL from reverse (explicit branch coverage). + Then it inserts a FrontendRedirectAction with the preview URL from reverse (explicit branch coverage). """ page = mock.Mock() page.pk = 77 @@ -428,18 +435,18 @@ def test_add_frontend_preview_action_preview_branch_explicit( menu_items, request=None, context=context ) assert len(menu_items) == 1 - assert getattr(menu_items[0], "url", None) == "/admin/preview/77" + assert getattr(menu_items[0], "url", None) == "/admin/preview/77?route=preview" # Reset menu_items before the next call to avoid accumulation menu_items = [] wagtail_hooks.add_frontend_preview_action( menu_items, request=None, context=context ) assert len(menu_items) == 1 - assert getattr(menu_items[0], "url", None) == "/admin/preview/99" + assert getattr(menu_items[0], "url", None) == "/admin/preview/99?route=preview" - @patch("cms.dashboard.wagtail_hooks._build_frontend_preview_url") + @patch("cms.dashboard.wagtail_hooks._build_frontend_redirect_url") @patch( - "cms.dashboard.wagtail_hooks.FrontendPreviewAction", + "cms.dashboard.wagtail_hooks.FrontendRedirectAction", side_effect=lambda **kwargs: type("FPA", (), kwargs)(), ) @patch( @@ -458,22 +465,23 @@ def test_add_frontend_preview_action_calls_url_builder_for_preview_label( page.pk = 123 menu_items = [] context = {"page": page} - mock_build_url.return_value = "/admin/preview/123" + mock_build_url.return_value = "/admin/preview/123?route=preview" wagtail_hooks.add_frontend_preview_action( menu_items, request=None, context=context ) - mock_build_url.assert_called_once_with(page=page) + mock_build_url.assert_called_once_with(page=page, route="preview") assert len(menu_items) == 1 - assert getattr(menu_items[0], "url", None) == "/admin/preview/123" + assert getattr(menu_items[0], "url", None) == "/admin/preview/123?route=preview" @patch( "cms.dashboard.wagtail_hooks.Button", side_effect=lambda **kwargs: type("Btn", (), kwargs)(), ) @patch( - "cms.dashboard.wagtail_hooks._build_view_live_url", return_value="https://live" + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", + return_value="https://live", ) @patch( "cms.dashboard.wagtail_hooks._get_preview_button_label", @@ -533,14 +541,16 @@ def test_get_preview_button_label_custom_preview_disabled(self): assert wagtail_hooks._get_preview_button_label(page) is None @patch( - "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_route_slug", + "cms.dashboard.wagtail_hooks.FrontendRedirectView.build_route_slug", return_value="sluggy", ) @patch( "cms.dashboard.wagtail_hooks.settings", ) @patch("cms.dashboard.wagtail_hooks.reverse", side_effect=NoReverseMatch()) - @patch("cms.dashboard.wagtail_hooks._build_view_live_url", return_value=None) + @patch( + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", return_value=None + ) @patch( "cms.dashboard.wagtail_hooks._get_preview_button_label", return_value="View Live", @@ -584,7 +594,7 @@ def test_add_frontend_preview_action_view_live_falsy_url(self): return_value="View Live", ): with mock.patch( - "cms.dashboard.wagtail_hooks._build_view_live_url", + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", return_value=None, ): wagtail_hooks.add_frontend_preview_action(menu_items, None, context) @@ -625,33 +635,43 @@ def test_get_preview_button_label_none(self): page.live = False assert wagtail_hooks._get_preview_button_label(page) is None - def test_build_view_live_url_exception_and_slug(self): + @patch( + "cms.dashboard.wagtail_hooks.reverse", + return_value="cms/admin/redirect-to-frontend", + ) + def test_build_redirect_url_exception_and_slug(self, mock_reverse): """ - Given a page where get_url_parts raises AttributeError, - When _build_view_live_url is called, - Then it returns slug url or None. + When _build_frontend_redirect_url is called, + Then it returns "redirect-to-frontend" reverse lookup + i.e. the api endpoint registered in register_admin_urls """ - config.FRONTEND_URL = "http://localhost:3000" page = mock.Mock() - page.get_url_parts.side_effect = AttributeError() page.slug = "sluggy" - build_view_live_url = wagtail_hooks._build_view_live_url(page) - assert build_view_live_url == "http://localhost:3000/nocache/sluggy" - page.slug = "" - build_view_live_url = wagtail_hooks._build_view_live_url(page) - assert build_view_live_url == "http://localhost:3000/nocache" + build_view_live_url = wagtail_hooks._build_frontend_redirect_url( + page=page, route="nocache" + ) + assert build_view_live_url == "cms/admin/redirect-to-frontend?route=nocache" - def test_build_view_live_url_success(self): + @patch( + "cms.dashboard.wagtail_hooks.reverse", + return_value="cms/admin/redirect-to-frontend", + ) + def test_build_frontend_redirect_url_success(self, mock_reverse): """ Given a page where get_url_parts returns a path, - When _build_view_live_url is called, + When _build_frontend_redirect_url is called, Then it returns the correct live URL using the path. """ - config.FRONTEND_URL = "http://localhost:3000" page = mock.Mock() - page.get_url_parts.return_value = ("http", "domain", "/foo/bar/") - view_live_url = wagtail_hooks._build_view_live_url(page) - assert view_live_url == "http://localhost:3000/nocache/foo/bar" + page.get_url_parts.return_value = ( + "http", + "domain", + "cms/admin/redirect-to-frontend", + ) + view_live_url = wagtail_hooks._build_frontend_redirect_url( + page=page, route="nocache" + ) + assert view_live_url == "cms/admin/redirect-to-frontend?route=nocache" def test_frontend_preview_button_label_none(self): """ @@ -687,7 +707,7 @@ def test_frontend_preview_button_view_live_no_url(self): return_value="View Live", ): with mock.patch( - "cms.dashboard.wagtail_hooks._build_view_live_url", + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", return_value=None, ): with mock.patch( @@ -699,7 +719,7 @@ def test_frontend_preview_button_view_live_no_url(self): FRONTEND_URL="https://frontend.test", ): with mock.patch( - "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_route_slug", + "cms.dashboard.wagtail_hooks.FrontendRedirectView.build_route_slug", return_value="sluggy", ): result = wagtail_hooks.frontend_preview_button( @@ -770,19 +790,19 @@ def test_replace_view_live_button_href_rewrites_only_view_live_anchor(self): assert 'target="_blank"' in result assert 'rel="noreferrer"' in result - @patch("cms.dashboard.wagtail_hooks._build_view_live_url") + @patch("cms.dashboard.wagtail_hooks._build_frontend_redirect_url") @patch("cms.dashboard.wagtail_hooks.settings", PAGE_PREVIEWS_ENABLED=True) def test_rewrite_post_publish_view_live_button_url_updates_message( self, _mock_settings, - mock_build_view_live_url, + mock_build_frontend_redirect_url, ): """ Given previews are enabled and a queued publish message contains View live When _rewrite_post_publish_view_live_button_url is called Then the backend live link is replaced with the frontend live link """ - mock_build_view_live_url.return_value = ( + mock_build_frontend_redirect_url.return_value = ( "http://localhost:3000/weather-health-alerts/" ) @@ -934,7 +954,7 @@ def disable_wagtail_hooks(): yield -class TestAddFrontendPreviewActionBranches: +class TestAddFrontendRedirectActionBranches: @pytest.mark.parametrize( "pk,reverse_side_effect,slug,expected_url", [ @@ -946,10 +966,10 @@ class TestAddFrontendPreviewActionBranches: "https://frontend.test/preview?slug=sluggy", ), # reverse returns preview url - (99, "/admin/preview/99", None, "/admin/preview/99"), + (99, "/admin/preview/99", None, "/admin/preview/99?route=preview"), # explicit branch coverage, two calls - (77, "/admin/preview/77", None, "/admin/preview/77"), - (99, "/admin/preview/99", None, "/admin/preview/99"), + (77, "/admin/preview/77", None, "/admin/preview/77?route=preview"), + (99, "/admin/preview/99", None, "/admin/preview/99?route=preview"), ], ) def test_add_frontend_preview_action_branches( @@ -978,7 +998,7 @@ def test_add_frontend_preview_action_branches( ): build_route_slug_patch = ( mock.patch( - "cms.dashboard.wagtail_hooks.PreviewToFrontendRedirectView.build_route_slug", + "cms.dashboard.wagtail_hooks.FrontendRedirectView.build_route_slug", return_value=slug, ) if slug @@ -994,11 +1014,11 @@ def fpa_mock(**kwargs): return obj with mock.patch( - "cms.dashboard.wagtail_hooks.FrontendPreviewAction", + "cms.dashboard.wagtail_hooks.FrontendRedirectAction", side_effect=fpa_mock, ): with mock.patch( - "cms.dashboard.wagtail_hooks._build_frontend_preview_url", + "cms.dashboard.wagtail_hooks._build_frontend_redirect_url", return_value=expected_url, ): wagtail_hooks.add_frontend_preview_action( @@ -1106,14 +1126,14 @@ def test_build_link_props_for_non_existent_page( assert link_props == expected_props -class TestFrontendPreviewAction: +class TestFrontendRedirectAction: def test_get_url_returns_url(self): """ - Given a FrontendPreviewAction instance with a URL, + Given a FrontendRedirectAction instance with a URL, When get_url is called, Then it returns the correct URL. """ - action = wagtail_hooks.FrontendPreviewAction( + action = wagtail_hooks.FrontendRedirectAction( url="/test-url/", label="Test", order=1 ) assert action.get_url(parent_context=None) == "/test-url/" diff --git a/tests/unit/metrics/api/test_middleware copy.py b/tests/unit/metrics/api/test_middleware copy.py deleted file mode 100644 index 494bd2351..000000000 --- a/tests/unit/metrics/api/test_middleware copy.py +++ /dev/null @@ -1,70 +0,0 @@ -import json -from unittest import mock - -from metrics.api.middleware import RequestScopedCachingConfigMiddleware - -MODULE_PATH = "metrics.api.middleware" - - -class TestRequestScopedCachingConfigMiddleware: - @staticmethod - def _build_request(*, path: str, headers: dict | None = None, path_info=None): - request = mock.MagicMock() - request.path = path - request.path_info = path if path_info is None else path_info - request.headers = headers or {} - return request - - @mock.patch(f"{MODULE_PATH}.disable_request_caching") - def test_disables_request_caching_when_invalid_header( - self, - spy_disable_request_caching: mock.MagicMock, - ): - """ - Given a request - When TestRequestScopedCachingConfigMiddleware processes the request - Then it must call disable_request_caching - """ - request = self._build_request( - path="/api/pages/1/", - path_info=object(), - ) - request.path = object() - - get_response = mock.Mock(return_value={"ok": True}) - middleware = RequestScopedCachingConfigMiddleware(get_response=get_response) - - response = middleware(request) - RequestScopedCachingConfigMiddleware._set_no_cache_if_header_is_valid( - request=request - ) - - assert response == {"ok": True} - assert spy_disable_request_caching.assert_called_once - - @mock.patch(f"{MODULE_PATH}.get_cache_control_header") - def test_disables_request_caching_when_valid_header( - self, mock_get_cache_control_header: mock.MagicMock - ): - """ - Given a request - When TestRequestScopedCachingConfigMiddleware processes the request - Then it must call disable_request_caching - """ - request = self._build_request( - path="/api/pages/1/", - path_info=object(), - ) - request.path = object() - mock_get_cache_control_header.return_value = "no-store" - - get_response = mock.Mock(return_value={"ok": True}) - middleware = RequestScopedCachingConfigMiddleware(get_response=get_response) - - response = middleware(request) - result = RequestScopedCachingConfigMiddleware._set_no_cache_if_header_is_valid( - request=request - ) - - assert response == {"ok": True} - assert result is None diff --git a/tests/unit/metrics/api/test_middleware.py b/tests/unit/metrics/api/test_middleware_embargo.py similarity index 98% rename from tests/unit/metrics/api/test_middleware.py rename to tests/unit/metrics/api/test_middleware_embargo.py index 176808c1b..763ad92dd 100644 --- a/tests/unit/metrics/api/test_middleware.py +++ b/tests/unit/metrics/api/test_middleware_embargo.py @@ -76,7 +76,7 @@ def test_sets_embargo_time_when_valid_draft_header_present( spy_validate_token.assert_called_once_with("validtoken") spy_set_embargo_time.assert_called_once_with( - 1, + embargo_time_value=1, token="validtoken", ) @@ -273,4 +273,6 @@ def test_returns_401_when_embargo_time_is_rejected( response = middleware(request) self._assert_invalid_token_response(response) - spy_set_embargo_time.assert_called_once_with(1, token="validtoken") + spy_set_embargo_time.assert_called_once_with( + embargo_time_value=1, token="validtoken" + ) diff --git a/tests/unit/metrics/api/test_middleware_request_scoped_caching_config.py b/tests/unit/metrics/api/test_middleware_request_scoped_caching_config.py new file mode 100644 index 000000000..b9afa0ed0 --- /dev/null +++ b/tests/unit/metrics/api/test_middleware_request_scoped_caching_config.py @@ -0,0 +1,187 @@ +import json +from unittest import mock + +from metrics.api.middleware import RequestScopedCachingConfigMiddleware +from django.http import JsonResponse + +MODULE_PATH = "metrics.api.middleware" + + +class TestRequestScopedCachingConfigMiddleware: + @staticmethod + def _build_request(*, path: str, headers: dict | None = None, path_info=None): + request = mock.MagicMock() + request.path = path + request.path_info = path if path_info is None else path_info + request.headers = headers or {} + return request + + @mock.patch(f"{MODULE_PATH}.get_cache_control_header") + @mock.patch(f"{MODULE_PATH}.disable_request_caching") + def test_set_cache_with_valid_cache_headers_should_disable_cache( + self, mock_get_cache_control_header, spy_disable_request_caching + ): + """ + Given valid Cache-Control headers + When attempt to disable cache + Then it should call disable_request_caching and return none + """ + + mock_get_cache_control_header.return_value = None + + request = self._build_request( + path="/api/pages/1/", + path_info=object(), + headers={"Cache-Control:": "no-store"}, + ) + + result = RequestScopedCachingConfigMiddleware._set_no_cache_if_header_is_valid( + request=request + ) + + spy_disable_request_caching.assert_called_once() + assert result is None + + @mock.patch(f"{MODULE_PATH}.get_cache_control_header") + def test_set_cache_with_no_cache_headers_should_return_none( + self, mock_get_cache_control_header + ): + """ + Given no Cache-Control headers + When attempt to disable cache + Then it should return none + """ + + mock_get_cache_control_header.return_value = None + + request = self._build_request( + path="/api/pages/1/", path_info=object(), headers={} + ) + + result = RequestScopedCachingConfigMiddleware._set_no_cache_if_header_is_valid( + request=request + ) + + assert result is None + + @mock.patch(f"{MODULE_PATH}.get_cms_auth_bearer_token") + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + @mock.patch( + f"{MODULE_PATH}.RequestScopedCachingConfigMiddleware._is_custom_api_request" + ) + @mock.patch( + f"{MODULE_PATH}.RequestScopedCachingConfigMiddleware._set_no_cache_if_header_is_valid" + ) + def test_enables_cache_if_valid_api_path( + self, + mock_get_cms_auth_bearer_token, + mock_validate_preview_hmac_token, + mock_is_custom_api_request, + mock_set_no_cache_if_header_is_valid, + ): + """ + Given valid API path + When RequestScopedCachingConfigMiddleware + Then it should disable the cache + """ + + mock_get_cms_auth_bearer_token.return_value = "valid token" + mock_validate_preview_hmac_token.return_value = True + mock_is_custom_api_request.return_value = True + + request = self._build_request( + path="/api/pages/1/", + path_info=object(), + headers={"x-cms-auth": "Bearer valid-HMAC-token"}, + ) + + get_response = mock.Mock(return_value={"ok": True}) + middleware = RequestScopedCachingConfigMiddleware(get_response=get_response) + response = middleware(request) + + assert response == {"ok": True} + mock_set_no_cache_if_header_is_valid.assert_called_once() + + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + @mock.patch( + f"{MODULE_PATH}.RequestScopedCachingConfigMiddleware._set_no_cache_if_header_is_valid" + ) + def test_is_custom_api_request_calls_set_no_cache( + self, mock_validate_preview_hmac_token, spy__set_no_cache_if_header_is_valid + ): + """ + Given a request + with valid token + with valid api path + When TestRequestScopedCachingConfigMiddleware + Then it must call _set_no_cache_if_header_is_valid + """ + + mock_validate_preview_hmac_token.return_value = True + + request = self._build_request( + path="/api/pages/1/", + path_info=object(), + headers={"x-cms-auth": "Bearer valid-HMAC-token"}, + ) + + request.path = object() + get_response = mock.Mock(return_value={"ok": True}) + middleware = RequestScopedCachingConfigMiddleware(get_response=get_response) + + # When + response = middleware(request) + + # Then + spy__set_no_cache_if_header_is_valid.assert_called_once + assert response == {"ok": True} + + @mock.patch(f"{MODULE_PATH}.validate_preview_hmac_token") + def test_invalid_token_returns_401(self, mock_validate_preview_hmac_token): + """ + Given a request + with invalid token + When TestRequestScopedCachingConfigMiddleware + Then it must return 401 (Unauthorized) + """ + + request = self._build_request( + path="/api/pages/1/", + path_info=object(), + headers={"x-cms-auth": "Bearer invalid-HMAC-token"}, + ) + + mock_validate_preview_hmac_token.return_value = False + + request.path = object() + get_response = mock.Mock(return_value={"ok": True}) + middleware = RequestScopedCachingConfigMiddleware(get_response=get_response) + + # When + response = middleware(request) + + # Then + assert response.status_code == 401 + assert isinstance(response, JsonResponse) + + def test_no_token_returns_ok(self): + """ + Given a request with no token + When TestRequestScopedCachingConfigMiddleware + Then it must return OK + """ + + # Given ruquest without header (no token) + request = self._build_request( + path="/api/pages/1/", + path_info=object(), + ) + request.path = object() + get_response = mock.Mock(return_value={"ok": True}) + middleware = RequestScopedCachingConfigMiddleware(get_response=get_response) + + # When + response = middleware(request) + + # Then + assert response == {"ok": True} diff --git a/validation/shared.py b/validation/shared.py index 0ea3783e4..b4eeb86ba 100644 --- a/validation/shared.py +++ b/validation/shared.py @@ -1,94 +1,3 @@ -import logging -import typing as t - -from django.conf import settings -from django.core.signing import BadSignature, SignatureExpired, loads -from django.utils import timezone - -logger = logging.getLogger(__name__) - -CMS_AUTH_HEADER = "x-cms-auth" -CACHE_CONTROL_HEADER = "Cache-Control" -# we support only one level of disablement and no-store is the most resolute -CACHE_CONTROL_CACHE_DISABLED = "no-store" # must match exactly - - -def get_cms_auth_bearer_token( - headers: t.Mapping[str, str], -) -> str | None: - """Extract Bearer token from the x-cms-auth header. - - Returns None if the header is missing or not in Bearer format. - """ - auth_header = headers.get(CMS_AUTH_HEADER, "") - if not auth_header or not auth_header.lower().startswith("bearer "): - return None - - return auth_header.split(" ", 1)[1].strip() - - -def get_cms_auth_payload(token: str) -> dict | None: - """Decode CMS auth token payload. - - Returns None for invalid or malformed tokens. - """ - try: - payload = loads(token, salt=settings.PAGE_PREVIEWS_TOKEN_SALT) - except (BadSignature, SignatureExpired, ValueError, TypeError): - return None - - if not isinstance(payload, dict): - return None - - return payload - - -def validate_preview_hmac_payload( - payload: dict | None, *, page_id: int | None = None -) -> bool: - """Validate decoded CMS auth payload fields. - - Expects a decoded payload dict and checks required claims. - """ - if payload is None: - return False - - exp = payload.get("exp") - if exp is None or timezone.now().timestamp() > exp: - return False - - if page_id is not None: - payload_page_id = payload.get("page_id") - if payload_page_id is None or int(payload_page_id) != int(page_id): - return False - - return True - - -def validate_preview_hmac_token( - token: str, - *, - page_id: int | None = None, - include_payload: bool = False, -) -> bool | dict: - """ - Validate and decode a CMS auth token. Optionally checks page_id. - - Returns: - - bool by default - - decoded payload dict when include_payload=True and token is valid - """ - payload = get_cms_auth_payload(token) - is_valid = validate_preview_hmac_payload(payload, page_id=page_id) - if not is_valid: - return False - - if include_payload: - return payload or {} - - return True - - def format_child_and_parent_theme_name(name: str) -> str: """Naming of themes can sometimes use a `-` rather than `_` in their naming This formats these strings to ensure `-` is replaced with `_` for @@ -102,26 +11,3 @@ def format_child_and_parent_theme_name(name: str) -> str: """ return name.replace("-", "_").upper() - - -def get_cache_control_header( - headers: t.Mapping[str, str], -) -> str | None: - """Extract Cache-Control value from the header. - Returns None if the header is missing. - All elements in CACHE_CONTROL_HEADER must be matched - in request header - Example: CACHE_CONTROL_HEADER = 'private, no-cache' - cache_control_header = 'no-cache, private' - This will return True - - """ - cache_control_header = headers.get(CACHE_CONTROL_HEADER, "") - - required = {p.strip() for p in CACHE_CONTROL_CACHE_DISABLED.split(",")} - actual = {p.strip() for p in cache_control_header.split(",")} - - if not required.issubset(actual): - return None - - return cache_control_header