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 %}