Repository: netbox-community/netbox

Issue:
Custom link in table view
### NetBox version

2.11.9

### Feature type

Change to existing functionality

### Proposed functionality

Custom links as available columns to add to the table view of IP Addresses.

### Use case

I have a custom link set up on each ip address that links to the http://{{ obj.address[0] }} browser page. This makes it easy to get the router login page / web site etc with a simple click. It would be nice, just as with the custom fields, you could add a custom link as a **column** to the table view of ip addresses. That way you can click on the link without having to open the object itself. I can see many other uses for links in columns like this... integrating with external websites / other IPAM sites etc.


### Database changes

None that I can see

### External dependencies

_No response_


Discussion:
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. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
Blocked by #6817. Once we figure that out, we can take this on.
Fixed #6817 :slightly_smiling_face: 
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. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).

Resolving patch:
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -229,6 +229,24 @@ def __str__(self):
     def get_absolute_url(self):
         return reverse('extras:customlink', args=[self.pk])
 
+    def render(self, context):
+        """
+        Render the CustomLink given the provided context, and return the text, link, and link_target.
+
+        :param context: The context passed to Jinja2
+        """
+        text = render_jinja2(self.link_text, context)
+        if not text:
+            return {}
+        link = render_jinja2(self.link_url, context)
+        link_target = ' target="_blank"' if self.new_window else ''
+
+        return {
+            'text': text,
+            'link': link,
+            'link_target': link_target,
+        }
+
 
 @extras_features('webhooks', 'export_templates')
 class ExportTemplate(ChangeLoggedModel):
diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py
--- a/netbox/extras/templatetags/custom_links.py
+++ b/netbox/extras/templatetags/custom_links.py
@@ -62,16 +62,14 @@ def custom_links(context, obj):
         # Add non-grouped links
         else:
             try:
-                text_rendered = render_jinja2(cl.link_text, link_context)
-                if text_rendered:
-                    link_rendered = render_jinja2(cl.link_url, link_context)
-                    link_target = ' target="_blank"' if cl.new_window else ''
+                rendered = cl.render(link_context)
+                if rendered:
                     template_code += LINK_BUTTON.format(
-                        link_rendered, link_target, cl.button_class, text_rendered
+                        rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
                     )
             except Exception as e:
-                template_code += '<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{}">' \
-                                 '<i class="mdi mdi-alert"></i> {}</a>\n'.format(e, cl.name)
+                template_code += f'<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{e}">' \
+                                 f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
 
     # Add grouped links to template
     for group, links in group_names.items():
@@ -80,17 +78,15 @@ def custom_links(context, obj):
 
         for cl in links:
             try:
-                text_rendered = render_jinja2(cl.link_text, link_context)
-                if text_rendered:
-                    link_target = ' target="_blank"' if cl.new_window else ''
-                    link_rendered = render_jinja2(cl.link_url, link_context)
+                rendered = cl.render(link_context)
+                if rendered:
                     links_rendered.append(
-                        GROUP_LINK.format(link_rendered, link_target, text_rendered)
+                        GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
                     )
             except Exception as e:
                 links_rendered.append(
-                    '<li><a class="dropdown-item" disabled="disabled" title="{}"><span class="text-muted">'
-                    '<i class="mdi mdi-alert"></i> {}</span></a></li>'.format(e, cl.name)
+                    f'<li><a class="dropdown-item" disabled="disabled" title="{e}"><span class="text-muted">'
+                    f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
                 )
 
         if links_rendered:
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py
--- a/netbox/utilities/tables.py
+++ b/netbox/utilities/tables.py
@@ -12,7 +12,7 @@
 from django_tables2.utils import Accessor
 
 from extras.choices import CustomFieldTypeChoices
-from extras.models import CustomField
+from extras.models import CustomField, CustomLink
 from .utils import content_type_identifier, content_type_name
 from .paginator import EnhancedPaginator, get_paginate_count
 
@@ -34,15 +34,18 @@ class Meta:
         }
 
     def __init__(self, *args, user=None, extra_columns=None, **kwargs):
+        if extra_columns is None:
+            extra_columns = []
+
         # Add custom field columns
         obj_type = ContentType.objects.get_for_model(self._meta.model)
         cf_columns = [
             (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type)
         ]
-        if extra_columns is not None:
-            extra_columns.extend(cf_columns)
-        else:
-            extra_columns = cf_columns
+        cl_columns = [
+            (f'cl_{cl.name}', CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type)
+        ]
+        extra_columns.extend([*cf_columns, *cl_columns])
 
         super().__init__(*args, extra_columns=extra_columns, **kwargs)
 
@@ -418,6 +421,37 @@ def render(self, value):
         return self.default
 
 
+class CustomLinkColumn(tables.Column):
+    """
+    Render a custom links as a table column.
+    """
+    def __init__(self, customlink, *args, **kwargs):
+        self.customlink = customlink
+        kwargs['accessor'] = Accessor('pk')
+        if 'verbose_name' not in kwargs:
+            kwargs['verbose_name'] = customlink.name
+
+        super().__init__(*args, **kwargs)
+
+    def render(self, record):
+        try:
+            rendered = self.customlink.render({'obj': record})
+            if rendered:
+                return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
+        except Exception as e:
+            return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> Error</span>')
+        return ''
+
+    def value(self, record):
+        try:
+            rendered = self.customlink.render({'obj': record})
+            if rendered:
+                return rendered['link']
+        except Exception:
+            pass
+        return None
+
+
 class MPTTColumn(tables.TemplateColumn):
     """
     Display a nested hierarchy for MPTT-enabled models.


Test patch: