Repository: netbox-community/netbox

Issue:
Enable plugins to inject content within the top navigation bar
### NetBox version

v4.0.3

### Feature type

New functionality

### Proposed functionality

It is currently possible for a plugin to embed custom content in NetBox core views by employing one or more of the available [extension methods](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/views/#extra-template-content) of the PluginTemplateExtension class. This proposal would add a new method, `navbar()`, to enable a plugin to inject custom content within the top navigation bar, to the left of the user menu (opposite the global search bar).

This will also require slight modification to the logic for [registering template extensions](https://github.com/netbox-community/netbox/blob/05c69f84e69ac4ea8197452a75837e07d032993f/netbox/netbox/plugins/registration.py#L35), which currently requires the association of an extension with a particular NetBox model.

### Use case

It may be desirable for a plugin to embed a notification or control widget of some kind which persists across multiple views.

### Database changes

None

### External dependencies

None


Discussion:
Funny, we had a discussion in the #netbox-plugin-development on this :-)
There was the question what the best way is to inject CSS or JS.
BOTTOM_BANNER was the best solution for now.

So can we extend this FR to include a mean to inject something like CSS and JS ?
Or should we open another FR for this
@PieterL75 what would not be in scope for this FR.

Resolving patch:
diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py
--- a/netbox/netbox/plugins/registration.py
+++ b/netbox/netbox/plugins/registration.py
@@ -32,21 +32,13 @@ def register_template_extensions(class_list):
                     template_extension=template_extension
                 )
             )
-        if template_extension.model is None:
-            raise TypeError(
-                _("PluginTemplateExtension class {template_extension} does not define a valid model!").format(
-                    template_extension=template_extension
-                )
-            )
 
         registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
 
 
 def register_menu(menu):
     if not isinstance(menu, PluginMenu):
-        raise TypeError(_("{item} must be an instance of netbox.plugins.PluginMenuItem").format(
-            item=menu_link
-        ))
+        raise TypeError(_("{item} must be an instance of netbox.plugins.PluginMenuItem").format(item=menu))
     registry['plugins']['menus'].append(menu)
 
 
diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py
--- a/netbox/netbox/plugins/templates.py
+++ b/netbox/netbox/plugins/templates.py
@@ -14,7 +14,8 @@ class PluginTemplateExtension:
     The `model` attribute on the class defines the which model detail page this class renders content for. It
     should be set as a string in the form '<app_label>.<model_name>'. render() provides the following context data:
 
-    * object - The object being viewed
+    * object - The object being viewed (object views only)
+    * model - The type of object being viewed (list views only)
     * request - The current request
     * settings - Global NetBox settings
     * config - Plugin-specific configuration parameters
@@ -36,6 +37,13 @@ def render(self, template_name, extra_context=None):
 
         return get_template(template_name).render({**self.context, **extra_context})
 
+    def navbar(self):
+        """
+        Content that will be rendered inside the top navigation menu. Content should be returned as an HTML
+        string. Note that content does not need to be marked as safe because this is automatically handled.
+        """
+        raise NotImplementedError
+
     def left_page(self):
         """
         Content that will be rendered on the left of the detail page view. Content should be returned as an
diff --git a/netbox/utilities/templatetags/plugins.py b/netbox/utilities/templatetags/plugins.py
--- a/netbox/utilities/templatetags/plugins.py
+++ b/netbox/utilities/templatetags/plugins.py
@@ -22,7 +22,7 @@ def _get_registered_content(obj, method, template_context):
         'perms': template_context['perms'],
     }
 
-    model_name = obj._meta.label_lower
+    model_name = obj._meta.label_lower if obj is not None else None
     template_extensions = registry['plugins']['template_extensions'].get(model_name, [])
     for template_extension in template_extensions:
 
@@ -43,6 +43,14 @@ def _get_registered_content(obj, method, template_context):
     return mark_safe(html)
 
 
+@register.simple_tag(takes_context=True)
+def plugin_navbar(context):
+    """
+    Render any navbar content embedded by plugins
+    """
+    return _get_registered_content(None, 'navbar', context)
+
+
 @register.simple_tag(takes_context=True)
 def plugin_buttons(context, obj):
     """


Test patch:
diff --git a/netbox/netbox/tests/dummy_plugin/template_content.py b/netbox/netbox/tests/dummy_plugin/template_content.py
--- a/netbox/netbox/tests/dummy_plugin/template_content.py
+++ b/netbox/netbox/tests/dummy_plugin/template_content.py
@@ -1,6 +1,12 @@
 from netbox.plugins.templates import PluginTemplateExtension
 
 
+class GlobalContent(PluginTemplateExtension):
+
+    def navbar(self):
+        return "GLOBAL CONTENT - NAVBAR"
+
+
 class SiteContent(PluginTemplateExtension):
     model = 'dcim.site'
 
@@ -20,4 +26,4 @@ def list_buttons(self):
         return "SITE CONTENT - LIST BUTTONS"
 
 
-template_extensions = [SiteContent]
+template_extensions = [GlobalContent, SiteContent]
diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py
--- a/netbox/netbox/tests/test_plugins.py
+++ b/netbox/netbox/tests/test_plugins.py
@@ -99,8 +99,9 @@ def test_template_extensions(self):
         """
         Check that plugin TemplateExtensions are registered.
         """
-        from netbox.tests.dummy_plugin.template_content import SiteContent
+        from netbox.tests.dummy_plugin.template_content import GlobalContent, SiteContent
 
+        self.assertIn(GlobalContent, registry['plugins']['template_extensions'][None])
         self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
 
     def test_registered_columns(self):