diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md
index 615a314b8..7aeae7dad 100644
--- a/docs/models/extras/customfield.md
+++ b/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.
diff --git a/docs/models/extras/customfieldchoiceset.md b/docs/models/extras/customfieldchoiceset.md
index 4f4e134ec..53914499e 100644
--- a/docs/models/extras/customfieldchoiceset.md
+++ b/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.
diff --git a/netbox/extras/api/serializers_/customfields.py b/netbox/extras/api/serializers_/customfields.py
index 37eaca4c8..175b88646 100644
--- a/netbox/extras/api/serializers_/customfields.py
+++ b/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')
diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py
index de319bd15..b011819d4 100644
--- a/netbox/extras/choices.py
+++ b/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
#
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index 444b87da1..8e9683636 100644
--- a/netbox/extras/filtersets.py
+++ b/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):
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index 2bdb4beb6..78f6d8c0b 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/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'(?choice1:First Choice')
)
+ 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 '
+ 'value:color. Example:'
+ )
+ + ' choice1:red
'
+ + _('Supported colors: {colors}').format(
+ colors=', '.join(f'{color}' 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'(? 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):
diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py
index 1aac5e971..bed77aad0 100644
--- a/netbox/extras/graphql/types.py
+++ b/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(
diff --git a/netbox/extras/migrations/0138_customfieldchoiceset_choice_colors.py b/netbox/extras/migrations/0138_customfieldchoiceset_choice_colors.py
new file mode 100644
index 000000000..e3d113bc5
--- /dev/null
+++ b/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),
+ ),
+ ]
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index e6df7c4b6..e7e8d2b84 100644
--- a/netbox/extras/models/customfields.py
+++ b/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()
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index a50ce72a0..22fea092c 100644
--- a/netbox/extras/tests/test_api.py
+++ b/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
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
index 7ab77e039..0b9382701 100644
--- a/netbox/extras/tests/test_customfields.py
+++ b/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
diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py
index 764166a63..c9727ad2f 100644
--- a/netbox/extras/tests/test_filtersets.py
+++ b/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)
diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py
index b9f65c5e4..aea84e92c 100644
--- a/netbox/extras/tests/test_forms.py
+++ b/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):
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index cba8ea853..080613950 100644
--- a/netbox/extras/views.py
+++ b/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:
diff --git a/netbox/templates/extras/panels/customfieldchoiceset_choices.html b/netbox/templates/extras/panels/customfieldchoiceset_choices.html
index db5be1520..d1ed91eca 100644
--- a/netbox/templates/extras/panels/customfieldchoiceset_choices.html
+++ b/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 @@
{{ value|json }}
+{% 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 %}
+