Repository: netbox-community/netbox

Issue:
Simplify approach for registering plugin template content
Similar to #4401, this issue proposes a new mechanism for plugins to register custom template content.

### Current Approach

The current approach requires three components:

1. Declare all PluginTemplateContent subclasses in a file.
2. Create a function returning those classes and register it as receiver for the `register_detail_page_content_classes` signal in `signals.py`.
3. Extend the PluginConfig subclass to import `signals.py` on `ready()` (which may already be in place).

### Proposed Approach

This issue proposes extending the PluginTemplateContent class to automatically register its subclasses. This can be achieved using the [`__init_subclass__()` method](https://docs.python.org/3.6/whatsnew/3.6.html#pep-487-simpler-customization-of-class-creation) introduced in Python 3.6. Below is a theoretical example:

```python
from extras.registry import registry

# Bit of hand-waving here; just want to call out that the registry store exists
registry['plugin_template_content_classes'] = collections.defaultdict(list)

class PluginTemplateContent:

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        
        # Register the subclass
        registry['plugin_template_content_classes'][cls.model].append(cls)
```

This should automatically register each subclass as it is imported. The only missing component is the actual import, which we can handle in PluginConfig's `ready()` method, assuming we define a standard filename for template content.

With this approach, the following would be sufficient for a plugin to declare a piece of template content:

**template_content.py**
```python
from extras.plugins import PluginTemplateContent

class TestButtonOne(PluginTemplateContent):
    model = 'dcim.site'

    def full_width_page(self):
        return self.render('my_custom_content.html')
    ...
```

### Justification

1. This approach requires a minimum of effort from plugin developers: simply define the necessary subclasses in the specified file, or ensure that they are otherwise imported by your plugin.
2. Registration happens at initialization, rather than on-demand, so any errors are immediately raised.
3. Validation is simplified, since we no longer have to check for the parent class.



Discussion:
I think we should use the same mechanism as #4401 for two reasons:

1. It standardizes the interface pattern for this kind of thing. We have two such use cases today, but #4393 would likely take the same approach and should we decide to support custom scripts and reports through plugins.
2. As brought up in #4401 using the list/tuple variable is an established Django pattern, but it also ensures deterministic ordering for the end-user without having to play with declaration order.
I agree that we should establish a clear pattern in the way we integrate plugin components. I wasn't thinking about ordering with regard to the template content, but that's a good point: A plugin might introduce multiple PluginTemplateContent instances for the same view, and we should provide a mechanism for the author to manipulate the order in which they appear.
I have to agree, while that init_subclasses is nifty, I think sticking to the mechanism in #4401 is probably best.  While plugins are a fairly advanced feature, anything we can do to maintain consistency I think would be desired.

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
@@ -6,10 +6,10 @@
 from django.utils.module_loading import import_string
 
 from extras.registry import registry
-from .signals import register_detail_page_content_classes
 
 
 # Initialize plugin registry stores
+registry['plugin_template_content_classes'] = collections.defaultdict(list)
 registry['plugin_nav_menu_links'] = {}
 
 
@@ -48,10 +48,18 @@ class PluginConfig(AppConfig):
 
     # Default integration paths. Plugin authors can override these to customize the paths to
     # integrated components.
+    template_content_classes = 'template_content.template_content_classes'
     menu_items = 'navigation.menu_items'
 
     def ready(self):
 
+        # Register template content
+        try:
+            class_list = import_string(f"{self.__module__}.{self.template_content_classes}")
+            register_template_content_classes(class_list)
+        except ImportError:
+            pass
+
         # Register navigation menu items (if defined)
         try:
             menu_items = import_string(f"{self.__module__}.{self.menu_items}")
@@ -67,7 +75,7 @@ def ready(self):
 class PluginTemplateContent:
     """
     This class is used to register plugin content to be injected into core NetBox templates.
-    It contains methods that are overriden by plugin authors to return template content.
+    It contains methods that are overridden by plugin authors to return template content.
 
     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>'.
@@ -80,8 +88,8 @@ def __init__(self, obj, context):
 
     def render(self, template, extra_context=None):
         """
-        Convenience menthod for rendering the provided template name. The detail page object is automatically
-        passed into the template context as `obj` and the origional detail page's context is available as
+        Convenience method for rendering the provided template name. The detail page object is automatically
+        passed into the template context as `obj` and the original detail page's context is available as
         `obj_context`. An additional context dictionary may be passed as `extra_context`.
         """
         context = {
@@ -123,36 +131,20 @@ def buttons(self):
         raise NotImplementedError
 
 
-def register_content_classes():
-    """
-    Helper method that populates the registry with all template content classes that have been registered by plugins
-    """
-    registry['plugin_template_content_classes'] = collections.defaultdict(list)
-
-    responses = register_detail_page_content_classes.send('registration_event')
-    for receiver, response in responses:
-        if not isinstance(response, list):
-            response = [response]
-        for template_class in response:
-            if not inspect.isclass(template_class):
-                raise TypeError('Plugin content class {} was passes as an instance!'.format(template_class))
-            if not issubclass(template_class, PluginTemplateContent):
-                raise TypeError('{} is not a subclass of extras.plugins.PluginTemplateContent!'.format(template_class))
-            if template_class.model is None:
-                raise TypeError('Plugin content class {} does not define a valid model!'.format(template_class))
-
-            registry['plugin_template_content_classes'][template_class.model].append(template_class)
-
-
-def get_content_classes(model):
+def register_template_content_classes(class_list):
     """
-    Given a model string, return the list of all registered template content classes.
-    Populate the registry if it is empty.
+    Register a list of PluginTemplateContent classes
     """
-    if 'plugin_template_content_classes' not in registry:
-        register_content_classes()
-
-    return registry['plugin_template_content_classes'].get(model, [])
+    # Validation
+    for template_content_class in class_list:
+        if not inspect.isclass(template_content_class):
+            raise TypeError('Plugin content class {} was passes as an instance!'.format(template_content_class))
+        if not issubclass(template_content_class, PluginTemplateContent):
+            raise TypeError('{} is not a subclass of extras.plugins.PluginTemplateContent!'.format(template_content_class))
+        if template_content_class.model is None:
+            raise TypeError('Plugin content class {} does not define a valid model!'.format(template_content_class))
+
+        registry['plugin_template_content_classes'][template_content_class.model].append(template_content_class)
 
 
 #
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
@@ -3,7 +3,9 @@
 
 
 class PluginSignal(django.dispatch.Signal):
-
+    """
+    FUTURE USE
+    """
     def _sorted_receivers(self, sender):
         orig_list = self._live_receivers(sender)
         sorted_list = sorted(
@@ -24,11 +26,3 @@ def send(self, sender, **kwargs):
             response = receiver(signal=self, sender=sender, **kwargs)
             responses.append((receiver, response))
         return responses
-
-
-"""
-This signal collects template content classes which render content for object detail pages
-"""
-register_detail_page_content_classes = PluginSignal(
-    providing_args=[]
-)
diff --git a/netbox/extras/templatetags/plugins.py b/netbox/extras/templatetags/plugins.py
--- a/netbox/extras/templatetags/plugins.py
+++ b/netbox/extras/templatetags/plugins.py
@@ -1,9 +1,7 @@
 from django import template as template_
-from django.template.loader import get_template
 from django.utils.safestring import mark_safe
 
-from extras.plugins import get_content_classes
-
+from extras.registry import registry
 
 register = template_.Library()
 
@@ -15,7 +13,8 @@ def _get_registered_content(obj, method, context):
     """
     html = ''
 
-    plugin_template_classes = get_content_classes(obj._meta.label_lower)
+    model_name = obj._meta.label_lower
+    plugin_template_classes = registry['plugin_template_content_classes'].get(model_name, [])
     for plugin_template_class in plugin_template_classes:
         plugin_template_renderer = plugin_template_class(obj, context)
         try:


Test patch: