From b1a60ca89433f46ab7f5777e35b2d6abcd1a9d31 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Nov 2023 09:12:58 -0500 Subject: [PATCH 1/4] Add ui_visible and ui_editable fields --- netbox/extras/api/serializers.py | 8 +++-- netbox/extras/choices.py | 26 +++++++++++++++++ netbox/extras/filtersets.py | 2 +- netbox/extras/forms/bulk_edit.py | 10 +++++++ netbox/extras/forms/bulk_import.py | 14 ++++++++- netbox/extras/forms/filtersets.py | 12 +++++++- netbox/extras/forms/mixins.py | 7 ++--- netbox/extras/forms/model_forms.py | 2 +- .../migrations/0100_customfield_ui_attrs.py | 21 ++++++++++++++ netbox/extras/models/customfields.py | 29 +++++++++++++++---- netbox/extras/tables/tables.py | 10 +++++-- netbox/extras/tests/test_filtersets.py | 23 ++++++++++----- netbox/extras/tests/test_views.py | 13 +++++---- netbox/netbox/forms/base.py | 13 ++++----- netbox/netbox/models/features.py | 17 +++++------ netbox/netbox/tables/tables.py | 4 +-- netbox/templates/extras/customfield.html | 8 +++-- 17 files changed, 167 insertions(+), 52 deletions(-) create mode 100644 netbox/extras/migrations/0100_customfield_ui_attrs.py diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index c1fad99eead..031a9c5a007 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -97,14 +97,16 @@ class CustomFieldSerializer(ValidatedModelSerializer): data_type = serializers.SerializerMethodField() choice_set = NestedCustomFieldChoiceSetSerializer(required=False) ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False) + ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False) + ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, 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', 'choice_set', 'created', - 'last_updated', + 'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'ui_visible', 'ui_editable', + 'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'choice_set', 'created', 'last_updated', ] def validate_type(self, value): diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 0572a33a129..48e7f355bf5 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -68,6 +68,32 @@ class CustomFieldVisibilityChoices(ChoiceSet): ) +class CustomFieldUIVisibleChoices(ChoiceSet): + + ALWAYS = 'always' + IF_SET = 'if-set' + HIDDEN = 'hidden' + + CHOICES = ( + (ALWAYS, _('Always'), 'green'), + (IF_SET, _('If set'), 'yellow'), + (HIDDEN, _('Hidden'), 'gray'), + ) + + +class CustomFieldUIEditableChoices(ChoiceSet): + + YES = 'yes' + NO = 'no' + HIDDEN = 'hidden' + + CHOICES = ( + (YES, _('Yes'), 'green'), + (NO, _('No'), 'red'), + (HIDDEN, _('Hidden'), 'gray'), + ) + + class CustomFieldChoiceSetBaseChoices(ChoiceSet): IATA = 'IATA' diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index fec06726357..a1c1f83f226 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -88,7 +88,7 @@ class Meta: model = CustomField fields = [ 'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visibility', - 'weight', 'is_cloneable', 'description', + 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', 'description', ] def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 821ce7eb243..a7867ab6b69 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -54,6 +54,16 @@ class CustomFieldBulkEditForm(BulkEditForm): required=False, initial='' ) + ui_visible = forms.ChoiceField( + label=_("UI visible"), + choices=add_blank_choice(CustomFieldUIVisibleChoices), + required=False + ) + ui_editable = forms.ChoiceField( + label=_("UI editable"), + choices=add_blank_choice(CustomFieldUIEditableChoices), + required=False + ) is_cloneable = forms.NullBooleanField( label=_('Is cloneable'), required=False, diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 03a6d118b1e..302c38eaac4 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -56,13 +56,25 @@ class CustomFieldImportForm(CSVModelForm): choices=CustomFieldVisibilityChoices, help_text=_('How the custom field is displayed in the user interface') ) + ui_visible = CSVChoiceField( + label=_('UI visible'), + choices=CustomFieldUIVisibleChoices, + required=False, + help_text=_('Whether the custom field is displayed in the UI') + ) + ui_editable = CSVChoiceField( + label=_('UI editable'), + choices=CustomFieldUIEditableChoices, + required=False, + help_text=_('Whether the custom field is editable in the UI') + ) class Meta: model = CustomField fields = ( 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum', - 'validation_maximum', 'validation_regex', 'ui_visibility', 'is_cloneable', + 'validation_maximum', 'validation_regex', 'ui_visibility', 'ui_visible', 'ui_editable', 'is_cloneable', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index c0c8835b492..5cc46b49ba3 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -40,7 +40,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): (None, ('q', 'filter_id')), (_('Attributes'), ( 'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility', - 'is_cloneable', + 'ui_visible', 'ui_editable', 'is_cloneable', )), ) content_type_id = ContentTypeMultipleChoiceField( @@ -78,6 +78,16 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('UI visibility') ) + ui_visible = forms.ChoiceField( + choices=add_blank_choice(CustomFieldUIVisibleChoices), + required=False, + label=_('UI visible') + ) + ui_editable = forms.ChoiceField( + choices=add_blank_choice(CustomFieldUIEditableChoices), + required=False, + label=_('UI editable') + ) is_cloneable = forms.NullBooleanField( label=_('Is cloneable'), required=False, diff --git a/netbox/extras/forms/mixins.py b/netbox/extras/forms/mixins.py index 5366dcc285f..e9fb897c0ca 100644 --- a/netbox/extras/forms/mixins.py +++ b/netbox/extras/forms/mixins.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ -from extras.choices import CustomFieldVisibilityChoices +from extras.choices import * from extras.models import * from utilities.forms.fields import DynamicModelMultipleChoiceField @@ -40,7 +40,7 @@ def _get_content_type(self): def _get_custom_fields(self, content_type): return CustomField.objects.filter(content_types=content_type).exclude( - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ui_visible=CustomFieldUIVisibleChoices.HIDDEN ) def _get_form_field(self, customfield): @@ -51,9 +51,6 @@ def _append_customfield_fields(self): Append form fields for all CustomFields assigned to this object type. """ for customfield in self._get_custom_fields(self._get_content_type()): - if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: - continue - field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 7ab568ae03c..d8a6574e358 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -61,7 +61,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): (_('Custom Field'), ( 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description', )), - (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')), + (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visibility', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')), (_('Values'), ('default', 'choice_set')), (_('Validation'), ('validation_minimum', 'validation_maximum', 'validation_regex')), ) diff --git a/netbox/extras/migrations/0100_customfield_ui_attrs.py b/netbox/extras/migrations/0100_customfield_ui_attrs.py new file mode 100644 index 00000000000..084557ebdde --- /dev/null +++ b/netbox/extras/migrations/0100_customfield_ui_attrs.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0099_cachedvalue_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='ui_editable', + field=models.CharField(default='yes', max_length=50), + ), + migrations.AddField( + model_name='customfield', + name='ui_visible', + field=models.CharField(default='always', max_length=50), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 2cb12ed5bdc..58334bf084c 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -10,7 +10,6 @@ from django.core.validators import RegexValidator, ValidationError from django.db import models from django.urls import reverse -from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -187,6 +186,20 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): verbose_name=_('UI visibility'), help_text=_('Specifies the visibility of custom field in the UI') ) + ui_visible = models.CharField( + max_length=50, + choices=CustomFieldUIVisibleChoices, + default=CustomFieldUIVisibleChoices.ALWAYS, + verbose_name=_('UI visible'), + help_text=_('Specifies whether the custom field is displayed in the UI') + ) + ui_editable = models.CharField( + max_length=50, + choices=CustomFieldUIEditableChoices, + default=CustomFieldUIEditableChoices.YES, + verbose_name=_('UI editable'), + help_text=_('Specifies whether the custom field value can be edited in the UI') + ) is_cloneable = models.BooleanField( default=False, verbose_name=_('is cloneable'), @@ -198,7 +211,7 @@ 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', - 'choice_set', 'ui_visibility', 'is_cloneable', + 'choice_set', 'ui_visibility', 'ui_visible', 'ui_editable', 'is_cloneable', ) class Meta: @@ -232,6 +245,12 @@ def choices(self): return self.choice_set.choices return [] + def get_ui_visible_color(self): + return CustomFieldUIVisibleChoices.colors.get(self.ui_visible) + + def get_ui_editable_color(self): + return CustomFieldUIEditableChoices.colors.get(self.ui_editable) + def get_choice_label(self, value): if not hasattr(self, '_choice_map'): self._choice_map = dict(self.choices) @@ -382,7 +401,7 @@ def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibil set_initial: Set initial data for the field. This should be False when generating a field for bulk editing. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. - enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering. + enforce_visibility: Honor the value of CustomField.ui_visible. Set to False for filtering. for_csv_import: Return a form field suitable for bulk import of objects in CSV format. """ initial = self.default if set_initial else None @@ -507,10 +526,10 @@ def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibil field.help_text = render_markdown(self.description) # Annotate read-only fields - if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: + if enforce_visibility and self.ui_editable != CustomFieldUIEditableChoices.YES: field.disabled = True prepend = '
' if field.help_text else '' - field.help_text += f'{prepend} ' + _('Field is set to read-only.') + field.help_text += f'{prepend} ' + _('Field is not editable.') return field diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 9e14a2d2745..57bb33bd744 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -74,6 +74,12 @@ class CustomFieldTable(NetBoxTable): ui_visibility = columns.ChoiceFieldColumn( verbose_name=_('UI Visibility') ) + ui_visible = columns.ChoiceFieldColumn( + verbose_name=_('Visible') + ) + ui_editable = columns.ChoiceFieldColumn( + verbose_name=_('Editable') + ) description = columns.MarkdownColumn( verbose_name=_('Description') ) @@ -94,8 +100,8 @@ class Meta(NetBoxTable.Meta): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description', - 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choice_set', 'choices', - 'created', 'last_updated', + 'search_weight', 'filter_logic', 'ui_visibility', 'ui_visible', 'ui_editable', 'is_cloneable', 'weight', + 'choice_set', 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 69111e6a781..c5a6706c07f 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -40,7 +40,8 @@ def setUpTestData(cls): required=True, weight=100, filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE + ui_visible=CustomFieldUIVisibleChoices.ALWAYS, + ui_editable=CustomFieldUIEditableChoices.YES ), CustomField( name='Custom Field 2', @@ -48,7 +49,8 @@ def setUpTestData(cls): required=False, weight=200, filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY + ui_visible=CustomFieldUIVisibleChoices.IF_SET, + ui_editable=CustomFieldUIEditableChoices.NO ), CustomField( name='Custom Field 3', @@ -56,7 +58,8 @@ def setUpTestData(cls): required=False, weight=300, filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN ), CustomField( name='Custom Field 4', @@ -64,7 +67,8 @@ def setUpTestData(cls): required=False, weight=400, filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN, choice_set=choice_sets[0] ), CustomField( @@ -73,7 +77,8 @@ def setUpTestData(cls): required=False, weight=500, filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN, choice_set=choice_sets[1] ), ) @@ -106,8 +111,12 @@ def test_filter_logic(self): params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_ui_visibility(self): - params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE} + def test_ui_visible(self): + params = {'ui_visible': CustomFieldUIVisibleChoices.ALWAYS} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_ui_editable(self): + params = {'ui_editable': CustomFieldUIEditableChoices.YES} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_choice_set(self): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index e034abff53b..3d4b3e9a9f2 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -50,15 +50,16 @@ def setUpTestData(cls): 'default': None, 'weight': 200, 'required': True, - 'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, + 'ui_visible': CustomFieldUIVisibleChoices.ALWAYS, + 'ui_editable': CustomFieldUIEditableChoices.YES, } cls.csv_data = ( - '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,Choice Set 1,,,,read-write', - 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write', + 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable', + 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},always,yes', + 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,always,yes', + 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,always,yes', + 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,always,yes', ) cls.csv_update_data = ( diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 43d0850f0eb..b51efe9c01d 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -3,7 +3,7 @@ from django.db.models import Q from django.utils.translation import gettext_lazy as _ -from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices +from extras.choices import * from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin from extras.models import CustomField, Tag from utilities.forms import CSVModelForm @@ -76,11 +76,9 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): ) def _get_custom_fields(self, content_type): - return CustomField.objects.filter(content_types=content_type).filter( - ui_visibility__in=[ - CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, - CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET, - ] + return CustomField.objects.filter( + content_types=content_type, + ui_editable=CustomFieldUIEditableChoices.YES ) def _get_form_field(self, customfield): @@ -131,7 +129,8 @@ def _get_form_field(self, customfield): def _extend_nullable_fields(self): nullable_custom_fields = [ - name for name, customfield in self.custom_fields.items() if (not customfield.required and customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE) + name for name, customfield in self.custom_fields.items() + if (not customfield.required and customfield.ui_editable == CustomFieldUIEditableChoices.YES) ] self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index cce265efc6d..18186abace1 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -13,7 +13,7 @@ from taggit.managers import TaggableManager from core.choices import JobStatusChoices -from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices +from extras.choices import * from extras.utils import is_taggable, register_features from netbox.registry import registry from netbox.signals import post_clean @@ -205,12 +205,11 @@ def get_custom_fields(self, omit_hidden=False): for field in CustomField.objects.get_for_model(self): value = self.custom_field_data.get(field.name) - # Skip fields that are hidden if 'omit_hidden' is set - if omit_hidden: - if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: - continue - if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value: - continue + # Skip hidden fields if 'omit_hidden' is True + if omit_hidden and field.ui_visible == CustomFieldUIVisibleChoices.HIDDEN: + continue + elif omit_hidden and field.ui_visible == CustomFieldUIVisibleChoices.IF_SET and not value: + continue data[field] = field.deserialize(value) @@ -232,12 +231,12 @@ def get_custom_fields_by_group(self): from extras.models import CustomField groups = defaultdict(dict) visible_custom_fields = CustomField.objects.get_for_model(self).exclude( - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ui_visible=CustomFieldUIVisibleChoices.HIDDEN ) for cf in visible_custom_fields: value = self.custom_field_data.get(cf.name) - if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET: + if value in (None, []) and cf.ui_visible == CustomFieldUIVisibleChoices.IF_SET: continue value = cf.deserialize(value) groups[cf.group_name][cf] = value diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index cb53310ccfa..29899d25f10 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -11,7 +11,7 @@ from django_tables2.data import TableQuerysetData from extras.models import CustomField, CustomLink -from extras.choices import CustomFieldVisibilityChoices +from extras.choices import * from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.utils import get_viewname, highlight_string, title @@ -195,7 +195,7 @@ def __init__(self, *args, extra_columns=None, **kwargs): content_type = ContentType.objects.get_for_model(self._meta.model) custom_fields = CustomField.objects.filter( content_types=content_type - ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) + ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN) extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index dd5cce7bdbd..95919b4147f 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -79,8 +79,12 @@
{% trans "Behavior" %}
{{ object.weight }} - {% trans "UI Visibility" %} - {{ object.get_ui_visibility_display }} + {% trans "UI Visible" %} + {{ object.get_ui_visible_display }} + + + {% trans "UI Editable" %} + {{ object.get_ui_editable_display }} From 9838c347973f79b07622a07f5dc29c023df7a065 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Nov 2023 09:23:00 -0500 Subject: [PATCH 2/4] Extend migration to map new visible/editable values --- .../migrations/0100_customfield_ui_attrs.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/netbox/extras/migrations/0100_customfield_ui_attrs.py b/netbox/extras/migrations/0100_customfield_ui_attrs.py index 084557ebdde..44de004fe9a 100644 --- a/netbox/extras/migrations/0100_customfield_ui_attrs.py +++ b/netbox/extras/migrations/0100_customfield_ui_attrs.py @@ -1,6 +1,18 @@ from django.db import migrations, models +def update_ui_attrs(apps, schema_editor): + """ + Replicate legacy ui_visibility values to the new ui_visible and ui_editable fields. + """ + CustomField = apps.get_model('extras', 'CustomField') + + CustomField.objects.filter(ui_visibility='read-write').update(ui_visible='always', ui_editable='yes') + CustomField.objects.filter(ui_visibility='read-only').update(ui_visible='always', ui_editable='no') + CustomField.objects.filter(ui_visibility='hidden').update(ui_visible='hidden', ui_editable='hidden') + CustomField.objects.filter(ui_visibility='hidden-ifunset').update(ui_visible='if-set', ui_editable='yes') + + class Migration(migrations.Migration): dependencies = [ @@ -18,4 +30,8 @@ class Migration(migrations.Migration): name='ui_visible', field=models.CharField(default='always', max_length=50), ), + migrations.RunPython( + code=update_ui_attrs, + reverse_code=migrations.RunPython.noop + ), ] From 5a2a3e3705e8575a475c195a5f26abc72b9d94b6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Nov 2023 09:27:58 -0500 Subject: [PATCH 3/4] Remove ui_visibility field --- netbox/extras/api/serializers.py | 7 +++---- netbox/extras/choices.py | 15 --------------- netbox/extras/filtersets.py | 4 ++-- netbox/extras/forms/bulk_edit.py | 6 ------ netbox/extras/forms/bulk_import.py | 7 +------ netbox/extras/forms/filtersets.py | 9 ++------- netbox/extras/forms/model_forms.py | 2 +- .../migrations/0100_customfield_ui_attrs.py | 4 ++++ netbox/extras/models/customfields.py | 9 +-------- netbox/extras/tables/tables.py | 7 ++----- 10 files changed, 16 insertions(+), 54 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 031a9c5a007..73b42f4c262 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -96,7 +96,6 @@ 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) ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False) ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False) @@ -104,9 +103,9 @@ 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', 'ui_visible', 'ui_editable', - 'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', - 'choice_set', 'created', 'last_updated', + 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', + 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', + 'created', 'last_updated', ] def validate_type(self, value): diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 48e7f355bf5..fdb951b7ddf 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -53,21 +53,6 @@ class CustomFieldFilterLogicChoices(ChoiceSet): ) -class CustomFieldVisibilityChoices(ChoiceSet): - - VISIBILITY_READ_WRITE = 'read-write' - VISIBILITY_READ_ONLY = 'read-only' - VISIBILITY_HIDDEN = 'hidden' - VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset' - - CHOICES = ( - (VISIBILITY_READ_WRITE, _('Read/write')), - (VISIBILITY_READ_ONLY, _('Read-only')), - (VISIBILITY_HIDDEN, _('Hidden')), - (VISIBILITY_HIDDEN_IFUNSET, _('Hidden (if unset)')), - ) - - class CustomFieldUIVisibleChoices(ChoiceSet): ALWAYS = 'always' diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index a1c1f83f226..32850bee2cf 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -87,8 +87,8 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField fields = [ - 'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visibility', - 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', 'description', + 'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible', + 'ui_editable', 'weight', 'is_cloneable', 'description', ] def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index a7867ab6b69..5da2a5ddeca 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -48,12 +48,6 @@ class CustomFieldBulkEditForm(BulkEditForm): queryset=CustomFieldChoiceSet.objects.all(), required=False ) - ui_visibility = forms.ChoiceField( - label=_("UI visibility"), - choices=add_blank_choice(CustomFieldVisibilityChoices), - required=False, - initial='' - ) ui_visible = forms.ChoiceField( label=_("UI visible"), choices=add_blank_choice(CustomFieldUIVisibleChoices), diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 302c38eaac4..ecfe641a5f3 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -51,11 +51,6 @@ class CustomFieldImportForm(CSVModelForm): required=False, help_text=_('Choice set (for selection fields)') ) - ui_visibility = CSVChoiceField( - label=_('UI visibility'), - choices=CustomFieldVisibilityChoices, - help_text=_('How the custom field is displayed in the user interface') - ) ui_visible = CSVChoiceField( label=_('UI visible'), choices=CustomFieldUIVisibleChoices, @@ -74,7 +69,7 @@ class Meta: fields = ( 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum', - 'validation_maximum', 'validation_regex', 'ui_visibility', 'ui_visible', 'ui_editable', 'is_cloneable', + 'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 5cc46b49ba3..a6e4135d496 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -39,8 +39,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), (_('Attributes'), ( - 'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility', - 'ui_visible', 'ui_editable', 'is_cloneable', + 'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable', + 'is_cloneable', )), ) content_type_id = ContentTypeMultipleChoiceField( @@ -73,11 +73,6 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('Choice set') ) - ui_visibility = forms.ChoiceField( - choices=add_blank_choice(CustomFieldVisibilityChoices), - required=False, - label=_('UI visibility') - ) ui_visible = forms.ChoiceField( choices=add_blank_choice(CustomFieldUIVisibleChoices), required=False, diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index d8a6574e358..eff0edf869d 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -61,7 +61,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): (_('Custom Field'), ( 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description', )), - (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visibility', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')), + (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')), (_('Values'), ('default', 'choice_set')), (_('Validation'), ('validation_minimum', 'validation_maximum', 'validation_regex')), ) diff --git a/netbox/extras/migrations/0100_customfield_ui_attrs.py b/netbox/extras/migrations/0100_customfield_ui_attrs.py index 44de004fe9a..a4a713a865e 100644 --- a/netbox/extras/migrations/0100_customfield_ui_attrs.py +++ b/netbox/extras/migrations/0100_customfield_ui_attrs.py @@ -34,4 +34,8 @@ class Migration(migrations.Migration): code=update_ui_attrs, reverse_code=migrations.RunPython.noop ), + migrations.RemoveField( + model_name='customfield', + name='ui_visibility', + ), ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 58334bf084c..8d8ad545f1f 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -179,13 +179,6 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): blank=True, null=True ) - ui_visibility = models.CharField( - max_length=50, - choices=CustomFieldVisibilityChoices, - default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, - verbose_name=_('UI visibility'), - help_text=_('Specifies the visibility of custom field in the UI') - ) ui_visible = models.CharField( max_length=50, choices=CustomFieldUIVisibleChoices, @@ -211,7 +204,7 @@ 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', - 'choice_set', 'ui_visibility', 'ui_visible', 'ui_editable', 'is_cloneable', + 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable', ) class Meta: diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 57bb33bd744..54194c00fb8 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -71,9 +71,6 @@ class CustomFieldTable(NetBoxTable): required = columns.BooleanColumn( verbose_name=_('Required') ) - ui_visibility = columns.ChoiceFieldColumn( - verbose_name=_('UI Visibility') - ) ui_visible = columns.ChoiceFieldColumn( verbose_name=_('Visible') ) @@ -100,8 +97,8 @@ class Meta(NetBoxTable.Meta): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description', - 'search_weight', 'filter_logic', 'ui_visibility', 'ui_visible', 'ui_editable', 'is_cloneable', 'weight', - 'choice_set', 'choices', 'created', 'last_updated', + 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', 'weight', 'choice_set', + 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') From 455ca26b8cd41ead4e13e08fa7eeb37a1bdbe753 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Nov 2023 10:47:33 -0500 Subject: [PATCH 4/4] Update docs --- docs/customization/custom-fields.md | 16 ++++++++++++---- docs/models/extras/customfield.md | 25 +++++++++++++++++-------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index 1e0d5c31ef5..e9ff7bd9f60 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -40,14 +40,22 @@ Related custom fields can be grouped together within the UI by assigning each th This parameter has no effect on the API representation of custom field data. -### Visibility +### Visibility & Editing -When creating a custom field, there are three options for UI visibility. These control how and whether the custom field is displayed within the NetBox UI. +!!! info "This feature was improved in NetBox v3.7." -* **Read/write** (default): The custom field is included when viewing and editing objects. -* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.) +When creating a custom field, users can control the conditions under which it may be displayed and edited within the NetBox user interface. The following choices are available for controlling the display of a custom field on an object: + +* **Always** (default): The custom field is included when viewing an object. +* **If Set**: The custom field is included only if a value has been defined for the object. * **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users. +Additionally, the following options are available for controlling whether custom field values can be altered within the NetBox UI: + +* **Yes** (default): The custom field's value may be modified when editing an object. +* **No**: The custom field is displayed for reference when editing an object, but its value may not be modified. +* **Hidden**: The custom field is not displayed when editing an object. + Note that this setting has no impact on the REST or GraphQL APIs: Custom field data will always be available via either API. ### Validation diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index bf0c4755ac6..e68ddb79d35 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -64,16 +64,25 @@ Defines how filters are evaluated against custom field values. | Loose | Match any occurrence of the value | | Exact | Match only the complete field value | -### UI Visibility +### UI Visible -Controls how and whether the custom field is displayed within the NetBox user interface. +Controls whether the custom field is displayed for objects within the NetBox user interface. -| Option | Description | -|-------------------|--------------------------------------------------| -| Read/write | Display and permit editing (default) | -| Read-only | Display field but disallow editing | -| Hidden | Do not display field in the UI | -| Hidden (if unset) | Display in the UI only when a value has been set | +| Option | Description | +|--------|----------------------------------------------------------------| +| Always | The field is always displayed when viewing an object (default) | +| If set | The field is displayed only if a value has been defined | +| Hidden | The field is not displayed when viewing an object | + +### UI Editable + +Controls whether the custom field is editable on objects within the NetBox user interface. + +| Option | Description | +|--------|------------------------------------------------------------------------------| +| Yes | The field's value may be changed when editing an object (default) | +| No | The field's value is displayed when editing an object but may not be altered | +| Hidden | The field is not displayed when editing an object | ### Default