2
0
Эх сурвалжийг харах

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 <mhauser@netboxlabs.com>
studioussagar 1 өдөр өмнө
parent
commit
eaa2816964

+ 30 - 2
netbox/netbox/tables/columns.py

@@ -539,9 +539,37 @@ class CustomFieldColumn(tables.Column):
         if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
             return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
         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'<span class="badge text-bg-{escape(color)}">{escape(label)}</span>'
+                )
+            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'<span class="badge text-bg-{escape(color or "secondary")}">{escape(label)}</span>'
+                    )
+                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)

+ 115 - 0
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', '<b>Bold Option</b>'),
+                ('y', "<script>alert('xss')</script>"),
+            ),
+            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('&lt;b&gt;Bold Option&lt;/b&gt;', rendered)
+        self.assertNotIn('&amp;lt;', rendered)
+        self.assertIn('&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;', rendered,)
+        self.assertNotIn('<script>', rendered)
+
+        self.assertIn('text-bg-red', rendered)
+        self.assertIn('text-bg-secondary', rendered)