Skip to content

Commit

Permalink
Merge pull request #10441 from netbox-community/9071-plugin-menu
Browse files Browse the repository at this point in the history
9071 add header to plugin menu
  • Loading branch information
jeremystretch authored Sep 28, 2022
2 parents 5382ac2 + 3fbd514 commit c95ad5b
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 122 deletions.
2 changes: 1 addition & 1 deletion docs/development/adding-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Create the HTML template for the object view. (The other views each typically em

## 10. Add the model to the navigation menu

Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`.
Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`.

## 11. REST API components

Expand Down
78 changes: 61 additions & 17 deletions docs/plugins/development/navigation.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,67 @@
# Navigation

## Menu Items
## Menus

!!! note
This feature was introduced in NetBox v3.4.

A plugin can register its own submenu as part of NetBox's navigation menu. This is done by defining a variable named `menu` in `navigation.py`, pointing to an instance of the `PluginMenu` class. Each menu must define a label and grouped menu items (discussed below), and may optionally specify an icon. An example is shown below.

```python title="navigation.py"
from extras.plugins import PluginMenu

menu = PluginMenu(
label='My Plugin',
groups=(
('Foo', (item1, item2, item3)),
('Bar', (item4, item5)),
),
icon='mdi mdi-router'
)
```

Note that each group is a two-tuple containing a label and an iterable of menu items. The group's label serves as the section header within the submenu. A group label is required even if you have only one group of items.

!!! tip
The path to the menu class can be modified by setting `menu` in the PluginConfig instance.

A `PluginMenu` has the following attributes:

To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below.
| Attribute | Required | Description |
|--------------|----------|---------------------------------------------------|
| `label` | Yes | The text displayed as the menu heading |
| `groups` | Yes | An iterable of named groups containing menu items |
| `icon_class` | - | The CSS name of the icon to use for the heading |

!!! tip
The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance.
Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/)

```python
### The Default Menu

If your plugin has only a small number of menu items, it may be desirable to use NetBox's shared "Plugins" menu rather than creating your own. To do this, simply declare `menu_items` as a list of `PluginMenuItems` in `navigation.py`. The listed items will appear under a heading bearing the name of your plugin in the "Plugins" submenu.

```python title="navigation.py"
menu_items = (item1, item2, item3)
```

!!! tip
The path to the menu items list can be modified by setting `menu_items` in the PluginConfig instance.

## Menu Items

Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.

```python filename="navigation.py"
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices

menu_items = (
PluginMenuItem(
link='plugins:netbox_animal_sounds:random_animal',
link_text='Random sound',
buttons=(
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
)
),
item1 = PluginMenuItem(
link='plugins:myplugin:myview',
link_text='Some text',
buttons=(
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
)
)
```

Expand All @@ -34,17 +76,19 @@ A `PluginMenuItem` has the following attributes:

## Menu Buttons

Each menu item can include a set of buttons. These can be handy for providing shortcuts related to the menu item. For instance, most items in NetBox's navigation menu include buttons to create and import new objects.

A `PluginMenuButton` has the following attributes:

| Attribute | Required | Description |
|---------------|----------|--------------------------------------------------------------------|
| `link` | Yes | Name of the URL path to which this button links |
| `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) |
| `icon_class` | Yes | Button icon CSS class* |
| `icon_class` | Yes | Button icon CSS class |
| `color` | - | One of the choices provided by `ButtonColorChoices` |
| `permissions` | - | A list of permissions required to display this button |

*NetBox supports [Material Design Icons](https://materialdesignicons.com/).
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.

!!! note
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
!!! tip
Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/)
31 changes: 26 additions & 5 deletions netbox/extras/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@
from django.core.exceptions import ImproperlyConfigured
from django.template.loader import get_template

from extras.plugins.utils import import_object
from extras.registry import registry
from netbox.navigation import MenuGroup
from utilities.choices import ButtonColorChoices

from extras.plugins.utils import import_object


# Initialize plugin registry
registry['plugins'] = {
'graphql_schemas': [],
'menus': [],
'menu_items': {},
'preferences': {},
'template_extensions': collections.defaultdict(list),
Expand Down Expand Up @@ -57,6 +58,7 @@ class PluginConfig(AppConfig):
# Default integration paths. Plugin authors can override these to customize the paths to
# integrated components.
graphql_schema = 'graphql.schema'
menu = 'navigation.menu'
menu_items = 'navigation.menu_items'
template_extensions = 'template_content.template_extensions'
user_preferences = 'preferences.preferences'
Expand All @@ -69,9 +71,10 @@ def ready(self):
if template_extensions is not None:
register_template_extensions(template_extensions)

# Register navigation menu items (if defined)
menu_items = import_object(f"{self.__module__}.{self.menu_items}")
if menu_items is not None:
# Register navigation menu or menu items (if defined)
if menu := import_object(f"{self.__module__}.{self.menu}"):
register_menu(menu)
if menu_items := import_object(f"{self.__module__}.{self.menu_items}"):
register_menu_items(self.verbose_name, menu_items)

# Register GraphQL schema (if defined)
Expand Down Expand Up @@ -200,6 +203,18 @@ def register_template_extensions(class_list):
# Navigation menu links
#

class PluginMenu:
icon_class = 'mdi mdi-puzzle'

def __init__(self, label, groups, icon_class=None):
self.label = label
self.groups = [
MenuGroup(label, items) for label, items in groups
]
if icon_class is not None:
self.icon_class = icon_class


class PluginMenuItem:
"""
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
Expand Down Expand Up @@ -246,6 +261,12 @@ def __init__(self, link, title, icon_class, color=None, permissions=None):
self.color = color


def register_menu(menu):
if not isinstance(menu, PluginMenu):
raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu")
registry['plugins']['menus'].append(menu)


def register_menu_items(section_name, class_list):
"""
Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)
Expand Down
10 changes: 8 additions & 2 deletions netbox/extras/tests/dummy_plugin/navigation.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from extras.plugins import PluginMenuButton, PluginMenuItem
from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem


menu_items = (
items = (
PluginMenuItem(
link='plugins:dummy_plugin:dummy_models',
link_text='Item 1',
Expand All @@ -23,3 +23,9 @@
link_text='Item 2',
),
)

menu = PluginMenu(
label='Dummy',
groups=(('Group 1', items),),
)
menu_items = items
11 changes: 10 additions & 1 deletion netbox/extras/tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.test import Client, TestCase, override_settings
from django.urls import reverse

from extras.plugins import PluginMenu
from extras.registry import registry
from extras.tests.dummy_plugin import config as dummy_config
from netbox.graphql.schema import Query
Expand Down Expand Up @@ -58,9 +59,17 @@ def test_api_views(self):
response = client.get(url)
self.assertEqual(response.status_code, 200)

def test_menu(self):
"""
Check menu registration.
"""
menu = registry['plugins']['menus'][0]
self.assertIsInstance(menu, PluginMenu)
self.assertEqual(menu.label, 'Dummy')

def test_menu_items(self):
"""
Check that plugin MenuItems and MenuButtons are registered.
Check menu_items registration.
"""
self.assertIn('Dummy plugin', registry['plugins']['menu_items'])
menu_items = registry['plugins']['menu_items']['Dummy plugin']
Expand Down
92 changes: 92 additions & 0 deletions netbox/netbox/navigation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from dataclasses import dataclass
from typing import Sequence, Optional

from utilities.choices import ButtonColorChoices


__all__ = (
'get_model_item',
'get_model_buttons',
'Menu',
'MenuGroup',
'MenuItem',
'MenuItemButton',
)


#
# Navigation menu data classes
#

@dataclass
class MenuItemButton:

link: str
title: str
icon_class: str
permissions: Optional[Sequence[str]] = ()
color: Optional[str] = None


@dataclass
class MenuItem:

link: str
link_text: str
permissions: Optional[Sequence[str]] = ()
buttons: Optional[Sequence[MenuItemButton]] = ()


@dataclass
class MenuGroup:

label: str
items: Sequence[MenuItem]


@dataclass
class Menu:

label: str
icon_class: str
groups: Sequence[MenuGroup]


#
# Utility functions
#

def get_model_item(app_label, model_name, label, actions=('add', 'import')):
return MenuItem(
link=f'{app_label}:{model_name}_list',
link_text=label,
permissions=[f'{app_label}.view_{model_name}'],
buttons=get_model_buttons(app_label, model_name, actions)
)


def get_model_buttons(app_label, model_name, actions=('add', 'import')):
buttons = []

if 'add' in actions:
buttons.append(
MenuItemButton(
link=f'{app_label}:{model_name}_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=[f'{app_label}.add_{model_name}'],
color=ButtonColorChoices.GREEN
)
)
if 'import' in actions:
buttons.append(
MenuItemButton(
link=f'{app_label}:{model_name}_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=[f'{app_label}.add_{model_name}'],
color=ButtonColorChoices.CYAN
)
)

return buttons
Loading

0 comments on commit c95ad5b

Please sign in to comment.