From 7da2ce1ba53765b7a7a20139253d44f5ecb9ff94 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 17:03:41 -0500 Subject: [PATCH 1/8] Initial work on #7006 --- netbox/extras/api/customfields.py | 15 +- netbox/extras/choices.py | 2 + netbox/extras/forms/customfields.py | 13 +- netbox/extras/forms/models.py | 2 +- .../migrations/0068_custom_object_field.py | 18 ++ netbox/extras/models/customfields.py | 41 +++- netbox/extras/tests/test_customfields.py | 231 +++++++++--------- netbox/extras/tests/test_forms.py | 14 +- netbox/netbox/models.py | 15 +- .../templates/inc/panels/custom_fields.html | 2 + 10 files changed, 224 insertions(+), 129 deletions(-) create mode 100644 netbox/extras/migrations/0068_custom_object_field.py diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5cb1fc276..f2f4b69a6 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from rest_framework.fields import Field +from extras.choices import CustomFieldTypeChoices from extras.models import CustomField @@ -44,9 +45,17 @@ class CustomFieldsDataField(Field): return self._custom_fields def to_representation(self, obj): - return { - cf.name: obj.get(cf.name) for cf in self._get_custom_fields() - } + # TODO: Fix circular import + from utilities.api import get_serializer_for_model + data = {} + for cf in self._get_custom_fields(): + value = cf.deserialize(obj.get(cf.name)) + if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: + serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') + value = serializer(value, context=self.parent.context).data + data[cf.name] = value + + return data def to_internal_value(self, data): # If updating an existing instance, start with existing custom_field_data diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index cf64bc005..5c18f2705 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -16,6 +16,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_JSON = 'json' TYPE_SELECT = 'select' TYPE_MULTISELECT = 'multiselect' + TYPE_OBJECT = 'object' CHOICES = ( (TYPE_TEXT, 'Text'), @@ -27,6 +28,7 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_JSON, 'JSON'), (TYPE_SELECT, 'Selection'), (TYPE_MULTISELECT, 'Multiple selection'), + (TYPE_OBJECT, 'NetBox object'), ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index d58e6ce65..bd28a30e7 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -20,7 +20,7 @@ class CustomFieldsMixin: Extend a Form to include custom field support. """ def __init__(self, *args, **kwargs): - self.custom_fields = [] + self.custom_fields = {} super().__init__(*args, **kwargs) @@ -49,7 +49,7 @@ class CustomFieldsMixin: self.fields[field_name] = self._get_form_field(customfield) # Annotate the field in the list of CustomField form fields - self.custom_fields.append(field_name) + self.custom_fields[field_name] = customfield class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): @@ -70,12 +70,15 @@ class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): def clean(self): # Save custom field data on instance - for cf_name in self.custom_fields: + for cf_name, customfield in self.custom_fields.items(): key = cf_name[3:] # Strip "cf_" from field name value = self.cleaned_data.get(cf_name) - empty_values = self.fields[cf_name].empty_values + # Convert "empty" values to null - self.instance.custom_field_data[key] = value if value not in empty_values else None + if value in self.fields[cf_name].empty_values: + self.instance.custom_field_data[key] = None + else: + self.instance.custom_field_data[key] = customfield.serialize(value) return super().clean() diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index d75214722..55e58a7f2 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -35,7 +35,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): model = CustomField fields = '__all__' fieldsets = ( - ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')), + ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')), ('Assigned Models', ('content_types',)), ('Behavior', ('filter_logic',)), ('Values', ('default', 'choices')), diff --git a/netbox/extras/migrations/0068_custom_object_field.py b/netbox/extras/migrations/0068_custom_object_field.py new file mode 100644 index 000000000..0fa50a84d --- /dev/null +++ b/netbox/extras/migrations/0068_custom_object_field.py @@ -0,0 +1,18 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0067_configcontext_cluster_types'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='object_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 8c817ad33..fa65cbdee 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -16,7 +16,8 @@ from extras.utils import FeatureQuery, extras_features from netbox.models import ChangeLoggedModel from utilities import filters from utilities.forms import ( - CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice, + CSVChoiceField, DatePicker, DynamicModelChoiceField, LaxURLField, StaticSelectMultiple, StaticSelect, + add_blank_choice, ) from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex @@ -50,8 +51,17 @@ class CustomField(ChangeLoggedModel): type = models.CharField( max_length=50, choices=CustomFieldTypeChoices, - default=CustomFieldTypeChoices.TYPE_TEXT + default=CustomFieldTypeChoices.TYPE_TEXT, + help_text='The type of data this custom field holds' ) + object_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + blank=True, + null=True, + help_text='The type of NetBox object this field maps to (for object fields)' + ) + name = models.CharField( max_length=50, unique=True, @@ -122,7 +132,6 @@ class CustomField(ChangeLoggedModel): null=True, help_text='Comma-separated list of available choices (for selection fields)' ) - objects = CustomFieldManager() class Meta: @@ -234,6 +243,23 @@ class CustomField(ChangeLoggedModel): 'default': f"The specified default value ({self.default}) is not listed as an available choice." }) + def serialize(self, value): + """ + Prepare a value for storage as JSON data. + """ + if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None: + return value.pk + return value + + def deserialize(self, value): + """ + Convert JSON data to a Python object suitable for the field type. + """ + if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None: + model = self.object_type.model_class() + return model.objects.filter(pk=value).first() + return value + def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): """ Return a form field suitable for setting a CustomField's value for an object. @@ -300,6 +326,15 @@ class CustomField(ChangeLoggedModel): elif self.type == CustomFieldTypeChoices.TYPE_JSON: field = forms.JSONField(required=required, initial=initial) + # Object + elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: + model = self.object_type.model_class() + field = DynamicModelChoiceField( + queryset=model.objects.all(), + required=required, + initial=initial + ) + # Text else: if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index fdabe0fcf..df803ce1b 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -8,6 +8,7 @@ from dcim.forms import SiteCSVForm from dcim.models import Site, Rack from extras.choices import * from extras.models import CustomField +from ipam.models import VLAN from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -201,76 +202,67 @@ class CustomFieldAPITest(APITestCase): def setUpTestData(cls): content_type = ContentType.objects.get_for_model(Site) - # Text custom field - cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') - cls.cf_text.save() - cls.cf_text.content_types.set([content_type]) + # Create some VLANs + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + ) + VLAN.objects.bulk_create(vlans) - # Long text custom field - cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC') - cls.cf_longtext.save() - cls.cf_longtext.content_types.set([content_type]) + custom_fields = ( + CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), + CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), + CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123), + CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False), + CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), + CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), + CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'), + CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', default='Foo', choices=( + 'Foo', 'Bar', 'Baz' + )), + CustomField( + type=CustomFieldTypeChoices.TYPE_OBJECT, + name='object_field', + object_type=ContentType.objects.get_for_model(VLAN), + default=vlans[0].pk, + ), + ) + for cf in custom_fields: + cf.save() + cf.content_types.set([content_type]) - # Integer custom field - cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123) - cls.cf_integer.save() - cls.cf_integer.content_types.set([content_type]) - - # Boolean custom field - cls.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False) - cls.cf_boolean.save() - cls.cf_boolean.content_types.set([content_type]) - - # Date custom field - cls.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01') - cls.cf_date.save() - cls.cf_date.content_types.set([content_type]) - - # URL custom field - cls.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1') - cls.cf_url.save() - cls.cf_url.content_types.set([content_type]) - - # JSON custom field - cls.cf_json = CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}') - cls.cf_json.save() - cls.cf_json.content_types.set([content_type]) - - # Select custom field - cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz']) - cls.cf_select.default = 'Foo' - cls.cf_select.save() - cls.cf_select.content_types.set([content_type]) - - # Create some sites - cls.sites = ( + # Create some sites *after* creating the custom fields. This ensures that + # default values are not set for the assigned objects. + sites = ( Site(name='Site 1', slug='site-1'), Site(name='Site 2', slug='site-2'), ) - Site.objects.bulk_create(cls.sites) + Site.objects.bulk_create(sites) # Assign custom field values for site 2 - cls.sites[1].custom_field_data = { - cls.cf_text.name: 'bar', - cls.cf_longtext.name: 'DEF', - cls.cf_integer.name: 456, - cls.cf_boolean.name: True, - cls.cf_date.name: '2020-01-02', - cls.cf_url.name: 'http://example.com/2', - cls.cf_json.name: '{"foo": 1, "bar": 2}', - cls.cf_select.name: 'Bar', + sites[1].custom_field_data = { + custom_fields[0].name: 'bar', + custom_fields[1].name: 'DEF', + custom_fields[2].name: 456, + custom_fields[3].name: True, + custom_fields[4].name: '2020-01-02', + custom_fields[5].name: 'http://example.com/2', + custom_fields[6].name: '{"foo": 1, "bar": 2}', + custom_fields[7].name: 'Bar', + custom_fields[8].name: vlans[1].pk, } - cls.sites[1].save() + sites[1].save() def test_get_single_object_without_custom_field_data(self): """ Validate that custom fields are present on an object even if it has no values defined. """ - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk}) + site1 = Site.objects.get(name='Site 1') + url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk}) self.add_permissions('dcim.view_site') response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.sites[0].name) + self.assertEqual(response.data['name'], site1.name) self.assertEqual(response.data['custom_fields'], { 'text_field': None, 'longtext_field': None, @@ -280,18 +272,20 @@ class CustomFieldAPITest(APITestCase): 'url_field': None, 'json_field': None, 'choice_field': None, + 'object_field': None, }) def test_get_single_object_with_custom_field_data(self): """ Validate that custom fields are present and correctly set for an object with values defined. """ - site2_cfvs = self.sites[1].custom_field_data - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + site2_cfvs = site2.custom_field_data + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.view_site') response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.sites[1].name) + self.assertEqual(response.data['name'], site2.name) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field']) self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) @@ -300,11 +294,15 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) + self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field']) def test_create_single_object_with_defaults(self): """ Create a new site with no specified custom field values and check that it received the default values. """ + cf_defaults = { + cf.name: cf.default for cf in CustomField.objects.all() + } data = { 'name': 'Site 3', 'slug': 'site-3', @@ -317,25 +315,27 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data['custom_fields'] - self.assertEqual(response_cf['text_field'], self.cf_text.default) - self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) - self.assertEqual(response_cf['number_field'], self.cf_integer.default) - self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) - self.assertEqual(response_cf['date_field'], self.cf_date.default) - self.assertEqual(response_cf['url_field'], self.cf_url.default) - self.assertEqual(response_cf['json_field'], self.cf_json.default) - self.assertEqual(response_cf['choice_field'], self.cf_select.default) + self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) + self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) + self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) + self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) + self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) # Validate database data site = Site.objects.get(pk=response.data['id']) - self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) - self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) - self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) - self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) - self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) - self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) - self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) + self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) + self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) + self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) + self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) + self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) def test_create_single_object_with_values(self): """ @@ -353,6 +353,7 @@ class CustomFieldAPITest(APITestCase): 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', + 'object_field': VLAN.objects.get(vid=2).pk, }, } url = reverse('dcim-api:site-list') @@ -372,6 +373,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['json_field'], data_cf['json_field']) self.assertEqual(response_cf['choice_field'], data_cf['choice_field']) + self.assertEqual(response_cf['object_field']['id'], data_cf['object_field']) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -383,12 +385,16 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field']) self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) + self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field']) def test_create_multiple_objects_with_defaults(self): """ - Create three news sites with no specified custom field values and check that each received + Create three new sites with no specified custom field values and check that each received the default custom field values. """ + cf_defaults = { + cf.name: cf.default for cf in CustomField.objects.all() + } data = ( { 'name': 'Site 3', @@ -414,25 +420,27 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data[i]['custom_fields'] - self.assertEqual(response_cf['text_field'], self.cf_text.default) - self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) - self.assertEqual(response_cf['number_field'], self.cf_integer.default) - self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) - self.assertEqual(response_cf['date_field'], self.cf_date.default) - self.assertEqual(response_cf['url_field'], self.cf_url.default) - self.assertEqual(response_cf['json_field'], self.cf_json.default) - self.assertEqual(response_cf['choice_field'], self.cf_select.default) + self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) + self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) + self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) + self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) + self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) - self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) - self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) - self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) - self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) - self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) - self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) - self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) + self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) + self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) + self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) + self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) + self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) def test_create_multiple_objects_with_values(self): """ @@ -447,6 +455,7 @@ class CustomFieldAPITest(APITestCase): 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', + 'object_field': VLAN.objects.get(vid=2).pk, } data = ( { @@ -501,15 +510,15 @@ class CustomFieldAPITest(APITestCase): Update an object with existing custom field values. Ensure that only the updated custom field values are modified. """ - site = self.sites[1] - original_cfvs = {**site.custom_field_data} + site2 = Site.objects.get(name='Site 2') + original_cfvs = {**site2.custom_field_data} data = { 'custom_fields': { 'text_field': 'ABCD', 'number_field': 1234, }, } - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') response = self.client.patch(url, data, format='json', **self.header) @@ -527,23 +536,25 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) # Validate database data - site.refresh_from_db() - self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field']) - self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field']) - self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field']) - self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field']) - self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field']) - self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) - self.assertEqual(site.custom_field_data['json_field'], original_cfvs['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field']) + site2.refresh_from_db() + self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field']) + self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field']) + self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field']) + self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) + self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) + self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) + self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_field']) def test_minimum_maximum_values_validation(self): - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - self.cf_integer.validation_minimum = 10 - self.cf_integer.validation_maximum = 20 - self.cf_integer.save() + cf_integer = CustomField.objects.get(name='number_field') + cf_integer.validation_minimum = 10 + cf_integer.validation_maximum = 20 + cf_integer.save() data = {'custom_fields': {'number_field': 9}} response = self.client.patch(url, data, format='json', **self.header) @@ -558,11 +569,13 @@ class CustomFieldAPITest(APITestCase): self.assertHttpStatus(response, status.HTTP_200_OK) def test_regex_validation(self): - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters - self.cf_text.save() + cf_text = CustomField.objects.get(name='text_field') + cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters + cf_text.save() data = {'custom_fields': {'text_field': 'ABC123'}} response = self.client.patch(url, data, format='json', **self.header) diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index cf28a46e7..e8b16d7ab 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -38,10 +38,20 @@ class CustomFieldModelFormTest(TestCase): cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES) cf_select.content_types.set([obj_type]) - cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, - choices=CHOICES) + cf_multiselect = CustomField.objects.create( + name='multiselect', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + choices=CHOICES + ) cf_multiselect.content_types.set([obj_type]) + cf_object = CustomField.objects.create( + name='object', + type=CustomFieldTypeChoices.TYPE_OBJECT, + object_type=ContentType.objects.get_for_model(Site) + ) + cf_object.content_types.set([obj_type]) + def test_empty_values(self): """ Test that empty custom field values are stored as null diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 91240ee90..3e6ebd8b2 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -1,5 +1,4 @@ import logging -from collections import OrderedDict from django.contrib.contenttypes.fields import GenericRelation from django.core.serializers.json import DjangoJSONEncoder @@ -99,16 +98,20 @@ class CustomFieldsMixin(models.Model): """ from extras.models import CustomField - fields = CustomField.objects.get_for_model(self) - return OrderedDict([ - (field, self.custom_field_data.get(field.name)) for field in fields - ]) + data = {} + for field in CustomField.objects.get_for_model(self): + value = self.custom_field_data.get(field.name) + data[field] = field.deserialize(value) + + return data def clean(self): super().clean() from extras.models import CustomField - custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)} + custom_fields = { + cf.name: cf for cf in CustomField.objects.get_for_model(self) + } # Validate all field values for field_name, value in self.custom_field_data.items(): diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index b48a43f1c..46636504e 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -24,6 +24,8 @@
{{ value|render_json }}
{% elif field.type == 'multiselect' and value %} {{ value|join:", " }} + {% elif field.type == 'object' and value %} + {{ value }} {% elif value is not None %} {{ value }} {% elif field.required %} From b9b70fa052eac1295c4e83f2b42ff6643cc42560 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 17:07:37 -0500 Subject: [PATCH 2/8] Reindex migrations --- ...{0068_custom_object_field.py => 0069_custom_object_field.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename netbox/extras/migrations/{0068_custom_object_field.py => 0069_custom_object_field.py} (89%) diff --git a/netbox/extras/migrations/0068_custom_object_field.py b/netbox/extras/migrations/0069_custom_object_field.py similarity index 89% rename from netbox/extras/migrations/0068_custom_object_field.py rename to netbox/extras/migrations/0069_custom_object_field.py index 0fa50a84d..720e21edc 100644 --- a/netbox/extras/migrations/0068_custom_object_field.py +++ b/netbox/extras/migrations/0069_custom_object_field.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0067_configcontext_cluster_types'), + ('extras', '0068_configcontext_cluster_types'), ] operations = [ From 7bd4a5774a028868d8d67ed5022c4ad65d7f66c2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Jan 2022 17:05:54 -0500 Subject: [PATCH 3/8] Extend to support the assignment of multiple objects per field --- netbox/extras/api/customfields.py | 3 ++ netbox/extras/choices.py | 4 +- netbox/extras/models/customfields.py | 27 +++++++++--- netbox/extras/tests/test_customfields.py | 42 +++++++++++++++++++ .../templates/inc/panels/custom_fields.html | 16 +++++-- 5 files changed, 82 insertions(+), 10 deletions(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index f2f4b69a6..fd6e1f550 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -53,6 +53,9 @@ class CustomFieldsDataField(Field): if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') value = serializer(value, context=self.parent.context).data + elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') + value = serializer(value, many=True, context=self.parent.context).data data[cf.name] = value return data diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 5c18f2705..0632c2b1f 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -17,6 +17,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_SELECT = 'select' TYPE_MULTISELECT = 'multiselect' TYPE_OBJECT = 'object' + TYPE_MULTIOBJECT = 'multiobject' CHOICES = ( (TYPE_TEXT, 'Text'), @@ -28,7 +29,8 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_JSON, 'JSON'), (TYPE_SELECT, 'Selection'), (TYPE_MULTISELECT, 'Multiple selection'), - (TYPE_OBJECT, 'NetBox object'), + (TYPE_OBJECT, 'Object'), + (TYPE_MULTIOBJECT, 'Multiple objects'), ) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index fa65cbdee..99c483857 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -16,8 +16,8 @@ from extras.utils import FeatureQuery, extras_features from netbox.models import ChangeLoggedModel from utilities import filters from utilities.forms import ( - CSVChoiceField, DatePicker, DynamicModelChoiceField, LaxURLField, StaticSelectMultiple, StaticSelect, - add_blank_choice, + CSVChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, LaxURLField, + StaticSelectMultiple, StaticSelect, add_blank_choice, ) from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex @@ -61,7 +61,6 @@ class CustomField(ChangeLoggedModel): null=True, help_text='The type of NetBox object this field maps to (for object fields)' ) - name = models.CharField( max_length=50, unique=True, @@ -247,17 +246,26 @@ class CustomField(ChangeLoggedModel): """ Prepare a value for storage as JSON data. """ - if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None: + if value is None: + return value + if self.type == CustomFieldTypeChoices.TYPE_OBJECT: return value.pk + if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + return [obj.pk for obj in value] return value def deserialize(self, value): """ Convert JSON data to a Python object suitable for the field type. """ - if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None: + if value is None: + return value + if self.type == CustomFieldTypeChoices.TYPE_OBJECT: model = self.object_type.model_class() return model.objects.filter(pk=value).first() + if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + model = self.object_type.model_class() + return model.objects.filter(pk__in=value) return value def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): @@ -335,6 +343,15 @@ class CustomField(ChangeLoggedModel): initial=initial ) + # Multiple objects + elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + model = self.object_type.model_class() + field = DynamicModelMultipleChoiceField( + queryset=model.objects.all(), + required=required, + initial=initial + ) + # Text else: if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index df803ce1b..657c597f2 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -206,6 +206,9 @@ class CustomFieldAPITest(APITestCase): vlans = ( VLAN(name='VLAN 1', vid=1), VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=3), + VLAN(name='VLAN 4', vid=4), + VLAN(name='VLAN 5', vid=5), ) VLAN.objects.bulk_create(vlans) @@ -226,6 +229,12 @@ class CustomFieldAPITest(APITestCase): object_type=ContentType.objects.get_for_model(VLAN), default=vlans[0].pk, ), + CustomField( + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + name='multiobject_field', + object_type=ContentType.objects.get_for_model(VLAN), + default=[vlans[0].pk, vlans[1].pk], + ), ) for cf in custom_fields: cf.save() @@ -250,6 +259,7 @@ class CustomFieldAPITest(APITestCase): custom_fields[6].name: '{"foo": 1, "bar": 2}', custom_fields[7].name: 'Bar', custom_fields[8].name: vlans[1].pk, + custom_fields[9].name: [vlans[2].pk, vlans[3].pk], } sites[1].save() @@ -273,6 +283,7 @@ class CustomFieldAPITest(APITestCase): 'json_field': None, 'choice_field': None, 'object_field': None, + 'multiobject_field': None, }) def test_get_single_object_with_custom_field_data(self): @@ -295,6 +306,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field']) + self.assertEqual( + [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], + site2_cfvs['multiobject_field'] + ) def test_create_single_object_with_defaults(self): """ @@ -324,6 +339,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) + self.assertEqual( + [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], + cf_defaults['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -336,6 +355,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) def test_create_single_object_with_values(self): """ @@ -354,6 +374,7 @@ class CustomFieldAPITest(APITestCase): 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', 'object_field': VLAN.objects.get(vid=2).pk, + 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), }, } url = reverse('dcim-api:site-list') @@ -374,6 +395,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['json_field'], data_cf['json_field']) self.assertEqual(response_cf['choice_field'], data_cf['choice_field']) self.assertEqual(response_cf['object_field']['id'], data_cf['object_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + data_cf['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -386,6 +411,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field']) self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field']) def test_create_multiple_objects_with_defaults(self): """ @@ -429,6 +455,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + cf_defaults['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) @@ -441,6 +471,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) def test_create_multiple_objects_with_values(self): """ @@ -456,6 +487,7 @@ class CustomFieldAPITest(APITestCase): 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', 'object_field': VLAN.objects.get(vid=2).pk, + 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), } data = ( { @@ -493,6 +525,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) self.assertEqual(response_cf['json_field'], custom_field_data['json_field']) self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + custom_field_data['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) @@ -504,6 +540,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field']) self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field']) def test_update_single_object_with_values(self): """ @@ -534,6 +571,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) self.assertEqual(response_cf['json_field'], original_cfvs['json_field']) self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + original_cfvs['multiobject_field'] + ) # Validate database data site2.refresh_from_db() @@ -545,6 +586,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_field']) + self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field']) def test_minimum_maximum_values_validation(self): site2 = Site.objects.get(name='Site 2') diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 46636504e..c8838fa80 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -3,14 +3,14 @@ {% with custom_fields=object.get_custom_fields %} {% if custom_fields %}
-
- Custom Fields -
+
Custom Fields
{% for field, value in custom_fields.items %} - +
{{ field }} + {{ field }} + {% if field.type == 'longtext' and value %} {{ value|render_markdown }} @@ -26,6 +26,14 @@ {{ value|join:", " }} {% elif field.type == 'object' and value %} {{ value }} + {% elif field.type == 'multiobject' and value %} + {% if value %} +
    + {% for obj in value %} +
  • {{ obj }}
  • + {% endfor %} +
+ {% endif %} {% elif value is not None %} {{ value }} {% elif field.required %} From 397bbaf44d3f2039f299683903629c356d08bdc4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Jan 2022 21:04:44 -0500 Subject: [PATCH 4/8] Fix bulk editing for custom object fields --- netbox/netbox/views/generic/bulk_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 6f21a6879..d76bc598d 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -285,7 +285,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): return get_permission_for_model(self.queryset.model, 'change') def _update_objects(self, form, request): - custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] + custom_fields = getattr(form, 'custom_fields', []) standard_fields = [ field for field in form.fields if field not in custom_fields + ['pk'] ] @@ -327,7 +327,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if name in form.nullable_fields and name in nullified_fields: obj.custom_field_data[name] = None elif name in form.changed_data: - obj.custom_field_data[name] = form.cleaned_data[name] + obj.custom_field_data[name] = form.fields[name].prepare_value(form.cleaned_data[name]) obj.full_clean() obj.save() From 7f1028a8707b0f873cc1d6c36d4581472d577eb9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Jan 2022 21:21:23 -0500 Subject: [PATCH 5/8] Fix tests --- netbox/extras/models/customfields.py | 2 +- netbox/extras/tests/test_forms.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 99c483857..851680d8e 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -251,7 +251,7 @@ class CustomField(ChangeLoggedModel): if self.type == CustomFieldTypeChoices.TYPE_OBJECT: return value.pk if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - return [obj.pk for obj in value] + return [obj.pk for obj in value] or None return value def deserialize(self, value): diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index e8b16d7ab..1ec50b7dd 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -52,6 +52,13 @@ class CustomFieldModelFormTest(TestCase): ) cf_object.content_types.set([obj_type]) + cf_multiobject = CustomField.objects.create( + name='multiobject', + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + object_type=ContentType.objects.get_for_model(Site) + ) + cf_multiobject.content_types.set([obj_type]) + def test_empty_values(self): """ Test that empty custom field values are stored as null From 44415cf2d9e2b4dc1fa150a20bab4c624d142d11 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Jan 2022 13:24:37 -0500 Subject: [PATCH 6/8] Clean up & extend custom field tests --- netbox/extras/tests/test_customfields.py | 382 ++++++++++++++++------- 1 file changed, 270 insertions(+), 112 deletions(-) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 657c597f2..9191c1c5b 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -15,7 +15,8 @@ from virtualization.models import VirtualMachine class CustomFieldTest(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): Site.objects.bulk_create([ Site(name='Site A', slug='site-a'), @@ -23,137 +24,294 @@ class CustomFieldTest(TestCase): Site(name='Site C', slug='site-c'), ]) - def test_simple_fields(self): - DATA = ( - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_TEXT, - }, - 'value': 'Foobar!', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_LONGTEXT, - }, - 'value': 'Text with **Markdown**', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_INTEGER, - }, - 'value': 0, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_INTEGER, - 'validation_minimum': 1, - 'validation_maximum': 100, - }, - 'value': 42, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_INTEGER, - 'validation_minimum': -100, - 'validation_maximum': -1, - }, - 'value': -42, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_BOOLEAN, - }, - 'value': True, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_BOOLEAN, - }, - 'value': False, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_DATE, - }, - 'value': '2016-06-23', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_URL, - }, - 'value': 'http://example.com/', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_JSON, - }, - 'value': '{"foo": 1, "bar": 2}', - }, + cls.object_type = ContentType.objects.get_for_model(Site) + + def test_text_field(self): + value = 'Foobar!' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='text_field', + type=CustomFieldTypeChoices.TYPE_TEXT, + required=False ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - obj_type = ContentType.objects.get_for_model(Site) + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) - for data in DATA: + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) - # Create a custom field - cf = CustomField(name='my_field', required=False, **data['field']) - cf.save() - cf.content_types.set([obj_type]) + def test_longtext_field(self): + value = 'A' * 256 - # Check that the field has a null initial value - site = Site.objects.first() - self.assertIsNone(site.custom_field_data[cf.name]) + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='longtext_field', + type=CustomFieldTypeChoices.TYPE_LONGTEXT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - # Assign a value to the first Site - site.custom_field_data[cf.name] = data['value'] - site.save() + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) - # Retrieve the stored value - site.refresh_from_db() - self.assertEqual(site.custom_field_data[cf.name], data['value']) + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) - # Delete the stored value - site.custom_field_data.pop(cf.name) - site.save() - site.refresh_from_db() - self.assertIsNone(site.custom_field_data.get(cf.name)) + def test_integer_field(self): - # Delete the custom field - cf.delete() + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='integer_field', + type=CustomFieldTypeChoices.TYPE_INTEGER, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + for value in (123456, 0, -123456): + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_boolean_field(self): + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='boolean_field', + type=CustomFieldTypeChoices.TYPE_INTEGER, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + for value in (True, False): + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_date_field(self): + value = '2016-06-23' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='date_field', + type=CustomFieldTypeChoices.TYPE_TEXT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_url_field(self): + value = 'http://example.com/' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='url_field', + type=CustomFieldTypeChoices.TYPE_URL, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_json_field(self): + value = '{"foo": 1, "bar": 2}' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='json_field', + type=CustomFieldTypeChoices.TYPE_JSON, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) def test_select_field(self): - obj_type = ContentType.objects.get_for_model(Site) + CHOICES = ('Option A', 'Option B', 'Option C') + value = CHOICES[1] - # Create a custom field - cf = CustomField( + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='select_field', type=CustomFieldTypeChoices.TYPE_SELECT, - name='my_field', required=False, - choices=['Option A', 'Option B', 'Option C'] + choices=CHOICES ) - cf.save() - cf.content_types.set([obj_type]) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - # Check that the field has a null initial value - site = Site.objects.first() - self.assertIsNone(site.custom_field_data[cf.name]) + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) - # Assign a value to the first Site - site.custom_field_data[cf.name] = 'Option A' - site.save() + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) - # Retrieve the stored value - site.refresh_from_db() - self.assertEqual(site.custom_field_data[cf.name], 'Option A') + def test_multiselect_field(self): + CHOICES = ['Option A', 'Option B', 'Option C'] + value = [CHOICES[1], CHOICES[2]] - # Delete the stored value - site.custom_field_data.pop(cf.name) - site.save() - site.refresh_from_db() - self.assertIsNone(site.custom_field_data.get(cf.name)) + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='multiselect_field', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + required=False, + choices=CHOICES + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - # Delete the custom field - cf.delete() + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_object_field(self): + value = VLAN.objects.create(name='VLAN 1', vid=1).pk + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='object_field', + type=CustomFieldTypeChoices.TYPE_OBJECT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_multiobject_field(self): + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=3), + ) + VLAN.objects.bulk_create(vlans) + value = [vlan.pk for vlan in vlans] + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='object_field', + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) def test_rename_customfield(self): obj_type = ContentType.objects.get_for_model(Site) From e8ec8ac5a411b941db6e8aa468a97835d091d406 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Jan 2022 13:43:40 -0500 Subject: [PATCH 7/8] Add object_type validation --- netbox/extras/models/customfields.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 851680d8e..00c68939d 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -242,6 +242,17 @@ class CustomField(ChangeLoggedModel): 'default': f"The specified default value ({self.default}) is not listed as an available choice." }) + # Object fields must define an object_type; other fields must not + if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT): + if not self.object_type: + raise ValidationError({ + 'object_type': "Object fields must define an object type." + }) + elif self.object_type: + raise ValidationError({ + 'object_type': f"{self.get_type_display()} fields may not define an object type." + }) + def serialize(self, value): """ Prepare a value for storage as JSON data. From c19f590120edeeaa651525a9024664ae223b9de8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Jan 2022 13:44:21 -0500 Subject: [PATCH 8/8] Documentation and changelog for #7006 --- docs/models/extras/customfield.md | 6 ++++++ docs/release-notes/version-3.2.md | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index e3462a6a7..da73816b6 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -19,6 +19,8 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net * JSON: Arbitrary data stored in JSON format * Selection: A selection of one of several pre-defined custom choices * Multiple selection: A selection field which supports the assignment of multiple values +* Object: A single NetBox object of the type defined by `object_type` +* Multiple object: One or more NetBox objects of the type defined by `object_type` Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. @@ -41,3 +43,7 @@ NetBox supports limited custom validation for custom field values. Following are Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. + +### Custom Object Fields + +An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index d2702cfda..6240016cf 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -22,6 +22,12 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups//available- A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. +#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) + +Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type. + +Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided for each custom field object. + #### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed. @@ -96,6 +102,8 @@ Inventory item templates can be arranged hierarchically within a device type, an * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields * extras.ConfigContext * Add `cluster_types` field +* extras.CustomField + * Added `object_type` field * ipam.VLANGroup * Added the `/availables-vlans/` endpoint * Added the `min_vid` and `max_vid` fields