diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index 612faefed..1e0d5c31e 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -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. 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. diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index df0408f7c..bf0c4755a 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -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 diff --git a/docs/models/extras/customfieldchoiceset.md b/docs/models/extras/customfieldchoiceset.md new file mode 100644 index 000000000..8fa30cfc7 --- /dev/null +++ b/docs/models/extras/customfieldchoiceset.md @@ -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. + +### 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/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 4271e1748..a97c630d2 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -7,6 +7,7 @@ __all__ = [ 'NestedBookmarkSerializer', 'NestedConfigContextSerializer', 'NestedConfigTemplateSerializer', + 'NestedCustomFieldChoiceSetSerializer', 'NestedCustomFieldSerializer', 'NestedCustomLinkSerializer', 'NestedExportTemplateSerializer', @@ -34,6 +35,14 @@ class NestedCustomFieldSerializer(WritableNestedSerializer): 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', 'choices_count'] + + class NestedCustomLinkSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index f28a5c411..fea7582c0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -35,6 +35,7 @@ __all__ = ( 'ConfigContextSerializer', 'ConfigTemplateSerializer', 'ContentTypeSerializer', + 'CustomFieldChoiceSetSerializer', 'CustomFieldSerializer', 'CustomLinkSerializer', 'DashboardSerializer', @@ -94,6 +95,7 @@ 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: @@ -101,7 +103,7 @@ class CustomFieldSerializer(ValidatedModelSerializer): 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', + 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created', 'last_updated', ] @@ -127,6 +129,17 @@ class CustomFieldSerializer(ValidatedModelSerializer): 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', 'choices_count', + 'created', 'last_updated', + ] + + # # Custom links # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 6e610097f..c13d60797 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -9,6 +9,7 @@ router.APIRootView = views.ExtrasRootView 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) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 819d3d1eb..5761d6767 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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 @@ -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 # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index ef094c2d0..42277d219 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -20,6 +20,7 @@ __all__ = ( 'ConfigRevisionFilterSet', 'ConfigTemplateFilterSet', 'ContentTypeFilterSet', + 'CustomFieldChoiceSetFilterSet', 'CustomFieldFilterSet', 'CustomLinkFilterSet', 'ExportTemplateFilterSet', @@ -74,6 +75,14 @@ class CustomFieldFilterSet(BaseFilterSet): field_name='content_types__id' ) content_types = ContentTypeFilter() + choice_set_id = django_filters.ModelMultipleChoiceFilter( + queryset=CustomFieldChoiceSet.objects.all() + ) + choice_set = django_filters.ModelMultipleChoiceFilter( + field_name='choice_set__name', + queryset=CustomFieldChoiceSet.objects.all(), + to_field_name='name' + ) class Meta: model = CustomField @@ -93,6 +102,35 @@ class CustomFieldFilterSet(BaseFilterSet): ) +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): + # TODO: Support case-insensitive matching + return queryset.filter(extra_choices__overlap=value) + + class CustomLinkFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 7c838be20..b0c6b87ea 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -4,13 +4,14 @@ from django.utils.translation import gettext as _ 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', @@ -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), @@ -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): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 818b8a52f..b47fcba60 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -9,10 +9,13 @@ from extras.models import * 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', @@ -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, @@ -53,8 +57,22 @@ class CustomFieldImportForm(CSVModelForm): 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') + ) + + class Meta: + model = CustomFieldChoiceSet + fields = ( + 'name', 'description', 'extra_choices', 'order_alphabetically', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 56e9c8dfb..26b4d9a41 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -20,6 +20,7 @@ __all__ = ( 'ConfigContextFilterForm', 'ConfigRevisionFilterForm', 'ConfigTemplateFilterForm', + 'CustomFieldChoiceSetFilterForm', 'CustomFieldFilterForm', 'CustomLinkFilterForm', 'ExportTemplateFilterForm', @@ -37,7 +38,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), ('Attributes', ( - '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( @@ -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, @@ -75,10 +82,19 @@ 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')), - ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), + (_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')), ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 354d2a51a..428c6391b 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -16,9 +16,10 @@ from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup from utilities.forms import BootstrapMixin, add_blank_choice from utilities.forms.fields import ( - CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, - SlugField, + CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, JSONField, SlugField, ) +from utilities.forms.widgets import ArrayWidget from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -27,6 +28,7 @@ __all__ = ( 'ConfigContextForm', 'ConfigRevisionForm', 'ConfigTemplateForm', + 'CustomFieldChoiceSetForm', 'CustomFieldForm', 'CustomLinkForm', 'ExportTemplateForm', @@ -50,13 +52,17 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): required=False, help_text=_("Type of the related object (for object/multi-object fields only)") ) + choice_set = DynamicModelChoiceField( + queryset=CustomFieldChoiceSet.objects.all(), + required=False + ) fieldsets = ( ('Custom Field', ( 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description', )), ('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')), - ('Values', ('default', 'choices')), + ('Values', ('default', 'choice_set')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ) @@ -78,6 +84,20 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): self.fields['type'].disabled = True +class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm): + extra_choices = forms.CharField( + widget=ArrayWidget(), + help_text=_('Enter one choice per line.') + ) + + class Meta: + model = CustomFieldChoiceSet + fields = ('name', 'description', 'extra_choices', 'order_alphabetically') + + def clean_extra_choices(self): + return self.cleaned_data['extra_choices'].splitlines() + + class CustomLinkForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index c61b0b88c..e13cc0e9f 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -25,6 +25,12 @@ class ExtrasQuery(graphene.ObjectType): def resolve_custom_field_list(root, info, **kwargs): return gql_query_optimizer(models.CustomField.objects.all(), info) + custom_field_choice_set = ObjectField(CustomFieldChoiceSetType) + custom_field_choice_set_list = ObjectListField(CustomFieldChoiceSetType) + + def resolve_custom_field_choices_list(root, info, **kwargs): + return gql_query_optimizer(models.CustomFieldChoiceSet.objects.all(), info) + custom_link = ObjectField(CustomLinkType) custom_link_list = ObjectListField(CustomLinkType) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index ae7d5cef6..73ff8eb8a 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -5,6 +5,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType __all__ = ( 'ConfigContextType', 'ConfigTemplateType', + 'CustomFieldChoiceSetType', 'CustomFieldType', 'CustomLinkType', 'ExportTemplateType', @@ -41,6 +42,14 @@ class CustomFieldType(ObjectType): filterset_class = filtersets.CustomFieldFilterSet +class CustomFieldChoiceSetType(ObjectType): + + class Meta: + model = models.CustomFieldChoiceSet + fields = '__all__' + filterset_class = filtersets.CustomFieldChoiceSetFilterSet + + class CustomLinkType(ObjectType): class Meta: diff --git a/netbox/extras/migrations/0096_customfieldchoiceset.py b/netbox/extras/migrations/0096_customfieldchoiceset.py new file mode 100644 index 000000000..dea6f02fc --- /dev/null +++ b/netbox/extras/migrations/0096_customfieldchoiceset.py @@ -0,0 +1,61 @@ +import django.contrib.postgres.fields +from django.db import migrations, models + +from extras.choices import CustomFieldTypeChoices + + +def create_choice_sets(apps, schema_editor): + """ + Create a CustomFieldChoiceSet for each CustomField with choices defined. + """ + CustomField = apps.get_model('extras', 'CustomField') + CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet') + + # Create custom field choice sets + choice_fields = CustomField.objects.filter( + type__in=(CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT), + choices__len__gt=0 + ) + for cf in choice_fields: + choiceset = CustomFieldChoiceSet.objects.create( + name=f'{cf.name} Choices', + extra_choices=cf.choices + ) + cf.choice_set = choiceset + + # Update custom fields to point to new choice sets + CustomField.objects.bulk_update(choice_fields, ['choice_set']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0095_bookmarks'), + ] + + operations = [ + migrations.CreateModel( + name='CustomFieldChoiceSet', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None)), + ('order_alphabetically', models.BooleanField(default=False)), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='customfield', + name='choice_set', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='choices_for', to='extras.customfieldchoiceset'), + ), + migrations.RunPython( + code=create_choice_sets, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/extras/migrations/0097_customfield_remove_choices.py b/netbox/extras/migrations/0097_customfield_remove_choices.py new file mode 100644 index 000000000..f3e8c547e --- /dev/null +++ b/netbox/extras/migrations/0097_customfield_remove_choices.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.10 on 2023-07-17 15:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0096_customfieldchoiceset'), + ] + + operations = [ + migrations.RemoveField( + model_name='customfield', + name='choices', + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 423219ccb..399f01005 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,6 +1,6 @@ from .change_logging import * from .configs import * -from .customfields import CustomField +from .customfields import * from .dashboard import * from .models import * from .reports import * diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index be3540f08..bdb600c88 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -31,6 +31,7 @@ from utilities.validators import validate_regex __all__ = ( 'CustomField', + 'CustomFieldChoiceSet', 'CustomFieldManager', ) @@ -158,11 +159,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): 'example, ^[A-Z]{3}$ will limit values to exactly three uppercase letters.' ) ) - choices = ArrayField( - base_field=models.CharField(max_length=100), + choice_set = models.ForeignKey( + to='CustomFieldChoiceSet', + on_delete=models.PROTECT, + related_name='choices_for', blank=True, - null=True, - help_text=_('Comma-separated list of available choices (for selection fields)') + null=True ) ui_visibility = models.CharField( max_length=50, @@ -181,8 +183,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): clone_fields = ( 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight', - 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', - 'ui_visibility', 'is_cloneable', + 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'choice_set', 'ui_visibility', 'is_cloneable', ) class Meta: @@ -208,6 +210,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): def search_type(self): return SEARCH_TYPES.get(self.type) + @property + def choices(self): + if self.choice_set: + return self.choice_set.choices + return [] + def populate_initial_data(self, content_types): """ Populate initial custom field data upon either a) the creation of a new CustomField, or @@ -278,22 +286,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): 'validation_regex': "Regular expression validation is supported only for text and URL fields" }) - # Choices can be set only on selection fields - if self.choices and self.type not in ( - CustomFieldTypeChoices.TYPE_SELECT, - CustomFieldTypeChoices.TYPE_MULTISELECT - ): - raise ValidationError({ - 'choices': "Choices may be set only for custom selection fields." - }) - - # Selection fields must have at least one choice defined + # Choice set must be set on selection fields, and *only* on selection fields if self.type in ( CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT - ) and not self.choices: + ): + if not self.choice_set: + raise ValidationError({ + 'choice_set': "Selection fields must specify a set of choices." + }) + elif self.choice_set: raise ValidationError({ - 'choices': "Selection fields must specify at least one choice." + 'choice_set': "Choices may be set only on selection fields." }) # A selection field's default (if any) must be present in its available choices @@ -627,3 +631,52 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): elif self.required: raise ValidationError("Required field cannot be empty.") + + +class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): + """ + Represents a set of choices available for choice and multi-choice custom fields. + """ + name = models.CharField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + extra_choices = ArrayField( + base_field=models.CharField(max_length=100), + help_text=_('List of field choices') + ) + order_alphabetically = models.BooleanField( + default=False, + help_text=_('Choices are automatically ordered alphabetically on save') + ) + + clone_fields = ('extra_choices', 'order_alphabetically') + + class Meta: + ordering = ('name',) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:customfieldchoiceset', args=[self.pk]) + + @property + def choices(self): + return self.extra_choices + + @property + def choices_count(self): + return len(self.choices) + + def save(self, *args, **kwargs): + + # Sort choices if alphabetical ordering is enforced + if self.order_alphabetically: + self.extra_choices = sorted(self.choices) + + return super().save(*args, **kwargs) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 6cb363c01..e5e722398 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -2,6 +2,7 @@ import json import django_tables2 as tables from django.conf import settings +from django.utils.translation import gettext as _ from extras.models import * from netbox.tables import NetBoxTable, columns @@ -12,6 +13,7 @@ __all__ = ( 'ConfigContextTable', 'ConfigRevisionTable', 'ConfigTemplateTable', + 'CustomFieldChoiceSetTable', 'CustomFieldTable', 'CustomLinkTable', 'ExportTemplateTable', @@ -64,6 +66,11 @@ class CustomFieldTable(NetBoxTable): required = columns.BooleanColumn() ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") description = columns.MarkdownColumn() + choices = columns.ArrayColumn( + max_items=10, + orderable=False, + verbose_name=_('Choices') + ) is_cloneable = columns.BooleanColumn() class Meta(NetBoxTable.Meta): @@ -76,6 +83,33 @@ class CustomFieldTable(NetBoxTable): default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') +class CustomFieldChoiceSetTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + choices = columns.ArrayColumn( + max_items=10, + accessor=tables.A('extra_choices'), + orderable=False, + verbose_name=_('Choices') + ) + choice_count = tables.TemplateColumn( + accessor=tables.A('extra_choices'), + template_code='{{ value|length }}', + orderable=False, + verbose_name=_('Count') + ) + order_alphabetically = columns.BooleanColumn() + + class Meta(NetBoxTable.Meta): + model = CustomFieldChoiceSet + fields = ( + 'pk', 'id', 'name', 'description', 'choice_count', 'choices', 'order_alphabetically', 'created', + 'last_updated', + ) + default_columns = ('pk', 'name', 'choice_count', 'description') + + class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index cbf3b8529..922b45240 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -98,8 +98,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): { 'content_types': ['dcim.site'], 'name': 'cf6', - 'type': 'select', - 'choices': ['A', 'B', 'C'] + 'type': 'text', }, ] bulk_update_data = { @@ -134,6 +133,42 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): cf.content_types.add(site_ct) +class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase): + model = CustomFieldChoiceSet + brief_fields = ['choices_count', 'display', 'id', 'name', 'url'] + create_data = [ + { + 'name': 'Choice Set 4', + 'extra_choices': ['4A', '4B', '4C'], + }, + { + 'name': 'Choice Set 5', + 'extra_choices': ['5A', '5B', '5C'], + }, + { + 'name': 'Choice Set 6', + 'extra_choices': ['6A', '6B', '6C'], + }, + ] + bulk_update_data = { + 'description': 'New description', + } + update_data = { + 'name': 'Choice Set X', + 'extra_choices': ['X1', 'X2', 'X3'], + 'description': 'New description', + } + + @classmethod + 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.objects.bulk_create(choice_sets) + + class CustomLinkTest(APIViewTestCases.APIViewTestCase): model = CustomLink brief_fields = ['display', 'id', 'name', 'url'] diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index e0be8c3bd..9ebbeef5c 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -5,7 +5,7 @@ from rest_framework import status from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.choices import * -from extras.models import CustomField, ObjectChange, Tag +from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag from utilities.testing import APITestCase from utilities.testing.utils import create_tags, post_data from utilities.testing.views import ModelViewTestCase @@ -16,12 +16,16 @@ class ChangeLogViewTest(ModelViewTestCase): @classmethod def setUpTestData(cls): + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=['Bar', 'Foo'] + ) # Create a custom field on the Site model ct = ContentType.objects.get_for_model(Site) cf = CustomField( type=CustomFieldTypeChoices.TYPE_TEXT, - name='my_field', + name='cf1', required=False ) cf.save() @@ -30,9 +34,9 @@ class ChangeLogViewTest(ModelViewTestCase): # Create a select custom field on the Site model cf_select = CustomField( type=CustomFieldTypeChoices.TYPE_SELECT, - name='my_field_select', + name='cf2', required=False, - choices=['Bar', 'Foo'] + choice_set=choice_set ) cf_select.save() cf_select.content_types.set([ct]) @@ -43,8 +47,8 @@ class ChangeLogViewTest(ModelViewTestCase): 'name': 'Site 1', 'slug': 'site-1', 'status': SiteStatusChoices.STATUS_ACTIVE, - 'cf_my_field': 'ABC', - 'cf_my_field_select': 'Bar', + 'cf_cf1': 'ABC', + 'cf_cf2': 'Bar', 'tags': [tag.pk for tag in tags], } @@ -65,8 +69,8 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.changed_object, site) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) self.assertEqual(oc.prechange_data, None) - self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) - self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) + self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1']) + self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) def test_update_object(self): @@ -79,8 +83,8 @@ class ChangeLogViewTest(ModelViewTestCase): 'name': 'Site X', 'slug': 'site-x', 'status': SiteStatusChoices.STATUS_PLANNED, - 'cf_my_field': 'DEF', - 'cf_my_field_select': 'Foo', + 'cf_cf1': 'DEF', + 'cf_cf2': 'Foo', 'tags': [tags[2].pk], } @@ -102,8 +106,8 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc.prechange_data['name'], 'Site 1') self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) - self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) - self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) + self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1']) + self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) def test_delete_object(self): @@ -111,8 +115,8 @@ class ChangeLogViewTest(ModelViewTestCase): name='Site 1', slug='site-1', custom_field_data={ - 'my_field': 'ABC', - 'my_field_select': 'Bar' + 'cf1': 'ABC', + 'cf2': 'Bar' } ) site.save() @@ -131,8 +135,8 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.changed_object, None) self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) - self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC') - self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar') + self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC') + self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar') self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data, None) @@ -213,18 +217,22 @@ class ChangeLogAPITest(APITestCase): ct = ContentType.objects.get_for_model(Site) cf = CustomField( type=CustomFieldTypeChoices.TYPE_TEXT, - name='my_field', + name='cf1', required=False ) cf.save() cf.content_types.set([ct]) # Create a select custom field on the Site model + choice_set = CustomFieldChoiceSet.objects.create( + name='Choice Set 1', + extra_choices=['Bar', 'Foo'] + ) cf_select = CustomField( type=CustomFieldTypeChoices.TYPE_SELECT, - name='my_field_select', + name='cf2', required=False, - choices=['Bar', 'Foo'] + choice_set=choice_set ) cf_select.save() cf_select.content_types.set([ct]) @@ -242,8 +250,8 @@ class ChangeLogAPITest(APITestCase): 'name': 'Site 1', 'slug': 'site-1', 'custom_fields': { - 'my_field': 'ABC', - 'my_field_select': 'Bar', + 'cf1': 'ABC', + 'cf2': 'Bar', }, 'tags': [ {'name': 'Tag 1'}, @@ -276,8 +284,8 @@ class ChangeLogAPITest(APITestCase): 'name': 'Site X', 'slug': 'site-x', 'custom_fields': { - 'my_field': 'DEF', - 'my_field_select': 'Foo', + 'cf1': 'DEF', + 'cf2': 'Foo', }, 'tags': [ {'name': 'Tag 3'} @@ -305,8 +313,8 @@ class ChangeLogAPITest(APITestCase): name='Site 1', slug='site-1', custom_field_data={ - 'my_field': 'ABC', - 'my_field_select': 'Bar' + 'cf1': 'ABC', + 'cf2': 'Bar' } ) site.save() @@ -323,8 +331,8 @@ class ChangeLogAPITest(APITestCase): self.assertEqual(oc.changed_object, None) self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) - self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC') - self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar') + self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC') + self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar') self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data, None) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 3fd0dc83e..3b802a0f2 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -10,7 +10,7 @@ from dcim.filtersets import SiteFilterSet from dcim.forms import SiteImportForm from dcim.models import Manufacturer, Rack, Site from extras.choices import * -from extras.models import CustomField +from extras.models import CustomField, CustomFieldChoiceSet from ipam.models import VLAN from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -272,12 +272,18 @@ class CustomFieldTest(TestCase): CHOICES = ('Option A', 'Option B', 'Option C') value = CHOICES[1] + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=CHOICES + ) + # Create a custom field & check that initial value is null cf = CustomField.objects.create( name='select_field', type=CustomFieldTypeChoices.TYPE_SELECT, required=False, - choices=CHOICES + choice_set=choice_set ) cf.content_types.set([self.object_type]) instance = Site.objects.first() @@ -299,12 +305,18 @@ class CustomFieldTest(TestCase): CHOICES = ['Option A', 'Option B', 'Option C'] value = [CHOICES[1], CHOICES[2]] + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=CHOICES + ) + # Create a custom field & check that initial value is null cf = CustomField.objects.create( name='multiselect_field', type=CustomFieldTypeChoices.TYPE_MULTISELECT, required=False, - choices=CHOICES + choice_set=choice_set ) cf.content_types.set([self.object_type]) instance = Site.objects.first() @@ -438,6 +450,12 @@ class CustomFieldAPITest(APITestCase): ) VLAN.objects.bulk_create(vlans) + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=('Foo', 'Bar', 'Baz') + ) + custom_fields = ( CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), @@ -452,17 +470,13 @@ class CustomFieldAPITest(APITestCase): type=CustomFieldTypeChoices.TYPE_SELECT, name='select_field', default='Foo', - choices=( - 'Foo', 'Bar', 'Baz' - ) + choice_set=choice_set ), CustomField( type=CustomFieldTypeChoices.TYPE_MULTISELECT, name='multiselect_field', default=['Foo'], - choices=( - 'Foo', 'Bar', 'Baz' - ) + choice_set=choice_set ), CustomField( type=CustomFieldTypeChoices.TYPE_OBJECT, @@ -1024,6 +1038,12 @@ class CustomFieldImportTest(TestCase): @classmethod def setUpTestData(cls): + # 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') + ) + custom_fields = ( CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), @@ -1034,12 +1054,8 @@ class CustomFieldImportTest(TestCase): CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON), - CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ - 'Choice A', 'Choice B', 'Choice C', - ]), - CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[ - 'Choice A', 'Choice B', 'Choice C', - ]), + CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set), + CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set), ) for cf in custom_fields: cf.save() @@ -1203,6 +1219,11 @@ class CustomFieldModelFilterTest(TestCase): Manufacturer(name='Manufacturer 4', slug='manufacturer-4'), )) + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=['A', 'B', 'C', 'X'] + ) + # Integer filtering cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER) cf.save() @@ -1263,7 +1284,7 @@ class CustomFieldModelFilterTest(TestCase): cf = CustomField( name='cf9', type=CustomFieldTypeChoices.TYPE_SELECT, - choices=['Foo', 'Bar', 'Baz'] + choice_set=choice_set ) cf.save() cf.content_types.set([obj_type]) @@ -1272,7 +1293,7 @@ class CustomFieldModelFilterTest(TestCase): cf = CustomField( name='cf10', type=CustomFieldTypeChoices.TYPE_MULTISELECT, - choices=['A', 'B', 'C', 'X'] + choice_set=choice_set ) cf.save() cf.content_types.set([obj_type]) @@ -1305,7 +1326,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf6': '2016-06-26', 'cf7': 'http://a.example.com', 'cf8': 'http://a.example.com', - 'cf9': 'Foo', + 'cf9': 'A', 'cf10': ['A', 'X'], 'cf11': manufacturers[0].pk, 'cf12': [manufacturers[0].pk, manufacturers[3].pk], @@ -1319,7 +1340,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf6': '2016-06-27', 'cf7': 'http://b.example.com', 'cf8': 'http://b.example.com', - 'cf9': 'Bar', + 'cf9': 'B', 'cf10': ['B', 'X'], 'cf11': manufacturers[1].pk, 'cf12': [manufacturers[1].pk, manufacturers[3].pk], @@ -1333,7 +1354,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf6': '2016-06-28', 'cf7': 'http://c.example.com', 'cf8': 'http://c.example.com', - 'cf9': 'Baz', + 'cf9': 'C', 'cf10': ['C', 'X'], 'cf11': manufacturers[2].pk, 'cf12': [manufacturers[2].pk, manufacturers[3].pk], @@ -1399,7 +1420,7 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3) def test_filter_select(self): - self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) def test_filter_multiselect(self): self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2) diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index b4b216244..c558a0467 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -27,7 +27,11 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + choice_sets = ( + CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']), + CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']), + ) + CustomFieldChoiceSet.objects.bulk_create(choice_sets) custom_fields = ( CustomField( @@ -54,11 +58,31 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN ), + CustomField( + name='Custom Field 4', + type=CustomFieldTypeChoices.TYPE_SELECT, + required=False, + weight=400, + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + choice_set=choice_sets[0] + ), + CustomField( + name='Custom Field 5', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + required=False, + weight=500, + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + choice_set=choice_sets[1] + ), ) CustomField.objects.bulk_create(custom_fields) - custom_fields[0].content_types.add(content_types[0]) - custom_fields[1].content_types.add(content_types[1]) - custom_fields[2].content_types.add(content_types[2]) + custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site')) + custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack')) + custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) + custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) + custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) def test_name(self): params = {'name': ['Custom Field 1', 'Custom Field 2']} @@ -67,7 +91,7 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): def test_content_types(self): params = {'content_types': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_required(self): @@ -86,6 +110,34 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_choice_set(self): + params = {'choice_set': ['Choice Set 1', 'Choice Set 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'choice_set_id': CustomFieldChoiceSet.objects.values_list('pk', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests): + queryset = CustomFieldChoiceSet.objects.all() + filterset = CustomFieldChoiceSetFilterSet + + @classmethod + def setUpTestData(cls): + choice_sets = ( + CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']), + CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']), + CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I']), + ) + CustomFieldChoiceSet.objects.bulk_create(choice_sets) + + def test_name(self): + params = {'name': ['Choice Set 1', 'Choice Set 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_choice(self): + params = {'choice': ['A', 'D']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class WebhookTestCase(TestCase, BaseFilterSetTests): queryset = Webhook.objects.all() diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index cc3625c7c..9d6054b86 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -5,7 +5,7 @@ from dcim.forms import SiteForm from dcim.models import Site from extras.choices import CustomFieldTypeChoices from extras.forms import SavedFilterForm -from extras.models import CustomField +from extras.models import CustomField, CustomFieldChoiceSet class CustomFieldModelFormTest(TestCase): @@ -13,7 +13,10 @@ class CustomFieldModelFormTest(TestCase): @classmethod def setUpTestData(cls): obj_type = ContentType.objects.get_for_model(Site) - CHOICES = ('A', 'B', 'C') + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=('A', 'B', 'C') + ) cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT) cf_text.content_types.set([obj_type]) @@ -42,13 +45,17 @@ class CustomFieldModelFormTest(TestCase): cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON) cf_json.content_types.set([obj_type]) - cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES) + cf_select = CustomField.objects.create( + name='select', + type=CustomFieldTypeChoices.TYPE_SELECT, + choice_set=choice_set + ) cf_select.content_types.set([obj_type]) cf_multiselect = CustomField.objects.create( name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, - choices=CHOICES + choice_set=choice_set ) cf_multiselect.content_types.set([obj_type]) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 57efc5be7..acfdcf1e3 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -21,6 +21,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): def setUpTestData(cls): site_ct = ContentType.objects.get_for_model(Site) + CustomFieldChoiceSet.objects.create( + name='Choice Set 1', + extra_choices=('A', 'B', 'C') + ) + custom_fields = ( CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT), @@ -44,10 +49,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', + 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visibility', 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write', 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write', - 'field6,Field 6,select,dcim.site,,100,3000,exact,"A,B,C",,,,read-write', + 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,read-write', 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write', ) @@ -64,6 +69,43 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = CustomFieldChoiceSet + + @classmethod + 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.objects.bulk_create(choice_sets) + + cls.form_data = { + 'name': 'Choice Set X', + 'extra_choices': 'X1,X2,X3,X4,X5', + } + + 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"', + ) + + 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"', + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CustomLink diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 086537b99..fd95186e4 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -15,6 +15,14 @@ urlpatterns = [ path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'), path('custom-fields//', include(get_model_urls('extras', 'customfield'))), + # Custom field choices + path('custom-field-choices/', views.CustomFieldChoiceSetListView.as_view(), name='customfieldchoiceset_list'), + path('custom-field-choices/add/', views.CustomFieldChoiceSetEditView.as_view(), name='customfieldchoiceset_add'), + path('custom-field-choices/import/', views.CustomFieldChoiceSetBulkImportView.as_view(), name='customfieldchoiceset_import'), + path('custom-field-choices/edit/', views.CustomFieldChoiceSetBulkEditView.as_view(), name='customfieldchoiceset_bulk_edit'), + path('custom-field-choices/delete/', views.CustomFieldChoiceSetBulkDeleteView.as_view(), name='customfieldchoiceset_bulk_delete'), + path('custom-field-choices//', include(get_model_urls('extras', 'customfieldchoiceset'))), + # Custom links path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'), path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 931e9509c..193d8821b 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -34,7 +34,7 @@ from .scripts import run_script # class CustomFieldListView(generic.ObjectListView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') filterset = filtersets.CustomFieldFilterSet filterset_form = forms.CustomFieldFilterForm table = tables.CustomFieldTable @@ -42,38 +42,83 @@ class CustomFieldListView(generic.ObjectListView): @register_model_view(CustomField) class CustomFieldView(generic.ObjectView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') @register_model_view(CustomField, 'edit') class CustomFieldEditView(generic.ObjectEditView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') form = forms.CustomFieldForm @register_model_view(CustomField, 'delete') class CustomFieldDeleteView(generic.ObjectDeleteView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') class CustomFieldBulkImportView(generic.BulkImportView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') model_form = forms.CustomFieldImportForm class CustomFieldBulkEditView(generic.BulkEditView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') filterset = filtersets.CustomFieldFilterSet table = tables.CustomFieldTable form = forms.CustomFieldBulkEditForm class CustomFieldBulkDeleteView(generic.BulkDeleteView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') filterset = filtersets.CustomFieldFilterSet table = tables.CustomFieldTable +# +# Custom field choices +# + +class CustomFieldChoiceSetListView(generic.ObjectListView): + queryset = CustomFieldChoiceSet.objects.all() + filterset = filtersets.CustomFieldChoiceSetFilterSet + filterset_form = forms.CustomFieldChoiceSetFilterForm + table = tables.CustomFieldChoiceSetTable + + +@register_model_view(CustomFieldChoiceSet) +class CustomFieldChoiceSetView(generic.ObjectView): + queryset = CustomFieldChoiceSet.objects.all() + + +@register_model_view(CustomFieldChoiceSet, 'edit') +class CustomFieldChoiceSetEditView(generic.ObjectEditView): + queryset = CustomFieldChoiceSet.objects.all() + form = forms.CustomFieldChoiceSetForm + + +@register_model_view(CustomFieldChoiceSet, 'delete') +class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView): + queryset = CustomFieldChoiceSet.objects.all() + + +class CustomFieldChoiceSetBulkImportView(generic.BulkImportView): + queryset = CustomFieldChoiceSet.objects.all() + model_form = forms.CustomFieldChoiceSetImportForm + + +class CustomFieldChoiceSetBulkEditView(generic.BulkEditView): + queryset = CustomFieldChoiceSet.objects.all() + filterset = filtersets.CustomFieldChoiceSetFilterSet + table = tables.CustomFieldChoiceSetTable + form = forms.CustomFieldChoiceSetBulkEditForm + + +class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView): + queryset = CustomFieldChoiceSet.objects.all() + filterset = filtersets.CustomFieldChoiceSetFilterSet + table = tables.CustomFieldChoiceSetTable + + # # Custom links # diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 100de16da..1f6853884 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -288,6 +288,7 @@ CUSTOMIZATION_MENU = Menu( label=_('Customization'), items=( get_model_item('extras', 'customfield', _('Custom Fields')), + get_model_item('extras', 'customfieldchoiceset', _('Custom Field Choices')), get_model_item('extras', 'customlink', _('Custom Links')), get_model_item('extras', 'exporttemplate', _('Export Templates')), get_model_item('extras', 'savedfilter', _('Saved Filters')), diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 9ef327026..1f698f396 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -21,6 +21,7 @@ from utilities.utils import content_type_identifier, content_type_name, get_view __all__ = ( 'ActionsColumn', + 'ArrayColumn', 'BooleanColumn', 'ChoiceFieldColumn', 'ColorColumn', @@ -591,3 +592,22 @@ class MarkdownColumn(tables.TemplateColumn): def value(self, value): return value + + +class ArrayColumn(tables.Column): + """ + List array items as a comma-separated list. + """ + def __init__(self, *args, max_items=None, **kwargs): + self.max_items = max_items + super().__init__(*args, **kwargs) + + def render(self, value): + if self.max_items: + # Limit the returned items to the specified maximum number + omitted = len(value) - self.max_items + value = value[:self.max_items - 1] + if omitted > 0: + value.append(f'({omitted} more)') + + return ', '.join(value) diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index b783c8a77..bab207243 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -15,14 +15,6 @@ Name {{ object.name }} - - Label - {{ object.label|placeholder }} - - - Group Name - {{ object.group_name|placeholder }} - Type @@ -30,6 +22,14 @@ {% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %} + + Label + {{ object.label|placeholder }} + + + Group + {{ object.group_name|placeholder }} + Description {{ object.description|markdown|placeholder }} @@ -38,6 +38,27 @@ Required {% checkmark object.required %} + + Cloneable + {% checkmark object.is_cloneable %} + + {% if object.choice_set %} + + Choice Set + {{ object.choice_set|linkify }} ({{ object.choice_set.choices|length }} choices) + + {% endif %} + + Default Value + {{ object.default }} + + + + +
+
Behavior
+
+ - - - - -
Search Weight @@ -60,33 +81,6 @@ UI Visibility {{ object.get_ui_visibility_display }}
Cloneable{% checkmark object.is_cloneable %}
-
-
-
-
- Values -
-
- - - - - - - - -
Default Value{{ object.default }}
Choices - {% if object.choices %} - {{ object.choices|join:", " }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
@@ -94,9 +88,7 @@
-
- Assigned Models -
+
Object Types
{% for ct in object.content_types.all %} @@ -108,9 +100,7 @@
-
- Validation Rules -
+
Validation Rules
@@ -138,8 +128,8 @@
-
- {% plugin_full_width_page object %} -
+
+ {% plugin_full_width_page object %} +
{% endblock %} diff --git a/netbox/templates/extras/customfieldchoiceset.html b/netbox/templates/extras/customfieldchoiceset.html new file mode 100644 index 000000000..25c95729e --- /dev/null +++ b/netbox/templates/extras/customfieldchoiceset.html @@ -0,0 +1,64 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Custom Field Choice Set
+
+
+ + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|markdown|placeholder }}
Choices{{ object.choices|length }}
Order Alphabetically{% checkmark object.order_alphabetically %}
Used by +
    + {% for cf in object.choices_for.all %} +
  • {{ cf|linkify }}
  • + {% endfor %} +
+
+
+
+ {% plugin_left_page object %} +
+
+
+
Choices
+
+ + {% for choice in object.choices %} + + + + {% endfor %} +
{{ choice }}
+
+
+ {% plugin_right_page object %} +
+ +
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/utilities/forms/widgets/misc.py b/netbox/utilities/forms/widgets/misc.py index ca2e64319..e999af831 100644 --- a/netbox/utilities/forms/widgets/misc.py +++ b/netbox/utilities/forms/widgets/misc.py @@ -1,6 +1,7 @@ from django import forms __all__ = ( + 'ArrayWidget', 'ClearableFileInput', 'MarkdownWidget', 'NumberWithOptions', @@ -43,3 +44,13 @@ class SlugWidget(forms.TextInput): Subclass TextInput and add a slug regeneration button next to the form field. """ template_name = 'widgets/sluginput.html' + + +class ArrayWidget(forms.Textarea): + """ + Render each item of an array on a new line within a textarea for easy editing/ + """ + def format_value(self, value): + if value is None or not len(value): + return None + return '\n'.join(value)