281 lines
11 KiB
Python
281 lines
11 KiB
Python
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__
|
|
)
|