From a11abf87ec5980fb518202fa46f05307c1cd86f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 9 Mar 2022 10:59:22 -0500 Subject: [PATCH 1/4] Refactor API serializers --- netbox/circuits/api/serializers.py | 10 +- netbox/dcim/api/serializers.py | 54 +++--- netbox/ipam/api/serializers.py | 34 ++-- netbox/netbox/api/serializers.py | 193 ---------------------- netbox/netbox/api/serializers/__init__.py | 27 +++ netbox/netbox/api/serializers/base.py | 43 +++++ netbox/netbox/api/serializers/features.py | 80 +++++++++ netbox/netbox/api/serializers/nested.py | 62 +++++++ netbox/tenancy/api/serializers.py | 10 +- netbox/virtualization/api/serializers.py | 12 +- netbox/wireless/api/serializers.py | 6 +- 11 files changed, 275 insertions(+), 256 deletions(-) delete mode 100644 netbox/netbox/api/serializers.py create mode 100644 netbox/netbox/api/serializers/__init__.py create mode 100644 netbox/netbox/api/serializers/base.py create mode 100644 netbox/netbox/api/serializers/features.py create mode 100644 netbox/netbox/api/serializers/nested.py diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 90767d081..539ff9466 100644 --- a/netbox/circuits/api/serializers.py +++ b/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) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index bfc2b2d00..155eca716 100644 --- a/netbox/dcim/api/serializers.py +++ b/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( diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 8fb3bdea5..c26575f1f 100644 --- a/netbox/ipam/api/serializers.py +++ b/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) diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py deleted file mode 100644 index 4c26dbada..000000000 --- a/netbox/netbox/api/serializers.py +++ /dev/null @@ -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() diff --git a/netbox/netbox/api/serializers/__init__.py b/netbox/netbox/api/serializers/__init__.py new file mode 100644 index 000000000..d7c363754 --- /dev/null +++ b/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(TaggableObjectSerializer, 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() diff --git a/netbox/netbox/api/serializers/base.py b/netbox/netbox/api/serializers/base.py new file mode 100644 index 000000000..f1aea0e2b --- /dev/null +++ b/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 diff --git a/netbox/netbox/api/serializers/features.py b/netbox/netbox/api/serializers/features.py new file mode 100644 index 000000000..34572352c --- /dev/null +++ b/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', + 'TaggableObjectSerializer', +) + + +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 TaggableObjectSerializer(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 diff --git a/netbox/netbox/api/serializers/nested.py b/netbox/netbox/api/serializers/nested.py new file mode 100644 index 000000000..95dcd560c --- /dev/null +++ b/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'] diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index a0482aa1d..21a75a6bf 100644 --- a/netbox/tenancy/api/serializers.py +++ b/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() diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 3d3451062..afdf50b96 100644 --- a/netbox/virtualization/api/serializers.py +++ b/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) diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index f1fa6d58d..4a6abe94d 100644 --- a/netbox/wireless/api/serializers.py +++ b/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() From efd5a73a187f1183a278595d7345046abee5800b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 9 Mar 2022 11:09:06 -0500 Subject: [PATCH 2/4] Refactor API views --- netbox/circuits/api/views.py | 4 +- netbox/dcim/api/views.py | 50 ++-- netbox/extras/api/views.py | 20 +- netbox/ipam/api/views.py | 2 +- netbox/netbox/api/serializers/__init__.py | 2 +- netbox/netbox/api/serializers/features.py | 4 +- netbox/netbox/api/views.py | 275 ---------------------- netbox/netbox/api/viewsets/__init__.py | 168 +++++++++++++ netbox/netbox/api/viewsets/mixins.py | 113 +++++++++ netbox/users/api/views.py | 10 +- netbox/virtualization/api/views.py | 4 +- 11 files changed, 329 insertions(+), 323 deletions(-) create mode 100644 netbox/netbox/api/viewsets/__init__.py create mode 100644 netbox/netbox/api/viewsets/mixins.py diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 2b3e3b122..378aeeb51 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -4,7 +4,7 @@ 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 @@ -57,7 +57,7 @@ class CircuitViewSet(CustomFieldModelViewSet): # Circuit Terminations # -class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet): +class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = CircuitTermination.objects.prefetch_related( 'circuit', 'site', 'provider_network', 'cable' ) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index edba03b60..077d7c753 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -19,7 +19,7 @@ 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 @@ -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 @@ -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 @@ -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 @@ -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( diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 074a33823..1e7f46a86 100644 --- a/netbox/extras/api/views.py +++ b/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,14 +69,14 @@ 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): +class CustomFieldModelViewSet(NetBoxModelViewSet): """ Include the applicable set of CustomFields in the ModelViewSet context. """ @@ -98,7 +98,7 @@ class CustomFieldModelViewSet(ModelViewSet): # Custom links # -class CustomLinkViewSet(ModelViewSet): +class CustomLinkViewSet(NetBoxModelViewSet): metadata_class = ContentTypeMetadata queryset = CustomLink.objects.all() serializer_class = serializers.CustomLinkSerializer @@ -109,7 +109,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 +120,7 @@ class ExportTemplateViewSet(ModelViewSet): # Tags # -class TagViewSet(ModelViewSet): +class TagViewSet(NetBoxModelViewSet): queryset = Tag.objects.annotate( tagged_items=count_related(TaggedItem, 'tag') ) @@ -132,7 +132,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 +143,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 +154,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', ) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 357937855..445771215 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -13,7 +13,7 @@ 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.mixins import ObjectValidationMixin from netbox.config import get_config from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import count_related diff --git a/netbox/netbox/api/serializers/__init__.py b/netbox/netbox/api/serializers/__init__.py index d7c363754..adc556549 100644 --- a/netbox/netbox/api/serializers/__init__.py +++ b/netbox/netbox/api/serializers/__init__.py @@ -9,7 +9,7 @@ from .nested import * # Base model serializers # -class NetBoxModelSerializer(TaggableObjectSerializer, CustomFieldModelSerializer, ValidatedModelSerializer): +class NetBoxModelSerializer(TaggableModelSerializer, CustomFieldModelSerializer, ValidatedModelSerializer): """ Adds support for custom fields and tags. """ diff --git a/netbox/netbox/api/serializers/features.py b/netbox/netbox/api/serializers/features.py index 34572352c..5332a22d6 100644 --- a/netbox/netbox/api/serializers/features.py +++ b/netbox/netbox/api/serializers/features.py @@ -8,7 +8,7 @@ from .nested import NestedTagSerializer __all__ = ( 'CustomFieldModelSerializer', - 'TaggableObjectSerializer', + 'TaggableModelSerializer', ) @@ -44,7 +44,7 @@ class CustomFieldModelSerializer(serializers.Serializer): instance.custom_fields[field.name] = instance.cf.get(field.name) -class TaggableObjectSerializer(serializers.Serializer): +class TaggableModelSerializer(serializers.Serializer): """ Introduces support for Tag assignment. Adds `tags` serialization, and handles tag assignment on create() and update(). diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 2df0a4c83..835ebc6a9 100644 --- a/netbox/netbox/api/views.py +++ b/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): """ diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py new file mode 100644 index 000000000..05bd8cb19 --- /dev/null +++ b/netbox/netbox/api/viewsets/__init__.py @@ -0,0 +1,168 @@ +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_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) diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py new file mode 100644 index 000000000..7dc1111f3 --- /dev/null +++ b/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) diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index a8896e0ba..c3495afdf 100644 --- a/netbox/users/api/views.py +++ b/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 diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 471589ba5..91b4fbbab 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,7 +1,7 @@ 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, CustomFieldModelViewSet, NetBoxModelViewSet from utilities.utils import count_related from virtualization import filtersets from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -78,7 +78,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', From bbdeae0ed9bcc06fb96ffa2970272e1a3447448c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 9 Mar 2022 11:44:55 -0500 Subject: [PATCH 3/4] Move CustomFieldModelViewSet functionality into NetBoxModelViewSet --- netbox/circuits/api/views.py | 9 +++---- netbox/dcim/api/views.py | 32 +++++++++++------------ netbox/extras/api/views.py | 18 ------------- netbox/ipam/api/views.py | 35 +++++++++++++------------- netbox/netbox/api/viewsets/__init__.py | 14 +++++++++++ netbox/tenancy/api/views.py | 16 ++++++------ netbox/virtualization/api/views.py | 11 ++++---- netbox/wireless/api/views.py | 8 +++--- 8 files changed, 69 insertions(+), 74 deletions(-) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 378aeeb51..99687fe9d 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -3,7 +3,6 @@ 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.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') @@ -70,7 +69,7 @@ class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet): # Provider networks # -class ProviderNetworkViewSet(CustomFieldModelViewSet): +class ProviderNetworkViewSet(NetBoxModelViewSet): queryset = ProviderNetwork.objects.prefetch_related('tags') serializer_class = serializers.ProviderNetworkSerializer filterset_class = filtersets.ProviderNetworkFilterSet diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 077d7c753..e99ef333a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -14,7 +14,7 @@ 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 @@ -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( @@ -260,7 +260,7 @@ class RackReservationViewSet(NetBoxModelViewSet): # 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') ) @@ -360,7 +360,7 @@ class InventoryItemTemplateViewSet(NetBoxModelViewSet): # 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', ) @@ -633,7 +633,7 @@ class InventoryItemViewSet(NetBoxModelViewSet): # Device component roles # -class InventoryItemRoleViewSet(CustomFieldModelViewSet): +class InventoryItemRoleViewSet(NetBoxModelViewSet): queryset = InventoryItemRole.objects.prefetch_related('tags').annotate( inventoryitem_count=count_related(InventoryItem, 'role') ) @@ -685,7 +685,7 @@ class PowerPanelViewSet(NetBoxModelViewSet): # Power feeds # -class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet): +class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerFeed.objects.prefetch_related( 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags' ) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 1e7f46a86..4f42b4c93 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -76,24 +76,6 @@ class CustomFieldViewSet(NetBoxModelViewSet): filterset_class = filtersets.CustomFieldFilterSet -class CustomFieldModelViewSet(NetBoxModelViewSet): - """ - 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 # diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 445771215..c09cffa05 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,18 +1,17 @@ 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.viewsets import NetBoxModelViewSet from netbox.api.viewsets.mixins import ObjectValidationMixin from netbox.config import get_config from utilities.constants import ADVISORY_LOCK_KEYS @@ -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' ) diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 05bd8cb19..462c07c6f 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -70,6 +70,20 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali 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: diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index fbc166bdb..39c86d80e 100644 --- a/netbox/tenancy/api/views.py +++ b/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 diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 91b4fbbab..665114881 100644 --- a/netbox/virtualization/api/views.py +++ b/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, NetBoxModelViewSet +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' ) diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index 734f6940f..77a766c50 100644 --- a/netbox/wireless/api/views.py +++ b/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 From e36ae4f0f71e3ffe96fef9c9d1c5864a12322089 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 9 Mar 2022 11:52:14 -0500 Subject: [PATCH 4/4] Document support for NetBoxModelSerializer, NetBoxModelViewSet --- docs/plugins/development/rest-api.md | 37 ++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/plugins/development/rest-api.md b/docs/plugins/development/rest-api.md index 37851b052..a4c14422e 100644 --- a/docs/plugins/development/rest-api.md +++ b/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 -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. +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.). + +### 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 -Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`. +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`. + +### 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.