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

+ 12 - 3
netbox/extras/api/customfields.py

@@ -1,6 +1,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from rest_framework.fields import Field
 from rest_framework.fields import Field
 
 
+from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField
 from extras.models import CustomField
 
 
 
 
@@ -44,9 +45,17 @@ class CustomFieldsDataField(Field):
         return self._custom_fields
         return self._custom_fields
 
 
     def to_representation(self, obj):
     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):
     def to_internal_value(self, data):
         # If updating an existing instance, start with existing custom_field_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_JSON = 'json'
     TYPE_SELECT = 'select'
     TYPE_SELECT = 'select'
     TYPE_MULTISELECT = 'multiselect'
     TYPE_MULTISELECT = 'multiselect'
+    TYPE_OBJECT = 'object'
 
 
     CHOICES = (
     CHOICES = (
         (TYPE_TEXT, 'Text'),
         (TYPE_TEXT, 'Text'),
@@ -27,6 +28,7 @@ class CustomFieldTypeChoices(ChoiceSet):
         (TYPE_JSON, 'JSON'),
         (TYPE_JSON, 'JSON'),
         (TYPE_SELECT, 'Selection'),
         (TYPE_SELECT, 'Selection'),
         (TYPE_MULTISELECT, 'Multiple 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.
     Extend a Form to include custom field support.
     """
     """
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
-        self.custom_fields = []
+        self.custom_fields = {}
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
@@ -49,7 +49,7 @@ class CustomFieldsMixin:
             self.fields[field_name] = self._get_form_field(customfield)
             self.fields[field_name] = self._get_form_field(customfield)
 
 
             # Annotate the field in the list of CustomField form fields
             # 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):
 class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
@@ -70,12 +70,15 @@ class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
     def clean(self):
     def clean(self):
 
 
         # Save custom field data on instance
         # 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
             key = cf_name[3:]  # Strip "cf_" from field name
             value = self.cleaned_data.get(cf_name)
             value = self.cleaned_data.get(cf_name)
-            empty_values = self.fields[cf_name].empty_values
+
             # Convert "empty" values to null
             # 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()
         return super().clean()
 
 

+ 1 - 1
netbox/extras/forms/models.py

@@ -35,7 +35,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
         model = CustomField
         model = CustomField
         fields = '__all__'
         fields = '__all__'
         fieldsets = (
         fieldsets = (
-            ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
+            ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')),
             ('Assigned Models', ('content_types',)),
             ('Assigned Models', ('content_types',)),
             ('Behavior', ('filter_logic',)),
             ('Behavior', ('filter_logic',)),
             ('Values', ('default', 'choices')),
             ('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 netbox.models import ChangeLoggedModel
 from utilities import filters
 from utilities import filters
 from utilities.forms import (
 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.querysets import RestrictedQuerySet
 from utilities.validators import validate_regex
 from utilities.validators import validate_regex
@@ -50,8 +51,17 @@ class CustomField(ChangeLoggedModel):
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=CustomFieldTypeChoices,
         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(
     name = models.CharField(
         max_length=50,
         max_length=50,
         unique=True,
         unique=True,
@@ -122,7 +132,6 @@ class CustomField(ChangeLoggedModel):
         null=True,
         null=True,
         help_text='Comma-separated list of available choices (for selection fields)'
         help_text='Comma-separated list of available choices (for selection fields)'
     )
     )
-
     objects = CustomFieldManager()
     objects = CustomFieldManager()
 
 
     class Meta:
     class Meta:
@@ -234,6 +243,23 @@ class CustomField(ChangeLoggedModel):
                 'default': f"The specified default value ({self.default}) is not listed as an available choice."
                 '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):
     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.
         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:
         elif self.type == CustomFieldTypeChoices.TYPE_JSON:
             field = forms.JSONField(required=required, initial=initial)
             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
         # Text
         else:
         else:
             if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:
             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 dcim.models import Site, Rack
 from extras.choices import *
 from extras.choices import *
 from extras.models import CustomField
 from extras.models import CustomField
+from ipam.models import VLAN
 from utilities.testing import APITestCase, TestCase
 from utilities.testing import APITestCase, TestCase
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 
@@ -201,76 +202,67 @@ class CustomFieldAPITest(APITestCase):
     def setUpTestData(cls):
     def setUpTestData(cls):
         content_type = ContentType.objects.get_for_model(Site)
         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 1', slug='site-1'),
             Site(name='Site 2', slug='site-2'),
             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
         # 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):
     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.
         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')
         self.add_permissions('dcim.view_site')
 
 
         response = self.client.get(url, **self.header)
         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'], {
         self.assertEqual(response.data['custom_fields'], {
             'text_field': None,
             'text_field': None,
             'longtext_field': None,
             'longtext_field': None,
@@ -280,18 +272,20 @@ class CustomFieldAPITest(APITestCase):
             'url_field': None,
             'url_field': None,
             'json_field': None,
             'json_field': None,
             'choice_field': None,
             'choice_field': None,
+            'object_field': None,
         })
         })
 
 
     def test_get_single_object_with_custom_field_data(self):
     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.
         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')
         self.add_permissions('dcim.view_site')
 
 
         response = self.client.get(url, **self.header)
         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']['text_field'], site2_cfvs['text_field'])
         self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_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'])
         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']['url_field'], site2_cfvs['url_field'])
         self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_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']['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):
     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.
         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 = {
         data = {
             'name': 'Site 3',
             'name': 'Site 3',
             'slug': 'site-3',
             'slug': 'site-3',
@@ -317,25 +315,27 @@ class CustomFieldAPITest(APITestCase):
 
 
         # Validate response data
         # Validate response data
         response_cf = response.data['custom_fields']
         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
         # Validate database data
         site = Site.objects.get(pk=response.data['id'])
         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):
     def test_create_single_object_with_values(self):
         """
         """
@@ -353,6 +353,7 @@ class CustomFieldAPITest(APITestCase):
                 'url_field': 'http://example.com/2',
                 'url_field': 'http://example.com/2',
                 'json_field': '{"foo": 1, "bar": 2}',
                 'json_field': '{"foo": 1, "bar": 2}',
                 'choice_field': 'Bar',
                 'choice_field': 'Bar',
+                'object_field': VLAN.objects.get(vid=2).pk,
             },
             },
         }
         }
         url = reverse('dcim-api:site-list')
         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['url_field'], data_cf['url_field'])
         self.assertEqual(response_cf['json_field'], data_cf['json_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['choice_field'], data_cf['choice_field'])
+        self.assertEqual(response_cf['object_field']['id'], data_cf['object_field'])
 
 
         # Validate database data
         # Validate database data
         site = Site.objects.get(pk=response.data['id'])
         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['url_field'], data_cf['url_field'])
         self.assertEqual(site.custom_field_data['json_field'], data_cf['json_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['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):
     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.
         the default custom field values.
         """
         """
+        cf_defaults = {
+            cf.name: cf.default for cf in CustomField.objects.all()
+        }
         data = (
         data = (
             {
             {
                 'name': 'Site 3',
                 'name': 'Site 3',
@@ -414,25 +420,27 @@ class CustomFieldAPITest(APITestCase):
 
 
             # Validate response data
             # Validate response data
             response_cf = response.data[i]['custom_fields']
             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
             # Validate database data
             site = Site.objects.get(pk=response.data[i]['id'])
             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):
     def test_create_multiple_objects_with_values(self):
         """
         """
@@ -447,6 +455,7 @@ class CustomFieldAPITest(APITestCase):
             'url_field': 'http://example.com/2',
             'url_field': 'http://example.com/2',
             'json_field': '{"foo": 1, "bar": 2}',
             'json_field': '{"foo": 1, "bar": 2}',
             'choice_field': 'Bar',
             'choice_field': 'Bar',
+            'object_field': VLAN.objects.get(vid=2).pk,
         }
         }
         data = (
         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
         Update an object with existing custom field values. Ensure that only the updated custom field values are
         modified.
         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 = {
         data = {
             'custom_fields': {
             'custom_fields': {
                 'text_field': 'ABCD',
                 'text_field': 'ABCD',
                 'number_field': 1234,
                 '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')
         self.add_permissions('dcim.change_site')
 
 
         response = self.client.patch(url, data, format='json', **self.header)
         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'])
         self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field'])
 
 
         # Validate database data
         # 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):
     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.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}}
         data = {'custom_fields': {'number_field': 9}}
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
@@ -558,11 +569,13 @@ class CustomFieldAPITest(APITestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 
     def test_regex_validation(self):
     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.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'}}
         data = {'custom_fields': {'text_field': 'ABC123'}}
         response = self.client.patch(url, data, format='json', **self.header)
         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 = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
         cf_select.content_types.set([obj_type])
         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_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):
     def test_empty_values(self):
         """
         """
         Test that empty custom field values are stored as null
         Test that empty custom field values are stored as null

+ 9 - 6
netbox/netbox/models.py

@@ -1,5 +1,4 @@
 import logging
 import logging
-from collections import OrderedDict
 
 
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.serializers.json import DjangoJSONEncoder
@@ -99,16 +98,20 @@ class CustomFieldsMixin(models.Model):
         """
         """
         from extras.models import CustomField
         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):
     def clean(self):
         super().clean()
         super().clean()
         from extras.models import CustomField
         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
         # Validate all field values
         for field_name, value in self.custom_field_data.items():
         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>
                                     <pre>{{ value|render_json }}</pre>
                                 {% elif field.type == 'multiselect' and value %}
                                 {% elif field.type == 'multiselect' and value %}
                                     {{ value|join:", " }}
                                     {{ value|join:", " }}
+                                {% elif field.type == 'object' and value %}
+                                    <a href="{{ value.get_absolute_url }}">{{ value }}</a>
                                 {% elif value is not None %}
                                 {% elif value is not None %}
                                     {{ value }}
                                     {{ value }}
                                 {% elif field.required %}
                                 {% elif field.required %}