Skip to content

Commit c95ad5b

Browse files
Merge pull request #10441 from netbox-community/9071-plugin-menu
9071 add header to plugin menu
2 parents 5382ac2 + 3fbd514 commit c95ad5b

File tree

8 files changed

+211
-122
lines changed

8 files changed

+211
-122
lines changed

docs/development/adding-models.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Create the HTML template for the object view. (The other views each typically em
6060

6161
## 10. Add the model to the navigation menu
6262

63-
Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`.
63+
Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`.
6464

6565
## 11. REST API components
6666

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,67 @@
11
# Navigation
22

3-
## Menu Items
3+
## Menus
4+
5+
!!! note
6+
This feature was introduced in NetBox v3.4.
7+
8+
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.
9+
10+
```python title="navigation.py"
11+
from extras.plugins import PluginMenu
12+
13+
menu = PluginMenu(
14+
label='My Plugin',
15+
groups=(
16+
('Foo', (item1, item2, item3)),
17+
('Bar', (item4, item5)),
18+
),
19+
icon='mdi mdi-router'
20+
)
21+
```
22+
23+
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.
24+
25+
!!! tip
26+
The path to the menu class can be modified by setting `menu` in the PluginConfig instance.
27+
28+
A `PluginMenu` has the following attributes:
429

5-
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.
30+
| Attribute | Required | Description |
31+
|--------------|----------|---------------------------------------------------|
32+
| `label` | Yes | The text displayed as the menu heading |
33+
| `groups` | Yes | An iterable of named groups containing menu items |
34+
| `icon_class` | - | The CSS name of the icon to use for the heading |
635

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

10-
```python
39+
### The Default Menu
40+
41+
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.
42+
43+
```python title="navigation.py"
44+
menu_items = (item1, item2, item3)
45+
```
46+
47+
!!! tip
48+
The path to the menu items list can be modified by setting `menu_items` in the PluginConfig instance.
49+
50+
## Menu Items
51+
52+
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.
53+
54+
```python filename="navigation.py"
1155
from extras.plugins import PluginMenuButton, PluginMenuItem
1256
from utilities.choices import ButtonColorChoices
1357

14-
menu_items = (
15-
PluginMenuItem(
16-
link='plugins:netbox_animal_sounds:random_animal',
17-
link_text='Random sound',
18-
buttons=(
19-
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
20-
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
21-
)
22-
),
58+
item1 = PluginMenuItem(
59+
link='plugins:myplugin:myview',
60+
link_text='Some text',
61+
buttons=(
62+
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
63+
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
64+
)
2365
)
2466
```
2567

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

3577
## Menu Buttons
3678

79+
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.
80+
3781
A `PluginMenuButton` has the following attributes:
3882

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

47-
*NetBox supports [Material Design Icons](https://materialdesignicons.com/).
91+
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.
4892

49-
!!! note
50-
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.
93+
!!! tip
94+
Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/)

netbox/extras/plugins/__init__.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,16 @@
66
from django.core.exceptions import ImproperlyConfigured
77
from django.template.loader import get_template
88

9+
from extras.plugins.utils import import_object
910
from extras.registry import registry
11+
from netbox.navigation import MenuGroup
1012
from utilities.choices import ButtonColorChoices
1113

12-
from extras.plugins.utils import import_object
13-
1414

1515
# Initialize plugin registry
1616
registry['plugins'] = {
1717
'graphql_schemas': [],
18+
'menus': [],
1819
'menu_items': {},
1920
'preferences': {},
2021
'template_extensions': collections.defaultdict(list),
@@ -57,6 +58,7 @@ class PluginConfig(AppConfig):
5758
# Default integration paths. Plugin authors can override these to customize the paths to
5859
# integrated components.
5960
graphql_schema = 'graphql.schema'
61+
menu = 'navigation.menu'
6062
menu_items = 'navigation.menu_items'
6163
template_extensions = 'template_content.template_extensions'
6264
user_preferences = 'preferences.preferences'
@@ -69,9 +71,10 @@ def ready(self):
6971
if template_extensions is not None:
7072
register_template_extensions(template_extensions)
7173

72-
# Register navigation menu items (if defined)
73-
menu_items = import_object(f"{self.__module__}.{self.menu_items}")
74-
if menu_items is not None:
74+
# Register navigation menu or menu items (if defined)
75+
if menu := import_object(f"{self.__module__}.{self.menu}"):
76+
register_menu(menu)
77+
if menu_items := import_object(f"{self.__module__}.{self.menu_items}"):
7578
register_menu_items(self.verbose_name, menu_items)
7679

7780
# Register GraphQL schema (if defined)
@@ -200,6 +203,18 @@ def register_template_extensions(class_list):
200203
# Navigation menu links
201204
#
202205

206+
class PluginMenu:
207+
icon_class = 'mdi mdi-puzzle'
208+
209+
def __init__(self, label, groups, icon_class=None):
210+
self.label = label
211+
self.groups = [
212+
MenuGroup(label, items) for label, items in groups
213+
]
214+
if icon_class is not None:
215+
self.icon_class = icon_class
216+
217+
203218
class PluginMenuItem:
204219
"""
205220
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
@@ -246,6 +261,12 @@ def __init__(self, link, title, icon_class, color=None, permissions=None):
246261
self.color = color
247262

248263

264+
def register_menu(menu):
265+
if not isinstance(menu, PluginMenu):
266+
raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu")
267+
registry['plugins']['menus'].append(menu)
268+
269+
249270
def register_menu_items(section_name, class_list):
250271
"""
251272
Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)

netbox/extras/tests/dummy_plugin/navigation.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from extras.plugins import PluginMenuButton, PluginMenuItem
1+
from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
22

33

4-
menu_items = (
4+
items = (
55
PluginMenuItem(
66
link='plugins:dummy_plugin:dummy_models',
77
link_text='Item 1',
@@ -23,3 +23,9 @@
2323
link_text='Item 2',
2424
),
2525
)
26+
27+
menu = PluginMenu(
28+
label='Dummy',
29+
groups=(('Group 1', items),),
30+
)
31+
menu_items = items

netbox/extras/tests/test_plugins.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.test import Client, TestCase, override_settings
66
from django.urls import reverse
77

8+
from extras.plugins import PluginMenu
89
from extras.registry import registry
910
from extras.tests.dummy_plugin import config as dummy_config
1011
from netbox.graphql.schema import Query
@@ -58,9 +59,17 @@ def test_api_views(self):
5859
response = client.get(url)
5960
self.assertEqual(response.status_code, 200)
6061

62+
def test_menu(self):
63+
"""
64+
Check menu registration.
65+
"""
66+
menu = registry['plugins']['menus'][0]
67+
self.assertIsInstance(menu, PluginMenu)
68+
self.assertEqual(menu.label, 'Dummy')
69+
6170
def test_menu_items(self):
6271
"""
63-
Check that plugin MenuItems and MenuButtons are registered.
72+
Check menu_items registration.
6473
"""
6574
self.assertIn('Dummy plugin', registry['plugins']['menu_items'])
6675
menu_items = registry['plugins']['menu_items']['Dummy plugin']

netbox/netbox/navigation/__init__.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from dataclasses import dataclass
2+
from typing import Sequence, Optional
3+
4+
from utilities.choices import ButtonColorChoices
5+
6+
7+
__all__ = (
8+
'get_model_item',
9+
'get_model_buttons',
10+
'Menu',
11+
'MenuGroup',
12+
'MenuItem',
13+
'MenuItemButton',
14+
)
15+
16+
17+
#
18+
# Navigation menu data classes
19+
#
20+
21+
@dataclass
22+
class MenuItemButton:
23+
24+
link: str
25+
title: str
26+
icon_class: str
27+
permissions: Optional[Sequence[str]] = ()
28+
color: Optional[str] = None
29+
30+
31+
@dataclass
32+
class MenuItem:
33+
34+
link: str
35+
link_text: str
36+
permissions: Optional[Sequence[str]] = ()
37+
buttons: Optional[Sequence[MenuItemButton]] = ()
38+
39+
40+
@dataclass
41+
class MenuGroup:
42+
43+
label: str
44+
items: Sequence[MenuItem]
45+
46+
47+
@dataclass
48+
class Menu:
49+
50+
label: str
51+
icon_class: str
52+
groups: Sequence[MenuGroup]
53+
54+
55+
#
56+
# Utility functions
57+
#
58+
59+
def get_model_item(app_label, model_name, label, actions=('add', 'import')):
60+
return MenuItem(
61+
link=f'{app_label}:{model_name}_list',
62+
link_text=label,
63+
permissions=[f'{app_label}.view_{model_name}'],
64+
buttons=get_model_buttons(app_label, model_name, actions)
65+
)
66+
67+
68+
def get_model_buttons(app_label, model_name, actions=('add', 'import')):
69+
buttons = []
70+
71+
if 'add' in actions:
72+
buttons.append(
73+
MenuItemButton(
74+
link=f'{app_label}:{model_name}_add',
75+
title='Add',
76+
icon_class='mdi mdi-plus-thick',
77+
permissions=[f'{app_label}.add_{model_name}'],
78+
color=ButtonColorChoices.GREEN
79+
)
80+
)
81+
if 'import' in actions:
82+
buttons.append(
83+
MenuItemButton(
84+
link=f'{app_label}:{model_name}_import',
85+
title='Import',
86+
icon_class='mdi mdi-upload',
87+
permissions=[f'{app_label}.add_{model_name}'],
88+
color=ButtonColorChoices.CYAN
89+
)
90+
)
91+
92+
return buttons

0 commit comments

Comments
 (0)