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()