Răsfoiți Sursa

Closes #19648: Add support for colored Custom Field Choice Set values (#21984)

Fixes #19648
Martin Hauser 1 lună în urmă
părinte
comite
5f802bb18f

+ 2 - 0
docs/models/extras/customfield.md

@@ -103,6 +103,8 @@ The default value to populate for the custom field when creating new objects (op
 
 For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
 
+Choice sets may optionally define colors for individual values. Colored choices are rendered as badges on object detail pages for selection and multiple-selection custom fields.
+
 ### Cloneable
 
 If enabled, values from this field will be automatically pre-populated when cloning existing objects.

+ 22 - 0
docs/models/extras/customfieldchoiceset.md

@@ -22,6 +22,28 @@ The set of pre-defined choices to include. Available sets are listed below. This
 
 A set of custom choices that will be appended to the base choice set (if any).
 
+### Choice Colors
+
+Optional color bindings for individual choice values. Each color is bound to a choice by its value rather than its label.
+
+When editing a choice set in the UI, enter one mapping per line using the format `value:color`. Supported colors are:
+
+* `blue`
+* `indigo`
+* `purple`
+* `pink`
+* `red`
+* `orange`
+* `yellow`
+* `green`
+* `teal`
+* `cyan`
+* `gray`
+* `black`
+* `white`
+
+Colored choices are rendered as badges on object detail pages for selection and multiple-selection custom fields.
+
 ### Order Alphabetically
 
 If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined.

+ 5 - 1
netbox/extras/api/serializers_/customfields.py

@@ -27,13 +27,17 @@ class CustomFieldChoiceSetSerializer(OwnerMixin, ChangeLogMessageSerializer, Val
             max_length=2
         )
     )
+    choice_colors = serializers.DictField(
+        child=serializers.ChoiceField(choices=CustomFieldChoiceColorChoices),
+        required=False,
+    )
     choices_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = CustomFieldChoiceSet
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'description', 'base_choices', 'extra_choices',
-            'order_alphabetically', 'choices_count', 'owner', 'created', 'last_updated',
+            'choice_colors', 'order_alphabetically', 'choices_count', 'owner', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')
 

+ 33 - 0
netbox/extras/choices.py

@@ -95,6 +95,39 @@ class CustomFieldChoiceSetBaseChoices(ChoiceSet):
     )
 
 
+class CustomFieldChoiceColorChoices(ChoiceSet):
+
+    BLUE = 'blue'
+    INDIGO = 'indigo'
+    PURPLE = 'purple'
+    PINK = 'pink'
+    RED = 'red'
+    ORANGE = 'orange'
+    YELLOW = 'yellow'
+    GREEN = 'green'
+    TEAL = 'teal'
+    CYAN = 'cyan'
+    GRAY = 'gray'
+    BLACK = 'black'
+    WHITE = 'white'
+
+    CHOICES = (
+        (BLUE, _('Blue'), BLUE),
+        (INDIGO, _('Indigo'), INDIGO),
+        (PURPLE, _('Purple'), PURPLE),
+        (PINK, _('Pink'), PINK),
+        (RED, _('Red'), RED),
+        (ORANGE, _('Orange'), ORANGE),
+        (YELLOW, _('Yellow'), YELLOW),
+        (GREEN, _('Green'), GREEN),
+        (TEAL, _('Teal'), TEAL),
+        (CYAN, _('Cyan'), CYAN),
+        (GRAY, _('Gray'), GRAY),
+        (BLACK, _('Black'), BLACK),
+        (WHITE, _('White'), WHITE),
+    )
+
+
 #
 # CustomLinks
 #

+ 25 - 0
netbox/extras/filtersets.py

@@ -200,6 +200,12 @@ class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet
     choice = MultiValueCharFilter(
         method='filter_by_choice'
     )
+    choice_colors = django_filters.MultipleChoiceFilter(
+        choices=CustomFieldChoiceColorChoices,
+        method='filter_by_choice_colors',
+        label=_('Choice colors'),
+        distinct=False,
+    )
 
     class Meta:
         model = CustomFieldChoiceSet
@@ -219,6 +225,25 @@ class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet
         # TODO: Support case-insensitive matching
         return queryset.filter(extra_choices__overlap=value)
 
+    def filter_by_choice_colors(self, queryset, name, value):
+        if not value:
+            return queryset
+
+        choice_color_keys = set()
+        for choice_colors in queryset.values_list('choice_colors', flat=True):
+            if isinstance(choice_colors, dict):
+                choice_color_keys.update(choice_colors.keys())
+
+        if not choice_color_keys:
+            return queryset.none()
+
+        params = Q()
+        for key in choice_color_keys:
+            for color in value:
+                params |= Q(choice_colors__contains={key: color})
+
+        return queryset.filter(params)
+
 
 @register_filterset
 class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):

+ 31 - 1
netbox/extras/forms/bulk_import.py

@@ -99,11 +99,19 @@ class CustomFieldChoiceSetImportForm(OwnerCSVMixin, CSVModelForm):
             '"choice1:First Choice,choice2:Second Choice"'
         )
     )
+    choice_colors = SimpleArrayField(
+        base_field=forms.CharField(),
+        required=False,
+        help_text=_(
+            'Quoted string of comma-separated color mappings in the format '
+            '"choice1:red,choice2:green". Supported colors: {colors}'
+        ).format(colors=', '.join(CustomFieldChoiceColorChoices.values())),
+    )
 
     class Meta:
         model = CustomFieldChoiceSet
         fields = (
-            'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner',
+            'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically', 'owner',
         )
 
     def clean_extra_choices(self):
@@ -120,6 +128,28 @@ class CustomFieldChoiceSetImportForm(OwnerCSVMixin, CSVModelForm):
             return data
         return None
 
+    def clean_choice_colors(self):
+        if isinstance(self.cleaned_data['choice_colors'], list):
+            data = {}
+            for line in self.cleaned_data['choice_colors']:
+                try:
+                    value, color = re.split(r'(?<!\\):', line, maxsplit=1)
+                    value = value.replace('\\:', ':')
+                except ValueError as e:
+                    raise forms.ValidationError(
+                        _("Invalid color mapping '{line}'. Use the format value:color.").format(line=line)
+                    ) from e
+
+                value = value.strip()
+                color = color.strip()
+                if value in data:
+                    raise forms.ValidationError(
+                        _("Duplicate color mapping defined for choice '{value}'.").format(value=value)
+                    )
+                data[value] = color
+            return data
+        return {}
+
 
 class CustomLinkImportForm(OwnerCSVMixin, CSVModelForm):
     object_types = CSVMultipleContentTypeField(

+ 6 - 1
netbox/extras/forms/filtersets.py

@@ -128,7 +128,7 @@ class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, Filter
     model = CustomFieldChoiceSet
     fieldsets = (
         FieldSet('q', 'filter_id'),
-        FieldSet('base_choices', 'choice', name=_('Choices')),
+        FieldSet('base_choices', 'choice', 'choice_colors', name=_('Choices')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     base_choices = forms.MultipleChoiceField(
@@ -138,6 +138,11 @@ class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, Filter
     choice = forms.CharField(
         required=False
     )
+    choice_colors = forms.MultipleChoiceField(
+        choices=CustomFieldChoiceColorChoices,
+        required=False,
+        label=_('Choice colors'),
+    )
 
 
 class CustomLinkFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):

+ 56 - 2
netbox/extras/forms/model_forms.py

@@ -197,17 +197,33 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
             'colon. Example:'
         ) + ' <code>choice1:First Choice</code>')
     )
+    choice_colors = forms.CharField(
+        widget=ChoicesWidget(),
+        required=False,
+        help_text=mark_safe(
+            _(
+                'Bind an optional color to a choice by its value. Enter one mapping per line as '
+                '<code>value:color</code>. Example:'
+            )
+            + ' <code>choice1:red</code><br />'
+            + _('Supported colors: {colors}').format(
+                colors=', '.join(f'<code>{color}</code>' for color in CustomFieldChoiceColorChoices.values())
+            )
+        ),
+    )
 
     fieldsets = (
         FieldSet(
-            'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
+            'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically',
             name=_('Custom Field Choice Set')
         ),
     )
 
     class Meta:
         model = CustomFieldChoiceSet
-        fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')
+        fields = (
+            'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically', 'owner'
+        )
 
     def __init__(self, *args, initial=None, **kwargs):
         super().__init__(*args, initial=initial, **kwargs)
@@ -234,9 +250,25 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
 
             self.initial['extra_choices'] = '\n'.join(choices)
 
+        # Convert choice_colors JSONField from model to CharField for form
+        if 'choice_colors' in self.initial:
+            choice_colors = self.initial.get('choice_colors') or {}
+
+            if isinstance(choice_colors, str):
+                choice_colors = json.loads(choice_colors)
+
+            mappings = []
+            for value, color in sorted(choice_colors.items()):
+                value = value.replace(':', '\\:')
+                mappings.append(f'{value}:{color}')
+
+            self.initial['choice_colors'] = '\n'.join(mappings)
+
     def clean_extra_choices(self):
         data = []
         for line in self.cleaned_data['extra_choices'].splitlines():
+            if not line.strip():
+                continue
             try:
                 value, label = re.split(r'(?<!\\):', line, maxsplit=1)
                 value = value.replace('\\:', ':')
@@ -246,6 +278,28 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
             data.append((value.strip(), label.strip()))
         return data
 
+    def clean_choice_colors(self):
+        data = {}
+        for line in self.cleaned_data['choice_colors'].splitlines():
+            if not line.strip():
+                continue
+            try:
+                value, color = re.split(r'(?<!\\):', line, maxsplit=1)
+                value = value.replace('\\:', ':')
+            except ValueError as e:
+                raise forms.ValidationError(
+                    _("Invalid color mapping '{line}'. Use the format value:color.").format(line=line)
+                ) from e
+
+            value = value.strip()
+            color = color.strip()
+            if value in data:
+                raise forms.ValidationError(
+                    _("Duplicate color mapping defined for choice '{value}'.").format(value=value)
+                )
+            data[value] = color
+        return data
+
 
 class CustomLinkForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
     object_types = ContentTypeMultipleChoiceField(

+ 2 - 0
netbox/extras/graphql/enums.py

@@ -3,6 +3,7 @@ import strawberry
 from extras.choices import *
 
 __all__ = (
+    'CustomFieldChoiceColorEnum',
     'CustomFieldChoiceSetBaseEnum',
     'CustomFieldFilterLogicEnum',
     'CustomFieldTypeEnum',
@@ -15,6 +16,7 @@ __all__ = (
 )
 
 
+CustomFieldChoiceColorEnum = strawberry.enum(CustomFieldChoiceColorChoices.as_enum())
 CustomFieldChoiceSetBaseEnum = strawberry.enum(CustomFieldChoiceSetBaseChoices.as_enum())
 CustomFieldFilterLogicEnum = strawberry.enum(CustomFieldFilterLogicChoices.as_enum(prefix='filter'))
 CustomFieldTypeEnum = strawberry.enum(CustomFieldTypeChoices.as_enum(prefix='type'))

+ 28 - 0
netbox/extras/graphql/filters.py

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated
 
 import strawberry
 import strawberry_django
+from django.db.models import Q, QuerySet
 from strawberry.scalars import ID
 from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
 
@@ -199,6 +200,33 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
     )
     order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()
 
+    @strawberry_django.filter_field(resolve_value=True)
+    def choice_colors(
+        self,
+        queryset: QuerySet,
+        value: list[Annotated['CustomFieldChoiceColorEnum', strawberry.lazy('extras.graphql.enums')]],
+        prefix: str,
+    ) -> tuple[QuerySet, Q]:
+        if not value:
+            return queryset, Q()
+
+        field_name = f'{prefix}choice_colors'
+        choice_values = set()
+
+        for mapping in queryset.exclude(**{f'{field_name}__isnull': True}).values_list(field_name, flat=True):
+            if isinstance(mapping, dict):
+                choice_values.update(mapping.keys())
+
+        if not choice_values:
+            return queryset, Q(pk__in=[])
+
+        params = Q()
+        for color in value:
+            for choice_value in choice_values:
+                params |= Q(**{f'{field_name}__contains': {choice_value: color}})
+
+        return queryset, params
+
 
 @strawberry_django.filter_type(models.CustomLink, lookups=True)
 class CustomLinkFilter(ChangeLoggedModelFilter):

+ 3 - 1
netbox/extras/graphql/types.py

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated
 
 import strawberry
 import strawberry_django
+from strawberry.scalars import JSON
 
 from core.graphql.mixins import SyncedDataMixin
 from extras import models
@@ -106,7 +107,7 @@ class CustomFieldType(OwnerMixin, ObjectType):
 
 @strawberry_django.type(
     models.CustomFieldChoiceSet,
-    exclude=['extra_choices'],
+    exclude=['extra_choices', 'choice_colors'],
     filters=CustomFieldChoiceSetFilter,
     pagination=True
 )
@@ -114,6 +115,7 @@ class CustomFieldChoiceSetType(OwnerMixin, ObjectType):
 
     choices_for: list[Annotated["CustomFieldType", strawberry.lazy('extras.graphql.types')]]
     extra_choices: list[list[str]] | None
+    choice_colors: JSON
 
 
 @strawberry_django.type(

+ 15 - 0
netbox/extras/migrations/0138_customfieldchoiceset_choice_colors.py

@@ -0,0 +1,15 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('extras', '0137_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='customfieldchoiceset',
+            name='choice_colors',
+            field=models.JSONField(blank=True, default=dict),
+        ),
+    ]

+ 71 - 13
netbox/extras/models/customfields.py

@@ -317,6 +317,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
             self._choice_map = dict(self.choices)
         return self._choice_map.get(value, value)
 
+    def get_choice_color(self, value):
+        if self.choice_set:
+            return self.choice_set.get_choice_color(value)
+        return None
+
     def populate_initial_data(self, content_types):
         """
         Populate initial custom field data upon either a) the creation of a new CustomField, or
@@ -880,12 +885,16 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
         blank=True,
         null=True
     )
+    choice_colors = models.JSONField(
+        default=dict,
+        blank=True,
+    )
     order_alphabetically = models.BooleanField(
         default=False,
         help_text=_('Choices are automatically ordered alphabetically')
     )
 
-    clone_fields = ('extra_choices', 'order_alphabetically')
+    clone_fields = ('extra_choices', 'choice_colors', 'order_alphabetically')
 
     class Meta:
         ordering = ('name',)
@@ -919,6 +928,24 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
             self._choices = sorted(self._choices, key=lambda x: x[0])
         return self._choices
 
+    @property
+    def colors(self):
+        """
+        Return merged color mappings from the selected base choice set (if it defines colors)
+        and any custom color overrides defined on this choice set.
+        """
+        if not hasattr(self, '_colors'):
+            self._colors = {}
+            if self.base_choices:
+                base_choice_set = CHOICE_SETS.get(self.base_choices)
+                self._colors.update(getattr(base_choice_set, 'colors', {}))
+            if self.choice_colors:
+                self._colors.update(self.choice_colors)
+        return self._colors
+
+    def get_choice_color(self, value):
+        return self.colors.get(value)
+
     @property
     def choices_count(self):
         return len(self.choices)
@@ -934,25 +961,56 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
         if not self.base_choices and not self.extra_choices:
             raise ValidationError(_("Must define base or extra choices."))
 
-        # Check for duplicate values in extra_choices
-        choice_values = [c[0] for c in self.extra_choices] if self.extra_choices else []
-        if len(set(choice_values)) != len(choice_values):
-            # At least one duplicate value is present. Find the first one and raise an error.
-            _seen = []
-            for value in choice_values:
-                if value in _seen:
+        if self.choice_colors is None:
+            self.choice_colors = {}
+        elif not isinstance(self.choice_colors, dict):
+            raise ValidationError({
+                'choice_colors': _('Color mappings must be defined as a JSON object.')
+            })
+
+        valid_choice_values = set()
+        extra_choice_values = set()
+
+        if self.base_choices:
+            valid_choice_values.update(CHOICE_SETS.get(self.base_choices).values())
+
+        if self.extra_choices:
+            for value, _label in self.extra_choices:
+                if value in extra_choice_values:
                     raise ValidationError(_("Duplicate value '{value}' found in extra choices.").format(value=value))
-                _seen.append(value)
+                extra_choice_values.add(value)
+            valid_choice_values.update(extra_choice_values)
+
+        invalid_choice_values = set()
+        invalid_colors = set()
+        valid_colors = set(CustomFieldChoiceColorChoices.values())
+
+        for value, color in self.choice_colors.items():
+            if value not in valid_choice_values:
+                invalid_choice_values.add(value)
+            if color not in valid_colors:
+                invalid_colors.add(color)
+
+        if invalid_choice_values:
+            raise ValidationError({
+                'choice_colors': _(
+                    'Color mappings must reference an existing choice value. Invalid value(s): {values}.'
+                ).format(values=', '.join(sorted(invalid_choice_values)))
+            })
+
+        if invalid_colors:
+            raise ValidationError({
+                'choice_colors': _(
+                    'Invalid color value(s): {colors}. Use a supported named color.'
+                ).format(colors=', '.join(sorted(invalid_colors)))
+            })
 
         # Check whether any choices have been removed. If so, check whether any of the removed
         # choices are still set in custom field data for any object.
         original_choices = set([
             c[0] for c in self._original_extra_choices
         ]) if self._original_extra_choices else set()
-        current_choices = set([
-            c[0] for c in self.extra_choices
-        ]) if self.extra_choices else set()
-        if removed_choices := original_choices - current_choices:
+        if removed_choices := original_choices - valid_choice_values:
             for custom_field in self.choices_for.all():
                 for object_type in custom_field.object_types.all():
                     model = object_type.model_class()

+ 43 - 0
netbox/extras/tests/test_api.py

@@ -215,6 +215,10 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
                 ['4B', 'Choice 2'],
                 ['4C', 'Choice 3'],
             ],
+            'choice_colors': {
+                '4A': 'red',
+                '4B': 'green',
+            },
         },
         {
             'name': 'Choice Set 5',
@@ -223,6 +227,9 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
                 ['5B', 'Choice 2'],
                 ['5C', 'Choice 3'],
             ],
+            'choice_colors': {
+                '5C': 'blue',
+            },
         },
         {
             'name': 'Choice Set 6',
@@ -243,6 +250,10 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
             ['X2', 'Choice 2'],
             ['X3', 'Choice 3'],
         ],
+        'choice_colors': {
+            'X1': 'red',
+            'X3': 'green',
+        },
         'description': 'New description',
     }
 
@@ -281,6 +292,38 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
         response = self.client.post(self._get_list_url(), data, format='json', **self.header)
         self.assertEqual(response.status_code, 400)
 
+    def test_invalid_choice_color(self):
+        self.add_permissions('extras.add_customfieldchoiceset')
+        data = {
+            'name': 'test',
+            'extra_choices': [
+                ['choice1', 'Choice 1'],
+                ['choice2', 'Choice 2'],
+            ],
+            'choice_colors': {
+                'choice1': 'magenta',
+            },
+        }
+
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertEqual(response.status_code, 400)
+
+    def test_invalid_choice_color_reference(self):
+        self.add_permissions('extras.add_customfieldchoiceset')
+        data = {
+            'name': 'test',
+            'extra_choices': [
+                ['choice1', 'Choice 1'],
+                ['choice2', 'Choice 2'],
+            ],
+            'choice_colors': {
+                'choice3': 'red',
+            },
+        }
+
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertEqual(response.status_code, 400)
+
 
 class CustomLinkTest(APIViewTestCases.APIViewTestCase):
     model = CustomLink

+ 67 - 0
netbox/extras/tests/test_customfields.py

@@ -399,6 +399,73 @@ class CustomFieldTest(TestCase):
         instance.refresh_from_db()
         self.assertIsNone(instance.custom_field_data.get(cf.name))
 
+    def test_choice_set_colors(self):
+        choice_set = CustomFieldChoiceSet(
+            name='Test Choice Set',
+            extra_choices=(
+                ('a', 'Option A'),
+                ('b', 'Option B'),
+            ),
+            choice_colors={
+                'a': CustomFieldChoiceColorChoices.RED,
+                'b': CustomFieldChoiceColorChoices.GREEN,
+            },
+        )
+        choice_set.full_clean()
+
+        self.assertEqual(
+            choice_set.colors,
+            {
+                'a': CustomFieldChoiceColorChoices.RED,
+                'b': CustomFieldChoiceColorChoices.GREEN,
+            },
+        )
+
+    def test_choice_set_invalid_color_mapping_value(self):
+        choice_set = CustomFieldChoiceSet(
+            name='Test Choice Set',
+            extra_choices=(
+                ('a', 'Option A'),
+                ('b', 'Option B'),
+            ),
+            choice_colors={'c': CustomFieldChoiceColorChoices.RED},
+        )
+
+        with self.assertRaises(ValidationError) as cm:
+            choice_set.full_clean()
+
+        self.assertIn('choice_colors', cm.exception.message_dict)
+
+    def test_choice_set_invalid_color_value(self):
+        choice_set = CustomFieldChoiceSet(
+            name='Test Choice Set',
+            extra_choices=(
+                ('a', 'Option A'),
+                ('b', 'Option B'),
+            ),
+            choice_colors={'a': 'magenta'},
+        )
+
+        with self.assertRaises(ValidationError) as cm:
+            choice_set.full_clean()
+
+        self.assertIn('choice_colors', cm.exception.message_dict)
+
+    def test_choice_set_invalid_color_mapping_structure(self):
+        choice_set = CustomFieldChoiceSet(
+            name='Test Choice Set',
+            extra_choices=(
+                ('a', 'Option A'),
+                ('b', 'Option B'),
+            ),
+            choice_colors=['a:red'],
+        )
+
+        with self.assertRaises(ValidationError) as cm:
+            choice_set.full_clean()
+
+        self.assertIn('choice_colors', cm.exception.message_dict)
+
     def test_remove_selected_choice(self):
         """
         Removing a ChoiceSet choice that is referenced by an object should raise

+ 25 - 2
netbox/extras/tests/test_filtersets.py

@@ -160,8 +160,21 @@ class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     def setUpTestData(cls):
         choice_sets = (
-            CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C'], description='foobar1'),
-            CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F'], description='foobar2'),
+            CustomFieldChoiceSet(
+                name='Choice Set 1',
+                extra_choices=['A', 'B', 'C'],
+                choice_colors={'A': CustomFieldChoiceColorChoices.RED},
+                description='foobar1',
+            ),
+            CustomFieldChoiceSet(
+                name='Choice Set 2',
+                extra_choices=['D', 'E', 'F'],
+                choice_colors={
+                    'D': CustomFieldChoiceColorChoices.GREEN,
+                    'E': CustomFieldChoiceColorChoices.RED,
+                },
+                description='foobar2',
+            ),
             CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I'], description='foobar3'),
         )
         CustomFieldChoiceSet.objects.bulk_create(choice_sets)
@@ -178,6 +191,16 @@ class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'choice': ['A', 'D']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_choice_colors(self):
+        params = {'choice_colors': [CustomFieldChoiceColorChoices.RED]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+        params = {'choice_colors': [CustomFieldChoiceColorChoices.GREEN]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+        params = {'choice_colors': [CustomFieldChoiceColorChoices.YELLOW]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
     def test_description(self):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 26 - 0
netbox/extras/tests/test_forms.py

@@ -115,6 +115,32 @@ class CustomFieldChoiceSetFormTest(TestCase):
         # cleaned extra choices are correct, which does actually mean a list of tuples
         self.assertEqual(updated.extra_choices, [('foo:bar', 'label'), ('value', 'label:with:colons')])
 
+    def test_choice_colors_round_trip_on_edit(self):
+        choice_set = CustomFieldChoiceSet.objects.create(
+            name='Test Choice Set',
+            extra_choices=[['foo:bar', 'label'], ['choice2', 'Choice 2']],
+            choice_colors={'foo:bar': 'red', 'choice2': 'green'},
+        )
+
+        form = CustomFieldChoiceSetForm(instance=choice_set)
+        initial_choices = form.initial['extra_choices']
+        initial_choice_colors = form.initial['choice_colors']
+
+        self.assertEqual(initial_choice_colors, 'choice2:green\nfoo\\:bar:red')
+
+        form = CustomFieldChoiceSetForm(
+            {
+                'name': choice_set.name,
+                'extra_choices': initial_choices,
+                'choice_colors': initial_choice_colors,
+            },
+            instance=choice_set,
+        )
+        self.assertTrue(form.is_valid())
+        updated = form.save()
+
+        self.assertEqual(updated.choice_colors, {'choice2': 'green', 'foo:bar': 'red'})
+
 
 class SavedFilterFormTest(TestCase):
 

+ 9 - 1
netbox/extras/views.py

@@ -167,7 +167,15 @@ class CustomFieldChoiceSetView(generic.ObjectView):
             page_number = request.GET.get('page', 1)
         except ValueError:
             page_number = 1
-        paginator = EnhancedPaginator(instance.choices, per_page)
+        choice_rows = [
+            {
+                'value': value,
+                'label': label,
+                'color': instance.get_choice_color(value),
+            }
+            for value, label in instance.choices
+        ]
+        paginator = EnhancedPaginator(choice_rows, per_page)
         try:
             choices = paginator.page(page_number)
         except EmptyPage:

+ 12 - 3
netbox/templates/extras/panels/customfieldchoiceset_choices.html

@@ -1,4 +1,5 @@
 {% extends "ui/panels/_base.html" %}
+{% load helpers %}
 {% load i18n %}
 
 {% block panel_content %}
@@ -7,13 +8,21 @@
       <tr>
         <th>{% trans "Value" %}</th>
         <th>{% trans "Label" %}</th>
+        <th>{% trans "Color" %}</th>
       </tr>
     </thead>
     <tbody>
-      {% for value, label in choices %}
+      {% for choice in choices %}
         <tr>
-          <td>{{ value }}</td>
-          <td>{{ label }}</td>
+          <td>{{ choice.value }}</td>
+          <td>{{ choice.label }}</td>
+          <td>
+            {% if choice.color %}
+              {% badge choice.color bg_color=choice.color %}
+            {% else %}
+              {{ ''|placeholder }}
+            {% endif %}
+          </td>
         </tr>
       {% endfor %}
     </tbody>

+ 19 - 1
netbox/utilities/templates/builtins/customfield_value.html

@@ -18,8 +18,26 @@
   <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
 {% elif customfield.type == 'json' and value is not None %}
   <pre>{{ value|json }}</pre>
+{% elif customfield.type == 'select' and value %}
+  {% if color %}
+    {% badge value bg_color=color %}
+  {% else %}
+    {{ value }}
+  {% endif %}
 {% elif customfield.type == 'multiselect' and value %}
-  {{ value|join:", " }}
+  {% if value_has_colors %}
+    <div class="d-flex flex-wrap gap-1">
+      {% for choice_label, choice_color in value %}
+        {% if choice_color %}
+          {% badge choice_label bg_color=choice_color %}
+        {% else %}
+          {% badge choice_label %}
+        {% endif %}
+      {% endfor %}
+    </div>
+  {% else %}
+    {{ value|join:", " }}
+  {% endif %}
 {% elif customfield.type == 'object' and value %}
   {{ value|linkify }}
 {% elif customfield.type == 'multiobject' and value %}

+ 10 - 1
netbox/utilities/templatetags/builtins/tags.py

@@ -46,14 +46,23 @@ def customfield_value(customfield, value):
         customfield: A CustomField instance
         value: The custom field value applied to an object
     """
+    color = None
+    value_has_colors = False
+
     if value:
         if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
+            color = customfield.get_choice_color(value)
             value = customfield.get_choice_label(value)
         elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
-            value = [customfield.get_choice_label(v) for v in value]
+            value = [(customfield.get_choice_label(v), customfield.get_choice_color(v)) for v in value]
+            value_has_colors = any(choice_color for _, choice_color in value)
+            if not value_has_colors:
+                value = [choice_label for choice_label, _ in value]
     return {
         'customfield': customfield,
         'value': value,
+        'color': color,
+        'value_has_colors': value_has_colors,
     }
 
 

+ 54 - 1
netbox/utilities/tests/test_templatetags.py

@@ -3,10 +3,63 @@ from unittest.mock import patch
 from django.template.loader import render_to_string
 from django.test import TestCase, override_settings
 
-from utilities.templatetags.builtins.tags import badge, static_with_params
+from core.models import ObjectType
+from dcim.models import Site
+from extras.choices import CustomFieldTypeChoices
+from extras.models import CustomField, CustomFieldChoiceSet
+from utilities.templatetags.builtins.tags import badge, customfield_value, static_with_params
 from utilities.templatetags.helpers import _humanize_capacity, humanize_speed
 
 
+class CustomFieldValueTagTest(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        object_type = ObjectType.objects.get_for_model(Site)
+        choice_set = CustomFieldChoiceSet.objects.create(
+            name='Choice Set 1',
+            extra_choices=(('a', 'Option A'), ('b', 'Option B')),
+            choice_colors={'a': 'red'},
+        )
+
+        cls.select_field = CustomField.objects.create(
+            name='select_field',
+            type=CustomFieldTypeChoices.TYPE_SELECT,
+            choice_set=choice_set,
+        )
+        cls.select_field.object_types.set([object_type])
+
+        cls.multiselect_field = CustomField.objects.create(
+            name='multiselect_field',
+            type=CustomFieldTypeChoices.TYPE_MULTISELECT,
+            choice_set=choice_set,
+        )
+        cls.multiselect_field.object_types.set([object_type])
+
+    def test_select_choice_context_includes_color(self):
+        context = customfield_value(self.select_field, 'a')
+
+        self.assertEqual(context['value'], 'Option A')
+        self.assertEqual(context['color'], 'red')
+
+    def test_multiselect_choice_context_includes_colors(self):
+        context = customfield_value(self.multiselect_field, ['a', 'b'])
+
+        self.assertTrue(context['value_has_colors'])
+        self.assertEqual(
+            context['value'],
+            [
+                ('Option A', 'red'),
+                ('Option B', None),
+            ],
+        )
+
+    def test_multiselect_choice_context_without_colors_preserves_plain_labels(self):
+        context = customfield_value(self.multiselect_field, ['b'])
+
+        self.assertFalse(context['value_has_colors'])
+        self.assertEqual(context['value'], ['Option B'])
+
+
 class StaticWithParamsTest(TestCase):
     """
     Test the static_with_params template tag functionality.