Source code for flex_menu.renderers

"""Renderer system converting processed menu items into HTML using templates."""

import logging
from typing import Any

from django.conf import settings
from django.forms import Media
from django.template.loader import render_to_string
from django.utils.module_loading import import_string
from django.utils.safestring import mark_safe

from .menu import MenuItem

logger = logging.getLogger(__name__)


[docs] class BaseRenderer: """Base renderer class for menu rendering. Renderers define how menu items are presented using templates. Templates are selected based on menu depth and properties. An optional inner ``Media`` class defines CSS and JS dependencies for this renderer, following Django's ``Form.Media``/``ModelAdmin.Media`` pattern. Attributes: templates: Mapping of depth -> template configuration. Each depth can have 'parent' and 'leaf' templates, or just 'default'. If a depth is not found, uses the 'default' key if present, otherwise raises an error. Example: :: class MyRenderer(BaseRenderer): templates = { 0: {"default": "menu/container.html"}, 1: { "parent": "menu/dropdown.html", "leaf": "menu/item.html", }, } class Media: css = {"all": ("menu/styles.css",)} js = ("menu/scripts.js",) """ templates: dict[int | str, dict[str, str]] = { "default": { "parent": "menu/group.html", "leaf": "menu/item.html", } } def __init__(self): if hasattr(self, "Media"): self._media = Media(self.Media) else: self._media = Media() @property def media(self): """Return media assets (CSS/JS) required by this renderer.""" return self._media
[docs] def get_template(self, item: MenuItem) -> str: """Get the template path for a menu item based on depth and properties. Args: item: The menu item to render. Returns: Template path string. Raises: ValueError: If no template found for this depth and no default provided. """ depth = item.depth depth_templates = self.templates.get(depth) if not depth_templates: depth_templates = self.templates.get("default") if not depth_templates: supported_depths = [k for k in self.templates if k != "default"] raise ValueError( f"Renderer {self.__class__.__name__} does not support depth {depth}. " f"Supported depths: {supported_depths}. " f"Add a 'default' key to templates dict to handle arbitrary depths." ) template_key = "parent" if item.has_children else "leaf" template = depth_templates.get(template_key) if not template: template = depth_templates.get("default") if not template: raise ValueError( f"Renderer {self.__class__.__name__} has no template for " f"depth={depth}, key='{template_key}'. " f"Available keys: {list(depth_templates.keys())}" ) return template
[docs] def get_context_data(self, item: MenuItem, **kwargs) -> dict[str, Any]: """Build context data for template rendering. Args: item: The menu item to render. **kwargs: Additional context data. Returns: Dictionary of context data for the template. """ context = { "item": item, "renderer": self, "depth": item.depth, "visible": item.visible, "children": item.visible_children, "selected": item.selected, "label": item.name, "url": item.url if item.url else None, **item.extra_context, **kwargs, } return context
[docs] def render(self, item: MenuItem, **kwargs) -> str: """Render a menu item using its template. Args: item: The menu item to render. **kwargs: Additional context data. Returns: Rendered HTML string, or empty string if item is not visible. """ if not item.visible: return "" template = self.get_template(item) context = self.get_context_data(item, **kwargs) # render_to_string has already autoescaped every value it interpolated, so # what comes back is markup this renderer produced, not user input. return mark_safe(render_to_string(template, context)) # noqa: S308
[docs] def get_renderer(name: str | None = None) -> BaseRenderer: """Get a renderer instance by name from settings. Loads renderer class path from FLEX_MENUS['renderers'] dict in settings. If no name provided, uses FLEX_MENUS['default_renderer']. Args: name: Renderer name or None for default. Returns: Renderer instance. Raises: ValueError: If renderer not found in settings. ImportError: If renderer class cannot be imported. TypeError: If the imported renderer class has no 'render' method. Example: :: # In settings.py FLEX_MENUS = { "renderers": { "bootstrap5": "myapp.renderers.Bootstrap5Renderer", "tailwind": "myapp.renderers.TailwindRenderer", }, "default_renderer": "bootstrap5", } # In code or template renderer = get_renderer("bootstrap5") html = renderer.render(menu_item) """ config = getattr(settings, "FLEX_MENUS", {}) renderers = config.get("renderers", {}) if name is None: name = config.get("default_renderer", "default") renderer_path = renderers.get(name) if not renderer_path: if name == "default": return BaseRenderer() available = list(renderers.keys()) raise ValueError( f"Renderer '{name}' not found in FLEX_MENUS['renderers']. " f"Available renderers: {available or ['(none configured)']}. " f"Add to settings.py: FLEX_MENUS = {{ 'renderers': {{ '{name}': 'path.to.RendererClass' }} }}" ) try: renderer_class = import_string(renderer_path) except ImportError as e: raise ImportError( f"Cannot import renderer '{name}' from '{renderer_path}': {e}" ) from e if not hasattr(renderer_class, "render"): raise TypeError(f"Renderer class '{renderer_path}' must have a 'render' method") return renderer_class() # type: ignore[no-any-return]