From 5f802bb18f09dd4d38ce04c3bd8c4224d08296ac Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Fri, 24 Apr 2026 19:37:32 +0200 Subject: [PATCH] Closes #19648: Add support for colored Custom Field Choice Set values (#21984) Fixes #19648 --- docs/models/extras/customfield.md | 2 + docs/models/extras/customfieldchoiceset.md | 22 +++++ .../extras/api/serializers_/customfields.py | 6 +- netbox/extras/choices.py | 33 ++++++++ netbox/extras/filtersets.py | 25 ++++++ netbox/extras/forms/bulk_import.py | 32 ++++++- netbox/extras/forms/filtersets.py | 7 +- netbox/extras/forms/model_forms.py | 58 ++++++++++++- netbox/extras/graphql/enums.py | 2 + netbox/extras/graphql/filters.py | 28 +++++++ netbox/extras/graphql/types.py | 4 +- ...0138_customfieldchoiceset_choice_colors.py | 15 ++++ netbox/extras/models/customfields.py | 84 ++++++++++++++++--- netbox/extras/tests/test_api.py | 43 ++++++++++ netbox/extras/tests/test_customfields.py | 67 +++++++++++++++ netbox/extras/tests/test_filtersets.py | 27 +++++- netbox/extras/tests/test_forms.py | 26 ++++++ netbox/extras/views.py | 10 ++- .../panels/customfieldchoiceset_choices.html | 15 +++- .../templates/builtins/customfield_value.html | 20 ++++- .../utilities/templatetags/builtins/tags.py | 11 ++- netbox/utilities/tests/test_templatetags.py | 55 +++++++++++- 22 files changed, 564 insertions(+), 28 deletions(-) create mode 100644 netbox/extras/migrations/0138_customfieldchoiceset_choice_colors.py 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 @@ {% trans "Value" %} {% trans "Label" %} + {% trans "Color" %} - {% for value, label in choices %} + {% for choice in choices %} - {{ value }} - {{ label }} + {{ choice.value }} + {{ choice.label }} + + {% if choice.color %} + {% badge choice.color bg_color=choice.color %} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endfor %} diff --git a/netbox/utilities/templates/builtins/customfield_value.html b/netbox/utilities/templates/builtins/customfield_value.html index d244cef08..cb09ed262 100644 --- a/netbox/utilities/templates/builtins/customfield_value.html +++ b/netbox/utilities/templates/builtins/customfield_value.html @@ -18,8 +18,26 @@ {{ value|truncatechars:70 }} {% elif customfield.type == 'json' and value is not None %}
{{ 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 %} +
+ {% for choice_label, choice_color in value %} + {% if choice_color %} + {% badge choice_label bg_color=choice_color %} + {% else %} + {% badge choice_label %} + {% endif %} + {% endfor %} +
+ {% else %} + {{ value|join:", " }} + {% endif %} {% elif customfield.type == 'object' and value %} {{ value|linkify }} {% elif customfield.type == 'multiobject' and value %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 2e2c94e57..289fb6c5f 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/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, } diff --git a/netbox/utilities/tests/test_templatetags.py b/netbox/utilities/tests/test_templatetags.py index 3686b7e6f..667ea064a 100644 --- a/netbox/utilities/tests/test_templatetags.py +++ b/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.