Repository: netbox-community/netbox

Issue:
Simplify approach for registering plugin navigation items
### Proposed Changes
I'd like a to adopt a simpler mechanism than what currently exists in the `3351-plugins` branch for plugins to register navigation menu items. We have a few options, which I'll outline here.

### Current approach: Signals

The current approach requires three components. First, we declare the menu items in a file:

**navigation.py**
```python
class NavLink1(PluginNavMenuLink):
    link = 'plugins:my_plugin:view1'
    link_text = 'Link A'

class NavLink2(PluginNavMenuLink):
    link = 'plugins:my_plugin:view2'
    link_text = 'Link B'
```

Next, we create a function to return these classes, and register it as a signal receiver:

**signals.py**
```python
@receiver(register_nav_menu_link_classes)
def nav_menu_link_classes(**kwargs):
    return [NavLink1, NavLink2]
```

Finally, we extend our PluginApp to import this function on `ready()`. (We could probably build this part into the PluginApp class.)

**__init__.py**
```python
class MyPluginConfig(PluginConfig):
    ...

    def ready(self):
        from . import signals
```

### Proposal: Import of a known variable

The first option I propose is to direct plugin authors to define a single variable within a standard file, i.e. `navigation.py`, which contains all navigation menu items for their plugin. (This is very similar in concept to the way a plugin currently declares URL patterns in `urls.py`.)

**navigation.py**
```python
class NavLink1(PluginNavMenuLink):
    link = 'plugins:my_plugin:view1'
    link_text = 'Link A'

class NavLink2(PluginNavMenuLink):
    link = 'plugins:my_plugin:view2'
    link_text = 'Link B'

menu_items = (NavLink1, NavLink2)
```

NetBox would automatically import and register the classes in `menu_items` (if defined by the plugin). This could be handled by the PluginConfig's `ready()` method, like how we're currently handling signals.

With a minor tweak to the PluginNavMenuLink class to accept attributes on initialization, the above example could be further simplified to skip naming the individual instances:

**navigation.py**
```python
menu_items = (
    PluginNavMenuLink(
        link='plugins:my_plugin:view1',
        link_text='Link A'
    ),
    PluginNavMenuLink(
        link='plugins:my_plugin:view2',
        link_text='Link B'
    ),
)
```

### Justification
I prefer this approach for four reasons:

1. It minimizes the amount of code needed from the plugin author.
2. It is much easier to understand what's happening, especially for someone unfamiliar with Django signals.
3. It provides the simplest possible integration point: A single variable.
4. This approach is already well-established by Django's URL patterns mechanism (wherein each app provides a `urlpatterns` variable).


Discussion:
I should also point out that we do want to require a list/tuple of items specifically, to ensure that the menu items are added in the desired order.

Resolving patch:
diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py
--- a/netbox/extras/plugins/__init__.py
+++ b/netbox/extras/plugins/__init__.py
@@ -1,11 +1,16 @@
 import collections
 import inspect
 
-from django.apps import AppConfig, apps
+from django.apps import AppConfig
 from django.template.loader import get_template
+from django.utils.module_loading import import_string
 
 from extras.registry import registry
-from .signals import register_detail_page_content_classes, register_nav_menu_link_classes
+from .signals import register_detail_page_content_classes
+
+
+# Initialize plugin registry stores
+registry['plugin_nav_menu_links'] = {}
 
 
 #
@@ -41,6 +46,19 @@ class PluginConfig(AppConfig):
     # Caching configuration
     caching_config = {}
 
+    # Default integration paths. Plugin authors can override these to customize the paths to
+    # integrated components.
+    menu_items = 'navigation.menu_items'
+
+    def ready(self):
+
+        # Register navigation menu items (if defined)
+        try:
+            menu_items = import_string(f"{self.__module__}.{self.menu_items}")
+            register_menu_items(self.verbose_name, menu_items)
+        except ImportError:
+            pass
+
 
 #
 # Template content injection
@@ -138,7 +156,7 @@ def get_content_classes(model):
 
 
 #
-# Nav menu links
+# Navigation menu links
 #
 
 class PluginNavMenuLink:
@@ -149,10 +167,14 @@ class PluginNavMenuLink:
     Links are specified as Django reverse URL strings.
     Buttons are each specified as a list of PluginNavMenuButton instances.
     """
-    link = None
-    link_text = None
-    link_permission = None
-    buttons = []
+    def __init__(self, link, link_text, permission=None, buttons=None):
+        self.link = link
+        self.link_text = link_text
+        self.permission = permission
+        if buttons is None:
+            self.buttons = []
+        else:
+            self.buttons = buttons
 
 
 class PluginNavMenuButton:
@@ -168,42 +190,16 @@ def __init__(self, link, title, icon_class, color, permission=None):
         self.permission = permission
 
 
-def register_nav_menu_links():
-    """
-    Helper method that populates the registry with all nav menu link classes that have been registered by plugins
-    """
-    registry['plugin_nav_menu_link_classes'] = {}
-
-    responses = register_nav_menu_link_classes.send('registration_event')
-    for receiver, response in responses:
-
-        # Derive menu section header from plugin name
-        app_config = apps.get_app_config(receiver.__module__.split('.')[0])
-        section_name = getattr(app_config, 'verbose_name', app_config.name)
-
-        if not isinstance(response, list):
-            response = [response]
-        for link_class in response:
-            if not inspect.isclass(link_class):
-                raise TypeError('Plugin nav menu link class {} was passes as an instance!'.format(link_class))
-            if not issubclass(link_class, PluginNavMenuLink):
-                raise TypeError('{} is not a subclass of extras.plugins.PluginNavMenuLink!'.format(link_class))
-            if link_class.link is None or link_class.link_text is None:
-                raise TypeError('Plugin nav menu link {} must specify at least link and link_text'.format(link_class))
-
-            for button in link_class.buttons:
-                if not isinstance(button, PluginNavMenuButton):
-                    raise TypeError('{} must be an instance of PluginNavMenuButton!'.format(button))
-
-        registry['plugin_nav_menu_link_classes'][section_name] = response
-
-
-def get_nav_menu_link_classes():
+def register_menu_items(section_name, class_list):
     """
-    Return the list of all registered nav menu link classes.
-    Populate the registry if it is empty.
+    Register a list of PluginNavMenuLink instances for a given menu section (e.g. plugin name)
     """
-    if 'plugin_nav_menu_link_classes' not in registry:
-        register_nav_menu_links()
+    # Validation
+    for menu_link in class_list:
+        if not isinstance(menu_link, PluginNavMenuLink):
+            raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginNavMenuLink")
+        for button in menu_link.buttons:
+            if not isinstance(button, PluginNavMenuButton):
+                raise TypeError(f"{button} must be an instance of extras.plugins.PluginNavMenuButton")
 
-    return registry['plugin_nav_menu_link_classes']
+    registry['plugin_nav_menu_links'][section_name] = class_list
diff --git a/netbox/extras/plugins/context_processors.py b/netbox/extras/plugins/context_processors.py
--- a/netbox/extras/plugins/context_processors.py
+++ b/netbox/extras/plugins/context_processors.py
@@ -1,12 +1,10 @@
-from . import get_nav_menu_link_classes
+from extras.registry import registry
 
 
 def nav_menu_links(request):
     """
     Retrieve and expose all plugin registered nav links
     """
-    nav_menu_links = get_nav_menu_link_classes()
-
     return {
-        'plugin_nav_menu_links': nav_menu_links
+        'plugin_nav_menu_links': registry['plugin_nav_menu_links']
     }
diff --git a/netbox/extras/plugins/signals.py b/netbox/extras/plugins/signals.py
--- a/netbox/extras/plugins/signals.py
+++ b/netbox/extras/plugins/signals.py
@@ -32,11 +32,3 @@ def send(self, sender, **kwargs):
 register_detail_page_content_classes = PluginSignal(
     providing_args=[]
 )
-
-
-"""
-This signal collects nav menu link classes
-"""
-register_nav_menu_link_classes = PluginSignal(
-    providing_args=[]
-)


Test patch: