dynamicweb/cms/plugin_pool.py

409 lines
18 KiB
Python

import fnmatch
from collections import defaultdict
from functools import lru_cache
from operator import attrgetter
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.template import TemplateDoesNotExist, TemplateSyntaxError
from django.template.defaultfilters import slugify
from django.urls import URLResolver, include, re_path
from django.utils.encoding import force_str
from django.utils.functional import cached_property, lazy
from django.utils.module_loading import autodiscover_modules
from django.utils.translation import activate, deactivate_all, get_language
from cms.exceptions import PluginAlreadyRegistered, PluginNotRegistered
from cms.models import Page
from cms.models.placeholdermodel import Placeholder
from cms.plugin_base import CMSPluginBase
from cms.utils.helpers import normalize_name
_GLOB_CHARS = ("*", "?", "[")
def _has_glob(pattern: str) -> bool:
"""Return ``True`` if ``pattern`` looks like a glob (contains ``*``, ``?`` or ``[``)."""
return isinstance(pattern, str) and any(char in pattern for char in _GLOB_CHARS)
class PluginPool:
plugins: dict[str, type[CMSPluginBase]]
root_plugin_cache: dict[str, list[type[CMSPluginBase]]]
discovered: bool
global_restrictions_cache: defaultdict[str, dict]
def __init__(self):
self.plugins = {}
self.root_plugin_cache = {}
self.discovered = False
self.global_restrictions_cache = defaultdict(dict)
@property
def global_template_restrictions(self) -> bool:
return any(".htm" in (key or "") for key in self.global_restrictions_cache)
def _clear_cached(self) -> None:
self.root_plugin_cache = {}
self.global_restrictions_cache = defaultdict(dict)
self.get_all_plugins_for_model.cache_clear()
self.expand_plugin_patterns.cache_clear()
if "registered_plugins" in self.__dict__:
del self.__dict__["registered_plugins"]
if "plugins_with_extra_menu" in self.__dict__:
del self.__dict__["plugins_with_extra_menu"]
if "plugins_with_extra_placeholder_menu" in self.__dict__:
del self.__dict__["plugins_with_extra_placeholder_menu"]
@lru_cache # noqa: B019
def expand_plugin_patterns(self, patterns: tuple[str, ...]) -> frozenset[str]:
"""
Expand a tuple of plugin-name patterns into the set of matching plugin names.
Literal names are kept verbatim (so an unregistered literal simply matches no
installed plugin, preserving historic behaviour). Glob patterns -- ``*``, ``?``
and ``[seq]`` -- are matched case-sensitively against the names of all registered
plugins.
The result is memoized; the cache is cleared whenever plugins are (un)registered
(see :meth:`_clear_cached`), so the registry is only scanned on the first use of a
given pattern tuple. This keeps restriction evaluation -- which is on the critical
rendering path -- cheap.
"""
globs = [pattern for pattern in patterns if _has_glob(pattern)]
if not globs:
# Fast path: no globs, nothing to expand against the registry.
return frozenset(patterns)
names = tuple(self.plugins)
matched = {pattern for pattern in patterns if not _has_glob(pattern)}
for pattern in globs:
matched.update(name for name in names if fnmatch.fnmatchcase(name, pattern))
return frozenset(matched)
def resolve_plugin_patterns(self, patterns: list[str] | None) -> list[str] | None:
"""
Resolve a ``child_classes``/``parent_classes`` value, expanding any glob patterns.
``None`` (no restriction) is returned unchanged. A list without glob patterns is
returned as-is, both to avoid overhead and to preserve its ordering. A list with at
least one glob is expanded to the sorted list of matching registered plugin names.
Expansion happens *before* emptiness is interpreted, so a glob that matches nothing
yields an empty list -- i.e. "no plugins allowed", just like an explicit ``[]``.
"""
if patterns is None:
return None
if not any(_has_glob(pattern) for pattern in patterns):
return patterns
return sorted(self.expand_plugin_patterns(tuple(patterns)))
def discover_plugins(self) -> None:
if self.discovered:
return
autodiscover_modules("cms_plugins")
self.discovered = True
# Sort plugins by their module and name
self.plugins = dict(sorted(self.plugins.items(), key=lambda key: (key[1].module, key[1].name)))
def clear(self) -> None:
self.discovered = False
self.plugins = {}
self._clear_cached()
def validate_templates(self, plugin: type[CMSPluginBase] | None = None) -> None:
"""
Verify that all plugins have a valid render template.
Plugins that have render_plugin=False and allow_children=False are
always deemed valid.
"""
if plugin:
plugins = [plugin]
else:
plugins = self.plugins.values()
for plugin_class in plugins:
if (
plugin_class.render_plugin
and type(plugin_class.render_plugin) is not property
or hasattr(plugin_class.model, "render_template")
or hasattr(plugin_class, "get_render_template")
):
if plugin_class.render_template is None and not hasattr(plugin_class, "get_render_template"):
raise ImproperlyConfigured(
"CMS Plugins must define a render template, "
"a get_render_template method or "
"set render_plugin=False: %s" % plugin_class
)
# If plugin class defines get_render_template we cannot
# statically check for valid template file as it depends
# on plugin configuration and context.
# We cannot prevent developer to shoot in the users' feet
if not hasattr(plugin_class, "get_render_template"):
from django.template import loader
template = plugin_class.render_template
if isinstance(template, str) and template:
try:
loader.get_template(template)
except TemplateDoesNotExist as e:
# Note that the template loader will throw
# TemplateDoesNotExist if the plugin's render_template
# does in fact exist, but it includes a template that
# doesn't.
if str(e) == template:
raise ImproperlyConfigured(
"CMS Plugins must define a render template (%s) that exists: %s"
% (plugin_class, template)
)
else:
pass
except TemplateSyntaxError:
pass
else:
if plugin_class.allow_children:
raise ImproperlyConfigured(
"CMS Plugins can not define render_plugin=False and allow_children=True: %s" % plugin_class
)
def register_plugin(self, plugin: type[CMSPluginBase]) -> type[CMSPluginBase]:
"""
Register the given plugin.
Static sanity checks is also performed.
If a plugin is already registered, this will raise PluginAlreadyRegistered.
"""
if not issubclass(plugin, CMSPluginBase):
raise ImproperlyConfigured("CMS Plugins must be subclasses of CMSPluginBase, %r is not." % plugin)
plugin_name = plugin.__name__
if plugin_name in self.plugins:
raise PluginAlreadyRegistered(
f"Cannot register {plugin!r}, a plugin with this name ({plugin_name!r}) is already registered."
)
plugin.value = plugin_name
self.plugins[plugin_name] = plugin
self._clear_cached()
return plugin
def unregister_plugin(self, plugin: type[CMSPluginBase]) -> None:
"""
Unregister the given plugin.
If the plugin isn't already registered, this will raise PluginNotRegistered.
"""
plugin_name = plugin.__name__
if plugin_name not in self.plugins:
raise PluginNotRegistered("The plugin %r is not registered" % plugin)
del self.plugins[plugin_name]
self._clear_cached()
@lru_cache # noqa: B019
def get_all_plugins_for_model(self, model: type[models.Model]) -> list[type[CMSPluginBase]]:
"""
Retrieve all plugins that can be used to edit the given model.
This method applies two levels of filtering:
1. Plugin-level filtering (allowed_models on plugin):
- If a plugin has allowed_models defined, the model must be in that list
- If allowed_models is None, the plugin is available for all models
2. Model-level filtering (allowed_plugins on model):
- If the model has allowed_plugins defined, only those plugins are returned
- If allowed_plugins is None, all plugins (passing filter 1) are returned
- If allowed_plugins is an empty list [], no plugins are returned
Args:
model: The Django model class to get plugins for
Returns:
List of plugin classes that can be used with this model
"""
obj_type = f"{model._meta.app_label}.{model._meta.model_name}" if model else "None"
assert obj_type != "cms.page"
obj_allowed_plugins = getattr(model, "allowed_plugins", None)
# Filters for allowed_models
plugins = (plugin for plugin in self.plugins.values() if not plugin.allowed_models or obj_type in plugin.allowed_models)
# Filters for allowed_plugins
if obj_allowed_plugins is not None:
plugins = (plugin for plugin in plugins if plugin.__name__ in obj_allowed_plugins)
return list(plugins)
def get_all_plugins(
self,
placeholder: str | None = None,
page: Page | None = None,
setting_key: str = "plugins",
include_page_only: bool = True,
root_plugin: bool = False,
):
from cms.utils.placeholder import get_placeholder_conf
plugins = self.get_all_plugins_for_model(page.__class__) if page else self.plugins.values()
template = (
lazy(page.get_template, str)() if page and hasattr(page, "get_template") else None
) # Make template lazy to avoid unnecessary db access
# ``setting_key=None`` skips the placeholder's allow list (``excluded_plugins`` still applies)
allowed_plugins = (
get_placeholder_conf(
setting_key,
placeholder,
template,
)
if setting_key
else None
) or ()
excluded_plugins = (
get_placeholder_conf(
"excluded_plugins",
placeholder,
template,
)
or ()
)
if allowed_plugins:
# Check that plugins are in the list of the allowed ones
plugins = (plugin for plugin in plugins if plugin.__name__ in allowed_plugins)
if excluded_plugins:
# Check that plugins are not in the list of the excluded ones
plugins = (plugin for plugin in plugins if plugin.__name__ not in excluded_plugins)
if placeholder is not None:
# Honour each plugin's allowed_slots restriction (plugin-side counterpart to the
# placeholder's plugins/excluded_plugins config).
plugins = (plugin for plugin in plugins if plugin.is_allowed_in_slot(placeholder))
if root_plugin:
# Filters out any plugin that requires a parent or has set parent classes
plugins = (plugin for plugin in plugins if not plugin.requires_parent_plugin(placeholder, page))
return plugins
def get_root_plugins(self, placeholder: Placeholder) -> list[type[CMSPluginBase]]:
template = placeholder.source.get_template() if hasattr(placeholder.source, "get_template") else "None"
key = f"{template}:{placeholder.slot}"
if key not in self.root_plugin_cache:
self.root_plugin_cache[key] =list(self.get_all_plugins(placeholder.slot, placeholder.source, root_plugin=True))
return self.root_plugin_cache[key]
def get_text_enabled_plugins(self, placeholder, page) -> list[type[CMSPluginBase]]:
plugins = set(self.get_all_plugins(placeholder, page, root_plugin=False))
plugins.update(self.get_all_plugins(placeholder, page, setting_key="text_only_plugins", root_plugin=False))
return sorted((p for p in plugins if p.text_enabled), key=attrgetter("module", "name"))
def get_plugin(self, name) -> type[CMSPluginBase]:
"""
Retrieve a plugin from the cache.
"""
return self.plugins[name]
def get_patterns(self) -> list[URLResolver]:
self.discover_plugins()
# We want untranslated name of the plugin for its slug so we deactivate translation
lang = get_language()
deactivate_all()
try:
url_patterns = []
for plugin in self.registered_plugins:
p = plugin()
slug = slugify(force_str(normalize_name(p.__class__.__name__)))
url_patterns += [
re_path(rf"^plugin/{slug}/", include(p.plugin_urls)),
]
finally:
# Reactivate translation
activate(lang)
return url_patterns
def get_system_plugins(self) -> list[str]:
return [plugin.__name__ for plugin in self.plugins.values() if plugin.system]
@cached_property
def registered_plugins(self) -> list[type[CMSPluginBase]]:
return sorted(self.get_all_plugins(root_plugin=False), key=attrgetter("module", "name"))
@cached_property
def plugins_with_extra_menu(self) -> list[type[CMSPluginBase]]:
plugin_classes = [cls for cls in self.registered_plugins if cls._has_extra_plugin_menu_items]
return plugin_classes
@cached_property
def plugins_with_extra_placeholder_menu(self) -> list[type[CMSPluginBase]]:
plugin_classes = [cls for cls in self.registered_plugins if cls._has_extra_placeholder_menu_items]
return plugin_classes
def get_restrictions_cache(
self, request_cache: dict, instance: CMSPluginBase, obj: models.Model | None
) -> defaultdict[str, dict]:
"""
Retrieve the restrictions cache for a given plugin instance.
This method checks if the plugin class can be cached globally. This is the case if the
plugin restrictions only depend on template and placeholder slot as described by the
CMS_PLACEHOLDER_CONF setting.
If it can, it retrieves the appropriate restrictions cache based on the template and slot
of the plugin instance's placeholder. If not, it returns the (local) request cache which will
be recalculated for each request.
Args:
request_cache (dict): The current request cache (only filled is non globally cacheable).
instance (CMSPluginBase): The plugin instance for which to retrieve the restrictions cache.
obj (Optional[models.Model]): The model instance associated with the plugin instance, if any.
Returns:
dict: The restrictions cache for the given plugin instance - or the cache valid for the request.
"""
plugin_class = self.get_plugin(instance.plugin_type)
object_class = f"{obj._meta.app_label}.{obj._meta.model_name}" if obj else ""
if not self.can_cache_globally(plugin_class):
return request_cache
slot = instance.placeholder.slot
if self.global_template_restrictions:
template = plugin_class._get_template_for_conf(obj) if obj else ""
else:
template = ""
if template and f"{object_class}:{template} {slot}" in self.global_restrictions_cache:
return self.global_restrictions_cache[f"{object_class}:{template} {slot}"]
if template and f"{object_class}:{template}" in self.global_restrictions_cache:
return self.global_restrictions_cache[f"{object_class}:{template}"]
if slot and f"{object_class}:{slot}" in self.global_restrictions_cache:
return self.global_restrictions_cache[f"{object_class}:{slot}"]
return self.global_restrictions_cache[object_class]
restriction_methods = (
"get_require_parent",
"get_child_class_overrides",
"get_parent_classes",
"get_child_classes",
)
def can_cache_globally(self, plugin_class: type[CMSPluginBase]) -> bool:
"""
Check if the restrictions for a given plugin class can be cached globally.
This is the case if the plugin restrictions only depend on template and placeholder slot as
described by the CMS_PLACEHOLDER_CONF setting.
Args:
plugin_class (CMSPluginBase): The plugin class for which to check if restrictions can be cached globally.
Returns:
bool: True if the restrictions can be cached globally, False otherwise.
"""
if not hasattr(plugin_class, "_cache_restrictions_globally"):
plugin_class._cache_restrictions_globally = all(
hasattr(getattr(plugin_class, method_name), "_template_slot_caching")
for method_name in self.restriction_methods
)
return plugin_class._cache_restrictions_globally
plugin_pool = PluginPool()