from django.core.exceptions import ImproperlyConfigured from django.forms import Media, MediaDefiningClass from django.utils.functional import cached_property from wagtail import hooks from wagtail.admin.ui.sidebar import LinkMenuItem as LinkMenuItemComponent from wagtail.admin.ui.sidebar import SubMenuItem as SubMenuItemComponent from wagtail.coreutils import cautious_slugify class MenuItem(metaclass=MediaDefiningClass): def __init__( self, label, url, name=None, classname="", icon_name="", attrs=None, order=1000, ): self.label = label self.url = url self.classname = classname self.icon_name = icon_name self.name = name or cautious_slugify(str(label)) self.attrs = attrs or {} self.order = order def is_shown(self, request): """ Whether this menu item should be shown for the given request; permission checks etc should go here. By default, menu items are shown all the time """ return True def render_component(self, request): return LinkMenuItemComponent( self.name, self.label, self.url, icon_name=self.icon_name, classname=self.classname, attrs=self.attrs, ) class DismissibleMenuItemMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.attrs["data-controller"] = "w-dismissible" self.attrs["data-w-dismissible-dismissed-class"] = "w-dismissible--dismissed" self.attrs["data-w-dismissible-id-value"] = self.name def render_component(self, request): profile = getattr(request.user, "wagtail_userprofile", None) # Menu item instances are cached, so make sure the existence of the # data-w-dismissible-dismissed-value attribute is correct for the user if profile and profile.dismissibles.get(self.name): self.attrs["data-w-dismissible-dismissed-value"] = "true" else: self.attrs.pop("data-w-dismissible-dismissed-value", None) return super().render_component(request) class DismissibleMenuItem(DismissibleMenuItemMixin, MenuItem): pass class Menu: def __init__(self, register_hook_name=None, construct_hook_name=None, items=None): if register_hook_name is not None and not isinstance(register_hook_name, str): raise ImproperlyConfigured( "Expected a string or None as register_hook_name, got %r. " "Did you mean to pass an `items` keyword argument instead?" % register_hook_name ) self.register_hook_name = register_hook_name self.construct_hook_name = construct_hook_name self.initial_menu_items = items @cached_property def registered_menu_items(self): # Construct the list of menu items from the set passed to the constructor along with any # registered through hooks. We can't do this in __init__ because we can't rely on all hooks # modules to have been imported at the point that we create the admin_menu and # settings_menu instances if self.initial_menu_items: items = self.initial_menu_items.copy() else: items = [] if self.register_hook_name: for fn in hooks.get_hooks(self.register_hook_name): items.append(fn()) return items def menu_items_for_request(self, request): items = [item for item in self.registered_menu_items if item.is_shown(request)] # provide a hook for modifying the menu, if construct_hook_name has been set if self.construct_hook_name: for fn in hooks.get_hooks(self.construct_hook_name): fn(request, items) return items @property def media(self): media = Media() for item in self.registered_menu_items: media += item.media return media def render_component(self, request): menu_items = self.menu_items_for_request(request) rendered_menu_items = [] for item in sorted(menu_items, key=lambda i: i.order): rendered_menu_items.append(item.render_component(request)) return rendered_menu_items class SubmenuMenuItem(MenuItem): """A MenuItem which wraps an inner Menu object""" def __init__(self, label, menu, **kwargs): self.menu = menu super().__init__(label, "#", **kwargs) def is_shown(self, request): # show the submenu if one or more of its children is shown return bool(self.menu.menu_items_for_request(request)) def render_component(self, request): return SubMenuItemComponent( self.name, self.label, self.menu.render_component(request), icon_name=self.icon_name, classname=self.classname, attrs=self.attrs, ) class DismissibleSubmenuMenuItem(DismissibleMenuItemMixin, SubmenuMenuItem): pass class AdminOnlyMenuItem(MenuItem): """A MenuItem which is only shown to superusers""" def is_shown(self, request): return request.user.is_superuser class WagtailMenuRegisterable: #: The icon used for the menu item that appears in Wagtail's sidebar. menu_icon = "" #: The displayed label used for the menu item. menu_label = "" #: The ``name`` argument passed to the ``MenuItem`` constructor, becoming the ``name`` attribute value for that instance. #: This can be useful when manipulating the menu items in a custom menu hook, e.g. :ref:`construct_main_menu`. #: If unset, a slugified version of :attr:`menu_label` is used. menu_name = "" #: An integer determining the order of the menu item, 0 being the first place. #: By default, it will be the last item before Reports, whose order is 9000. menu_order = 8999 #: The URL to be used for the menu item. menu_url = None #: Register the menu item within the admin's main menu. add_to_admin_menu = False #: Register the menu item within the admin's "Settings" menu. #: This takes precedence if both ``add_to_admin_menu`` and ``add_to_settings_menu`` are set to ``True``. add_to_settings_menu = False @cached_property def menu_item_class(self): """A ``wagtail.admin.menu.MenuItem`` subclass to be registered with a menu hook.""" return type( f"{self.__class__.__name__}MenuItem", (MenuItem,), {}, ) def get_menu_item(self, order=None): """ Returns a ``wagtail.admin.menu.MenuItem`` instance to be registered with the Wagtail admin. The ``order`` parameter allows the method to be called from the outside (e.g. a ``ViewSetGroup``) to create a sub menu item with the correct order. """ return self.menu_item_class( label=self.menu_label, url=self.menu_url, name=self.menu_name, icon_name=self.menu_icon, order=order if order is not None else self.menu_order, ) @cached_property def menu_hook(self): """ The name of the hook to register the menu item within. This takes precedence over ``add_to_admin_menu`` and ``add_to_settings_menu``. """ if self.add_to_settings_menu: return "register_settings_menu_item" if self.add_to_admin_menu: return "register_admin_menu_item" def register_menu_item(self): """Registers the menu item with the Wagtail admin.""" if self.menu_hook: hooks.register(self.menu_hook, self.get_menu_item) class WagtailMenuRegisterableGroup(WagtailMenuRegisterable): """ A container for grouping together multiple WagtailMenuRegisterable instances. Creates a menu item with a submenu for accessing the main URL for each instances. """ #: A list or tuple of ``WagtailMenuRegisterable`` classes or instances #: to be grouped together. items = () menu_icon = "folder-open-inverse" add_to_admin_menu = True def __init__(self): """ When initialising, instantiate the classes (or use the instances) within 'items', and assign the list to a ``registerables`` attribute. """ self.registerables = [ registerable() if callable(registerable) else registerable for registerable in self.items ] def get_submenu_items(self): menu_items = [] item_order = 1 for registerable in self.registerables: menu_items.append(registerable.get_menu_item(order=item_order)) item_order += 1 return menu_items def get_menu_item(self, order=None): return SubmenuMenuItem( label=self.menu_label, menu=Menu(items=self.get_submenu_items()), name=self.menu_name, icon_name=self.menu_icon, order=order if order is not None else self.menu_order, ) admin_menu = Menu( register_hook_name="register_admin_menu_item", construct_hook_name="construct_main_menu", ) settings_menu = Menu( register_hook_name="register_settings_menu_item", construct_hook_name="construct_settings_menu", ) reports_menu = Menu( register_hook_name="register_reports_menu_item", construct_hook_name="construct_reports_menu", ) help_menu = Menu( register_hook_name="register_help_menu_item", construct_hook_name="construct_help_menu", )