Source code for flex_menu.menu
"""Menu and MenuItem, the tree that menus are declared with."""
import logging
from collections.abc import Callable
from typing import Optional
from urllib.parse import urlencode, urlsplit
from anytree import Node, RenderTree, search
from django.conf import settings
from django.core.handlers.wsgi import WSGIRequest
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.functional import Promise
from .utils import get_required_url_params
[docs]
def _should_log_url_failures():
"""Report whether URL resolution failures should be logged.
Failed URL resolution is often expected, such as a menu item hidden
because the user lacks permission or an optional view isn't installed, so
logging defaults to DEBUG and can be overridden with the
FLEX_MENUS['log_url_failures'] setting.
Returns:
True if URL failures should be logged, False otherwise.
"""
return getattr(settings, "FLEX_MENUS", {}).get("log_url_failures", settings.DEBUG)
[docs]
class _NoParentType:
"""Sentinel distinguishing "no parent specified" from "explicitly no parent"."""
pass
_NO_PARENT = _NoParentType()
[docs]
class MenuItem(Node):
"""A menu item that is a clickable link, a container, or a non-clickable item.
A MenuItem is a clickable link (has ``url``/``view_name``), a
container/parent (has children), or a non-clickable item (headers,
dividers). It cannot have both a URL and children - it must be either a
link OR a container, not both.
Args:
name: Unique identifier for this menu item.
view_name: Django URL name for reverse resolution.
url: Static URL string or callable returning URL.
params: Query parameters dict to append to URL.
parent: Parent menu item (or None for root-level items).
children: List of child menu items.
check: A ``callable(request, **kwargs) -> bool`` or a boolean value.
extra_context: Additional context for template rendering.
**kwargs: Additional attributes for the node.
Processing a copy (see ``process()``) also sets ``visible``, ``selected``
and the resolved ``url`` on that copy.
Attributes:
request: The current request, set on the copy ``process()`` returns.
Raises:
ValueError: If both URL/view_name and children are provided.
"""
request: WSGIRequest | None
_processed_children: list["MenuItem"]
_original_children: tuple["MenuItem", ...]
_cached_url: str | None
def __init__(
self,
name: str,
view_name: str = "",
url: str | Callable = "",
params: dict | None = None,
parent: Optional["MenuItem"] | _NoParentType = None,
children: list["MenuItem"] | None = None,
check: Callable | bool = True,
extra_context: dict | None = None,
**kwargs,
):
if (view_name or url) and children:
raise ValueError(
f"MenuItem '{name}' cannot have both a URL/view_name and children. "
f"Menu items must be EITHER a link OR a container, not both. "
f"If you need a clickable item in a dropdown, add it as the first child."
)
if parent is None:
parent = root
elif parent is _NO_PARENT:
parent = None
super().__init__(name, parent=parent, children=children, **kwargs)
self.view_name = view_name
self._url = url
self.params = params or {}
self._check = check
self.extra_context = extra_context or {}
self.visible = False
self.selected = False
self.url: str | None = None
self.request: WSGIRequest | None = None
self._processed_children: list[MenuItem] = []
[docs]
def __str__(self) -> str:
"""Show the menu item's name."""
return f"MenuItem(name={self.name})"
[docs]
def __getitem__(self, name: str) -> "MenuItem":
"""Get child by name using bracket notation."""
node = self.get(name)
if node is None:
raise KeyError(f"No child with name {name} found.")
return node
@property
def has_url(self) -> bool:
"""True if this menu item has a URL (view_name or url)."""
return bool(self.view_name or self._url)
@property
def has_children(self) -> bool:
"""True if this menu has child items."""
return len(self.children) > 0 # type: ignore[has-type]
@property
def visible_children(self) -> list["MenuItem"]:
"""Return processed, visible children (after processing)."""
return self._processed_children
@property
def has_visible_children(self) -> bool:
"""True if this menu has visible children after processing."""
return len(self._processed_children) > 0
@property
def is_parent(self) -> bool:
"""Alias for has_children."""
return self.has_children
@property
def is_leaf(self) -> bool:
"""True if this is a leaf node (no children)."""
return not self.has_children
@property
def is_clickable(self) -> bool:
"""True if this item can be clicked (has URL)."""
return self.has_url
@property
def depth(self) -> int:
"""Depth in the tree, where 0 is the root, 1 is top-level items, and so on."""
return len(self.path) - 1
[docs]
def append(self, child: "MenuItem") -> None:
"""Append a child menu item.
Args:
child: The child menu item to append.
Raises:
ValueError: If this item has a URL (cannot have both URL and children).
"""
if self.has_url:
raise ValueError(
f"MenuItem '{self.name}' has a URL and cannot have children. "
f"Menu items must be EITHER a link OR a container, not both."
)
child.parent = self # type: ignore[has-type]
[docs]
def extend(self, children: list["MenuItem"]) -> None:
"""Append multiple child menu items.
Args:
children: List of child menu items to append.
Raises:
ValueError: If this item has a URL (cannot have both URL and children).
"""
if self.has_url:
raise ValueError(
f"MenuItem '{self.name}' has a URL and cannot have children. "
f"Menu items must be EITHER a link OR a container, not both."
)
for child in children:
child.parent = self # type: ignore[has-type]
[docs]
def insert(
self,
children: "MenuItem | list[MenuItem]",
position: int,
) -> None:
"""Insert child menu items at a specified position.
Args:
children: A child or list of children to insert.
position: Position index to insert at.
Raises:
ValueError: If this item has a URL (cannot have both URL and children).
"""
if self.has_url:
raise ValueError(
f"MenuItem '{self.name}' has a URL and cannot have children. "
f"Menu items must be EITHER a link OR a container, not both."
)
if not isinstance(children, list):
children = [children]
old = list(self.children) # type: ignore[has-type]
new = old[:position] + children + old[position:]
self.children = new
[docs]
def insert_after(self, child: "MenuItem", named: str) -> None:
"""Insert a child menu item after an existing child with specified name.
Args:
child: The new child menu item to insert.
named: The name of the existing child after which to insert.
Raises:
ValueError: If no child with specified name exists or if this item has a URL.
"""
if self.has_url:
raise ValueError(
f"MenuItem '{self.name}' has a URL and cannot have children. "
f"Menu items must be EITHER a link OR a container, not both."
)
existing_child = self.get(named)
if existing_child:
children_list = list(self.children)
insert_index = children_list.index(existing_child) + 1
self.children = [
*children_list[:insert_index],
child,
*children_list[insert_index:],
]
else:
raise ValueError(f"No child with name '{named}' found.")
[docs]
def pop(self, name: str | None = None) -> "MenuItem":
"""Remove a child node or detach the current node from its parent.
Args:
name: The name of the child to remove. If None, removes this node.
Returns:
The removed node.
Raises:
ValueError: If no child with the specified name exists.
"""
if name:
node = self.get(name)
if node:
node.parent = None # type: ignore[has-type]
return node
else:
raise ValueError(f"No child with name {name} found.")
self.parent = None
return self
[docs]
def get(self, name: str, maxlevel: int | None = None) -> Optional["MenuItem"]:
"""Find a child node by name.
Args:
name: The name of the child node to find.
maxlevel: The maximum depth to search.
1 = direct children only, 2 = children and grandchildren, etc.
Returns:
The child node, or None if not found.
"""
if not name:
return None
# anytree counts maxlevel from 1 at the search root, so maxlevel=1
# (direct children) needs anytree's maxlevel=2.
anytree_maxlevel = maxlevel + 1 if maxlevel is not None else None
result = search.find_by_attr(
self, value=name, name="name", maxlevel=anytree_maxlevel
)
return result # type: ignore[no-any-return]
[docs]
def print_tree(self) -> str:
"""Print the menu tree structure.
Returns:
A string representation of the tree.
"""
result = RenderTree(self).by_attr("name")
return str(result)
[docs]
def check(self, request, **kwargs) -> bool:
"""Check if the menu item is visible based on the request.
Args:
request: The HTTP request object.
**kwargs: Additional arguments for custom check functions.
Returns:
True if the menu item is visible, False otherwise.
"""
if callable(self._check):
result = self._check(request, **kwargs)
return bool(result)
return bool(self._check)
[docs]
def process(self, request, **kwargs) -> "MenuItem":
"""Process the menu item for a specific request.
Creates a processed copy with request-specific state to avoid race conditions.
For items with children, recursively processes all children.
Args:
request: The HTTP request object.
**kwargs: Additional arguments passed to check functions and URL resolution.
Returns:
A processed copy of this menu item with request-specific state.
"""
# Create shallow copy to avoid mutating the shared instance
processed = self._create_request_copy()
processed.request = request
processed.visible = processed.check(request, **kwargs)
if not processed.visible:
return processed
if processed.has_url:
processed.url = processed.resolve_url(**kwargs)
if not processed.url:
# Parents without a resolvable URL still show via their children;
# leaf nodes with no URL have nothing left to display.
if not processed.has_children:
processed.visible = False
return processed
else:
processed.match_url()
children_to_process = getattr(
processed, "_original_children", processed.children
)
if children_to_process:
processed_children = []
for child in children_to_process:
processed_child = child.process(request, **kwargs)
if processed_child.visible:
processed_child.parent = processed
processed_children.append(processed_child)
processed._processed_children = processed_children
# If any child is selected, this item is also selected so that
# ancestor chains of a selected item stay open/highlighted.
if any(child.selected for child in processed_children):
processed.selected = True
if not processed.has_url and not processed_children:
processed.visible = False
return processed
[docs]
def _create_request_copy(self) -> "MenuItem":
"""Create a shallow copy for request processing.
Creates a copy that maintains the tree structure so depth calculations work.
The copy will be detached from the global root but maintain proper parent-child relationships.
"""
# The copy starts detached whether this item sits under another item or at
# the top of the tree: a nested copy gets its parent when that parent
# processes it as a child, and a top-level one never has any to get.
parent_copy = _NO_PARENT
copy_instance = self.__class__(
name=self.name,
view_name=self.view_name,
url=self._url,
params=self.params.copy() if self.params else None,
parent=parent_copy,
check=self._check,
extra_context=self.extra_context.copy(),
)
copy_instance._original_children = self.children # type: ignore[assignment]
return copy_instance
[docs]
def resolve_url(self, *args, **kwargs) -> str | None:
"""Resolve the URL for this menu item.
Supports three types of URLs:
- Django view names (resolved via reverse())
- Static URL strings
- Callable functions that return URLs
Args:
*args: Positional arguments for URL resolution.
**kwargs: Keyword arguments for URL resolution. Extra kwargs not needed
for the URL pattern will be filtered out automatically.
Returns:
The resolved URL string, or None if resolution fails.
"""
if not args and not kwargs and hasattr(self, "_cached_url"):
return self._cached_url
if self.view_name:
# Extra kwargs may carry context (e.g. object instances) that aren't
# URL params, so only pass through what the URL pattern accepts.
filtered_kwargs = kwargs
if kwargs:
try:
param_names = set(get_required_url_params(self.view_name))
logger_instance = logging.getLogger(__name__)
logger_instance.debug(
f"URL param extraction for '{self.view_name}': param_names={param_names}, kwargs={list(kwargs.keys())}"
)
if param_names:
filtered_kwargs = {
k: v for k, v in kwargs.items() if k in param_names
}
logger_instance.debug(
f"Filtered kwargs for '{self.view_name}': {list(filtered_kwargs.keys())}"
)
except NoReverseMatch:
logger_instance = logging.getLogger(__name__)
logger_instance.debug(
f"Could not find URL pattern for '{self.view_name}', using all kwargs"
)
try:
url = reverse(self.view_name, args=args, kwargs=filtered_kwargs)
except NoReverseMatch as e:
if _should_log_url_failures():
logger = logging.getLogger(__name__)
logger.warning(
f"Could not reverse URL for view '{self.view_name}' in menu item '{self.name}'"
)
logger.warning(f"Reverse error: {e}")
try:
param_names_for_log = set(
get_required_url_params(self.view_name)
)
if param_names_for_log:
logger.warning(
f"Detected URL params: {param_names_for_log}"
)
logger.warning(f"Filtered kwargs: {filtered_kwargs}")
else:
logger.warning(
f"Could not detect URL params - passed all kwargs: {list(kwargs.keys())}"
)
except NoReverseMatch:
logger.warning(
f"Could not detect URL params - passed all kwargs: {list(kwargs.keys())}"
)
if not args and not kwargs:
self._cached_url = None
return None
else:
if not args and not kwargs:
self._cached_url = url
return url
elif self._url and callable(self._url):
try:
return self._url(self.request, *args, **kwargs) # type: ignore[no-any-return]
except Exception as e:
if _should_log_url_failures():
logger = logging.getLogger(__name__)
logger.warning(
f"Error calling URL function for menu item '{self.name}': {e}"
)
return None
elif self._url:
static_url: str = self._url
if self.params:
query_string = urlencode(self.params)
separator = "&" if "?" in static_url else "?"
static_url = static_url + separator + query_string
if not args and not kwargs:
self._cached_url = static_url
return static_url
return None
[docs]
@staticmethod
def _normalized_path(value: str | Promise) -> str:
"""Strip query string/fragment and any single trailing slash for comparison.
A menu declared at module level resolves its URLs with reverse_lazy,
because the URLconf is not loaded when the module is imported. That
hands the item a lazy proxy, and urlsplit only accepts str or bytes,
so the proxy is resolved here before the split.
"""
path = urlsplit(str(value)).path
if len(path) > 1:
path = path.rstrip("/")
return path
[docs]
def match_url(self) -> bool:
"""Check if the menu item points at the current request.
Items with a ``view_name`` are matched against the request's resolved
view name, which identifies the destination Django resolved to rather
than a reconstructed path string. That handles a request whose path
carries kwargs for a different object, an i18n language prefix, or
query string/trailing-slash variation, all of which a raw path
comparison gets wrong.
Items configured with a literal ``url`` or a callable have no
resolved view to compare against, so they fall back to a normalized
path comparison (query string, fragment and trailing slash ignored).
Returns:
True if the item matches the current request, False otherwise.
"""
if not self.request:
self.selected = False
return False
resolver_match = getattr(self.request, "resolver_match", None)
if self.view_name and resolver_match is not None:
self.selected = getattr(resolver_match, "view_name", None) == self.view_name
return self.selected
url = getattr(self, "url", None)
if not url:
self.selected = False
return False
self.selected = self._normalized_path(url) == self._normalized_path(
self.request.path
)
return self.selected
# Global root menu instance
root = MenuItem("DjangoFlexMenu", parent=_NO_PARENT)
[docs]
class Menu(MenuItem):
"""Top-level menu container that automatically registers itself to the root.
This is a convenience class for defining menus. It's functionally identical
to MenuItem but automatically attaches to the global root menu, and cannot
have a URL (it is a container only).
Args:
name: Unique identifier for this menu.
children: List of child menu items.
check: A ``callable(request, **kwargs) -> bool`` or a boolean value.
extra_context: Additional context for template rendering.
**kwargs: Additional attributes for the node.
Example:
::
NavMenu = Menu(
"main_nav",
children=[
MenuItem(name="home", label="Home", view_name="home"),
MenuItem(name="about", label="About", view_name="about"),
],
)
Then in a template::
{% render_menu 'main_nav' renderer='bootstrap5' %}
"""
def __init__(
self,
name: str,
children: list["MenuItem"] | None = None,
check: Callable | bool = True,
extra_context: dict | None = None,
**kwargs,
):
super().__init__(
name=name,
parent=root,
children=children,
check=check,
extra_context=extra_context,
**kwargs,
)
[docs]
def _create_request_copy(self) -> "MenuItem":
"""Use MenuItem's copy construction instead of Menu's root-attaching one."""
# Detached for the same reason as MenuItem._create_request_copy: the
# parent attaches the copy while processing it as a child, if there is one.
parent_copy = _NO_PARENT
# Use MenuItem directly, not self.__class__, to avoid Menu's parent=root
copy_instance = MenuItem(
name=self.name,
view_name=self.view_name,
url=self._url,
params=self.params.copy() if self.params else None,
parent=parent_copy,
check=self._check,
extra_context=self.extra_context.copy(),
)
copy_instance._original_children = self.children # type: ignore[assignment]
return copy_instance