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

Merge pull request #8833 from netbox-community/8823-api-serializers

Closes #8823: Add plugin support for REST API components
Jeremy Stretch 3 лет назад
Родитель
Сommit
f559ceeb7f

+ 27 - 10
docs/plugins/development/rest-api.md

@@ -6,25 +6,37 @@ Generally speaking, there aren't many NetBox-specific components to implementing
 
 ## Serializers
 
-First, create a serializer for the plugin model, in `api/serializers.py`. Specify its model class and the fields to include within the serializer's `Meta` class.
+Serializers are responsible for converting Python objects to JSON data suitable for conveying to consumers, and vice versa. NetBox provides the `NetBoxModelSerializer` class for use by plugins to handle the assignment of tags and custom field data. (These features can also be included ad hoc via the `CustomFieldModelSerializer` and `TaggableModelSerializer` classes.)
+
+### Example
+
+To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class.
 
 ```python
-from rest_framework.serializers import ModelSerializer
+# api/serializers.py
+from netbox.api.serializers import NetBoxModelSerializer
 from my_plugin.models import MyModel
 
-class MyModelSerializer(ModelSerializer):
+class MyModelSerializer(NetBoxModelSerializer):
 
     class Meta:
         model = MyModel
         fields = ('id', 'foo', 'bar')
 ```
 
-## Views
+## Viewsets
+
+Just as in the user interface, a REST API view handles the business logic of displaying and interacting with NetBox objects. NetBox provides the `NetBoxModelViewSet` class, which extends DRF's built-in `ModelViewSet` to handle bulk operations and object validation.
+
+Unlike the user interface, typically only a single view set is required per model: This view set handles all request types (`GET`, `POST`, `DELETE`, etc.).
 
-Next, create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for objects. This is defined in `api/views.py`. Specify the `queryset` and `serializer_class` attributes under the view set.
+### Example
+
+To create a viewset for a plugin model, subclass `NetBoxModelViewSet` in `api/views.py`, and define the `queryset` and `serializer_class` attributes.
 
 ```python
-from rest_framework.viewsets import ModelViewSet
+# api/views.py
+from netbox.api.viewsets import ModelViewSet
 from my_plugin.models import MyModel
 from .serializers import MyModelSerializer
 
@@ -33,11 +45,16 @@ class MyModelViewSet(ModelViewSet):
     serializer_class = MyModelSerializer
 ```
 
-## URLs
+## Routers
+
+Routers map URLs to REST API views (endpoints). NetBox does not provide any custom components for this; the [`DefaultRouter`](https://www.django-rest-framework.org/api-guide/routers/#defaultrouter) class provided by DRF should suffice for most use cases.
+
+Routers should be exposed in `api/urls.py`. This file **must** define a variable named `urlpatterns`.
 
-Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`.
+### Example
 
 ```python
+# api/urls.py
 from rest_framework import routers
 from .views import MyModelViewSet
 
@@ -46,7 +63,7 @@ router.register('my-model', MyModelViewSet)
 urlpatterns = router.urls
 ```
 
-With these three components in place, we can request `/api/plugins/my-plugin/my-model/` to retrieve a list of all MyModel instances.
+This will make the plugin's view accessible at `/api/plugins/my-plugin/my-model/`.
 
 !!! warning
-    This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors may need to address.
+    The examples provided here are intended to serve as a minimal reference implementation only. This documentation does not address authentication, performance, or myriad other concerns that plugin authors may need to address.

+ 5 - 5
netbox/circuits/api/serializers.py

@@ -5,7 +5,7 @@ from circuits.models import *
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 from dcim.api.serializers import LinkTerminationSerializer
 from netbox.api import ChoiceField
-from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
+from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from .nested_serializers import *
 
@@ -14,7 +14,7 @@ from .nested_serializers import *
 # Providers
 #
 
-class ProviderSerializer(PrimaryModelSerializer):
+class ProviderSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     circuit_count = serializers.IntegerField(read_only=True)
 
@@ -30,7 +30,7 @@ class ProviderSerializer(PrimaryModelSerializer):
 # Provider networks
 #
 
-class ProviderNetworkSerializer(PrimaryModelSerializer):
+class ProviderNetworkSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
     provider = NestedProviderSerializer()
 
@@ -46,7 +46,7 @@ class ProviderNetworkSerializer(PrimaryModelSerializer):
 # Circuits
 #
 
-class CircuitTypeSerializer(PrimaryModelSerializer):
+class CircuitTypeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
     circuit_count = serializers.IntegerField(read_only=True)
 
@@ -70,7 +70,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
         ]
 
 
-class CircuitSerializer(PrimaryModelSerializer):
+class CircuitSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
     provider = NestedProviderSerializer()
     status = ChoiceField(choices=CircuitStatusChoices, required=False)

+ 6 - 7
netbox/circuits/api/views.py

@@ -3,8 +3,7 @@ from rest_framework.routers import APIRootView
 from circuits import filtersets
 from circuits.models import *
 from dcim.api.views import PassThroughPortMixin
-from extras.api.views import CustomFieldModelViewSet
-from netbox.api.views import ModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet
 from utilities.utils import count_related
 from . import serializers
 
@@ -21,7 +20,7 @@ class CircuitsRootView(APIRootView):
 # Providers
 #
 
-class ProviderViewSet(CustomFieldModelViewSet):
+class ProviderViewSet(NetBoxModelViewSet):
     queryset = Provider.objects.prefetch_related('tags').annotate(
         circuit_count=count_related(Circuit, 'provider')
     )
@@ -33,7 +32,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
 #  Circuit Types
 #
 
-class CircuitTypeViewSet(CustomFieldModelViewSet):
+class CircuitTypeViewSet(NetBoxModelViewSet):
     queryset = CircuitType.objects.prefetch_related('tags').annotate(
         circuit_count=count_related(Circuit, 'type')
     )
@@ -45,7 +44,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet):
 # Circuits
 #
 
-class CircuitViewSet(CustomFieldModelViewSet):
+class CircuitViewSet(NetBoxModelViewSet):
     queryset = Circuit.objects.prefetch_related(
         'type', 'tenant', 'provider', 'termination_a', 'termination_z'
     ).prefetch_related('tags')
@@ -57,7 +56,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
 # Circuit Terminations
 #
 
-class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
+class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = CircuitTermination.objects.prefetch_related(
         'circuit', 'site', 'provider_network', 'cable'
     )
@@ -70,7 +69,7 @@ class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
 # Provider networks
 #
 
-class ProviderNetworkViewSet(CustomFieldModelViewSet):
+class ProviderNetworkViewSet(NetBoxModelViewSet):
     queryset = ProviderNetwork.objects.prefetch_related('tags')
     serializer_class = serializers.ProviderNetworkSerializer
     filterset_class = filtersets.ProviderNetworkFilterSet

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

@@ -12,7 +12,7 @@ from ipam.api.nested_serializers import (
 from ipam.models import ASN, VLAN
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import (
-    NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
+    NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
 )
 from netbox.config import ConfigItem
 from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -109,7 +109,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
         ]
 
 
-class SiteSerializer(PrimaryModelSerializer):
+class SiteSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
     status = ChoiceField(choices=SiteStatusChoices, required=False)
     region = NestedRegionSerializer(required=False, allow_null=True)
@@ -161,7 +161,7 @@ class LocationSerializer(NestedGroupModelSerializer):
         ]
 
 
-class RackRoleSerializer(PrimaryModelSerializer):
+class RackRoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
     rack_count = serializers.IntegerField(read_only=True)
 
@@ -173,7 +173,7 @@ class RackRoleSerializer(PrimaryModelSerializer):
         ]
 
 
-class RackSerializer(PrimaryModelSerializer):
+class RackSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
     site = NestedSiteSerializer()
     location = NestedLocationSerializer(required=False, allow_null=True, default=None)
@@ -212,7 +212,7 @@ class RackUnitSerializer(serializers.Serializer):
         return obj['name']
 
 
-class RackReservationSerializer(PrimaryModelSerializer):
+class RackReservationSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
     rack = NestedRackSerializer()
     user = NestedUserSerializer()
@@ -266,7 +266,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
 # Device/module types
 #
 
-class ManufacturerSerializer(PrimaryModelSerializer):
+class ManufacturerSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
     devicetype_count = serializers.IntegerField(read_only=True)
     inventoryitem_count = serializers.IntegerField(read_only=True)
@@ -280,7 +280,7 @@ class ManufacturerSerializer(PrimaryModelSerializer):
         ]
 
 
-class DeviceTypeSerializer(PrimaryModelSerializer):
+class DeviceTypeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     manufacturer = NestedManufacturerSerializer()
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
@@ -296,7 +296,7 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
         ]
 
 
-class ModuleTypeSerializer(PrimaryModelSerializer):
+class ModuleTypeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
     manufacturer = NestedManufacturerSerializer()
     # module_count = serializers.IntegerField(read_only=True)
@@ -487,7 +487,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
 # Devices
 #
 
-class DeviceRoleSerializer(PrimaryModelSerializer):
+class DeviceRoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
     device_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
@@ -500,7 +500,7 @@ class DeviceRoleSerializer(PrimaryModelSerializer):
         ]
 
 
-class PlatformSerializer(PrimaryModelSerializer):
+class PlatformSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
     device_count = serializers.IntegerField(read_only=True)
@@ -514,7 +514,7 @@ class PlatformSerializer(PrimaryModelSerializer):
         ]
 
 
-class DeviceSerializer(PrimaryModelSerializer):
+class DeviceSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     device_type = NestedDeviceTypeSerializer()
     device_role = NestedDeviceRoleSerializer()
@@ -556,7 +556,7 @@ class DeviceSerializer(PrimaryModelSerializer):
         return data
 
 
-class ModuleSerializer(PrimaryModelSerializer):
+class ModuleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
     device = NestedDeviceSerializer()
     module_bay = NestedModuleBaySerializer()
@@ -594,7 +594,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
 # Device components
 #
 
-class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -622,7 +622,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali
         ]
 
 
-class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -650,7 +650,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
         ]
 
 
-class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -685,7 +685,7 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
         ]
 
 
-class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -709,7 +709,7 @@ class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
         ]
 
 
-class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -768,7 +768,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
         return super().validate(data)
 
 
-class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
+class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -798,7 +798,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'label']
 
 
-class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
+class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -818,7 +818,7 @@ class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
         ]
 
 
-class ModuleBaySerializer(PrimaryModelSerializer):
+class ModuleBaySerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
     device = NestedDeviceSerializer()
     # installed_module = NestedModuleSerializer(required=False, allow_null=True)
@@ -831,7 +831,7 @@ class ModuleBaySerializer(PrimaryModelSerializer):
         ]
 
 
-class DeviceBaySerializer(PrimaryModelSerializer):
+class DeviceBaySerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
     device = NestedDeviceSerializer()
     installed_device = NestedDeviceSerializer(required=False, allow_null=True)
@@ -844,7 +844,7 @@ class DeviceBaySerializer(PrimaryModelSerializer):
         ]
 
 
-class InventoryItemSerializer(PrimaryModelSerializer):
+class InventoryItemSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     device = NestedDeviceSerializer()
     parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
@@ -879,7 +879,7 @@ class InventoryItemSerializer(PrimaryModelSerializer):
 # Device component roles
 #
 
-class InventoryItemRoleSerializer(PrimaryModelSerializer):
+class InventoryItemRoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
     inventoryitem_count = serializers.IntegerField(read_only=True)
 
@@ -895,7 +895,7 @@ class InventoryItemRoleSerializer(PrimaryModelSerializer):
 # Cables
 #
 
-class CableSerializer(PrimaryModelSerializer):
+class CableSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
     termination_a_type = ContentTypeField(
         queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
@@ -1001,7 +1001,7 @@ class CablePathSerializer(serializers.ModelSerializer):
 # Virtual chassis
 #
 
-class VirtualChassisSerializer(PrimaryModelSerializer):
+class VirtualChassisSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     master = NestedDeviceSerializer(required=False)
     member_count = serializers.IntegerField(read_only=True)
@@ -1018,7 +1018,7 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
 # Power panels
 #
 
-class PowerPanelSerializer(PrimaryModelSerializer):
+class PowerPanelSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
     site = NestedSiteSerializer()
     location = NestedLocationSerializer(
@@ -1036,7 +1036,7 @@ class PowerPanelSerializer(PrimaryModelSerializer):
         ]
 
 
-class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
     power_panel = NestedPowerPanelSerializer()
     rack = NestedRackSerializer(

+ 41 - 41
netbox/dcim/api/views.py

@@ -14,12 +14,12 @@ from rest_framework.viewsets import ViewSet
 from circuits.models import Circuit
 from dcim import filtersets
 from dcim.models import *
-from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
+from extras.api.views import ConfigContextQuerySetMixin
 from ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.metadata import ContentTypeMetadata
-from netbox.api.views import ModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.config import get_config
 from utilities.api import get_serializer_for_model
 from utilities.utils import count_related
@@ -103,7 +103,7 @@ class PassThroughPortMixin(object):
 # Regions
 #
 
-class RegionViewSet(CustomFieldModelViewSet):
+class RegionViewSet(NetBoxModelViewSet):
     queryset = Region.objects.add_related_count(
         Region.objects.all(),
         Site,
@@ -119,7 +119,7 @@ class RegionViewSet(CustomFieldModelViewSet):
 # Site groups
 #
 
-class SiteGroupViewSet(CustomFieldModelViewSet):
+class SiteGroupViewSet(NetBoxModelViewSet):
     queryset = SiteGroup.objects.add_related_count(
         SiteGroup.objects.all(),
         Site,
@@ -135,7 +135,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
 # Sites
 #
 
-class SiteViewSet(CustomFieldModelViewSet):
+class SiteViewSet(NetBoxModelViewSet):
     queryset = Site.objects.prefetch_related(
         'region', 'tenant', 'asns', 'tags'
     ).annotate(
@@ -154,7 +154,7 @@ class SiteViewSet(CustomFieldModelViewSet):
 # Locations
 #
 
-class LocationViewSet(CustomFieldModelViewSet):
+class LocationViewSet(NetBoxModelViewSet):
     queryset = Location.objects.add_related_count(
         Location.objects.add_related_count(
             Location.objects.all(),
@@ -176,7 +176,7 @@ class LocationViewSet(CustomFieldModelViewSet):
 # Rack roles
 #
 
-class RackRoleViewSet(CustomFieldModelViewSet):
+class RackRoleViewSet(NetBoxModelViewSet):
     queryset = RackRole.objects.prefetch_related('tags').annotate(
         rack_count=count_related(Rack, 'role')
     )
@@ -188,7 +188,7 @@ class RackRoleViewSet(CustomFieldModelViewSet):
 # Racks
 #
 
-class RackViewSet(CustomFieldModelViewSet):
+class RackViewSet(NetBoxModelViewSet):
     queryset = Rack.objects.prefetch_related(
         'site', 'location', 'role', 'tenant', 'tags'
     ).annotate(
@@ -250,7 +250,7 @@ class RackViewSet(CustomFieldModelViewSet):
 # Rack reservations
 #
 
-class RackReservationViewSet(ModelViewSet):
+class RackReservationViewSet(NetBoxModelViewSet):
     queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant')
     serializer_class = serializers.RackReservationSerializer
     filterset_class = filtersets.RackReservationFilterSet
@@ -260,7 +260,7 @@ class RackReservationViewSet(ModelViewSet):
 # Manufacturers
 #
 
-class ManufacturerViewSet(CustomFieldModelViewSet):
+class ManufacturerViewSet(NetBoxModelViewSet):
     queryset = Manufacturer.objects.prefetch_related('tags').annotate(
         devicetype_count=count_related(DeviceType, 'manufacturer'),
         inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
@@ -274,7 +274,7 @@ class ManufacturerViewSet(CustomFieldModelViewSet):
 # Device/module types
 #
 
-class DeviceTypeViewSet(CustomFieldModelViewSet):
+class DeviceTypeViewSet(NetBoxModelViewSet):
     queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
         device_count=count_related(Device, 'device_type')
     )
@@ -283,7 +283,7 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
     brief_prefetch_fields = ['manufacturer']
 
 
-class ModuleTypeViewSet(CustomFieldModelViewSet):
+class ModuleTypeViewSet(NetBoxModelViewSet):
     queryset = ModuleType.objects.prefetch_related('manufacturer', 'tags').annotate(
         # module_count=count_related(Module, 'module_type')
     )
@@ -296,61 +296,61 @@ class ModuleTypeViewSet(CustomFieldModelViewSet):
 # Device type components
 #
 
-class ConsolePortTemplateViewSet(ModelViewSet):
+class ConsolePortTemplateViewSet(NetBoxModelViewSet):
     queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.ConsolePortTemplateSerializer
     filterset_class = filtersets.ConsolePortTemplateFilterSet
 
 
-class ConsoleServerPortTemplateViewSet(ModelViewSet):
+class ConsoleServerPortTemplateViewSet(NetBoxModelViewSet):
     queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.ConsoleServerPortTemplateSerializer
     filterset_class = filtersets.ConsoleServerPortTemplateFilterSet
 
 
-class PowerPortTemplateViewSet(ModelViewSet):
+class PowerPortTemplateViewSet(NetBoxModelViewSet):
     queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.PowerPortTemplateSerializer
     filterset_class = filtersets.PowerPortTemplateFilterSet
 
 
-class PowerOutletTemplateViewSet(ModelViewSet):
+class PowerOutletTemplateViewSet(NetBoxModelViewSet):
     queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.PowerOutletTemplateSerializer
     filterset_class = filtersets.PowerOutletTemplateFilterSet
 
 
-class InterfaceTemplateViewSet(ModelViewSet):
+class InterfaceTemplateViewSet(NetBoxModelViewSet):
     queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.InterfaceTemplateSerializer
     filterset_class = filtersets.InterfaceTemplateFilterSet
 
 
-class FrontPortTemplateViewSet(ModelViewSet):
+class FrontPortTemplateViewSet(NetBoxModelViewSet):
     queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.FrontPortTemplateSerializer
     filterset_class = filtersets.FrontPortTemplateFilterSet
 
 
-class RearPortTemplateViewSet(ModelViewSet):
+class RearPortTemplateViewSet(NetBoxModelViewSet):
     queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.RearPortTemplateSerializer
     filterset_class = filtersets.RearPortTemplateFilterSet
 
 
-class ModuleBayTemplateViewSet(ModelViewSet):
+class ModuleBayTemplateViewSet(NetBoxModelViewSet):
     queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.ModuleBayTemplateSerializer
     filterset_class = filtersets.ModuleBayTemplateFilterSet
 
 
-class DeviceBayTemplateViewSet(ModelViewSet):
+class DeviceBayTemplateViewSet(NetBoxModelViewSet):
     queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.DeviceBayTemplateSerializer
     filterset_class = filtersets.DeviceBayTemplateFilterSet
 
 
-class InventoryItemTemplateViewSet(ModelViewSet):
+class InventoryItemTemplateViewSet(NetBoxModelViewSet):
     queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
     serializer_class = serializers.InventoryItemTemplateSerializer
     filterset_class = filtersets.InventoryItemTemplateFilterSet
@@ -360,7 +360,7 @@ class InventoryItemTemplateViewSet(ModelViewSet):
 # Device roles
 #
 
-class DeviceRoleViewSet(CustomFieldModelViewSet):
+class DeviceRoleViewSet(NetBoxModelViewSet):
     queryset = DeviceRole.objects.prefetch_related('tags').annotate(
         device_count=count_related(Device, 'device_role'),
         virtualmachine_count=count_related(VirtualMachine, 'role')
@@ -373,7 +373,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet):
 # Platforms
 #
 
-class PlatformViewSet(CustomFieldModelViewSet):
+class PlatformViewSet(NetBoxModelViewSet):
     queryset = Platform.objects.prefetch_related('tags').annotate(
         device_count=count_related(Device, 'platform'),
         virtualmachine_count=count_related(VirtualMachine, 'platform')
@@ -386,7 +386,7 @@ class PlatformViewSet(CustomFieldModelViewSet):
 # Devices/modules
 #
 
-class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
+class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
     queryset = Device.objects.prefetch_related(
         'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
         'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
@@ -532,7 +532,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
         return Response(response)
 
 
-class ModuleViewSet(CustomFieldModelViewSet):
+class ModuleViewSet(NetBoxModelViewSet):
     queryset = Module.objects.prefetch_related(
         'device', 'module_bay', 'module_type__manufacturer', 'tags',
     )
@@ -544,7 +544,7 @@ class ModuleViewSet(CustomFieldModelViewSet):
 # Device components
 #
 
-class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
+class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = ConsolePort.objects.prefetch_related(
         'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
     )
@@ -553,7 +553,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
     brief_prefetch_fields = ['device']
 
 
-class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
+class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = ConsoleServerPort.objects.prefetch_related(
         'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
     )
@@ -562,7 +562,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
     brief_prefetch_fields = ['device']
 
 
-class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
+class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerPort.objects.prefetch_related(
         'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
     )
@@ -571,7 +571,7 @@ class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
     brief_prefetch_fields = ['device']
 
 
-class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
+class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerOutlet.objects.prefetch_related(
         'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
     )
@@ -580,7 +580,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
     brief_prefetch_fields = ['device']
 
 
-class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
+class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = Interface.objects.prefetch_related(
         'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer',
         'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
@@ -590,7 +590,7 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
     brief_prefetch_fields = ['device']
 
 
-class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
+class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = FrontPort.objects.prefetch_related(
         'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags'
     )
@@ -599,7 +599,7 @@ class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
     brief_prefetch_fields = ['device']
 
 
-class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
+class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = RearPort.objects.prefetch_related(
         'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags'
     )
@@ -608,21 +608,21 @@ class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
     brief_prefetch_fields = ['device']
 
 
-class ModuleBayViewSet(ModelViewSet):
+class ModuleBayViewSet(NetBoxModelViewSet):
     queryset = ModuleBay.objects.prefetch_related('tags')
     serializer_class = serializers.ModuleBaySerializer
     filterset_class = filtersets.ModuleBayFilterSet
     brief_prefetch_fields = ['device']
 
 
-class DeviceBayViewSet(ModelViewSet):
+class DeviceBayViewSet(NetBoxModelViewSet):
     queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags')
     serializer_class = serializers.DeviceBaySerializer
     filterset_class = filtersets.DeviceBayFilterSet
     brief_prefetch_fields = ['device']
 
 
-class InventoryItemViewSet(ModelViewSet):
+class InventoryItemViewSet(NetBoxModelViewSet):
     queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
     serializer_class = serializers.InventoryItemSerializer
     filterset_class = filtersets.InventoryItemFilterSet
@@ -633,7 +633,7 @@ class InventoryItemViewSet(ModelViewSet):
 # Device component roles
 #
 
-class InventoryItemRoleViewSet(CustomFieldModelViewSet):
+class InventoryItemRoleViewSet(NetBoxModelViewSet):
     queryset = InventoryItemRole.objects.prefetch_related('tags').annotate(
         inventoryitem_count=count_related(InventoryItem, 'role')
     )
@@ -645,7 +645,7 @@ class InventoryItemRoleViewSet(CustomFieldModelViewSet):
 # Cables
 #
 
-class CableViewSet(ModelViewSet):
+class CableViewSet(NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = Cable.objects.prefetch_related(
         'termination_a', 'termination_b'
@@ -658,7 +658,7 @@ class CableViewSet(ModelViewSet):
 # Virtual chassis
 #
 
-class VirtualChassisViewSet(ModelViewSet):
+class VirtualChassisViewSet(NetBoxModelViewSet):
     queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
         member_count=count_related(Device, 'virtual_chassis')
     )
@@ -671,7 +671,7 @@ class VirtualChassisViewSet(ModelViewSet):
 # Power panels
 #
 
-class PowerPanelViewSet(ModelViewSet):
+class PowerPanelViewSet(NetBoxModelViewSet):
     queryset = PowerPanel.objects.prefetch_related(
         'site', 'location'
     ).annotate(
@@ -685,7 +685,7 @@ class PowerPanelViewSet(ModelViewSet):
 # Power feeds
 #
 
-class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
+class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerFeed.objects.prefetch_related(
         'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
     )

+ 9 - 27
netbox/extras/api/views.py

@@ -18,7 +18,7 @@ from extras.reports import get_report, get_reports, run_report
 from extras.scripts import get_script, get_scripts, run_script
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
-from netbox.api.views import ModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet
 from utilities.exceptions import RQWorkerNotRunningException
 from utilities.utils import copy_safe_request, count_related
 from . import serializers
@@ -58,7 +58,7 @@ class ConfigContextQuerySetMixin:
 # Webhooks
 #
 
-class WebhookViewSet(ModelViewSet):
+class WebhookViewSet(NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = Webhook.objects.all()
     serializer_class = serializers.WebhookSerializer
@@ -69,36 +69,18 @@ class WebhookViewSet(ModelViewSet):
 # Custom fields
 #
 
-class CustomFieldViewSet(ModelViewSet):
+class CustomFieldViewSet(NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = CustomField.objects.all()
     serializer_class = serializers.CustomFieldSerializer
     filterset_class = filtersets.CustomFieldFilterSet
 
 
-class CustomFieldModelViewSet(ModelViewSet):
-    """
-    Include the applicable set of CustomFields in the ModelViewSet context.
-    """
-
-    def get_serializer_context(self):
-
-        # Gather all custom fields for the model
-        content_type = ContentType.objects.get_for_model(self.queryset.model)
-        custom_fields = content_type.custom_fields.all()
-
-        context = super().get_serializer_context()
-        context.update({
-            'custom_fields': custom_fields,
-        })
-        return context
-
-
 #
 # Custom links
 #
 
-class CustomLinkViewSet(ModelViewSet):
+class CustomLinkViewSet(NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = CustomLink.objects.all()
     serializer_class = serializers.CustomLinkSerializer
@@ -109,7 +91,7 @@ class CustomLinkViewSet(ModelViewSet):
 # Export templates
 #
 
-class ExportTemplateViewSet(ModelViewSet):
+class ExportTemplateViewSet(NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = ExportTemplate.objects.all()
     serializer_class = serializers.ExportTemplateSerializer
@@ -120,7 +102,7 @@ class ExportTemplateViewSet(ModelViewSet):
 # Tags
 #
 
-class TagViewSet(ModelViewSet):
+class TagViewSet(NetBoxModelViewSet):
     queryset = Tag.objects.annotate(
         tagged_items=count_related(TaggedItem, 'tag')
     )
@@ -132,7 +114,7 @@ class TagViewSet(ModelViewSet):
 # Image attachments
 #
 
-class ImageAttachmentViewSet(ModelViewSet):
+class ImageAttachmentViewSet(NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = ImageAttachment.objects.all()
     serializer_class = serializers.ImageAttachmentSerializer
@@ -143,7 +125,7 @@ class ImageAttachmentViewSet(ModelViewSet):
 # Journal entries
 #
 
-class JournalEntryViewSet(ModelViewSet):
+class JournalEntryViewSet(NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = JournalEntry.objects.all()
     serializer_class = serializers.JournalEntrySerializer
@@ -154,7 +136,7 @@ class JournalEntryViewSet(ModelViewSet):
 # Config contexts
 #
 
-class ConfigContextViewSet(ModelViewSet):
+class ConfigContextViewSet(NetBoxModelViewSet):
     queryset = ConfigContext.objects.prefetch_related(
         'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
     )

+ 17 - 17
netbox/ipam/api/serializers.py

@@ -9,7 +9,7 @@ from ipam.choices import *
 from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
 from ipam.models import *
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
-from netbox.api.serializers import PrimaryModelSerializer
+from netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
@@ -20,7 +20,7 @@ from .nested_serializers import *
 # ASNs
 #
 
-class ASNSerializer(PrimaryModelSerializer):
+class ASNSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     site_count = serializers.IntegerField(read_only=True)
@@ -37,7 +37,7 @@ class ASNSerializer(PrimaryModelSerializer):
 # VRFs
 #
 
-class VRFSerializer(PrimaryModelSerializer):
+class VRFSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     import_targets = SerializedPKRelatedField(
@@ -67,7 +67,7 @@ class VRFSerializer(PrimaryModelSerializer):
 # Route targets
 #
 
-class RouteTargetSerializer(PrimaryModelSerializer):
+class RouteTargetSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail')
     tenant = NestedTenantSerializer(required=False, allow_null=True)
 
@@ -82,7 +82,7 @@ class RouteTargetSerializer(PrimaryModelSerializer):
 # RIRs/aggregates
 #
 
-class RIRSerializer(PrimaryModelSerializer):
+class RIRSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
     aggregate_count = serializers.IntegerField(read_only=True)
 
@@ -94,7 +94,7 @@ class RIRSerializer(PrimaryModelSerializer):
         ]
 
 
-class AggregateSerializer(PrimaryModelSerializer):
+class AggregateSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     rir = NestedRIRSerializer()
@@ -113,7 +113,7 @@ class AggregateSerializer(PrimaryModelSerializer):
 # FHRP Groups
 #
 
-class FHRPGroupSerializer(PrimaryModelSerializer):
+class FHRPGroupSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail')
     ip_addresses = NestedIPAddressSerializer(many=True, read_only=True)
 
@@ -125,7 +125,7 @@ class FHRPGroupSerializer(PrimaryModelSerializer):
         ]
 
 
-class FHRPGroupAssignmentSerializer(PrimaryModelSerializer):
+class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
     group = NestedFHRPGroupSerializer()
     interface_type = ContentTypeField(
@@ -153,7 +153,7 @@ class FHRPGroupAssignmentSerializer(PrimaryModelSerializer):
 # VLANs
 #
 
-class RoleSerializer(PrimaryModelSerializer):
+class RoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
     prefix_count = serializers.IntegerField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
@@ -166,7 +166,7 @@ class RoleSerializer(PrimaryModelSerializer):
         ]
 
 
-class VLANGroupSerializer(PrimaryModelSerializer):
+class VLANGroupSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
     scope_type = ContentTypeField(
         queryset=ContentType.objects.filter(
@@ -196,7 +196,7 @@ class VLANGroupSerializer(PrimaryModelSerializer):
         return serializer(obj.scope, context=context).data
 
 
-class VLANSerializer(PrimaryModelSerializer):
+class VLANSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
     site = NestedSiteSerializer(required=False, allow_null=True)
     group = NestedVLANGroupSerializer(required=False, allow_null=True, default=None)
@@ -230,7 +230,7 @@ class AvailableVLANSerializer(serializers.Serializer):
         ])
 
 
-class CreateAvailableVLANSerializer(PrimaryModelSerializer):
+class CreateAvailableVLANSerializer(NetBoxModelSerializer):
     site = NestedSiteSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=VLANStatusChoices, required=False)
@@ -251,7 +251,7 @@ class CreateAvailableVLANSerializer(PrimaryModelSerializer):
 # Prefixes
 #
 
-class PrefixSerializer(PrimaryModelSerializer):
+class PrefixSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
@@ -323,7 +323,7 @@ class AvailablePrefixSerializer(serializers.Serializer):
 # IP ranges
 #
 
-class IPRangeSerializer(PrimaryModelSerializer):
+class IPRangeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:iprange-detail')
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
@@ -345,7 +345,7 @@ class IPRangeSerializer(PrimaryModelSerializer):
 # IP addresses
 #
 
-class IPAddressSerializer(PrimaryModelSerializer):
+class IPAddressSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
@@ -403,7 +403,7 @@ class AvailableIPSerializer(serializers.Serializer):
 # Services
 #
 
-class ServiceTemplateSerializer(PrimaryModelSerializer):
+class ServiceTemplateSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
     protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
 
@@ -415,7 +415,7 @@ class ServiceTemplateSerializer(PrimaryModelSerializer):
         ]
 
 
-class ServiceSerializer(PrimaryModelSerializer):
+class ServiceSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
     device = NestedDeviceSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)

+ 18 - 19
netbox/ipam/api/views.py

@@ -1,19 +1,18 @@
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
-from django_pglocks import advisory_lock
 from django.shortcuts import get_object_or_404
+from django_pglocks import advisory_lock
 from drf_yasg.utils import swagger_auto_schema
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.views import APIView
 
-
 from dcim.models import Site
-from extras.api.views import CustomFieldModelViewSet
 from ipam import filtersets
 from ipam.models import *
-from netbox.api.views import ModelViewSet, ObjectValidationMixin
+from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets.mixins import ObjectValidationMixin
 from netbox.config import get_config
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.utils import count_related
@@ -32,13 +31,13 @@ class IPAMRootView(APIRootView):
 # Viewsets
 #
 
-class ASNViewSet(CustomFieldModelViewSet):
+class ASNViewSet(NetBoxModelViewSet):
     queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate(site_count=count_related(Site, 'asns'))
     serializer_class = serializers.ASNSerializer
     filterset_class = filtersets.ASNFilterSet
 
 
-class VRFViewSet(CustomFieldModelViewSet):
+class VRFViewSet(NetBoxModelViewSet):
     queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
         'import_targets', 'export_targets', 'tags'
     ).annotate(
@@ -49,13 +48,13 @@ class VRFViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.VRFFilterSet
 
 
-class RouteTargetViewSet(CustomFieldModelViewSet):
+class RouteTargetViewSet(NetBoxModelViewSet):
     queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
     serializer_class = serializers.RouteTargetSerializer
     filterset_class = filtersets.RouteTargetFilterSet
 
 
-class RIRViewSet(CustomFieldModelViewSet):
+class RIRViewSet(NetBoxModelViewSet):
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
     ).prefetch_related('tags')
@@ -63,13 +62,13 @@ class RIRViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.RIRFilterSet
 
 
-class AggregateViewSet(CustomFieldModelViewSet):
+class AggregateViewSet(NetBoxModelViewSet):
     queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
     serializer_class = serializers.AggregateSerializer
     filterset_class = filtersets.AggregateFilterSet
 
 
-class RoleViewSet(CustomFieldModelViewSet):
+class RoleViewSet(NetBoxModelViewSet):
     queryset = Role.objects.annotate(
         prefix_count=count_related(Prefix, 'role'),
         vlan_count=count_related(VLAN, 'role')
@@ -78,7 +77,7 @@ class RoleViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.RoleFilterSet
 
 
-class PrefixViewSet(CustomFieldModelViewSet):
+class PrefixViewSet(NetBoxModelViewSet):
     queryset = Prefix.objects.prefetch_related(
         'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
     )
@@ -93,7 +92,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
         return super().get_serializer_class()
 
 
-class IPRangeViewSet(CustomFieldModelViewSet):
+class IPRangeViewSet(NetBoxModelViewSet):
     queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags')
     serializer_class = serializers.IPRangeSerializer
     filterset_class = filtersets.IPRangeFilterSet
@@ -101,7 +100,7 @@ class IPRangeViewSet(CustomFieldModelViewSet):
     parent_model = IPRange  # AvailableIPsMixin
 
 
-class IPAddressViewSet(CustomFieldModelViewSet):
+class IPAddressViewSet(NetBoxModelViewSet):
     queryset = IPAddress.objects.prefetch_related(
         'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
     )
@@ -109,20 +108,20 @@ class IPAddressViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.IPAddressFilterSet
 
 
-class FHRPGroupViewSet(CustomFieldModelViewSet):
+class FHRPGroupViewSet(NetBoxModelViewSet):
     queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')
     serializer_class = serializers.FHRPGroupSerializer
     filterset_class = filtersets.FHRPGroupFilterSet
     brief_prefetch_fields = ('ip_addresses',)
 
 
-class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet):
+class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
     queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'interface')
     serializer_class = serializers.FHRPGroupAssignmentSerializer
     filterset_class = filtersets.FHRPGroupAssignmentFilterSet
 
 
-class VLANGroupViewSet(CustomFieldModelViewSet):
+class VLANGroupViewSet(NetBoxModelViewSet):
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
     ).prefetch_related('tags')
@@ -130,7 +129,7 @@ class VLANGroupViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.VLANGroupFilterSet
 
 
-class VLANViewSet(CustomFieldModelViewSet):
+class VLANViewSet(NetBoxModelViewSet):
     queryset = VLAN.objects.prefetch_related(
         'site', 'group', 'tenant', 'role', 'tags'
     ).annotate(
@@ -140,13 +139,13 @@ class VLANViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.VLANFilterSet
 
 
-class ServiceTemplateViewSet(CustomFieldModelViewSet):
+class ServiceTemplateViewSet(NetBoxModelViewSet):
     queryset = ServiceTemplate.objects.prefetch_related('tags')
     serializer_class = serializers.ServiceTemplateSerializer
     filterset_class = filtersets.ServiceTemplateFilterSet
 
 
-class ServiceViewSet(CustomFieldModelViewSet):
+class ServiceViewSet(NetBoxModelViewSet):
     queryset = Service.objects.prefetch_related(
         'device', 'virtual_machine', 'tags', 'ipaddresses'
     )

+ 0 - 193
netbox/netbox/api/serializers.py

@@ -1,193 +0,0 @@
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
-from django.db.models import ManyToManyField
-from rest_framework import serializers
-from rest_framework.exceptions import ValidationError
-from rest_framework.fields import CreateOnlyDefault
-
-from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
-from extras.models import CustomField, Tag
-from utilities.utils import dict_to_filter_params
-
-
-class BaseModelSerializer(serializers.ModelSerializer):
-    display = serializers.SerializerMethodField(read_only=True)
-
-    def get_display(self, obj):
-        return str(obj)
-
-
-class ValidatedModelSerializer(BaseModelSerializer):
-    """
-    Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
-    validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
-    """
-    def validate(self, data):
-
-        # Remove custom fields data and tags (if any) prior to model validation
-        attrs = data.copy()
-        attrs.pop('custom_fields', None)
-        attrs.pop('tags', None)
-
-        # Skip ManyToManyFields
-        for field in self.Meta.model._meta.get_fields():
-            if isinstance(field, ManyToManyField):
-                attrs.pop(field.name, None)
-
-        # Run clean() on an instance of the model
-        if self.instance is None:
-            instance = self.Meta.model(**attrs)
-        else:
-            instance = self.instance
-            for k, v in attrs.items():
-                setattr(instance, k, v)
-        instance.full_clean()
-
-        return data
-
-
-class CustomFieldModelSerializer(ValidatedModelSerializer):
-    """
-    Extends ModelSerializer to render any CustomFields and their values associated with an object.
-    """
-    custom_fields = CustomFieldsDataField(
-        source='custom_field_data',
-        default=CreateOnlyDefault(CustomFieldDefaultValues())
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        if self.instance is not None:
-
-            # Retrieve the set of CustomFields which apply to this type of object
-            content_type = ContentType.objects.get_for_model(self.Meta.model)
-            fields = CustomField.objects.filter(content_types=content_type)
-
-            # Populate CustomFieldValues for each instance from database
-            if type(self.instance) in (list, tuple):
-                for obj in self.instance:
-                    self._populate_custom_fields(obj, fields)
-            else:
-                self._populate_custom_fields(self.instance, fields)
-
-    def _populate_custom_fields(self, instance, custom_fields):
-        instance.custom_fields = {}
-        for field in custom_fields:
-            instance.custom_fields[field.name] = instance.cf.get(field.name)
-
-
-#
-# Nested serializers
-#
-
-class WritableNestedSerializer(BaseModelSerializer):
-    """
-    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
-
-        # Dictionary of related object attributes
-        if isinstance(data, dict):
-            params = dict_to_filter_params(data)
-            queryset = self.Meta.model.objects
-            try:
-                return queryset.get(**params)
-            except ObjectDoesNotExist:
-                raise ValidationError(
-                    "Related object not found using the provided attributes: {}".format(params)
-                )
-            except MultipleObjectsReturned:
-                raise ValidationError(
-                    "Multiple objects match the provided attributes: {}".format(params)
-                )
-            except FieldError as e:
-                raise ValidationError(e)
-
-        # Integer PK of related object
-        if isinstance(data, int):
-            pk = data
-        else:
-            try:
-                # PK might have been mistakenly passed as a string
-                pk = int(data)
-            except (TypeError, ValueError):
-                raise ValidationError(
-                    "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
-                    "unrecognized value: {}".format(data)
-                )
-
-        # Look up object by PK
-        queryset = self.Meta.model.objects
-        try:
-            return queryset.get(pk=int(data))
-        except ObjectDoesNotExist:
-            raise ValidationError(
-                "Related object not found using the provided numeric ID: {}".format(pk)
-            )
-
-
-#
-# Nested tags serialization
-#
-
-# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers
-class NestedTagSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
-
-    class Meta:
-        model = Tag
-        fields = ['id', 'url', 'display', 'name', 'slug', 'color']
-
-
-#
-# Base model serializers
-#
-
-class PrimaryModelSerializer(CustomFieldModelSerializer):
-    """
-    Adds support for custom fields and tags.
-    """
-    tags = NestedTagSerializer(many=True, required=False)
-
-    def create(self, validated_data):
-        tags = validated_data.pop('tags', None)
-        instance = super().create(validated_data)
-
-        if tags is not None:
-            return self._save_tags(instance, tags)
-        return instance
-
-    def update(self, instance, validated_data):
-        tags = validated_data.pop('tags', None)
-
-        # Cache tags on instance for change logging
-        instance._tags = tags or []
-
-        instance = super().update(instance, validated_data)
-
-        if tags is not None:
-            return self._save_tags(instance, tags)
-        return instance
-
-    def _save_tags(self, instance, tags):
-        if tags:
-            instance.tags.set([t.name for t in tags])
-        else:
-            instance.tags.clear()
-
-        return instance
-
-
-class NestedGroupModelSerializer(PrimaryModelSerializer):
-    """
-    Extends PrimaryModelSerializer to include MPTT support.
-    """
-    _depth = serializers.IntegerField(source='level', read_only=True)
-
-
-class BulkOperationSerializer(serializers.Serializer):
-    id = serializers.IntegerField()

+ 27 - 0
netbox/netbox/api/serializers/__init__.py

@@ -0,0 +1,27 @@
+from rest_framework import serializers
+
+from .base import *
+from .features import *
+from .nested import *
+
+
+#
+# Base model serializers
+#
+
+class NetBoxModelSerializer(TaggableModelSerializer, CustomFieldModelSerializer, ValidatedModelSerializer):
+    """
+    Adds support for custom fields and tags.
+    """
+    pass
+
+
+class NestedGroupModelSerializer(NetBoxModelSerializer):
+    """
+    Extends PrimaryModelSerializer to include MPTT support.
+    """
+    _depth = serializers.IntegerField(source='level', read_only=True)
+
+
+class BulkOperationSerializer(serializers.Serializer):
+    id = serializers.IntegerField()

+ 43 - 0
netbox/netbox/api/serializers/base.py

@@ -0,0 +1,43 @@
+from django.db.models import ManyToManyField
+from rest_framework import serializers
+
+__all__ = (
+    'BaseModelSerializer',
+    'ValidatedModelSerializer',
+)
+
+
+class BaseModelSerializer(serializers.ModelSerializer):
+    display = serializers.SerializerMethodField(read_only=True)
+
+    def get_display(self, obj):
+        return str(obj)
+
+
+class ValidatedModelSerializer(BaseModelSerializer):
+    """
+    Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
+    validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
+    """
+    def validate(self, data):
+
+        # Remove custom fields data and tags (if any) prior to model validation
+        attrs = data.copy()
+        attrs.pop('custom_fields', None)
+        attrs.pop('tags', None)
+
+        # Skip ManyToManyFields
+        for field in self.Meta.model._meta.get_fields():
+            if isinstance(field, ManyToManyField):
+                attrs.pop(field.name, None)
+
+        # Run clean() on an instance of the model
+        if self.instance is None:
+            instance = self.Meta.model(**attrs)
+        else:
+            instance = self.instance
+            for k, v in attrs.items():
+                setattr(instance, k, v)
+        instance.full_clean()
+
+        return data

+ 80 - 0
netbox/netbox/api/serializers/features.py

@@ -0,0 +1,80 @@
+from django.contrib.contenttypes.models import ContentType
+from rest_framework import serializers
+from rest_framework.fields import CreateOnlyDefault
+
+from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
+from extras.models import CustomField
+from .nested import NestedTagSerializer
+
+__all__ = (
+    'CustomFieldModelSerializer',
+    'TaggableModelSerializer',
+)
+
+
+class CustomFieldModelSerializer(serializers.Serializer):
+    """
+    Introduces support for custom field assignment. Adds `custom_fields` serialization and ensures
+    that custom field data is populated upon initialization.
+    """
+    custom_fields = CustomFieldsDataField(
+        source='custom_field_data',
+        default=CreateOnlyDefault(CustomFieldDefaultValues())
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if self.instance is not None:
+
+            # Retrieve the set of CustomFields which apply to this type of object
+            content_type = ContentType.objects.get_for_model(self.Meta.model)
+            fields = CustomField.objects.filter(content_types=content_type)
+
+            # Populate custom field values for each instance from database
+            if type(self.instance) in (list, tuple):
+                for obj in self.instance:
+                    self._populate_custom_fields(obj, fields)
+            else:
+                self._populate_custom_fields(self.instance, fields)
+
+    def _populate_custom_fields(self, instance, custom_fields):
+        instance.custom_fields = {}
+        for field in custom_fields:
+            instance.custom_fields[field.name] = instance.cf.get(field.name)
+
+
+class TaggableModelSerializer(serializers.Serializer):
+    """
+    Introduces support for Tag assignment. Adds `tags` serialization, and handles tag assignment
+    on create() and update().
+    """
+    tags = NestedTagSerializer(many=True, required=False)
+
+    def create(self, validated_data):
+        tags = validated_data.pop('tags', None)
+        instance = super().create(validated_data)
+
+        if tags is not None:
+            return self._save_tags(instance, tags)
+        return instance
+
+    def update(self, instance, validated_data):
+        tags = validated_data.pop('tags', None)
+
+        # Cache tags on instance for change logging
+        instance._tags = tags or []
+
+        instance = super().update(instance, validated_data)
+
+        if tags is not None:
+            return self._save_tags(instance, tags)
+        return instance
+
+    def _save_tags(self, instance, tags):
+        if tags:
+            instance.tags.set([t.name for t in tags])
+        else:
+            instance.tags.clear()
+
+        return instance

+ 62 - 0
netbox/netbox/api/serializers/nested.py

@@ -0,0 +1,62 @@
+from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
+from rest_framework import serializers
+from rest_framework.exceptions import ValidationError
+
+from extras.models import Tag
+from utilities.utils import dict_to_filter_params
+from .base import BaseModelSerializer
+
+__all__ = (
+    'NestedTagSerializer',
+    'WritableNestedSerializer',
+)
+
+
+class WritableNestedSerializer(BaseModelSerializer):
+    """
+    Represents an object related through a ForeignKey field. On write, it accepts a primary key (PK) value or a
+    dictionary of attributes which can be used to uniquely identify the related object. This class should be
+    subclassed to return a full representation of the related object on read.
+    """
+    def to_internal_value(self, data):
+
+        if data is None:
+            return None
+
+        # Dictionary of related object attributes
+        if isinstance(data, dict):
+            params = dict_to_filter_params(data)
+            queryset = self.Meta.model.objects
+            try:
+                return queryset.get(**params)
+            except ObjectDoesNotExist:
+                raise ValidationError(f"Related object not found using the provided attributes: {params}")
+            except MultipleObjectsReturned:
+                raise ValidationError(f"Multiple objects match the provided attributes: {params}")
+            except FieldError as e:
+                raise ValidationError(e)
+
+        # Integer PK of related object
+        try:
+            # Cast as integer in case a PK was mistakenly sent as a string
+            pk = int(data)
+        except (TypeError, ValueError):
+            raise ValidationError(
+                f"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
+                f"unrecognized value: {data}"
+            )
+
+        # Look up object by PK
+        try:
+            return self.Meta.model.objects.get(pk=pk)
+        except ObjectDoesNotExist:
+            raise ValidationError(f"Related object not found using the provided numeric ID: {pk}")
+
+
+# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers
+class NestedTagSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
+
+    class Meta:
+        model = Tag
+        fields = ['id', 'url', 'display', 'name', 'slug', 'color']

+ 0 - 275
netbox/netbox/api/views.py

@@ -1,292 +1,17 @@
-import logging
 import platform
 from collections import OrderedDict
 
 from django import __version__ as DJANGO_VERSION
 from django.apps import apps
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
-from django.db import transaction
-from django.db.models import ProtectedError
-from django.shortcuts import get_object_or_404
 from django_rq.queues import get_connection
-from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
 from rest_framework.views import APIView
-from rest_framework.viewsets import ModelViewSet as ModelViewSet_
 from rq.worker import Worker
 
-from extras.models import ExportTemplate
-from netbox.api import BulkOperationSerializer
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
-from netbox.api.exceptions import SerializerNotFound
-from utilities.api import get_serializer_for_model
 
-HTTP_ACTIONS = {
-    'GET': 'view',
-    'OPTIONS': None,
-    'HEAD': 'view',
-    'POST': 'add',
-    'PUT': 'change',
-    'PATCH': 'change',
-    'DELETE': 'delete',
-}
-
-
-#
-# Mixins
-#
-
-class BulkUpdateModelMixin:
-    """
-    Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
-    or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set.
-    For example:
-
-    PATCH /api/dcim/sites/
-    [
-        {
-            "id": 123,
-            "name": "New name"
-        },
-        {
-            "id": 456,
-            "status": "planned"
-        }
-    ]
-    """
-    def bulk_update(self, request, *args, **kwargs):
-        partial = kwargs.pop('partial', False)
-        serializer = BulkOperationSerializer(data=request.data, many=True)
-        serializer.is_valid(raise_exception=True)
-        qs = self.get_queryset().filter(
-            pk__in=[o['id'] for o in serializer.data]
-        )
-
-        # Map update data by object ID
-        update_data = {
-            obj.pop('id'): obj for obj in request.data
-        }
-
-        data = self.perform_bulk_update(qs, update_data, partial=partial)
-
-        return Response(data, status=status.HTTP_200_OK)
-
-    def perform_bulk_update(self, objects, update_data, partial):
-        with transaction.atomic():
-            data_list = []
-            for obj in objects:
-                data = update_data.get(obj.id)
-                if hasattr(obj, 'snapshot'):
-                    obj.snapshot()
-                serializer = self.get_serializer(obj, data=data, partial=partial)
-                serializer.is_valid(raise_exception=True)
-                self.perform_update(serializer)
-                data_list.append(serializer.data)
-
-            return data_list
-
-    def bulk_partial_update(self, request, *args, **kwargs):
-        kwargs['partial'] = True
-        return self.bulk_update(request, *args, **kwargs)
-
-
-class BulkDestroyModelMixin:
-    """
-    Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one
-    or more JSON objects, each specifying the numeric ID of an object to be deleted. For example:
-
-    DELETE /api/dcim/sites/
-    [
-        {"id": 123},
-        {"id": 456}
-    ]
-    """
-    def bulk_destroy(self, request, *args, **kwargs):
-        serializer = BulkOperationSerializer(data=request.data, many=True)
-        serializer.is_valid(raise_exception=True)
-        qs = self.get_queryset().filter(
-            pk__in=[o['id'] for o in serializer.data]
-        )
-
-        self.perform_bulk_destroy(qs)
-
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
-    def perform_bulk_destroy(self, objects):
-        with transaction.atomic():
-            for obj in objects:
-                if hasattr(obj, 'snapshot'):
-                    obj.snapshot()
-                self.perform_destroy(obj)
-
-
-class ObjectValidationMixin:
-
-    def _validate_objects(self, instance):
-        """
-        Check that the provided instance or list of instances are matched by the current queryset. This confirms that
-        any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
-        """
-        if type(instance) is list:
-            # Check that all instances are still included in the view's queryset
-            conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
-            if conforming_count != len(instance):
-                raise ObjectDoesNotExist
-        else:
-            # Check that the instance is matched by the view's queryset
-            self.queryset.get(pk=instance.pk)
-
-
-#
-# Viewsets
-#
-
-class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet_):
-    """
-    Extend DRF's ModelViewSet to support bulk update and delete functions.
-    """
-    brief = False
-    brief_prefetch_fields = []
-
-    def get_object_with_snapshot(self):
-        """
-        Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
-        record the "before" data in the changelog.
-        """
-        obj = super().get_object()
-        if hasattr(obj, 'snapshot'):
-            obj.snapshot()
-        return obj
-
-    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):
-            kwargs['many'] = True
-
-        return super().get_serializer(*args, **kwargs)
-
-    def get_serializer_class(self):
-        logger = logging.getLogger('netbox.api.views.ModelViewSet')
-
-        # If using 'brief' mode, find and return the nested serializer for this model, if one exists
-        if self.brief:
-            logger.debug("Request is for 'brief' format; initializing nested serializer")
-            try:
-                serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
-                logger.debug(f"Using serializer {serializer}")
-                return serializer
-            except SerializerNotFound:
-                logger.debug(f"Nested serializer for {self.queryset.model} not found!")
-
-        # Fall back to the hard-coded serializer class
-        logger.debug(f"Using serializer {self.serializer_class}")
-        return self.serializer_class
-
-    def get_queryset(self):
-        # If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
-        if self.brief:
-            return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
-
-        return super().get_queryset()
-
-    def initialize_request(self, request, *args, **kwargs):
-        # Check if brief=True has been passed
-        if request.method == 'GET' and request.GET.get('brief'):
-            self.brief = True
-
-        return super().initialize_request(request, *args, **kwargs)
-
-    def initial(self, request, *args, **kwargs):
-        super().initial(request, *args, **kwargs)
-
-        if not request.user.is_authenticated:
-            return
-
-        # Restrict the view's QuerySet to allow only the permitted objects
-        action = HTTP_ACTIONS[request.method]
-        if action:
-            self.queryset = self.queryset.restrict(request.user, action)
-
-    def dispatch(self, request, *args, **kwargs):
-        logger = logging.getLogger('netbox.api.views.ModelViewSet')
-
-        try:
-            return super().dispatch(request, *args, **kwargs)
-        except ProtectedError as e:
-            protected_objects = list(e.protected_objects)
-            msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
-            msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
-            logger.warning(msg)
-            return self.finalize_response(
-                request,
-                Response({'detail': msg}, status=409),
-                *args,
-                **kwargs
-            )
-
-    def list(self, request, *args, **kwargs):
-        """
-        Overrides ListModelMixin to allow processing ExportTemplates.
-        """
-        if 'export' in request.GET:
-            content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
-            et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
-            queryset = self.filter_queryset(self.get_queryset())
-            return et.render_to_response(queryset)
-
-        return super().list(request, *args, **kwargs)
-
-    def perform_create(self, serializer):
-        model = self.queryset.model
-        logger = logging.getLogger('netbox.api.views.ModelViewSet')
-        logger.info(f"Creating new {model._meta.verbose_name}")
-
-        # Enforce object-level permissions on save()
-        try:
-            with transaction.atomic():
-                instance = serializer.save()
-                self._validate_objects(instance)
-        except ObjectDoesNotExist:
-            raise PermissionDenied()
-
-    def update(self, request, *args, **kwargs):
-        # Hotwire get_object() to ensure we save a pre-change snapshot
-        self.get_object = self.get_object_with_snapshot
-        return super().update(request, *args, **kwargs)
-
-    def perform_update(self, serializer):
-        model = self.queryset.model
-        logger = logging.getLogger('netbox.api.views.ModelViewSet')
-        logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
-
-        # Enforce object-level permissions on save()
-        try:
-            with transaction.atomic():
-                instance = serializer.save()
-                self._validate_objects(instance)
-        except ObjectDoesNotExist:
-            raise PermissionDenied()
-
-    def destroy(self, request, *args, **kwargs):
-        # Hotwire get_object() to ensure we save a pre-change snapshot
-        self.get_object = self.get_object_with_snapshot
-        return super().destroy(request, *args, **kwargs)
-
-    def perform_destroy(self, instance):
-        model = self.queryset.model
-        logger = logging.getLogger('netbox.api.views.ModelViewSet')
-        logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
-
-        return super().perform_destroy(instance)
-
-
-#
-# Views
-#
 
 class APIRootView(APIView):
     """

+ 182 - 0
netbox/netbox/api/viewsets/__init__.py

@@ -0,0 +1,182 @@
+import logging
+
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
+from django.db import transaction
+from django.db.models import ProtectedError
+from django.shortcuts import get_object_or_404
+from rest_framework.response import Response
+from rest_framework.viewsets import ModelViewSet
+
+from extras.models import ExportTemplate
+from netbox.api.exceptions import SerializerNotFound
+from utilities.api import get_serializer_for_model
+from .mixins import *
+
+__all__ = (
+    'NetBoxModelViewSet',
+)
+
+HTTP_ACTIONS = {
+    'GET': 'view',
+    'OPTIONS': None,
+    'HEAD': 'view',
+    'POST': 'add',
+    'PUT': 'change',
+    'PATCH': 'change',
+    'DELETE': 'delete',
+}
+
+
+class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet):
+    """
+    Extend DRF's ModelViewSet to support bulk update and delete functions.
+    """
+    brief = False
+    brief_prefetch_fields = []
+
+    def get_object_with_snapshot(self):
+        """
+        Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
+        record the "before" data in the changelog.
+        """
+        obj = super().get_object()
+        if hasattr(obj, 'snapshot'):
+            obj.snapshot()
+        return obj
+
+    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):
+            kwargs['many'] = True
+
+        return super().get_serializer(*args, **kwargs)
+
+    def get_serializer_class(self):
+        logger = logging.getLogger('netbox.api.views.ModelViewSet')
+
+        # If using 'brief' mode, find and return the nested serializer for this model, if one exists
+        if self.brief:
+            logger.debug("Request is for 'brief' format; initializing nested serializer")
+            try:
+                serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
+                logger.debug(f"Using serializer {serializer}")
+                return serializer
+            except SerializerNotFound:
+                logger.debug(f"Nested serializer for {self.queryset.model} not found!")
+
+        # Fall back to the hard-coded serializer class
+        logger.debug(f"Using serializer {self.serializer_class}")
+        return self.serializer_class
+
+    def get_serializer_context(self):
+        """
+        For models which support custom fields, populate the `custom_fields` context.
+        """
+        context = super().get_serializer_context()
+
+        if hasattr(self.queryset.model, 'custom_fields'):
+            content_type = ContentType.objects.get_for_model(self.queryset.model)
+            context.update({
+                'custom_fields': content_type.custom_fields.all(),
+            })
+
+        return context
+
+    def get_queryset(self):
+        # If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
+        if self.brief:
+            return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
+
+        return super().get_queryset()
+
+    def initialize_request(self, request, *args, **kwargs):
+        # Check if brief=True has been passed
+        if request.method == 'GET' and request.GET.get('brief'):
+            self.brief = True
+
+        return super().initialize_request(request, *args, **kwargs)
+
+    def initial(self, request, *args, **kwargs):
+        super().initial(request, *args, **kwargs)
+
+        if not request.user.is_authenticated:
+            return
+
+        # Restrict the view's QuerySet to allow only the permitted objects
+        action = HTTP_ACTIONS[request.method]
+        if action:
+            self.queryset = self.queryset.restrict(request.user, action)
+
+    def dispatch(self, request, *args, **kwargs):
+        logger = logging.getLogger('netbox.api.views.ModelViewSet')
+
+        try:
+            return super().dispatch(request, *args, **kwargs)
+        except ProtectedError as e:
+            protected_objects = list(e.protected_objects)
+            msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
+            msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
+            logger.warning(msg)
+            return self.finalize_response(
+                request,
+                Response({'detail': msg}, status=409),
+                *args,
+                **kwargs
+            )
+
+    def list(self, request, *args, **kwargs):
+        """
+        Overrides ListModelMixin to allow processing ExportTemplates.
+        """
+        if 'export' in request.GET:
+            content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
+            et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
+            queryset = self.filter_queryset(self.get_queryset())
+            return et.render_to_response(queryset)
+
+        return super().list(request, *args, **kwargs)
+
+    def perform_create(self, serializer):
+        model = self.queryset.model
+        logger = logging.getLogger('netbox.api.views.ModelViewSet')
+        logger.info(f"Creating new {model._meta.verbose_name}")
+
+        # Enforce object-level permissions on save()
+        try:
+            with transaction.atomic():
+                instance = serializer.save()
+                self._validate_objects(instance)
+        except ObjectDoesNotExist:
+            raise PermissionDenied()
+
+    def update(self, request, *args, **kwargs):
+        # Hotwire get_object() to ensure we save a pre-change snapshot
+        self.get_object = self.get_object_with_snapshot
+        return super().update(request, *args, **kwargs)
+
+    def perform_update(self, serializer):
+        model = self.queryset.model
+        logger = logging.getLogger('netbox.api.views.ModelViewSet')
+        logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
+
+        # Enforce object-level permissions on save()
+        try:
+            with transaction.atomic():
+                instance = serializer.save()
+                self._validate_objects(instance)
+        except ObjectDoesNotExist:
+            raise PermissionDenied()
+
+    def destroy(self, request, *args, **kwargs):
+        # Hotwire get_object() to ensure we save a pre-change snapshot
+        self.get_object = self.get_object_with_snapshot
+        return super().destroy(request, *args, **kwargs)
+
+    def perform_destroy(self, instance):
+        model = self.queryset.model
+        logger = logging.getLogger('netbox.api.views.ModelViewSet')
+        logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
+
+        return super().perform_destroy(instance)

+ 113 - 0
netbox/netbox/api/viewsets/mixins.py

@@ -0,0 +1,113 @@
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import transaction
+from rest_framework import status
+from rest_framework.response import Response
+
+from netbox.api.serializers import BulkOperationSerializer
+
+__all__ = (
+    'BulkUpdateModelMixin',
+    'BulkDestroyModelMixin',
+    'ObjectValidationMixin',
+)
+
+
+class BulkUpdateModelMixin:
+    """
+    Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
+    or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set.
+    For example:
+
+    PATCH /api/dcim/sites/
+    [
+        {
+            "id": 123,
+            "name": "New name"
+        },
+        {
+            "id": 456,
+            "status": "planned"
+        }
+    ]
+    """
+    def bulk_update(self, request, *args, **kwargs):
+        partial = kwargs.pop('partial', False)
+        serializer = BulkOperationSerializer(data=request.data, many=True)
+        serializer.is_valid(raise_exception=True)
+        qs = self.get_queryset().filter(
+            pk__in=[o['id'] for o in serializer.data]
+        )
+
+        # Map update data by object ID
+        update_data = {
+            obj.pop('id'): obj for obj in request.data
+        }
+
+        data = self.perform_bulk_update(qs, update_data, partial=partial)
+
+        return Response(data, status=status.HTTP_200_OK)
+
+    def perform_bulk_update(self, objects, update_data, partial):
+        with transaction.atomic():
+            data_list = []
+            for obj in objects:
+                data = update_data.get(obj.id)
+                if hasattr(obj, 'snapshot'):
+                    obj.snapshot()
+                serializer = self.get_serializer(obj, data=data, partial=partial)
+                serializer.is_valid(raise_exception=True)
+                self.perform_update(serializer)
+                data_list.append(serializer.data)
+
+            return data_list
+
+    def bulk_partial_update(self, request, *args, **kwargs):
+        kwargs['partial'] = True
+        return self.bulk_update(request, *args, **kwargs)
+
+
+class BulkDestroyModelMixin:
+    """
+    Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one
+    or more JSON objects, each specifying the numeric ID of an object to be deleted. For example:
+
+    DELETE /api/dcim/sites/
+    [
+        {"id": 123},
+        {"id": 456}
+    ]
+    """
+    def bulk_destroy(self, request, *args, **kwargs):
+        serializer = BulkOperationSerializer(data=request.data, many=True)
+        serializer.is_valid(raise_exception=True)
+        qs = self.get_queryset().filter(
+            pk__in=[o['id'] for o in serializer.data]
+        )
+
+        self.perform_bulk_destroy(qs)
+
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+    def perform_bulk_destroy(self, objects):
+        with transaction.atomic():
+            for obj in objects:
+                if hasattr(obj, 'snapshot'):
+                    obj.snapshot()
+                self.perform_destroy(obj)
+
+
+class ObjectValidationMixin:
+
+    def _validate_objects(self, instance):
+        """
+        Check that the provided instance or list of instances are matched by the current queryset. This confirms that
+        any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
+        """
+        if type(instance) is list:
+            # Check that all instances are still included in the view's queryset
+            conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
+            if conforming_count != len(instance):
+                raise ObjectDoesNotExist
+        else:
+            # Check that the instance is matched by the view's queryset
+            self.queryset.get(pk=instance.pk)

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

@@ -3,7 +3,7 @@ from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 
 from netbox.api import ChoiceField, ContentTypeField
-from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
+from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
 from tenancy.choices import ContactPriorityChoices
 from tenancy.models import *
 from utilities.api import get_serializer_for_model
@@ -27,7 +27,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
         ]
 
 
-class TenantSerializer(PrimaryModelSerializer):
+class TenantSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
     group = NestedTenantGroupSerializer(required=False, allow_null=True)
     circuit_count = serializers.IntegerField(read_only=True)
@@ -67,7 +67,7 @@ class ContactGroupSerializer(NestedGroupModelSerializer):
         ]
 
 
-class ContactRoleSerializer(PrimaryModelSerializer):
+class ContactRoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
 
     class Meta:
@@ -77,7 +77,7 @@ class ContactRoleSerializer(PrimaryModelSerializer):
         ]
 
 
-class ContactSerializer(PrimaryModelSerializer):
+class ContactSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail')
     group = NestedContactGroupSerializer(required=False, allow_null=True, default=None)
 
@@ -89,7 +89,7 @@ class ContactSerializer(PrimaryModelSerializer):
         ]
 
 
-class ContactAssignmentSerializer(PrimaryModelSerializer):
+class ContactAssignmentSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
     content_type = ContentTypeField(
         queryset=ContentType.objects.all()

+ 8 - 8
netbox/tenancy/api/views.py

@@ -1,9 +1,9 @@
 from rest_framework.routers import APIRootView
 
 from circuits.models import Circuit
-from dcim.models import Device, Rack, Site, Cable
-from extras.api.views import CustomFieldModelViewSet
+from dcim.models import Device, Rack, Site
 from ipam.models import IPAddress, Prefix, VLAN, VRF
+from netbox.api.viewsets import NetBoxModelViewSet
 from tenancy import filtersets
 from tenancy.models import *
 from utilities.utils import count_related
@@ -23,7 +23,7 @@ class TenancyRootView(APIRootView):
 # Tenants
 #
 
-class TenantGroupViewSet(CustomFieldModelViewSet):
+class TenantGroupViewSet(NetBoxModelViewSet):
     queryset = TenantGroup.objects.add_related_count(
         TenantGroup.objects.all(),
         Tenant,
@@ -35,7 +35,7 @@ class TenantGroupViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.TenantGroupFilterSet
 
 
-class TenantViewSet(CustomFieldModelViewSet):
+class TenantViewSet(NetBoxModelViewSet):
     queryset = Tenant.objects.prefetch_related(
         'group', 'tags'
     ).annotate(
@@ -58,7 +58,7 @@ class TenantViewSet(CustomFieldModelViewSet):
 # Contacts
 #
 
-class ContactGroupViewSet(CustomFieldModelViewSet):
+class ContactGroupViewSet(NetBoxModelViewSet):
     queryset = ContactGroup.objects.add_related_count(
         ContactGroup.objects.all(),
         Contact,
@@ -70,19 +70,19 @@ class ContactGroupViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.ContactGroupFilterSet
 
 
-class ContactRoleViewSet(CustomFieldModelViewSet):
+class ContactRoleViewSet(NetBoxModelViewSet):
     queryset = ContactRole.objects.prefetch_related('tags')
     serializer_class = serializers.ContactRoleSerializer
     filterset_class = filtersets.ContactRoleFilterSet
 
 
-class ContactViewSet(CustomFieldModelViewSet):
+class ContactViewSet(NetBoxModelViewSet):
     queryset = Contact.objects.prefetch_related('group', 'tags')
     serializer_class = serializers.ContactSerializer
     filterset_class = filtersets.ContactFilterSet
 
 
-class ContactAssignmentViewSet(CustomFieldModelViewSet):
+class ContactAssignmentViewSet(NetBoxModelViewSet):
     queryset = ContactAssignment.objects.prefetch_related('object', 'contact', 'role')
     serializer_class = serializers.ContactAssignmentSerializer
     filterset_class = filtersets.ContactAssignmentFilterSet

+ 5 - 5
netbox/users/api/views.py

@@ -9,7 +9,7 @@ from rest_framework.status import HTTP_201_CREATED
 from rest_framework.views import APIView
 from rest_framework.viewsets import ViewSet
 
-from netbox.api.views import ModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet
 from users import filtersets
 from users.models import ObjectPermission, Token, UserConfig
 from utilities.querysets import RestrictedQuerySet
@@ -29,13 +29,13 @@ class UsersRootView(APIRootView):
 # Users and groups
 #
 
-class UserViewSet(ModelViewSet):
+class UserViewSet(NetBoxModelViewSet):
     queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username')
     serializer_class = serializers.UserSerializer
     filterset_class = filtersets.UserFilterSet
 
 
-class GroupViewSet(ModelViewSet):
+class GroupViewSet(NetBoxModelViewSet):
     queryset = RestrictedQuerySet(model=Group).annotate(user_count=Count('user')).order_by('name')
     serializer_class = serializers.GroupSerializer
     filterset_class = filtersets.GroupFilterSet
@@ -45,7 +45,7 @@ class GroupViewSet(ModelViewSet):
 # REST API tokens
 #
 
-class TokenViewSet(ModelViewSet):
+class TokenViewSet(NetBoxModelViewSet):
     queryset = RestrictedQuerySet(model=Token).prefetch_related('user')
     serializer_class = serializers.TokenSerializer
     filterset_class = filtersets.TokenFilterSet
@@ -94,7 +94,7 @@ class TokenProvisionView(APIView):
 # ObjectPermissions
 #
 
-class ObjectPermissionViewSet(ModelViewSet):
+class ObjectPermissionViewSet(NetBoxModelViewSet):
     queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users')
     serializer_class = serializers.ObjectPermissionSerializer
     filterset_class = filtersets.ObjectPermissionFilterSet

+ 6 - 6
netbox/virtualization/api/serializers.py

@@ -6,7 +6,7 @@ from dcim.choices import InterfaceModeChoices
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
 from ipam.models import VLAN
 from netbox.api import ChoiceField, SerializedPKRelatedField
-from netbox.api.serializers import PrimaryModelSerializer
+from netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -17,7 +17,7 @@ from .nested_serializers import *
 # Clusters
 #
 
-class ClusterTypeSerializer(PrimaryModelSerializer):
+class ClusterTypeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
     cluster_count = serializers.IntegerField(read_only=True)
 
@@ -29,7 +29,7 @@ class ClusterTypeSerializer(PrimaryModelSerializer):
         ]
 
 
-class ClusterGroupSerializer(PrimaryModelSerializer):
+class ClusterGroupSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
     cluster_count = serializers.IntegerField(read_only=True)
 
@@ -41,7 +41,7 @@ class ClusterGroupSerializer(PrimaryModelSerializer):
         ]
 
 
-class ClusterSerializer(PrimaryModelSerializer):
+class ClusterSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
     type = NestedClusterTypeSerializer()
     group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None)
@@ -62,7 +62,7 @@ class ClusterSerializer(PrimaryModelSerializer):
 # Virtual machines
 #
 
-class VirtualMachineSerializer(PrimaryModelSerializer):
+class VirtualMachineSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
     status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     site = NestedSiteSerializer(read_only=True)
@@ -103,7 +103,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 # VM interfaces
 #
 
-class VMInterfaceSerializer(PrimaryModelSerializer):
+class VMInterfaceSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
     virtual_machine = NestedVirtualMachineSerializer()
     parent = NestedVMInterfaceSerializer(required=False, allow_null=True)

+ 7 - 6
netbox/virtualization/api/views.py

@@ -1,7 +1,8 @@
 from rest_framework.routers import APIRootView
 
 from dcim.models import Device
-from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet, ModelViewSet
+from extras.api.views import ConfigContextQuerySetMixin
+from netbox.api.viewsets import NetBoxModelViewSet
 from utilities.utils import count_related
 from virtualization import filtersets
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -20,7 +21,7 @@ class VirtualizationRootView(APIRootView):
 # Clusters
 #
 
-class ClusterTypeViewSet(CustomFieldModelViewSet):
+class ClusterTypeViewSet(NetBoxModelViewSet):
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
     ).prefetch_related('tags')
@@ -28,7 +29,7 @@ class ClusterTypeViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.ClusterTypeFilterSet
 
 
-class ClusterGroupViewSet(CustomFieldModelViewSet):
+class ClusterGroupViewSet(NetBoxModelViewSet):
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')
     ).prefetch_related('tags')
@@ -36,7 +37,7 @@ class ClusterGroupViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.ClusterGroupFilterSet
 
 
-class ClusterViewSet(CustomFieldModelViewSet):
+class ClusterViewSet(NetBoxModelViewSet):
     queryset = Cluster.objects.prefetch_related(
         'type', 'group', 'tenant', 'site', 'tags'
     ).annotate(
@@ -51,7 +52,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
 # Virtual machines
 #
 
-class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
+class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
     queryset = VirtualMachine.objects.prefetch_related(
         'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
     )
@@ -78,7 +79,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet)
         return serializers.VirtualMachineWithConfigContextSerializer
 
 
-class VMInterfaceViewSet(ModelViewSet):
+class VMInterfaceViewSet(NetBoxModelViewSet):
     queryset = VMInterface.objects.prefetch_related(
         'virtual_machine', 'parent', 'tags', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses',
         'fhrp_group_assignments',

+ 3 - 3
netbox/wireless/api/serializers.py

@@ -4,7 +4,7 @@ from dcim.choices import LinkStatusChoices
 from dcim.api.serializers import NestedInterfaceSerializer
 from ipam.api.serializers import NestedVLANSerializer
 from netbox.api import ChoiceField
-from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
+from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
 from wireless.choices import *
 from wireless.models import *
 from .nested_serializers import *
@@ -29,7 +29,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer):
         ]
 
 
-class WirelessLANSerializer(PrimaryModelSerializer):
+class WirelessLANSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail')
     group = NestedWirelessLANGroupSerializer(required=False, allow_null=True)
     vlan = NestedVLANSerializer(required=False, allow_null=True)
@@ -44,7 +44,7 @@ class WirelessLANSerializer(PrimaryModelSerializer):
         ]
 
 
-class WirelessLinkSerializer(PrimaryModelSerializer):
+class WirelessLinkSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail')
     status = ChoiceField(choices=LinkStatusChoices, required=False)
     interface_a = NestedInterfaceSerializer()

+ 4 - 4
netbox/wireless/api/views.py

@@ -1,6 +1,6 @@
 from rest_framework.routers import APIRootView
 
-from extras.api.views import CustomFieldModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet
 from wireless import filtersets
 from wireless.models import *
 from . import serializers
@@ -14,7 +14,7 @@ class WirelessRootView(APIRootView):
         return 'Wireless'
 
 
-class WirelessLANGroupViewSet(CustomFieldModelViewSet):
+class WirelessLANGroupViewSet(NetBoxModelViewSet):
     queryset = WirelessLANGroup.objects.add_related_count(
         WirelessLANGroup.objects.all(),
         WirelessLAN,
@@ -26,13 +26,13 @@ class WirelessLANGroupViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.WirelessLANGroupFilterSet
 
 
-class WirelessLANViewSet(CustomFieldModelViewSet):
+class WirelessLANViewSet(NetBoxModelViewSet):
     queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags')
     serializer_class = serializers.WirelessLANSerializer
     filterset_class = filtersets.WirelessLANFilterSet
 
 
-class WirelessLinkViewSet(CustomFieldModelViewSet):
+class WirelessLinkViewSet(NetBoxModelViewSet):
     queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tags')
     serializer_class = serializers.WirelessLinkSerializer
     filterset_class = filtersets.WirelessLinkFilterSet