Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closes #12988: Introduce custom field choice sets #13195

Merged
merged 12 commits into from
Jul 19, 2023
2 changes: 1 addition & 1 deletion docs/customization/custom-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are

### Custom Selection Fields

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.
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.
jeremystretch marked this conversation as resolved.
Show resolved Hide resolved

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.

Expand Down
4 changes: 2 additions & 2 deletions docs/models/extras/customfield.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ Controls how and whether the custom field is displayed within the NetBox user in

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.

### Choices
### Choice Set

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

### Cloneable

Expand Down
17 changes: 17 additions & 0 deletions docs/models/extras/customfieldchoiceset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Custom Field Choice Sets

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.

## Fields

### Name

The human-friendly name of the choice set.

### Extra Choices

The list of valid choices, entered as a comma-separated list.
jeremystretch marked this conversation as resolved.
Show resolved Hide resolved

### 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.
9 changes: 9 additions & 0 deletions netbox/extras/api/nested_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
'NestedBookmarkSerializer',
'NestedConfigContextSerializer',
'NestedConfigTemplateSerializer',
'NestedCustomFieldChoiceSetSerializer',
'NestedCustomFieldSerializer',
'NestedCustomLinkSerializer',
'NestedExportTemplateSerializer',
Expand Down Expand Up @@ -34,6 +35,14 @@ class Meta:
fields = ['id', 'url', 'display', 'name']


class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')

class Meta:
model = models.CustomFieldChoiceSet
fields = ['id', 'url', 'display', 'name']


class NestedCustomLinkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')

Expand Down
17 changes: 15 additions & 2 deletions netbox/extras/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
'ConfigContextSerializer',
'ConfigTemplateSerializer',
'ContentTypeSerializer',
'CustomFieldChoiceSetSerializer',
'CustomFieldSerializer',
'CustomLinkSerializer',
'DashboardSerializer',
Expand Down Expand Up @@ -94,15 +95,16 @@ class CustomFieldSerializer(ValidatedModelSerializer):
)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField()
choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)

class Meta:
model = CustomField
fields = [
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default',
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created',
'last_updated',
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'choices',
'created', 'last_updated',
]

def validate_type(self, value):
Expand All @@ -127,6 +129,17 @@ def get_data_type(self, obj):
return 'string'


class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')

class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'created',
'last_updated',
]


#
# Custom links
#
Expand Down
1 change: 1 addition & 0 deletions netbox/extras/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

router.register('webhooks', views.WebhookViewSet)
router.register('custom-fields', views.CustomFieldViewSet)
router.register('custom-field-choices', views.CustomFieldChoiceSetViewSet)
router.register('custom-links', views.CustomLinkViewSet)
router.register('export-templates', views.ExportTemplateViewSet)
router.register('saved-filters', views.SavedFilterViewSet)
Expand Down
9 changes: 7 additions & 2 deletions netbox/extras/api/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.decorators import action
Expand Down Expand Up @@ -55,11 +54,17 @@ class WebhookViewSet(NetBoxModelViewSet):

class CustomFieldViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
serializer_class = serializers.CustomFieldSerializer
filterset_class = filtersets.CustomFieldFilterSet


class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
queryset = CustomFieldChoiceSet.objects.all()
serializer_class = serializers.CustomFieldChoiceSetSerializer
filterset_class = filtersets.CustomFieldChoiceSetFilterSet


#
# Custom links
#
Expand Down
32 changes: 32 additions & 0 deletions netbox/extras/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet',
'ContentTypeFilterSet',
'CustomFieldChoiceSetFilterSet',
'CustomFieldFilterSet',
'CustomLinkFilterSet',
'ExportTemplateFilterSet',
Expand Down Expand Up @@ -74,6 +75,9 @@ class CustomFieldFilterSet(BaseFilterSet):
field_name='content_types__id'
)
content_types = ContentTypeFilter()
choice_set_id = django_filters.ModelMultipleChoiceFilter(
queryset=CustomFieldChoiceSet.objects.all()
)

class Meta:
model = CustomField
Expand All @@ -93,6 +97,34 @@ def search(self, queryset, name, value):
)


class CustomFieldChoiceSetFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
choice = MultiValueCharFilter(
method='filter_by_choice'
)

class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'name', 'description', 'order_alphabetically',
]

def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(extra_choices__contains=value)
)

def filter_by_choice(self, queryset, name, value):
return queryset.filter(extra_choices__overlap=value)


class CustomLinkFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
Expand Down
25 changes: 23 additions & 2 deletions netbox/extras/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
from extras.choices import *
from extras.models import *
from utilities.forms import BulkEditForm, add_blank_choice
from utilities.forms.fields import ColorField
from utilities.forms.fields import ColorField, DynamicModelChoiceField
from utilities.forms.widgets import BulkEditNullBooleanSelect

__all__ = (
'ConfigContextBulkEditForm',
'ConfigTemplateBulkEditForm',
'CustomFieldBulkEditForm',
'CustomFieldChoiceSetBulkEditForm',
'CustomLinkBulkEditForm',
'ExportTemplateBulkEditForm',
'JournalEntryBulkEditForm',
Expand Down Expand Up @@ -38,6 +39,10 @@ class CustomFieldBulkEditForm(BulkEditForm):
weight = forms.IntegerField(
required=False
)
choice_set = DynamicModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False
)
ui_visibility = forms.ChoiceField(
label=_("UI visibility"),
choices=add_blank_choice(CustomFieldVisibilityChoices),
Expand All @@ -49,7 +54,23 @@ class CustomFieldBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect()
)

nullable_fields = ('group_name', 'description',)
nullable_fields = ('group_name', 'description', 'choice_set')


class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
required=False
)
order_alphabetically = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)

nullable_fields = ('description',)


class CustomLinkBulkEditForm(BulkEditForm):
Expand Down
30 changes: 24 additions & 6 deletions netbox/extras/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelImportForm
from utilities.forms import CSVModelForm
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField,
)

__all__ = (
'ConfigTemplateImportForm',
'CustomFieldChoiceSetImportForm',
'CustomFieldImportForm',
'CustomLinkImportForm',
'ExportTemplateImportForm',
Expand All @@ -39,10 +42,11 @@ class CustomFieldImportForm(CSVModelForm):
required=False,
help_text=_("Object type (for object or multi-object fields)")
)
choices = SimpleArrayField(
base_field=forms.CharField(),
choice_set = CSVModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
to_field_name='name',
required=False,
help_text=_('Comma-separated list of field choices')
help_text=_('Choice set (for selection fields)')
)
ui_visibility = CSVChoiceField(
choices=CustomFieldVisibilityChoices,
Expand All @@ -53,8 +57,22 @@ class Meta:
model = CustomField
fields = (
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'ui_visibility', 'is_cloneable',
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
'validation_maximum', 'validation_regex', 'ui_visibility', 'is_cloneable',
)


class CustomFieldChoiceSetImportForm(CSVModelForm):
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
help_text=_('Comma-separated list of field choices')
jeremystretch marked this conversation as resolved.
Show resolved Hide resolved
)

class Meta:
model = CustomFieldChoiceSet
fields = (
'name', 'description', 'extra_choices', 'order_alphabetically',
)


Expand Down
18 changes: 17 additions & 1 deletion netbox/extras/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
'ConfigContextFilterForm',
'ConfigRevisionFilterForm',
'ConfigTemplateFilterForm',
'CustomFieldChoiceSetFilterForm',
'CustomFieldFilterForm',
'CustomLinkFilterForm',
'ExportTemplateFilterForm',
Expand All @@ -37,7 +38,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
('Attributes', (
jeremystretch marked this conversation as resolved.
Show resolved Hide resolved
'type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility', 'is_cloneable',
'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility',
'is_cloneable',
)),
)
content_type_id = ContentTypeMultipleChoiceField(
Expand All @@ -62,6 +64,11 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
choice_set_id = DynamicModelMultipleChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False,
label=_('Choice set')
)
ui_visibility = forms.ChoiceField(
choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False,
Expand All @@ -75,6 +82,15 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
)


class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'choice')),
)
choice = forms.CharField(
required=False
)


class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
Expand Down
Loading