import inspect
import warnings
from collections import deque
from content_editor.contents import contents_for_item
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.template import Context, Engine
from django.utils.functional import SimpleLazyObject
from django.utils.html import mark_safe
__all__ = (
"PluginNotRegisteredError",
"default_context",
"render_in_context",
"template_renderer",
"RegionRenderer",
"TemplatePluginRenderer",
)
[docs]
class PluginNotRegisteredError(Exception):
"""
Exception raised when encountering a plugin which isn't known to the
renderer.
"""
# Backwards compatibility
PluginNotRegistered = PluginNotRegisteredError
[docs]
def default_context(plugin, context):
"""
Return the default context for plugins rendered with a template, which
simply is a single variable named ``plugin`` containing the plugin
instance.
"""
return {"plugin": plugin}
[docs]
def render_in_context(context, template, local_context=None):
"""Render using a template rendering context
This utility avoids the problem of ``render_to_string`` requiring a
``dict`` and not a full-blown ``Context`` instance which would needlessly
burn CPU cycles."""
if context is None:
context = Context()
if not hasattr(template, "render"): # Quacks like a template?
try:
engine = context.template.engine
except AttributeError:
engine = Engine.get_default()
if isinstance(template, (list, tuple)):
template = engine.select_template(template)
else:
template = engine.get_template(template)
with context.push(local_context):
return template.render(context)
[docs]
def template_renderer(template_name, local_context=default_context, /):
"""
Build a renderer for the region renderer which uses a template (or a list
of templates) and optionally a local context function. The context contains
the site-wide context variables too when invoked via ``{% render_region %}``
"""
return lambda plugin, context: render_in_context(
context, template_name, local_context(plugin, context)
)
_default_marks = {"default"}
[docs]
class RegionRenderer:
"""
The region renderer knows how to render single plugins and also complete
regions.
The basic usage is to instantiate the region renderer, register plugins
and render a full region with it.
"""
def __init__(self):
self._renderers = {}
self._subregions = {}
self._marks = {}
self.handlers = {
key[7:]: getattr(self, key)
for key in dir(self)
if key.startswith("handle_")
}
[docs]
def register(self, plugin, renderer, /, subregion="default", marks=_default_marks):
"""
Register a plugin class
The renderer is either a static value or a function which always
receives two arguments: The plugin instance and the context. When using
``{% render_region %}`` and the Django template language the context
will be a Django template ``Context`` (or even ``RequestContext``)
instance.
The two optional keyword arguments' are:
- ``subregion: str = "default"``: The subregion for this plugin as a
string or as a callable accepting a single plugin instance. A
matching ``handle_<subregion>`` callable has to exist on the region
renderer instance or rendering **will** crash loudly.
- ``marks: Set[str] = {"default"}``: The marks of this plugin. Marks
only have the meaning you assign to them. Marks are preferrable to
running ``isinstance`` on plugin instances especially when using the
same region renderer class for different content types (e.g. pages
and blog articles).
"""
if plugin in self._renderers:
warnings.warn(
f"The plugin {plugin} has already been registered with {self.__class__} before.",
stacklevel=2,
)
self._renderers[plugin] = renderer
self._subregions[plugin] = subregion
self._marks[plugin] = marks
if callable(renderer) and len(inspect.signature(renderer).parameters) < 2:
raise ImproperlyConfigured(
f"The renderer function {renderer} has less than the two required arguments."
)
[docs]
def plugins(self):
"""
Return a list of all registered plugin classes
"""
return list(self._renderers)
[docs]
def render_plugin(self, plugin, context):
"""
Render a single plugin using the registered renderer
"""
try:
renderer = self._renderers[plugin.__class__]
except KeyError as exc:
raise PluginNotRegisteredError(
f"Plugin {plugin._meta.label_lower} is not registered"
) from exc
if callable(renderer):
return renderer(plugin, context)
return renderer
[docs]
def subregion(self, plugin):
"""
Return the subregion of a plugin instance
"""
try:
subregion = self._subregions[plugin.__class__]
except KeyError as exc:
raise PluginNotRegisteredError(
f"Plugin {plugin._meta.label_lower} is not registered"
) from exc
if callable(subregion):
return subregion(plugin)
return subregion
[docs]
def takewhile_subregion(self, plugins, subregion):
"""
Yield all plugins from the head of the ``plugins`` deque as long as
their subregion equals ``subregion``.
"""
while plugins and self.subregion(plugins[0]) == subregion:
yield plugins.popleft()
[docs]
def marks(self, plugin):
"""
Return the marks of a plugin instance
"""
try:
marks = self._marks[plugin.__class__]
except KeyError as exc:
raise PluginNotRegisteredError(
f"Plugin {plugin._meta.label_lower} is not registered"
) from exc
if callable(marks):
return marks(plugin)
return marks
[docs]
def takewhile_mark(self, plugins, mark):
"""
Yield all plugins from the head of the ``plugins`` deque as long as
their marks include ``mark``.
"""
while plugins and mark in self.marks(plugins[0]):
yield plugins.popleft()
[docs]
def handle(self, plugins, context):
"""
Runs the ``handle_<subregion>`` handler for the head of the ``plugins``
deque.
This method requires that a matching handler for all values returned by
``self.subregion()`` exists.
You probably want to call this method when overriding the rendering of
a complete region.
"""
plugins = deque(plugins)
while plugins:
yield from self.handlers[self.subregion(plugins[0])](plugins, context)
[docs]
def handle_default(self, plugins, context):
"""
Renders plugins from the queue as long as there are plugins belonging
to the ``default`` subregion.
"""
for plugin in self.takewhile_subregion(plugins, "default"):
yield self.render_plugin(plugin, context)
[docs]
def render_region(self, *, region, contents, context):
"""
Render one region.
"""
return mark_safe("".join(self.handle(contents[region.key], context)))
[docs]
def render_regions(self, *, regions, contents, context):
"""
Render multiple regions.
This method should return a dictionary.
"""
return {
region.key: self.render_region(
region=region, contents=contents, context=context
)
for region in regions
}
[docs]
def regions_from_contents(self, contents, **kwargs):
"""
Return an opaque object encapsulating
:mod:`content_editor.contents.Contents` and the logic required to
render them.
All you need to know is that the return value has a ``regions``
attribute containing a list of regions and a ``render`` method
accepting a region key and a context instance.
"""
return _Regions(contents=contents, renderer=self, **kwargs)
[docs]
def regions_from_item(self, item, /, *, inherit_from=None, timeout=None, **kwargs):
"""
Return an opaque object, see
:func:`~feincms3.renderer.RegionRenderer.regions_from_contents`
Automatically caches the return value if ``timeout`` is truthy. The
default cache key only takes the ``item``'s class and primary key into
account. You may have to override the cache key by passing
``cache_key`` if you're doing strange^Wadvanced things.
"""
if timeout and kwargs.get("cache_key") is None:
kwargs["cache_key"] = f"regions-{item._meta.label_lower}-{item.pk}"
contents = SimpleLazyObject(
lambda: contents_for_item(item, self.plugins(), inherit_from=inherit_from)
)
return self.regions_from_contents(contents, timeout=timeout, **kwargs)
# TemplatePluginRenderer compatibility
[docs]
def register_string_renderer(self, plugin, renderer):
"""Backwards compatibility for ``TemplatePluginRenderer``. It is
deprecated, don't use in new code."""
warnings.warn(
"register_string_renderer is deprecated. Use register instead."
" (Hint: register_string_renderer(plugin, renderer) can be replaced by"
" register(plugin, renderer) most of the time. The renderer has to be"
" changed to accept an additional `context` argument.)",
DeprecationWarning,
stacklevel=2,
)
if callable(renderer):
self.register(plugin, lambda plugin, context: renderer(plugin))
else:
self.register(plugin, renderer)
[docs]
def register_template_renderer(
self, plugin, template_name, context=default_context
):
"""Backwards compatibility for ``TemplatePluginRenderer``. It is
deprecated, don't use in new code."""
warnings.warn(
"register_template_renderer is deprecated. Use register instead."
" (Hint: register_template_renderer(plugin, template_name, local_context)"
" can be replaced by register(plugin, template_renderer(template_name, local_context))"
" most of the time.)",
DeprecationWarning,
stacklevel=2,
)
self.register(plugin, _compat_template_renderer(template_name, context))
[docs]
def render_plugin_in_context(self, plugin, context=None):
"""Backwards compatibility for ``TemplatePluginRenderer``. It is
deprecated, don't use in new code."""
warnings.warn(
"render_plugin_in_context is deprecated. Use render_plugin instead."
" (Hint: render_plugin works exactly the same except that the `context` argument"
" is required.)",
DeprecationWarning,
stacklevel=2,
)
return self.render_plugin(plugin, context)
class _Regions:
"""
Opaque object implementing the following interface:
- ``regions``: A list of regions used for the content editor ``Contents``
instance.
- ``render(region_key, context)``: A function which actually runs the
renderer.
"""
def __init__(self, *, contents, renderer, cache_key=None, timeout=None):
self._contents = contents
self._renderer = renderer
self._cache_key = cache_key
self._timeout = timeout
def _rendered(self, context):
caching = self._cache_key and self._timeout
if caching and (result := cache.get(self._cache_key)):
return result
result = self._renderer.render_regions(
regions=self.regions, contents=self._contents, context=context
)
if caching:
cache.set(self._cache_key, result, timeout=self._timeout)
return result
@property
def regions(self):
return self._contents.regions
def render(self, region_key, context):
rendered = self._rendered(context)
return rendered.get(region_key, "")
def _compat_template_renderer(_tpl, _ctx=default_context, /):
"""Compatibility implementation which accepts an optionally callable
template and an optionally callable context function."""
def renderer(plugin, context):
template_name = _tpl(plugin) if callable(_tpl) else _tpl
local_context = _ctx(plugin, context) if callable(_ctx) else _ctx
return render_in_context(context, template_name, local_context)
return renderer
[docs]
class TemplatePluginRenderer(RegionRenderer):
"""
TemplatePluginRenderer is deprecated, use
:class:`~feincms3.renderer.RegionRenderer`.
"""
def __init__(self, *args, **kwargs):
warnings.warn(
"TemplatePluginRenderer is deprecated. Switch to the RegionRenderer now."
" (Hint: An incremental upgrade is supported. You can start by"
" replacing TemplatePluginRenderer with RegionRenderer, it's just an"
" alias with an additional warning.)",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)