Skip to content

Commit

Permalink
Closes #13299: Improve options for controlling custom field visibility (
Browse files Browse the repository at this point in the history
#14289)

* Add ui_visible and ui_editable fields

* Extend migration to map new visible/editable values

* Remove ui_visibility field

* Update docs
  • Loading branch information
jeremystretch authored Nov 20, 2023
1 parent 549b0ea commit a73ba00
Show file tree
Hide file tree
Showing 19 changed files with 204 additions and 93 deletions.
16 changes: 12 additions & 4 deletions docs/customization/custom-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 17 additions & 8 deletions docs/models/extras/customfield.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 5 additions & 4 deletions netbox/extras/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
29 changes: 20 additions & 9 deletions netbox/extras/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
)


Expand Down
4 changes: 2 additions & 2 deletions netbox/extras/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
14 changes: 9 additions & 5 deletions netbox/extras/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
17 changes: 12 additions & 5 deletions netbox/extras/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,25 @@ 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:
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_visible', 'ui_editable', 'is_cloneable',
)


Expand Down
13 changes: 9 additions & 4 deletions netbox/extras/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)),
)
Expand Down Expand Up @@ -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'),
Expand Down
7 changes: 2 additions & 5 deletions netbox/extras/forms/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion netbox/extras/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
)
Expand Down
41 changes: 41 additions & 0 deletions netbox/extras/migrations/0100_customfield_ui_attrs.py
Original file line number Diff line number Diff line change
@@ -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',
),
]
31 changes: 22 additions & 9 deletions netbox/extras/models/customfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -229,6 +236,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)
Expand Down Expand Up @@ -379,7 +392,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
Expand Down Expand Up @@ -504,10 +517,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 = '<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

Expand Down
11 changes: 7 additions & 4 deletions netbox/extras/tables/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -94,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', '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')

Expand Down
Loading

0 comments on commit a73ba00

Please sign in to comment.