diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 4703458a6..b36d22105 100644 --- a/netbox/circuits/api/serializers.py +++ b/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', + ] # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index a3149f963..a9c52e3fd 100644 --- a/netbox/dcim/api/serializers.py +++ b/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 = [] diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index e47eb41ab..dafed750b 100644 --- a/netbox/extras/api/customfields.py +++ b/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 - content_type = ContentType.objects.get_for_model(self.Meta.model) - fields = CustomField.objects.filter(obj_type=content_type) + if self.instance is not None: - # 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) + # 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) + 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): diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 5d90613b0..485448336 100644 --- a/netbox/extras/models.py +++ b/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 diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 791c6a1a2..9e475fde8 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/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']) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 5a350acb7..7d9a5778c 100644 --- a/netbox/ipam/api/serializers.py +++ b/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'] # diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 67231fe67..e649b6f03 100644 --- a/netbox/tenancy/api/serializers.py +++ b/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']