Skip to content

Commit 96ea0ac

Browse files
Closes #12988: Introduce custom field choice sets (#13195)
* Initial work on custom field choice sets * Rename choices to extra_choices (prep for #12194) * Remove CustomField.choices * Add & update tests * Clean up table columns * Add order_alphanetically boolean for choice sets * Introduce ArrayColumn for choice lists * Show dependent custom fields on choice set view * Update custom fields documentation * Introduce ArrayWidget for more convenient editing of choices * Incorporate PR feedback * Misc cleanup
1 parent 837be4d commit 96ea0ac

32 files changed

+792
-150
lines changed

docs/customization/custom-fields.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are
6060

6161
### Custom Selection Fields
6262

63-
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
63+
Each custom selection field must designate a [choice set](../models/extras/customfieldchoiceset.md) containing at least two choices. These are specified as a comma-separated list.
6464

6565
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.
6666

docs/models/extras/customfield.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ Controls how and whether the custom field is displayed within the NetBox user in
7979

8080
The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices.
8181

82-
### Choices
82+
### Choice Set
8383

84-
For choice and multi-choice custom fields only. A comma-delimited list of the available choices.
84+
For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
8585

8686
### Cloneable
8787

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Custom Field Choice Sets
2+
3+
Single- and multi-selection [custom fields documentation](../../customization/custom-fields.md) must define a set of valid choices from which the user may choose when defining the field value. These choices are defined as sets that may be reused among multiple custom fields.
4+
5+
## Fields
6+
7+
### Name
8+
9+
The human-friendly name of the choice set.
10+
11+
### Extra Choices
12+
13+
The list of valid choices, entered as a comma-separated list.
14+
15+
### Order Alphabetically
16+
17+
If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined.

netbox/extras/api/nested_serializers.py

+9
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
'NestedBookmarkSerializer',
88
'NestedConfigContextSerializer',
99
'NestedConfigTemplateSerializer',
10+
'NestedCustomFieldChoiceSetSerializer',
1011
'NestedCustomFieldSerializer',
1112
'NestedCustomLinkSerializer',
1213
'NestedExportTemplateSerializer',
@@ -34,6 +35,14 @@ class Meta:
3435
fields = ['id', 'url', 'display', 'name']
3536

3637

38+
class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer):
39+
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
40+
41+
class Meta:
42+
model = models.CustomFieldChoiceSet
43+
fields = ['id', 'url', 'display', 'name', 'choices_count']
44+
45+
3746
class NestedCustomLinkSerializer(WritableNestedSerializer):
3847
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
3948

netbox/extras/api/serializers.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
'ConfigContextSerializer',
3636
'ConfigTemplateSerializer',
3737
'ContentTypeSerializer',
38+
'CustomFieldChoiceSetSerializer',
3839
'CustomFieldSerializer',
3940
'CustomLinkSerializer',
4041
'DashboardSerializer',
@@ -94,14 +95,15 @@ class CustomFieldSerializer(ValidatedModelSerializer):
9495
)
9596
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
9697
data_type = serializers.SerializerMethodField()
98+
choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
9799
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
98100

99101
class Meta:
100102
model = CustomField
101103
fields = [
102104
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
103105
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default',
104-
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created',
106+
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created',
105107
'last_updated',
106108
]
107109

@@ -127,6 +129,17 @@ def get_data_type(self, obj):
127129
return 'string'
128130

129131

132+
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
133+
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
134+
135+
class Meta:
136+
model = CustomFieldChoiceSet
137+
fields = [
138+
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count',
139+
'created', 'last_updated',
140+
]
141+
142+
130143
#
131144
# Custom links
132145
#

netbox/extras/api/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
router.register('webhooks', views.WebhookViewSet)
1111
router.register('custom-fields', views.CustomFieldViewSet)
12+
router.register('custom-field-choices', views.CustomFieldChoiceSetViewSet)
1213
router.register('custom-links', views.CustomLinkViewSet)
1314
router.register('export-templates', views.ExportTemplateViewSet)
1415
router.register('saved-filters', views.SavedFilterViewSet)

netbox/extras/api/views.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from django.contrib.contenttypes.models import ContentType
22
from django.http import Http404
3-
from django.shortcuts import get_object_or_404
43
from django_rq.queues import get_connection
54
from rest_framework import status
65
from rest_framework.decorators import action
@@ -55,11 +54,17 @@ class WebhookViewSet(NetBoxModelViewSet):
5554

5655
class CustomFieldViewSet(NetBoxModelViewSet):
5756
metadata_class = ContentTypeMetadata
58-
queryset = CustomField.objects.all()
57+
queryset = CustomField.objects.select_related('choice_set')
5958
serializer_class = serializers.CustomFieldSerializer
6059
filterset_class = filtersets.CustomFieldFilterSet
6160

6261

62+
class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
63+
queryset = CustomFieldChoiceSet.objects.all()
64+
serializer_class = serializers.CustomFieldChoiceSetSerializer
65+
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
66+
67+
6368
#
6469
# Custom links
6570
#

netbox/extras/filtersets.py

+38
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
'ConfigRevisionFilterSet',
2121
'ConfigTemplateFilterSet',
2222
'ContentTypeFilterSet',
23+
'CustomFieldChoiceSetFilterSet',
2324
'CustomFieldFilterSet',
2425
'CustomLinkFilterSet',
2526
'ExportTemplateFilterSet',
@@ -74,6 +75,14 @@ class CustomFieldFilterSet(BaseFilterSet):
7475
field_name='content_types__id'
7576
)
7677
content_types = ContentTypeFilter()
78+
choice_set_id = django_filters.ModelMultipleChoiceFilter(
79+
queryset=CustomFieldChoiceSet.objects.all()
80+
)
81+
choice_set = django_filters.ModelMultipleChoiceFilter(
82+
field_name='choice_set__name',
83+
queryset=CustomFieldChoiceSet.objects.all(),
84+
to_field_name='name'
85+
)
7786

7887
class Meta:
7988
model = CustomField
@@ -93,6 +102,35 @@ def search(self, queryset, name, value):
93102
)
94103

95104

105+
class CustomFieldChoiceSetFilterSet(BaseFilterSet):
106+
q = django_filters.CharFilter(
107+
method='search',
108+
label=_('Search'),
109+
)
110+
choice = MultiValueCharFilter(
111+
method='filter_by_choice'
112+
)
113+
114+
class Meta:
115+
model = CustomFieldChoiceSet
116+
fields = [
117+
'id', 'name', 'description', 'order_alphabetically',
118+
]
119+
120+
def search(self, queryset, name, value):
121+
if not value.strip():
122+
return queryset
123+
return queryset.filter(
124+
Q(name__icontains=value) |
125+
Q(description__icontains=value) |
126+
Q(extra_choices__contains=value)
127+
)
128+
129+
def filter_by_choice(self, queryset, name, value):
130+
# TODO: Support case-insensitive matching
131+
return queryset.filter(extra_choices__overlap=value)
132+
133+
96134
class CustomLinkFilterSet(BaseFilterSet):
97135
q = django_filters.CharFilter(
98136
method='search',

netbox/extras/forms/bulk_edit.py

+23-2
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
from extras.choices import *
55
from extras.models import *
66
from utilities.forms import BulkEditForm, add_blank_choice
7-
from utilities.forms.fields import ColorField
7+
from utilities.forms.fields import ColorField, DynamicModelChoiceField
88
from utilities.forms.widgets import BulkEditNullBooleanSelect
99

1010
__all__ = (
1111
'ConfigContextBulkEditForm',
1212
'ConfigTemplateBulkEditForm',
1313
'CustomFieldBulkEditForm',
14+
'CustomFieldChoiceSetBulkEditForm',
1415
'CustomLinkBulkEditForm',
1516
'ExportTemplateBulkEditForm',
1617
'JournalEntryBulkEditForm',
@@ -38,6 +39,10 @@ class CustomFieldBulkEditForm(BulkEditForm):
3839
weight = forms.IntegerField(
3940
required=False
4041
)
42+
choice_set = DynamicModelChoiceField(
43+
queryset=CustomFieldChoiceSet.objects.all(),
44+
required=False
45+
)
4146
ui_visibility = forms.ChoiceField(
4247
label=_("UI visibility"),
4348
choices=add_blank_choice(CustomFieldVisibilityChoices),
@@ -49,7 +54,23 @@ class CustomFieldBulkEditForm(BulkEditForm):
4954
widget=BulkEditNullBooleanSelect()
5055
)
5156

52-
nullable_fields = ('group_name', 'description',)
57+
nullable_fields = ('group_name', 'description', 'choice_set')
58+
59+
60+
class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
61+
pk = forms.ModelMultipleChoiceField(
62+
queryset=CustomFieldChoiceSet.objects.all(),
63+
widget=forms.MultipleHiddenInput
64+
)
65+
description = forms.CharField(
66+
required=False
67+
)
68+
order_alphabetically = forms.NullBooleanField(
69+
required=False,
70+
widget=BulkEditNullBooleanSelect()
71+
)
72+
73+
nullable_fields = ('description',)
5374

5475

5576
class CustomLinkBulkEditForm(BulkEditForm):

netbox/extras/forms/bulk_import.py

+24-6
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
from extras.utils import FeatureQuery
1010
from netbox.forms import NetBoxModelImportForm
1111
from utilities.forms import CSVModelForm
12-
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
12+
from utilities.forms.fields import (
13+
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField,
14+
)
1315

1416
__all__ = (
1517
'ConfigTemplateImportForm',
18+
'CustomFieldChoiceSetImportForm',
1619
'CustomFieldImportForm',
1720
'CustomLinkImportForm',
1821
'ExportTemplateImportForm',
@@ -39,10 +42,11 @@ class CustomFieldImportForm(CSVModelForm):
3942
required=False,
4043
help_text=_("Object type (for object or multi-object fields)")
4144
)
42-
choices = SimpleArrayField(
43-
base_field=forms.CharField(),
45+
choice_set = CSVModelChoiceField(
46+
queryset=CustomFieldChoiceSet.objects.all(),
47+
to_field_name='name',
4448
required=False,
45-
help_text=_('Comma-separated list of field choices')
49+
help_text=_('Choice set (for selection fields)')
4650
)
4751
ui_visibility = CSVChoiceField(
4852
choices=CustomFieldVisibilityChoices,
@@ -53,8 +57,22 @@ class Meta:
5357
model = CustomField
5458
fields = (
5559
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
56-
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
57-
'validation_regex', 'ui_visibility', 'is_cloneable',
60+
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
61+
'validation_maximum', 'validation_regex', 'ui_visibility', 'is_cloneable',
62+
)
63+
64+
65+
class CustomFieldChoiceSetImportForm(CSVModelForm):
66+
extra_choices = SimpleArrayField(
67+
base_field=forms.CharField(),
68+
required=False,
69+
help_text=_('Comma-separated list of field choices')
70+
)
71+
72+
class Meta:
73+
model = CustomFieldChoiceSet
74+
fields = (
75+
'name', 'description', 'extra_choices', 'order_alphabetically',
5876
)
5977

6078

netbox/extras/forms/filtersets.py

+18-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
'ConfigContextFilterForm',
2121
'ConfigRevisionFilterForm',
2222
'ConfigTemplateFilterForm',
23+
'CustomFieldChoiceSetFilterForm',
2324
'CustomFieldFilterForm',
2425
'CustomLinkFilterForm',
2526
'ExportTemplateFilterForm',
@@ -37,7 +38,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
3738
fieldsets = (
3839
(None, ('q', 'filter_id')),
3940
('Attributes', (
40-
'type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility', 'is_cloneable',
41+
'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility',
42+
'is_cloneable',
4143
)),
4244
)
4345
content_type_id = ContentTypeMultipleChoiceField(
@@ -62,6 +64,11 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
6264
choices=BOOLEAN_WITH_BLANK_CHOICES
6365
)
6466
)
67+
choice_set_id = DynamicModelMultipleChoiceField(
68+
queryset=CustomFieldChoiceSet.objects.all(),
69+
required=False,
70+
label=_('Choice set')
71+
)
6572
ui_visibility = forms.ChoiceField(
6673
choices=add_blank_choice(CustomFieldVisibilityChoices),
6774
required=False,
@@ -75,10 +82,19 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
7582
)
7683

7784

85+
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
86+
fieldsets = (
87+
(None, ('q', 'filter_id', 'choice')),
88+
)
89+
choice = forms.CharField(
90+
required=False
91+
)
92+
93+
7894
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
7995
fieldsets = (
8096
(None, ('q', 'filter_id')),
81-
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
97+
(_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')),
8298
)
8399
content_types = ContentTypeMultipleChoiceField(
84100
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),

0 commit comments

Comments
 (0)