Repository: nautobot/nautobot

Issue:
Display custom fields in listings/tabular views
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for proposing specific new features or enhancements.
    If you have a general idea or question, please start a discussion instead:
    https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot.
-->
### Environment
* Python version: 3.7.3
* Nautobot version: 1.0.0b4

<!--
    Describe in detail the new functionality you are proposing. Include any
    specific changes to work flows, data models, or the user interface.
-->
### Proposed Functionality
The ability to select / display custom fields as columns in tabular/listing views (e.g. interfaces on a device) along with the built-in fields.

<!--
    Convey an example use case for your proposed feature. Write from the
    perspective of a Nautobot user who would benefit from the proposed
    functionality and describe how.
--->
### Use Case
When one adds a custom field, it is due to having a specific requirement that NetBox is not able to model natively yet. There might be properties that are important enough to display in the overview, and not just in the details of a given object (for example: the "custom role" of an interface).

I am not familiar with the roadmap, but in case custom fields do become "first-class citizens" in the Nautobot ecosystem, this would be a straightforward addition (as opposed to having hardcoded columns for each object type).

<!--
    Note any changes to the database schema necessary to support the new
    feature. For example, does the proposal require adding a new model or
    field? (Not all new features require database changes.)
--->
### Database Changes
None, as far as I can tell.

<!--
    List any new dependencies on external libraries or services that this new
    feature would introduce. For example, does the proposal require the
    installation of a new Python package? (Not all new features introduce new
    dependencies.)
-->
### External Dependencies
Don't think so.


Discussion:
Thanks for this request. We'll review it and see if it fits with what we're trying to do! 
FYI: The code for this was already implemented in Netbox and tested in v2.10 (but will be deployed in v2.11), so maybe Nautobot could use it. https://github.com/netbox-community/netbox/pull/5460

This is something we would very much like to see in Nautobot too, few different people already asked me about this possibility.

Resolving patch:
diff --git a/nautobot/utilities/tables.py b/nautobot/utilities/tables.py
--- a/nautobot/utilities/tables.py
+++ b/nautobot/utilities/tables.py
@@ -2,12 +2,18 @@
 from django.conf import settings
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist
 from django.db.models.fields.related import RelatedField
 from django.urls import reverse
+from django.utils.html import escape, format_html
 from django.utils.safestring import mark_safe
 from django.utils.text import Truncator
 from django_tables2.data import TableQuerysetData
+from django_tables2.utils import Accessor
+
+from nautobot.extras.models import CustomField
+from nautobot.extras.choices import CustomFieldTypeChoices
 
 
 class BaseTable(tables.Table):
@@ -23,11 +29,19 @@ class Meta:
         }
 
     def __init__(self, *args, user=None, **kwargs):
+        # Add custom field columns
+        obj_type = ContentType.objects.get_for_model(self._meta.model)
+
+        for cf in CustomField.objects.filter(content_types=obj_type):
+            name = "cf_{}".format(cf.name)
+            self.base_columns[name] = CustomFieldColumn(cf)
+
+        # Init table
         super().__init__(*args, **kwargs)
 
         # Set default empty_text if none was provided
         if self.empty_text is None:
-            self.empty_text = "No {} found".format(self._meta.model._meta.verbose_name_plural)
+            self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found"
 
         # Hide non-default columns
         default_columns = list(getattr(self.Meta, "default_columns", list()))
@@ -319,3 +333,39 @@ def render(self, value):
             trunc = Truncator(value)
             value = trunc.words(self.truncate_words)
         return value
+
+
+class CustomFieldColumn(tables.Column):
+    """
+    Display custom fields in the appropriate format.
+    """
+
+    def __init__(self, customfield, *args, **kwargs):
+        self.customfield = customfield
+        kwargs["accessor"] = Accessor(f"_custom_field_data__{customfield.name}")
+        kwargs["verbose_name"] = customfield.label or customfield.name
+
+        super().__init__(*args, **kwargs)
+
+    def render(self, record, bound_column, value):
+        if value is None:
+            return self.default
+
+        if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+            if value is True:
+                template = '<span class="text-success"><i class="mdi mdi-check-bold"></i></span>'
+            else:
+                template = '<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>'
+        elif self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+            if value:
+                template = ""
+                for v in value:
+                    template += format_html('<span class="label label-default">{}</span> ', v)
+        elif self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
+            template = format_html('<span class="label label-default">{}</span>', value)
+        elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
+            template = format_html('<a href="{}">{}</a>', value, value)
+        else:
+            template = escape(value)
+
+        return mark_safe(template)


Test patch:
diff --git a/nautobot/extras/tests/test_customfields.py b/nautobot/extras/tests/test_customfields.py
--- a/nautobot/extras/tests/test_customfields.py
+++ b/nautobot/extras/tests/test_customfields.py
@@ -7,8 +7,10 @@
 from nautobot.dcim.filters import SiteFilterSet
 from nautobot.dcim.forms import SiteCSVForm
 from nautobot.dcim.models import Site, Rack, Device
+from nautobot.dcim.tables import SiteTable
 from nautobot.extras.choices import *
 from nautobot.extras.models import ComputedField, CustomField, CustomFieldChoice, Status
+from nautobot.utilities.tables import CustomFieldColumn
 from nautobot.utilities.testing import APITestCase, CeleryTestCase, TestCase
 from nautobot.virtualization.models import VirtualMachine
 
@@ -1131,3 +1133,115 @@ def test_update_custom_field_choice_data_task(self):
         site.refresh_from_db()
 
         self.assertEqual(site.cf["cf1"], "Bar")
+
+
+class CustomFieldTableTest(TestCase):
+    def setUp(self):
+        content_type = ContentType.objects.get_for_model(Site)
+
+        # Text custom field
+        cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name="text_field", default="foo")
+        cf_text.save()
+        cf_text.content_types.set([content_type])
+
+        # Integer custom field
+        cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name="number_field", default=123)
+        cf_integer.save()
+        cf_integer.content_types.set([content_type])
+
+        # Boolean custom field
+        cf_boolean = CustomField(
+            type=CustomFieldTypeChoices.TYPE_BOOLEAN,
+            name="boolean_field",
+            default=False,
+        )
+        cf_boolean.save()
+        cf_boolean.content_types.set([content_type])
+
+        # Date custom field
+        cf_date = CustomField(
+            type=CustomFieldTypeChoices.TYPE_DATE,
+            name="date_field",
+            default="2020-01-01",
+        )
+        cf_date.save()
+        cf_date.content_types.set([content_type])
+
+        # URL custom field
+        cf_url = CustomField(
+            type=CustomFieldTypeChoices.TYPE_URL,
+            name="url_field",
+            default="http://example.com/1",
+        )
+        cf_url.save()
+        cf_url.content_types.set([content_type])
+
+        # Select custom field
+        cf_select = CustomField(
+            type=CustomFieldTypeChoices.TYPE_SELECT,
+            name="choice_field",
+        )
+        cf_select.save()
+        cf_select.content_types.set([content_type])
+        CustomFieldChoice.objects.create(field=cf_select, value="Foo")
+        CustomFieldChoice.objects.create(field=cf_select, value="Bar")
+        CustomFieldChoice.objects.create(field=cf_select, value="Baz")
+        cf_select.default = "Foo"
+        cf_select.save()
+
+        # Multi-select custom field
+        cf_multi_select = CustomField(
+            type=CustomFieldTypeChoices.TYPE_MULTISELECT,
+            name="multi_choice_field",
+        )
+        cf_multi_select.save()
+        cf_multi_select.content_types.set([content_type])
+        CustomFieldChoice.objects.create(field=cf_multi_select, value="Foo")
+        CustomFieldChoice.objects.create(field=cf_multi_select, value="Bar")
+        CustomFieldChoice.objects.create(field=cf_multi_select, value="Baz")
+        cf_multi_select.default = ["Foo", "Bar"]
+        cf_multi_select.save()
+
+        statuses = Status.objects.get_for_model(Site)
+
+        # Create a site
+        self.site = Site.objects.create(name="Site Custom", slug="site-1", status=statuses.get(slug="active"))
+
+        # Assign custom field values for site 2
+        self.site._custom_field_data = {
+            cf_text.name: "bar",
+            cf_integer.name: 456,
+            cf_boolean.name: True,
+            cf_date.name: "2020-01-02",
+            cf_url.name: "http://example.com/2",
+            cf_select.name: "Bar",
+            cf_multi_select.name: ["Bar", "Baz"],
+        }
+        self.site.save()
+
+    def test_custom_field_table_render(self):
+        queryset = Site.objects.filter(name=self.site.name)
+        site_table = SiteTable(queryset)
+
+        custom_column_expected = {
+            "text_field": "bar",
+            "number_field": "456",
+            "boolean_field": '<span class="text-success"><i class="mdi mdi-check-bold"></i></span>',
+            "date_field": "2020-01-02",
+            "url_field": '<a href="http://example.com/2">http://example.com/2</a>',
+            "choice_field": '<span class="label label-default">Bar</span>',
+            "multi_choice_field": (
+                '<span class="label label-default">Bar</span> <span class="label label-default">Baz</span> '
+            ),
+        }
+
+        bound_row = site_table.rows[0]
+
+        for col_name, col_expected_value in custom_column_expected.items():
+            internal_col_name = "cf_" + col_name
+            custom_column = site_table.base_columns.get(internal_col_name)
+            self.assertIsNotNone(custom_column)
+            self.assertIsInstance(custom_column, CustomFieldColumn)
+
+            rendered_value = bound_row.get_cell(internal_col_name)
+            self.assertEqual(rendered_value, col_expected_value)