Source code for shuup.xtheme.resources

import os
import re
from logging import getLogger
from typing import TYPE_CHECKING

import six
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _

from shuup.apps.provides import get_provide_objects
from shuup.compat import contextfunction
from shuup.core import cache
from shuup.core.fields import TaggedJSONEncoder
from shuup.core.shop_provider import get_shop
from shuup.utils.django_compat import force_text
from shuup.xtheme.utils import get_html_attrs

if TYPE_CHECKING:  # pragma: no cover
    from shuup.xtheme.models import Snippet

LOGGER = getLogger(__name__)

LOCATION_INFO = {
    "head_end": {
        "name": _("End of head"),
        "regex": re.compile(r"</head>", re.I),
        "placement": "pre",
    },
    "head_start": {
        "name": _("Start of head"),
        "regex": re.compile(r"<head[^>]*>", re.I),
        "placement": "post",
    },
    "body_end": {
        "name": _("End of body"),
        "regex": re.compile(r"</body>", re.I),
        "placement": "pre",
    },
    "body_start": {
        "name": _("Start of body"),
        "regex": re.compile(r"<body[^>]*>", re.I),
        "placement": "post",
    },
    "content_start": {
        "name": _("Content start"),
        "regex": re.compile(r"^.*", re.I),
        "placement": "pre",
    },
    "content_end": {
        "name": _("Content end"),
        "regex": re.compile(r".*$", re.I),
        "placement": "post",
    },
}

KNOWN_LOCATIONS = set(LOCATION_INFO.keys())

RESOURCE_CONTAINER_VAR_NAME = "_xtheme_resources"
GLOBAL_SNIPPETS_CACHE_KEY = "global_snippets_{shop_id}"


[docs] class InlineScriptResource(six.text_type): """ An inline script resource (a subclass of string). The contents are rendered inside a ``<script>`` tag. """
[docs] @classmethod def from_vars(cls, var_name, *args, **kwargs): """ Create an InlineScriptResource assigning an object of variables into a name in the `window` scope. Aside from ``var_name`` the signature of this function is similar to that of ``dict``. Useful for configuration options, etc. :param var_name: The variable to add into global scope. :type var_name: str :return: An `InlineScriptResource` object. :rtype: InlineScriptResource """ ns = dict(*args, **kwargs) return cls(f"window.{var_name} = {TaggedJSONEncoder().encode(ns)};")
[docs] class JinjaMarkupResource: """ A Jinja markup resource. """
[docs] def __init__(self, template, context): self.template = template self.context = context
def __str__(self): return self.template
[docs] def render(self): template = force_text(self.template) if not template: return template from django.template import engines for engine_name in engines: engine = engines[engine_name] try: return engine.env.from_string(template).render(self.context) except Exception: LOGGER.exception("Error! Failed to render Jinja string in Snippet plugin.") return force_text(_("(Error while rendering.)"))
def __eq__(self, other): return self.render() == other
[docs] class InlineMarkupResource(six.text_type): """ An inline markup resource (a subclass of string). The contents are rendered as-is. """
[docs] class InlineStyleResource(six.text_type): """ An inline style resource (a subclass of string). """
[docs] class ResourceContainer: """ ResourceContainers deal with storing and rendering injected resources. A ResourceContainer is injected into rendering contexts by `~shuup.xtheme.engine.XthemeTemplate` (akin to how `django-jinja`'s Template injects `request` and `csrf_token`). """
[docs] def __init__(self): self.resources = {}
[docs] def add_resource(self, location, resource): """ Add a resource into the given location. Duplicate resources are ignored (and false is returned). Resource injection order is retained. :param location: The name of the location. See KNOWN_LOCATIONS. :type location: str :param resource: The actual resource. Either an URL string or one of the inline resource classes. :type resource: str|InlineMarkupResource|InlineScriptResource :return: Success flag. :rtype: bool """ if not resource: return False if location not in KNOWN_LOCATIONS: raise ValueError(f"Error! `{location!r}` is not a known xtheme resource location.") lst = self.resources.setdefault(location, []) if resource not in lst: lst.append(resource) return True return False
[docs] def render_resources(self, location, clean=True): """ Render the resources for the given location, then (by default) clean that list of resources. :param location: The name of the location. See `KNOWN_LOCATIONS`. :type location: str :param clean: Whether or not to clean up the list of resources. :type clean: bool :return: String of HTML. """ lst = self.resources.get(location) if not lst: return "" content = "".join(self._render_resource(resource) for resource in lst) if clean: # pragma: no branch self.resources.pop(location, None) return mark_safe(content)
def _render_resource(self, resource): """ Render a single resource. :param resource: The resource. :type resource: str|InlineMarkupResource|InlineScriptResource :return: String of HTML. """ if not resource: # pragma: no cover return "" if isinstance(resource, JinjaMarkupResource): return resource.render() if isinstance(resource, InlineMarkupResource): return force_text(resource) if isinstance(resource, InlineStyleResource): return f'<style type="text/css">{resource}</style>' if isinstance(resource, InlineScriptResource): return f"<script>{resource}</script>" resource = force_text(resource) from six.moves.urllib.parse import urlparse file_path = urlparse(resource) file_name = os.path.basename(file_path.path) if file_name.endswith(".js"): return "<script{}></script>".format(get_html_attrs({"src": resource})) if file_name.endswith(".css"): return "<link{}>".format(get_html_attrs({"href": resource, "rel": "stylesheet"})) return f"<!-- (unknown resource type: {escape(resource)}) -->"
[docs] @contextfunction def inject_resources(context, content, clean=True): """ Inject all the resources in the context's `ResourceContainer` into appropriate places in the content given. :param context: Rendering context. :type context: jinja2.runtime.Context :param content: HTML content. :type content: str :param clean: Clean the resource container as we go? :type clean: bool :return: Possibly modified HTML content. :rtype: str """ rc = get_resource_container(context) if not rc: # No resource container? Well, whatever. return content for location_name, location in LOCATION_INFO.items(): if not rc.resources.get(location_name): continue injection = rc.render_resources(location_name, clean=clean) if not injection: continue match = location["regex"].search(content) if not match: continue start = match.start() end = match.end() placement = location["placement"] if placement == "pre": content = content[:start] + injection + content[start:] elif placement == "post": content = content[:end] + injection + content[end:] else: # pragma: no cover raise ValueError(f"Error! Unknown placement `{placement}`.") return content
[docs] def get_resource_container(context): """ Get a `ResourceContainer` from a rendering context. :param context: Context. :type context: jinja2.runtime.Context :return: Resource Container. :rtype: shuup.xtheme.resources.ResourceContainer|None """ return context.get(RESOURCE_CONTAINER_VAR_NAME)
[docs] @contextfunction def add_resource(context, location, resource): """ Add an Xtheme resource into the given context. :param context: Context. :type context: jinja2.runtime.Context :param location: Location string (see `KNOWN_LOCATIONS`). :type location: str :param resource: Resource descriptor (URL or inline markup object). :type resource: str|InlineMarkupResource|InlineScriptResource :return: Success flag. :rtype: bool """ rc = get_resource_container(context) if rc: return bool(rc.add_resource(location, resource)) return False
[docs] def valid_view(context): """ Prevent adding the global snippet in admin views and in editor view. """ view_class = getattr(context["view"], "__class__", None) if context.get("view") else None request = context.get("request") if not (view_class and request): return False match = request.resolver_match if not (match and match.app_name != "shuup_admin"): return False from shuup.xtheme.views.editor import EditorView if issubclass(view_class, EditorView): return False return True
[docs] class SnippetBlocker:
[docs] @classmethod def should_block_global_snippet_injection(cls, snippet: "Snippet", context: dict) -> bool: raise NotImplementedError
[docs] def inject_global_snippet(context, content): # noqa: C901 if not valid_view(context): return from shuup.xtheme import get_current_theme from shuup.xtheme.models import Snippet, SnippetType request = context["request"] shop = getattr(request, "shop", None) or get_shop(context["request"]) cache_key = GLOBAL_SNIPPETS_CACHE_KEY.format(shop_id=shop.id) snippets = cache.get(cache_key) if snippets is None: snippets = Snippet.objects.filter(shop=shop) cache.set(cache_key, snippets) for snippet in snippets: if snippet.themes: current_theme = getattr(request, "theme", None) or get_current_theme(shop) if current_theme and current_theme.identifier not in snippet.themes: continue snippet_blockers = get_provide_objects("xtheme_snippet_blocker") # type: Iterable[SnippetBlocker] blocked = False for snippet_blocker in snippet_blockers: if snippet_blocker.should_block_global_snippet_injection(snippet, context): blocked = True break if blocked: continue content = snippet.snippet if snippet.snippet_type == SnippetType.InlineJS: content = InlineScriptResource(content) elif snippet.snippet_type == SnippetType.InlineCSS: content = InlineStyleResource(content) elif snippet.snippet_type == SnippetType.InlineHTMLMarkup: content = InlineMarkupResource(content) elif snippet.snippet_type == SnippetType.InlineJinjaHTMLMarkup: context = dict(context.items()) # prevent recursive injection context["allow_resource_injection"] = False content = JinjaMarkupResource(content, context) add_resource(context, snippet.location, content)