Add ui_visible and ui_editable fields

This commit is contained in:
Jeremy Stretch 2023-11-16 09:12:58 -05:00
parent 840b7d804c
commit b1a60ca894
17 changed files with 167 additions and 52 deletions

View File

@ -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):

View File

@ -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'

View File

@ -88,7 +88,7 @@ class CustomFieldFilterSet(BaseFilterSet):
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):

View File

@ -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,

View File

@ -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',
)

View File

@ -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,

View File

@ -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)

View File

@ -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')),
)

View File

@ -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),
),
]

View File

@ -10,7 +10,6 @@ from django.contrib.postgres.fields import ArrayField
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 @@ 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)
@ -382,7 +401,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
@ -507,10 +526,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

View File

@ -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 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_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')

View File

@ -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):

View File

@ -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 = (

View File

@ -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)

View File

@ -13,7 +13,7 @@ from django.utils.translation import gettext_lazy as _
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 @@ 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

View File

@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
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 @@ 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

View File

@ -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>