Repository: netbox-community/netbox

Issue:
Allow plugins to add columns to tables
### NetBox version

dev / v3.6.3

### Feature type

New functionality

### Proposed functionality

Allow plugins to register columns for built-in NetBox tables.


### Use case

NetBox comes with admirably configurable tables and filters for all its built-in models, and makes it easy for plugins to provide tables like that, too. Some plugins manage fairly separate data, like DNS. However, when plugins *connect* to built-in models (instead of just providing separate models), the additional data cannot be seen from the built-in tables.

This is especially jarring for users when the data *can* be shown on the detail pages (by way of the `template_extensions` mechanism). Naturally, users tend to ask for ways to use the data displayed on the detail pages in the list/table views. As a plugin developer, I frequently build models that attach to existing NetBox models, and I get those requests a lot ("why do I have to go to the detail page of the Tenant to see the new data, can't you show it in the Tenant list?").

(I realise that there are *technically* ways to add columns to the tables right now: You can subclass/extend the built-in tables and provide them at a different URL, or, worst-of-all-worlds, patch something in. However, I think both of these are terrible in terms of maintainability and generally not good practice.)

An example could be a plugin that extends the customer management side of NetBox with additional data on Contacts, Tenants, or Sites, and would like to a) show the added data in those tables (e.g. the Site table), but also in other tables that can show Site information, e.g. the Device table.

I realise this isn't a trivial request. If you think this is generally worthwhile, I'd be happy to work on this feature!

### Database changes

_No response_

### External dependencies

_No response_


Discussion:
Thank you for your interest in extending NetBox. Unfortunately, the information you have provided does not constitute an actionable feature request. Per our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md), a feature request must include a thorough description of the proposed functionality, including any database changes, new views or API endpoints, and so on. It must also include a detailed use case justifying its implementation. If you would like to elaborate on your proposal, please modify your post above. If sufficient detail is not added, this issue will be closed.
Apologies – updated the ticket with a longer description.
to give a very specific example of how this could be used for the netbox-gateways plugin.

the plugin could "register" that It should show related gateways objects on the prefix core module.
so it would add a "gateway" column to the prefix tables and maybe even a "gateway" field on the prefix API serializer for general API use.

being able to virtually extend the core module tables/api replys with extra data (possibly similar to how custom fields are presented in their own small dictionary) would be a fantastic feature to make plugins feel more connected to the core models they are integrating with. 
I did some quick digging and the [base Table class](https://django-tables2.readthedocs.io/en/latest/pages/api-reference.html#table) from django-tables2 supports an `extra_columns` keyword argument that can be used to add, well, extra columns to a table. (This is the same mechanism we leverage to register columns for custom fields today.) We can override this in the init method of our own NetBoxTable class and inject columns that have been registered by plugins.

Resolving patch:
diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py
--- a/netbox/netbox/registry.py
+++ b/netbox/netbox/registry.py
@@ -28,6 +28,7 @@ def __delitem__(self, key):
     'models': collections.defaultdict(set),
     'plugins': dict(),
     'search': dict(),
+    'tables': collections.defaultdict(dict),
     'views': collections.defaultdict(dict),
     'widgets': dict(),
 })
diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py
--- a/netbox/netbox/tables/tables.py
+++ b/netbox/netbox/tables/tables.py
@@ -1,3 +1,5 @@
+from copy import deepcopy
+
 import django_tables2 as tables
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.contenttypes.fields import GenericForeignKey
@@ -12,6 +14,7 @@
 
 from extras.models import CustomField, CustomLink
 from extras.choices import CustomFieldVisibilityChoices
+from netbox.registry import registry
 from netbox.tables import columns
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.utils import get_viewname, highlight_string, title
@@ -191,12 +194,17 @@ def __init__(self, *args, extra_columns=None, **kwargs):
         if extra_columns is None:
             extra_columns = []
 
+        if registered_columns := registry['tables'].get(self.__class__):
+            extra_columns.extend([
+                # Create a copy to avoid modifying the original Column
+                (name, deepcopy(column)) for name, column in registered_columns.items()
+            ])
+
         # Add custom field & custom link columns
         content_type = ContentType.objects.get_for_model(self._meta.model)
         custom_fields = CustomField.objects.filter(
             content_types=content_type
         ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN)
-
         extra_columns.extend([
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
         ])
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py
--- a/netbox/utilities/tables.py
+++ b/netbox/utilities/tables.py
@@ -1,6 +1,9 @@
+from netbox.registry import registry
+
 __all__ = (
     'get_table_ordering',
     'linkify_phone',
+    'register_table_column'
 )
 
 
@@ -26,3 +29,19 @@ def linkify_phone(value):
     if value is None:
         return None
     return f"tel:{value}"
+
+
+def register_table_column(column, name, *tables):
+    """
+    Register a custom column for use on one or more tables.
+
+    Args:
+        column: The column instance to register
+        name: The name of the table column
+        tables: One or more table classes
+    """
+    for table in tables:
+        reg = registry['tables'][table]
+        if name in reg:
+            raise ValueError(f"A column named {name} is already defined for table {table.__name__}")
+        reg[name] = column


Test patch:
diff --git a/netbox/netbox/tests/dummy_plugin/tables.py b/netbox/netbox/tests/dummy_plugin/tables.py
new file mode 100644
--- /dev/null
+++ b/netbox/netbox/tests/dummy_plugin/tables.py
@@ -0,0 +1,11 @@
+import django_tables2 as tables
+
+from dcim.tables import SiteTable
+from utilities.tables import register_table_column
+
+mycol = tables.Column(
+    verbose_name='My column',
+    accessor=tables.A('description')
+)
+
+register_table_column(mycol, 'foo', SiteTable)
diff --git a/netbox/netbox/tests/dummy_plugin/views.py b/netbox/netbox/tests/dummy_plugin/views.py
--- a/netbox/netbox/tests/dummy_plugin/views.py
+++ b/netbox/netbox/tests/dummy_plugin/views.py
@@ -4,6 +4,8 @@
 from dcim.models import Site
 from utilities.views import register_model_view
 from .models import DummyModel
+# Trigger registration of custom column
+from .tables import mycol
 
 
 class DummyModelsView(View):
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
@@ -97,6 +97,16 @@ def test_template_extensions(self):
 
         self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
 
+    def test_registered_columns(self):
+        """
+        Check that a plugin can register a custom column on a core model table.
+        """
+        from dcim.models import Site
+        from dcim.tables import SiteTable
+
+        table = SiteTable(Site.objects.all())
+        self.assertIn('foo', table.columns.names())
+
     def test_user_preferences(self):
         """
         Check that plugin UserPreferences are registered.