diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 6c6b5380e..d5d09301c 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -131,6 +131,10 @@ class CustomFieldSerializer(ValidatedModelSerializer): class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail') + base_choices = ChoiceField( + choices=CustomFieldChoiceSetBaseChoices, + required=False + ) class Meta: model = CustomFieldChoiceSet diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index dd98569a3..332131c7a 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -82,8 +82,8 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet): if page is not None: data = [ { - 'id': c[0], - 'display': c[1], + 'value': c[0], + 'label': c[1], } for c in page ] return self.get_paginated_response(data) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 80fe2d253..4458469f3 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -128,7 +128,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet): def filter_by_choice(self, queryset, name, value): # TODO: Support case-insensitive matching - return queryset.filter(extra_choices__overlap=value) + return queryset.filter(extra_choices__has_any_keys=value) class CustomLinkFilterSet(BaseFilterSet): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 86b41e9d2..e3ff2f6ff 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -68,11 +68,6 @@ class CustomFieldChoiceSetImportForm(CSVModelForm): required=False, help_text=_('The classification of entry') ) - extra_choices = SimpleArrayField( - base_field=forms.CharField(), - required=False, - help_text=_('Comma-separated list of field choices') - ) class Meta: model = CustomFieldChoiceSet diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index f60d76e8b..26a010af3 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -19,7 +19,7 @@ from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, ) -from utilities.forms.widgets import ArrayWidget +from utilities.forms.widgets import ChoicesWidget from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -85,19 +85,14 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm): - extra_choices = forms.CharField( - widget=ArrayWidget(), - required=False, - help_text=_('Enter one choice per line.') + extra_choices = forms.JSONField( + required=False ) class Meta: model = CustomFieldChoiceSet fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically') - def clean_extra_choices(self): - return self.cleaned_data['extra_choices'].splitlines() - class CustomLinkForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( diff --git a/netbox/extras/migrations/0096_customfieldchoiceset.py b/netbox/extras/migrations/0096_customfieldchoiceset.py index d657cdd7d..a11a32438 100644 --- a/netbox/extras/migrations/0096_customfieldchoiceset.py +++ b/netbox/extras/migrations/0096_customfieldchoiceset.py @@ -19,7 +19,7 @@ def create_choice_sets(apps, schema_editor): for cf in choice_fields: choiceset = CustomFieldChoiceSet.objects.create( name=f'{cf.name} Choices', - extra_choices=cf.choices + extra_choices=dict(zip(cf.choices, cf.choices)) # Convert list to key:val dict ) cf.choice_set = choiceset @@ -43,7 +43,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=100, unique=True)), ('description', models.CharField(blank=True, max_length=200)), ('base_choices', models.CharField(blank=True, max_length=50)), - ('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)), + ('extra_choices', models.JSONField(blank=True, default=dict, null=True)), ('order_alphabetically', models.BooleanField(default=False)), ], options={ diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 4b2f38b54..3f3bc820c 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -6,7 +6,6 @@ import django_filters from django import forms from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.contrib.postgres.fields import ArrayField from django.core.validators import RegexValidator, ValidationError from django.db import models from django.urls import reverse @@ -658,9 +657,8 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel blank=True, help_text=_('Base set of predefined choices (optional)') ) - extra_choices = ArrayField( - base_field=models.CharField(max_length=100), - help_text=_('List of field choices'), + extra_choices = models.JSONField( + default=dict, blank=True, null=True ) @@ -686,25 +684,27 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel Returns a concatenation of the base and extra choices. """ if not hasattr(self, '_choices'): - self._choices = [] + self._choices = {} if self.base_choices: - self._choices.extend(CHOICE_SETS.get(self.base_choices)) + self._choices.update(dict(CHOICE_SETS.get(self.base_choices))) if self.extra_choices: - self._choices.extend([(k, k) for k in self.extra_choices]) - return self._choices - - def clean(self): - if not self.base_choices and not self.extra_choices: - raise ValidationError(_("Must define base or extra choices.")) + self._choices.update(self.extra_choices) + if self.order_alphabetically: + self._choices = dict(sorted(self._choices.items())) + return list(self._choices.items()) @property def choices_count(self): return len(self.choices) + def clean(self): + if not self.base_choices and not self.extra_choices: + raise ValidationError(_("Must define base or extra choices.")) + def save(self, *args, **kwargs): # Sort choices if alphabetical ordering is enforced if self.order_alphabetically: - self.extra_choices = sorted(self.extra_choices) + self.extra_choices = dict(sorted(self.extra_choices.items())) return super().save(*args, **kwargs) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 73f831f96..d9c6b069a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -68,11 +68,9 @@ class CustomFieldTable(NetBoxTable): verbose_name="UI visibility" ) description = columns.MarkdownColumn() - choices = columns.ArrayColumn( + choices = columns.ChoiceSetColumn( max_items=10, - func=lambda x: x[1], - orderable=False, - verbose_name=_('Choices') + orderable=False ) is_cloneable = columns.BooleanColumn() @@ -91,16 +89,13 @@ class CustomFieldChoiceSetTable(NetBoxTable): linkify=True ) base_choices = columns.ChoiceFieldColumn() - extra_choices = columns.ArrayColumn( + extra_choices = tables.TemplateColumn( + template_code="""{% for k, v in value.items %}{{ v }}{% if not forloop.last %}, {% endif %}{% endfor %}""" + ) + choices = columns.ChoiceSetColumn( max_items=10, orderable=False ) - choices = columns.ArrayColumn( - max_items=10, - func=lambda x: x[1], - orderable=False, - verbose_name=_('Choices') - ) choice_count = tables.TemplateColumn( accessor=tables.A('extra_choices'), template_code='{{ value|length }}', diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 9ebbeef5c..95b981b78 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -17,8 +17,8 @@ class ChangeLogViewTest(ModelViewTestCase): @classmethod def setUpTestData(cls): choice_set = CustomFieldChoiceSet.objects.create( - name='Custom Field Choice Set 1', - extra_choices=['Bar', 'Foo'] + name='Choice Set 1', + extra_choices={'bar': 'Bar', 'foo': 'Foo'} ) # Create a custom field on the Site model @@ -48,7 +48,7 @@ class ChangeLogViewTest(ModelViewTestCase): 'slug': 'site-1', 'status': SiteStatusChoices.STATUS_ACTIVE, 'cf_cf1': 'ABC', - 'cf_cf2': 'Bar', + 'cf_cf2': 'bar', 'tags': [tag.pk for tag in tags], } @@ -84,7 +84,7 @@ class ChangeLogViewTest(ModelViewTestCase): 'slug': 'site-x', 'status': SiteStatusChoices.STATUS_PLANNED, 'cf_cf1': 'DEF', - 'cf_cf2': 'Foo', + 'cf_cf2': 'foo', 'tags': [tags[2].pk], } @@ -226,7 +226,7 @@ class ChangeLogAPITest(APITestCase): # Create a select custom field on the Site model choice_set = CustomFieldChoiceSet.objects.create( name='Choice Set 1', - extra_choices=['Bar', 'Foo'] + extra_choices={'bar': 'Bar', 'foo': 'Foo'} ) cf_select = CustomField( type=CustomFieldTypeChoices.TYPE_SELECT, @@ -251,7 +251,7 @@ class ChangeLogAPITest(APITestCase): 'slug': 'site-1', 'custom_fields': { 'cf1': 'ABC', - 'cf2': 'Bar', + 'cf2': 'bar', }, 'tags': [ {'name': 'Tag 1'}, @@ -285,7 +285,7 @@ class ChangeLogAPITest(APITestCase): 'slug': 'site-x', 'custom_fields': { 'cf1': 'DEF', - 'cf2': 'Foo', + 'cf2': 'foo', }, 'tags': [ {'name': 'Tag 3'} diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 3b802a0f2..4bfb5063d 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -269,8 +269,12 @@ class CustomFieldTest(TestCase): self.assertIsNone(instance.custom_field_data.get(cf.name)) def test_select_field(self): - CHOICES = ('Option A', 'Option B', 'Option C') - value = CHOICES[1] + CHOICES = { + 'a': 'Option A', + 'b': 'Option B', + 'c': 'Option C', + } + value = 'a' # Create a set of custom field choices choice_set = CustomFieldChoiceSet.objects.create( @@ -302,8 +306,12 @@ class CustomFieldTest(TestCase): self.assertIsNone(instance.custom_field_data.get(cf.name)) def test_multiselect_field(self): - CHOICES = ['Option A', 'Option B', 'Option C'] - value = [CHOICES[1], CHOICES[2]] + CHOICES = { + 'a': 'Option A', + 'b': 'Option B', + 'c': 'Option C', + } + value = ['a', 'b'] # Create a set of custom field choices choice_set = CustomFieldChoiceSet.objects.create( @@ -453,7 +461,7 @@ class CustomFieldAPITest(APITestCase): # Create a set of custom field choices choice_set = CustomFieldChoiceSet.objects.create( name='Custom Field Choice Set 1', - extra_choices=('Foo', 'Bar', 'Baz') + extra_choices={'foo': 'Foo', 'bar': 'Bar', 'baz': 'Baz'} ) custom_fields = ( @@ -469,13 +477,13 @@ class CustomFieldAPITest(APITestCase): CustomField( type=CustomFieldTypeChoices.TYPE_SELECT, name='select_field', - default='Foo', + default='foo', choice_set=choice_set ), CustomField( type=CustomFieldTypeChoices.TYPE_MULTISELECT, name='multiselect_field', - default=['Foo'], + default=['foo'], choice_set=choice_set ), CustomField( @@ -514,8 +522,8 @@ class CustomFieldAPITest(APITestCase): custom_fields[6].name: '2020-01-02 12:00:00', custom_fields[7].name: 'http://example.com/2', custom_fields[8].name: '{"foo": 1, "bar": 2}', - custom_fields[9].name: 'Bar', - custom_fields[10].name: ['Bar', 'Baz'], + custom_fields[9].name: 'bar', + custom_fields[10].name: ['bar', 'baz'], custom_fields[11].name: vlans[1].pk, custom_fields[12].name: [vlans[2].pk, vlans[3].pk], } @@ -671,8 +679,8 @@ class CustomFieldAPITest(APITestCase): 'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0), 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', - 'select_field': 'Bar', - 'multiselect_field': ['Bar', 'Baz'], + 'select_field': 'bar', + 'multiselect_field': ['bar', 'baz'], 'object_field': VLAN.objects.get(vid=2).pk, 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), }, @@ -799,8 +807,8 @@ class CustomFieldAPITest(APITestCase): 'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0), 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', - 'select_field': 'Bar', - 'multiselect_field': ['Bar', 'Baz'], + 'select_field': 'bar', + 'multiselect_field': ['bar', 'baz'], 'object_field': VLAN.objects.get(vid=2).pk, 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), } @@ -1041,7 +1049,7 @@ class CustomFieldImportTest(TestCase): # Create a set of custom field choices choice_set = CustomFieldChoiceSet.objects.create( name='Custom Field Choice Set 1', - extra_choices=('Choice A', 'Choice B', 'Choice C') + extra_choices={'a': 'Option A', 'b': 'Option B', 'c': 'Option C'} ) custom_fields = ( @@ -1067,8 +1075,8 @@ class CustomFieldImportTest(TestCase): """ data = ( ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_datetime', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'), - ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'), - ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), + ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'a', '"a,b"'), + ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'b', '"b,c"'), ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) @@ -1089,8 +1097,8 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site1.cf['datetime'].isoformat(), '2020-01-01T12:00:00+00:00') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') self.assertEqual(site1.custom_field_data['json'], {"foo": 123}) - self.assertEqual(site1.custom_field_data['select'], 'Choice A') - self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B']) + self.assertEqual(site1.custom_field_data['select'], 'a') + self.assertEqual(site1.custom_field_data['multiselect'], ['a', 'b']) # Validate data for site 2 site2 = Site.objects.get(name='Site 2') @@ -1104,8 +1112,8 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site2.cf['datetime'].isoformat(), '2020-01-02T12:00:00+00:00') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') self.assertEqual(site2.custom_field_data['json'], {"bar": 456}) - self.assertEqual(site2.custom_field_data['select'], 'Choice B') - self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C']) + self.assertEqual(site2.custom_field_data['select'], 'b') + self.assertEqual(site2.custom_field_data['multiselect'], ['b', 'c']) # No custom field data should be set for site 3 site3 = Site.objects.get(name='Site 3') @@ -1221,7 +1229,7 @@ class CustomFieldModelFilterTest(TestCase): choice_set = CustomFieldChoiceSet.objects.create( name='Custom Field Choice Set 1', - extra_choices=['A', 'B', 'C', 'X'] + extra_choices={'A': 'A', 'B': 'B', 'C': 'C', 'X': 'X'} ) # Integer filtering diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 9d6054b86..98e4a1b77 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -14,8 +14,8 @@ class CustomFieldModelFormTest(TestCase): def setUpTestData(cls): obj_type = ContentType.objects.get_for_model(Site) choice_set = CustomFieldChoiceSet.objects.create( - name='Custom Field Choice Set 1', - extra_choices=('A', 'B', 'C') + name='Choice Set 1', + extra_choices={'a': 'A', 'b': 'B', 'c': 'C'} ) cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index acfdcf1e3..0caecbbc3 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -1,3 +1,4 @@ +import json import urllib.parse import uuid @@ -23,7 +24,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): site_ct = ContentType.objects.get_for_model(Site) CustomFieldChoiceSet.objects.create( name='Choice Set 1', - extra_choices=('A', 'B', 'C') + extra_choices={'A': 'A', 'B': 'B', 'C': 'C'} ) custom_fields = ( @@ -76,29 +77,38 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase): def setUpTestData(cls): choice_sets = ( - CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']), - CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']), - CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']), + CustomFieldChoiceSet( + name='Choice Set 1', + extra_choices={'A1': 'Choice 1', 'A2': 'Choice 2', 'A3': 'Choice 3'} + ), + CustomFieldChoiceSet( + name='Choice Set 2', + extra_choices={'B1': 'Choice 1', 'B2': 'Choice 2', 'B3': 'Choice 3'} + ), + CustomFieldChoiceSet( + name='Choice Set 3', + extra_choices={'C1': 'Choice 1', 'C2': 'Choice 2', 'C3': 'Choice 3'} + ), ) CustomFieldChoiceSet.objects.bulk_create(choice_sets) cls.form_data = { 'name': 'Choice Set X', - 'extra_choices': 'X1,X2,X3,X4,X5', + 'extra_choices': json.dumps({'X1': 'Choice 1', 'X2': 'Choice 2', 'X3': 'Choice 3'}) } cls.csv_data = ( 'name,extra_choices', - 'Choice Set 4,"4A,4B,4C,4D,4E"', - 'Choice Set 5,"5A,5B,5C,5D,5E"', - 'Choice Set 6,"6A,6B,6C,6D,6E"', + 'Choice Set 4,"{""D1"": ""Choice 1"", ""D2"": ""Choice 2"", ""D3"": ""Choice 3""}"', + 'Choice Set 5,"{""E1"": ""Choice 1"", ""E2"": ""Choice 2"", ""E3"": ""Choice 3""}"', + 'Choice Set 6,"{""F1"": ""Choice 1"", ""F2"": ""Choice 2"", ""F3"": ""Choice 3""}"', ) cls.csv_update_data = ( 'id,extra_choices', - f'{choice_sets[0].pk},"1X,1Y,1Z"', - f'{choice_sets[1].pk},"2X,2Y,2Z"', - f'{choice_sets[2].pk},"3X,3Y,3Z"', + f'{choice_sets[0].pk},"{{""a"": ""A"", ""b"": ""B"", ""c"": ""C""}}"', + f'{choice_sets[1].pk},"{{""a"": ""A"", ""b"": ""B"", ""c"": ""C""}}"', + f'{choice_sets[2].pk},"{{""a"": ""A"", ""b"": ""B"", ""c"": ""C""}}"', ) cls.bulk_edit_data = { diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index c7cd490c2..e1648af96 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -9,8 +9,8 @@ from django.db.models import DateField, DateTimeField from django.template import Context, Template from django.urls import reverse from django.utils.dateparse import parse_date -from django.utils.html import escape from django.utils.formats import date_format +from django.utils.html import escape from django.utils.safestring import mark_safe from django_tables2.columns import library from django_tables2.utils import Accessor @@ -21,9 +21,9 @@ from utilities.utils import content_type_identifier, content_type_name, get_view __all__ = ( 'ActionsColumn', - 'ArrayColumn', 'BooleanColumn', 'ChoiceFieldColumn', + 'ChoiceSetColumn', 'ColorColumn', 'ColoredLabelColumn', 'ContentTypeColumn', @@ -594,27 +594,21 @@ class MarkdownColumn(tables.TemplateColumn): return value -class ArrayColumn(tables.Column): - """ - List array items as a comma-separated list. - """ - def __init__(self, *args, max_items=None, func=str, **kwargs): +class ChoiceSetColumn(tables.Column): + + def __init__(self, *args, max_items=None, **kwargs): self.max_items = max_items - self.func = func super().__init__(*args, **kwargs) def render(self, value): omitted_count = 0 + value = [v[1] for v in value] # Limit the returned items to the specified maximum number (if any) if self.max_items: omitted_count = len(value) - self.max_items value = value[:self.max_items - 1] - # Apply custom processing function (if any) per item - if self.func: - value = [self.func(v) for v in value] - # Annotate omitted items (if applicable) if omitted_count > 0: value.append(f'({omitted_count} more)') diff --git a/netbox/templates/extras/customfieldchoiceset.html b/netbox/templates/extras/customfieldchoiceset.html index c9f579192..2f42e405e 100644 --- a/netbox/templates/extras/customfieldchoiceset.html +++ b/netbox/templates/extras/customfieldchoiceset.html @@ -55,7 +55,7 @@