Explorar o código

Closes #13299: Improve options for controlling custom field visibility (#14289)

* Add ui_visible and ui_editable fields

* Extend migration to map new visible/editable values

* Remove ui_visibility field

* Update docs
Jeremy Stretch %!s(int64=2) %!d(string=hai) anos
pai
achega
a73ba00aa0

+ 12 - 4
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

+ 17 - 8
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
 

+ 5 - 4
netbox/extras/api/serializers.py

@@ -95,15 +95,16 @@ class CustomFieldSerializer(ValidatedModelSerializer):
     filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
     data_type = serializers.SerializerMethodField()
     choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
-    ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
+    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_visible', 'ui_editable', 'is_cloneable',
+            'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
+            'created', 'last_updated',
         ]
 
     def validate_type(self, value):

+ 20 - 9
netbox/extras/choices.py

@@ -53,18 +53,29 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
     )
 
 
-class CustomFieldVisibilityChoices(ChoiceSet):
+class CustomFieldUIVisibleChoices(ChoiceSet):
 
-    VISIBILITY_READ_WRITE = 'read-write'
-    VISIBILITY_READ_ONLY = 'read-only'
-    VISIBILITY_HIDDEN = 'hidden'
-    VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset'
+    ALWAYS = 'always'
+    IF_SET = 'if-set'
+    HIDDEN = 'hidden'
 
     CHOICES = (
-        (VISIBILITY_READ_WRITE, _('Read/write')),
-        (VISIBILITY_READ_ONLY, _('Read-only')),
-        (VISIBILITY_HIDDEN, _('Hidden')),
-        (VISIBILITY_HIDDEN_IFUNSET, _('Hidden (if unset)')),
+        (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'),
     )
 
 

+ 2 - 2
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',
-            '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):

+ 9 - 5
netbox/extras/forms/bulk_edit.py

@@ -48,11 +48,15 @@ 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),
+        required=False
+    )
+    ui_editable = forms.ChoiceField(
+        label=_("UI editable"),
+        choices=add_blank_choice(CustomFieldUIEditableChoices),
+        required=False
     )
     is_cloneable = forms.NullBooleanField(
         label=_('Is cloneable'),

+ 12 - 5
netbox/extras/forms/bulk_import.py

@@ -49,10 +49,17 @@ 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,
+        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:
@@ -60,7 +67,7 @@ class CustomFieldImportForm(CSVModelForm):
         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_visible', 'ui_editable', 'is_cloneable',
         )
 
 

+ 9 - 4
netbox/extras/forms/filtersets.py

@@ -38,7 +38,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
         (None, ('q', 'filter_id')),
         (_('Attributes'), (
-            'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility',
+            'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable',
             'is_cloneable',
         )),
     )
@@ -72,10 +72,15 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         label=_('Choice set')
     )
-    ui_visibility = forms.ChoiceField(
-        choices=add_blank_choice(CustomFieldVisibilityChoices),
+    ui_visible = forms.ChoiceField(
+        choices=add_blank_choice(CustomFieldUIVisibleChoices),
         required=False,
-        label=_('UI visibility')
+        label=_('UI visible')
+    )
+    ui_editable = forms.ChoiceField(
+        choices=add_blank_choice(CustomFieldUIEditableChoices),
+        required=False,
+        label=_('UI editable')
     )
     is_cloneable = forms.NullBooleanField(
         label=_('Is cloneable'),

+ 2 - 5
netbox/extras/forms/mixins.py

@@ -2,7 +2,7 @@ from django import forms
 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 @@ class CustomFieldsMixin:
 
     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 @@ class CustomFieldsMixin:
         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)
 

+ 1 - 1
netbox/extras/forms/model_forms.py

@@ -59,7 +59,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_visible', 'ui_editable', 'weight', 'is_cloneable')),
         (_('Values'), ('default', 'choice_set')),
         (_('Validation'), ('validation_minimum', 'validation_maximum', 'validation_regex')),
     )

+ 41 - 0
netbox/extras/migrations/0100_customfield_ui_attrs.py

@@ -0,0 +1,41 @@
+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 = [
+        ('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),
+        ),
+        migrations.RunPython(
+            code=update_ui_attrs,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RemoveField(
+            model_name='customfield',
+            name='ui_visibility',
+        ),
+    ]

+ 22 - 9
netbox/extras/models/customfields.py

@@ -177,12 +177,19 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         blank=True,
         null=True
     )
-    ui_visibility = models.CharField(
+    ui_visible = 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')
+        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,
@@ -195,7 +202,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_visible', 'ui_editable', 'is_cloneable',
     )
 
     class Meta:
@@ -229,6 +236,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
             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)
@@ -379,7 +392,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
         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
@@ -504,10 +517,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
             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 = '<br />' if field.help_text else ''
-            field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> ' + _('Field is set to read-only.')
+            field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> ' + _('Field is not editable.')
 
         return field
 

+ 7 - 4
netbox/extras/tables/tables.py

@@ -71,8 +71,11 @@ class CustomFieldTable(NetBoxTable):
     required = columns.BooleanColumn(
         verbose_name=_('Required')
     )
-    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 +97,8 @@ class CustomFieldTable(NetBoxTable):
         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_visible', 'ui_editable', 'is_cloneable', 'weight', 'choice_set',
+            'choices', 'created', 'last_updated',
         )
         default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
 

+ 16 - 7
netbox/extras/tests/test_filtersets.py

@@ -40,7 +40,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 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 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 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 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 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 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 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 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 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 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
         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):

+ 7 - 6
netbox/extras/tests/test_views.py

@@ -50,15 +50,16 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             '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 = (

+ 6 - 7
netbox/netbox/forms/base.py

@@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
 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 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
 
     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)
 

+ 8 - 9
netbox/netbox/models/features.py

@@ -13,7 +13,7 @@ from taggit.managers import TaggableManager
 
 from core.choices import JobStatusChoices
 from core.models import ContentType
-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 @@ class CustomFieldsMixin(models.Model):
         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 @@ class CustomFieldsMixin(models.Model):
         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

+ 2 - 2
netbox/netbox/tables/tables.py

@@ -12,8 +12,8 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django_tables2.data import TableQuerysetData
 
+from extras.choices import *
 from extras.models import CustomField, CustomLink
-from extras.choices import CustomFieldVisibilityChoices
 from netbox.registry import registry
 from netbox.tables import columns
 from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -204,7 +204,7 @@ class NetBoxTable(BaseTable):
         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
         ])

+ 6 - 2
netbox/templates/extras/customfield.html

@@ -79,8 +79,12 @@
             <td>{{ object.weight }}</td>
           </tr>
           <tr>
-            <th scope="row">{% trans "UI Visibility" %}</th>
-            <td>{{ object.get_ui_visibility_display }}</td>
+            <th scope="row">{% trans "UI Visible" %}</th>
+            <td>{{ object.get_ui_visible_display }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "UI Editable" %}</th>
+            <td>{{ object.get_ui_editable_display }}</td>
           </tr>
         </table>
       </div>