From eaa281696483710bc49b3b0c377fc603f74719ca Mon Sep 17 00:00:00 2001 From: studioussagar Date: Tue, 28 Jul 2026 19:05:30 +0530 Subject: [PATCH] Closes #22522: Render colored badges for Custom Field Choices in tables (#22663) Render select and multiselect custom field values as colored badges in table views when their associated choices define colors. For multiselect fields, render all selected values as badges when any selected choice has a color, using the secondary badge color for uncolored choices. Preserve comma-separated text when none of the selected choices has a color. Add test coverage for colored, uncolored, empty, mixed, and HTML-sensitive choice values. Co-authored-by: Martin Hauser --- netbox/netbox/tables/columns.py | 32 +++++++- netbox/netbox/tests/test_tables.py | 115 +++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index b6e441228..cd493051a 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -539,9 +539,37 @@ class CustomFieldColumn(tables.Column): if self.customfield.type == CustomFieldTypeChoices.TYPE_URL: return mark_safe(f'{escape(value)}') if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT: - return self.customfield.get_choice_label(value) + if value is None: + return self.default + label = self.customfield.get_choice_label(value) + color = self.customfield.get_choice_color(value) + if color: + return mark_safe( + f'{escape(label)}' + ) + return label if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT: - return ', '.join(self.customfield.get_choice_label(v) for v in value) + if not value: + return '' + + has_color = False + parts = [] + + for v in value: + label = self.customfield.get_choice_label(v) + color = self.customfield.get_choice_color(v) + if color: + has_color = True + parts.append((label, color)) + if has_color: + badges = [] + for label, color in parts: + badges.append( + f'{escape(label)}' + ) + return mark_safe(' '.join(badges)) + return ', '.join(label for label, _ in parts) + if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: return mark_safe(', '.join( self._linkify_item(obj) for obj in self.customfield.deserialize(value) diff --git a/netbox/netbox/tests/test_tables.py b/netbox/netbox/tests/test_tables.py index aa6fd5ad2..9543f7668 100644 --- a/netbox/netbox/tests/test_tables.py +++ b/netbox/netbox/tests/test_tables.py @@ -2,8 +2,11 @@ from django.contrib.auth.models import AnonymousUser from django.template import Context, Template from django.test import RequestFactory, TestCase +from core.models import ObjectType from dcim.models import Device, Site from dcim.tables import DeviceTable +from extras.choices import CustomFieldChoiceColorChoices, CustomFieldTypeChoices +from extras.models import CustomField, CustomFieldChoiceSet from netbox.tables import NetBoxTable, columns from utilities.testing import create_tags, create_test_device, create_test_user @@ -119,3 +122,115 @@ class TagColumnTestCase(TestCase): 'table': table }) template.render(context) + + +class CustomFieldColumnTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.object_type = ObjectType.objects.get_for_model(Site) + + # Choice set containing one colored and two uncolored choices + cls.mixed_choice_set = CustomFieldChoiceSet.objects.create( + name='Mixed Choice Set', + extra_choices=( + ('a', 'Option A'), + ('b', 'Option B'), + ('c', 'Option C'), + ), + choice_colors={ + 'a': CustomFieldChoiceColorChoices.RED, + }, + ) + + cls.select_cf = CustomField.objects.create( + name='select_field', + type=CustomFieldTypeChoices.TYPE_SELECT, + choice_set=cls.mixed_choice_set, + required=False, + ) + cls.select_cf.object_types.set([cls.object_type]) + + cls.multiselect_cf = CustomField.objects.create( + name='multiselect_field', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + choice_set=cls.mixed_choice_set, + required=False, + ) + cls.multiselect_cf.object_types.set([cls.object_type]) + + def test_colored_single_select(self): + column = columns.CustomFieldColumn(self.select_cf) + + rendered = str(column.render('a')) + + self.assertIn('badge', rendered) + self.assertIn('text-bg-red', rendered) + self.assertIn('Option A', rendered) + + def test_uncolored_single_select(self): + column = columns.CustomFieldColumn(self.select_cf) + + rendered = str(column.render('b')) + + self.assertEqual(rendered, 'Option B') + self.assertNotIn('badge', rendered) + + def test_empty_multiselect(self): + column = columns.CustomFieldColumn(self.multiselect_cf) + + rendered = column.render([]) + + self.assertEqual(rendered, '') + + def test_multiselect_without_selected_colored_choices(self): + column = columns.CustomFieldColumn(self.multiselect_cf) + + rendered = str(column.render(['b', 'c'])) + + self.assertEqual(rendered, 'Option B, Option C') + self.assertNotIn('badge', rendered) + + def test_multiselect_with_mixed_colored_choices(self): + column = columns.CustomFieldColumn(self.multiselect_cf) + + rendered = str(column.render(['a', 'b'])) + + self.assertIn('Option A', rendered) + self.assertIn('Option B', rendered) + + self.assertIn('text-bg-red', rendered) + self.assertIn('text-bg-secondary', rendered) + + self.assertNotIn(',', rendered) + + def test_html_sensitive_multiselect_labels(self): + choice_set = CustomFieldChoiceSet.objects.create( + name='HTML Choice Set', + extra_choices=( + ('x', 'Bold Option'), + ('y', ""), + ), + choice_colors={ + 'x': CustomFieldChoiceColorChoices.RED, + }, + ) + + custom_field = CustomField.objects.create( + name='html_multiselect_field', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + choice_set=choice_set, + required=False, + ) + custom_field.object_types.set([self.object_type]) + + column = columns.CustomFieldColumn(custom_field) + + rendered = str(column.render(['x', 'y'])) + + self.assertIn('<b>Bold Option</b>', rendered) + self.assertNotIn('&lt;', rendered) + self.assertIn('<script>alert('xss')</script>', rendered,) + self.assertNotIn('