from functools import partial from logging import getLogger from django.contrib import messages from django.contrib.sites.models import Site from django.core.exceptions import ValidationError from django.urls import NoReverseMatch from django.utils.functional import cached_property from django.utils.module_loading import autodiscover_modules from django.utils.translation import ( gettext_lazy as _, ) from cms.utils import get_current_site from cms.utils.conf import get_cms_setting, get_menu_cache from cms.utils.i18n import ( get_default_language_for_site, ) from menus.base import Menu from menus.exceptions import NamespaceAlreadyRegistered from menus.models import CacheKey logger = getLogger('menus') cache = get_menu_cache() def _build_nodes_inner_for_one_menu(nodes, menu_class_name): """ This is an easier to test "inner loop" building the menu tree structure for one menu (one language, one site) """ done_nodes = {} # Dict of node.id:Node final_nodes = [] # This is to prevent infinite loops - we need to compare the number of # times we see a specific node to "something", and for the time being, # it's the total number of nodes list_total_length = len(nodes) while nodes: # For when the node has a parent_id but we haven't seen it yet. # We must not append it to the final list in this case! should_add_to_final_list = True node = nodes.pop(0) # Increment the "seen" counter for this specific node. node._counter = getattr(node, '_counter', 0) + 1 # Implicit namespacing by menu.__name__ if not node.namespace: node.namespace = menu_class_name if node.namespace not in done_nodes: # We need to create the namespace dict to avoid KeyErrors done_nodes[node.namespace] = {} # If we have seen the parent_id already... if node.parent_id in done_nodes[node.namespace]: # Implicit parent namespace by menu.__name__ if not node.parent_namespace: node.parent_namespace = menu_class_name parent = done_nodes[node.namespace][node.parent_id] parent.children.append(node) node.parent = parent # If it has a parent_id but we haven't seen it yet... elif node.parent_id: # We check for infinite loops here, by comparing the number of # times we "saw" this node to the number of nodes in the list if node._counter < list_total_length: nodes.append(node) # Never add this node to the final list until it has a real # parent (node.parent) should_add_to_final_list = False if should_add_to_final_list: final_nodes.append(node) # add it to the "seen" list done_nodes[node.namespace][node.id] = node return final_nodes def _get_menu_class_for_instance(menu_class, instance): """ Returns a new menu class that subclasses menu_class but is bound to instance. This means it sets the "instance" attribute of the class. """ attrs = {'instance': instance} class_name = menu_class.__name__ meta_class = type(menu_class) return meta_class(class_name, (menu_class,), attrs) class MenuRenderer: # The main logic behind this class is to decouple # the singleton menu pool from the menu rendering logic. # By doing this we can be sure that each request has its # private instance that will always have the same attributes. def __init__(self, pool, request): self.pool = pool # It's important this happens on init # because we need to make sure that a menu renderer # points to the same registered menus as long as the # instance lives. self.menus = pool.get_registered_menus(for_rendering=True) self.request = request page = getattr(self.request, 'current_page', None) if page: # Avoid resolving site self.site = Site(id=page.site_id) else: self.site = get_current_site(request) self.request_language = None if hasattr(request, "LANGUAGE_CODE"): # use language from middleware - usually django.middleware.locale.LocaleMiddleware self.request_language = request.LANGUAGE_CODE if not self.request_language: self.request_language = get_default_language_for_site(self.site.pk) toolbar = getattr(request, "toolbar", None) self.edit_or_preview = toolbar.edit_mode_active or toolbar.preview_mode_active if toolbar else False @property def cache_key(self): prefix = get_cms_setting('CACHE_PREFIX') key = f"{prefix}menu_nodes_{self.request_language}_{self.site.pk}" if self.request.user.is_authenticated: key += f"_{self.request.user.pk}_user" if self.edit_or_preview: key += ':edit' else: key += ':public' return key @cached_property def is_cached(self): db_cache_key_lookup = CacheKey.objects.filter( key=self.cache_key, language=self.request_language, site=self.site.pk, ) return db_cache_key_lookup.exists() def _build_nodes(self): """ This is slow. Caching must be used. One menu is built per language and per site. Namespaces: they are ID prefixes to avoid node ID clashes when plugging multiple trees together. - We iterate on the list of nodes. - We store encountered nodes in a dict (with namespaces): done_nodes[][] = node - When a node has a parent defined, we lookup that parent in done_nodes if it's found: set the node as the node's parent's child (re-read this) else: the node is put at the bottom of the list """ key = self.cache_key cached_nodes = cache.get(key, None) if cached_nodes and self.is_cached: # Only use the cache if the key is present in the database. # This prevents a condition where keys which have been removed # from the database due to a change in content, are still used. return cached_nodes final_nodes = [] toolbar = getattr(self.request, 'toolbar', None) for menu_class_name in self.menus: menu = self.get_menu(menu_class_name) try: nodes = menu.get_nodes(self.request) except NoReverseMatch: # Apps might raise NoReverseMatch if an apphook does not yet # exist, skip them instead of crashing nodes = [] if toolbar and toolbar.is_staff: messages.error( self.request, _('Menu %s cannot be loaded. Please, make sure all its urls exist and can be resolved.') % menu_class_name ) logger.error("Menu %s could not be loaded." % menu_class_name, exc_info=True) # nodes is a list of navigation nodes (page tree in cms + others) final_nodes += _build_nodes_inner_for_one_menu(nodes, menu_class_name) cache.set(key, final_nodes, get_cms_setting('CACHE_DURATIONS')['menus']) if not self.is_cached: # No need to invalidate the internal lookup cache, # just set the value directly. self.__dict__['is_cached'] = True # We need to have a list of the cache keys for languages and sites that # span several processes - so we follow the Django way and share through # the database. It's still cheaper than recomputing every time! # This way we can selectively invalidate per-site and per-language, # since the cache is shared but the keys aren't CacheKey.objects.create(key=key, language=self.request_language, site=self.site.pk) return final_nodes def _mark_selected(self, nodes): """Mark the selected node and its ancestors, descendants and siblings.""" selected = next((node for node in nodes if node.is_selected(self.request)), None) if selected: selected.selected = True self._mark_ancestors(selected) self._mark_descendants(selected) root_nodes = (node for node in nodes if not node.parent) self._mark_siblings(selected, root_nodes) return nodes def _mark_ancestors(self, node): """Marks the ancestors of the selected node.""" while node.parent: node = node.parent node.ancestor = True def _mark_descendants(self, node): """Marks the descendants of the selected node.""" for child in node.children: child.descendant = True self._mark_descendants(child) def _mark_siblings(self, node, root_nodes): """Marks the siblings of the selected node. All root nodes are siblings of a root node.""" if node.parent: for sibling in node.parent.children: if sibling != node: sibling.sibling = True else: for sibling in root_nodes: if sibling != node: sibling.sibling = True def apply_modifiers(self, nodes, namespace=None, root_id=None, post_cut=False, breadcrumb=False): if not post_cut: nodes = self._mark_selected(nodes) # Only fetch modifiers when they're needed. # We can do this because unlike menu classes, # modifiers can't change on a request basis. for cls in self.pool.get_registered_modifiers(): inst = cls(renderer=self) nodes = inst.modify( self.request, nodes, namespace, root_id, post_cut, breadcrumb) return nodes def get_nodes(self, namespace=None, root_id=None, breadcrumb=False): nodes = self._build_nodes() nodes = self.apply_modifiers( nodes=nodes, namespace=namespace, root_id=root_id, post_cut=False, breadcrumb=breadcrumb, ) return nodes def get_menu(self, menu_name): MenuClass = self.menus[menu_name] return MenuClass(renderer=self) class MenuPool: def __init__(self): self.menus = {} self.modifiers = [] self.discovered = False def get_renderer(self, request): self.discover_menus() # Returns a menu pool wrapper that is bound # to the given request and can perform # operations based on the given request. return MenuRenderer(pool=self, request=request) def discover_menus(self): if self.discovered: return autodiscover_modules('cms_menus') from menus.modifiers import register register() self.discovered = True def get_registered_menus(self, for_rendering=False): """ Returns all registered menu classes. :param for_rendering: Flag that when True forces us to include all CMSAttachMenu subclasses, even if they're not attached. """ self.discover_menus() registered_menus = {} for menu_class_name, menu_cls in self.menus.items(): if isinstance(menu_cls, Menu): # A Menu **instance** was registered, # this is non-standard, but acceptable. menu_cls = menu_cls.__class__ if hasattr(menu_cls, "get_instances"): # It quacks like a CMSAttachMenu. # Expand the one CMSAttachMenu into multiple classes. # Each class is bound to the instance the menu is attached to. _get_menu_class = partial(_get_menu_class_for_instance, menu_cls) instances = menu_cls.get_instances() or [] for instance in instances: # For each instance, we create a unique class # that is bound to that instance. # Doing this allows us to delay the instantiation # of the menu class until it's needed. # Plus we keep the menus consistent by always # pointing to a class instead of an instance. namespace = f"{menu_class_name}:{instance.pk}" registered_menus[namespace] = _get_menu_class(instance) if not instances and not for_rendering: # The menu is a CMSAttachMenu but has no instances, # normally we'd just ignore it but it's been # explicitly set that we are not rendering these menus # via the (for_rendering) flag. registered_menus[menu_class_name] = menu_cls elif hasattr(menu_cls, "get_nodes"): # This is another type of Menu, cannot be expanded, but must be # instantiated, none-the-less. registered_menus[menu_class_name] = menu_cls else: raise ValidationError( "Something was registered as a menu, but isn't.") return registered_menus def get_registered_modifiers(self): return self.modifiers def clear(self, site_id=None, language=None, all=False): """ This invalidates the cache for a given menu (site_id and language) """ if all: cache_keys = CacheKey.objects.get_keys() else: cache_keys = CacheKey.objects.get_keys(site_id, language) to_be_deleted = cache_keys.distinct().values_list('key', flat=True) if to_be_deleted: cache.delete_many(to_be_deleted) cache_keys.delete() def register_menu(self, menu_cls): from menus.base import Menu assert issubclass(menu_cls, Menu) if menu_cls.__name__ in self.menus: raise NamespaceAlreadyRegistered( f"[{menu_cls.__name__}] a menu with this name is already registered") # Note: menu_cls should still be the menu CLASS at this point. self.menus[menu_cls.__name__] = menu_cls def register_modifier(self, modifier_class): from menus.base import Modifier assert issubclass(modifier_class, Modifier) if modifier_class not in self.modifiers: self.modifiers.append(modifier_class) def get_menus_by_attribute(self, name, value): """ Returns the list of menus that match the name/value criteria provided. """ # Note that we are limiting the output to only single instances of any # specific menu class. This is to address issue (#4041) which has # cropped-up in 3.0.13/3.0.0. # By setting for_rendering to False # we're limiting the output to menus # that are registered and have instances # (in case of attached menus). menus = self.get_registered_menus(for_rendering=False) return sorted( {(menu.__name__, menu.name) for menu_class_name, menu in menus.items() if getattr(menu, name, None) == value} ) def get_nodes_by_attribute(self, nodes, name, value): return [node for node in nodes if node.attr.get(name, None) == value] menu_pool = MenuPool()