Repository: netbox-community/netbox

Issue:
Allow ObjectChildrenView to be used by plugins
### NetBox version

v3.2.0

### Feature type

Change to existing functionality

### Proposed functionality

Officially allow plugins to use the ObjectChildrenView class.

Decide that it's ok to be used and document how to use it in the plugin development documentation.

### Use case

To be able to create models in plugins that have child models (Interfaces to a Firewall, Routes for a Router, etc.) and make them look and feel like any other child models do in "native" NetBox.

Reduces the amount of code that needs to be written for something that "native" NetBox already solves.

### Database changes

None

### External dependencies

None


Discussion:
I must admit that I already used this `ObjectChildrenView ` class even if not part of the official plugin API. I can take care of the documentation if you planned to make it public.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. **Do not** attempt to circumvent this process by "bumping" the issue; doing so will result in its immediate closure and you may be barred from participating in any future discussions. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).

Resolving patch:
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -24,6 +24,7 @@
 from utilities.permissions import get_permission_for_model
 from utilities.views import GetReturnURLMixin
 from .base import BaseMultiObjectView
+from .mixins import ActionsMixin, TableMixin
 
 __all__ = (
     'BulkComponentCreateView',
@@ -36,9 +37,9 @@
 )
 
 
-class ObjectListView(BaseMultiObjectView):
+class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
     """
-    Display multiple objects, all of the same type, as a table.
+    Display multiple objects, all the same type, as a table.
 
     Attributes:
         filterset: A django-filter FilterSet that is applied to the queryset
@@ -50,31 +51,10 @@ class ObjectListView(BaseMultiObjectView):
     template_name = 'generic/object_list.html'
     filterset = None
     filterset_form = None
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
-    })
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
 
-    def get_table(self, request, bulk_actions=True):
-        """
-        Return the django-tables2 Table instance to be used for rendering the objects list.
-
-        Args:
-            request: The current request
-            bulk_actions: Show checkboxes for object selection
-        """
-        table = self.table(self.queryset, user=request.user)
-        if 'pk' in table.base_columns and bulk_actions:
-            table.columns.show('pk')
-
-        return table
-
     #
     # Export methods
     #
@@ -147,19 +127,14 @@ def get(self, request):
             self.queryset = self.filterset(request.GET, self.queryset).qs
 
         # Determine the available actions
-        actions = []
-        for action in self.actions:
-            if request.user.has_perms([
-                get_permission_for_model(model, name) for name in self.action_perms[action]
-            ]):
-                actions.append(action)
+        actions = self.get_permitted_actions(request.user)
         has_bulk_actions = any([a.startswith('bulk_') for a in actions])
 
         if 'export' in request.GET:
 
             # Export the current table view
             if request.GET['export'] == 'table':
-                table = self.get_table(request, has_bulk_actions)
+                table = self.get_table(self.queryset, request, has_bulk_actions)
                 columns = [name for name, _ in table.selected_columns]
                 return self.export_table(table, columns)
 
@@ -177,12 +152,11 @@ def get(self, request):
 
             # Fall back to default table/YAML export
             else:
-                table = self.get_table(request, has_bulk_actions)
+                table = self.get_table(self.queryset, request, has_bulk_actions)
                 return self.export_table(table)
 
         # Render the objects table
-        table = self.get_table(request, has_bulk_actions)
-        table.configure(request)
+        table = self.get_table(self.queryset, request, has_bulk_actions)
 
         # If this is an HTMX request, return only the rendered table HTML
         if is_htmx(request):
@@ -190,15 +164,13 @@ def get(self, request):
                 'table': table,
             })
 
-        context = {
+        return render(request, self.template_name, {
             'model': model,
             'table': table,
             'actions': actions,
             'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
             **self.get_extra_context(request),
-        }
-
-        return render(request, self.template_name, context)
+        })
 
 
 class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py
new file mode 100644
--- /dev/null
+++ b/netbox/netbox/views/generic/mixins.py
@@ -0,0 +1,47 @@
+from collections import defaultdict
+
+from utilities.permissions import get_permission_for_model
+
+__all__ = (
+    'TableMixin',
+)
+
+
+class ActionsMixin:
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+    })
+
+    def get_permitted_actions(self, user, model=None):
+        """
+        Return a tuple of actions for which the given user is permitted to do.
+        """
+        model = model or self.queryset.model
+        return [
+            action for action in self.actions if user.has_perms([
+                get_permission_for_model(model, name) for name in self.action_perms[action]
+            ])
+        ]
+
+
+class TableMixin:
+
+    def get_table(self, data, request, bulk_actions=True):
+        """
+        Return the django-tables2 Table instance to be used for rendering the objects list.
+
+        Args:
+            data: Queryset or iterable containing table data
+            request: The current request
+            bulk_actions: Render checkboxes for object selection
+        """
+        table = self.table(data, user=request.user)
+        if 'pk' in table.base_columns and bulk_actions:
+            table.columns.show('pk')
+        table.configure(request)
+
+        return table
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -20,6 +20,7 @@
 from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
 from utilities.views import GetReturnURLMixin
 from .base import BaseObjectView
+from .mixins import ActionsMixin, TableMixin
 
 __all__ = (
     'ComponentCreateView',
@@ -69,12 +70,17 @@ def get(self, request, **kwargs):
         })
 
 
-class ObjectChildrenView(ObjectView):
+class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
     """
     Display a table of child objects associated with the parent object.
 
     Attributes:
-        table: Table class used to render child objects list
+        child_model: The model class which represents the child objects
+        table: The django-tables2 Table class used to render the child objects list
+        filterset: A django-filter FilterSet that is applied to the queryset
+        actions: Supported actions for the model. When adding custom actions, bulk action names must
+            be prefixed with `bulk_`. Default actions: add, import, export, bulk_edit, bulk_delete
+        action_perms: A dictionary mapping supported actions to a set of permissions required for each
     """
     child_model = None
     table = None
@@ -84,8 +90,9 @@ def get_children(self, request, parent):
         """
         Return a QuerySet of child objects.
 
-        request: The current request
-        parent: The parent object
+        Args:
+            request: The current request
+            parent: The parent object
         """
         raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
 
@@ -114,16 +121,11 @@ def get(self, request, *args, **kwargs):
         if self.filterset:
             child_objects = self.filterset(request.GET, child_objects).qs
 
-        permissions = {}
-        for action in ('change', 'delete'):
-            perm_name = get_permission_for_model(self.child_model, action)
-            permissions[action] = request.user.has_perm(perm_name)
+        # Determine the available actions
+        actions = self.get_permitted_actions(request.user, model=self.child_model)
 
-        table = self.table(self.prep_table_data(request, child_objects, instance), user=request.user)
-        # Determine whether to display bulk action checkboxes
-        if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
-            table.columns.show('pk')
-        table.configure(request)
+        table_data = self.prep_table_data(request, child_objects, instance)
+        table = self.get_table(table_data, request, bool(actions))
 
         # If this is an HTMX request, return only the rendered table HTML
         if is_htmx(request):
@@ -134,8 +136,9 @@ def get(self, request, *args, **kwargs):
 
         return render(request, self.get_template_name(), {
             'object': instance,
+            'child_model': self.child_model,
             'table': table,
-            'permissions': permissions,
+            'actions': actions,
             **self.get_extra_context(request, instance),
         })
 


Test patch: