forked from ungleich-public/dynamicweb
396 lines
15 KiB
Python
396 lines
15 KiB
Python
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[<namespace>][<node's id>] = 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()
|