1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

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

This commit is contained in:
Jeremy Stretch
2017-04-27 13:05:44 -04:00
7 changed files with 331 additions and 39 deletions

View File

@ -28,11 +28,14 @@ class NestedProviderSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug'] fields = ['id', 'url', 'name', 'slug']
class WritableProviderSerializer(serializers.ModelSerializer): class WritableProviderSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = Provider 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'] fields = ['id', 'url', 'cid']
class WritableCircuitSerializer(serializers.ModelSerializer): class WritableCircuitSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = Circuit 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',
]
# #

View File

@ -66,13 +66,13 @@ class NestedSiteSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug'] fields = ['id', 'url', 'name', 'slug']
class WritableSiteSerializer(serializers.ModelSerializer): class WritableSiteSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', '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'] fields = ['id', 'url', 'name', 'display_name']
class WritableRackSerializer(serializers.ModelSerializer): class WritableRackSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = Rack model = Rack
fields = [ fields = [
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', '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 # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
# prevents facility_id from being interpreted as a required field. # prevents facility_id from being interpreted as a required field.
@ -263,13 +263,13 @@ class NestedDeviceTypeSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'manufacturer', 'model', 'slug'] fields = ['id', 'url', 'manufacturer', 'model', 'slug']
class WritableDeviceTypeSerializer(serializers.ModelSerializer): class WritableDeviceTypeSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', '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: class Meta:
model = Device model = Device
fields = [ fields = [
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', '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 = [] validators = []

View File

@ -1,6 +1,8 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue
@ -14,12 +16,40 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
def to_representation(self, obj): def to_representation(self, obj):
return 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): class CustomFieldModelSerializer(serializers.ModelSerializer):
""" """
Extends ModelSerializer to render any CustomFields and their values associated with an object. 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): def __init__(self, *args, **kwargs):
@ -34,6 +64,8 @@ class CustomFieldModelSerializer(serializers.ModelSerializer):
super(CustomFieldModelSerializer, self).__init__(*args, **kwargs) super(CustomFieldModelSerializer, self).__init__(*args, **kwargs)
if self.instance is not None:
# Retrieve the set of CustomFields which apply to this type of object # Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model) content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(obj_type=content_type) fields = CustomField.objects.filter(obj_type=content_type)
@ -45,6 +77,47 @@ class CustomFieldModelSerializer(serializers.ModelSerializer):
except TypeError: except TypeError:
_populate_custom_fields(self.instance, fields) _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): class CustomFieldChoiceSerializer(serializers.ModelSerializer):
""" """

View File

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

View File

@ -1,7 +1,12 @@
from datetime import date 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.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from dcim.models import Site 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, CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE,
CF_TYPE_SELECT, CF_TYPE_URL, 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): def setUp(self):
@ -95,3 +102,209 @@ class CustomFieldTestCase(TestCase):
# Delete the custom field # Delete the custom field
cf.delete() 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'])

View File

@ -31,11 +31,11 @@ class NestedVRFSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'rd'] fields = ['id', 'url', 'name', 'rd']
class WritableVRFSerializer(serializers.ModelSerializer): class WritableVRFSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = VRF 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'] fields = ['id', 'url', 'family', 'prefix']
class WritableAggregateSerializer(serializers.ModelSerializer): class WritableAggregateSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = Aggregate 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'] fields = ['id', 'url', 'vid', 'name', 'display_name']
class WritableVLANSerializer(serializers.ModelSerializer): class WritableVLANSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = VLAN 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 = [] validators = []
def validate(self, data): def validate(self, data):
@ -216,11 +216,14 @@ class NestedPrefixSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'prefix'] fields = ['id', 'url', 'family', 'prefix']
class WritablePrefixSerializer(serializers.ModelSerializer): class WritablePrefixSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = Prefix 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() IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
class WritableIPAddressSerializer(serializers.ModelSerializer): class WritableIPAddressSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside'] fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', 'custom_fields']
# #

View File

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