From 70e1e1197ee291c3ba357157954ad223161993a9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jul 2023 11:05:10 -0400 Subject: [PATCH] Initial work on custom field choice sets --- netbox/extras/api/nested_serializers.py | 9 +++ netbox/extras/api/serializers.py | 16 +++- netbox/extras/api/urls.py | 1 + netbox/extras/api/views.py | 9 ++- netbox/extras/filtersets.py | 32 ++++++++ netbox/extras/forms/bulk_edit.py | 21 ++++- netbox/extras/forms/bulk_import.py | 24 +++++- netbox/extras/forms/filtersets.py | 18 ++++- netbox/extras/forms/model_forms.py | 17 +++- netbox/extras/graphql/schema.py | 6 ++ netbox/extras/graphql/types.py | 9 +++ .../migrations/0096_customfieldchoiceset.py | 60 ++++++++++++++ netbox/extras/models/__init__.py | 2 +- netbox/extras/models/customfields.py | 47 ++++++++++- netbox/extras/tables/tables.py | 14 ++++ netbox/extras/urls.py | 8 ++ netbox/extras/views.py | 59 ++++++++++++-- netbox/netbox/navigation/menu.py | 1 + netbox/templates/extras/customfield.html | 78 ++++++++----------- .../extras/customfieldchoiceset.html | 50 ++++++++++++ 20 files changed, 414 insertions(+), 67 deletions(-) create mode 100644 netbox/extras/migrations/0096_customfieldchoiceset.py create mode 100644 netbox/templates/extras/customfieldchoiceset.html diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 4271e1748..294b32290 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'] + + 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..7e4598293 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,8 +103,8 @@ 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', - 'last_updated', + 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'choices', + 'created', 'last_updated', ] def validate_type(self, value): @@ -127,6 +129,16 @@ 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', 'choices', '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..3c7fcfcf4 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,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 @@ -93,6 +97,34 @@ 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', + ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(choices__icontains=value) + ) + + def filter_by_choice(self, queryset, name, value): + return queryset.filter(choices__icontains=value.strip()) + + 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..9a9844c88 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,19 @@ 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 + ) + + nullable_fields = ('description',) class CustomLinkBulkEditForm(BulkEditForm): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 818b8a52f..350481195 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,6 +42,11 @@ class CustomFieldImportForm(CSVModelForm): required=False, help_text=_("Object type (for object or multi-object fields)") ) + choice_set = CSVModelChoiceField( + queryset=CustomFieldChoiceSet.objects.all(), + to_field_name='name', + help_text=_('Choice set (for selection fields)') + ) choices = SimpleArrayField( base_field=forms.CharField(), required=False, @@ -58,6 +66,20 @@ class CustomFieldImportForm(CSVModelForm): ) +class CustomFieldChoiceSetImportForm(CSVModelForm): + choices = SimpleArrayField( + base_field=forms.CharField(), + required=False, + help_text=_('Comma-separated list of field choices') + ) + + class Meta: + model = CustomFieldChoiceSet + fields = ( + 'name', 'description', 'choices', + ) + + class CustomLinkImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 56e9c8dfb..adf3d12a4 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,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')), diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 354d2a51a..614a5dfd9 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -16,8 +16,8 @@ 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 virtualization.models import Cluster, ClusterGroup, ClusterType @@ -27,6 +27,7 @@ __all__ = ( 'ConfigContextForm', 'ConfigRevisionForm', 'ConfigTemplateForm', + 'CustomFieldChoiceSetForm', 'CustomFieldForm', 'CustomLinkForm', 'ExportTemplateForm', @@ -50,13 +51,16 @@ 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() + ) 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 +82,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): self.fields['type'].disabled = True +class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = CustomFieldChoiceSet + fields = ('name', 'description', 'choices') + + 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..dc935440b 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_choices = ObjectField(CustomFieldChoiceSetType) + custom_field_choices_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..94843f4b2 --- /dev/null +++ b/netbox/extras/migrations/0096_customfieldchoiceset.py @@ -0,0 +1,60 @@ +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', + 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)), + ('choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None)), + ], + 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='custom_fields', to='extras.customfieldchoiceset'), + ), + migrations.RunPython( + code=create_choice_sets, + reverse_code=migrations.RunPython.noop + ), + ] 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..68118525c 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,6 +159,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): 'example, ^[A-Z]{3}$ will limit values to exactly three uppercase letters.' ) ) + choice_set = models.ForeignKey( + to='CustomFieldChoiceSet', + on_delete=models.PROTECT, + related_name='custom_fields', + blank=True, + null=True + ) choices = ArrayField( base_field=models.CharField(max_length=100), blank=True, @@ -278,13 +286,17 @@ 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 ( + # Choice set must be set on selection fields + if self.type in ( CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT - ): + ) and not self.choice_set: raise ValidationError({ - 'choices': "Choices may be set only for custom selection fields." + 'choice_set': "Selection fields must define a set of choices." + }) + elif self.choice_set: + raise ValidationError({ + 'choice_set': "Choices may be set only for selection fields." }) # Selection fields must have at least one choice defined @@ -627,3 +639,30 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): elif self.required: raise ValidationError("Required field cannot be empty.") + + +class CustomFieldChoiceSet(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 + ) + choices = ArrayField( + base_field=models.CharField(max_length=100), + help_text=_('Comma-separated list of available choices (for selection fields)') + ) + + class Meta: + ordering = ('name',) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:customfieldchoiceset', args=[self.pk]) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 6cb363c01..03da7a2bd 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -12,6 +12,7 @@ __all__ = ( 'ConfigContextTable', 'ConfigRevisionTable', 'ConfigTemplateTable', + 'CustomFieldChoiceSetTable', 'CustomFieldTable', 'CustomLinkTable', 'ExportTemplateTable', @@ -76,6 +77,19 @@ class CustomFieldTable(NetBoxTable): default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') +class CustomFieldChoiceSetTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + + class Meta(NetBoxTable.Meta): + model = CustomFieldChoiceSet + fields = ( + 'pk', 'id', 'name', 'description', 'choices', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'description', 'choices') + + class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True 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/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..f2df0f67f --- /dev/null +++ b/netbox/templates/extras/customfieldchoiceset.html @@ -0,0 +1,50 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Custom Field Choice Set
+
+
+ + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|markdown|placeholder }}
Used by{# TODO #}
+
+
+ {% plugin_left_page object %} +
+
+
+
Choices
+
+ + {% for choice in object.choices %} + + + + {% endfor %} +
{{ choice }}
+
+
+ {% plugin_right_page object %} +
+ +
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %}