jeremystretch 4 лет назад
Родитель
Сommit
fa1e28e860

+ 12 - 3
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

+ 2 - 0
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'),
     )
 
 

+ 8 - 5
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()
 

+ 1 - 1
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')),

+ 18 - 0
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'),
+        ),
+    ]

+ 38 - 3
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:

+ 124 - 111
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])
-
-        # 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])
-
-        # 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 VLANs
+        vlans = (
+            VLAN(name='VLAN 1', vid=1),
+            VLAN(name='VLAN 2', vid=2),
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        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])
+
+        # 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)

+ 12 - 2
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

+ 9 - 6
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():

+ 2 - 0
netbox/templates/inc/panels/custom_fields.html

@@ -24,6 +24,8 @@
                                     <pre>{{ value|render_json }}</pre>
                                 {% elif field.type == 'multiselect' and value %}
                                     {{ value|join:", " }}
+                                {% elif field.type == 'object' and value %}
+                                    <a href="{{ value.get_absolute_url }}">{{ value }}</a>
                                 {% elif value is not None %}
                                     {{ value }}
                                 {% elif field.required %}