Move PreviewMixin to preview submodule

pull/12894/head
Matt Westcott 2025-02-17 23:48:22 +00:00
rodzic 204df71f40
commit f2ebae3343
2 zmienionych plików z 282 dodań i 277 usunięć

Wyświetl plik

@ -15,8 +15,6 @@ import functools
import logging
import posixpath
import uuid
from io import StringIO
from urllib.parse import urlsplit
from warnings import warn
from django import forms
@ -27,24 +25,19 @@ from django.contrib.contenttypes.models import ContentType
from django.core import checks
from django.core.exceptions import (
FieldDoesNotExist,
ImproperlyConfigured,
PermissionDenied,
ValidationError,
)
from django.core.handlers.base import BaseHandler
from django.core.handlers.wsgi import WSGIRequest
from django.db import models, transaction
from django.db.models import Q, Value
from django.db.models.expressions import OuterRef, Subquery
from django.db.models.functions import Concat, Substr
from django.dispatch import receiver
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseNotAllowed
from django.http.request import validate_host
from django.template.response import TemplateResponse
from django.urls import NoReverseMatch, reverse
from django.utils import timezone
from django.utils import translation as translation
from django.utils.cache import patch_cache_control
from django.utils.encoding import force_bytes, force_str
from django.utils.functional import Promise, cached_property
from django.utils.module_loading import import_string
@ -125,6 +118,7 @@ from .media import ( # noqa: F401
get_root_collection_id,
)
from .panels import CommentPanelPlaceholder, PanelPlaceholder
from .preview import PreviewableMixin
from .reference_index import ReferenceIndex # noqa: F401
from .revisions import ( # noqa: F401
PageRevisionsManager,
@ -297,276 +291,6 @@ class PageBase(models.base.ModelBase):
PAGE_MODEL_CLASSES.append(cls)
class PreviewableMixin:
"""A mixin that allows a model to have previews."""
def make_preview_request(
self, original_request=None, preview_mode=None, extra_request_attrs=None
):
"""
Simulate a request to this object, by constructing a fake HttpRequest object that is (as far
as possible) representative of a real request to this object's front-end URL, and invoking
serve_preview with that request (and the given preview_mode).
Used for previewing / moderation and any other place where we
want to display a view of this object in the admin interface without going through the regular
page routing logic.
If you pass in a real request object as original_request, additional information (e.g. client IP, cookies)
will be included in the dummy request.
"""
dummy_meta = self._get_dummy_headers(original_request)
request = WSGIRequest(dummy_meta)
# Add a flag to let middleware know that this is a dummy request.
request.is_dummy = True
if extra_request_attrs:
for k, v in extra_request_attrs.items():
setattr(request, k, v)
obj = self
# Build a custom django.core.handlers.BaseHandler subclass that invokes serve_preview as
# the eventual view function called at the end of the middleware chain, rather than going
# through the URL resolver
class Handler(BaseHandler):
def _get_response(self, request):
request.is_preview = True
request.preview_mode = preview_mode
response = obj.serve_preview(request, preview_mode)
if hasattr(response, "render") and callable(response.render):
response = response.render()
patch_cache_control(response, private=True)
return response
# Invoke this custom handler.
handler = Handler()
handler.load_middleware()
return handler.get_response(request)
def _get_fallback_hostname(self):
"""
Return a hostname that can be used on preview requests when the object has no
routable URL, or the real hostname is not valid according to ALLOWED_HOSTS.
"""
try:
hostname = settings.ALLOWED_HOSTS[0]
except IndexError:
# Django disallows empty ALLOWED_HOSTS outright when DEBUG=False, so we must
# have DEBUG=True. In this mode Django allows localhost amongst others.
return "localhost"
if hostname == "*":
# Any hostname is allowed
return "localhost"
# Hostnames beginning with a dot are domain wildcards such as ".example.com" -
# these allow example.com itself, so just strip the dot
return hostname.lstrip(".")
def _get_dummy_headers(self, original_request=None):
"""
Return a dict of META information to be included in a faked HttpRequest object to pass to
serve_preview.
"""
url = self._get_dummy_header_url(original_request)
if url:
url_info = urlsplit(url)
hostname = url_info.hostname
if not validate_host(
hostname,
settings.ALLOWED_HOSTS or [".localhost", "127.0.0.1", "[::1]"],
):
# The hostname is not valid according to ALLOWED_HOSTS - use a fallback
hostname = self._get_fallback_hostname()
path = url_info.path
port = url_info.port or (443 if url_info.scheme == "https" else 80)
scheme = url_info.scheme
else:
# Cannot determine a URL to this object - cobble together an arbitrary valid one
hostname = self._get_fallback_hostname()
path = "/"
port = 80
scheme = "http"
http_host = hostname
if port != (443 if scheme == "https" else 80):
http_host = f"{http_host}:{port}"
dummy_values = {
"REQUEST_METHOD": "GET",
"PATH_INFO": path,
"SERVER_NAME": hostname,
"SERVER_PORT": port,
"SERVER_PROTOCOL": "HTTP/1.1",
"HTTP_HOST": http_host,
"wsgi.version": (1, 0),
"wsgi.input": StringIO(),
"wsgi.errors": StringIO(),
"wsgi.url_scheme": scheme,
"wsgi.multithread": True,
"wsgi.multiprocess": True,
"wsgi.run_once": False,
}
# Add important values from the original request object, if it was provided.
HEADERS_FROM_ORIGINAL_REQUEST = [
"REMOTE_ADDR",
"HTTP_X_FORWARDED_FOR",
"HTTP_COOKIE",
"HTTP_USER_AGENT",
"HTTP_AUTHORIZATION",
"wsgi.version",
"wsgi.multithread",
"wsgi.multiprocess",
"wsgi.run_once",
]
if settings.SECURE_PROXY_SSL_HEADER:
HEADERS_FROM_ORIGINAL_REQUEST.append(settings.SECURE_PROXY_SSL_HEADER[0])
if original_request:
for header in HEADERS_FROM_ORIGINAL_REQUEST:
if header in original_request.META:
dummy_values[header] = original_request.META[header]
return dummy_values
def _get_dummy_header_url(self, original_request=None):
"""
Return the URL that _get_dummy_headers() should use to set META headers
for the faked HttpRequest.
"""
return self.full_url
def get_full_url(self):
return None
full_url = property(get_full_url)
DEFAULT_PREVIEW_MODES = [("", _("Default"))]
DEFAULT_PREVIEW_SIZES = [
{
"name": "mobile",
"icon": "mobile-alt",
"device_width": 375,
"label": _("Preview in mobile size"),
},
{
"name": "tablet",
"icon": "tablet-alt",
"device_width": 768,
"label": _("Preview in tablet size"),
},
{
"name": "desktop",
"icon": "desktop",
"device_width": 1280,
"label": _("Preview in desktop size"),
},
]
@property
def preview_modes(self):
"""
A list of ``(internal_name, display_name)`` tuples for the modes in which
this object can be displayed for preview/moderation purposes. Ordinarily an object
will only have one display mode, but subclasses can override this -
for example, a page containing a form might have a default view of the form,
and a post-submission 'thank you' page.
Set to ``[]`` to completely disable previewing for this model.
"""
return PreviewableMixin.DEFAULT_PREVIEW_MODES
@property
def default_preview_mode(self):
"""
The default preview mode to use in live preview.
This default is also used in areas that do not give the user the option of selecting a
mode explicitly, e.g. in the moderator approval workflow.
If ``preview_modes`` is empty, an ``IndexError`` will be raised.
"""
return self.preview_modes[0][0]
@property
def preview_sizes(self):
"""
A list of dictionaries, each representing a preview size option for this object.
Override this property to customize the preview sizes.
Each dictionary in the list should include the following keys:
- ``name``: A string representing the internal name of the preview size.
- ``icon``: A string specifying the icon's name for the preview size button.
- ``device_width``: An integer indicating the device's width in pixels.
- ``label``: A string for the aria label on the preview size button.
.. code-block:: python
@property
def preview_sizes(self):
return [
{
"name": "mobile",
"icon": "mobile-icon",
"device_width": 320,
"label": "Preview in mobile size"
},
# Add more preview size dictionaries as needed.
]
"""
return PreviewableMixin.DEFAULT_PREVIEW_SIZES
@property
def default_preview_size(self):
"""
The default preview size name to use in live preview.
Defaults to ``"mobile"``, which is the first one defined in ``preview_sizes``.
If ``preview_sizes`` is empty, an ``IndexError`` will be raised.
"""
return self.preview_sizes[0]["name"]
def is_previewable(self):
"""Returns ``True`` if at least one preview mode is specified in ``preview_modes``."""
return bool(self.preview_modes)
def serve_preview(self, request, mode_name):
"""
Returns an HTTP response for use in object previews.
This method can be overridden to implement custom rendering and/or
routing logic.
Any templates rendered during this process should use the ``request``
object passed here - this ensures that ``request.user`` and other
properties are set appropriately for the wagtail user bar to be
displayed/hidden. This request will always be a GET.
"""
return TemplateResponse(
request,
self.get_preview_template(request, mode_name),
self.get_preview_context(request, mode_name),
)
def get_preview_context(self, request, mode_name):
"""
Returns a context dictionary for use in templates for previewing this object.
"""
return {"object": self, "request": request}
def get_preview_template(self, request, mode_name):
"""
Returns a template to be used when previewing this object.
Subclasses of ``PreviewableMixin`` must override this method to return the
template name to be used in the preview. Alternatively, subclasses can also
override the ``serve_preview`` method to completely customise the preview
rendering logic.
"""
raise ImproperlyConfigured(
"%s (subclass of PreviewableMixin) must override get_preview_template or serve_preview"
% type(self).__name__
)
class LockableMixin(models.Model):
locked = models.BooleanField(
verbose_name=_("locked"), default=False, editable=False

Wyświetl plik

@ -0,0 +1,281 @@
from io import StringIO
from urllib.parse import urlsplit
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.handlers.base import BaseHandler
from django.core.handlers.wsgi import WSGIRequest
from django.http.request import validate_host
from django.template.response import TemplateResponse
from django.utils.cache import patch_cache_control
from django.utils.translation import gettext_lazy as _
class PreviewableMixin:
"""A mixin that allows a model to have previews."""
def make_preview_request(
self, original_request=None, preview_mode=None, extra_request_attrs=None
):
"""
Simulate a request to this object, by constructing a fake HttpRequest object that is (as far
as possible) representative of a real request to this object's front-end URL, and invoking
serve_preview with that request (and the given preview_mode).
Used for previewing / moderation and any other place where we
want to display a view of this object in the admin interface without going through the regular
page routing logic.
If you pass in a real request object as original_request, additional information (e.g. client IP, cookies)
will be included in the dummy request.
"""
dummy_meta = self._get_dummy_headers(original_request)
request = WSGIRequest(dummy_meta)
# Add a flag to let middleware know that this is a dummy request.
request.is_dummy = True
if extra_request_attrs:
for k, v in extra_request_attrs.items():
setattr(request, k, v)
obj = self
# Build a custom django.core.handlers.BaseHandler subclass that invokes serve_preview as
# the eventual view function called at the end of the middleware chain, rather than going
# through the URL resolver
class Handler(BaseHandler):
def _get_response(self, request):
request.is_preview = True
request.preview_mode = preview_mode
response = obj.serve_preview(request, preview_mode)
if hasattr(response, "render") and callable(response.render):
response = response.render()
patch_cache_control(response, private=True)
return response
# Invoke this custom handler.
handler = Handler()
handler.load_middleware()
return handler.get_response(request)
def _get_fallback_hostname(self):
"""
Return a hostname that can be used on preview requests when the object has no
routable URL, or the real hostname is not valid according to ALLOWED_HOSTS.
"""
try:
hostname = settings.ALLOWED_HOSTS[0]
except IndexError:
# Django disallows empty ALLOWED_HOSTS outright when DEBUG=False, so we must
# have DEBUG=True. In this mode Django allows localhost amongst others.
return "localhost"
if hostname == "*":
# Any hostname is allowed
return "localhost"
# Hostnames beginning with a dot are domain wildcards such as ".example.com" -
# these allow example.com itself, so just strip the dot
return hostname.lstrip(".")
def _get_dummy_headers(self, original_request=None):
"""
Return a dict of META information to be included in a faked HttpRequest object to pass to
serve_preview.
"""
url = self._get_dummy_header_url(original_request)
if url:
url_info = urlsplit(url)
hostname = url_info.hostname
if not validate_host(
hostname,
settings.ALLOWED_HOSTS or [".localhost", "127.0.0.1", "[::1]"],
):
# The hostname is not valid according to ALLOWED_HOSTS - use a fallback
hostname = self._get_fallback_hostname()
path = url_info.path
port = url_info.port or (443 if url_info.scheme == "https" else 80)
scheme = url_info.scheme
else:
# Cannot determine a URL to this object - cobble together an arbitrary valid one
hostname = self._get_fallback_hostname()
path = "/"
port = 80
scheme = "http"
http_host = hostname
if port != (443 if scheme == "https" else 80):
http_host = f"{http_host}:{port}"
dummy_values = {
"REQUEST_METHOD": "GET",
"PATH_INFO": path,
"SERVER_NAME": hostname,
"SERVER_PORT": port,
"SERVER_PROTOCOL": "HTTP/1.1",
"HTTP_HOST": http_host,
"wsgi.version": (1, 0),
"wsgi.input": StringIO(),
"wsgi.errors": StringIO(),
"wsgi.url_scheme": scheme,
"wsgi.multithread": True,
"wsgi.multiprocess": True,
"wsgi.run_once": False,
}
# Add important values from the original request object, if it was provided.
HEADERS_FROM_ORIGINAL_REQUEST = [
"REMOTE_ADDR",
"HTTP_X_FORWARDED_FOR",
"HTTP_COOKIE",
"HTTP_USER_AGENT",
"HTTP_AUTHORIZATION",
"wsgi.version",
"wsgi.multithread",
"wsgi.multiprocess",
"wsgi.run_once",
]
if settings.SECURE_PROXY_SSL_HEADER:
HEADERS_FROM_ORIGINAL_REQUEST.append(settings.SECURE_PROXY_SSL_HEADER[0])
if original_request:
for header in HEADERS_FROM_ORIGINAL_REQUEST:
if header in original_request.META:
dummy_values[header] = original_request.META[header]
return dummy_values
def _get_dummy_header_url(self, original_request=None):
"""
Return the URL that _get_dummy_headers() should use to set META headers
for the faked HttpRequest.
"""
return self.full_url
def get_full_url(self):
return None
full_url = property(get_full_url)
DEFAULT_PREVIEW_MODES = [("", _("Default"))]
DEFAULT_PREVIEW_SIZES = [
{
"name": "mobile",
"icon": "mobile-alt",
"device_width": 375,
"label": _("Preview in mobile size"),
},
{
"name": "tablet",
"icon": "tablet-alt",
"device_width": 768,
"label": _("Preview in tablet size"),
},
{
"name": "desktop",
"icon": "desktop",
"device_width": 1280,
"label": _("Preview in desktop size"),
},
]
@property
def preview_modes(self):
"""
A list of ``(internal_name, display_name)`` tuples for the modes in which
this object can be displayed for preview/moderation purposes. Ordinarily an object
will only have one display mode, but subclasses can override this -
for example, a page containing a form might have a default view of the form,
and a post-submission 'thank you' page.
Set to ``[]`` to completely disable previewing for this model.
"""
return PreviewableMixin.DEFAULT_PREVIEW_MODES
@property
def default_preview_mode(self):
"""
The default preview mode to use in live preview.
This default is also used in areas that do not give the user the option of selecting a
mode explicitly, e.g. in the moderator approval workflow.
If ``preview_modes`` is empty, an ``IndexError`` will be raised.
"""
return self.preview_modes[0][0]
@property
def preview_sizes(self):
"""
A list of dictionaries, each representing a preview size option for this object.
Override this property to customize the preview sizes.
Each dictionary in the list should include the following keys:
- ``name``: A string representing the internal name of the preview size.
- ``icon``: A string specifying the icon's name for the preview size button.
- ``device_width``: An integer indicating the device's width in pixels.
- ``label``: A string for the aria label on the preview size button.
.. code-block:: python
@property
def preview_sizes(self):
return [
{
"name": "mobile",
"icon": "mobile-icon",
"device_width": 320,
"label": "Preview in mobile size"
},
# Add more preview size dictionaries as needed.
]
"""
return PreviewableMixin.DEFAULT_PREVIEW_SIZES
@property
def default_preview_size(self):
"""
The default preview size name to use in live preview.
Defaults to ``"mobile"``, which is the first one defined in ``preview_sizes``.
If ``preview_sizes`` is empty, an ``IndexError`` will be raised.
"""
return self.preview_sizes[0]["name"]
def is_previewable(self):
"""Returns ``True`` if at least one preview mode is specified in ``preview_modes``."""
return bool(self.preview_modes)
def serve_preview(self, request, mode_name):
"""
Returns an HTTP response for use in object previews.
This method can be overridden to implement custom rendering and/or
routing logic.
Any templates rendered during this process should use the ``request``
object passed here - this ensures that ``request.user`` and other
properties are set appropriately for the wagtail user bar to be
displayed/hidden. This request will always be a GET.
"""
return TemplateResponse(
request,
self.get_preview_template(request, mode_name),
self.get_preview_context(request, mode_name),
)
def get_preview_context(self, request, mode_name):
"""
Returns a context dictionary for use in templates for previewing this object.
"""
return {"object": self, "request": request}
def get_preview_template(self, request, mode_name):
"""
Returns a template to be used when previewing this object.
Subclasses of ``PreviewableMixin`` must override this method to return the
template name to be used in the preview. Alternatively, subclasses can also
override the ``serve_preview`` method to completely customise the preview
rendering logic.
"""
raise ImproperlyConfigured(
"%s (subclass of PreviewableMixin) must override get_preview_template or serve_preview"
% type(self).__name__
)