2
0
Эх сурвалжийг харах

Closes #9608: Move from drf-yasg to spectacular

Co-authored-by: arthanson <worldnomad@gmail.com>
Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
Arthur Hanson 2 жил өмнө
parent
commit
ecd0c56554
35 өөрчлөгдсөн 513 нэмэгдсэн , 339 устгасан
  1. 3 3
      base_requirements.txt
  2. 8 0
      netbox/circuits/api/nested_serializers.py
  3. 4 4
      netbox/circuits/api/serializers.py
  4. 224 0
      netbox/core/api/schema.py
  5. 1 0
      netbox/core/apps.py
  6. 38 1
      netbox/dcim/api/nested_serializers.py
  7. 57 43
      netbox/dcim/api/serializers.py
  8. 10 17
      netbox/dcim/api/views.py
  9. 1 1
      netbox/dcim/tests/test_api.py
  10. 3 0
      netbox/extras/api/customfields.py
  11. 7 5
      netbox/extras/api/serializers.py
  12. 2 2
      netbox/extras/api/views.py
  13. 5 4
      netbox/extras/plugins/views.py
  14. 13 0
      netbox/ipam/api/nested_serializers.py
  15. 11 9
      netbox/ipam/api/serializers.py
  16. 33 21
      netbox/ipam/api/views.py
  17. 0 2
      netbox/ipam/filtersets.py
  18. 4 0
      netbox/netbox/api/fields.py
  19. 3 0
      netbox/netbox/api/serializers/base.py
  20. 2 2
      netbox/netbox/api/serializers/generic.py
  21. 5 2
      netbox/netbox/api/views.py
  22. 13 44
      netbox/netbox/settings.py
  23. 5 19
      netbox/netbox/urls.py
  24. 7 0
      netbox/tenancy/api/nested_serializers.py
  25. 4 3
      netbox/tenancy/api/serializers.py
  26. 5 3
      netbox/users/api/nested_serializers.py
  27. 3 0
      netbox/users/api/serializers.py
  28. 8 3
      netbox/users/api/views.py
  29. 0 142
      netbox/utilities/custom_inspectors.py
  30. 10 0
      netbox/utilities/filters.py
  31. 4 4
      netbox/utilities/tests/test_api.py
  32. 10 0
      netbox/virtualization/api/nested_serializers.py
  33. 5 4
      netbox/virtualization/api/serializers.py
  34. 4 0
      netbox/wireless/api/nested_serializers.py
  35. 1 1
      requirements.txt

+ 3 - 3
base_requirements.txt

@@ -66,9 +66,9 @@ django-timezone-field
 # https://github.com/encode/django-rest-framework
 djangorestframework
 
-# Swagger/OpenAPI schema generation for REST APIs
-# https://github.com/axnsan12/drf-yasg
-drf-yasg[validation]
+# Sane and flexible OpenAPI 3 schema generation for Django REST framework.
+# https://github.com/tfranzel/drf-spectacular
+drf-spectacular
 
 # RSS feed parser
 # https://github.com/kurtmckee/feedparser

+ 8 - 0
netbox/circuits/api/nested_serializers.py

@@ -1,3 +1,5 @@
+from drf_spectacular.utils import extend_schema_field, extend_schema_serializer
+from drf_spectacular.types import OpenApiTypes
 from rest_framework import serializers
 
 from circuits.models import *
@@ -29,6 +31,9 @@ class NestedProviderNetworkSerializer(WritableNestedSerializer):
 # Providers
 #
 
+@extend_schema_serializer(
+    exclude_fields=('circuit_count',),
+)
 class NestedProviderSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     circuit_count = serializers.IntegerField(read_only=True)
@@ -54,6 +59,9 @@ class NestedProviderAccountSerializer(WritableNestedSerializer):
 # Circuits
 #
 
+@extend_schema_serializer(
+    exclude_fields=('circuit_count',),
+)
 class NestedCircuitTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
     circuit_count = serializers.IntegerField(read_only=True)

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

@@ -92,8 +92,8 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
 
 class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
-    site = NestedSiteSerializer()
-    provider_network = NestedProviderNetworkSerializer()
+    site = NestedSiteSerializer(allow_null=True)
+    provider_network = NestedProviderNetworkSerializer(allow_null=True)
 
     class Meta:
         model = CircuitTermination
@@ -110,8 +110,8 @@ class CircuitSerializer(NetBoxModelSerializer):
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     type = NestedCircuitTypeSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    termination_a = CircuitCircuitTerminationSerializer(read_only=True)
-    termination_z = CircuitCircuitTerminationSerializer(read_only=True)
+    termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
+    termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
 
     class Meta:
         model = Circuit

+ 224 - 0
netbox/core/api/schema.py

@@ -0,0 +1,224 @@
+import re
+import typing
+
+from drf_spectacular.extensions import (
+    OpenApiSerializerFieldExtension,
+    OpenApiViewExtension,
+)
+from drf_spectacular.openapi import AutoSchema
+from drf_spectacular.plumbing import (
+    ComponentRegistry,
+    ResolvedComponent,
+    build_basic_type,
+    build_media_type_object,
+    build_object_type,
+    is_serializer,
+)
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema
+from rest_framework.relations import ManyRelatedField
+
+from netbox.api.fields import ChoiceField, SerializedPKRelatedField
+from netbox.api.serializers import WritableNestedSerializer
+
+# see netbox.api.routers.NetBoxRouter
+BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
+WRITABLE_ACTIONS = ("PATCH", "POST", "PUT")
+
+
+class FixTimeZoneSerializerField(OpenApiSerializerFieldExtension):
+    target_class = 'timezone_field.rest_framework.TimeZoneSerializerField'
+
+    def map_serializer_field(self, auto_schema, direction):
+        return build_basic_type(OpenApiTypes.STR)
+
+
+class ChoiceFieldFix(OpenApiSerializerFieldExtension):
+    target_class = 'netbox.api.fields.ChoiceField'
+
+    def map_serializer_field(self, auto_schema, direction):
+        if direction == 'request':
+            return build_basic_type(OpenApiTypes.STR)
+
+        elif direction == "response":
+            return build_object_type(
+                properties={
+                    "value": build_basic_type(OpenApiTypes.STR),
+                    "label": build_basic_type(OpenApiTypes.STR),
+                }
+            )
+
+
+class NetBoxAutoSchema(AutoSchema):
+    """
+    Overrides to drf_spectacular.openapi.AutoSchema to fix following issues:
+        1. bulk serializers cause operation_id conflicts with non-bulk ones
+        2. bulk operations should specify a list
+        3. bulk operations don't have filter params
+        4. bulk operations don't have pagination
+        5. bulk delete should specify input
+    """
+
+    writable_serializers = {}
+
+    @property
+    def is_bulk_action(self):
+        if hasattr(self.view, "action") and self.view.action in BULK_ACTIONS:
+            return True
+        else:
+            return False
+
+    def get_operation_id(self):
+        """
+        bulk serializers cause operation_id conflicts with non-bulk ones
+        bulk operations cause id conflicts in spectacular resulting in numerous:
+        Warning: operationId "xxx" has collisions [xxx]. "resolving with numeral suffixes"
+        code is modified from drf_spectacular.openapi.AutoSchema.get_operation_id
+        """
+        if self.is_bulk_action:
+            tokenized_path = self._tokenize_path()
+            # replace dashes as they can be problematic later in code generation
+            tokenized_path = [t.replace('-', '_') for t in tokenized_path]
+
+            if self.method == 'GET' and self._is_list_view():
+                # this shouldn't happen, but keeping it here to follow base code
+                action = 'list'
+            else:
+                # action = self.method_mapping[self.method.lower()]
+                # use bulk name so partial_update -> bulk_partial_update
+                action = self.view.action.lower()
+
+            if not tokenized_path:
+                tokenized_path.append('root')
+
+            if re.search(r'<drf_format_suffix\w*:\w+>', self.path_regex):
+                tokenized_path.append('formatted')
+
+            return '_'.join(tokenized_path + [action])
+
+        # if not bulk - just return normal id
+        return super().get_operation_id()
+
+    def get_request_serializer(self) -> typing.Any:
+        # bulk operations should specify a list
+        serializer = super().get_request_serializer()
+
+        if self.is_bulk_action:
+            return type(serializer)(many=True)
+
+        # handle mapping for Writable serializers - adapted from dansheps original code
+        # for drf-yasg
+        if serializer is not None and self.method in WRITABLE_ACTIONS:
+            writable_class = self.get_writable_class(serializer)
+            if writable_class is not None:
+                if hasattr(serializer, "child"):
+                    child_serializer = self.get_writable_class(serializer.child)
+                    serializer = writable_class(context=serializer.context, child=child_serializer)
+                else:
+                    serializer = writable_class(context=serializer.context)
+
+        return serializer
+
+    def get_response_serializers(self) -> typing.Any:
+        # bulk operations should specify a list
+        response_serializers = super().get_response_serializers()
+
+        if self.is_bulk_action:
+            return type(response_serializers)(many=True)
+
+        return response_serializers
+
+    def get_serializer_ref_name(self, serializer):
+        # from drf-yasg.utils
+        """Get serializer's ref_name (or None for ModelSerializer if it is named 'NestedSerializer')
+        :param serializer: Serializer instance
+        :return: Serializer's ``ref_name`` or ``None`` for inline serializer
+        :rtype: str or None
+        """
+        serializer_meta = getattr(serializer, 'Meta', None)
+        serializer_name = type(serializer).__name__
+        if hasattr(serializer_meta, 'ref_name'):
+            ref_name = serializer_meta.ref_name
+        elif serializer_name == 'NestedSerializer' and isinstance(serializer, serializers.ModelSerializer):
+            ref_name = None
+        else:
+            ref_name = serializer_name
+            if ref_name.endswith('Serializer'):
+                ref_name = ref_name[: -len('Serializer')]
+        return ref_name
+
+    def get_writable_class(self, serializer):
+        properties = {}
+        fields = {} if hasattr(serializer, 'child') else serializer.fields
+
+        for child_name, child in fields.items():
+            if isinstance(child, (ChoiceField, WritableNestedSerializer)):
+                properties[child_name] = None
+            elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
+                properties[child_name] = None
+
+        if not properties:
+            return None
+
+        if type(serializer) not in self.writable_serializers:
+            writable_name = 'Writable' + type(serializer).__name__
+            meta_class = getattr(type(serializer), 'Meta', None)
+            if meta_class:
+                ref_name = 'Writable' + self.get_serializer_ref_name(serializer)
+                writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name})
+                properties['Meta'] = writable_meta
+
+            self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)
+
+        writable_class = self.writable_serializers[type(serializer)]
+        return writable_class
+
+    def get_filter_backends(self):
+        # bulk operations don't have filter params
+        if self.is_bulk_action:
+            return []
+        return super().get_filter_backends()
+
+    def _get_paginator(self):
+        # bulk operations don't have pagination
+        if self.is_bulk_action:
+            return None
+        return super()._get_paginator()
+
+    def _get_request_body(self, direction='request'):
+        # bulk delete should specify input
+        if (not self.is_bulk_action) or (self.method != 'DELETE'):
+            return super()._get_request_body(direction)
+
+        # rest from drf_spectacular.openapi.AutoSchema._get_request_body
+        # but remove the unsafe method check
+
+        request_serializer = self.get_request_serializer()
+
+        if isinstance(request_serializer, dict):
+            content = []
+            request_body_required = True
+            for media_type, serializer in request_serializer.items():
+                schema, partial_request_body_required = self._get_request_for_media_type(serializer, direction)
+                examples = self._get_examples(serializer, direction, media_type)
+                if schema is None:
+                    continue
+                content.append((media_type, schema, examples))
+                request_body_required &= partial_request_body_required
+        else:
+            schema, request_body_required = self._get_request_for_media_type(request_serializer, direction)
+            if schema is None:
+                return None
+            content = [
+                (media_type, schema, self._get_examples(request_serializer, direction, media_type))
+                for media_type in self.map_parsers()
+            ]
+
+        request_body = {
+            'content': {
+                media_type: build_media_type_object(schema, examples) for media_type, schema, examples in content
+            }
+        }
+        if request_body_required:
+            request_body['required'] = request_body_required
+        return request_body

+ 1 - 0
netbox/core/apps.py

@@ -6,3 +6,4 @@ class CoreConfig(AppConfig):
 
     def ready(self):
         from . import data_backends, search
+        from core.api import schema  # noqa: E402

+ 38 - 1
netbox/dcim/api/nested_serializers.py

@@ -1,3 +1,4 @@
+from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
 from dcim import models
@@ -53,6 +54,9 @@ __all__ = [
 # Regions/sites
 #
 
+@extend_schema_serializer(
+    exclude_fields=('site_count',),
+)
 class NestedRegionSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
     site_count = serializers.IntegerField(read_only=True)
@@ -63,6 +67,9 @@ class NestedRegionSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth']
 
 
+@extend_schema_serializer(
+    exclude_fields=('site_count',),
+)
 class NestedSiteGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
     site_count = serializers.IntegerField(read_only=True)
@@ -85,6 +92,9 @@ class NestedSiteSerializer(WritableNestedSerializer):
 # Racks
 #
 
+@extend_schema_serializer(
+    exclude_fields=('rack_count',),
+)
 class NestedLocationSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
     rack_count = serializers.IntegerField(read_only=True)
@@ -95,6 +105,9 @@ class NestedLocationSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count', '_depth']
 
 
+@extend_schema_serializer(
+    exclude_fields=('rack_count',),
+)
 class NestedRackRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
     rack_count = serializers.IntegerField(read_only=True)
@@ -104,6 +117,9 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count']
 
 
+@extend_schema_serializer(
+    exclude_fields=('device_count',),
+)
 class NestedRackSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
     device_count = serializers.IntegerField(read_only=True)
@@ -129,6 +145,9 @@ class NestedRackReservationSerializer(WritableNestedSerializer):
 # Device/module types
 #
 
+@extend_schema_serializer(
+    exclude_fields=('devicetype_count',),
+)
 class NestedManufacturerSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
     devicetype_count = serializers.IntegerField(read_only=True)
@@ -138,6 +157,9 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug', 'devicetype_count']
 
 
+@extend_schema_serializer(
+    exclude_fields=('device_count',),
+)
 class NestedDeviceTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     manufacturer = NestedManufacturerSerializer(read_only=True)
@@ -247,6 +269,9 @@ class NestedInventoryItemTemplateSerializer(WritableNestedSerializer):
 # Devices
 #
 
+@extend_schema_serializer(
+    exclude_fields=('device_count', 'virtualmachine_count'),
+)
 class NestedDeviceRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
     device_count = serializers.IntegerField(read_only=True)
@@ -257,6 +282,9 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
 
 
+@extend_schema_serializer(
+    exclude_fields=('device_count', 'virtualmachine_count'),
+)
 class NestedPlatformSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
     device_count = serializers.IntegerField(read_only=True)
@@ -386,7 +414,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
 
 class NestedModuleBaySerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
-    module = NestedModuleSerializer(read_only=True)
+    module = NestedModuleSerializer(required=False, read_only=True, allow_null=True)
 
     class Meta:
         model = models.ModuleBay
@@ -412,6 +440,9 @@ class NestedInventoryItemSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'device', 'name', '_depth']
 
 
+@extend_schema_serializer(
+    exclude_fields=('inventoryitem_count',),
+)
 class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
     inventoryitem_count = serializers.IntegerField(read_only=True)
@@ -437,6 +468,9 @@ class NestedCableSerializer(BaseModelSerializer):
 # Virtual chassis
 #
 
+@extend_schema_serializer(
+    exclude_fields=('member_count',),
+)
 class NestedVirtualChassisSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     master = NestedDeviceSerializer()
@@ -451,6 +485,9 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
 # Power panels/feeds
 #
 
+@extend_schema_serializer(
+    exclude_fields=('powerfeed_count',),
+)
 class NestedPowerPanelSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
     powerfeed_count = serializers.IntegerField(read_only=True)

+ 57 - 43
netbox/dcim/api/serializers.py

@@ -2,7 +2,8 @@ import decimal
 
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
-from drf_yasg.utils import swagger_serializer_method
+from drf_spectacular.utils import extend_schema_field
+from drf_spectacular.types import OpenApiTypes
 from rest_framework import serializers
 from timezone_field.rest_framework import TimeZoneSerializerField
 
@@ -33,12 +34,13 @@ from .nested_serializers import *
 
 
 class CabledObjectSerializer(serializers.ModelSerializer):
-    cable = NestedCableSerializer(read_only=True)
+    cable = NestedCableSerializer(read_only=True, allow_null=True)
     cable_end = serializers.CharField(read_only=True)
     link_peers_type = serializers.SerializerMethodField(read_only=True)
     link_peers = serializers.SerializerMethodField(read_only=True)
     _occupied = serializers.SerializerMethodField(read_only=True)
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_link_peers_type(self, obj):
         """
         Return the type of the peer link terminations, or None.
@@ -51,7 +53,7 @@ class CabledObjectSerializer(serializers.ModelSerializer):
 
         return None
 
-    @swagger_serializer_method(serializer_or_field=serializers.ListField)
+    @extend_schema_field(serializers.ListField)
     def get_link_peers(self, obj):
         """
         Return the appropriate serializer for the link termination model.
@@ -64,7 +66,7 @@ class CabledObjectSerializer(serializers.ModelSerializer):
         context = {'request': self.context['request']}
         return serializer(obj.link_peers, context=context, many=True).data
 
-    @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
+    @extend_schema_field(serializers.BooleanField)
     def get__occupied(self, obj):
         return obj._occupied
 
@@ -77,11 +79,12 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
     connected_endpoints = serializers.SerializerMethodField(read_only=True)
     connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True)
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_connected_endpoints_type(self, obj):
         if endpoints := obj.connected_endpoints:
             return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
 
-    @swagger_serializer_method(serializer_or_field=serializers.ListField)
+    @extend_schema_field(serializers.ListField)
     def get_connected_endpoints(self, obj):
         """
         Return the appropriate serializer for the type of connected object.
@@ -91,7 +94,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
             context = {'request': self.context['request']}
             return serializer(endpoints, many=True, context=context).data
 
-    @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
+    @extend_schema_field(serializers.BooleanField)
     def get_connected_endpoints_reachable(self, obj):
         return obj._path and obj._path.is_complete and obj._path.is_active
 
@@ -198,12 +201,12 @@ class RackSerializer(NetBoxModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=RackStatusChoices, required=False)
     role = NestedRackRoleSerializer(required=False, allow_null=True)
-    type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
+    type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False, allow_null=True)
     facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'),
                                         default=None)
     width = ChoiceField(choices=RackWidthChoices, required=False)
-    outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
-    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
+    outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True)
+    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
     device_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
 
@@ -232,6 +235,7 @@ class RackUnitSerializer(serializers.Serializer):
     occupied = serializers.BooleanField(read_only=True)
     display = serializers.SerializerMethodField(read_only=True)
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_display(self, obj):
         return obj['name']
 
@@ -318,9 +322,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
         min_value=0,
         default=1.0
     )
-    subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
-    airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
-    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
+    subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
+    airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
+    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
     device_count = serializers.IntegerField(read_only=True)
 
     class Meta:
@@ -335,7 +339,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
 class ModuleTypeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
     manufacturer = NestedManufacturerSerializer()
-    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
+    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
 
     class Meta:
         model = ModuleType
@@ -416,7 +420,8 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
     type = ChoiceField(
         choices=PowerPortTypeChoices,
         allow_blank=True,
-        required=False
+        required=False,
+        allow_null=True
     )
 
     class Meta:
@@ -442,7 +447,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     type = ChoiceField(
         choices=PowerOutletTypeChoices,
         allow_blank=True,
-        required=False
+        required=False,
+        allow_null=True
     )
     power_port = NestedPowerPortTemplateSerializer(
         required=False,
@@ -451,7 +457,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     feed_leg = ChoiceField(
         choices=PowerOutletFeedLegChoices,
         allow_blank=True,
-        required=False
+        required=False,
+        allow_null=True
     )
 
     class Meta:
@@ -482,12 +489,14 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
     poe_mode = ChoiceField(
         choices=InterfacePoEModeChoices,
         required=False,
-        allow_blank=True
+        allow_blank=True,
+        allow_null=True
     )
     poe_type = ChoiceField(
         choices=InterfacePoETypeChoices,
         required=False,
-        allow_blank=True
+        allow_blank=True,
+        allow_null=True
     )
 
     class Meta:
@@ -589,7 +598,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
             'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
         ]
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_component(self, obj):
         if obj.component is None:
             return None
@@ -640,7 +649,7 @@ class DeviceSerializer(NetBoxModelSerializer):
     site = NestedSiteSerializer()
     location = NestedLocationSerializer(required=False, allow_null=True, default=None)
     rack = NestedRackSerializer(required=False, allow_null=True, default=None)
-    face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='')
+    face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default=lambda: '')
     position = serializers.DecimalField(
         max_digits=4,
         decimal_places=1,
@@ -669,7 +678,7 @@ class DeviceSerializer(NetBoxModelSerializer):
             'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
 
-    @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
+    @extend_schema_field(NestedDeviceSerializer)
     def get_parent_device(self, obj):
         try:
             device_bay = obj.parent_bay
@@ -682,7 +691,7 @@ class DeviceSerializer(NetBoxModelSerializer):
 
 
 class DeviceWithConfigContextSerializer(DeviceSerializer):
-    config_context = serializers.SerializerMethodField()
+    config_context = serializers.SerializerMethodField(read_only=True)
 
     class Meta(DeviceSerializer.Meta):
         fields = [
@@ -692,7 +701,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
             'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
         ]
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_config_context(self, obj):
         return obj.get_config_context()
 
@@ -701,7 +710,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     device = NestedDeviceSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
-    primary_ip = NestedIPAddressSerializer(read_only=True)
+    primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
 
@@ -806,7 +815,8 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
     type = ChoiceField(
         choices=PowerOutletTypeChoices,
         allow_blank=True,
-        required=False
+        required=False,
+        allow_null=True
     )
     power_port = NestedPowerPortSerializer(
         required=False,
@@ -815,7 +825,8 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
     feed_leg = ChoiceField(
         choices=PowerOutletFeedLegChoices,
         allow_blank=True,
-        required=False
+        required=False,
+        allow_null=True
     )
 
     class Meta:
@@ -838,7 +849,8 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
     type = ChoiceField(
         choices=PowerPortTypeChoices,
         allow_blank=True,
-        required=False
+        required=False,
+        allow_null=True
     )
 
     class Meta:
@@ -868,12 +880,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
     parent = NestedInterfaceSerializer(required=False, allow_null=True)
     bridge = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
-    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
-    duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True)
-    rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
-    rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
-    poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
-    poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
+    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True)
+    duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
+    rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True)
+    rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True)
+    poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True)
+    poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
@@ -882,8 +894,8 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
         many=True
     )
     vrf = NestedVRFSerializer(required=False, allow_null=True)
-    l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
-    wireless_link = NestedWirelessLinkSerializer(read_only=True)
+    l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True)
+    wireless_link = NestedWirelessLinkSerializer(read_only=True, allow_null=True)
     wireless_lans = SerializedPKRelatedField(
         queryset=WirelessLAN.objects.all(),
         serializer=NestedWirelessLANSerializer,
@@ -892,6 +904,8 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
     )
     count_ipaddresses = serializers.IntegerField(read_only=True)
     count_fhrp_groups = serializers.IntegerField(read_only=True)
+    mac_address = serializers.CharField(required=False, default=None)
+    wwn = serializers.CharField(required=False, default=None)
 
     class Meta:
         model = Interface
@@ -1015,7 +1029,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
             'custom_fields', 'created', 'last_updated', '_depth',
         ]
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_component(self, obj):
         if obj.component is None:
             return None
@@ -1050,7 +1064,7 @@ class CableSerializer(NetBoxModelSerializer):
     b_terminations = GenericObjectSerializer(many=True, required=False)
     status = ChoiceField(choices=LinkStatusChoices, required=False)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
+    length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
 
     class Meta:
         model = Cable
@@ -1086,7 +1100,7 @@ class CableTerminationSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination'
         ]
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_termination(self, obj):
         serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
@@ -1100,7 +1114,7 @@ class CablePathSerializer(serializers.ModelSerializer):
         model = CablePath
         fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
 
-    @swagger_serializer_method(serializer_or_field=serializers.ListField)
+    @extend_schema_field(serializers.ListField)
     def get_path(self, obj):
         ret = []
         for nodes in obj.path_objects:
@@ -1159,19 +1173,19 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
     )
     type = ChoiceField(
         choices=PowerFeedTypeChoices,
-        default=PowerFeedTypeChoices.TYPE_PRIMARY
+        default=lambda: PowerFeedTypeChoices.TYPE_PRIMARY,
     )
     status = ChoiceField(
         choices=PowerFeedStatusChoices,
-        default=PowerFeedStatusChoices.STATUS_ACTIVE
+        default=lambda: PowerFeedStatusChoices.STATUS_ACTIVE,
     )
     supply = ChoiceField(
         choices=PowerFeedSupplyChoices,
-        default=PowerFeedSupplyChoices.SUPPLY_AC
+        default=lambda: PowerFeedSupplyChoices.SUPPLY_AC,
     )
     phase = ChoiceField(
         choices=PowerFeedPhaseChoices,
-        default=PowerFeedPhaseChoices.PHASE_SINGLE
+        default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE,
     )
 
     class Meta:

+ 10 - 17
netbox/dcim/api/views.py

@@ -1,8 +1,7 @@
 from django.http import Http404, HttpResponse
 from django.shortcuts import get_object_or_404
-from drf_yasg import openapi
-from drf_yasg.openapi import Parameter
-from drf_yasg.utils import swagger_auto_schema
+from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
+from drf_spectacular.types import OpenApiTypes
 from rest_framework.decorators import action
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
@@ -194,10 +193,6 @@ class RackViewSet(NetBoxModelViewSet):
     serializer_class = serializers.RackSerializer
     filterset_class = filtersets.RackFilterSet
 
-    @swagger_auto_schema(
-        responses={200: serializers.RackUnitSerializer(many=True)},
-        query_serializer=serializers.RackElevationDetailFilterSerializer
-    )
     @action(detail=True)
     def elevation(self, request, pk=None):
         """
@@ -622,28 +617,26 @@ class ConnectedDeviceViewSet(ViewSet):
     * `peer_interface`: The name of the peer interface
     """
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
-    _device_param = Parameter(
+    _device_param = OpenApiParameter(
         name='peer_device',
-        in_='query',
+        location='query',
         description='The name of the peer device',
         required=True,
-        type=openapi.TYPE_STRING
+        type=OpenApiTypes.STR
     )
-    _interface_param = Parameter(
+    _interface_param = OpenApiParameter(
         name='peer_interface',
-        in_='query',
+        location='query',
         description='The name of the peer interface',
         required=True,
-        type=openapi.TYPE_STRING
+        type=OpenApiTypes.STR
     )
+    serializer_class = serializers.DeviceSerializer
 
     def get_view_name(self):
         return "Connected Device Locator"
 
-    @swagger_auto_schema(
-        manual_parameters=[_device_param, _interface_param],
-        responses={'200': serializers.DeviceSerializer}
-    )
+    @extend_schema(responses={200: OpenApiTypes.OBJECT})
     def list(self, request):
 
         peer_device_name = request.query_params.get(self._device_param.name)

+ 1 - 1
netbox/dcim/tests/test_api.py

@@ -1674,7 +1674,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
 
 class ModuleBayTest(APIViewTestCases.APIViewTestCase):
     model = ModuleBay
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['display', 'id', 'module', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }

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

@@ -1,4 +1,6 @@
 from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from drf_spectacular.types import OpenApiTypes
 from rest_framework.fields import Field
 from rest_framework.serializers import ValidationError
 
@@ -36,6 +38,7 @@ class CustomFieldDefaultValues:
         return value
 
 
+@extend_schema_field(OpenApiTypes.OBJECT)
 class CustomFieldsDataField(Field):
 
     def _get_custom_fields(self):

+ 7 - 5
netbox/extras/api/serializers.py

@@ -1,7 +1,6 @@
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
-from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 
 from core.api.serializers import JobSerializer
@@ -11,6 +10,8 @@ from dcim.api.nested_serializers import (
     NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
 )
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
+from drf_spectacular.utils import extend_schema_field
+from drf_spectacular.types import OpenApiTypes
 from extras.choices import *
 from extras.models import *
 from extras.utils import FeatureQuery
@@ -103,6 +104,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
             'last_updated',
         ]
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_data_type(self, obj):
         types = CustomFieldTypeChoices
         if obj.type == types.TYPE_INTEGER:
@@ -230,7 +232,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
 
         return data
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_parent(self, obj):
         serializer = get_serializer_for_model(obj.parent, prefix=NESTED_SERIALIZER_PREFIX)
         return serializer(obj.parent, context={'request': self.context['request']}).data
@@ -280,7 +282,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
 
         return data
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_assigned_object(self, instance):
         serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
@@ -453,7 +455,7 @@ class ScriptSerializer(serializers.Serializer):
     vars = serializers.SerializerMethodField(read_only=True)
     result = NestedJobSerializer()
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_vars(self, instance):
         return {
             k: v.__class__.__name__ for k, v in instance._get_vars().items()
@@ -514,7 +516,7 @@ class ObjectChangeSerializer(BaseModelSerializer):
             'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
         ]
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_changed_object(self, obj):
         """
         Serialize a nested representation of the changed object.

+ 2 - 2
netbox/extras/api/views.py

@@ -168,7 +168,7 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
 class ReportViewSet(ViewSet):
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
     _ignore_model_permissions = True
-    exclude_from_schema = True
+    schema = None
     lookup_value_regex = '[^/]+'  # Allow dots
 
     def _get_report(self, pk):
@@ -270,7 +270,7 @@ class ReportViewSet(ViewSet):
 class ScriptViewSet(ViewSet):
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
     _ignore_model_permissions = True
-    exclude_from_schema = True
+    schema = None
     lookup_value_regex = '[^/]+'  # Allow dots
 
     def _get_script(self, pk):

+ 5 - 4
netbox/extras/plugins/views.py

@@ -5,6 +5,7 @@ from django.conf import settings
 from django.shortcuts import render
 from django.urls.exceptions import NoReverseMatch
 from django.views.generic import View
+from drf_spectacular.utils import extend_schema
 from rest_framework import permissions
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
@@ -22,14 +23,14 @@ class InstalledPluginsAdminView(View):
         })
 
 
+@extend_schema(exclude=True)
 class InstalledPluginsAPIView(APIView):
     """
     API view for listing all installed plugins
     """
     permission_classes = [permissions.IsAdminUser]
     _ignore_model_permissions = True
-    exclude_from_schema = True
-    swagger_schema = None
+    schema = None
 
     def get_view_name(self):
         return "Installed Plugins"
@@ -49,10 +50,10 @@ class InstalledPluginsAPIView(APIView):
         return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS])
 
 
+@extend_schema(exclude=True)
 class PluginsAPIRootView(APIView):
     _ignore_model_permissions = True
-    exclude_from_schema = True
-    swagger_schema = None
+    schema = None
 
     def get_view_name(self):
         return "Plugins"

+ 13 - 0
netbox/ipam/api/nested_serializers.py

@@ -1,3 +1,4 @@
+from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
 from ipam import models
@@ -54,6 +55,9 @@ class NestedASNSerializer(WritableNestedSerializer):
 # VRFs
 #
 
+@extend_schema_serializer(
+    exclude_fields=('prefix_count',),
+)
 class NestedVRFSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
     prefix_count = serializers.IntegerField(read_only=True)
@@ -79,6 +83,9 @@ class NestedRouteTargetSerializer(WritableNestedSerializer):
 # RIRs/aggregates
 #
 
+@extend_schema_serializer(
+    exclude_fields=('aggregate_count',),
+)
 class NestedRIRSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
     aggregate_count = serializers.IntegerField(read_only=True)
@@ -121,6 +128,9 @@ class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
 # VLANs
 #
 
+@extend_schema_serializer(
+    exclude_fields=('prefix_count', 'vlan_count'),
+)
 class NestedRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
     prefix_count = serializers.IntegerField(read_only=True)
@@ -131,6 +141,9 @@ class NestedRoleSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug', 'prefix_count', 'vlan_count']
 
 
+@extend_schema_serializer(
+    exclude_fields=('vlan_count',),
+)
 class NestedVLANGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
     vlan_count = serializers.IntegerField(read_only=True)

+ 11 - 9
netbox/ipam/api/serializers.py

@@ -1,5 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
-from drf_yasg.utils import swagger_serializer_method
+from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
@@ -136,6 +136,7 @@ class AggregateSerializer(NetBoxModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     rir = NestedRIRSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
+    prefix = serializers.CharField()
 
     class Meta:
         model = Aggregate
@@ -177,7 +178,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
             'last_updated',
         ]
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_interface(self, obj):
         if obj.interface is None:
             return None
@@ -225,7 +226,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
         ]
         validators = []
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_scope(self, obj):
         if obj.scope_id is None:
             return None
@@ -242,7 +243,7 @@ class VLANSerializer(NetBoxModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=VLANStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
-    l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
+    l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True)
     prefix_count = serializers.IntegerField(read_only=True)
 
     class Meta:
@@ -302,6 +303,7 @@ class PrefixSerializer(NetBoxModelSerializer):
     role = NestedRoleSerializer(required=False, allow_null=True)
     children = serializers.IntegerField(read_only=True)
     _depth = serializers.IntegerField(read_only=True)
+    prefix = serializers.CharField()
 
     class Meta:
         model = Prefix
@@ -371,13 +373,13 @@ class IPRangeSerializer(NetBoxModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=IPRangeStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
-    children = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = IPRange
         fields = [
             'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role',
-            'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         read_only_fields = ['family']
 
@@ -392,7 +394,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=IPAddressStatusChoices, required=False)
-    role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
+    role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False, allow_null=True)
     assigned_object_type = ContentTypeField(
         queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
         required=False,
@@ -410,7 +412,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
             'tags', 'custom_fields', 'created', 'last_updated',
         ]
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_assigned_object(self, obj):
         if obj.assigned_object is None:
             return None
@@ -519,7 +521,7 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer):
             'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated'
         ]
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_assigned_object(self, instance):
         serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}

+ 33 - 21
netbox/ipam/api/views.py

@@ -2,7 +2,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
 from django.shortcuts import get_object_or_404
 from django_pglocks import advisory_lock
-from drf_yasg.utils import swagger_auto_schema
+from drf_spectacular.utils import extend_schema, extend_schema_view
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
@@ -210,7 +210,7 @@ def get_results_limit(request):
 class AvailableASNsView(ObjectValidationMixin, APIView):
     queryset = ASN.objects.all()
 
-    @swagger_auto_schema(responses={200: serializers.AvailableASNSerializer(many=True)})
+    @extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)})
     def get(self, request, pk):
         asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk)
         limit = get_results_limit(request)
@@ -224,10 +224,7 @@ class AvailableASNsView(ObjectValidationMixin, APIView):
 
         return Response(serializer.data)
 
-    @swagger_auto_schema(
-        request_body=serializers.AvailableASNSerializer,
-        responses={201: serializers.ASNSerializer(many=True)}
-    )
+    @extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)})
     @advisory_lock(ADVISORY_LOCK_KEYS['available-asns'])
     def post(self, request, pk):
         self.queryset = self.queryset.restrict(request.user, 'add')
@@ -274,11 +271,17 @@ class AvailableASNsView(ObjectValidationMixin, APIView):
 
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
+    def get_serializer_class(self):
+        if self.request.method == "GET":
+            return serializers.AvailableASNSerializer
+
+        return serializers.ASNSerializer
+
 
 class AvailablePrefixesView(ObjectValidationMixin, APIView):
     queryset = Prefix.objects.all()
 
-    @swagger_auto_schema(responses={200: serializers.AvailablePrefixSerializer(many=True)})
+    @extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)})
     def get(self, request, pk):
         prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
         available_prefixes = prefix.get_available_prefixes()
@@ -290,10 +293,7 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView):
 
         return Response(serializer.data)
 
-    @swagger_auto_schema(
-        request_body=serializers.PrefixLengthSerializer,
-        responses={201: serializers.PrefixSerializer(many=True)}
-    )
+    @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)})
     @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
     def post(self, request, pk):
         self.queryset = self.queryset.restrict(request.user, 'add')
@@ -356,6 +356,12 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView):
 
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
+    def get_serializer_class(self):
+        if self.request.method == "GET":
+            return serializers.AvailablePrefixSerializer
+
+        return serializers.PrefixLengthSerializer
+
 
 class AvailableIPAddressesView(ObjectValidationMixin, APIView):
     queryset = IPAddress.objects.all()
@@ -363,7 +369,7 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView):
     def get_parent(self, request, pk):
         raise NotImplemented()
 
-    @swagger_auto_schema(responses={200: serializers.AvailableIPSerializer(many=True)})
+    @extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)})
     def get(self, request, pk):
         parent = self.get_parent(request, pk)
         limit = get_results_limit(request)
@@ -382,10 +388,7 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView):
 
         return Response(serializer.data)
 
-    @swagger_auto_schema(
-        request_body=serializers.AvailableIPSerializer,
-        responses={201: serializers.IPAddressSerializer(many=True)}
-    )
+    @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
     @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
     def post(self, request, pk):
         self.queryset = self.queryset.restrict(request.user, 'add')
@@ -430,6 +433,12 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView):
 
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
+    def get_serializer_class(self):
+        if self.request.method == "GET":
+            return serializers.AvailableIPSerializer
+
+        return serializers.IPAddressSerializer
+
 
 class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
 
@@ -446,7 +455,7 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView):
 class AvailableVLANsView(ObjectValidationMixin, APIView):
     queryset = VLAN.objects.all()
 
-    @swagger_auto_schema(responses={200: serializers.AvailableVLANSerializer(many=True)})
+    @extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)})
     def get(self, request, pk):
         vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
         limit = get_results_limit(request)
@@ -459,10 +468,7 @@ class AvailableVLANsView(ObjectValidationMixin, APIView):
 
         return Response(serializer.data)
 
-    @swagger_auto_schema(
-        request_body=serializers.CreateAvailableVLANSerializer,
-        responses={201: serializers.VLANSerializer(many=True)}
-    )
+    @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
     @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans'])
     def post(self, request, pk):
         self.queryset = self.queryset.restrict(request.user, 'add')
@@ -514,3 +520,9 @@ class AvailableVLANsView(ObjectValidationMixin, APIView):
             return Response(serializer.data, status=status.HTTP_201_CREATED)
 
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+    def get_serializer_class(self):
+        if self.request.method == "GET":
+            return serializers.AvailableVLANSerializer
+
+        return serializers.VLANSerializer

+ 0 - 2
netbox/ipam/filtersets.py

@@ -16,8 +16,6 @@ from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .models import *
 
-from rest_framework import serializers
-
 __all__ = (
     'AggregateFilterSet',
     'ASNFilterSet',

+ 4 - 0
netbox/netbox/api/fields.py

@@ -1,4 +1,6 @@
 from django.core.exceptions import ObjectDoesNotExist
+from drf_spectacular.utils import extend_schema_field
+from drf_spectacular.types import OpenApiTypes
 from netaddr import IPNetwork
 from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
@@ -12,6 +14,7 @@ __all__ = (
 )
 
 
+@extend_schema_field(OpenApiTypes.STR)
 class ChoiceField(serializers.Field):
     """
     Represent a ChoiceField as {'value': <DB value>, 'label': <string>}. Accepts a single value on write.
@@ -86,6 +89,7 @@ class ChoiceField(serializers.Field):
         return self._choices
 
 
+@extend_schema_field(OpenApiTypes.STR)
 class ContentTypeField(RelatedField):
     """
     Represent a ContentType as '<app_label>.<model>'

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

@@ -1,5 +1,7 @@
 from django.db.models import ManyToManyField
 from rest_framework import serializers
+from drf_spectacular.utils import extend_schema_field
+from drf_spectacular.types import OpenApiTypes
 
 __all__ = (
     'BaseModelSerializer',
@@ -10,6 +12,7 @@ __all__ = (
 class BaseModelSerializer(serializers.ModelSerializer):
     display = serializers.SerializerMethodField(read_only=True)
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_display(self, obj):
         return str(obj)
 

+ 2 - 2
netbox/netbox/api/serializers/generic.py

@@ -1,5 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
-from drf_yasg.utils import swagger_serializer_method
+from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from netbox.api.fields import ContentTypeField
@@ -38,7 +38,7 @@ class GenericObjectSerializer(serializers.Serializer):
 
         return data
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_object(self, obj):
         serializer = get_serializer_for_model(obj, prefix=NESTED_SERIALIZER_PREFIX)
         # context = {'request': self.context['request']}

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

@@ -4,6 +4,8 @@ from django import __version__ as DJANGO_VERSION
 from django.apps import apps
 from django.conf import settings
 from django_rq.queues import get_connection
+from drf_spectacular.utils import extend_schema
+from drf_spectacular.types import OpenApiTypes
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
 from rest_framework.views import APIView
@@ -17,12 +19,12 @@ class APIRootView(APIView):
     This is the root of NetBox's REST API. API endpoints are arranged by app and model name; e.g. `/api/dcim/sites/`.
     """
     _ignore_model_permissions = True
-    exclude_from_schema = True
-    swagger_schema = None
+    # schema = None
 
     def get_view_name(self):
         return "API Root"
 
+    @extend_schema(exclude=True)
     def get(self, request, format=None):
 
         return Response({
@@ -46,6 +48,7 @@ class StatusView(APIView):
     """
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
 
+    @extend_schema(responses={200: OpenApiTypes.OBJECT})
     def get(self, request):
         # Gather the version numbers from all installed Django apps
         installed_apps = {}

+ 13 - 44
netbox/netbox/settings.py

@@ -344,7 +344,7 @@ INSTALLED_APPS = [
     'virtualization',
     'wireless',
     'django_rq',  # Must come after extras to allow overriding management commands
-    'drf_yasg',
+    'drf_spectacular',
 ]
 
 # Middleware
@@ -561,6 +561,7 @@ REST_FRAMEWORK = {
         'rest_framework.renderers.JSONRenderer',
         'netbox.api.renderers.FormlessBrowsableAPIRenderer',
     ),
+    'DEFAULT_SCHEMA_CLASS': 'core.api.schema.NetBoxAutoSchema',
     'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
     'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
     'SCHEMA_COERCE_METHOD_NAMES': {
@@ -573,6 +574,17 @@ REST_FRAMEWORK = {
     'VIEW_NAME_FUNCTION': 'utilities.api.get_view_name',
 }
 
+#
+# DRF Spectacular
+#
+
+SPECTACULAR_SETTINGS = {
+    "TITLE": "NetBox API",
+    "DESCRIPTION": "API to access NetBox",
+    "LICENSE": {"name": "Apache v2 License"},
+    "VERSION": VERSION,
+    'COMPONENT_SPLIT_REQUEST': True,
+}
 
 #
 # Graphene
@@ -585,49 +597,6 @@ GRAPHENE = {
 }
 
 
-#
-# drf_yasg (OpenAPI/Swagger)
-#
-
-SWAGGER_SETTINGS = {
-    'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
-    'DEFAULT_FIELD_INSPECTORS': [
-        'utilities.custom_inspectors.CustomFieldsDataFieldInspector',
-        'utilities.custom_inspectors.NullableBooleanFieldInspector',
-        'utilities.custom_inspectors.ChoiceFieldInspector',
-        'utilities.custom_inspectors.SerializedPKRelatedFieldInspector',
-        'drf_yasg.inspectors.CamelCaseJSONFilter',
-        'drf_yasg.inspectors.ReferencingSerializerInspector',
-        'drf_yasg.inspectors.RelatedFieldInspector',
-        'drf_yasg.inspectors.ChoiceFieldInspector',
-        'drf_yasg.inspectors.FileFieldInspector',
-        'drf_yasg.inspectors.DictFieldInspector',
-        'drf_yasg.inspectors.JSONFieldInspector',
-        'drf_yasg.inspectors.SerializerMethodFieldInspector',
-        'drf_yasg.inspectors.SimpleFieldInspector',
-        'drf_yasg.inspectors.StringDefaultFieldInspector',
-    ],
-    'DEFAULT_FILTER_INSPECTORS': [
-        'drf_yasg.inspectors.CoreAPICompatInspector',
-    ],
-    'DEFAULT_INFO': 'netbox.urls.openapi_info',
-    'DEFAULT_MODEL_DEPTH': 1,
-    'DEFAULT_PAGINATOR_INSPECTORS': [
-        'utilities.custom_inspectors.NullablePaginatorInspector',
-        'drf_yasg.inspectors.DjangoRestResponsePagination',
-        'drf_yasg.inspectors.CoreAPICompatInspector',
-    ],
-    'SECURITY_DEFINITIONS': {
-        'Bearer': {
-            'type': 'apiKey',
-            'name': 'Authorization',
-            'in': 'header',
-        }
-    },
-    'VALIDATOR_URL': None,
-}
-
-
 #
 # Django RQ (Webhooks backend)
 #

+ 5 - 19
netbox/netbox/urls.py

@@ -3,8 +3,7 @@ from django.conf.urls import include
 from django.urls import path, re_path
 from django.views.decorators.csrf import csrf_exempt
 from django.views.static import serve
-from drf_yasg import openapi
-from drf_yasg.views import get_schema_view
+from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
 
 from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
 from netbox.api.views import APIRootView, StatusView
@@ -14,20 +13,6 @@ from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
 from users.views import LoginView, LogoutView
 from .admin import admin_site
 
-openapi_info = openapi.Info(
-    title="NetBox API",
-    default_version='v3',
-    description="API to access NetBox",
-    terms_of_service="https://github.com/netbox-community/netbox",
-    license=openapi.License(name="Apache v2 License"),
-)
-
-schema_view = get_schema_view(
-    openapi_info,
-    validators=['flex', 'ssv'],
-    public=True,
-    permission_classes=()
-)
 
 _patterns = [
 
@@ -66,9 +51,10 @@ _patterns = [
     path('api/virtualization/', include('virtualization.api.urls')),
     path('api/wireless/', include('wireless.api.urls')),
     path('api/status/', StatusView.as_view(), name='api-status'),
-    path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'),
-    path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'),
-    re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=86400), name='schema_swagger'),
+
+    path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
+    path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='api_docs'),
+    path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'),
 
     # GraphQL
     path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),

+ 7 - 0
netbox/tenancy/api/nested_serializers.py

@@ -1,3 +1,4 @@
+from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
 from netbox.api.serializers import WritableNestedSerializer
@@ -17,6 +18,9 @@ __all__ = [
 # Tenants
 #
 
+@extend_schema_serializer(
+    exclude_fields=('tenant_count',),
+)
 class NestedTenantGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
     tenant_count = serializers.IntegerField(read_only=True)
@@ -39,6 +43,9 @@ class NestedTenantSerializer(WritableNestedSerializer):
 # Contacts
 #
 
+@extend_schema_serializer(
+    exclude_fields=('contact_count',),
+)
 class NestedContactGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
     contact_count = serializers.IntegerField(read_only=True)

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

@@ -1,5 +1,6 @@
 from django.contrib.auth.models import ContentType
-from drf_yasg.utils import swagger_serializer_method
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from netbox.api.fields import ChoiceField, ContentTypeField
@@ -98,7 +99,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
     object = serializers.SerializerMethodField(read_only=True)
     contact = NestedContactSerializer()
     role = NestedContactRoleSerializer(required=False, allow_null=True)
-    priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default='')
+    priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default=lambda: '')
 
     class Meta:
         model = ContactAssignment
@@ -107,7 +108,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
             'last_updated',
         ]
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(OpenApiTypes.OBJECT)
     def get_object(self, instance):
         serializer = get_serializer_for_model(instance.content_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}

+ 5 - 3
netbox/users/api/nested_serializers.py

@@ -1,6 +1,7 @@
 from django.contrib.auth.models import Group, User
 from django.contrib.contenttypes.models import ContentType
-from drf_yasg.utils import swagger_serializer_method
+from drf_spectacular.utils import extend_schema_field
+from drf_spectacular.types import OpenApiTypes
 from rest_framework import serializers
 
 from netbox.api.fields import ContentTypeField
@@ -30,6 +31,7 @@ class NestedUserSerializer(WritableNestedSerializer):
         model = User
         fields = ['id', 'url', 'display', 'username']
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_display(self, obj):
         if full_name := obj.get_full_name():
             return f"{obj.username} ({full_name})"
@@ -57,10 +59,10 @@ class NestedObjectPermissionSerializer(WritableNestedSerializer):
         model = ObjectPermission
         fields = ['id', 'url', 'display', 'name', 'enabled', 'object_types', 'groups', 'users', 'actions']
 
-    @swagger_serializer_method(serializer_or_field=serializers.ListField)
+    @extend_schema_field(serializers.ListField)
     def get_groups(self, obj):
         return [g.name for g in obj.groups.all()]
 
-    @swagger_serializer_method(serializer_or_field=serializers.ListField)
+    @extend_schema_field(serializers.ListField)
     def get_users(self, obj):
         return [u.username for u in obj.users.all()]

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

@@ -1,6 +1,8 @@
 from django.conf import settings
 from django.contrib.auth.models import Group, User
 from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from drf_spectacular.types import OpenApiTypes
 from rest_framework import serializers
 
 from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
@@ -47,6 +49,7 @@ class UserSerializer(ValidatedModelSerializer):
 
         return user
 
+    @extend_schema_field(OpenApiTypes.STR)
     def get_display(self, obj):
         if full_name := obj.get_full_name():
             return f"{obj.username} ({full_name})"

+ 8 - 3
netbox/users/api/views.py

@@ -1,6 +1,8 @@
 from django.contrib.auth import authenticate
 from django.contrib.auth.models import Group, User
 from django.db.models import Count
+from drf_spectacular.utils import extend_schema
+from drf_spectacular.types import OpenApiTypes
 from rest_framework.exceptions import AuthenticationFailed
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
@@ -55,9 +57,6 @@ class TokenViewSet(NetBoxModelViewSet):
         Limit the non-superusers to their own Tokens.
         """
         queryset = super().get_queryset()
-        # Workaround for schema generation (drf_yasg)
-        if getattr(self, 'swagger_fake_view', False):
-            return queryset.none()
         if not self.request.user.is_authenticated:
             return queryset.none()
         if self.request.user.is_superuser:
@@ -71,6 +70,7 @@ class TokenProvisionView(APIView):
     """
     permission_classes = []
 
+    # @extend_schema(methods=["post"], responses={201: serializers.TokenSerializer})
     def post(self, request):
         serializer = serializers.TokenProvisionSerializer(data=request.data)
         serializer.is_valid()
@@ -93,6 +93,9 @@ class TokenProvisionView(APIView):
 
         return Response(data, status=HTTP_201_CREATED)
 
+    def get_serializer_class(self):
+        return serializers.TokenSerializer
+
 
 #
 # ObjectPermissions
@@ -117,6 +120,7 @@ class UserConfigViewSet(ViewSet):
     def get_queryset(self):
         return UserConfig.objects.filter(user=self.request.user)
 
+    @extend_schema(responses={200: OpenApiTypes.OBJECT})
     def list(self, request):
         """
         Return the UserConfig for the currently authenticated User.
@@ -125,6 +129,7 @@ class UserConfigViewSet(ViewSet):
 
         return Response(userconfig.data)
 
+    @extend_schema(methods=["patch"], responses={201: OpenApiTypes.OBJECT})
     def patch(self, request):
         """
         Update the UserConfig for the currently authenticated User.

+ 0 - 142
netbox/utilities/custom_inspectors.py

@@ -1,142 +0,0 @@
-from drf_yasg import openapi
-from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, SwaggerAutoSchema
-from drf_yasg.utils import get_serializer_ref_name
-from rest_framework.fields import ChoiceField
-from rest_framework.relations import ManyRelatedField
-
-from extras.api.customfields import CustomFieldsDataField
-from netbox.api.fields import ChoiceField, SerializedPKRelatedField
-from netbox.api.serializers import WritableNestedSerializer
-
-
-class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
-    writable_serializers = {}
-
-    def get_operation_id(self, operation_keys=None):
-        operation_keys = operation_keys or self.operation_keys
-        operation_id = self.overrides.get('operation_id', '')
-        if not operation_id:
-            # Overwrite the action for bulk update/bulk delete views to ensure they get an operation ID that's
-            # unique from their single-object counterparts (see #3436)
-            if operation_keys[-1] in ('delete', 'partial_update', 'update') and not getattr(self.view, 'detail', None):
-                operation_keys[-1] = f'bulk_{operation_keys[-1]}'
-            operation_id = '_'.join(operation_keys)
-
-        return operation_id
-
-    def get_request_serializer(self):
-        serializer = super().get_request_serializer()
-
-        if serializer is not None and not isinstance(serializer, openapi.Schema) and self.method in self.implicit_body_methods:
-            if writable_class := self.get_writable_class(serializer):
-                if hasattr(serializer, 'child'):
-                    child_serializer = self.get_writable_class(serializer.child)
-                    serializer = writable_class(context=serializer.context, child=child_serializer)
-                else:
-                    serializer = writable_class(context=serializer.context)
-        return serializer
-
-    def get_writable_class(self, serializer):
-        properties = {}
-        fields = {} if hasattr(serializer, 'child') else serializer.fields
-        for child_name, child in fields.items():
-            if isinstance(child, (ChoiceField, WritableNestedSerializer)):
-                properties[child_name] = None
-            elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
-                properties[child_name] = None
-
-        if properties:
-            if type(serializer) not in self.writable_serializers:
-                writable_name = 'Writable' + type(serializer).__name__
-                meta_class = getattr(type(serializer), 'Meta', None)
-                if meta_class:
-                    ref_name = 'Writable' + get_serializer_ref_name(serializer)
-                    writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name})
-                    properties['Meta'] = writable_meta
-
-                self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)
-
-            writable_class = self.writable_serializers[type(serializer)]
-            return writable_class
-
-
-class SerializedPKRelatedFieldInspector(FieldInspector):
-    def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
-        SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
-        if isinstance(field, SerializedPKRelatedField):
-            return self.probe_field_inspectors(field.serializer(), ChildSwaggerType, use_references)
-
-        return NotHandled
-
-
-class ChoiceFieldInspector(FieldInspector):
-    def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
-        # this returns a callable which extracts title, description and other stuff
-        # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
-        SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
-
-        if isinstance(field, ChoiceField):
-            choices = field._choices
-            choice_value = list(choices.keys())
-            choice_label = list(choices.values())
-            value_schema = openapi.Schema(type=openapi.TYPE_STRING, enum=choice_value)
-
-            if set([None] + choice_value) == {None, True, False}:
-                # DeviceType.subdevice_role and Device.face need to be differentiated since they each have
-                # subtly different values in their choice keys.
-                # - subdevice_role and connection_status are booleans, although subdevice_role includes None
-                # - face is an integer set {0, 1} which is easily confused with {False, True}
-                schema_type = openapi.TYPE_STRING
-                if all(type(x) == bool for x in [c for c in choice_value if c is not None]):
-                    schema_type = openapi.TYPE_BOOLEAN
-                value_schema = openapi.Schema(type=schema_type, enum=choice_value)
-                value_schema['x-nullable'] = True
-
-            if all(type(x) == int for x in [c for c in choice_value if c is not None]):
-                # Change value_schema for IPAddressFamilyChoices, RackWidthChoices
-                value_schema = openapi.Schema(type=openapi.TYPE_INTEGER, enum=choice_value)
-
-            schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={
-                "label": openapi.Schema(type=openapi.TYPE_STRING, enum=choice_label),
-                "value": value_schema
-            })
-
-            return schema
-
-        return NotHandled
-
-
-class NullableBooleanFieldInspector(FieldInspector):
-    def process_result(self, result, method_name, obj, **kwargs):
-
-        if isinstance(result, openapi.Schema) and isinstance(obj, ChoiceField) and result.type == 'boolean':
-            keys = obj.choices.keys()
-            if set(keys) == {None, True, False}:
-                result['x-nullable'] = True
-                result.type = 'boolean'
-
-        return result
-
-
-class CustomFieldsDataFieldInspector(FieldInspector):
-
-    def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
-        SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
-
-        if isinstance(field, CustomFieldsDataField) and swagger_object_type == openapi.Schema:
-            return SwaggerType(type=openapi.TYPE_OBJECT)
-
-        return NotHandled
-
-
-class NullablePaginatorInspector(PaginatorInspector):
-    def process_result(self, result, method_name, obj, **kwargs):
-        if method_name == 'get_paginated_response' and isinstance(result, openapi.Schema):
-            next = result.properties['next']
-            if isinstance(next, openapi.Schema):
-                next['x-nullable'] = True
-            previous = result.properties['previous']
-            if isinstance(previous, openapi.Schema):
-                previous['x-nullable'] = True
-
-        return result

+ 10 - 0
netbox/utilities/filters.py

@@ -3,6 +3,8 @@ from django import forms
 from django.conf import settings
 from django.core.exceptions import ValidationError
 from django_filters.constants import EMPTY_VALUES
+from drf_spectacular.utils import extend_schema_field
+from drf_spectacular.types import OpenApiTypes
 
 
 def multivalue_field_factory(field_class):
@@ -37,26 +39,32 @@ def multivalue_field_factory(field_class):
 # Filters
 #
 
+@extend_schema_field(OpenApiTypes.STR)
 class MultiValueCharFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.CharField)
 
 
+@extend_schema_field(OpenApiTypes.DATE)
 class MultiValueDateFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.DateField)
 
 
+@extend_schema_field(OpenApiTypes.DATETIME)
 class MultiValueDateTimeFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.DateTimeField)
 
 
+@extend_schema_field(OpenApiTypes.INT32)
 class MultiValueNumberFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.IntegerField)
 
 
+@extend_schema_field(OpenApiTypes.DECIMAL)
 class MultiValueDecimalFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.DecimalField)
 
 
+@extend_schema_field(OpenApiTypes.TIME)
 class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.TimeField)
 
@@ -65,6 +73,7 @@ class MACAddressFilter(django_filters.CharFilter):
     pass
 
 
+@extend_schema_field(OpenApiTypes.STR)
 class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.CharField)
 
@@ -75,6 +84,7 @@ class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter):
             return qs.none()
 
 
+@extend_schema_field(OpenApiTypes.STR)
 class MultiValueWWNFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.CharField)
 

+ 4 - 4
netbox/utilities/tests/test_api.py

@@ -249,9 +249,9 @@ class APIDocsTestCase(TestCase):
     def test_api_docs(self):
 
         url = reverse('api_docs')
-        params = {
-            "format": "openapi",
-        }
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        url = reverse('schema')
+        response = self.client.get(url)
         self.assertEqual(response.status_code, 200)

+ 10 - 0
netbox/virtualization/api/nested_serializers.py

@@ -1,3 +1,4 @@
+from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
 from netbox.api.serializers import WritableNestedSerializer
@@ -16,6 +17,9 @@ __all__ = [
 #
 
 
+@extend_schema_serializer(
+    exclude_fields=('cluster_count',),
+)
 class NestedClusterTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
     cluster_count = serializers.IntegerField(read_only=True)
@@ -25,6 +29,9 @@ class NestedClusterTypeSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug', 'cluster_count']
 
 
+@extend_schema_serializer(
+    exclude_fields=('cluster_count',),
+)
 class NestedClusterGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
     cluster_count = serializers.IntegerField(read_only=True)
@@ -34,6 +41,9 @@ class NestedClusterGroupSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug', 'cluster_count']
 
 
+@extend_schema_serializer(
+    exclude_fields=('virtualmachine_count',),
+)
 class NestedClusterSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
     virtualmachine_count = serializers.IntegerField(read_only=True)

+ 5 - 4
netbox/virtualization/api/serializers.py

@@ -1,4 +1,4 @@
-from drf_yasg.utils import swagger_serializer_method
+from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from dcim.api.nested_serializers import (
@@ -100,7 +100,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
             'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
         ]
 
-    @swagger_serializer_method(serializer_or_field=serializers.JSONField)
+    @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_config_context(self, obj):
         return obj.get_config_context()
 
@@ -114,7 +114,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
     virtual_machine = NestedVirtualMachineSerializer()
     parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
     bridge = NestedVMInterfaceSerializer(required=False, allow_null=True)
-    mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
+    mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
@@ -123,9 +123,10 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
         many=True
     )
     vrf = NestedVRFSerializer(required=False, allow_null=True)
-    l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
+    l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True)
     count_ipaddresses = serializers.IntegerField(read_only=True)
     count_fhrp_groups = serializers.IntegerField(read_only=True)
+    mac_address = serializers.CharField(required=False, default=None)
 
     class Meta:
         model = VMInterface

+ 4 - 0
netbox/wireless/api/nested_serializers.py

@@ -1,3 +1,4 @@
+from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
 from netbox.api.serializers import WritableNestedSerializer
@@ -10,6 +11,9 @@ __all__ = (
 )
 
 
+@extend_schema_serializer(
+    exclude_fields=('wirelesslan_count',),
+)
 class NestedWirelessLANGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail')
     wirelesslan_count = serializers.IntegerField(read_only=True)

+ 1 - 1
requirements.txt

@@ -15,7 +15,7 @@ django-tables2==2.5.3
 django-taggit==3.1.0
 django-timezone-field==5.0
 djangorestframework==3.14.0
-drf-yasg[validation]==1.21.5
+drf-spectacular==0.25.1
 feedparser==6.0.10
 graphene-django==3.0.0
 gunicorn==20.1.0