Răsfoiți Sursa

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 2 ani în urmă
părinte
comite
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.
 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.
 * **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.
 Note that this setting has no impact on the REST or GraphQL APIs: Custom field data will always be available via either API.
 
 
 ### Validation
 ### 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   |
 | Loose    | Match any occurrence of the value   |
 | Exact    | Match only the complete field 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
 ### Default
 
 

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

@@ -95,15 +95,16 @@ class CustomFieldSerializer(ValidatedModelSerializer):
     filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
     filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
     data_type = serializers.SerializerMethodField()
     data_type = serializers.SerializerMethodField()
     choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
     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:
     class Meta:
         model = CustomField
         model = CustomField
         fields = [
         fields = [
             'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
             '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):
     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 = (
     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:
     class Meta:
         model = CustomField
         model = CustomField
         fields = [
         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):
     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(),
         queryset=CustomFieldChoiceSet.objects.all(),
         required=False
         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(
     is_cloneable = forms.NullBooleanField(
         label=_('Is cloneable'),
         label=_('Is cloneable'),

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

@@ -49,10 +49,17 @@ class CustomFieldImportForm(CSVModelForm):
         required=False,
         required=False,
         help_text=_('Choice set (for selection fields)')
         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:
     class Meta:
@@ -60,7 +67,7 @@ class CustomFieldImportForm(CSVModelForm):
         fields = (
         fields = (
             'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
             'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
             'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
             '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 = (
     fieldsets = (
         (None, ('q', 'filter_id')),
         (None, ('q', 'filter_id')),
         (_('Attributes'), (
         (_('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',
             'is_cloneable',
         )),
         )),
     )
     )
@@ -72,10 +72,15 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         required=False,
         label=_('Choice set')
         label=_('Choice set')
     )
     )
-    ui_visibility = forms.ChoiceField(
-        choices=add_blank_choice(CustomFieldVisibilityChoices),
+    ui_visible = forms.ChoiceField(
+        choices=add_blank_choice(CustomFieldUIVisibleChoices),
         required=False,
         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(
     is_cloneable = forms.NullBooleanField(
         label=_('Is cloneable'),
         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.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from extras.choices import CustomFieldVisibilityChoices
+from extras.choices import *
 from extras.models import *
 from extras.models import *
 from utilities.forms.fields import DynamicModelMultipleChoiceField
 from utilities.forms.fields import DynamicModelMultipleChoiceField
 
 
@@ -40,7 +40,7 @@ class CustomFieldsMixin:
 
 
     def _get_custom_fields(self, content_type):
     def _get_custom_fields(self, content_type):
         return CustomField.objects.filter(content_types=content_type).exclude(
         return CustomField.objects.filter(content_types=content_type).exclude(
-            ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
+            ui_visible=CustomFieldUIVisibleChoices.HIDDEN
         )
         )
 
 
     def _get_form_field(self, customfield):
     def _get_form_field(self, customfield):
@@ -51,9 +51,6 @@ class CustomFieldsMixin:
         Append form fields for all CustomFields assigned to this object type.
         Append form fields for all CustomFields assigned to this object type.
         """
         """
         for customfield in self._get_custom_fields(self._get_content_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}'
             field_name = f'cf_{customfield.name}'
             self.fields[field_name] = self._get_form_field(customfield)
             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'), (
         (_('Custom Field'), (
             'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
             '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')),
         (_('Values'), ('default', 'choice_set')),
         (_('Validation'), ('validation_minimum', 'validation_maximum', 'validation_regex')),
         (_('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,
         blank=True,
         null=True
         null=True
     )
     )
-    ui_visibility = models.CharField(
+    ui_visible = models.CharField(
         max_length=50,
         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(
     is_cloneable = models.BooleanField(
         default=False,
         default=False,
@@ -195,7 +202,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     clone_fields = (
     clone_fields = (
         'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
         'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
         'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
         '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:
     class Meta:
@@ -229,6 +236,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
             return self.choice_set.choices
             return self.choice_set.choices
         return []
         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):
     def get_choice_label(self, value):
         if not hasattr(self, '_choice_map'):
         if not hasattr(self, '_choice_map'):
             self._choice_map = dict(self.choices)
             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.
         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_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.
         for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
         """
         """
         initial = self.default if set_initial else None
         initial = self.default if set_initial else None
@@ -504,10 +517,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
             field.help_text = render_markdown(self.description)
             field.help_text = render_markdown(self.description)
 
 
         # Annotate read-only fields
         # 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
             field.disabled = True
             prepend = '<br />' if field.help_text else ''
             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
         return field
 
 

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

@@ -71,8 +71,11 @@ class CustomFieldTable(NetBoxTable):
     required = columns.BooleanColumn(
     required = columns.BooleanColumn(
         verbose_name=_('Required')
         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(
     description = columns.MarkdownColumn(
         verbose_name=_('Description')
         verbose_name=_('Description')
@@ -94,8 +97,8 @@ class CustomFieldTable(NetBoxTable):
         model = CustomField
         model = CustomField
         fields = (
         fields = (
             'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
             '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')
         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,
                 required=True,
                 weight=100,
                 weight=100,
                 filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE,
                 filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE,
-                ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
+                ui_visible=CustomFieldUIVisibleChoices.ALWAYS,
+                ui_editable=CustomFieldUIEditableChoices.YES
             ),
             ),
             CustomField(
             CustomField(
                 name='Custom Field 2',
                 name='Custom Field 2',
@@ -48,7 +49,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 required=False,
                 required=False,
                 weight=200,
                 weight=200,
                 filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT,
                 filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT,
-                ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY
+                ui_visible=CustomFieldUIVisibleChoices.IF_SET,
+                ui_editable=CustomFieldUIEditableChoices.NO
             ),
             ),
             CustomField(
             CustomField(
                 name='Custom Field 3',
                 name='Custom Field 3',
@@ -56,7 +58,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 required=False,
                 required=False,
                 weight=300,
                 weight=300,
                 filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
                 filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
-                ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
+                ui_visible=CustomFieldUIVisibleChoices.HIDDEN,
+                ui_editable=CustomFieldUIEditableChoices.HIDDEN
             ),
             ),
             CustomField(
             CustomField(
                 name='Custom Field 4',
                 name='Custom Field 4',
@@ -64,7 +67,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 required=False,
                 required=False,
                 weight=400,
                 weight=400,
                 filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
                 filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
-                ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
+                ui_visible=CustomFieldUIVisibleChoices.HIDDEN,
+                ui_editable=CustomFieldUIEditableChoices.HIDDEN,
                 choice_set=choice_sets[0]
                 choice_set=choice_sets[0]
             ),
             ),
             CustomField(
             CustomField(
@@ -73,7 +77,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 required=False,
                 required=False,
                 weight=500,
                 weight=500,
                 filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
                 filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
-                ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
+                ui_visible=CustomFieldUIVisibleChoices.HIDDEN,
+                ui_editable=CustomFieldUIEditableChoices.HIDDEN,
                 choice_set=choice_sets[1]
                 choice_set=choice_sets[1]
             ),
             ),
         )
         )
@@ -106,8 +111,12 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
         params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE}
         params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_choice_set(self):
     def test_choice_set(self):

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

@@ -50,15 +50,16 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'default': None,
             'default': None,
             'weight': 200,
             'weight': 200,
             'required': True,
             'required': True,
-            'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
+            'ui_visible': CustomFieldUIVisibleChoices.ALWAYS,
+            'ui_editable': CustomFieldUIEditableChoices.YES,
         }
         }
 
 
         cls.csv_data = (
         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 = (
         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.db.models import Q
 from django.utils.translation import gettext_lazy as _
 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.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
 from extras.models import CustomField, Tag
 from extras.models import CustomField, Tag
 from utilities.forms import CSVModelForm
 from utilities.forms import CSVModelForm
@@ -76,11 +76,9 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
     )
     )
 
 
     def _get_custom_fields(self, content_type):
     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):
     def _get_form_field(self, customfield):
@@ -131,7 +129,8 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
 
 
     def _extend_nullable_fields(self):
     def _extend_nullable_fields(self):
         nullable_custom_fields = [
         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)
         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.choices import JobStatusChoices
 from core.models import ContentType
 from core.models import ContentType
-from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
+from extras.choices import *
 from extras.utils import is_taggable, register_features
 from extras.utils import is_taggable, register_features
 from netbox.registry import registry
 from netbox.registry import registry
 from netbox.signals import post_clean
 from netbox.signals import post_clean
@@ -205,12 +205,11 @@ class CustomFieldsMixin(models.Model):
         for field in CustomField.objects.get_for_model(self):
         for field in CustomField.objects.get_for_model(self):
             value = self.custom_field_data.get(field.name)
             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)
             data[field] = field.deserialize(value)
 
 
@@ -232,12 +231,12 @@ class CustomFieldsMixin(models.Model):
         from extras.models import CustomField
         from extras.models import CustomField
         groups = defaultdict(dict)
         groups = defaultdict(dict)
         visible_custom_fields = CustomField.objects.get_for_model(self).exclude(
         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:
         for cf in visible_custom_fields:
             value = self.custom_field_data.get(cf.name)
             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
                 continue
             value = cf.deserialize(value)
             value = cf.deserialize(value)
             groups[cf.group_name][cf] = 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.utils.translation import gettext_lazy as _
 from django_tables2.data import TableQuerysetData
 from django_tables2.data import TableQuerysetData
 
 
+from extras.choices import *
 from extras.models import CustomField, CustomLink
 from extras.models import CustomField, CustomLink
-from extras.choices import CustomFieldVisibilityChoices
 from netbox.registry import registry
 from netbox.registry import registry
 from netbox.tables import columns
 from netbox.tables import columns
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 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)
         content_type = ContentType.objects.get_for_model(self._meta.model)
         custom_fields = CustomField.objects.filter(
         custom_fields = CustomField.objects.filter(
             content_types=content_type
             content_types=content_type
-        ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN)
+        ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN)
         extra_columns.extend([
         extra_columns.extend([
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
             (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>
             <td>{{ object.weight }}</td>
           </tr>
           </tr>
           <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>
           </tr>
         </table>
         </table>
       </div>
       </div>