dynamicweb/cms/cms_menus.py

526 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <this is the current page>
* 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 <current page>
* 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)