Просмотр исходного кода

Merge pull request #2024 from digitalocean/1794-writable-nested-serializers

1794 writable nested serializers
Jeremy Stretch 7 лет назад
Родитель
Сommit
7805848e6c

+ 8 - 37
netbox/circuits/api/serializers.py

@@ -7,7 +7,7 @@ from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
+from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer
 
 
 #
@@ -24,7 +24,7 @@ class ProviderSerializer(CustomFieldModelSerializer):
         ]
 
 
-class NestedProviderSerializer(serializers.ModelSerializer):
+class NestedProviderSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
 
     class Meta:
@@ -32,16 +32,6 @@ class NestedProviderSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'slug']
 
 
-class WritableProviderSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = Provider
-        fields = [
-            'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
-            'custom_fields', 'created', 'last_updated',
-        ]
-
-
 #
 # Circuit types
 #
@@ -53,7 +43,7 @@ class CircuitTypeSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug']
 
 
-class NestedCircuitTypeSerializer(serializers.ModelSerializer):
+class NestedCircuitTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
 
     class Meta:
@@ -67,9 +57,9 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer):
 
 class CircuitSerializer(CustomFieldModelSerializer):
     provider = NestedProviderSerializer()
-    status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES)
+    status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False)
     type = NestedCircuitTypeSerializer()
-    tenant = NestedTenantSerializer()
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
 
     class Meta:
         model = Circuit
@@ -79,7 +69,7 @@ class CircuitSerializer(CustomFieldModelSerializer):
         ]
 
 
-class NestedCircuitSerializer(serializers.ModelSerializer):
+class NestedCircuitSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
 
     class Meta:
@@ -87,33 +77,14 @@ class NestedCircuitSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'cid']
 
 
-class WritableCircuitSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = Circuit
-        fields = [
-            'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
-            'comments', 'custom_fields', 'created', 'last_updated',
-        ]
-
-
 #
 # Circuit Terminations
 #
 
-class CircuitTerminationSerializer(serializers.ModelSerializer):
+class CircuitTerminationSerializer(ValidatedModelSerializer):
     circuit = NestedCircuitSerializer()
     site = NestedSiteSerializer()
-    interface = InterfaceSerializer()
-
-    class Meta:
-        model = CircuitTermination
-        fields = [
-            'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
-        ]
-
-
-class WritableCircuitTerminationSerializer(ValidatedModelSerializer):
+    interface = InterfaceSerializer(required=False, allow_null=True)
 
     class Meta:
         model = CircuitTermination

+ 0 - 3
netbox/circuits/api/views.py

@@ -30,7 +30,6 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
 class ProviderViewSet(CustomFieldModelViewSet):
     queryset = Provider.objects.all()
     serializer_class = serializers.ProviderSerializer
-    write_serializer_class = serializers.WritableProviderSerializer
     filter_class = filters.ProviderFilter
 
     @detail_route()
@@ -61,7 +60,6 @@ class CircuitTypeViewSet(ModelViewSet):
 class CircuitViewSet(CustomFieldModelViewSet):
     queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
     serializer_class = serializers.CircuitSerializer
-    write_serializer_class = serializers.WritableCircuitSerializer
     filter_class = filters.CircuitFilter
 
 
@@ -72,5 +70,4 @@ class CircuitViewSet(CustomFieldModelViewSet):
 class CircuitTerminationViewSet(ModelViewSet):
     queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
     serializer_class = serializers.CircuitTerminationSerializer
-    write_serializer_class = serializers.WritableCircuitTerminationSerializer
     filter_class = filters.CircuitTerminationFilter

+ 130 - 300
netbox/dcim/api/serializers.py

@@ -20,7 +20,9 @@ from extras.api.customfields import CustomFieldModelSerializer
 from ipam.models import IPAddress, VLAN
 from tenancy.api.serializers import NestedTenantSerializer
 from users.api.serializers import NestedUserSerializer
-from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer
+from utilities.api import (
+    ChoiceFieldSerializer, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, WritableNestedSerializer,
+)
 from virtualization.models import Cluster
 
 
@@ -28,7 +30,7 @@ from virtualization.models import Cluster
 # Regions
 #
 
-class NestedRegionSerializer(serializers.ModelSerializer):
+class NestedRegionSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
 
     class Meta:
@@ -37,14 +39,7 @@ class NestedRegionSerializer(serializers.ModelSerializer):
 
 
 class RegionSerializer(serializers.ModelSerializer):
-    parent = NestedRegionSerializer()
-
-    class Meta:
-        model = Region
-        fields = ['id', 'name', 'slug', 'parent']
-
-
-class WritableRegionSerializer(ValidatedModelSerializer):
+    parent = NestedRegionSerializer(required=False, allow_null=True)
 
     class Meta:
         model = Region
@@ -56,9 +51,9 @@ class WritableRegionSerializer(ValidatedModelSerializer):
 #
 
 class SiteSerializer(CustomFieldModelSerializer):
-    status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
-    region = NestedRegionSerializer()
-    tenant = NestedTenantSerializer()
+    status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES, required=False)
+    region = NestedRegionSerializer(required=False, allow_null=True)
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
     time_zone = TimeZoneField(required=False)
 
     class Meta:
@@ -71,7 +66,7 @@ class SiteSerializer(CustomFieldModelSerializer):
         ]
 
 
-class NestedSiteSerializer(serializers.ModelSerializer):
+class NestedSiteSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
 
     class Meta:
@@ -79,23 +74,11 @@ class NestedSiteSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'slug']
 
 
-class WritableSiteSerializer(CustomFieldModelSerializer):
-    time_zone = TimeZoneField(required=False)
-
-    class Meta:
-        model = Site
-        fields = [
-            'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
-            'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
-            'custom_fields', 'created', 'last_updated',
-        ]
-
-
 #
 # Rack groups
 #
 
-class RackGroupSerializer(serializers.ModelSerializer):
+class RackGroupSerializer(ValidatedModelSerializer):
     site = NestedSiteSerializer()
 
     class Meta:
@@ -103,7 +86,7 @@ class RackGroupSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'site']
 
 
-class NestedRackGroupSerializer(serializers.ModelSerializer):
+class NestedRackGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
 
     class Meta:
@@ -111,13 +94,6 @@ class NestedRackGroupSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'slug']
 
 
-class WritableRackGroupSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = RackGroup
-        fields = ['id', 'name', 'slug', 'site']
-
-
 #
 # Rack roles
 #
@@ -129,7 +105,7 @@ class RackRoleSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'color']
 
 
-class NestedRackRoleSerializer(serializers.ModelSerializer):
+class NestedRackRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
 
     class Meta:
@@ -143,11 +119,11 @@ class NestedRackRoleSerializer(serializers.ModelSerializer):
 
 class RackSerializer(CustomFieldModelSerializer):
     site = NestedSiteSerializer()
-    group = NestedRackGroupSerializer()
-    tenant = NestedTenantSerializer()
-    role = NestedRackRoleSerializer()
-    type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES)
-    width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES)
+    group = NestedRackGroupSerializer(required=False, allow_null=True)
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
+    role = NestedRackRoleSerializer(required=False, allow_null=True)
+    type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False)
+    width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False)
 
     class Meta:
         model = Rack
@@ -155,24 +131,6 @@ class RackSerializer(CustomFieldModelSerializer):
             'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
             'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
         ]
-
-
-class NestedRackSerializer(serializers.ModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
-
-    class Meta:
-        model = Rack
-        fields = ['id', 'url', 'name', 'display_name']
-
-
-class WritableRackSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = Rack
-        fields = [
-            'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
-            'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
-        ]
         # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
         # prevents facility_id from being interpreted as a required field.
         validators = [
@@ -188,16 +146,24 @@ class WritableRackSerializer(CustomFieldModelSerializer):
             validator(data)
 
         # Enforce model validation
-        super(WritableRackSerializer, self).validate(data)
+        super(RackSerializer, self).validate(data)
 
         return data
 
 
+class NestedRackSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
+
+    class Meta:
+        model = Rack
+        fields = ['id', 'url', 'name', 'display_name']
+
+
 #
 # Rack units
 #
 
-class NestedDeviceSerializer(serializers.ModelSerializer):
+class NestedDeviceSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
 
     class Meta:
@@ -219,23 +185,16 @@ class RackUnitSerializer(serializers.Serializer):
 # Rack reservations
 #
 
-class RackReservationSerializer(serializers.ModelSerializer):
+class RackReservationSerializer(ValidatedModelSerializer):
     rack = NestedRackSerializer()
     user = NestedUserSerializer()
-    tenant = NestedTenantSerializer()
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
 
     class Meta:
         model = RackReservation
         fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
 
 
-class WritableRackReservationSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = RackReservation
-        fields = ['id', 'rack', 'units', 'user', 'description']
-
-
 #
 # Manufacturers
 #
@@ -247,7 +206,7 @@ class ManufacturerSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug']
 
 
-class NestedManufacturerSerializer(serializers.ModelSerializer):
+class NestedManufacturerSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
 
     class Meta:
@@ -261,8 +220,8 @@ class NestedManufacturerSerializer(serializers.ModelSerializer):
 
 class DeviceTypeSerializer(CustomFieldModelSerializer):
     manufacturer = NestedManufacturerSerializer()
-    interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES)
-    subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES)
+    interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES, required=False)
+    subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False)
     instance_count = serializers.IntegerField(source='instances.count', read_only=True)
 
     class Meta:
@@ -274,30 +233,20 @@ class DeviceTypeSerializer(CustomFieldModelSerializer):
         ]
 
 
-class NestedDeviceTypeSerializer(serializers.ModelSerializer):
+class NestedDeviceTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
-    manufacturer = NestedManufacturerSerializer()
+    manufacturer = NestedManufacturerSerializer(read_only=True)
 
     class Meta:
         model = DeviceType
         fields = ['id', 'url', 'manufacturer', 'model', 'slug']
 
 
-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', 'custom_fields',
-        ]
-
-
 #
 # Console port templates
 #
 
-class ConsolePortTemplateSerializer(serializers.ModelSerializer):
+class ConsolePortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
 
     class Meta:
@@ -305,18 +254,11 @@ class ConsolePortTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
 
 
-class WritableConsolePortTemplateSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = ConsolePortTemplate
-        fields = ['id', 'device_type', 'name']
-
-
 #
 # Console server port templates
 #
 
-class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer):
+class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
 
     class Meta:
@@ -324,18 +266,11 @@ class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
 
 
-class WritableConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = ConsoleServerPortTemplate
-        fields = ['id', 'device_type', 'name']
-
-
 #
 # Power port templates
 #
 
-class PowerPortTemplateSerializer(serializers.ModelSerializer):
+class PowerPortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
 
     class Meta:
@@ -343,18 +278,11 @@ class PowerPortTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
 
 
-class WritablePowerPortTemplateSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = PowerPortTemplate
-        fields = ['id', 'device_type', 'name']
-
-
 #
 # Power outlet templates
 #
 
-class PowerOutletTemplateSerializer(serializers.ModelSerializer):
+class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
 
     class Meta:
@@ -362,27 +290,13 @@ class PowerOutletTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
 
 
-class WritablePowerOutletTemplateSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = PowerOutletTemplate
-        fields = ['id', 'device_type', 'name']
-
-
 #
 # Interface templates
 #
 
-class InterfaceTemplateSerializer(serializers.ModelSerializer):
+class InterfaceTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
-    form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
-
-    class Meta:
-        model = InterfaceTemplate
-        fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
-
-
-class WritableInterfaceTemplateSerializer(ValidatedModelSerializer):
+    form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False)
 
     class Meta:
         model = InterfaceTemplate
@@ -393,7 +307,7 @@ class WritableInterfaceTemplateSerializer(ValidatedModelSerializer):
 # Device bay templates
 #
 
-class DeviceBayTemplateSerializer(serializers.ModelSerializer):
+class DeviceBayTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
 
     class Meta:
@@ -401,13 +315,6 @@ class DeviceBayTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
 
 
-class WritableDeviceBayTemplateSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = DeviceBayTemplate
-        fields = ['id', 'device_type', 'name']
-
-
 #
 # Device roles
 #
@@ -419,7 +326,7 @@ class DeviceRoleSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'color', 'vm_role']
 
 
-class NestedDeviceRoleSerializer(serializers.ModelSerializer):
+class NestedDeviceRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
 
     class Meta:
@@ -431,15 +338,15 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer):
 # Platforms
 #
 
-class PlatformSerializer(serializers.ModelSerializer):
-    manufacturer = NestedManufacturerSerializer()
+class PlatformSerializer(ValidatedModelSerializer):
+    manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
 
     class Meta:
         model = Platform
         fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
 
 
-class NestedPlatformSerializer(serializers.ModelSerializer):
+class NestedPlatformSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
 
     class Meta:
@@ -447,13 +354,6 @@ class NestedPlatformSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'slug']
 
 
-class WritablePlatformSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = Platform
-        fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
-
-
 #
 # Devices
 #
@@ -489,18 +389,18 @@ class DeviceVirtualChassisSerializer(serializers.ModelSerializer):
 class DeviceSerializer(CustomFieldModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_role = NestedDeviceRoleSerializer()
-    tenant = NestedTenantSerializer()
-    platform = NestedPlatformSerializer()
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
+    platform = NestedPlatformSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer()
-    rack = NestedRackSerializer()
-    face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
-    status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES)
-    primary_ip = DeviceIPAddressSerializer()
-    primary_ip4 = DeviceIPAddressSerializer()
-    primary_ip6 = DeviceIPAddressSerializer()
+    rack = NestedRackSerializer(required=False, allow_null=True)
+    face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES, required=False)
+    status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES, required=False)
+    primary_ip = DeviceIPAddressSerializer(read_only=True)
+    primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True)
+    primary_ip6 = DeviceIPAddressSerializer(required=False, allow_null=True)
     parent_device = serializers.SerializerMethodField()
-    cluster = NestedClusterSerializer()
-    virtual_chassis = DeviceVirtualChassisSerializer()
+    cluster = NestedClusterSerializer(required=False, allow_null=True)
+    virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True)
 
     class Meta:
         model = Device
@@ -510,27 +410,6 @@ class DeviceSerializer(CustomFieldModelSerializer):
             'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created',
             'last_updated',
         ]
-
-    def get_parent_device(self, obj):
-        try:
-            device_bay = obj.parent_bay
-        except DeviceBay.DoesNotExist:
-            return None
-        context = {'request': self.context['request']}
-        data = NestedDeviceSerializer(instance=device_bay.device, context=context).data
-        data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
-        return data
-
-
-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', 'cluster', 'virtual_chassis', 'vc_position',
-            'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated',
-        ]
         validators = []
 
     def validate(self, data):
@@ -542,8 +421,18 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
             validator(data)
 
         # Enforce model validation
-        super(WritableDeviceSerializer, self).validate(data)
+        super(DeviceSerializer, self).validate(data)
+
+        return data
 
+    def get_parent_device(self, obj):
+        try:
+            device_bay = obj.parent_bay
+        except DeviceBay.DoesNotExist:
+            return None
+        context = {'request': self.context['request']}
+        data = NestedDeviceSerializer(instance=device_bay.device, context=context).data
+        data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
         return data
 
 
@@ -551,7 +440,7 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
 # Console server ports
 #
 
-class ConsoleServerPortSerializer(serializers.ModelSerializer):
+class ConsoleServerPortSerializer(ValidatedModelSerializer):
     device = NestedDeviceSerializer()
 
     class Meta:
@@ -560,27 +449,22 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer):
         read_only_fields = ['connected_console']
 
 
-class WritableConsoleServerPortSerializer(ValidatedModelSerializer):
+class NestedConsoleServerPortSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
+    device = NestedDeviceSerializer(read_only=True)
 
     class Meta:
         model = ConsoleServerPort
-        fields = ['id', 'device', 'name']
+        fields = ['id', 'url', 'device', 'name']
 
 
 #
 # Console ports
 #
 
-class ConsolePortSerializer(serializers.ModelSerializer):
+class ConsolePortSerializer(ValidatedModelSerializer):
     device = NestedDeviceSerializer()
-    cs_port = ConsoleServerPortSerializer()
-
-    class Meta:
-        model = ConsolePort
-        fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
-
-
-class WritableConsolePortSerializer(ValidatedModelSerializer):
+    cs_port = NestedConsoleServerPortSerializer(required=False, allow_null=True)
 
     class Meta:
         model = ConsolePort
@@ -591,7 +475,7 @@ class WritableConsolePortSerializer(ValidatedModelSerializer):
 # Power outlets
 #
 
-class PowerOutletSerializer(serializers.ModelSerializer):
+class PowerOutletSerializer(ValidatedModelSerializer):
     device = NestedDeviceSerializer()
 
     class Meta:
@@ -600,27 +484,22 @@ class PowerOutletSerializer(serializers.ModelSerializer):
         read_only_fields = ['connected_port']
 
 
-class WritablePowerOutletSerializer(ValidatedModelSerializer):
+class NestedPowerOutletSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
+    device = NestedDeviceSerializer(read_only=True)
 
     class Meta:
         model = PowerOutlet
-        fields = ['id', 'device', 'name']
+        fields = ['id', 'url', 'device', 'name']
 
 
 #
 # Power ports
 #
 
-class PowerPortSerializer(serializers.ModelSerializer):
+class PowerPortSerializer(ValidatedModelSerializer):
     device = NestedDeviceSerializer()
-    power_outlet = PowerOutletSerializer()
-
-    class Meta:
-        model = PowerPort
-        fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
-
-
-class WritablePowerPortSerializer(ValidatedModelSerializer):
+    power_outlet = NestedPowerOutletSerializer(required=False, allow_null=True)
 
     class Meta:
         model = PowerPort
@@ -631,12 +510,13 @@ class WritablePowerPortSerializer(ValidatedModelSerializer):
 # Interfaces
 #
 
-class NestedInterfaceSerializer(serializers.ModelSerializer):
+class NestedInterfaceSerializer(WritableNestedSerializer):
+    device = NestedDeviceSerializer(read_only=True)
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
 
     class Meta:
         model = Interface
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'device', 'name']
 
 
 class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
@@ -647,8 +527,8 @@ class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'cid']
 
 
-class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
-    circuit = InterfaceNestedCircuitSerializer()
+class InterfaceCircuitTerminationSerializer(WritableNestedSerializer):
+    circuit = InterfaceNestedCircuitSerializer(read_only=True)
 
     class Meta:
         model = CircuitTermination
@@ -658,7 +538,7 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
 
 
 # Cannot import ipam.api.NestedVLANSerializer due to circular dependency
-class InterfaceVLANSerializer(serializers.ModelSerializer):
+class InterfaceVLANSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
 
     class Meta:
@@ -666,16 +546,21 @@ class InterfaceVLANSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'vid', 'name', 'display_name']
 
 
-class InterfaceSerializer(serializers.ModelSerializer):
+class InterfaceSerializer(ValidatedModelSerializer):
     device = NestedDeviceSerializer()
-    form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
-    lag = NestedInterfaceSerializer()
+    form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False)
+    lag = NestedInterfaceSerializer(required=False, allow_null=True)
     is_connected = serializers.SerializerMethodField(read_only=True)
     interface_connection = serializers.SerializerMethodField(read_only=True)
-    circuit_termination = InterfaceCircuitTerminationSerializer()
-    untagged_vlan = InterfaceVLANSerializer()
-    mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
-    tagged_vlans = InterfaceVLANSerializer(many=True)
+    circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True)
+    mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES, required=False)
+    untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
+    tagged_vlans = SerializedPKRelatedField(
+        queryset=VLAN.objects.all(),
+        serializer=InterfaceVLANSerializer,
+        required=False,
+        many=True
+    )
 
     class Meta:
         model = Interface
@@ -684,6 +569,25 @@ class InterfaceSerializer(serializers.ModelSerializer):
             'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
         ]
 
+    def validate(self, data):
+
+        # All associated VLANs be global or assigned to the parent device's site.
+        device = self.instance.device if self.instance else data.get('device')
+        untagged_vlan = data.get('untagged_vlan')
+        if untagged_vlan and untagged_vlan.site not in [device.site, None]:
+            raise serializers.ValidationError({
+                'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
+                                 "global.".format(untagged_vlan)
+            })
+        for vlan in data.get('tagged_vlans', []):
+            if vlan.site not in [device.site, None]:
+                raise serializers.ValidationError({
+                    'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
+                                    "be global.".format(vlan)
+                })
+
+        return super(InterfaceSerializer, self).validate(data)
+
     def get_is_connected(self, obj):
         """
         Return True if the interface has a connected interface or circuit termination.
@@ -700,69 +604,26 @@ class InterfaceSerializer(serializers.ModelSerializer):
     def get_interface_connection(self, obj):
         if obj.connection:
             return OrderedDict((
-                ('interface', PeerInterfaceSerializer(obj.connected_interface, context=self.context).data),
+                ('interface', NestedInterfaceSerializer(obj.connected_interface, context=self.context).data),
                 ('status', obj.connection.connection_status),
             ))
         return None
 
 
-class PeerInterfaceSerializer(serializers.ModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
-    device = NestedDeviceSerializer()
-    form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
-    lag = NestedInterfaceSerializer()
-
-    class Meta:
-        model = Interface
-        fields = [
-            'id', 'url', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
-            'description',
-        ]
-
-
-class WritableInterfaceSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = Interface
-        fields = [
-            'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
-            'mode', 'untagged_vlan', 'tagged_vlans',
-        ]
-
-    def validate(self, data):
-
-        # All associated VLANs be global or assigned to the parent device's site.
-        device = self.instance.device if self.instance else data.get('device')
-        untagged_vlan = data.get('untagged_vlan')
-        if untagged_vlan and untagged_vlan.site not in [device.site, None]:
-            raise serializers.ValidationError({
-                'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
-                                 "global.".format(untagged_vlan)
-            })
-        for vlan in data.get('tagged_vlans', []):
-            if vlan.site not in [device.site, None]:
-                raise serializers.ValidationError({
-                    'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
-                                    "be global.".format(vlan)
-                })
-
-        return super(WritableInterfaceSerializer, self).validate(data)
-
-
 #
 # Device bays
 #
 
-class DeviceBaySerializer(serializers.ModelSerializer):
+class DeviceBaySerializer(ValidatedModelSerializer):
     device = NestedDeviceSerializer()
-    installed_device = NestedDeviceSerializer()
+    installed_device = NestedDeviceSerializer(required=False, allow_null=True)
 
     class Meta:
         model = DeviceBay
         fields = ['id', 'device', 'name', 'installed_device']
 
 
-class NestedDeviceBaySerializer(serializers.ModelSerializer):
+class NestedDeviceBaySerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
 
     class Meta:
@@ -770,32 +631,15 @@ class NestedDeviceBaySerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name']
 
 
-class WritableDeviceBaySerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = DeviceBay
-        fields = ['id', 'device', 'name', 'installed_device']
-
-
 #
 # Inventory items
 #
 
-class InventoryItemSerializer(serializers.ModelSerializer):
+class InventoryItemSerializer(ValidatedModelSerializer):
     device = NestedDeviceSerializer()
-    manufacturer = NestedManufacturerSerializer()
-
-    class Meta:
-        model = InventoryItem
-        fields = [
-            'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
-            'description',
-        ]
-
-
-class WritableInventoryItemSerializer(ValidatedModelSerializer):
     # Provide a default value to satisfy UniqueTogetherValidator
     parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
+    manufacturer = NestedManufacturerSerializer()
 
     class Meta:
         model = InventoryItem
@@ -809,17 +653,17 @@ class WritableInventoryItemSerializer(ValidatedModelSerializer):
 # Interface connections
 #
 
-class InterfaceConnectionSerializer(serializers.ModelSerializer):
-    interface_a = PeerInterfaceSerializer()
-    interface_b = PeerInterfaceSerializer()
-    connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES)
+class InterfaceConnectionSerializer(ValidatedModelSerializer):
+    interface_a = NestedInterfaceSerializer()
+    interface_b = NestedInterfaceSerializer()
+    connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES, required=False)
 
     class Meta:
         model = InterfaceConnection
         fields = ['id', 'interface_a', 'interface_b', 'connection_status']
 
 
-class NestedInterfaceConnectionSerializer(serializers.ModelSerializer):
+class NestedInterfaceConnectionSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail')
 
     class Meta:
@@ -827,18 +671,11 @@ class NestedInterfaceConnectionSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'connection_status']
 
 
-class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = InterfaceConnection
-        fields = ['id', 'interface_a', 'interface_b', 'connection_status']
-
-
 #
 # Virtual chassis
 #
 
-class VirtualChassisSerializer(serializers.ModelSerializer):
+class VirtualChassisSerializer(ValidatedModelSerializer):
     master = NestedDeviceSerializer()
 
     class Meta:
@@ -846,16 +683,9 @@ class VirtualChassisSerializer(serializers.ModelSerializer):
         fields = ['id', 'master', 'domain']
 
 
-class NestedVirtualChassisSerializer(serializers.ModelSerializer):
+class NestedVirtualChassisSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
 
     class Meta:
         model = VirtualChassis
         fields = ['id', 'url']
-
-
-class WritableVirtualChassisSerializer(ValidatedModelSerializer):
-
-    class Meta:
-        model = VirtualChassis
-        fields = ['id', 'master', 'domain']

+ 0 - 23
netbox/dcim/api/views.py

@@ -52,7 +52,6 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
 class RegionViewSet(ModelViewSet):
     queryset = Region.objects.all()
     serializer_class = serializers.RegionSerializer
-    write_serializer_class = serializers.WritableRegionSerializer
     filter_class = filters.RegionFilter
 
 
@@ -63,7 +62,6 @@ class RegionViewSet(ModelViewSet):
 class SiteViewSet(CustomFieldModelViewSet):
     queryset = Site.objects.select_related('region', 'tenant')
     serializer_class = serializers.SiteSerializer
-    write_serializer_class = serializers.WritableSiteSerializer
     filter_class = filters.SiteFilter
 
     @detail_route()
@@ -84,7 +82,6 @@ class SiteViewSet(CustomFieldModelViewSet):
 class RackGroupViewSet(ModelViewSet):
     queryset = RackGroup.objects.select_related('site')
     serializer_class = serializers.RackGroupSerializer
-    write_serializer_class = serializers.WritableRackGroupSerializer
     filter_class = filters.RackGroupFilter
 
 
@@ -105,7 +102,6 @@ class RackRoleViewSet(ModelViewSet):
 class RackViewSet(CustomFieldModelViewSet):
     queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
     serializer_class = serializers.RackSerializer
-    write_serializer_class = serializers.WritableRackSerializer
     filter_class = filters.RackFilter
 
     @detail_route()
@@ -136,7 +132,6 @@ class RackViewSet(CustomFieldModelViewSet):
 class RackReservationViewSet(ModelViewSet):
     queryset = RackReservation.objects.select_related('rack', 'user', 'tenant')
     serializer_class = serializers.RackReservationSerializer
-    write_serializer_class = serializers.WritableRackReservationSerializer
     filter_class = filters.RackReservationFilter
 
     # Assign user from request
@@ -161,7 +156,6 @@ class ManufacturerViewSet(ModelViewSet):
 class DeviceTypeViewSet(CustomFieldModelViewSet):
     queryset = DeviceType.objects.select_related('manufacturer')
     serializer_class = serializers.DeviceTypeSerializer
-    write_serializer_class = serializers.WritableDeviceTypeSerializer
     filter_class = filters.DeviceTypeFilter
 
 
@@ -172,42 +166,36 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
 class ConsolePortTemplateViewSet(ModelViewSet):
     queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.ConsolePortTemplateSerializer
-    write_serializer_class = serializers.WritableConsolePortTemplateSerializer
     filter_class = filters.ConsolePortTemplateFilter
 
 
 class ConsoleServerPortTemplateViewSet(ModelViewSet):
     queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.ConsoleServerPortTemplateSerializer
-    write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
     filter_class = filters.ConsoleServerPortTemplateFilter
 
 
 class PowerPortTemplateViewSet(ModelViewSet):
     queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.PowerPortTemplateSerializer
-    write_serializer_class = serializers.WritablePowerPortTemplateSerializer
     filter_class = filters.PowerPortTemplateFilter
 
 
 class PowerOutletTemplateViewSet(ModelViewSet):
     queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.PowerOutletTemplateSerializer
-    write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
     filter_class = filters.PowerOutletTemplateFilter
 
 
 class InterfaceTemplateViewSet(ModelViewSet):
     queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.InterfaceTemplateSerializer
-    write_serializer_class = serializers.WritableInterfaceTemplateSerializer
     filter_class = filters.InterfaceTemplateFilter
 
 
 class DeviceBayTemplateViewSet(ModelViewSet):
     queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.DeviceBayTemplateSerializer
-    write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
     filter_class = filters.DeviceBayTemplateFilter
 
 
@@ -228,7 +216,6 @@ class DeviceRoleViewSet(ModelViewSet):
 class PlatformViewSet(ModelViewSet):
     queryset = Platform.objects.all()
     serializer_class = serializers.PlatformSerializer
-    write_serializer_class = serializers.WritablePlatformSerializer
     filter_class = filters.PlatformFilter
 
 
@@ -244,7 +231,6 @@ class DeviceViewSet(CustomFieldModelViewSet):
         'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
     )
     serializer_class = serializers.DeviceSerializer
-    write_serializer_class = serializers.WritableDeviceSerializer
     filter_class = filters.DeviceFilter
 
     @detail_route(url_path='napalm')
@@ -318,35 +304,30 @@ class DeviceViewSet(CustomFieldModelViewSet):
 class ConsolePortViewSet(ModelViewSet):
     queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
     serializer_class = serializers.ConsolePortSerializer
-    write_serializer_class = serializers.WritableConsolePortSerializer
     filter_class = filters.ConsolePortFilter
 
 
 class ConsoleServerPortViewSet(ModelViewSet):
     queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
     serializer_class = serializers.ConsoleServerPortSerializer
-    write_serializer_class = serializers.WritableConsoleServerPortSerializer
     filter_class = filters.ConsoleServerPortFilter
 
 
 class PowerPortViewSet(ModelViewSet):
     queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
     serializer_class = serializers.PowerPortSerializer
-    write_serializer_class = serializers.WritablePowerPortSerializer
     filter_class = filters.PowerPortFilter
 
 
 class PowerOutletViewSet(ModelViewSet):
     queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
     serializer_class = serializers.PowerOutletSerializer
-    write_serializer_class = serializers.WritablePowerOutletSerializer
     filter_class = filters.PowerOutletFilter
 
 
 class InterfaceViewSet(ModelViewSet):
     queryset = Interface.objects.select_related('device')
     serializer_class = serializers.InterfaceSerializer
-    write_serializer_class = serializers.WritableInterfaceSerializer
     filter_class = filters.InterfaceFilter
 
     @detail_route()
@@ -363,14 +344,12 @@ class InterfaceViewSet(ModelViewSet):
 class DeviceBayViewSet(ModelViewSet):
     queryset = DeviceBay.objects.select_related('installed_device')
     serializer_class = serializers.DeviceBaySerializer
-    write_serializer_class = serializers.WritableDeviceBaySerializer
     filter_class = filters.DeviceBayFilter
 
 
 class InventoryItemViewSet(ModelViewSet):
     queryset = InventoryItem.objects.select_related('device', 'manufacturer')
     serializer_class = serializers.InventoryItemSerializer
-    write_serializer_class = serializers.WritableInventoryItemSerializer
     filter_class = filters.InventoryItemFilter
 
 
@@ -393,7 +372,6 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
 class InterfaceConnectionViewSet(ModelViewSet):
     queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
     serializer_class = serializers.InterfaceConnectionSerializer
-    write_serializer_class = serializers.WritableInterfaceConnectionSerializer
     filter_class = filters.InterfaceConnectionFilter
 
 
@@ -404,7 +382,6 @@ class InterfaceConnectionViewSet(ModelViewSet):
 class VirtualChassisViewSet(ModelViewSet):
     queryset = VirtualChassis.objects.all()
     serializer_class = serializers.VirtualChassisSerializer
-    write_serializer_class = serializers.WritableVirtualChassisSerializer
 
 
 #

+ 18 - 27
netbox/dcim/tests/test_api.py

@@ -2327,8 +2327,8 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
             'device': self.device.pk,
             'name': 'Test Interface 4',
             'mode': IFACE_MODE_TAGGED,
+            'untagged_vlan': self.vlan3.id,
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
-            'untagged_vlan': self.vlan3.id
         }
 
         url = reverse('dcim-api:interface-list')
@@ -2336,11 +2336,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Interface.objects.count(), 4)
-        interface5 = Interface.objects.get(pk=response.data['id'])
-        self.assertEqual(interface5.device_id, data['device'])
-        self.assertEqual(interface5.name, data['name'])
-        self.assertEqual(interface5.tagged_vlans.count(), 2)
-        self.assertEqual(interface5.untagged_vlan.id, data['untagged_vlan'])
+        self.assertEqual(response.data['device']['id'], data['device'])
+        self.assertEqual(response.data['name'], data['name'])
+        self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan'])
+        self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans'])
 
     def test_create_interface_bulk(self):
 
@@ -2375,22 +2374,22 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
                 'device': self.device.pk,
                 'name': 'Test Interface 4',
                 'mode': IFACE_MODE_TAGGED,
-                'tagged_vlans': [self.vlan1.id],
                 'untagged_vlan': self.vlan2.id,
+                'tagged_vlans': [self.vlan1.id],
             },
             {
                 'device': self.device.pk,
                 'name': 'Test Interface 5',
                 'mode': IFACE_MODE_TAGGED,
-                'tagged_vlans': [self.vlan1.id],
                 'untagged_vlan': self.vlan2.id,
+                'tagged_vlans': [self.vlan1.id],
             },
             {
                 'device': self.device.pk,
                 'name': 'Test Interface 6',
                 'mode': IFACE_MODE_TAGGED,
-                'tagged_vlans': [self.vlan1.id],
                 'untagged_vlan': self.vlan2.id,
+                'tagged_vlans': [self.vlan1.id],
             },
         ]
 
@@ -2399,15 +2398,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(Interface.objects.count(), 6)
-        self.assertEqual(response.data[0]['name'], data[0]['name'])
-        self.assertEqual(response.data[1]['name'], data[1]['name'])
-        self.assertEqual(response.data[2]['name'], data[2]['name'])
-        self.assertEqual(len(response.data[0]['tagged_vlans']), 1)
-        self.assertEqual(len(response.data[1]['tagged_vlans']), 1)
-        self.assertEqual(len(response.data[2]['tagged_vlans']), 1)
-        self.assertEqual(response.data[0]['untagged_vlan'], self.vlan2.id)
-        self.assertEqual(response.data[1]['untagged_vlan'], self.vlan2.id)
-        self.assertEqual(response.data[2]['untagged_vlan'], self.vlan2.id)
+        for i in range(0, 3):
+            self.assertEqual(response.data[i]['name'], data[i]['name'])
+            self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans'])
+            self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan'])
 
     def test_update_interface(self):
 
@@ -2852,9 +2846,9 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase):
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(InterfaceConnection.objects.count(), 6)
-        self.assertEqual(response.data[0]['interface_a'], data[0]['interface_a'])
-        self.assertEqual(response.data[1]['interface_a'], data[1]['interface_a'])
-        self.assertEqual(response.data[2]['interface_a'], data[2]['interface_a'])
+        for i in range(0, 3):
+            self.assertEqual(response.data[i]['interface_a']['id'], data[i]['interface_a'])
+            self.assertEqual(response.data[i]['interface_b']['id'], data[i]['interface_b'])
 
     def test_update_interfaceconnection(self):
 
@@ -3052,12 +3046,9 @@ class VirtualChassisTest(HttpStatusMixin, APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(VirtualChassis.objects.count(), 5)
-        self.assertEqual(response.data[0]['master'], data[0]['master'])
-        self.assertEqual(response.data[0]['domain'], data[0]['domain'])
-        self.assertEqual(response.data[1]['master'], data[1]['master'])
-        self.assertEqual(response.data[1]['domain'], data[1]['domain'])
-        self.assertEqual(response.data[2]['master'], data[2]['master'])
-        self.assertEqual(response.data[2]['domain'], data[2]['domain'])
+        for i in range(0, 3):
+            self.assertEqual(response.data[i]['master']['id'], data[i]['master'])
+            self.assertEqual(response.data[i]['domain'], data[i]['domain'])
 
     def test_update_virtualchassis(self):
 

+ 23 - 42
netbox/extras/api/serializers.py

@@ -15,7 +15,7 @@ from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, Val
 # Graphs
 #
 
-class GraphSerializer(serializers.ModelSerializer):
+class GraphSerializer(ValidatedModelSerializer):
     type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
 
     class Meta:
@@ -23,13 +23,6 @@ class GraphSerializer(serializers.ModelSerializer):
         fields = ['id', 'type', 'weight', 'name', 'source', 'link']
 
 
-class WritableGraphSerializer(serializers.ModelSerializer):
-
-    class Meta:
-        model = Graph
-        fields = ['id', 'type', 'weight', 'name', 'source', 'link']
-
-
 class RenderedGraphSerializer(serializers.ModelSerializer):
     embed_url = serializers.SerializerMethodField()
     embed_link = serializers.SerializerMethodField()
@@ -50,7 +43,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
 # Export templates
 #
 
-class ExportTemplateSerializer(serializers.ModelSerializer):
+class ExportTemplateSerializer(ValidatedModelSerializer):
 
     class Meta:
         model = ExportTemplate
@@ -61,7 +54,7 @@ class ExportTemplateSerializer(serializers.ModelSerializer):
 # Topology maps
 #
 
-class TopologyMapSerializer(serializers.ModelSerializer):
+class TopologyMapSerializer(ValidatedModelSerializer):
     site = NestedSiteSerializer()
 
     class Meta:
@@ -69,45 +62,19 @@ class TopologyMapSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
 
 
-class WritableTopologyMapSerializer(serializers.ModelSerializer):
-
-    class Meta:
-        model = TopologyMap
-        fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
-
-
 #
 # Image attachments
 #
 
-class ImageAttachmentSerializer(serializers.ModelSerializer):
-    parent = serializers.SerializerMethodField()
-
-    class Meta:
-        model = ImageAttachment
-        fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created']
-
-    def get_parent(self, obj):
-
-        # Static mapping of models to their nested serializers
-        if isinstance(obj.parent, Device):
-            serializer = NestedDeviceSerializer
-        elif isinstance(obj.parent, Rack):
-            serializer = NestedRackSerializer
-        elif isinstance(obj.parent, Site):
-            serializer = NestedSiteSerializer
-        else:
-            raise Exception("Unexpected type of parent object for ImageAttachment")
-
-        return serializer(obj.parent, context={'request': self.context['request']}).data
-
-
-class WritableImageAttachmentSerializer(ValidatedModelSerializer):
+class ImageAttachmentSerializer(ValidatedModelSerializer):
     content_type = ContentTypeFieldSerializer()
+    parent = serializers.SerializerMethodField(read_only=True)
 
     class Meta:
         model = ImageAttachment
-        fields = ['id', 'content_type', 'object_id', 'name', 'image']
+        fields = [
+            'id', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created',
+        ]
 
     def validate(self, data):
 
@@ -120,10 +87,24 @@ class WritableImageAttachmentSerializer(ValidatedModelSerializer):
             )
 
         # Enforce model validation
-        super(WritableImageAttachmentSerializer, self).validate(data)
+        super(ImageAttachmentSerializer, self).validate(data)
 
         return data
 
+    def get_parent(self, obj):
+
+        # Static mapping of models to their nested serializers
+        if isinstance(obj.parent, Device):
+            serializer = NestedDeviceSerializer
+        elif isinstance(obj.parent, Rack):
+            serializer = NestedRackSerializer
+        elif isinstance(obj.parent, Site):
+            serializer = NestedSiteSerializer
+        else:
+            raise Exception("Unexpected type of parent object for ImageAttachment")
+
+        return serializer(obj.parent, context={'request': self.context['request']}).data
+
 
 #
 # Reports

+ 0 - 3
netbox/extras/api/views.py

@@ -67,7 +67,6 @@ class CustomFieldModelViewSet(ModelViewSet):
 class GraphViewSet(ModelViewSet):
     queryset = Graph.objects.all()
     serializer_class = serializers.GraphSerializer
-    write_serializer_class = serializers.WritableGraphSerializer
     filter_class = filters.GraphFilter
 
 
@@ -88,7 +87,6 @@ class ExportTemplateViewSet(ModelViewSet):
 class TopologyMapViewSet(ModelViewSet):
     queryset = TopologyMap.objects.select_related('site')
     serializer_class = serializers.TopologyMapSerializer
-    write_serializer_class = serializers.WritableTopologyMapSerializer
     filter_class = filters.TopologyMapFilter
 
     @detail_route()
@@ -118,7 +116,6 @@ class TopologyMapViewSet(ModelViewSet):
 class ImageAttachmentViewSet(ModelViewSet):
     queryset = ImageAttachment.objects.all()
     serializer_class = serializers.ImageAttachmentSerializer
-    write_serializer_class = serializers.WritableImageAttachmentSerializer
 
 
 #

+ 58 - 114
netbox/ipam/api/serializers.py

@@ -14,7 +14,7 @@ from ipam.constants import (
 )
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
+from utilities.api import ChoiceFieldSerializer, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer
 from virtualization.api.serializers import NestedVirtualMachineSerializer
 
 
@@ -23,7 +23,7 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer
 #
 
 class VRFSerializer(CustomFieldModelSerializer):
-    tenant = NestedTenantSerializer()
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
 
     class Meta:
         model = VRF
@@ -33,7 +33,7 @@ class VRFSerializer(CustomFieldModelSerializer):
         ]
 
 
-class NestedVRFSerializer(serializers.ModelSerializer):
+class NestedVRFSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
 
     class Meta:
@@ -41,15 +41,6 @@ class NestedVRFSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'rd']
 
 
-class WritableVRFSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = VRF
-        fields = [
-            'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated',
-        ]
-
-
 #
 # Roles
 #
@@ -61,7 +52,7 @@ class RoleSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'weight']
 
 
-class NestedRoleSerializer(serializers.ModelSerializer):
+class NestedRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
 
     class Meta:
@@ -80,7 +71,7 @@ class RIRSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'is_private']
 
 
-class NestedRIRSerializer(serializers.ModelSerializer):
+class NestedRIRSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
 
     class Meta:
@@ -100,9 +91,10 @@ class AggregateSerializer(CustomFieldModelSerializer):
         fields = [
             'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated',
         ]
+        read_only_fields = ['family']
 
 
-class NestedAggregateSerializer(serializers.ModelSerializer):
+class NestedAggregateSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
 
     class Meta(AggregateSerializer.Meta):
@@ -110,34 +102,12 @@ class NestedAggregateSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'family', 'prefix']
 
 
-class WritableAggregateSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = Aggregate
-        fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated']
-
-
 #
 # VLAN groups
 #
 
-class VLANGroupSerializer(serializers.ModelSerializer):
-    site = NestedSiteSerializer()
-
-    class Meta:
-        model = VLANGroup
-        fields = ['id', 'name', 'slug', 'site']
-
-
-class NestedVLANGroupSerializer(serializers.ModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
-
-    class Meta:
-        model = VLANGroup
-        fields = ['id', 'url', 'name', 'slug']
-
-
-class WritableVLANGroupSerializer(serializers.ModelSerializer):
+class VLANGroupSerializer(ValidatedModelSerializer):
+    site = NestedSiteSerializer(required=False, allow_null=True)
 
     class Meta:
         model = VLANGroup
@@ -154,21 +124,29 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer):
                 validator(data)
 
         # Enforce model validation
-        super(WritableVLANGroupSerializer, self).validate(data)
+        super(VLANGroupSerializer, self).validate(data)
 
         return data
 
 
+class NestedVLANGroupSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
+
+    class Meta:
+        model = VLANGroup
+        fields = ['id', 'url', 'name', 'slug']
+
+
 #
 # VLANs
 #
 
 class VLANSerializer(CustomFieldModelSerializer):
-    site = NestedSiteSerializer()
-    group = NestedVLANGroupSerializer()
-    tenant = NestedTenantSerializer()
-    status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES)
-    role = NestedRoleSerializer()
+    site = NestedSiteSerializer(required=False, allow_null=True)
+    group = NestedVLANGroupSerializer(required=False, allow_null=True)
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
+    status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False)
+    role = NestedRoleSerializer(required=False, allow_null=True)
 
     class Meta:
         model = VLAN
@@ -176,24 +154,6 @@ class VLANSerializer(CustomFieldModelSerializer):
             'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
             'custom_fields', 'created', 'last_updated',
         ]
-
-
-class NestedVLANSerializer(serializers.ModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
-
-    class Meta:
-        model = VLAN
-        fields = ['id', 'url', 'vid', 'name', 'display_name']
-
-
-class WritableVLANSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = VLAN
-        fields = [
-            'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created',
-            'last_updated',
-        ]
         validators = []
 
     def validate(self, data):
@@ -206,22 +166,30 @@ class WritableVLANSerializer(CustomFieldModelSerializer):
                 validator(data)
 
         # Enforce model validation
-        super(WritableVLANSerializer, self).validate(data)
+        super(VLANSerializer, self).validate(data)
 
         return data
 
 
+class NestedVLANSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
+
+    class Meta:
+        model = VLAN
+        fields = ['id', 'url', 'vid', 'name', 'display_name']
+
+
 #
 # Prefixes
 #
 
 class PrefixSerializer(CustomFieldModelSerializer):
-    site = NestedSiteSerializer()
-    vrf = NestedVRFSerializer()
-    tenant = NestedTenantSerializer()
-    vlan = NestedVLANSerializer()
-    status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES)
-    role = NestedRoleSerializer()
+    site = NestedSiteSerializer(required=False, allow_null=True)
+    vrf = NestedVRFSerializer(required=False, allow_null=True)
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
+    vlan = NestedVLANSerializer(required=False, allow_null=True)
+    status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False)
+    role = NestedRoleSerializer(required=False, allow_null=True)
 
     class Meta:
         model = Prefix
@@ -229,9 +197,10 @@ class PrefixSerializer(CustomFieldModelSerializer):
             'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
             'custom_fields', 'created', 'last_updated',
         ]
+        read_only_fields = ['family']
 
 
-class NestedPrefixSerializer(serializers.ModelSerializer):
+class NestedPrefixSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
 
     class Meta:
@@ -239,16 +208,6 @@ class NestedPrefixSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'family', 'prefix']
 
 
-class WritablePrefixSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = Prefix
-        fields = [
-            'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
-            'custom_fields', 'created', 'last_updated',
-        ]
-
-
 class AvailablePrefixSerializer(serializers.Serializer):
 
     def to_representation(self, instance):
@@ -288,11 +247,11 @@ class IPAddressInterfaceSerializer(serializers.ModelSerializer):
 
 
 class IPAddressSerializer(CustomFieldModelSerializer):
-    vrf = NestedVRFSerializer()
-    tenant = NestedTenantSerializer()
-    status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
-    role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
-    interface = IPAddressInterfaceSerializer()
+    vrf = NestedVRFSerializer(required=False, allow_null=True)
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
+    status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False)
+    role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False)
+    interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
 
     class Meta:
         model = IPAddress
@@ -300,9 +259,10 @@ class IPAddressSerializer(CustomFieldModelSerializer):
             'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
             'nat_outside', 'custom_fields', 'created', 'last_updated',
         ]
+        read_only_fields = ['family']
 
 
-class NestedIPAddressSerializer(serializers.ModelSerializer):
+class NestedIPAddressSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
 
     class Meta:
@@ -310,18 +270,8 @@ class NestedIPAddressSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'family', 'address']
 
 
-IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
-IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
-
-
-class WritableIPAddressSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = IPAddress
-        fields = [
-            'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
-            'custom_fields', 'created', 'last_updated',
-        ]
+IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer(required=False, allow_null=True)
+IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer(read_only=True)
 
 
 class AvailableIPSerializer(serializers.Serializer):
@@ -342,22 +292,16 @@ class AvailableIPSerializer(serializers.Serializer):
 # Services
 #
 
-class ServiceSerializer(serializers.ModelSerializer):
-    device = NestedDeviceSerializer()
-    virtual_machine = NestedVirtualMachineSerializer()
+class ServiceSerializer(ValidatedModelSerializer):
+    device = NestedDeviceSerializer(required=False, allow_null=True)
+    virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
     protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
-    ipaddresses = NestedIPAddressSerializer(many=True)
-
-    class Meta:
-        model = Service
-        fields = [
-            'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
-            'last_updated',
-        ]
-
-
-# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
-class WritableServiceSerializer(serializers.ModelSerializer):
+    ipaddresses = SerializedPKRelatedField(
+        queryset=IPAddress.objects.all(),
+        serializer=NestedIPAddressSerializer,
+        required=False,
+        many=True
+    )
 
     class Meta:
         model = Service

+ 4 - 11
netbox/ipam/api/views.py

@@ -35,7 +35,6 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
 class VRFViewSet(CustomFieldModelViewSet):
     queryset = VRF.objects.select_related('tenant')
     serializer_class = serializers.VRFSerializer
-    write_serializer_class = serializers.WritableVRFSerializer
     filter_class = filters.VRFFilter
 
 
@@ -56,7 +55,6 @@ class RIRViewSet(ModelViewSet):
 class AggregateViewSet(CustomFieldModelViewSet):
     queryset = Aggregate.objects.select_related('rir')
     serializer_class = serializers.AggregateSerializer
-    write_serializer_class = serializers.WritableAggregateSerializer
     filter_class = filters.AggregateFilter
 
 
@@ -77,7 +75,6 @@ class RoleViewSet(ModelViewSet):
 class PrefixViewSet(CustomFieldModelViewSet):
     queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     serializer_class = serializers.PrefixSerializer
-    write_serializer_class = serializers.WritablePrefixSerializer
     filter_class = filters.PrefixFilter
 
     @detail_route(url_path='available-prefixes', methods=['get', 'post'])
@@ -120,9 +117,9 @@ class PrefixViewSet(CustomFieldModelViewSet):
 
             # Initialize the serializer with a list or a single object depending on what was requested
             if isinstance(request.data, list):
-                serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True)
+                serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True)
             else:
-                serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0])
+                serializer = serializers.PrefixSerializer(data=requested_prefixes[0])
 
             # Create the new Prefix(es)
             if serializer.is_valid():
@@ -177,9 +174,9 @@ class PrefixViewSet(CustomFieldModelViewSet):
 
             # Initialize the serializer with a list or a single object depending on what was requested
             if isinstance(request.data, list):
-                serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True)
+                serializer = serializers.IPAddressSerializer(data=requested_ips, many=True)
             else:
-                serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0])
+                serializer = serializers.IPAddressSerializer(data=requested_ips[0])
 
             # Create the new IP address(es)
             if serializer.is_valid():
@@ -223,7 +220,6 @@ class IPAddressViewSet(CustomFieldModelViewSet):
         'nat_outside'
     )
     serializer_class = serializers.IPAddressSerializer
-    write_serializer_class = serializers.WritableIPAddressSerializer
     filter_class = filters.IPAddressFilter
 
 
@@ -234,7 +230,6 @@ class IPAddressViewSet(CustomFieldModelViewSet):
 class VLANGroupViewSet(ModelViewSet):
     queryset = VLANGroup.objects.select_related('site')
     serializer_class = serializers.VLANGroupSerializer
-    write_serializer_class = serializers.WritableVLANGroupSerializer
     filter_class = filters.VLANGroupFilter
 
 
@@ -245,7 +240,6 @@ class VLANGroupViewSet(ModelViewSet):
 class VLANViewSet(CustomFieldModelViewSet):
     queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
     serializer_class = serializers.VLANSerializer
-    write_serializer_class = serializers.WritableVLANSerializer
     filter_class = filters.VLANFilter
 
 
@@ -256,5 +250,4 @@ class VLANViewSet(CustomFieldModelViewSet):
 class ServiceViewSet(ModelViewSet):
     queryset = Service.objects.select_related('device')
     serializer_class = serializers.ServiceSerializer
-    write_serializer_class = serializers.WritableServiceSerializer
     filter_class = filters.ServiceFilter

+ 4 - 11
netbox/secrets/api/serializers.py

@@ -5,7 +5,7 @@ from rest_framework.validators import UniqueTogetherValidator
 
 from dcim.api.serializers import NestedDeviceSerializer
 from secrets.models import Secret, SecretRole
-from utilities.api import ValidatedModelSerializer
+from utilities.api import ValidatedModelSerializer, WritableNestedSerializer
 
 
 #
@@ -19,7 +19,7 @@ class SecretRoleSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug']
 
 
-class NestedSecretRoleSerializer(serializers.ModelSerializer):
+class NestedSecretRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
 
     class Meta:
@@ -31,16 +31,9 @@ class NestedSecretRoleSerializer(serializers.ModelSerializer):
 # Secrets
 #
 
-class SecretSerializer(serializers.ModelSerializer):
+class SecretSerializer(ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     role = NestedSecretRoleSerializer()
-
-    class Meta:
-        model = Secret
-        fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
-
-
-class WritableSecretSerializer(serializers.ModelSerializer):
     plaintext = serializers.CharField()
 
     class Meta:
@@ -64,6 +57,6 @@ class WritableSecretSerializer(serializers.ModelSerializer):
             validator(data)
 
         # Enforce model validation
-        super(WritableSecretSerializer, self).validate(data)
+        super(SecretSerializer, self).validate(data)
 
         return data

+ 0 - 1
netbox/secrets/api/views.py

@@ -51,7 +51,6 @@ class SecretViewSet(ModelViewSet):
         'role__users', 'role__groups',
     )
     serializer_class = serializers.SecretSerializer
-    write_serializer_class = serializers.WritableSecretSerializer
     filter_class = filters.SecretFilter
 
     master_key = None

+ 4 - 11
netbox/tenancy/api/serializers.py

@@ -4,7 +4,7 @@ from rest_framework import serializers
 
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.models import Tenant, TenantGroup
-from utilities.api import ValidatedModelSerializer
+from utilities.api import ValidatedModelSerializer, WritableNestedSerializer
 
 
 #
@@ -18,7 +18,7 @@ class TenantGroupSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug']
 
 
-class NestedTenantGroupSerializer(serializers.ModelSerializer):
+class NestedTenantGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
 
     class Meta:
@@ -31,23 +31,16 @@ class NestedTenantGroupSerializer(serializers.ModelSerializer):
 #
 
 class TenantSerializer(CustomFieldModelSerializer):
-    group = NestedTenantGroupSerializer()
+    group = NestedTenantGroupSerializer(required=False)
 
     class Meta:
         model = Tenant
         fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
 
 
-class NestedTenantSerializer(serializers.ModelSerializer):
+class NestedTenantSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
 
     class Meta:
         model = Tenant
         fields = ['id', 'url', 'name', 'slug']
-
-
-class WritableTenantSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = Tenant
-        fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']

+ 0 - 1
netbox/tenancy/api/views.py

@@ -32,5 +32,4 @@ class TenantGroupViewSet(ModelViewSet):
 class TenantViewSet(CustomFieldModelViewSet):
     queryset = Tenant.objects.select_related('group')
     serializer_class = serializers.TenantSerializer
-    write_serializer_class = serializers.WritableTenantSerializer
     filter_class = filters.TenantFilter

+ 3 - 2
netbox/users/api/serializers.py

@@ -1,10 +1,11 @@
 from __future__ import unicode_literals
 
 from django.contrib.auth.models import User
-from rest_framework import serializers
 
+from utilities.api import WritableNestedSerializer
 
-class NestedUserSerializer(serializers.ModelSerializer):
+
+class NestedUserSerializer(WritableNestedSerializer):
 
     class Meta:
         model = User

+ 63 - 39
netbox/utilities/api.py

@@ -5,11 +5,13 @@ import pytz
 
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import ManyToManyField
 from django.http import Http404
 from rest_framework import mixins
 from rest_framework.exceptions import APIException
 from rest_framework.permissions import BasePermission
+from rest_framework.relations import PrimaryKeyRelatedField
 from rest_framework.response import Response
 from rest_framework.serializers import Field, ModelSerializer, ValidationError
 from rest_framework.viewsets import GenericViewSet, ViewSet
@@ -37,36 +39,9 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
 
 
 #
-# Serializers
+# Fields
 #
 
-class ValidatedModelSerializer(ModelSerializer):
-    """
-    Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation.
-    """
-    def validate(self, data):
-
-        # Remove custom field data (if any) prior to model validation
-        attrs = data.copy()
-        attrs.pop('custom_fields', None)
-
-        # Run clean() on an instance of the model
-        if self.instance is None:
-            model = self.Meta.model
-            # Ignore ManyToManyFields for new instances (a PK is needed for validation)
-            for field in model._meta.get_fields():
-                if isinstance(field, ManyToManyField) and field.name in attrs:
-                    attrs.pop(field.name)
-            instance = self.Meta.model(**attrs)
-        else:
-            instance = self.instance
-            for k, v in attrs.items():
-                setattr(instance, k, v)
-        instance.clean()
-
-        return data
-
-
 class ChoiceFieldSerializer(Field):
     """
     Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.
@@ -86,7 +61,7 @@ class ChoiceFieldSerializer(Field):
         return {'value': obj, 'label': self._choices[obj]}
 
     def to_internal_value(self, data):
-        return self._choices.get(data)
+        return data
 
 
 class ContentTypeFieldSerializer(Field):
@@ -108,7 +83,6 @@ class TimeZoneField(Field):
     """
     Represent a pytz time zone.
     """
-
     def to_representation(self, obj):
         return obj.zone if obj else None
 
@@ -121,6 +95,64 @@ class TimeZoneField(Field):
             raise ValidationError('Invalid time zone "{}"'.format(data))
 
 
+class SerializedPKRelatedField(PrimaryKeyRelatedField):
+    """
+    Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
+    objects in a ManyToManyField while still allowing a set of primary keys to be written.
+    """
+    def __init__(self, serializer, **kwargs):
+        self.serializer = serializer
+        self.pk_field = kwargs.pop('pk_field', None)
+        super(SerializedPKRelatedField, self).__init__(**kwargs)
+
+    def to_representation(self, value):
+        return self.serializer(value, context={'request': self.context['request']}).data
+
+
+#
+# Serializers
+#
+
+class ValidatedModelSerializer(ModelSerializer):
+    """
+    Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation.
+    """
+    def validate(self, data):
+
+        # Remove custom field data (if any) prior to model validation
+        attrs = data.copy()
+        attrs.pop('custom_fields', None)
+
+        # Run clean() on an instance of the model
+        if self.instance is None:
+            model = self.Meta.model
+            # Ignore ManyToManyFields for new instances (a PK is needed for validation)
+            for field in model._meta.get_fields():
+                if isinstance(field, ManyToManyField) and field.name in attrs:
+                    attrs.pop(field.name)
+            instance = self.Meta.model(**attrs)
+        else:
+            instance = self.instance
+            for k, v in attrs.items():
+                setattr(instance, k, v)
+        instance.clean()
+
+        return data
+
+
+class WritableNestedSerializer(ModelSerializer):
+    """
+    Returns a nested representation of an object on read, but accepts only a primary key on write.
+    """
+    def to_internal_value(self, data):
+        if data is None:
+            return None
+        try:
+            return self.Meta.model.objects.get(pk=data)
+        except ObjectDoesNotExist:
+            raise ValidationError("Invalid ID")
+
+
 #
 # Viewsets
 #
@@ -132,16 +164,8 @@ class ModelViewSet(mixins.CreateModelMixin,
                    mixins.ListModelMixin,
                    GenericViewSet):
     """
-    Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality:
-    1. Use an alternate serializer (if provided) for write operations
-    2. Accept either a single object or a list of objects to create
+    Accept either a single object or a list of objects to create.
     """
-    def get_serializer_class(self):
-        # Check for a different serializer to use for write operations
-        if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
-            return self.write_serializer_class
-        return self.serializer_class
-
     def get_serializer(self, *args, **kwargs):
         # If a list of objects has been provided, initialize the serializer with many=True
         if isinstance(kwargs.get('data', {}), list):

+ 18 - 44
netbox/virtualization/api/serializers.py

@@ -8,7 +8,7 @@ from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from ipam.models import IPAddress
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
+from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer
 from virtualization.constants import VM_STATUS_CHOICES
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
@@ -24,7 +24,7 @@ class ClusterTypeSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug']
 
 
-class NestedClusterTypeSerializer(serializers.ModelSerializer):
+class NestedClusterTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
 
     class Meta:
@@ -43,7 +43,7 @@ class ClusterGroupSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug']
 
 
-class NestedClusterGroupSerializer(serializers.ModelSerializer):
+class NestedClusterGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
 
     class Meta:
@@ -57,15 +57,15 @@ class NestedClusterGroupSerializer(serializers.ModelSerializer):
 
 class ClusterSerializer(CustomFieldModelSerializer):
     type = NestedClusterTypeSerializer()
-    group = NestedClusterGroupSerializer()
-    site = NestedSiteSerializer()
+    group = NestedClusterGroupSerializer(required=False, allow_null=True)
+    site = NestedSiteSerializer(required=False, allow_null=True)
 
     class Meta:
         model = Cluster
         fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
 
 
-class NestedClusterSerializer(serializers.ModelSerializer):
+class NestedClusterSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
 
     class Meta:
@@ -73,13 +73,6 @@ class NestedClusterSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name']
 
 
-class WritableClusterSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = Cluster
-        fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
-
-
 #
 # Virtual machines
 #
@@ -94,14 +87,14 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer):
 
 
 class VirtualMachineSerializer(CustomFieldModelSerializer):
-    status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES)
-    cluster = NestedClusterSerializer()
-    role = NestedDeviceRoleSerializer()
-    tenant = NestedTenantSerializer()
-    platform = NestedPlatformSerializer()
-    primary_ip = VirtualMachineIPAddressSerializer()
-    primary_ip4 = VirtualMachineIPAddressSerializer()
-    primary_ip6 = VirtualMachineIPAddressSerializer()
+    status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES, required=False)
+    cluster = NestedClusterSerializer(required=False, allow_null=True)
+    role = NestedDeviceRoleSerializer(required=False, allow_null=True)
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
+    platform = NestedPlatformSerializer(required=False, allow_null=True)
+    primary_ip = VirtualMachineIPAddressSerializer(read_only=True)
+    primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
+    primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
 
     class Meta:
         model = VirtualMachine
@@ -111,7 +104,7 @@ class VirtualMachineSerializer(CustomFieldModelSerializer):
         ]
 
 
-class NestedVirtualMachineSerializer(serializers.ModelSerializer):
+class NestedVirtualMachineSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
 
     class Meta:
@@ -119,22 +112,13 @@ class NestedVirtualMachineSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name']
 
 
-class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
-
-    class Meta:
-        model = VirtualMachine
-        fields = [
-            'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus',
-            'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
-        ]
-
-
 #
 # VM interfaces
 #
 
-class InterfaceSerializer(serializers.ModelSerializer):
+class InterfaceSerializer(ValidatedModelSerializer):
     virtual_machine = NestedVirtualMachineSerializer()
+    form_factor = serializers.IntegerField(default=IFACE_FF_VIRTUAL)
 
     class Meta:
         model = Interface
@@ -143,19 +127,9 @@ class InterfaceSerializer(serializers.ModelSerializer):
         ]
 
 
-class NestedInterfaceSerializer(serializers.ModelSerializer):
+class NestedInterfaceSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
 
     class Meta:
         model = Interface
         fields = ['id', 'url', 'name']
-
-
-class WritableInterfaceSerializer(ValidatedModelSerializer):
-    form_factor = serializers.IntegerField(default=IFACE_FF_VIRTUAL)
-
-    class Meta:
-        model = Interface
-        fields = [
-            'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
-        ]

+ 0 - 3
netbox/virtualization/api/views.py

@@ -37,7 +37,6 @@ class ClusterGroupViewSet(ModelViewSet):
 class ClusterViewSet(CustomFieldModelViewSet):
     queryset = Cluster.objects.select_related('type', 'group')
     serializer_class = serializers.ClusterSerializer
-    write_serializer_class = serializers.WritableClusterSerializer
     filter_class = filters.ClusterFilter
 
 
@@ -48,12 +47,10 @@ class ClusterViewSet(CustomFieldModelViewSet):
 class VirtualMachineViewSet(CustomFieldModelViewSet):
     queryset = VirtualMachine.objects.all()
     serializer_class = serializers.VirtualMachineSerializer
-    write_serializer_class = serializers.WritableVirtualMachineSerializer
     filter_class = filters.VirtualMachineFilter
 
 
 class InterfaceViewSet(ModelViewSet):
     queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
     serializer_class = serializers.InterfaceSerializer
-    write_serializer_class = serializers.WritableInterfaceSerializer
     filter_class = filters.InterfaceFilter

+ 1 - 1
requirements.txt

@@ -5,7 +5,7 @@ django-filter>=1.1.0
 django-mptt>=0.9.0
 django-tables2>=1.19.0
 django-timezone-field>=2.0
-djangorestframework>=3.7.7
+djangorestframework>=3.7.7,<3.8.2
 drf-yasg[validation]>=1.4.4
 graphviz>=0.8.2
 Markdown>=2.6.11