import logging import sys from collections import OrderedDict, defaultdict, deque from collections.abc import Iterable, Sequence from copy import deepcopy from itertools import starmap from operator import itemgetter from django.db import models, transaction from django.http import HttpRequest from django.utils.translation import gettext as _ from cms.constants import PLUGIN_ITERATOR_CHUNK_SIZE from cms.exceptions import PluginLimitReached from cms.models.pluginmodel import CMSPlugin from cms.plugin_base import CMSPluginBase from cms.plugin_pool import plugin_pool from cms.utils import get_language_from_request from cms.utils.permissions import has_plugin_permission from cms.utils.placeholder import get_placeholder_conf logger = logging.getLogger(__name__) def get_plugin_class(plugin_type: str) -> type[CMSPluginBase]: """Returns the plugin class for a given plugin_type (str)""" return plugin_pool.get_plugin(plugin_type) def get_plugin_model(plugin_type: str) -> type[CMSPlugin]: """Returns the plugin model class for a given plugin_type (str)""" return get_plugin_class(plugin_type).model def get_plugins(request, placeholder, template, lang=None): """ Get a list of plugins for a placeholder in a specified template. Respects the placeholder's cache. :param request: (HttpRequest) The HTTP request object. :param placeholder: (Placeholder) The placeholder object for which to retrieve plugins. :param template: (Template) The template object in which the placeholder resides (not used). :param lang: (str, optional) The language code for localization. Defaults to None. Returns: list: A list of plugins for the specified placeholder in the template. Raises: None. Examples:: # Get plugins for a placeholder in a template plugins = get_plugins(request, placeholder, template) # Get plugins for a placeholder in a template with specific language plugins = get_plugins(request, placeholder, template, lang="en") """ if not placeholder: return [] if not hasattr(placeholder, "_plugins_cache"): assign_plugins(request, [placeholder], template, lang) return placeholder._plugins_cache def assign_plugins(request, placeholders, template=None, lang=None): """ Fetch all plugins for the given ``placeholders`` and cast them down to the concrete instances in one query per type. :param request: The current request. :param placeholders: An iterable of placeholder objects. :param template: (optional) The template object. :param lang: (optional) The language code. This method assigns plugins to the given placeholders. It retrieves the plugins from the database based on the placeholders and the language. The plugins are then downcasted to their specific plugin types. The plugins are split up by placeholder and stored in a dictionary where the key is the placeholder ID and the value is a list of plugins. For each placeholder, if there are plugins assigned to it, the plugins are organized as a layered tree structure. Otherwise, an empty list is assigned. The list of all plugins for each placeholder is stored in the `_all_plugins_cache` attribute of the placeholder, while the list of root plugins is stored in the `_plugins_cache` attribute """ if not placeholders: return placeholders = tuple(placeholders) # Trigger db hit lang = lang or get_language_from_request(request) plugins = list(CMSPlugin.objects.filter(placeholder__in=placeholders, language=lang)) if not plugins: # Create default plugins if enabled plugins = create_default_plugins(request, placeholders, template, lang) else: plugins = downcast_plugins(plugins, placeholders, request=request) # split the plugins up by placeholder plugins_by_placeholder = defaultdict(list) for plugin in plugins: plugins_by_placeholder[plugin.placeholder_id].append(plugin) for placeholder in placeholders: all_plugins = plugins_by_placeholder[placeholder.pk] if all_plugins: layered_plugins = get_plugins_as_layered_tree(all_plugins) else: layered_plugins = [] # This is all the plugins. placeholder._all_plugins_cache = all_plugins # This is only the root plugins. placeholder._plugins_cache = layered_plugins def create_default_plugins(request, placeholders, template, lang): """ Create all default plugins for the given ``placeholders`` if they have a "default_plugins" configuration value in settings. return all plugins, children, grandchildren (etc.) created """ from cms.api import add_plugin def _create_default_plugins(placeholder, confs, parent=None): """ Auxiliary function that builds all of a placeholder's default plugins at the current level and drives the recursion down the tree. Returns the plugins at the current level along with all descendants. """ plugins, descendants = [], [] addable_confs = (conf for conf in confs if has_plugin_permission(request.user, conf["plugin_type"], "add")) for conf in addable_confs: plugin = add_plugin(placeholder, conf["plugin_type"], lang, target=parent, **conf["values"]) if "children" in conf: args = placeholder, conf["children"], plugin descendants += _create_default_plugins(*args) plugin.notify_on_autoadd(request, conf) plugins.append(plugin) if parent: parent.notify_on_autoadd_children(request, conf, plugins) return plugins + descendants unfiltered_confs = ((ph, get_placeholder_conf("default_plugins", ph.slot, template)) for ph in placeholders) # Empty confs must be filtered before filtering on add permission mutable_confs = ( (ph, default_plugin_confs) for ph, default_plugin_confs in filter(itemgetter(1), unfiltered_confs) if ph.has_change_permission(request.user) ) return sum(starmap(_create_default_plugins, mutable_confs), []) def get_plugins_as_layered_tree(plugins): """ Given an iterable of plugins ordered by position, returns a deque of root plugins with their respective children set in the child_plugin_instances attribute. """ delayed = defaultdict(deque) root_plugins = deque() for plugin in reversed(plugins): plugin.child_plugin_instances = delayed[plugin.pk] if plugin.parent_id: delayed[plugin.parent_id].appendleft(plugin) else: root_plugins.appendleft(plugin) return root_plugins def get_plugin_restrictions(plugin, page=None, restrictions_cache=None): if restrictions_cache is None: restrictions_cache = {} plugin_type = plugin.plugin_type plugin_class = get_plugin_class(plugin.plugin_type) cache = plugin_pool.get_restrictions_cache(restrictions_cache, plugin, page or plugin.placeholder.source) parents_cache = cache.setdefault("plugin_parents", {}) children_cache = cache.setdefault("plugin_children", {}) try: parent_classes = parents_cache[plugin_type] except KeyError: parent_classes = plugin_class.get_parent_classes( slot=plugin.placeholder.slot, page=page, instance=plugin, ) if plugin_class.cache_parent_classes: parents_cache[plugin_type] = parent_classes or [] # Note: a plugin's allowed *parents* are not sent to the frontend -- droppability is already # encoded in every potential parent's child list (including the placeholder's root list, which # excludes plugins that require a parent). ``parent_classes`` is still returned here for # backwards compatibility with external callers. child_classes = [] if plugin_class.allow_children: # Only check for children if children are allowed try: child_classes = children_cache[plugin_type] # Still check child classes that did not cache their parent classes uncached_child_classes = plugin_class.get_child_classes( slot=plugin.placeholder.slot, page=page, instance=plugin, only_uncached=True, ) child_classes = child_classes + uncached_child_classes # Creates a new list to not change the cache child_classes = child_classes or [""] except KeyError: child_classes = plugin_class.get_child_classes( slot=plugin.placeholder.slot, page=page, instance=plugin, only_uncached=False, ) if plugin_class.cache_child_classes: # Check if child classes should be cached # Only add plugins to the cache that have the cache_parent_class attribute set children_cache[plugin_type] = [ plugin for plugin in (child_classes or []) if plugin_pool.get_plugin(plugin).cache_parent_classes ] or [""] # An empty list of allowed children (e.g. ``child_classes = []``) is sent to the # frontend as the ``[""]`` sentinel so that no plugins can be dropped as children. child_classes = child_classes or [""] return child_classes, parent_classes def _reunite_orphaned_placeholder_plugin_children(root_plugin, orphaned_plugin_list, plugins_by_id): """ Handle plugins where the parent hasn't yet been copied (child seen before the parent) CAVEAT: The only reason this exists is because the plugin position is not sequential through children when the user nests plugins. It's now too late as content already has this issue, it would be a very expensive calculation to recalculate every placeholders positions, needs to be handled gracefully so that it doesn't actually matter :-). """ for old_plugin_parent_id, new_plugin in orphaned_plugin_list: new_parent = plugins_by_id.get(old_plugin_parent_id, root_plugin) if new_parent: new_plugin.parent = new_parent new_plugin.save() @transaction.atomic def copy_plugins_to_placeholder( plugins, placeholder, language=None, root_plugin=None, start_positions=None, plugins_are_downcast=False, ): """Copies an iterable of plugins to a placeholder :param iterable plugins: Plugins to be copied :param placeholder: Target placeholder :type placeholder: :class:`cms.models.placeholdermodel.Placeholder` instance :param str language: target language (if no root plugin is given) :param root_plugin: :type placeholder: :class:`cms.models.pluginmodel.CMSPlugin` instance :param int start_positions: Cache for start positions by language :param bool plugins_are_downcast: Whether the plugins are already concrete plugin instances The logic of this method is the following: #. Get bound plugins for each source plugin #. Get the parent plugin (if it exists) #. then get a copy of the source plugin instance #. Set the id/pk to None to it the id of the generic plugin instance above; this will effectively change the generic plugin created above into a concrete one #. find the position in the new placeholder #. save the concrete plugin (which creates a new plugin in the database) #. trigger the copy relations #. return the plugin ids """ plugin_pairs = [] plugins_by_id = OrderedDict() # Keeps track of the next available position per language. positions_by_language = {} orphaned_plugin_list = [] if start_positions: positions_by_language.update(start_positions) if root_plugin: language = root_plugin.language source_plugins = plugins if plugins_are_downcast else get_bound_plugins(plugins) for source_plugin in source_plugins: parent = plugins_by_id.get(source_plugin.parent_id, root_plugin) plugin_model = source_plugin.__class__ # get_plugin_model(source_plugin.plugin_type) if plugin_model is not CMSPlugin: new_plugin = deepcopy(source_plugin) new_plugin.pk = None new_plugin.id = None new_plugin.language = language or new_plugin.language new_plugin.placeholder = placeholder new_plugin.parent = parent else: new_plugin = CMSPlugin( language=(language or source_plugin.language), parent=parent, plugin_type=source_plugin.plugin_type, placeholder=placeholder, ) try: position = positions_by_language[new_plugin.language] except KeyError: offset = placeholder.get_last_plugin_position(new_plugin.language) or 0 # The position is relative to language. position = placeholder.get_next_plugin_position( language=new_plugin.language, parent=new_plugin.parent, insert_order="last", ) # Because it is the first time this language is processed, # shift all plugins to the right of the next position. placeholder._shift_plugin_positions( new_plugin.language, start=position, offset=offset, ) new_plugin.position = position new_plugin.save() positions_by_language[new_plugin.language] = position + 1 if plugin_model != CMSPlugin: new_plugin.copy_relations(source_plugin) plugin_pairs.append((new_plugin, source_plugin)) plugins_by_id[source_plugin.pk] = new_plugin # Rescue any orphaned plugins if not parent and source_plugin.parent_id: orphaned_plugin_list.append((source_plugin.parent_id, new_plugin)) # Reunite any orphaned plugins with the parent if orphaned_plugin_list: _reunite_orphaned_placeholder_plugin_children(root_plugin, orphaned_plugin_list, plugins_by_id) # Backwards compatibility # This magic is needed for advanced plugins like Text Plugins that can have # nested plugins and need to update their content based on the new plugins. for new_plugin, old_plugin in plugin_pairs: new_plugin.post_copy(old_plugin, plugin_pairs) for language in positions_by_language: placeholder._recalculate_plugin_positions(language) return list(plugins_by_id.values()) def get_bound_plugins(plugins): """ Get the bound plugins by downcasting the plugins to their respective classes. Raises a KeyError if the plugin type is not available. Creates a map of plugin types and their corresponding plugin IDs for later use in downcasting. Then, retrieves the plugin instances from the plugin model using the mapped plugin IDs. Finally, iterates over the plugins and yields the downcasted versions if they have a valid parent. Does not affect caching. :param plugins: List of ``CMSPlugin`` instances. :type plugins: List[CMSPlugin] :return: Generator that yields the downcasted plugins. :rtype: Generator[CMSPlugin, None, None] Example:: plugins = [plugin_instance1, plugin_instance2] for bound_plugin in get_bound_plugins(plugins): # Do something with the bound_plugin pass """ plugin_types_map = defaultdict(list) plugin_ids = [] plugin_lookup = {} # make a map of plugin types, needed later for downcasting for plugin in plugins: plugin_ids.append(plugin.pk) plugin_model = get_plugin_model(plugin.plugin_type) base_model = plugin_model._meta.concrete_model # Collect all base models if base_model is CMSPlugin: plugin.__class__ = plugin_model # In case it's a proxy model plugin_lookup[plugin.pk] = plugin # Otherwise, no downcast needed else: plugin_types_map[base_model].append(plugin.pk) for base_model, pks in plugin_types_map.items(): plugin_queryset = base_model.objects.filter(pk__in=pks) # put them in a map, so we can replace the base CMSPlugins with their # downcasted versions for instance in plugin_queryset.iterator(chunk_size=PLUGIN_ITERATOR_CHUNK_SIZE): model = get_plugin_model(instance.plugin_type) # Get original class instance.__class__ = model # Cast to correct model (including proxies) plugin_lookup[instance.pk] = instance for plugin in plugins: parent_not_available = not plugin.parent_id or plugin.parent_id not in plugin_ids # The plugin either has no parent or needs to have a non-ghost parent valid_parent = parent_not_available or plugin.parent_id in plugin_lookup if valid_parent and plugin.pk in plugin_lookup: plugin._inst = plugin_lookup[plugin.pk] # Populate bound plugin cache for plugin yield plugin_lookup[plugin.pk] def downcast_plugins( plugins: Iterable[CMSPlugin], placeholders: Sequence | None = None, select_placeholder: bool = False, request: HttpRequest | None = None, ) -> Iterable[CMSPlugin]: """ Downcasts the given list of plugins to their respective classes. Ignores any plugins that are not available. :param plugins: List of plugins to downcast. :type plugins: List[CMSPlugin] :param placeholders: List of placeholders associated with the plugins. :type placeholders: Optional[List[Placeholder]] :param select_placeholder: If True, select_related the plugin queryset with placeholder. :type select_placeholder: bool :param request: The current request. :type request: Optional[HttpRequest] :return: Generator that yields the downcasted plugins. :rtype: Generator[CMSPlugin, None, None] """ plugin_types_map = defaultdict(list) plugin_lookup = {} plugin_ids = [] # make a map of plugin types, needed later for downcasting for plugin in plugins: # Keep track of the plugin ids we've received try: plugin_model = get_plugin_model(plugin.plugin_type) base_model = plugin_model._meta.concrete_model # Collect all base models except KeyError: # Plugin not available logger.error(f"Plugin not installed: {plugin.plugin_type} (pk={plugin.pk})", exc_info=sys.exc_info()) continue plugin_ids.append(plugin.pk) if base_model is CMSPlugin: plugin.__class__ = plugin_model # In case it is a proxy model plugin_lookup[plugin.pk] = plugin # otherwise, no downcast needed else: plugin_types_map[base_model].append(plugin.pk) placeholders = placeholders or [] placeholders_by_id = {placeholder.pk: placeholder for placeholder in placeholders} for plugin_model, pks in plugin_types_map.items(): # get all the plugins of type cls.model plugin_qs = plugin_model.objects.filter(pk__in=pks) if select_placeholder: plugin_qs = plugin_qs.select_related("placeholder") # put them in a map, so we can replace the base CMSPlugins with their # downcasted versions for instance in plugin_qs.iterator(chunk_size=PLUGIN_ITERATOR_CHUNK_SIZE): cls = get_plugin_class(instance.plugin_type) # Plugin class instance.__class__ = cls.model # Cast to original model (including proxies) plugin_lookup[instance.pk] = instance for plugin in plugins: parent_not_available = not plugin.parent_id or plugin.parent_id not in plugin_ids # The plugin either has no parent or needs to have a non-ghost parent valid_parent = parent_not_available or plugin.parent_id in plugin_lookup if valid_parent and plugin.pk in plugin_lookup: instance = plugin_lookup[plugin.pk] if instance.parent_id in plugin_lookup: instance._state.fields_cache["parent"] = plugin_lookup[instance.parent_id] placeholder = placeholders_by_id.get(instance.placeholder_id) if placeholder: instance.placeholder = placeholder cls = get_plugin_class(instance.plugin_type) if not cls.cache and not cls().get_cache_expiration(request, instance, placeholder): placeholder.cache_placeholder = False plugin._inst = instance # Populate bound plugin cache for plugin yield instance def has_reached_plugin_limit(placeholder, plugin_type, language, template=None): """ Checks if the global maximum limit for plugins in a placeholder has been reached. If not then it checks if it has reached its maximum plugin_type limit. Parameters: - placeholder: The placeholder object to check the limit for. - plugin_type: The type of plugin to check the limit for. - language: The language code for the plugins. - template: The template object for the placeholder. Optional. Returns: - False if the limit has not been reached. Raises: - PluginLimitReached: If the limit has been reached for the placeholder. """ limits = get_placeholder_conf("limits", placeholder.slot, template) if not limits: return False global_limit = limits.get("global") type_limit = limits.get(plugin_type) # total plugin counts counts = placeholder.get_plugins(language=language).aggregate( total_count=models.Count('pk'), type_count=models.Count('pk', filter=models.Q(plugin_type=plugin_type)) ) # Ensure counts default to integers if None total_count = counts.get('total_count') or 0 type_count = counts.get('type_count') or 0 global_limit = limits.get("global") type_limit = limits.get(plugin_type) if global_limit and total_count >= global_limit: raise PluginLimitReached(_("This placeholder already has the maximum number of plugins (%s).") % global_limit) if type_limit and type_count >= type_limit: plugin_name = get_plugin_class(plugin_type).name raise PluginLimitReached( _( "This placeholder already has the maximum number (%(limit)s) of allowed %(plugin_name)s plugins." ) % {"limit": type_limit, "plugin_name": plugin_name} ) def get_plugin_disallowed_in_slot(plugin_types: Iterable[str], slot: str | None) -> str | None: """Return the first plugin type whose ``allowed_slots`` restriction forbids ``slot``. ``plugin_types`` is an iterable of plugin type names (e.g. the plugin being moved or copied together with all of its descendants). Returns the offending plugin type name, or ``None`` if every plugin may be added to ``slot``. Unregistered plugin types are skipped (treated as allowed), mirroring the rest of the move/copy handling. """ for plugin_type in dict.fromkeys(plugin_types): try: plugin_class = plugin_pool.get_plugin(plugin_type) except KeyError: continue if not plugin_class.is_allowed_in_slot(slot): return plugin_type return None