Explorar el Código

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

Fixes #19648
Martin Hauser hace 1 mes
padre
commit
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.
 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
 ### Cloneable
 
 
 If enabled, values from this field will be automatically pre-populated when cloning existing objects.
 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).
 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
 ### 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.
 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
             max_length=2
         )
         )
     )
     )
+    choice_colors = serializers.DictField(
+        child=serializers.ChoiceField(choices=CustomFieldChoiceColorChoices),
+        required=False,
+    )
     choices_count = serializers.IntegerField(read_only=True)
     choices_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = CustomFieldChoiceSet
         model = CustomFieldChoiceSet
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'description', 'base_choices', 'extra_choices',
             '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')
         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
 # CustomLinks
 #
 #

+ 25 - 0
netbox/extras/filtersets.py

@@ -200,6 +200,12 @@ class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet
     choice = MultiValueCharFilter(
     choice = MultiValueCharFilter(
         method='filter_by_choice'
         method='filter_by_choice'
     )
     )
+    choice_colors = django_filters.MultipleChoiceFilter(
+        choices=CustomFieldChoiceColorChoices,
+        method='filter_by_choice_colors',
+        label=_('Choice colors'),
+        distinct=False,
+    )
 
 
     class Meta:
     class Meta:
         model = CustomFieldChoiceSet
         model = CustomFieldChoiceSet
@@ -219,6 +225,25 @@ class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet
         # TODO: Support case-insensitive matching
         # TODO: Support case-insensitive matching
         return queryset.filter(extra_choices__overlap=value)
         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
 @register_filterset
 class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
 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"'
             '"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:
     class Meta:
         model = CustomFieldChoiceSet
         model = CustomFieldChoiceSet
         fields = (
         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):
     def clean_extra_choices(self):
@@ -120,6 +128,28 @@ class CustomFieldChoiceSetImportForm(OwnerCSVMixin, CSVModelForm):
             return data
             return data
         return None
         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):
 class CustomLinkImportForm(OwnerCSVMixin, CSVModelForm):
     object_types = CSVMultipleContentTypeField(
     object_types = CSVMultipleContentTypeField(

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

@@ -128,7 +128,7 @@ class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, Filter
     model = CustomFieldChoiceSet
     model = CustomFieldChoiceSet
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id'),
         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')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     base_choices = forms.MultipleChoiceField(
     base_choices = forms.MultipleChoiceField(
@@ -138,6 +138,11 @@ class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, Filter
     choice = forms.CharField(
     choice = forms.CharField(
         required=False
         required=False
     )
     )
+    choice_colors = forms.MultipleChoiceField(
+        choices=CustomFieldChoiceColorChoices,
+        required=False,
+        label=_('Choice colors'),
+    )
 
 
 
 
 class CustomLinkFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
 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:'
             'colon. Example:'
         ) + ' <code>choice1:First Choice</code>')
         ) + ' <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 = (
     fieldsets = (
         FieldSet(
         FieldSet(
-            'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
+            'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically',
             name=_('Custom Field Choice Set')
             name=_('Custom Field Choice Set')
         ),
         ),
     )
     )
 
 
     class Meta:
     class Meta:
         model = CustomFieldChoiceSet
         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):
     def __init__(self, *args, initial=None, **kwargs):
         super().__init__(*args, initial=initial, **kwargs)
         super().__init__(*args, initial=initial, **kwargs)
@@ -234,9 +250,25 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
 
 
             self.initial['extra_choices'] = '\n'.join(choices)
             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):
     def clean_extra_choices(self):
         data = []
         data = []
         for line in self.cleaned_data['extra_choices'].splitlines():
         for line in self.cleaned_data['extra_choices'].splitlines():
+            if not line.strip():
+                continue
             try:
             try:
                 value, label = re.split(r'(?<!\\):', line, maxsplit=1)
                 value, label = re.split(r'(?<!\\):', line, maxsplit=1)
                 value = value.replace('\\:', ':')
                 value = value.replace('\\:', ':')
@@ -246,6 +278,28 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
             data.append((value.strip(), label.strip()))
             data.append((value.strip(), label.strip()))
         return data
         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):
 class CustomLinkForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
     object_types = ContentTypeMultipleChoiceField(
     object_types = ContentTypeMultipleChoiceField(

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

@@ -3,6 +3,7 @@ import strawberry
 from extras.choices import *
 from extras.choices import *
 
 
 __all__ = (
 __all__ = (
+    'CustomFieldChoiceColorEnum',
     'CustomFieldChoiceSetBaseEnum',
     'CustomFieldChoiceSetBaseEnum',
     'CustomFieldFilterLogicEnum',
     'CustomFieldFilterLogicEnum',
     'CustomFieldTypeEnum',
     'CustomFieldTypeEnum',
@@ -15,6 +16,7 @@ __all__ = (
 )
 )
 
 
 
 
+CustomFieldChoiceColorEnum = strawberry.enum(CustomFieldChoiceColorChoices.as_enum())
 CustomFieldChoiceSetBaseEnum = strawberry.enum(CustomFieldChoiceSetBaseChoices.as_enum())
 CustomFieldChoiceSetBaseEnum = strawberry.enum(CustomFieldChoiceSetBaseChoices.as_enum())
 CustomFieldFilterLogicEnum = strawberry.enum(CustomFieldFilterLogicChoices.as_enum(prefix='filter'))
 CustomFieldFilterLogicEnum = strawberry.enum(CustomFieldFilterLogicChoices.as_enum(prefix='filter'))
 CustomFieldTypeEnum = strawberry.enum(CustomFieldTypeChoices.as_enum(prefix='type'))
 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
 import strawberry_django
 import strawberry_django
+from django.db.models import Q, QuerySet
 from strawberry.scalars import ID
 from strawberry.scalars import ID
 from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
 from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
 
 
@@ -199,6 +200,33 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
     )
     )
     order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()
     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)
 @strawberry_django.filter_type(models.CustomLink, lookups=True)
 class CustomLinkFilter(ChangeLoggedModelFilter):
 class CustomLinkFilter(ChangeLoggedModelFilter):

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

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
+from strawberry.scalars import JSON
 
 
 from core.graphql.mixins import SyncedDataMixin
 from core.graphql.mixins import SyncedDataMixin
 from extras import models
 from extras import models
@@ -106,7 +107,7 @@ class CustomFieldType(OwnerMixin, ObjectType):
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.CustomFieldChoiceSet,
     models.CustomFieldChoiceSet,
-    exclude=['extra_choices'],
+    exclude=['extra_choices', 'choice_colors'],
     filters=CustomFieldChoiceSetFilter,
     filters=CustomFieldChoiceSetFilter,
     pagination=True
     pagination=True
 )
 )
@@ -114,6 +115,7 @@ class CustomFieldChoiceSetType(OwnerMixin, ObjectType):
 
 
     choices_for: list[Annotated["CustomFieldType", strawberry.lazy('extras.graphql.types')]]
     choices_for: list[Annotated["CustomFieldType", strawberry.lazy('extras.graphql.types')]]
     extra_choices: list[list[str]] | None
     extra_choices: list[list[str]] | None
+    choice_colors: JSON
 
 
 
 
 @strawberry_django.type(
 @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)
             self._choice_map = dict(self.choices)
         return self._choice_map.get(value, value)
         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):
     def populate_initial_data(self, content_types):
         """
         """
         Populate initial custom field data upon either a) the creation of a new CustomField, or
         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,
         blank=True,
         null=True
         null=True
     )
     )
+    choice_colors = models.JSONField(
+        default=dict,
+        blank=True,
+    )
     order_alphabetically = models.BooleanField(
     order_alphabetically = models.BooleanField(
         default=False,
         default=False,
         help_text=_('Choices are automatically ordered alphabetically')
         help_text=_('Choices are automatically ordered alphabetically')
     )
     )
 
 
-    clone_fields = ('extra_choices', 'order_alphabetically')
+    clone_fields = ('extra_choices', 'choice_colors', 'order_alphabetically')
 
 
     class Meta:
     class Meta:
         ordering = ('name',)
         ordering = ('name',)
@@ -919,6 +928,24 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
             self._choices = sorted(self._choices, key=lambda x: x[0])
             self._choices = sorted(self._choices, key=lambda x: x[0])
         return self._choices
         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
     @property
     def choices_count(self):
     def choices_count(self):
         return len(self.choices)
         return len(self.choices)
@@ -934,25 +961,56 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
         if not self.base_choices and not self.extra_choices:
         if not self.base_choices and not self.extra_choices:
             raise ValidationError(_("Must define base or 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))
                     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
         # 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.
         # choices are still set in custom field data for any object.
         original_choices = set([
         original_choices = set([
             c[0] for c in self._original_extra_choices
             c[0] for c in self._original_extra_choices
         ]) if self._original_extra_choices else set()
         ]) 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 custom_field in self.choices_for.all():
                 for object_type in custom_field.object_types.all():
                 for object_type in custom_field.object_types.all():
                     model = object_type.model_class()
                     model = object_type.model_class()

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

@@ -215,6 +215,10 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
                 ['4B', 'Choice 2'],
                 ['4B', 'Choice 2'],
                 ['4C', 'Choice 3'],
                 ['4C', 'Choice 3'],
             ],
             ],
+            'choice_colors': {
+                '4A': 'red',
+                '4B': 'green',
+            },
         },
         },
         {
         {
             'name': 'Choice Set 5',
             'name': 'Choice Set 5',
@@ -223,6 +227,9 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
                 ['5B', 'Choice 2'],
                 ['5B', 'Choice 2'],
                 ['5C', 'Choice 3'],
                 ['5C', 'Choice 3'],
             ],
             ],
+            'choice_colors': {
+                '5C': 'blue',
+            },
         },
         },
         {
         {
             'name': 'Choice Set 6',
             'name': 'Choice Set 6',
@@ -243,6 +250,10 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
             ['X2', 'Choice 2'],
             ['X2', 'Choice 2'],
             ['X3', 'Choice 3'],
             ['X3', 'Choice 3'],
         ],
         ],
+        'choice_colors': {
+            'X1': 'red',
+            'X3': 'green',
+        },
         'description': 'New description',
         'description': 'New description',
     }
     }
 
 
@@ -281,6 +292,38 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
         response = self.client.post(self._get_list_url(), data, format='json', **self.header)
         response = self.client.post(self._get_list_url(), data, format='json', **self.header)
         self.assertEqual(response.status_code, 400)
         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):
 class CustomLinkTest(APIViewTestCases.APIViewTestCase):
     model = CustomLink
     model = CustomLink

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

@@ -399,6 +399,73 @@ class CustomFieldTest(TestCase):
         instance.refresh_from_db()
         instance.refresh_from_db()
         self.assertIsNone(instance.custom_field_data.get(cf.name))
         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):
     def test_remove_selected_choice(self):
         """
         """
         Removing a ChoiceSet choice that is referenced by an object should raise
         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
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         choice_sets = (
         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(name='Choice Set 3', extra_choices=['G', 'H', 'I'], description='foobar3'),
         )
         )
         CustomFieldChoiceSet.objects.bulk_create(choice_sets)
         CustomFieldChoiceSet.objects.bulk_create(choice_sets)
@@ -178,6 +191,16 @@ class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'choice': ['A', 'D']}
         params = {'choice': ['A', 'D']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_description(self):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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
         # 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')])
         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):
 class SavedFilterFormTest(TestCase):
 
 

+ 9 - 1
netbox/extras/views.py

@@ -167,7 +167,15 @@ class CustomFieldChoiceSetView(generic.ObjectView):
             page_number = request.GET.get('page', 1)
             page_number = request.GET.get('page', 1)
         except ValueError:
         except ValueError:
             page_number = 1
             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:
         try:
             choices = paginator.page(page_number)
             choices = paginator.page(page_number)
         except EmptyPage:
         except EmptyPage:

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

@@ -1,4 +1,5 @@
 {% extends "ui/panels/_base.html" %}
 {% extends "ui/panels/_base.html" %}
+{% load helpers %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block panel_content %}
 {% block panel_content %}
@@ -7,13 +8,21 @@
       <tr>
       <tr>
         <th>{% trans "Value" %}</th>
         <th>{% trans "Value" %}</th>
         <th>{% trans "Label" %}</th>
         <th>{% trans "Label" %}</th>
+        <th>{% trans "Color" %}</th>
       </tr>
       </tr>
     </thead>
     </thead>
     <tbody>
     <tbody>
-      {% for value, label in choices %}
+      {% for choice in choices %}
         <tr>
         <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>
         </tr>
       {% endfor %}
       {% endfor %}
     </tbody>
     </tbody>

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

@@ -18,8 +18,26 @@
   <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
   <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
 {% elif customfield.type == 'json' and value is not None %}
 {% elif customfield.type == 'json' and value is not None %}
   <pre>{{ value|json }}</pre>
   <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 %}
 {% 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 %}
 {% elif customfield.type == 'object' and value %}
   {{ value|linkify }}
   {{ value|linkify }}
 {% elif customfield.type == 'multiobject' and value %}
 {% 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
         customfield: A CustomField instance
         value: The custom field value applied to an object
         value: The custom field value applied to an object
     """
     """
+    color = None
+    value_has_colors = False
+
     if value:
     if value:
         if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
         if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
+            color = customfield.get_choice_color(value)
             value = customfield.get_choice_label(value)
             value = customfield.get_choice_label(value)
         elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
         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 {
     return {
         'customfield': customfield,
         'customfield': customfield,
         'value': value,
         '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.template.loader import render_to_string
 from django.test import TestCase, override_settings
 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
 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):
 class StaticWithParamsTest(TestCase):
     """
     """
     Test the static_with_params template tag functionality.
     Test the static_with_params template tag functionality.