Browse Source

Merge branch 'writable-custom-fields' into v2-develop

Jeremy Stretch 8 years ago
parent
commit
b82f25c503

+ 10 - 4
netbox/circuits/api/serializers.py

@@ -28,11 +28,14 @@ class NestedProviderSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'slug']
 
 
-class WritableProviderSerializer(serializers.ModelSerializer):
+class WritableProviderSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Provider
-        fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
+        fields = [
+            'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
+            'custom_fields',
+        ]
 
 
 #
@@ -79,11 +82,14 @@ class NestedCircuitSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'cid']
 
 
-class WritableCircuitSerializer(serializers.ModelSerializer):
+class WritableCircuitSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Circuit
-        fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
+        fields = [
+            'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
+            'custom_fields',
+        ]
 
 
 #

+ 8 - 8
netbox/dcim/api/serializers.py

@@ -66,13 +66,13 @@ class NestedSiteSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'slug']
 
 
-class WritableSiteSerializer(serializers.ModelSerializer):
+class WritableSiteSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Site
         fields = [
             'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
-            'contact_name', 'contact_phone', 'contact_email', 'comments',
+            'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields',
         ]
 
 
@@ -150,13 +150,13 @@ class NestedRackSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'display_name']
 
 
-class WritableRackSerializer(serializers.ModelSerializer):
+class WritableRackSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Rack
         fields = [
             'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
-            'comments',
+            'comments', 'custom_fields',
         ]
         # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
         # prevents facility_id from being interpreted as a required field.
@@ -263,13 +263,13 @@ class NestedDeviceTypeSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'manufacturer', 'model', 'slug']
 
 
-class WritableDeviceTypeSerializer(serializers.ModelSerializer):
+class WritableDeviceTypeSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = DeviceType
         fields = [
             'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
-            'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments',
+            'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
         ]
 
 
@@ -476,13 +476,13 @@ class DeviceSerializer(CustomFieldModelSerializer):
         }
 
 
-class WritableDeviceSerializer(serializers.ModelSerializer):
+class WritableDeviceSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Device
         fields = [
             'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
-            'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments',
+            'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
         ]
         validators = []
 

+ 83 - 10
netbox/extras/api/customfields.py

@@ -1,6 +1,8 @@
 from django.contrib.contenttypes.models import ContentType
+from django.db import transaction
 
 from rest_framework import serializers
+from rest_framework.exceptions import ValidationError
 
 from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue
 
@@ -14,12 +16,40 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
     def to_representation(self, obj):
         return obj
 
+    def to_internal_value(self, data):
+
+        content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
+        custom_fields = {field.name: field for field in CustomField.objects.filter(obj_type=content_type)}
+
+        for field_name, value in data.items():
+
+            # Validate custom field name
+            if field_name not in custom_fields:
+                raise ValidationError(u"Invalid custom field for {} objects: {}".format(content_type, field_name))
+
+            # Validate selected choice
+            cf = custom_fields[field_name]
+            if cf.type == CF_TYPE_SELECT:
+                valid_choices = [c.pk for c in cf.choices.all()]
+                if value not in valid_choices:
+                    raise ValidationError(u"Invalid choice ({}) for field {}".format(value, field_name))
+
+        # Check for missing required fields
+        missing_fields = []
+        for field_name, field in custom_fields.items():
+            if field.required and field_name not in data:
+                missing_fields.append(field_name)
+        if missing_fields:
+            raise ValidationError(u"Missing required fields: {}".format(u", ".join(missing_fields)))
+
+        return data
+
 
 class CustomFieldModelSerializer(serializers.ModelSerializer):
     """
     Extends ModelSerializer to render any CustomFields and their values associated with an object.
     """
-    custom_fields = CustomFieldsSerializer()
+    custom_fields = CustomFieldsSerializer(required=False)
 
     def __init__(self, *args, **kwargs):
 
@@ -34,16 +64,59 @@ class CustomFieldModelSerializer(serializers.ModelSerializer):
 
         super(CustomFieldModelSerializer, self).__init__(*args, **kwargs)
 
-        # Retrieve the set of CustomFields which apply to this type of object
+        if self.instance is not None:
+
+            # Retrieve the set of CustomFields which apply to this type of object
+            content_type = ContentType.objects.get_for_model(self.Meta.model)
+            fields = CustomField.objects.filter(obj_type=content_type)
+
+            # Populate CustomFieldValues for each instance from database
+            try:
+                for obj in self.instance:
+                    _populate_custom_fields(obj, fields)
+            except TypeError:
+                _populate_custom_fields(self.instance, fields)
+
+    def _save_custom_fields(self, instance, custom_fields):
         content_type = ContentType.objects.get_for_model(self.Meta.model)
-        fields = CustomField.objects.filter(obj_type=content_type)
-
-        # Populate CustomFieldValues for each instance from database
-        try:
-            for obj in self.instance:
-                _populate_custom_fields(obj, fields)
-        except TypeError:
-            _populate_custom_fields(self.instance, fields)
+        for field_name, value in custom_fields.items():
+            custom_field = CustomField.objects.get(name=field_name)
+            CustomFieldValue.objects.update_or_create(
+                field=custom_field,
+                obj_type=content_type,
+                obj_id=instance.pk,
+                defaults={'serialized_value': value},
+            )
+
+    def create(self, validated_data):
+
+        custom_fields = validated_data.pop('custom_fields', None)
+
+        with transaction.atomic():
+
+            instance = super(CustomFieldModelSerializer, self).create(validated_data)
+
+            # Save custom fields
+            if custom_fields is not None:
+                self._save_custom_fields(instance, custom_fields)
+                instance.custom_fields = custom_fields
+
+        return instance
+
+    def update(self, instance, validated_data):
+
+        custom_fields = validated_data.pop('custom_fields', None)
+
+        with transaction.atomic():
+
+            instance = super(CustomFieldModelSerializer, self).update(instance, validated_data)
+
+            # Save custom fields
+            if custom_fields is not None:
+                self._save_custom_fields(instance, custom_fields)
+                instance.custom_fields = custom_fields
+
+        return instance
 
 
 class CustomFieldChoiceSerializer(serializers.ModelSerializer):

+ 1 - 4
netbox/extras/models.py

@@ -156,10 +156,7 @@ class CustomField(models.Model):
             # Read date as YYYY-MM-DD
             return date(*[int(n) for n in serialized_value.split('-')])
         if self.type == CF_TYPE_SELECT:
-            try:
-                return self.choices.get(pk=int(serialized_value))
-            except CustomFieldChoice.DoesNotExist:
-                return None
+            return self.choices.get(pk=int(serialized_value))
         return serialized_value
 
 

+ 214 - 1
netbox/extras/tests/test_customfields.py

@@ -1,7 +1,12 @@
 from datetime import date
 
+from rest_framework import status
+from rest_framework.test import APITestCase
+
+from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
+from django.urls import reverse
 
 from dcim.models import Site
 
@@ -9,9 +14,11 @@ from extras.models import (
     CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE,
     CF_TYPE_SELECT, CF_TYPE_URL,
 )
+from users.models import Token
+from utilities.tests import HttpStatusMixin
 
 
-class CustomFieldTestCase(TestCase):
+class CustomFieldTest(TestCase):
 
     def setUp(self):
 
@@ -95,3 +102,209 @@ class CustomFieldTestCase(TestCase):
 
         # Delete the custom field
         cf.delete()
+
+
+class CustomFieldAPITest(HttpStatusMixin, APITestCase):
+
+    def setUp(self):
+
+        user = User.objects.create(username='testuser', is_superuser=True)
+        token = Token.objects.create(user=user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+
+        content_type = ContentType.objects.get_for_model(Site)
+
+        # Text custom field
+        self.cf_text = CustomField(type=CF_TYPE_TEXT, name='magic_word')
+        self.cf_text.save()
+        self.cf_text.obj_type = [content_type]
+        self.cf_text.save()
+
+        # Integer custom field
+        self.cf_integer = CustomField(type=CF_TYPE_INTEGER, name='magic_number')
+        self.cf_integer.save()
+        self.cf_integer.obj_type = [content_type]
+        self.cf_integer.save()
+
+        # Boolean custom field
+        self.cf_boolean = CustomField(type=CF_TYPE_BOOLEAN, name='is_magic')
+        self.cf_boolean.save()
+        self.cf_boolean.obj_type = [content_type]
+        self.cf_boolean.save()
+
+        # Date custom field
+        self.cf_date = CustomField(type=CF_TYPE_DATE, name='magic_date')
+        self.cf_date.save()
+        self.cf_date.obj_type = [content_type]
+        self.cf_date.save()
+
+        # URL custom field
+        self.cf_url = CustomField(type=CF_TYPE_URL, name='magic_url')
+        self.cf_url.save()
+        self.cf_url.obj_type = [content_type]
+        self.cf_url.save()
+
+        # Select custom field
+        self.cf_select = CustomField(type=CF_TYPE_SELECT, name='magic_choice')
+        self.cf_select.save()
+        self.cf_select.obj_type = [content_type]
+        self.cf_select.save()
+        self.cf_select_choice1 = CustomFieldChoice(field=self.cf_select, value='Foo')
+        self.cf_select_choice1.save()
+        self.cf_select_choice2 = CustomFieldChoice(field=self.cf_select, value='Bar')
+        self.cf_select_choice2.save()
+        self.cf_select_choice3 = CustomFieldChoice(field=self.cf_select, value='Baz')
+        self.cf_select_choice3.save()
+
+        self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+
+    def test_get_obj_without_custom_fields(self):
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.site.name)
+        self.assertEqual(response.data['custom_fields'], {
+            'magic_word': None,
+            'magic_number': None,
+            'is_magic': None,
+            'magic_date': None,
+            'magic_url': None,
+            'magic_choice': None,
+        })
+
+    def test_get_obj_with_custom_fields(self):
+
+        CUSTOM_FIELD_VALUES = [
+            (self.cf_text, 'Test string'),
+            (self.cf_integer, 1234),
+            (self.cf_boolean, True),
+            (self.cf_date, date(2016, 6, 23)),
+            (self.cf_url, 'http://example.com/'),
+            (self.cf_select, self.cf_select_choice1.pk),
+        ]
+        for field, value in CUSTOM_FIELD_VALUES:
+            cfv = CustomFieldValue(field=field, obj=self.site)
+            cfv.value = value
+            cfv.save()
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.site.name)
+        self.assertEqual(response.data['custom_fields'].get('magic_word'), CUSTOM_FIELD_VALUES[0][1])
+        self.assertEqual(response.data['custom_fields'].get('magic_number'), CUSTOM_FIELD_VALUES[1][1])
+        self.assertEqual(response.data['custom_fields'].get('is_magic'), CUSTOM_FIELD_VALUES[2][1])
+        self.assertEqual(response.data['custom_fields'].get('magic_date'), CUSTOM_FIELD_VALUES[3][1])
+        self.assertEqual(response.data['custom_fields'].get('magic_url'), CUSTOM_FIELD_VALUES[4][1])
+        self.assertEqual(response.data['custom_fields'].get('magic_choice'), {
+            'value': self.cf_select_choice1.pk, 'label': 'Foo'
+        })
+
+    def test_set_custom_field_text(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'magic_word': 'Foo bar baz',
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('magic_word'), data['custom_fields']['magic_word'])
+        cfv = self.site.custom_field_values.get(field=self.cf_text)
+        self.assertEqual(cfv.value, data['custom_fields']['magic_word'])
+
+    def test_set_custom_field_integer(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'magic_number': 42,
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('magic_number'), data['custom_fields']['magic_number'])
+        cfv = self.site.custom_field_values.get(field=self.cf_integer)
+        self.assertEqual(cfv.value, data['custom_fields']['magic_number'])
+
+    def test_set_custom_field_boolean(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'is_magic': 0,
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('is_magic'), data['custom_fields']['is_magic'])
+        cfv = self.site.custom_field_values.get(field=self.cf_boolean)
+        self.assertEqual(cfv.value, data['custom_fields']['is_magic'])
+
+    def test_set_custom_field_date(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'magic_date': '2017-04-25',
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('magic_date'), data['custom_fields']['magic_date'])
+        cfv = self.site.custom_field_values.get(field=self.cf_date)
+        self.assertEqual(cfv.value.isoformat(), data['custom_fields']['magic_date'])
+
+    def test_set_custom_field_url(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'magic_url': 'http://example.com/2/',
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('magic_url'), data['custom_fields']['magic_url'])
+        cfv = self.site.custom_field_values.get(field=self.cf_url)
+        self.assertEqual(cfv.value, data['custom_fields']['magic_url'])
+
+    def test_set_custom_field_select(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'magic_choice': self.cf_select_choice2.pk,
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('magic_choice'), data['custom_fields']['magic_choice'])
+        cfv = self.site.custom_field_values.get(field=self.cf_select)
+        self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])

+ 13 - 10
netbox/ipam/api/serializers.py

@@ -31,11 +31,11 @@ class NestedVRFSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'rd']
 
 
-class WritableVRFSerializer(serializers.ModelSerializer):
+class WritableVRFSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = VRF
-        fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description']
+        fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
 
 
 #
@@ -96,11 +96,11 @@ class NestedAggregateSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'family', 'prefix']
 
 
-class WritableAggregateSerializer(serializers.ModelSerializer):
+class WritableAggregateSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Aggregate
-        fields = ['id', 'prefix', 'rir', 'date_added', 'description']
+        fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
 
 
 #
@@ -169,11 +169,11 @@ class NestedVLANSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'vid', 'name', 'display_name']
 
 
-class WritableVLANSerializer(serializers.ModelSerializer):
+class WritableVLANSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = VLAN
-        fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
+        fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields']
         validators = []
 
     def validate(self, data):
@@ -216,11 +216,14 @@ class NestedPrefixSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'family', 'prefix']
 
 
-class WritablePrefixSerializer(serializers.ModelSerializer):
+class WritablePrefixSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Prefix
-        fields = ['id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description']
+        fields = [
+            'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
+            'custom_fields',
+        ]
 
 
 #
@@ -252,11 +255,11 @@ IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
 IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
 
 
-class WritableIPAddressSerializer(serializers.ModelSerializer):
+class WritableIPAddressSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = IPAddress
-        fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside']
+        fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', 'custom_fields']
 
 
 #

+ 2 - 2
netbox/tenancy/api/serializers.py

@@ -43,8 +43,8 @@ class NestedTenantSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'slug']
 
 
-class WritableTenantSerializer(serializers.ModelSerializer):
+class WritableTenantSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Tenant
-        fields = ['id', 'name', 'slug', 'group', 'description', 'comments']
+        fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']