import re from collections import defaultdict from collections.abc import Generator, Iterable from django.utils.functional import SimpleLazyObject from cms import constants from cms.apphook_pool import apphook_pool from cms.models import Page, PageContent, PagePermission, PageUrl from cms.toolbar.utils import get_object_edit_url, get_object_preview_url, get_toolbar_from_request from cms.utils.conf import get_cms_setting from cms.utils.i18n import ( get_fallback_languages, get_public_languages, hide_untranslated, is_valid_site_language, ) from cms.utils.page_permissions import user_can_view_all_pages from menus.base import Menu, Modifier, NavigationNode from menus.menu_pool import menu_pool # Shortcut for visibility markers VISIBLE_FOR_AUTHENTICATED = constants.VISIBILITY_ALL, constants.VISIBILITY_USERS VISIBLE_FOR_ANONYMOUS = constants.VISIBILITY_ALL, constants.VISIBILITY_ANONYMOUS def get_visible_page_contents(request, page_contents: Iterable[PageContent], site) -> Iterable[PageContent]: """ This code is a many-pages-at-once version of cms.utils.page_permissions.user_can_view_page. `pages` contains all published pages. """ public_for = get_cms_setting("PUBLIC_FOR") can_see_unrestricted = public_for == "all" or (public_for == "staff" and request.user.is_staff) if not request.user.is_authenticated and not can_see_unrestricted: # User is not authenticated and can't see unrestricted pages, # no need to check for page restrictions because if there's some, # user is anon and if there is not any, user can't see unrestricted. return [] if user_can_view_all_pages(request.user, site): return page_contents if not get_cms_setting("PERMISSION"): # If there's no restrictions, let the user see all pages # only if he can see unrestricted, otherwise return no pages. return page_contents if can_see_unrestricted else [] restrictions = PagePermission.objects.filter( page_id__in={page_content.page.pk for page_content in page_contents}, can_view=True, ) restriction_map = {perm.page_id: perm for perm in restrictions} user_id = request.user.pk user_groups = SimpleLazyObject(lambda: frozenset(request.user.groups.values_list("pk", flat=True))) is_auth_user = request.user.is_authenticated def user_can_see_page(page: Page) -> bool: if page.pk in restriction_map: # set internal fk cache to our page with loaded ancestors and descendants PagePermission.page.field.set_cached_value(restriction_map[page.pk], page) restricted = False for perm in restrictions: if perm.get_page_permission_tuple().contains(page.path): if not is_auth_user: return False if perm.user_id == user_id or perm.group_id in user_groups: return True restricted = True # Page has no view restrictions, fallback to the project's # CMS_PUBLIC_FOR setting. return can_see_unrestricted and not restricted return list(page_content for page_content in page_contents if user_can_see_page(page_content.page)) class CMSNavigationNode(NavigationNode): """ Represents a CMS Navigation Node for a Page object in the page tree. Attributes: path: The path of the node. language: The language used for the node (optional). """ def __init__(self, *args, language: str | None = None, **kwargs): """ Initializes a CMSNavigationNode instance. :param *args: Positional arguments. :param language: The language used for the node. Optional. :param **kwargs: Keyword arguments. """ # language is only used when we're dealing with a fallback self.language = language super().__init__(*args, **kwargs) def is_selected(self, request) -> bool: try: return request.current_page.pk == self.id except AttributeError: return False class CMSMenu(Menu): """Subclass of :class:`menus.base.Menu`. Its :meth:`~menus.base.Menu.get_nodes()` creates a list of NavigationNodes based on a site's :class:`cms.models.pagemodel.Page` objects. """ def __init__(self, renderer): """ Initializes a CMSMenu instance. :param renderer: The renderer object. """ super().__init__(renderer) lang = renderer.request_language site_pk = renderer.site.pk if is_valid_site_language(lang, site_id=site_pk): _valid_language = True _hide_untranslated = hide_untranslated(lang, site_pk) else: _valid_language = False _hide_untranslated = False if _valid_language: # The request language has been explicitly configured # for the current site. if _hide_untranslated: fallbacks = [] else: fallbacks = get_fallback_languages(lang, site_id=site_pk) self.languages = [lang] + [_lang for _lang in fallbacks if _lang != lang] else: # The request language is not configured for the current site. # Fallback to all configured public languages for the current site. self.languages = get_public_languages(site_pk) def select_lang(self, page_contents: Iterable[PageContent]) -> Generator[PageContent, None, None]: """Generator that returns only those page content objects passed that contain the first language present in the languages list.""" lang_index = len(self.languages) translation = None page = None for page_content in page_contents: if translation and page != page_content.page: yield translation lang_index = self.languages.index(page_content.language) translation = page_content page = page_content.page elif self.languages.index(page_content.language) < lang_index: lang_index = self.languages.index(page_content.language) translation = page_content page = page_content.page if translation: yield translation def get_menu_node_for_page_content( self, page_content: PageContent, view_url: str | None = None, cut: bool = False, ) -> CMSNavigationNode: """ Transform a CMS page content object into a navigation node. :param page: The page to transform. :param languages: The list of the current language plus fallbacks used to render the menu. :param view_url: If given, serves as a "pattern" for a view url with the assumption that "/0/" is replaced by the actual page content pk. Default is None. :param cut: If True the parent_id is set to None. Default is False. :returns: A CMSNavigationNode instance. """ page = page_content.page # These are simple to port over, since they are not calculated. # Other attributes will be added conditionally later. visibility = page_content.limit_visibility_in_menu attr = { "is_page": True, "soft_root": page_content.soft_root, "redirect_url": page_content.redirect, "auth_required": page.login_required, "reverse_id": page.reverse_id, "is_home": page.is_home, "visible_for_authenticated": visibility in VISIBLE_FOR_AUTHENTICATED, "visible_for_anonymous": visibility in VISIBLE_FOR_ANONYMOUS, } extenders = [] if page.navigation_extenders: if page.navigation_extenders in self.renderer.menus: extenders.append(page.navigation_extenders) elif f"{page.navigation_extenders}:{page.pk}" in self.renderer.menus: extenders.append(f"{page.navigation_extenders}:{page.pk}") # Is this page an apphook? If so, we need to handle the apphooks's nodes # Only run this if we have a translation in the requested language for this # object. The page content cache should have been prepopulated in CMSMenu.get_nodes # but otherwise, just request the title normally if page.application_urls and page_content.language == self.languages[0]: # it means it is an apphook app = apphook_pool.get_apphook(page.application_urls) if app: extenders.extend(app.get_menus(page, self.languages[0])) # CMSAattachMenus are treated a bit differently to allow them to be # able to be attached to multiple points in the navigation. attr["navigation_extenders"] = [ f"{ext.__name__}:{page.pk}" if hasattr(ext, "get_instances") else getattr(ext, "__name__", ext) for ext in extenders ] # Now finally, build the NavigationNode object and return it. # The parent_id is manually set by the menu get_nodes method. if view_url: # Build view url by replacing "/0/" in the url template by the actual pk of the page content object # Hacky, but faster than calling `admin_reverse` for each page content object url = re.sub("(/0/)", f"/{page_content.pk}/", view_url) else: url = page.get_absolute_url(language=page_content.language) return CMSNavigationNode( title=page_content.menu_title or page_content.title, url=url, id=page.pk, parent_id=None if cut else page_content.page.parent_id, attr=attr, visible=page_content.in_navigation, language=(page_content.language if page_content.language != self.languages[0] else None), ) def get_nodes(self, request) -> list[NavigationNode]: """ Returns a list of NavigationNode objects representing the navigation nodes to be displayed in the menu. This method is performance-critical since the number of page content objects can be large. :param self: The instance of the class. :param request: The HTTP request object. :return: A list of NavigationNode objects representing the navigation nodes. :rtype: list[NavigationNode] .. note:: * The method retrieves the necessary data from the database to build the navigation nodes for the menu. * The behavior of the method depends on whether the edit mode or preview mode is active in the toolbar. * If either edit mode or preview mode is active, the method retrieves all current page content objects visible in the admin for the current page. * If neither edit mode nor preview mode is active, the method retrieves only public page content objects. * The retrieved page contents are filtered based on the specified languages, sorted by page path, and filtered by site. * Only specific fields of the page content objects are selected to optimize performance. * If either edit mode or preview mode is active, a preview URL is constructed for a "virtual" non-existing page content with id=0 to avoid too many calls to ``revert`` the admin URL. * The method includes a nested function for prefetching URLs and filling the URL cache. * The visibility of the page contents is further filtered based on authentication and permissions. * The homepage is determined based on the page contents and marked for cutting if necessary. * The menu node for each page content is created using the get_menu_node_for_page_content method of the instance. * The prefetch_urls function is called for each page content to fill the URL cache and provide necessary data for creating the menu node. * The select_lang method is used to filter the page contents based on the specified language preferences. """ site = self.renderer.site toolbar = get_toolbar_from_request(request) if toolbar.edit_mode_active or toolbar.preview_mode_active: # Get all translations visible in the admin for the current page translations_qs = PageContent.admin_manager.current_content() else: # Only get public translations translations_qs = PageContent.objects page_contents = ( translations_qs.filter(language__in=self.languages) .order_by("page__path") .filter(page__site=site) .select_related("page") .only( "page_id", "language", "menu_title", "title", "limit_visibility_in_menu", "soft_root", "in_navigation", "redirect", "page__site_id", "page__parent_id", "page__path", # needed by the view-restriction check in get_visible_page_contents "page__is_home", "page__login_required", "page__reverse_id", "page__navigation_extenders", "page__application_urls", ) ) if toolbar.edit_mode_active or toolbar.preview_mode_active: # Edit URL for a "virtual" non-existing page content with id=0. This is used to quickly build many # edit urls by replacing "/0/" by the page content pk in the edit url view_url = get_object_edit_url(PageContent(id=0)) if toolbar.edit_mode_active else get_object_preview_url(PageContent(id=0)) def prefetch_urls(page_content: PageContent) -> PageContent: return page_content else: view_url = None # No short-cut here prefetched_urls = PageUrl.objects.filter( language__in=(page_content.language for page_content in page_contents), page_id__in=(page_content.page.pk for page_content in page_contents), ) # Fetch the PageUrl objects # Prepare for filling urls_cache filtered_urls = defaultdict(dict) for page_url in prefetched_urls: filtered_urls[page_url.page_id][page_url.language] = page_url def prefetch_urls(page_content: PageContent) -> PageContent: # Fill url cache for existing urls and page contents into the cache # Access the cache directly to not lead to a db hit when accessing # non-existing languages page_content.page.urls_cache = filtered_urls.get(page_content.page_id) return page_content page_contents = get_visible_page_contents(request, page_contents, site) home = next((page_content for page_content in page_contents if page_content.page.is_home), None) # Find homepage cut_homepage = home and not home.in_navigation homepage_pk = home.page.pk if home else None return [ self.get_menu_node_for_page_content( prefetch_urls(page_content), view_url=view_url, cut=page_content.page.parent_id == homepage_pk and cut_homepage, ) for page_content in self.select_lang(page_contents) ] menu_pool.register_menu(CMSMenu) class NavExtender(Modifier): def modify(self, request, nodes, namespace, root_id, post_cut, breadcrumb): if post_cut: return nodes # rearrange the parent relations # Find home home = next((n for n in nodes if n.attr.get("is_home", False)), None) # Find nodes with NavExtenders exts = [] for node in nodes: extenders = node.attr.get("navigation_extenders", None) if extenders: for ext in extenders: if ext not in exts: exts.append(ext) # Link the nodes for extnode in nodes: if extnode.namespace == ext and not extnode.parent_id: # if home has nav extenders but home is not visible if node == home and not node.visible: # extnode.parent_id = None extnode.parent_namespace = None extnode.parent = None else: extnode.parent_id = node.id extnode.parent_namespace = node.namespace extnode.parent = node node.children.append(extnode) removed = [] # find all not assigned nodes for menu in self.renderer.menus.items(): if hasattr(menu[1], "cms_enabled") and menu[1].cms_enabled and menu[0] not in exts: for node in nodes: if node.namespace == menu[0]: removed.append(node) if breadcrumb: # if breadcrumb and home not in navigation add node if breadcrumb and home and not home.visible: home.visible = True if request.path_info == home.get_absolute_url(): home.selected = True else: home.selected = False # remove all nodes that are nav_extenders and not assigned for node in removed: nodes.remove(node) return nodes menu_pool.register_modifier(NavExtender) class SoftRootCutter(Modifier): """ A soft root is a page that acts as the root for a menu navigation tree. Typically, this will be a page that is the root of a significant new section on your site. When the soft root feature is enabled, the navigation menu for any page will start at the nearest soft root, rather than at the real root of the site’s page hierarchy. This feature is useful when your site has deep page hierarchies (and therefore multiple levels in its navigation trees). In such a case, you usually don’t want to present site visitors with deep menus of nested items. For example, you’re on the page -Introduction to Bleeding-?, so the menu might look like this: * School of Medicine * Medical Education * Departments * Department of Lorem Ipsum * Department of Donec Imperdiet * Department of Cras Eros * Department of Mediaeval Surgery * Theory * Cures * Bleeding * Introduction to Bleeding * Bleeding - the scientific evidence * Cleaning up the mess * Cupping * Leaches * Maggots * Techniques * Instruments * Department of Curabitur a Purus * Department of Sed Accumsan * Department of Etiam * Research * Administration * Contact us * Impressum which is frankly overwhelming. By making "Department of Mediaeval Surgery" a soft root, the menu becomes much more manageable: * Department of Mediaeval Surgery * Theory * Cures * Bleeding * Introduction to Bleeding * Bleeding - the scientific evidence * Cleaning up the mess * Cupping * Leaches * Maggots * Techniques * Instruments """ def modify(self, request, nodes, namespace, root_id, post_cut, breadcrumb): # only apply this modifier if we're pre-cut (since what we do is cut) # or if no id argument is provided, indicating {% show_menu_below_id %} if post_cut or root_id: return nodes selected = None root_nodes = [] # find the selected node as well as all the root nodes for node in nodes: if node.selected: selected = node if not node.parent: root_nodes.append(node) # if we found a selected ... if selected: # and the selected is a softroot if selected.attr.get("soft_root", False): # get it's descendants nodes = selected.get_descendants() # remove the link to parent selected.parent = None # make the selected page the root in the menu nodes = [selected] + nodes else: # if it's not a soft root, walk ancestors (upwards!) nodes = self.find_ancestors_and_remove_children(selected, nodes) return nodes def find_and_remove_children(self, node, nodes): for child in node.children: if child.attr.get("soft_root", False): self.remove_children(child, nodes) return nodes def remove_children(self, node, nodes): for child in node.children: nodes.remove(child) self.remove_children(child, nodes) node.children = [] def find_ancestors_and_remove_children(self, node, nodes): """ Check ancestors of node for soft roots """ if node.parent: if node.parent.attr.get("soft_root", False): nodes = node.parent.get_descendants() node.parent.parent = None nodes = [node.parent] + nodes else: nodes = self.find_ancestors_and_remove_children(node.parent, nodes) else: for newnode in nodes: if newnode != node and not newnode.parent: self.find_and_remove_children(newnode, nodes) for child in node.children: if child != node: self.find_and_remove_children(child, nodes) return nodes menu_pool.register_modifier(SoftRootCutter)