Explorar el Código

Merge branch 'feature' into 15277-object-types

Jeremy Stretch hace 1 año
padre
commit
6f6d483ca5
Se han modificado 100 ficheros con 4169 adiciones y 3685 borrados
  1. 2 144
      netbox/circuits/api/serializers.py
  2. 0 0
      netbox/circuits/api/serializers_/__init__.py
  3. 81 0
      netbox/circuits/api/serializers_/circuits.py
  4. 68 0
      netbox/circuits/api/serializers_/providers.py
  5. 3 2
      netbox/core/api/nested_serializers.py
  6. 2 73
      netbox/core/api/serializers.py
  7. 0 0
      netbox/core/api/serializers_/__init__.py
  8. 53 0
      netbox/core/api/serializers_/data.py
  9. 31 0
      netbox/core/api/serializers_/jobs.py
  10. 0 22
      netbox/dcim/api/nested_serializers.py
  11. 13 1305
      netbox/dcim/api/serializers.py
  12. 0 0
      netbox/dcim/api/serializers_/__init__.py
  13. 37 0
      netbox/dcim/api/serializers_/base.py
  14. 126 0
      netbox/dcim/api/serializers_/cables.py
  15. 368 0
      netbox/dcim/api/serializers_/device_components.py
  16. 157 0
      netbox/dcim/api/serializers_/devices.py
  17. 327 0
      netbox/dcim/api/serializers_/devicetype_components.py
  18. 74 0
      netbox/dcim/api/serializers_/devicetypes.py
  19. 26 0
      netbox/dcim/api/serializers_/manufacturers.py
  20. 29 0
      netbox/dcim/api/serializers_/platforms.py
  21. 80 0
      netbox/dcim/api/serializers_/power.py
  22. 117 0
      netbox/dcim/api/serializers_/racks.py
  23. 31 0
      netbox/dcim/api/serializers_/rackunits.py
  24. 43 0
      netbox/dcim/api/serializers_/roles.py
  25. 98 0
      netbox/dcim/api/serializers_/sites.py
  26. 25 0
      netbox/dcim/api/serializers_/virtualchassis.py
  27. 4 7
      netbox/dcim/api/views.py
  28. 7 11
      netbox/extras/api/customfields.py
  29. 2 2
      netbox/extras/api/mixins.py
  30. 15 658
      netbox/extras/api/serializers.py
  31. 0 0
      netbox/extras/api/serializers_/__init__.py
  32. 50 0
      netbox/extras/api/serializers_/attachments.py
  33. 35 0
      netbox/extras/api/serializers_/bookmarks.py
  34. 55 0
      netbox/extras/api/serializers_/change_logging.py
  35. 131 0
      netbox/extras/api/serializers_/configcontexts.py
  36. 30 0
      netbox/extras/api/serializers_/configtemplates.py
  37. 16 0
      netbox/extras/api/serializers_/contenttypes.py
  38. 91 0
      netbox/extras/api/serializers_/customfields.py
  39. 26 0
      netbox/extras/api/serializers_/customlinks.py
  40. 13 0
      netbox/extras/api/serializers_/dashboard.py
  41. 71 0
      netbox/extras/api/serializers_/events.py
  42. 36 0
      netbox/extras/api/serializers_/exporttemplates.py
  43. 63 0
      netbox/extras/api/serializers_/journaling.py
  44. 26 0
      netbox/extras/api/serializers_/savedfilters.py
  45. 77 0
      netbox/extras/api/serializers_/scripts.py
  46. 30 0
      netbox/extras/api/serializers_/tags.py
  47. 7 509
      netbox/ipam/api/serializers.py
  48. 0 0
      netbox/ipam/api/serializers_/__init__.py
  49. 78 0
      netbox/ipam/api/serializers_/asns.py
  50. 52 0
      netbox/ipam/api/serializers_/fhrpgroups.py
  51. 198 0
      netbox/ipam/api/serializers_/ip.py
  52. 25 0
      netbox/ipam/api/serializers_/roles.py
  53. 49 0
      netbox/ipam/api/serializers_/services.py
  54. 112 0
      netbox/ipam/api/serializers_/vlans.py
  55. 54 0
      netbox/ipam/api/serializers_/vrfs.py
  56. 4 2
      netbox/netbox/api/fields.py
  57. 50 7
      netbox/netbox/api/serializers/base.py
  58. 2 4
      netbox/netbox/api/serializers/generic.py
  59. 3 41
      netbox/netbox/api/serializers/nested.py
  60. 1 1
      netbox/netbox/api/viewsets/__init__.py
  61. 1 0
      netbox/netbox/constants.py
  62. 0 0
      netbox/project-static/dist/netbox.css
  63. 0 0
      netbox/project-static/dist/netbox.js
  64. 0 0
      netbox/project-static/dist/netbox.js.map
  65. BIN
      netbox/project-static/img/tint_20.png
  66. 6 5
      netbox/project-static/src/objectSelector.ts
  67. 1 1
      netbox/project-static/src/select/dynamic.ts
  68. 2 2
      netbox/project-static/src/select/static.ts
  69. 0 23
      netbox/project-static/src/util.ts
  70. 7 0
      netbox/project-static/styles/custom/_markdown.scss
  71. 1 1
      netbox/project-static/styles/custom/_misc.scss
  72. 4 0
      netbox/project-static/styles/overrides/_tabler.scss
  73. 0 1
      netbox/project-static/styles/transitional/_tables.scss
  74. 0 5
      netbox/templates/base/base.html
  75. 1 1
      netbox/templates/dcim/rack.html
  76. 5 7
      netbox/templates/dcim/rack_elevation_list.html
  77. 1 1
      netbox/templates/inc/toast.html
  78. 2 122
      netbox/tenancy/api/serializers.py
  79. 0 0
      netbox/tenancy/api/serializers_/__init__.py
  80. 81 0
      netbox/tenancy/api/serializers_/contacts.py
  81. 51 0
      netbox/tenancy/api/serializers_/tenants.py
  82. 3 187
      netbox/users/api/serializers.py
  83. 0 0
      netbox/users/api/serializers_/__init__.py
  84. 44 0
      netbox/users/api/serializers_/permissions.py
  85. 94 0
      netbox/users/api/serializers_/tokens.py
  86. 72 0
      netbox/users/api/serializers_/users.py
  87. 52 5
      netbox/utilities/api.py
  88. 1 1
      netbox/utilities/templates/form_helpers/render_field.html
  89. 1 1
      netbox/utilities/templates/navigation/menu.html
  90. 3 1
      netbox/utilities/templates/widgets/number_with_options.html
  91. 2 188
      netbox/virtualization/api/serializers.py
  92. 0 0
      netbox/virtualization/api/serializers_/__init__.py
  93. 65 0
      netbox/virtualization/api/serializers_/clusters.py
  94. 142 0
      netbox/virtualization/api/serializers_/virtualmachines.py
  95. 3 279
      netbox/vpn/api/serializers.py
  96. 0 0
      netbox/vpn/api/serializers_/__init__.py
  97. 136 0
      netbox/vpn/api/serializers_/crypto.py
  98. 70 0
      netbox/vpn/api/serializers_/l2vpn.py
  99. 112 0
      netbox/vpn/api/serializers_/tunnels.py
  100. 2 66
      netbox/wireless/api/serializers.py

+ 2 - 144
netbox/circuits/api/serializers.py

@@ -1,145 +1,3 @@
-from rest_framework import serializers
-
-from circuits.choices import CircuitStatusChoices
-from circuits.models import *
-from dcim.api.nested_serializers import NestedSiteSerializer
-from dcim.api.serializers import CabledObjectSerializer
-from ipam.api.nested_serializers import NestedASNSerializer
-from ipam.models import ASN
-from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
-from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
-from tenancy.api.nested_serializers import NestedTenantSerializer
+from .serializers_.providers import *
+from .serializers_.circuits import *
 from .nested_serializers import *
-
-
-#
-# Providers
-#
-
-class ProviderSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
-    accounts = SerializedPKRelatedField(
-        queryset=ProviderAccount.objects.all(),
-        serializer=NestedProviderAccountSerializer,
-        required=False,
-        many=True
-    )
-    asns = SerializedPKRelatedField(
-        queryset=ASN.objects.all(),
-        serializer=NestedASNSerializer,
-        required=False,
-        many=True
-    )
-
-    # Related object counts
-    circuit_count = RelatedObjectCountField('circuits')
-
-    class Meta:
-        model = Provider
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
-            'custom_fields', 'created', 'last_updated', 'circuit_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
-
-
-#
-# Provider Accounts
-#
-
-class ProviderAccountSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
-    provider = NestedProviderSerializer()
-
-    class Meta:
-        model = ProviderAccount
-        fields = [
-            'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
-
-
-#
-# Provider networks
-#
-
-class ProviderNetworkSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
-    provider = NestedProviderSerializer()
-
-    class Meta:
-        model = ProviderNetwork
-        fields = [
-            'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-#
-# Circuits
-#
-
-class CircuitTypeSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
-
-    # Related object counts
-    circuit_count = RelatedObjectCountField('circuits')
-
-    class Meta:
-        model = CircuitType
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
-            'last_updated', 'circuit_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
-
-
-class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
-    site = NestedSiteSerializer(allow_null=True)
-    provider_network = NestedProviderNetworkSerializer(allow_null=True)
-
-    class Meta:
-        model = CircuitTermination
-        fields = [
-            'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
-            'description',
-        ]
-
-
-class CircuitSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
-    provider = NestedProviderSerializer()
-    provider_account = NestedProviderAccountSerializer(required=False, allow_null=True)
-    status = ChoiceField(choices=CircuitStatusChoices, required=False)
-    type = NestedCircuitTypeSerializer()
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
-    termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
-
-    class Meta:
-        model = Circuit
-        fields = [
-            'id', 'url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date',
-            'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'cid', 'description')
-
-
-class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
-    circuit = NestedCircuitSerializer()
-    site = NestedSiteSerializer(required=False, allow_null=True)
-    provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
-
-    class Meta:
-        model = CircuitTermination
-        fields = [
-            'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
-            'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
-            'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
-        ]
-        brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')

+ 0 - 0
netbox/circuits/api/serializers_/__init__.py


+ 81 - 0
netbox/circuits/api/serializers_/circuits.py

@@ -0,0 +1,81 @@
+from rest_framework import serializers
+
+from circuits.choices import CircuitStatusChoices
+from circuits.models import Circuit, CircuitTermination, CircuitType
+from dcim.api.serializers_.cables import CabledObjectSerializer
+from dcim.api.serializers_.sites import SiteSerializer
+from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+
+from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
+
+__all__ = (
+    'CircuitSerializer',
+    'CircuitTerminationSerializer',
+    'CircuitTypeSerializer',
+)
+
+
+class CircuitTypeSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
+
+    # Related object counts
+    circuit_count = RelatedObjectCountField('circuits')
+
+    class Meta:
+        model = CircuitType
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'circuit_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
+
+
+class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
+    site = SiteSerializer(nested=True, allow_null=True)
+    provider_network = ProviderNetworkSerializer(nested=True, allow_null=True)
+
+    class Meta:
+        model = CircuitTermination
+        fields = [
+            'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
+            'description',
+        ]
+
+
+class CircuitSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
+    provider = ProviderSerializer(nested=True)
+    provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True)
+    status = ChoiceField(choices=CircuitStatusChoices, required=False)
+    type = CircuitTypeSerializer(nested=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
+    termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
+
+    class Meta:
+        model = Circuit
+        fields = [
+            'id', 'url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date',
+            'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'cid', 'description')
+
+
+class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
+    circuit = CircuitSerializer(nested=True)
+    site = SiteSerializer(nested=True, required=False, allow_null=True)
+    provider_network = ProviderNetworkSerializer(nested=True, required=False, allow_null=True)
+
+    class Meta:
+        model = CircuitTermination
+        fields = [
+            'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
+            'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
+            'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')

+ 68 - 0
netbox/circuits/api/serializers_/providers.py

@@ -0,0 +1,68 @@
+from rest_framework import serializers
+
+from circuits.models import Provider, ProviderAccount, ProviderNetwork
+from ipam.api.serializers_.asns import ASNSerializer
+from ipam.models import ASN
+from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
+from netbox.api.serializers import NetBoxModelSerializer
+from ..nested_serializers import *
+
+__all__ = (
+    'ProviderAccountSerializer',
+    'ProviderNetworkSerializer',
+    'ProviderSerializer',
+)
+
+
+class ProviderSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
+    accounts = SerializedPKRelatedField(
+        queryset=ProviderAccount.objects.all(),
+        serializer=NestedProviderAccountSerializer,
+        required=False,
+        many=True
+    )
+    asns = SerializedPKRelatedField(
+        queryset=ASN.objects.all(),
+        serializer=ASNSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+
+    # Related object counts
+    circuit_count = RelatedObjectCountField('circuits')
+
+    class Meta:
+        model = Provider
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
+            'custom_fields', 'created', 'last_updated', 'circuit_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
+
+
+class ProviderAccountSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
+    provider = ProviderSerializer(nested=True)
+
+    class Meta:
+        model = ProviderAccount
+        fields = [
+            'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
+
+
+class ProviderNetworkSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
+    provider = ProviderSerializer(nested=True)
+
+    class Meta:
+        model = ProviderNetwork
+        fields = [
+            'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 3 - 2
netbox/core/api/nested_serializers.py

@@ -4,7 +4,7 @@ from core.choices import JobStatusChoices
 from core.models import *
 from netbox.api.fields import ChoiceField
 from netbox.api.serializers import WritableNestedSerializer
-from users.api.nested_serializers import NestedUserSerializer
+from users.api.serializers import UserSerializer
 
 __all__ = (
     'NestedDataFileSerializer',
@@ -32,7 +32,8 @@ class NestedDataFileSerializer(WritableNestedSerializer):
 class NestedJobSerializer(serializers.ModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
     status = ChoiceField(choices=JobStatusChoices)
-    user = NestedUserSerializer(
+    user = UserSerializer(
+        nested=True,
         read_only=True
     )
 

+ 2 - 73
netbox/core/api/serializers.py

@@ -1,74 +1,3 @@
-from rest_framework import serializers
-
-from core.choices import *
-from core.models import *
-from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
-from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
-from netbox.utils import get_data_backend_choices
-from users.api.nested_serializers import NestedUserSerializer
+from .serializers_.data import *
+from .serializers_.jobs import *
 from .nested_serializers import *
-
-__all__ = (
-    'DataFileSerializer',
-    'DataSourceSerializer',
-    'JobSerializer',
-)
-
-
-class DataSourceSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='core-api:datasource-detail'
-    )
-    type = ChoiceField(
-        choices=get_data_backend_choices()
-    )
-    status = ChoiceField(
-        choices=DataSourceStatusChoices,
-        read_only=True
-    )
-
-    # Related object counts
-    file_count = RelatedObjectCountField('datafiles')
-
-    class Meta:
-        model = DataSource
-        fields = [
-            'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
-            'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class DataFileSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='core-api:datafile-detail'
-    )
-    source = NestedDataSourceSerializer(
-        read_only=True
-    )
-
-    class Meta:
-        model = DataFile
-        fields = [
-            'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
-        ]
-        brief_fields = ('id', 'url', 'display', 'path')
-
-
-class JobSerializer(BaseModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
-    user = NestedUserSerializer(
-        read_only=True
-    )
-    status = ChoiceField(choices=JobStatusChoices, read_only=True)
-    object_type = ContentTypeField(
-        read_only=True
-    )
-
-    class Meta:
-        model = Job
-        fields = [
-            'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
-            'started', 'completed', 'user', 'data', 'error', 'job_id',
-        ]
-        brief_fields = ('url', 'created', 'completed', 'user', 'status')

+ 0 - 0
netbox/core/api/serializers_/__init__.py


+ 53 - 0
netbox/core/api/serializers_/data.py

@@ -0,0 +1,53 @@
+from rest_framework import serializers
+
+from core.choices import *
+from core.models import DataFile, DataSource
+from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+from netbox.utils import get_data_backend_choices
+
+__all__ = (
+    'DataFileSerializer',
+    'DataSourceSerializer',
+)
+
+
+class DataSourceSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='core-api:datasource-detail'
+    )
+    type = ChoiceField(
+        choices=get_data_backend_choices()
+    )
+    status = ChoiceField(
+        choices=DataSourceStatusChoices,
+        read_only=True
+    )
+
+    # Related object counts
+    file_count = RelatedObjectCountField('datafiles')
+
+    class Meta:
+        model = DataSource
+        fields = [
+            'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
+            'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class DataFileSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='core-api:datafile-detail'
+    )
+    source = DataSourceSerializer(
+        nested=True,
+        read_only=True
+    )
+
+    class Meta:
+        model = DataFile
+        fields = [
+            'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
+        ]
+        brief_fields = ('id', 'url', 'display', 'path')

+ 31 - 0
netbox/core/api/serializers_/jobs.py

@@ -0,0 +1,31 @@
+from rest_framework import serializers
+
+from core.choices import *
+from core.models import Job
+from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.serializers import BaseModelSerializer
+from users.api.serializers_.users import UserSerializer
+
+__all__ = (
+    'JobSerializer',
+)
+
+
+class JobSerializer(BaseModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
+    user = UserSerializer(
+        nested=True,
+        read_only=True
+    )
+    status = ChoiceField(choices=JobStatusChoices, read_only=True)
+    object_type = ContentTypeField(
+        read_only=True
+    )
+
+    class Meta:
+        model = Job
+        fields = [
+            'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
+            'started', 'completed', 'user', 'data', 'error', 'job_id',
+        ]
+        brief_fields = ('url', 'created', 'completed', 'user', 'status')

+ 0 - 22
netbox/dcim/api/nested_serializers.py

@@ -6,8 +6,6 @@ from netbox.api.fields import RelatedObjectCountField
 from netbox.api.serializers import WritableNestedSerializer
 
 __all__ = [
-    'ComponentNestedModuleSerializer',
-    'ModuleBayNestedModuleSerializer',
     'NestedCableSerializer',
     'NestedConsolePortSerializer',
     'NestedConsolePortTemplateSerializer',
@@ -311,26 +309,6 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name']
 
 
-class ModuleBayNestedModuleSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
-
-    class Meta:
-        model = models.Module
-        fields = ['id', 'url', 'display', 'serial']
-
-
-class ComponentNestedModuleSerializer(WritableNestedSerializer):
-    """
-    Used by device component serializers.
-    """
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
-    module_bay = ModuleNestedModuleBaySerializer(read_only=True)
-
-    class Meta:
-        model = models.Module
-        fields = ['id', 'url', 'display', 'device', 'module_bay']
-
-
 class NestedModuleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
     device = NestedDeviceSerializer(read_only=True)

+ 13 - 1305
netbox/dcim/api/serializers.py

@@ -1,1306 +1,14 @@
-import decimal
-
-from django.contrib.contenttypes.models import ContentType
-from django.utils.translation import gettext as _
-from drf_spectacular.types import OpenApiTypes
-from drf_spectacular.utils import extend_schema_field
-from rest_framework import serializers
-from timezone_field.rest_framework import TimeZoneSerializerField
-
-from dcim.choices import *
-from dcim.constants import *
-from dcim.models import *
-from extras.api.nested_serializers import NestedConfigTemplateSerializer
-from ipam.api.nested_serializers import (
-    NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
-)
-from ipam.models import ASN, VLAN
-from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
-from netbox.api.serializers import (
-    GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer,
-    WritableNestedSerializer,
-)
-from netbox.config import ConfigItem
-from netbox.constants import NESTED_SERIALIZER_PREFIX
-from tenancy.api.nested_serializers import NestedTenantSerializer
-from users.api.nested_serializers import NestedUserSerializer
-from utilities.api import get_serializer_for_model
-from virtualization.api.nested_serializers import NestedClusterSerializer
-from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
-from wireless.api.nested_serializers import NestedWirelessLANSerializer, NestedWirelessLinkSerializer
-from wireless.choices import *
-from wireless.models import WirelessLAN
+from .serializers_.cables import *
+from .serializers_.sites import *
+from .serializers_.racks import *
+from .serializers_.manufacturers import *
+from .serializers_.platforms import *
+from .serializers_.roles import *
+from .serializers_.devicetypes import *
+from .serializers_.devicetype_components import *
+from .serializers_.virtualchassis import *
+from .serializers_.devices import *
+from .serializers_.device_components import *
+from .serializers_.power import *
+from .serializers_.rackunits import *
 from .nested_serializers import *
-
-
-class CabledObjectSerializer(serializers.ModelSerializer):
-    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.
-        """
-        if not obj.cable:
-            return None
-
-        if obj.link_peers:
-            return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}'
-
-        return None
-
-    @extend_schema_field(serializers.ListField)
-    def get_link_peers(self, obj):
-        """
-        Return the appropriate serializer for the link termination model.
-        """
-        if not obj.link_peers:
-            return []
-
-        # Return serialized peer termination objects
-        serializer = get_serializer_for_model(obj.link_peers[0], prefix=NESTED_SERIALIZER_PREFIX)
-        context = {'request': self.context['request']}
-        return serializer(obj.link_peers, context=context, many=True).data
-
-    @extend_schema_field(serializers.BooleanField)
-    def get__occupied(self, obj):
-        return obj._occupied
-
-
-class ConnectedEndpointsSerializer(serializers.ModelSerializer):
-    """
-    Legacy serializer for pre-v3.3 connections
-    """
-    connected_endpoints_type = serializers.SerializerMethodField(read_only=True)
-    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}'
-
-    @extend_schema_field(serializers.ListField)
-    def get_connected_endpoints(self, obj):
-        """
-        Return the appropriate serializer for the type of connected object.
-        """
-        if endpoints := obj.connected_endpoints:
-            serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX)
-            context = {'request': self.context['request']}
-            return serializer(endpoints, many=True, context=context).data
-
-    @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
-
-
-#
-# Regions/sites
-#
-
-class RegionSerializer(NestedGroupModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
-    parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
-    site_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = Region
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
-            'last_updated', 'site_count', '_depth',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
-
-
-class SiteGroupSerializer(NestedGroupModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
-    parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
-    site_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = SiteGroup
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
-            'last_updated', 'site_count', '_depth',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
-
-
-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)
-    group = NestedSiteGroupSerializer(required=False, allow_null=True)
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    time_zone = TimeZoneSerializerField(required=False, allow_null=True)
-    asns = SerializedPKRelatedField(
-        queryset=ASN.objects.all(),
-        serializer=NestedASNSerializer,
-        required=False,
-        many=True
-    )
-
-    # Related object counts
-    circuit_count = RelatedObjectCountField('circuit_terminations')
-    device_count = RelatedObjectCountField('devices')
-    prefix_count = RelatedObjectCountField('prefixes')
-    rack_count = RelatedObjectCountField('racks')
-    vlan_count = RelatedObjectCountField('vlans')
-    virtualmachine_count = RelatedObjectCountField('virtual_machines')
-
-    class Meta:
-        model = Site
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone',
-            'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags',
-            'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
-            'virtualmachine_count', 'vlan_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
-
-
-#
-# Racks
-#
-
-class LocationSerializer(NestedGroupModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
-    site = NestedSiteSerializer()
-    parent = NestedLocationSerializer(required=False, allow_null=True)
-    status = ChoiceField(choices=LocationStatusChoices, required=False)
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    rack_count = serializers.IntegerField(read_only=True)
-    device_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = Location
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
-            'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
-
-
-class RackRoleSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
-
-    # Related object counts
-    rack_count = RelatedObjectCountField('racks')
-
-    class Meta:
-        model = RackRole
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
-            'last_updated', 'rack_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
-
-
-class RackSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
-    site = NestedSiteSerializer()
-    location = NestedLocationSerializer(required=False, allow_null=True, default=None)
-    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, 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, allow_null=True)
-    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
-
-    # Related object counts
-    device_count = RelatedObjectCountField('devices')
-    powerfeed_count = RelatedObjectCountField('powerfeeds')
-
-    class Meta:
-        model = Rack
-        fields = [
-            'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
-            'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight', 'weight_unit',
-            'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
-            'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
-
-
-class RackUnitSerializer(serializers.Serializer):
-    """
-    A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
-    """
-    id = serializers.DecimalField(
-        max_digits=4,
-        decimal_places=1,
-        read_only=True
-    )
-    name = serializers.CharField(read_only=True)
-    face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
-    device = NestedDeviceSerializer(read_only=True)
-    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']
-
-
-class RackReservationSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
-    rack = NestedRackSerializer()
-    user = NestedUserSerializer()
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-
-    class Meta:
-        model = RackReservation
-        fields = [
-            'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
-            'comments', 'tags', 'custom_fields',
-        ]
-        brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
-
-
-class RackElevationDetailFilterSerializer(serializers.Serializer):
-    q = serializers.CharField(
-        required=False,
-        default=None
-    )
-    face = serializers.ChoiceField(
-        choices=DeviceFaceChoices,
-        default=DeviceFaceChoices.FACE_FRONT
-    )
-    render = serializers.ChoiceField(
-        choices=RackElevationDetailRenderChoices,
-        default=RackElevationDetailRenderChoices.RENDER_JSON
-    )
-    unit_width = serializers.IntegerField(
-        default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH')
-    )
-    unit_height = serializers.IntegerField(
-        default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
-    )
-    legend_width = serializers.IntegerField(
-        default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
-    )
-    margin_width = serializers.IntegerField(
-        default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
-    )
-    exclude = serializers.IntegerField(
-        required=False,
-        default=None
-    )
-    expand_devices = serializers.BooleanField(
-        required=False,
-        default=True
-    )
-    include_images = serializers.BooleanField(
-        required=False,
-        default=True
-    )
-
-
-#
-# Device/module types
-#
-
-class ManufacturerSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
-
-    # Related object counts
-    devicetype_count = RelatedObjectCountField('device_types')
-    inventoryitem_count = RelatedObjectCountField('inventory_items')
-    platform_count = RelatedObjectCountField('platforms')
-
-    class Meta:
-        model = Manufacturer
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
-            'devicetype_count', 'inventoryitem_count', 'platform_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
-
-
-class DeviceTypeSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
-    manufacturer = NestedManufacturerSerializer()
-    default_platform = NestedPlatformSerializer(required=False, allow_null=True)
-    u_height = serializers.DecimalField(
-        max_digits=4,
-        decimal_places=1,
-        label=_('Position (U)'),
-        min_value=0,
-        default=1.0
-    )
-    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)
-    front_image = serializers.URLField(allow_null=True, required=False)
-    rear_image = serializers.URLField(allow_null=True, required=False)
-
-    # Counter fields
-    console_port_template_count = serializers.IntegerField(read_only=True)
-    console_server_port_template_count = serializers.IntegerField(read_only=True)
-    power_port_template_count = serializers.IntegerField(read_only=True)
-    power_outlet_template_count = serializers.IntegerField(read_only=True)
-    interface_template_count = serializers.IntegerField(read_only=True)
-    front_port_template_count = serializers.IntegerField(read_only=True)
-    rear_port_template_count = serializers.IntegerField(read_only=True)
-    device_bay_template_count = serializers.IntegerField(read_only=True)
-    module_bay_template_count = serializers.IntegerField(read_only=True)
-    inventory_item_template_count = serializers.IntegerField(read_only=True)
-
-    # Related object counts
-    device_count = RelatedObjectCountField('instances')
-
-    class Meta:
-        model = DeviceType
-        fields = [
-            'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
-            'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
-            'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-            'device_count', 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
-            'power_outlet_template_count', 'interface_template_count', 'front_port_template_count',
-            'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
-            'inventory_item_template_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
-
-
-class ModuleTypeSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
-    manufacturer = NestedManufacturerSerializer()
-    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
-
-    class Meta:
-        model = ModuleType
-        fields = [
-            'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
-            'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
-
-
-#
-# Component templates
-#
-
-class ConsolePortTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
-    device_type = NestedDeviceTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    module_type = NestedModuleTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    type = ChoiceField(
-        choices=ConsolePortTypeChoices,
-        allow_blank=True,
-        required=False
-    )
-
-    class Meta:
-        model = ConsolePortTemplate
-        fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
-            'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
-    device_type = NestedDeviceTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    module_type = NestedModuleTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    type = ChoiceField(
-        choices=ConsolePortTypeChoices,
-        allow_blank=True,
-        required=False
-    )
-
-    class Meta:
-        model = ConsoleServerPortTemplate
-        fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
-            'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class PowerPortTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
-    device_type = NestedDeviceTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    module_type = NestedModuleTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    type = ChoiceField(
-        choices=PowerPortTypeChoices,
-        allow_blank=True,
-        required=False,
-        allow_null=True
-    )
-
-    class Meta:
-        model = PowerPortTemplate
-        fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
-            'allocated_draw', 'description', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class PowerOutletTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
-    device_type = NestedDeviceTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    module_type = NestedModuleTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    type = ChoiceField(
-        choices=PowerOutletTypeChoices,
-        allow_blank=True,
-        required=False,
-        allow_null=True
-    )
-    power_port = NestedPowerPortTemplateSerializer(
-        required=False,
-        allow_null=True
-    )
-    feed_leg = ChoiceField(
-        choices=PowerOutletFeedLegChoices,
-        allow_blank=True,
-        required=False,
-        allow_null=True
-    )
-
-    class Meta:
-        model = PowerOutletTemplate
-        fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
-            'description', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class InterfaceTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
-    device_type = NestedDeviceTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    module_type = NestedModuleTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    type = ChoiceField(choices=InterfaceTypeChoices)
-    bridge = NestedInterfaceTemplateSerializer(
-        required=False,
-        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
-    )
-    rf_role = ChoiceField(
-        choices=WirelessRoleChoices,
-        required=False,
-        allow_blank=True,
-        allow_null=True
-    )
-
-    class Meta:
-        model = InterfaceTemplate
-        fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
-            'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class RearPortTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
-    device_type = NestedDeviceTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    module_type = NestedModuleTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    type = ChoiceField(choices=PortTypeChoices)
-
-    class Meta:
-        model = RearPortTemplate
-        fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
-            'description', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class FrontPortTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
-    device_type = NestedDeviceTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    module_type = NestedModuleTypeSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    type = ChoiceField(choices=PortTypeChoices)
-    rear_port = NestedRearPortTemplateSerializer()
-
-    class Meta:
-        model = FrontPortTemplate
-        fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
-            'rear_port_position', 'description', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class ModuleBayTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail')
-    device_type = NestedDeviceTypeSerializer()
-
-    class Meta:
-        model = ModuleBayTemplate
-        fields = [
-            'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
-            'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class DeviceBayTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
-    device_type = NestedDeviceTypeSerializer()
-
-    class Meta:
-        model = DeviceBayTemplate
-        fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class InventoryItemTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail')
-    device_type = NestedDeviceTypeSerializer()
-    parent = serializers.PrimaryKeyRelatedField(
-        queryset=InventoryItemTemplate.objects.all(),
-        allow_null=True,
-        default=None
-    )
-    role = NestedInventoryItemRoleSerializer(required=False, allow_null=True)
-    manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
-    component_type = ContentTypeField(
-        queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS),
-        required=False,
-        allow_null=True
-    )
-    component = serializers.SerializerMethodField(read_only=True)
-    _depth = serializers.IntegerField(source='level', read_only=True)
-
-    class Meta:
-        model = InventoryItemTemplate
-        fields = [
-            'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
-            'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth')
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_component(self, obj):
-        if obj.component is None:
-            return None
-        serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX)
-        context = {'request': self.context['request']}
-        return serializer(obj.component, context=context).data
-
-
-#
-# Devices
-#
-
-class DeviceRoleSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
-    config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
-
-    # Related object counts
-    device_count = RelatedObjectCountField('devices')
-    virtualmachine_count = RelatedObjectCountField('virtual_machines')
-
-    class Meta:
-        model = DeviceRole
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
-            'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
-
-
-class PlatformSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
-    manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
-    config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
-
-    # Related object counts
-    device_count = RelatedObjectCountField('devices')
-    virtualmachine_count = RelatedObjectCountField('virtual_machines')
-
-    class Meta:
-        model = Platform
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
-            'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
-
-
-class DeviceSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
-    device_type = NestedDeviceTypeSerializer()
-    role = NestedDeviceRoleSerializer()
-    tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
-    platform = NestedPlatformSerializer(required=False, allow_null=True)
-    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=lambda: '')
-    position = serializers.DecimalField(
-        max_digits=4,
-        decimal_places=1,
-        allow_null=True,
-        label=_('Position (U)'),
-        min_value=decimal.Decimal(0.5),
-        default=None
-    )
-    status = ChoiceField(choices=DeviceStatusChoices, required=False)
-    airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
-    primary_ip = NestedIPAddressSerializer(read_only=True)
-    primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
-    primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
-    oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
-    parent_device = serializers.SerializerMethodField()
-    cluster = NestedClusterSerializer(required=False, allow_null=True)
-    virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
-    vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
-    config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
-
-    # Counter fields
-    console_port_count = serializers.IntegerField(read_only=True)
-    console_server_port_count = serializers.IntegerField(read_only=True)
-    power_port_count = serializers.IntegerField(read_only=True)
-    power_outlet_count = serializers.IntegerField(read_only=True)
-    interface_count = serializers.IntegerField(read_only=True)
-    front_port_count = serializers.IntegerField(read_only=True)
-    rear_port_count = serializers.IntegerField(read_only=True)
-    device_bay_count = serializers.IntegerField(read_only=True)
-    module_bay_count = serializers.IntegerField(read_only=True)
-    inventory_item_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = Device
-        fields = [
-            'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
-            'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
-            'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
-            'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
-            'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
-            'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
-            'module_bay_count', 'inventory_item_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-    @extend_schema_field(NestedDeviceSerializer)
-    def get_parent_device(self, obj):
-        try:
-            device_bay = obj.parent_bay
-        except DeviceBay.DoesNotExist:
-            return None
-        context = {'request': self.context['request']}
-        data = NestedDeviceSerializer(instance=device_bay.device, context=context).data
-        data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
-        return data
-
-
-class DeviceWithConfigContextSerializer(DeviceSerializer):
-    config_context = serializers.SerializerMethodField(read_only=True)
-
-    class Meta(DeviceSerializer.Meta):
-        fields = [
-            'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
-            'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
-            'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
-            'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags',
-            'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
-            'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
-            'device_bay_count', 'module_bay_count', 'inventory_item_count',
-        ]
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_config_context(self, obj):
-        return obj.get_config_context()
-
-
-class VirtualDeviceContextSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
-    device = NestedDeviceSerializer()
-    tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
-    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)
-    status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
-
-    # Related object counts
-    interface_count = RelatedObjectCountField('interfaces')
-
-    class Meta:
-        model = VirtualDeviceContext
-        fields = [
-            'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
-            'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-            'interface_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
-
-
-class ModuleSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
-    device = NestedDeviceSerializer()
-    module_bay = NestedModuleBaySerializer()
-    module_type = NestedModuleTypeSerializer()
-    status = ChoiceField(choices=ModuleStatusChoices, required=False)
-
-    class Meta:
-        model = Module
-        fields = [
-            'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
-            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
-
-
-#
-# Device components
-#
-
-class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
-    device = NestedDeviceSerializer()
-    module = ComponentNestedModuleSerializer(
-        required=False,
-        allow_null=True
-    )
-    type = ChoiceField(
-        choices=ConsolePortTypeChoices,
-        allow_blank=True,
-        required=False
-    )
-    speed = ChoiceField(
-        choices=ConsolePortSpeedChoices,
-        allow_null=True,
-        required=False
-    )
-
-    class Meta:
-        model = ConsoleServerPort
-        fields = [
-            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
-            'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
-            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
-            'last_updated', '_occupied',
-        ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
-
-
-class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
-    device = NestedDeviceSerializer()
-    module = ComponentNestedModuleSerializer(
-        required=False,
-        allow_null=True
-    )
-    type = ChoiceField(
-        choices=ConsolePortTypeChoices,
-        allow_blank=True,
-        required=False
-    )
-    speed = ChoiceField(
-        choices=ConsolePortSpeedChoices,
-        allow_null=True,
-        required=False
-    )
-
-    class Meta:
-        model = ConsolePort
-        fields = [
-            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
-            'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
-            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
-            'last_updated', '_occupied',
-        ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
-
-
-class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
-    device = NestedDeviceSerializer()
-    module = ComponentNestedModuleSerializer(
-        required=False,
-        allow_null=True
-    )
-    type = ChoiceField(
-        choices=PowerOutletTypeChoices,
-        allow_blank=True,
-        required=False,
-        allow_null=True
-    )
-    power_port = NestedPowerPortSerializer(
-        required=False,
-        allow_null=True
-    )
-    feed_leg = ChoiceField(
-        choices=PowerOutletFeedLegChoices,
-        allow_blank=True,
-        required=False,
-        allow_null=True
-    )
-
-    class Meta:
-        model = PowerOutlet
-        fields = [
-            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg',
-            'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
-            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
-            'created', 'last_updated', '_occupied',
-        ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
-
-
-class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
-    device = NestedDeviceSerializer()
-    module = ComponentNestedModuleSerializer(
-        required=False,
-        allow_null=True
-    )
-    type = ChoiceField(
-        choices=PowerPortTypeChoices,
-        allow_blank=True,
-        required=False,
-        allow_null=True
-    )
-
-    class Meta:
-        model = PowerPort
-        fields = [
-            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
-            'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
-            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
-            'created', 'last_updated', '_occupied',
-        ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
-
-
-class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
-    device = NestedDeviceSerializer()
-    vdcs = SerializedPKRelatedField(
-        queryset=VirtualDeviceContext.objects.all(),
-        serializer=NestedVirtualDeviceContextSerializer,
-        required=False,
-        many=True
-    )
-    module = ComponentNestedModuleSerializer(
-        required=False,
-        allow_null=True
-    )
-    type = ChoiceField(choices=InterfaceTypeChoices)
-    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, allow_null=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)
-    untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
-    tagged_vlans = SerializedPKRelatedField(
-        queryset=VLAN.objects.all(),
-        serializer=NestedVLANSerializer,
-        required=False,
-        many=True
-    )
-    vrf = NestedVRFSerializer(required=False, allow_null=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,
-        required=False,
-        many=True
-    )
-    count_ipaddresses = serializers.IntegerField(read_only=True)
-    count_fhrp_groups = serializers.IntegerField(read_only=True)
-    mac_address = serializers.CharField(
-        required=False,
-        default=None,
-        allow_blank=True,
-        allow_null=True
-    )
-    wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
-
-    class Meta:
-        model = Interface
-        fields = [
-            'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge',
-            'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role',
-            'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
-            'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
-            'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
-            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
-            'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
-        ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
-
-    def validate(self, data):
-
-        # Validate many-to-many VLAN assignments
-        device = self.instance.device if self.instance else data.get('device')
-        for vlan in data.get('tagged_vlans', []):
-            if vlan.site not in [device.site, None]:
-                raise serializers.ValidationError({
-                    'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, or "
-                                    f"it must be global."
-                })
-
-        return super().validate(data)
-
-
-class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
-    device = NestedDeviceSerializer()
-    module = ComponentNestedModuleSerializer(
-        required=False,
-        allow_null=True
-    )
-    type = ChoiceField(choices=PortTypeChoices)
-
-    class Meta:
-        model = RearPort
-        fields = [
-            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description',
-            'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
-            'last_updated', '_occupied',
-        ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
-
-
-class FrontPortRearPortSerializer(WritableNestedSerializer):
-    """
-    NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
-    """
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
-
-    class Meta:
-        model = RearPort
-        fields = ['id', 'url', 'display', 'name', 'label', 'description']
-
-
-class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
-    device = NestedDeviceSerializer()
-    module = ComponentNestedModuleSerializer(
-        required=False,
-        allow_null=True
-    )
-    type = ChoiceField(choices=PortTypeChoices)
-    rear_port = FrontPortRearPortSerializer()
-
-    class Meta:
-        model = FrontPort
-        fields = [
-            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
-            'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
-            'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
-        ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
-
-
-class ModuleBaySerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
-    device = NestedDeviceSerializer()
-    installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
-
-    class Meta:
-        model = ModuleBay
-        fields = [
-            'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
-            'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
-
-
-class DeviceBaySerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
-    device = NestedDeviceSerializer()
-    installed_device = NestedDeviceSerializer(required=False, allow_null=True)
-
-    class Meta:
-        model = DeviceBay
-        fields = [
-            'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
-            'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
-
-
-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)
-    role = NestedInventoryItemRoleSerializer(required=False, allow_null=True)
-    manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
-    component_type = ContentTypeField(
-        queryset=ContentType.objects.filter(MODULAR_COMPONENT_MODELS),
-        required=False,
-        allow_null=True
-    )
-    component = serializers.SerializerMethodField(read_only=True)
-    _depth = serializers.IntegerField(source='level', read_only=True)
-
-    class Meta:
-        model = InventoryItem
-        fields = [
-            'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial',
-            'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
-            'custom_fields', 'created', 'last_updated', '_depth',
-        ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_component(self, obj):
-        if obj.component is None:
-            return None
-        serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX)
-        context = {'request': self.context['request']}
-        return serializer(obj.component, context=context).data
-
-
-#
-# Device component roles
-#
-
-class InventoryItemRoleSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
-
-    # Related object counts
-    inventoryitem_count = RelatedObjectCountField('inventory_items')
-
-    class Meta:
-        model = InventoryItemRole
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
-            'last_updated', 'inventoryitem_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')
-
-
-#
-# Cables
-#
-
-class CableSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
-    a_terminations = GenericObjectSerializer(many=True, required=False)
-    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, allow_null=True)
-
-    class Meta:
-        model = Cable
-        fields = [
-            'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
-            'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'label', 'description')
-
-
-class TracedCableSerializer(serializers.ModelSerializer):
-    """
-    Used only while tracing a cable path.
-    """
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
-
-    class Meta:
-        model = Cable
-        fields = [
-            'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description',
-        ]
-
-
-class CableTerminationSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail')
-    termination_type = ContentTypeField(
-        queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
-    )
-    termination = serializers.SerializerMethodField(read_only=True)
-
-    class Meta:
-        model = CableTermination
-        fields = [
-            'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination',
-            'created', 'last_updated',
-        ]
-
-    @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']}
-        return serializer(obj.termination, context=context).data
-
-
-class CablePathSerializer(serializers.ModelSerializer):
-    path = serializers.SerializerMethodField(read_only=True)
-
-    class Meta:
-        model = CablePath
-        fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
-
-    @extend_schema_field(serializers.ListField)
-    def get_path(self, obj):
-        ret = []
-        for nodes in obj.path_objects:
-            serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX)
-            context = {'request': self.context['request']}
-            ret.append(serializer(nodes, context=context, many=True).data)
-        return ret
-
-
-#
-# Virtual chassis
-#
-
-class VirtualChassisSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
-    master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
-
-    # Counter fields
-    member_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = VirtualChassis
-        fields = [
-            'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated', 'member_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')
-
-
-#
-# Power panels
-#
-
-class PowerPanelSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
-    site = NestedSiteSerializer()
-    location = NestedLocationSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-
-    # Related object counts
-    powerfeed_count = RelatedObjectCountField('powerfeeds')
-
-    class Meta:
-        model = PowerPanel
-        fields = [
-            'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
-            'powerfeed_count', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
-
-
-class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
-    power_panel = NestedPowerPanelSerializer()
-    rack = NestedRackSerializer(
-        required=False,
-        allow_null=True,
-        default=None
-    )
-    type = ChoiceField(
-        choices=PowerFeedTypeChoices,
-        default=lambda: PowerFeedTypeChoices.TYPE_PRIMARY,
-    )
-    status = ChoiceField(
-        choices=PowerFeedStatusChoices,
-        default=lambda: PowerFeedStatusChoices.STATUS_ACTIVE,
-    )
-    supply = ChoiceField(
-        choices=PowerFeedSupplyChoices,
-        default=lambda: PowerFeedSupplyChoices.SUPPLY_AC,
-    )
-    phase = ChoiceField(
-        choices=PowerFeedPhaseChoices,
-        default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE,
-    )
-    tenant = NestedTenantSerializer(
-        required=False,
-        allow_null=True
-    )
-
-    class Meta:
-        model = PowerFeed
-        fields = [
-            'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
-            'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
-            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
-            'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')

+ 0 - 0
netbox/dcim/api/serializers_/__init__.py


+ 37 - 0
netbox/dcim/api/serializers_/base.py

@@ -0,0 +1,37 @@
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from utilities.api import get_serializer_for_model
+
+__all__ = (
+    'ConnectedEndpointsSerializer',
+)
+
+
+class ConnectedEndpointsSerializer(serializers.ModelSerializer):
+    """
+    Legacy serializer for pre-v3.3 connections
+    """
+    connected_endpoints_type = serializers.SerializerMethodField(read_only=True)
+    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}'
+
+    @extend_schema_field(serializers.ListField)
+    def get_connected_endpoints(self, obj):
+        """
+        Return the appropriate serializer for the type of connected object.
+        """
+        if endpoints := obj.connected_endpoints:
+            serializer = get_serializer_for_model(endpoints[0])
+            context = {'request': self.context['request']}
+            return serializer(endpoints, nested=True, many=True, context=context).data
+
+    @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

+ 126 - 0
netbox/dcim/api/serializers_/cables.py

@@ -0,0 +1,126 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.models import Cable, CablePath, CableTermination
+from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.serializers import GenericObjectSerializer, NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+from utilities.api import get_serializer_for_model
+
+__all__ = (
+    'CablePathSerializer',
+    'CableSerializer',
+    'CableTerminationSerializer',
+    'CabledObjectSerializer',
+    'TracedCableSerializer',
+)
+
+
+class CableSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
+    a_terminations = GenericObjectSerializer(many=True, required=False)
+    b_terminations = GenericObjectSerializer(many=True, required=False)
+    status = ChoiceField(choices=LinkStatusChoices, required=False)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
+
+    class Meta:
+        model = Cable
+        fields = [
+            'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
+            'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'label', 'description')
+
+
+class TracedCableSerializer(serializers.ModelSerializer):
+    """
+    Used only while tracing a cable path.
+    """
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
+
+    class Meta:
+        model = Cable
+        fields = [
+            'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description',
+        ]
+
+
+class CableTerminationSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail')
+    termination_type = ContentTypeField(
+        queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
+    )
+    termination = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = CableTermination
+        fields = [
+            'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination',
+            'created', 'last_updated',
+        ]
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_termination(self, obj):
+        serializer = get_serializer_for_model(obj.termination)
+        context = {'request': self.context['request']}
+        return serializer(obj.termination, nested=True, context=context).data
+
+
+class CablePathSerializer(serializers.ModelSerializer):
+    path = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = CablePath
+        fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
+
+    @extend_schema_field(serializers.ListField)
+    def get_path(self, obj):
+        ret = []
+        for nodes in obj.path_objects:
+            serializer = get_serializer_for_model(nodes[0])
+            context = {'request': self.context['request']}
+            ret.append(serializer(nodes, nested=True, many=True, context=context).data)
+        return ret
+
+
+class CabledObjectSerializer(serializers.ModelSerializer):
+    cable = CableSerializer(nested=True, 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.
+        """
+        if not obj.cable:
+            return None
+
+        if obj.link_peers:
+            return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}'
+
+        return None
+
+    @extend_schema_field(serializers.ListField)
+    def get_link_peers(self, obj):
+        """
+        Return the appropriate serializer for the link termination model.
+        """
+        if not obj.link_peers:
+            return []
+
+        # Return serialized peer termination objects
+        serializer = get_serializer_for_model(obj.link_peers[0])
+        context = {'request': self.context['request']}
+        return serializer(obj.link_peers, nested=True, many=True, context=context).data
+
+    @extend_schema_field(serializers.BooleanField)
+    def get__occupied(self, obj):
+        return obj._occupied

+ 368 - 0
netbox/dcim/api/serializers_/device_components.py

@@ -0,0 +1,368 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.models import (
+    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
+    RearPort, VirtualDeviceContext,
+)
+from ipam.api.serializers_.vlans import VLANSerializer
+from ipam.api.serializers_.vrfs import VRFSerializer
+from ipam.models import VLAN
+from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
+from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
+from utilities.api import get_serializer_for_model
+from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
+from wireless.api.nested_serializers import NestedWirelessLinkSerializer
+from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
+from wireless.choices import *
+from wireless.models import WirelessLAN
+from .base import ConnectedEndpointsSerializer
+from .cables import CabledObjectSerializer
+from .devices import DeviceSerializer, ModuleSerializer, VirtualDeviceContextSerializer
+from .manufacturers import ManufacturerSerializer
+from .roles import InventoryItemRoleSerializer
+from ..nested_serializers import *
+
+__all__ = (
+    'ConsolePortSerializer',
+    'ConsoleServerPortSerializer',
+    'DeviceBaySerializer',
+    'FrontPortSerializer',
+    'InterfaceSerializer',
+    'InventoryItemSerializer',
+    'ModuleBaySerializer',
+    'PowerOutletSerializer',
+    'PowerPortSerializer',
+    'RearPortSerializer',
+)
+
+
+class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
+    device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(
+        choices=ConsolePortTypeChoices,
+        allow_blank=True,
+        required=False
+    )
+    speed = ChoiceField(
+        choices=ConsolePortSpeedChoices,
+        allow_null=True,
+        required=False
+    )
+
+    class Meta:
+        model = ConsoleServerPort
+        fields = [
+            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
+            'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
+            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
+            'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
+
+
+class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
+    device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(
+        choices=ConsolePortTypeChoices,
+        allow_blank=True,
+        required=False
+    )
+    speed = ChoiceField(
+        choices=ConsolePortSpeedChoices,
+        allow_null=True,
+        required=False
+    )
+
+    class Meta:
+        model = ConsolePort
+        fields = [
+            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
+            'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
+            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
+            'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
+
+
+class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
+    device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(
+        choices=PowerPortTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = PowerPort
+        fields = [
+            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
+            'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
+            'created', 'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
+
+
+class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
+    device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(
+        choices=PowerOutletTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    power_port = PowerPortSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+    feed_leg = ChoiceField(
+        choices=PowerOutletFeedLegChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = PowerOutlet
+        fields = [
+            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg',
+            'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
+            'created', 'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
+
+
+class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
+    device = DeviceSerializer(nested=True)
+    vdcs = SerializedPKRelatedField(
+        queryset=VirtualDeviceContext.objects.all(),
+        serializer=VirtualDeviceContextSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(choices=InterfaceTypeChoices)
+    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, allow_null=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)
+    untagged_vlan = VLANSerializer(nested=True, required=False, allow_null=True)
+    tagged_vlans = SerializedPKRelatedField(
+        queryset=VLAN.objects.all(),
+        serializer=VLANSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    vrf = VRFSerializer(nested=True, required=False, allow_null=True)
+    l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
+    wireless_link = NestedWirelessLinkSerializer(read_only=True, allow_null=True)
+    wireless_lans = SerializedPKRelatedField(
+        queryset=WirelessLAN.objects.all(),
+        serializer=WirelessLANSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    count_ipaddresses = serializers.IntegerField(read_only=True)
+    count_fhrp_groups = serializers.IntegerField(read_only=True)
+    mac_address = serializers.CharField(
+        required=False,
+        default=None,
+        allow_blank=True,
+        allow_null=True
+    )
+    wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
+
+    class Meta:
+        model = Interface
+        fields = [
+            'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge',
+            'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role',
+            'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
+            'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
+            'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
+            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
+            'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
+
+    def validate(self, data):
+
+        # Validate many-to-many VLAN assignments
+        if not self.nested:
+            device = self.instance.device if self.instance else data.get('device')
+            for vlan in data.get('tagged_vlans', []):
+                if vlan.site not in [device.site, None]:
+                    raise serializers.ValidationError({
+                        'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, "
+                                        f"or it must be global."
+                    })
+
+        return super().validate(data)
+
+
+class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
+    device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(choices=PortTypeChoices)
+
+    class Meta:
+        model = RearPort
+        fields = [
+            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description',
+            'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
+            'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
+
+
+class FrontPortRearPortSerializer(WritableNestedSerializer):
+    """
+    NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
+    """
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
+
+    class Meta:
+        model = RearPort
+        fields = ['id', 'url', 'display', 'name', 'label', 'description']
+
+
+class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
+    device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(choices=PortTypeChoices)
+    rear_port = FrontPortRearPortSerializer()
+
+    class Meta:
+        model = FrontPort
+        fields = [
+            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
+            'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
+            'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
+
+
+class ModuleBaySerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
+    device = DeviceSerializer(nested=True)
+    installed_module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'serial', 'description'),
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = ModuleBay
+        fields = [
+            'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
+
+
+class DeviceBaySerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
+    device = DeviceSerializer(nested=True)
+    installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
+
+    class Meta:
+        model = DeviceBay
+        fields = [
+            'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
+
+
+class InventoryItemSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
+    device = DeviceSerializer(nested=True)
+    parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
+    role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
+    manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True, default=None)
+    component_type = ContentTypeField(
+        queryset=ContentType.objects.filter(MODULAR_COMPONENT_MODELS),
+        required=False,
+        allow_null=True
+    )
+    component = serializers.SerializerMethodField(read_only=True)
+    _depth = serializers.IntegerField(source='level', read_only=True)
+
+    class Meta:
+        model = InventoryItem
+        fields = [
+            'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial',
+            'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
+            'custom_fields', 'created', 'last_updated', '_depth',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_component(self, obj):
+        if obj.component is None:
+            return None
+        serializer = get_serializer_for_model(obj.component)
+        context = {'request': self.context['request']}
+        return serializer(obj.component, nested=True, context=context).data

+ 157 - 0
netbox/dcim/api/serializers_/devices.py

@@ -0,0 +1,157 @@
+import decimal
+
+from django.utils.translation import gettext as _
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from dcim.choices import *
+from dcim.models import Device, DeviceBay, Module, VirtualDeviceContext
+from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
+from ipam.api.serializers_.ip import IPAddressSerializer
+from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+from virtualization.api.serializers_.clusters import ClusterSerializer
+from .devicetypes import *
+from .platforms import PlatformSerializer
+from .racks import RackSerializer
+from .roles import DeviceRoleSerializer
+from .sites import LocationSerializer, SiteSerializer
+from .virtualchassis import VirtualChassisSerializer
+from ..nested_serializers import *
+
+__all__ = (
+    'DeviceSerializer',
+    'DeviceWithConfigContextSerializer',
+    'ModuleSerializer',
+    'VirtualDeviceContextSerializer',
+)
+
+
+class DeviceSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
+    device_type = DeviceTypeSerializer(nested=True)
+    role = DeviceRoleSerializer(nested=True)
+    tenant = TenantSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    platform = PlatformSerializer(nested=True, required=False, allow_null=True)
+    site = SiteSerializer(nested=True)
+    location = LocationSerializer(nested=True, required=False, allow_null=True, default=None)
+    rack = RackSerializer(nested=True, required=False, allow_null=True, default=None)
+    face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default=lambda: '')
+    position = serializers.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        allow_null=True,
+        label=_('Position (U)'),
+        min_value=decimal.Decimal(0.5),
+        default=None
+    )
+    status = ChoiceField(choices=DeviceStatusChoices, required=False)
+    airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
+    primary_ip = IPAddressSerializer(nested=True, read_only=True)
+    primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
+    primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True)
+    oob_ip = IPAddressSerializer(nested=True, required=False, allow_null=True)
+    parent_device = serializers.SerializerMethodField()
+    cluster = ClusterSerializer(nested=True, required=False, allow_null=True)
+    virtual_chassis = VirtualChassisSerializer(nested=True, required=False, allow_null=True, default=None)
+    vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
+    config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
+
+    # Counter fields
+    console_port_count = serializers.IntegerField(read_only=True)
+    console_server_port_count = serializers.IntegerField(read_only=True)
+    power_port_count = serializers.IntegerField(read_only=True)
+    power_outlet_count = serializers.IntegerField(read_only=True)
+    interface_count = serializers.IntegerField(read_only=True)
+    front_port_count = serializers.IntegerField(read_only=True)
+    rear_port_count = serializers.IntegerField(read_only=True)
+    device_bay_count = serializers.IntegerField(read_only=True)
+    module_bay_count = serializers.IntegerField(read_only=True)
+    inventory_item_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = Device
+        fields = [
+            'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
+            'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
+            'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
+            'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
+            'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
+            'module_bay_count', 'inventory_item_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+    @extend_schema_field(NestedDeviceSerializer)
+    def get_parent_device(self, obj):
+        try:
+            device_bay = obj.parent_bay
+        except DeviceBay.DoesNotExist:
+            return None
+        context = {'request': self.context['request']}
+        data = NestedDeviceSerializer(instance=device_bay.device, context=context).data
+        data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
+        return data
+
+
+class DeviceWithConfigContextSerializer(DeviceSerializer):
+    config_context = serializers.SerializerMethodField(read_only=True)
+
+    class Meta(DeviceSerializer.Meta):
+        fields = [
+            'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
+            'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
+            'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags',
+            'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
+            'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
+            'device_bay_count', 'module_bay_count', 'inventory_item_count',
+        ]
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_config_context(self, obj):
+        return obj.get_config_context()
+
+
+class VirtualDeviceContextSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
+    device = DeviceSerializer(nested=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
+    primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
+    primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
+    primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True)
+    status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
+
+    # Related object counts
+    interface_count = RelatedObjectCountField('interfaces')
+
+    class Meta:
+        model = VirtualDeviceContext
+        fields = [
+            'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
+            'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'interface_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
+
+
+class ModuleSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
+    device = DeviceSerializer(nested=True)
+    module_bay = NestedModuleBaySerializer()
+    module_type = ModuleTypeSerializer(nested=True)
+    status = ChoiceField(choices=ModuleStatusChoices, required=False)
+
+    class Meta:
+        model = Module
+        fields = [
+            'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')

+ 327 - 0
netbox/dcim/api/serializers_/devicetype_components.py

@@ -0,0 +1,327 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.models import (
+    ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
+    InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
+)
+from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.serializers import ValidatedModelSerializer
+from utilities.api import get_serializer_for_model
+from wireless.choices import *
+from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
+from .manufacturers import ManufacturerSerializer
+from .roles import InventoryItemRoleSerializer
+from ..nested_serializers import *
+
+__all__ = (
+    'ConsolePortTemplateSerializer',
+    'ConsoleServerPortTemplateSerializer',
+    'DeviceBayTemplateSerializer',
+    'FrontPortTemplateSerializer',
+    'InterfaceTemplateSerializer',
+    'InventoryItemTemplateSerializer',
+    'ModuleBayTemplateSerializer',
+    'PowerOutletTemplateSerializer',
+    'PowerPortTemplateSerializer',
+    'RearPortTemplateSerializer',
+)
+
+
+class ConsolePortTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
+    device_type = DeviceTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    module_type = ModuleTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(
+        choices=ConsolePortTypeChoices,
+        allow_blank=True,
+        required=False
+    )
+
+    class Meta:
+        model = ConsolePortTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
+            'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
+    device_type = DeviceTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    module_type = ModuleTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(
+        choices=ConsolePortTypeChoices,
+        allow_blank=True,
+        required=False
+    )
+
+    class Meta:
+        model = ConsoleServerPortTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
+            'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class PowerPortTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
+    device_type = DeviceTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    module_type = ModuleTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(
+        choices=PowerPortTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = PowerPortTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
+            'allocated_draw', 'description', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class PowerOutletTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
+    device_type = DeviceTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    module_type = ModuleTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(
+        choices=PowerOutletTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    power_port = PowerPortTemplateSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+    feed_leg = ChoiceField(
+        choices=PowerOutletFeedLegChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = PowerOutletTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
+            'description', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class InterfaceTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
+    device_type = DeviceTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    module_type = ModuleTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(choices=InterfaceTypeChoices)
+    bridge = NestedInterfaceTemplateSerializer(
+        required=False,
+        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
+    )
+    rf_role = ChoiceField(
+        choices=WirelessRoleChoices,
+        required=False,
+        allow_blank=True,
+        allow_null=True
+    )
+
+    class Meta:
+        model = InterfaceTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
+            'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class RearPortTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
+    device_type = DeviceTypeSerializer(
+        required=False,
+        nested=True,
+        allow_null=True,
+        default=None
+    )
+    module_type = ModuleTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(choices=PortTypeChoices)
+
+    class Meta:
+        model = RearPortTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
+            'description', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class FrontPortTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
+    device_type = DeviceTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    module_type = ModuleTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(choices=PortTypeChoices)
+    rear_port = RearPortTemplateSerializer(nested=True)
+
+    class Meta:
+        model = FrontPortTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
+            'rear_port_position', 'description', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class ModuleBayTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail')
+    device_type = DeviceTypeSerializer(
+        nested=True
+    )
+
+    class Meta:
+        model = ModuleBayTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
+            'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class DeviceBayTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
+    device_type = DeviceTypeSerializer(
+        nested=True
+    )
+
+    class Meta:
+        model = DeviceBayTemplate
+        fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class InventoryItemTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail')
+    device_type = DeviceTypeSerializer(
+        nested=True
+    )
+    parent = serializers.PrimaryKeyRelatedField(
+        queryset=InventoryItemTemplate.objects.all(),
+        allow_null=True,
+        default=None
+    )
+    role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
+    manufacturer = ManufacturerSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    component_type = ContentTypeField(
+        queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS),
+        required=False,
+        allow_null=True
+    )
+    component = serializers.SerializerMethodField(read_only=True)
+    _depth = serializers.IntegerField(source='level', read_only=True)
+
+    class Meta:
+        model = InventoryItemTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
+            'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_component(self, obj):
+        if obj.component is None:
+            return None
+        serializer = get_serializer_for_model(obj.component)
+        context = {'request': self.context['request']}
+        return serializer(obj.component, nested=True, context=context).data

+ 74 - 0
netbox/dcim/api/serializers_/devicetypes.py

@@ -0,0 +1,74 @@
+from django.utils.translation import gettext as _
+from rest_framework import serializers
+
+from dcim.choices import *
+from dcim.models import DeviceType, ModuleType
+from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+from .manufacturers import ManufacturerSerializer
+from .platforms import PlatformSerializer
+
+__all__ = (
+    'DeviceTypeSerializer',
+    'ModuleTypeSerializer',
+)
+
+
+class DeviceTypeSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
+    manufacturer = ManufacturerSerializer(nested=True)
+    default_platform = PlatformSerializer(nested=True, required=False, allow_null=True)
+    u_height = serializers.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        label=_('Position (U)'),
+        min_value=0,
+        default=1.0
+    )
+    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)
+    front_image = serializers.URLField(allow_null=True, required=False)
+    rear_image = serializers.URLField(allow_null=True, required=False)
+
+    # Counter fields
+    console_port_template_count = serializers.IntegerField(read_only=True)
+    console_server_port_template_count = serializers.IntegerField(read_only=True)
+    power_port_template_count = serializers.IntegerField(read_only=True)
+    power_outlet_template_count = serializers.IntegerField(read_only=True)
+    interface_template_count = serializers.IntegerField(read_only=True)
+    front_port_template_count = serializers.IntegerField(read_only=True)
+    rear_port_template_count = serializers.IntegerField(read_only=True)
+    device_bay_template_count = serializers.IntegerField(read_only=True)
+    module_bay_template_count = serializers.IntegerField(read_only=True)
+    inventory_item_template_count = serializers.IntegerField(read_only=True)
+
+    # Related object counts
+    device_count = RelatedObjectCountField('instances')
+
+    class Meta:
+        model = DeviceType
+        fields = [
+            'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
+            'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+            'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'device_count', 'console_port_template_count', 'console_server_port_template_count',
+            'power_port_template_count', 'power_outlet_template_count', 'interface_template_count',
+            'front_port_template_count', 'rear_port_template_count', 'device_bay_template_count',
+            'module_bay_template_count', 'inventory_item_template_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
+
+
+class ModuleTypeSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
+    manufacturer = ManufacturerSerializer(nested=True)
+    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
+
+    class Meta:
+        model = ModuleType
+        fields = [
+            'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
+            'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')

+ 26 - 0
netbox/dcim/api/serializers_/manufacturers.py

@@ -0,0 +1,26 @@
+from rest_framework import serializers
+
+from dcim.models import Manufacturer
+from netbox.api.fields import RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+
+__all__ = (
+    'ManufacturerSerializer',
+)
+
+
+class ManufacturerSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
+
+    # Related object counts
+    devicetype_count = RelatedObjectCountField('device_types')
+    inventoryitem_count = RelatedObjectCountField('inventory_items')
+    platform_count = RelatedObjectCountField('platforms')
+
+    class Meta:
+        model = Manufacturer
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
+            'devicetype_count', 'inventoryitem_count', 'platform_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')

+ 29 - 0
netbox/dcim/api/serializers_/platforms.py

@@ -0,0 +1,29 @@
+from rest_framework import serializers
+
+from dcim.models import Platform
+from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
+from netbox.api.fields import RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+from .manufacturers import ManufacturerSerializer
+
+__all__ = (
+    'PlatformSerializer',
+)
+
+
+class PlatformSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
+    manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True)
+    config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
+
+    # Related object counts
+    device_count = RelatedObjectCountField('devices')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
+
+    class Meta:
+        model = Platform
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')

+ 80 - 0
netbox/dcim/api/serializers_/power.py

@@ -0,0 +1,80 @@
+from rest_framework import serializers
+
+from dcim.choices import *
+from dcim.models import PowerFeed, PowerPanel
+from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+from .base import ConnectedEndpointsSerializer
+from .cables import CabledObjectSerializer
+from .racks import RackSerializer
+from .sites import LocationSerializer, SiteSerializer
+
+__all__ = (
+    'PowerFeedSerializer',
+    'PowerPanelSerializer',
+)
+
+
+class PowerPanelSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
+    site = SiteSerializer(nested=True)
+    location = LocationSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+
+    # Related object counts
+    powerfeed_count = RelatedObjectCountField('powerfeeds')
+
+    class Meta:
+        model = PowerPanel
+        fields = [
+            'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
+            'powerfeed_count', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
+
+
+class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
+    power_panel = PowerPanelSerializer(nested=True)
+    rack = RackSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(
+        choices=PowerFeedTypeChoices,
+        default=lambda: PowerFeedTypeChoices.TYPE_PRIMARY,
+    )
+    status = ChoiceField(
+        choices=PowerFeedStatusChoices,
+        default=lambda: PowerFeedStatusChoices.STATUS_ACTIVE,
+    )
+    supply = ChoiceField(
+        choices=PowerFeedSupplyChoices,
+        default=lambda: PowerFeedSupplyChoices.SUPPLY_AC,
+    )
+    phase = ChoiceField(
+        choices=PowerFeedPhaseChoices,
+        default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE,
+    )
+    tenant = TenantSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = PowerFeed
+        fields = [
+            'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
+            'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
+            'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')

+ 117 - 0
netbox/dcim/api/serializers_/racks.py

@@ -0,0 +1,117 @@
+from django.utils.translation import gettext as _
+from rest_framework import serializers
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.models import Rack, RackReservation, RackRole
+from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+from netbox.config import ConfigItem
+from tenancy.api.serializers_.tenants import TenantSerializer
+from users.api.serializers_.users import UserSerializer
+from .sites import LocationSerializer, SiteSerializer
+
+__all__ = (
+    'RackElevationDetailFilterSerializer',
+    'RackReservationSerializer',
+    'RackRoleSerializer',
+    'RackSerializer',
+)
+
+
+class RackRoleSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
+
+    # Related object counts
+    rack_count = RelatedObjectCountField('racks')
+
+    class Meta:
+        model = RackRole
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'rack_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
+
+
+class RackSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
+    site = SiteSerializer(nested=True)
+    location = LocationSerializer(nested=True, required=False, allow_null=True, default=None)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    status = ChoiceField(choices=RackStatusChoices, required=False)
+    role = RackRoleSerializer(nested=True, required=False, allow_null=True)
+    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, allow_null=True)
+    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
+
+    # Related object counts
+    device_count = RelatedObjectCountField('devices')
+    powerfeed_count = RelatedObjectCountField('powerfeeds')
+
+    class Meta:
+        model = Rack
+        fields = [
+            'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
+            'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight', 'weight_unit',
+            'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
+            'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
+
+
+class RackReservationSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
+    rack = RackSerializer(nested=True)
+    user = UserSerializer(nested=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+
+    class Meta:
+        model = RackReservation
+        fields = [
+            'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
+            'comments', 'tags', 'custom_fields',
+        ]
+        brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
+
+
+class RackElevationDetailFilterSerializer(serializers.Serializer):
+    q = serializers.CharField(
+        required=False,
+        default=None
+    )
+    face = serializers.ChoiceField(
+        choices=DeviceFaceChoices,
+        default=DeviceFaceChoices.FACE_FRONT
+    )
+    render = serializers.ChoiceField(
+        choices=RackElevationDetailRenderChoices,
+        default=RackElevationDetailRenderChoices.RENDER_JSON
+    )
+    unit_width = serializers.IntegerField(
+        default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH')
+    )
+    unit_height = serializers.IntegerField(
+        default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
+    )
+    legend_width = serializers.IntegerField(
+        default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
+    )
+    margin_width = serializers.IntegerField(
+        default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
+    )
+    exclude = serializers.IntegerField(
+        required=False,
+        default=None
+    )
+    expand_devices = serializers.BooleanField(
+        required=False,
+        default=True
+    )
+    include_images = serializers.BooleanField(
+        required=False,
+        default=True
+    )

+ 31 - 0
netbox/dcim/api/serializers_/rackunits.py

@@ -0,0 +1,31 @@
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from dcim.choices import *
+from netbox.api.fields import ChoiceField
+from .devices import DeviceSerializer
+
+__all__ = (
+    'RackUnitSerializer',
+)
+
+
+class RackUnitSerializer(serializers.Serializer):
+    """
+    A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
+    """
+    id = serializers.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        read_only=True
+    )
+    name = serializers.CharField(read_only=True)
+    face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
+    device = DeviceSerializer(nested=True, read_only=True)
+    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']

+ 43 - 0
netbox/dcim/api/serializers_/roles.py

@@ -0,0 +1,43 @@
+from rest_framework import serializers
+
+from dcim.models import DeviceRole, InventoryItemRole
+from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
+from netbox.api.fields import RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+
+__all__ = (
+    'DeviceRoleSerializer',
+    'InventoryItemRoleSerializer',
+)
+
+
+class DeviceRoleSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
+    config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
+
+    # Related object counts
+    device_count = RelatedObjectCountField('devices')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
+
+    class Meta:
+        model = DeviceRole
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
+
+
+class InventoryItemRoleSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
+
+    # Related object counts
+    inventoryitem_count = RelatedObjectCountField('inventory_items')
+
+    class Meta:
+        model = InventoryItemRole
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'inventoryitem_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')

+ 98 - 0
netbox/dcim/api/serializers_/sites.py

@@ -0,0 +1,98 @@
+from rest_framework import serializers
+from timezone_field.rest_framework import TimeZoneSerializerField
+
+from dcim.choices import *
+from dcim.models import Location, Region, Site, SiteGroup
+from ipam.api.serializers_.asns import ASNSerializer
+from ipam.models import ASN
+from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
+from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+from ..nested_serializers import *
+
+__all__ = (
+    'LocationSerializer',
+    'RegionSerializer',
+    'SiteGroupSerializer',
+    'SiteSerializer',
+)
+
+
+class RegionSerializer(NestedGroupModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
+    parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
+    site_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = Region
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'site_count', '_depth',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
+
+
+class SiteGroupSerializer(NestedGroupModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
+    parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
+    site_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = SiteGroup
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'site_count', '_depth',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
+
+
+class SiteSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
+    status = ChoiceField(choices=SiteStatusChoices, required=False)
+    region = RegionSerializer(nested=True, required=False, allow_null=True)
+    group = SiteGroupSerializer(nested=True, required=False, allow_null=True)
+    tenant = TenantSerializer(required=False, allow_null=True)
+    time_zone = TimeZoneSerializerField(required=False, allow_null=True)
+    asns = SerializedPKRelatedField(
+        queryset=ASN.objects.all(),
+        serializer=ASNSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+
+    # Related object counts
+    circuit_count = RelatedObjectCountField('circuit_terminations')
+    device_count = RelatedObjectCountField('devices')
+    prefix_count = RelatedObjectCountField('prefixes')
+    rack_count = RelatedObjectCountField('racks')
+    vlan_count = RelatedObjectCountField('vlans')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
+
+    class Meta:
+        model = Site
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone',
+            'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags',
+            'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
+            'virtualmachine_count', 'vlan_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
+
+
+class LocationSerializer(NestedGroupModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
+    site = SiteSerializer(nested=True)
+    parent = NestedLocationSerializer(required=False, allow_null=True)
+    status = ChoiceField(choices=LocationStatusChoices, required=False)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    rack_count = serializers.IntegerField(read_only=True)
+    device_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = Location
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')

+ 25 - 0
netbox/dcim/api/serializers_/virtualchassis.py

@@ -0,0 +1,25 @@
+from rest_framework import serializers
+
+from dcim.models import VirtualChassis
+from netbox.api.serializers import NetBoxModelSerializer
+from ..nested_serializers import *
+
+__all__ = (
+    'VirtualChassisSerializer',
+)
+
+
+class VirtualChassisSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
+    master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
+
+    # Counter fields
+    member_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = VirtualChassis
+        fields = [
+            'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated', 'member_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')

+ 4 - 7
netbox/dcim/api/views.py

@@ -7,7 +7,6 @@ from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.viewsets import ViewSet
 
-from circuits.models import Circuit
 from dcim import filtersets
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
@@ -18,10 +17,8 @@ from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
-from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
 from utilities.query_functions import CollateAsChar
-from utilities.utils import count_related
 from . import serializers
 from .exceptions import MissingFilterException
 
@@ -60,16 +57,16 @@ class PathEndpointMixin(object):
         # Serialize path objects, iterating over each three-tuple in the path
         for near_ends, cable, far_ends in obj.trace():
             if near_ends:
-                serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
-                near_ends = serializer_a(near_ends, many=True, context={'request': request}).data
+                serializer_a = get_serializer_for_model(near_ends[0])
+                near_ends = serializer_a(near_ends, nested=True, many=True, context={'request': request}).data
             else:
                 # Path is split; stop here
                 break
             if cable:
                 cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
             if far_ends:
-                serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
-                far_ends = serializer_b(far_ends, many=True, context={'request': request}).data
+                serializer_b = get_serializer_for_model(far_ends[0])
+                far_ends = serializer_b(far_ends, nested=True, many=True, context={'request': request}).data
 
             path.append((near_ends, cable, far_ends))
 

+ 7 - 11
netbox/extras/api/customfields.py

@@ -1,13 +1,12 @@
 from django.utils.translation import gettext as _
-from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 from rest_framework.fields import Field
 from rest_framework.serializers import ValidationError
 
 from core.models import ObjectType
 from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField
-from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
 
 
@@ -58,11 +57,11 @@ class CustomFieldsDataField(Field):
         for cf in self._get_custom_fields():
             value = cf.deserialize(obj.get(cf.name))
             if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
-                serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
-                value = serializer(value, context=self.parent.context).data
+                serializer = get_serializer_for_model(cf.object_type.model_class())
+                value = serializer(value, nested=True, context=self.parent.context).data
             elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
-                serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
-                value = serializer(value, many=True, context=self.parent.context).data
+                serializer = get_serializer_for_model(cf.object_type.model_class())
+                value = serializer(value, nested=True, many=True, context=self.parent.context).data
             data[cf.name] = value
 
         return data
@@ -80,12 +79,9 @@ class CustomFieldsDataField(Field):
                     CustomFieldTypeChoices.TYPE_OBJECT,
                     CustomFieldTypeChoices.TYPE_MULTIOBJECT
             ):
-                serializer_class = get_serializer_for_model(
-                    model=cf.object_type.model_class(),
-                    prefix=NESTED_SERIALIZER_PREFIX
-                )
+                serializer_class = get_serializer_for_model(cf.object_type.model_class())
                 many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
-                serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context)
+                serializer = serializer_class(data=data[cf.name], nested=True, many=many, context=self.parent.context)
                 if serializer.is_valid():
                     data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
                 else:

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

@@ -5,7 +5,7 @@ from rest_framework.response import Response
 from rest_framework.status import HTTP_400_BAD_REQUEST
 
 from netbox.api.renderers import TextRenderer
-from .nested_serializers import NestedConfigTemplateSerializer
+from .serializers import ConfigTemplateSerializer
 
 __all__ = (
     'ConfigContextQuerySetMixin',
@@ -52,7 +52,7 @@ class ConfigTemplateRenderMixin:
         if request.accepted_renderer.format == 'txt':
             return Response(output)
 
-        template_serializer = NestedConfigTemplateSerializer(configtemplate, context={'request': request})
+        template_serializer = ConfigTemplateSerializer(configtemplate, nested=True, context={'request': request})
 
         return Response({
             'configtemplate': template_serializer.data,

+ 15 - 658
netbox/extras/api/serializers.py

@@ -1,659 +1,16 @@
-from django.contrib.auth import get_user_model
-from django.core.exceptions import ObjectDoesNotExist
-from django.utils.translation import gettext as _
-from drf_spectacular.types import OpenApiTypes
-from drf_spectacular.utils import extend_schema_field
-from rest_framework import serializers
-
-from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
-from core.api.serializers import JobSerializer
-from core.models import ObjectType
-from dcim.api.nested_serializers import (
-    NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
-    NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
-)
-from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
-from extras.choices import *
-from extras.models import *
-from netbox.api.exceptions import SerializerNotFound
-from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
-from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
-from netbox.api.serializers.features import TaggableModelSerializer
-from netbox.constants import NESTED_SERIALIZER_PREFIX
-from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
-from tenancy.models import Tenant, TenantGroup
-from users.api.nested_serializers import NestedUserSerializer
-from utilities.api import get_serializer_for_model
-from virtualization.api.nested_serializers import (
-    NestedClusterGroupSerializer, NestedClusterSerializer, NestedClusterTypeSerializer,
-)
-from virtualization.models import Cluster, ClusterGroup, ClusterType
+from .serializers_.attachments import *
+from .serializers_.bookmarks import *
+from .serializers_.change_logging import *
+from .serializers_.contenttypes import *
+from .serializers_.customfields import *
+from .serializers_.customlinks import *
+from .serializers_.dashboard import *
+from .serializers_.events import *
+from .serializers_.exporttemplates import *
+from .serializers_.journaling import *
+from .serializers_.configcontexts import *
+from .serializers_.configtemplates import *
+from .serializers_.savedfilters import *
+from .serializers_.scripts import *
+from .serializers_.tags import *
 from .nested_serializers import *
-
-__all__ = (
-    'BookmarkSerializer',
-    'ConfigContextSerializer',
-    'ConfigTemplateSerializer',
-    'ContentTypeSerializer',
-    'CustomFieldChoiceSetSerializer',
-    'CustomFieldSerializer',
-    'CustomLinkSerializer',
-    'DashboardSerializer',
-    'EventRuleSerializer',
-    'ExportTemplateSerializer',
-    'ImageAttachmentSerializer',
-    'JournalEntrySerializer',
-    'ObjectChangeSerializer',
-    'SavedFilterSerializer',
-    'ScriptDetailSerializer',
-    'ScriptInputSerializer',
-    'ScriptSerializer',
-    'TagSerializer',
-    'WebhookSerializer',
-)
-
-
-#
-# Event Rules
-#
-
-class EventRuleSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail')
-    object_types = ContentTypeField(
-        queryset=ObjectType.objects.with_feature('event_rules'),
-        many=True
-    )
-    action_type = ChoiceField(choices=EventRuleActionChoices)
-    action_object_type = ContentTypeField(
-        queryset=ObjectType.objects.with_feature('event_rules'),
-    )
-    action_object = serializers.SerializerMethodField(read_only=True)
-
-    class Meta:
-        model = EventRule
-        fields = [
-            'id', 'url', 'display', 'object_types', 'name', 'type_create', 'type_update', 'type_delete',
-            'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
-            'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-    @extend_schema_field(OpenApiTypes.OBJECT)
-    def get_action_object(self, instance):
-        context = {'request': self.context['request']}
-        # We need to manually instantiate the serializer for scripts
-        if instance.action_type == EventRuleActionChoices.SCRIPT:
-            script = instance.action_object
-            instance = script.python_class() if script.python_class else None
-            return NestedScriptSerializer(instance, context=context).data
-        else:
-            serializer = get_serializer_for_model(
-                model=instance.action_object_type.model_class(),
-                prefix=NESTED_SERIALIZER_PREFIX
-            )
-            return serializer(instance.action_object, context=context).data
-
-
-#
-# Webhooks
-#
-
-class WebhookSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
-
-    class Meta:
-        model = Webhook
-        fields = [
-            'id', 'url', 'display', 'name', 'description', 'payload_url', 'http_method', 'http_content_type',
-            'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields',
-            'tags', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-#
-# Custom fields
-#
-
-class CustomFieldSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
-    object_types = ContentTypeField(
-        queryset=ObjectType.objects.with_feature('custom_fields'),
-        many=True
-    )
-    type = ChoiceField(choices=CustomFieldTypeChoices)
-    object_type = ContentTypeField(
-        queryset=ObjectType.objects.all(),
-        required=False,
-        allow_null=True
-    )
-    filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
-    data_type = serializers.SerializerMethodField()
-    choice_set = NestedCustomFieldChoiceSetSerializer(
-        required=False,
-        allow_null=True
-    )
-    ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False)
-    ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False)
-
-    class Meta:
-        model = CustomField
-        fields = [
-            'id', 'url', 'display', 'object_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
-            'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable',
-            'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
-            'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-    def validate_type(self, value):
-        if self.instance and self.instance.type != value:
-            raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
-
-        return value
-
-    @extend_schema_field(OpenApiTypes.STR)
-    def get_data_type(self, obj):
-        types = CustomFieldTypeChoices
-        if obj.type == types.TYPE_INTEGER:
-            return 'integer'
-        if obj.type == types.TYPE_DECIMAL:
-            return 'decimal'
-        if obj.type == types.TYPE_BOOLEAN:
-            return 'boolean'
-        if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT):
-            return 'object'
-        if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT):
-            return 'array'
-        return 'string'
-
-
-class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
-    base_choices = ChoiceField(
-        choices=CustomFieldChoiceSetBaseChoices,
-        required=False
-    )
-    extra_choices = serializers.ListField(
-        child=serializers.ListField(
-            min_length=2,
-            max_length=2
-        )
-    )
-
-    class Meta:
-        model = CustomFieldChoiceSet
-        fields = [
-            'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
-            'choices_count', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')
-
-
-#
-# Custom links
-#
-
-class CustomLinkSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
-    object_types = ContentTypeField(
-        queryset=ObjectType.objects.with_feature('custom_links'),
-        many=True
-    )
-
-    class Meta:
-        model = CustomLink
-        fields = [
-            'id', 'url', 'display', 'object_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
-            'button_class', 'new_window', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name')
-
-
-#
-# Export templates
-#
-
-class ExportTemplateSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
-    object_types = ContentTypeField(
-        queryset=ObjectType.objects.with_feature('export_templates'),
-        many=True
-    )
-    data_source = NestedDataSourceSerializer(
-        required=False
-    )
-    data_file = NestedDataFileSerializer(
-        read_only=True
-    )
-
-    class Meta:
-        model = ExportTemplate
-        fields = [
-            'id', 'url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
-            'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
-            'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-#
-# Saved filters
-#
-
-class SavedFilterSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail')
-    object_types = ContentTypeField(
-        queryset=ObjectType.objects.all(),
-        many=True
-    )
-
-    class Meta:
-        model = SavedFilter
-        fields = [
-            'id', 'url', 'display', 'object_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
-            'shared', 'parameters', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
-
-
-#
-# Bookmarks
-#
-
-class BookmarkSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
-    object_type = ContentTypeField(
-        queryset=ObjectType.objects.with_feature('bookmarks'),
-    )
-    object = serializers.SerializerMethodField(read_only=True)
-    user = NestedUserSerializer()
-
-    class Meta:
-        model = Bookmark
-        fields = [
-            'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
-        ]
-        brief_fields = ('id', 'url', 'display', 'object_id', 'object_type')
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_object(self, instance):
-        serializer = get_serializer_for_model(instance.object, prefix=NESTED_SERIALIZER_PREFIX)
-        return serializer(instance.object, context={'request': self.context['request']}).data
-
-
-#
-# Tags
-#
-
-class TagSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
-    object_types = ContentTypeField(
-        queryset=ObjectType.objects.with_feature('tags'),
-        many=True,
-        required=False
-    )
-
-    # Related object counts
-    tagged_items = RelatedObjectCountField('extras_taggeditem_items')
-
-    class Meta:
-        model = Tag
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
-            'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')
-
-
-#
-# Image attachments
-#
-
-class ImageAttachmentSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
-    object_type = ContentTypeField(
-        queryset=ObjectType.objects.all()
-    )
-    parent = serializers.SerializerMethodField(read_only=True)
-
-    class Meta:
-        model = ImageAttachment
-        fields = [
-            'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image', 'image_height',
-            'image_width', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'image')
-
-    def validate(self, data):
-
-        # Validate that the parent object exists
-        try:
-            data['object_type'].get_object_for_this_type(id=data['object_id'])
-        except ObjectDoesNotExist:
-            raise serializers.ValidationError(
-                "Invalid parent object: {} ID {}".format(data['object_type'], data['object_id'])
-            )
-
-        # Enforce model validation
-        super().validate(data)
-
-        return data
-
-    @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
-
-
-#
-# Journal entries
-#
-
-class JournalEntrySerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
-    assigned_object_type = ContentTypeField(
-        queryset=ObjectType.objects.all()
-    )
-    assigned_object = serializers.SerializerMethodField(read_only=True)
-    created_by = serializers.PrimaryKeyRelatedField(
-        allow_null=True,
-        queryset=get_user_model().objects.all(),
-        required=False,
-        default=serializers.CurrentUserDefault()
-    )
-    kind = ChoiceField(
-        choices=JournalEntryKindChoices,
-        required=False
-    )
-
-    class Meta:
-        model = JournalEntry
-        fields = [
-            'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
-            'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'created')
-
-    def validate(self, data):
-
-        # Validate that the parent object exists
-        if 'assigned_object_type' in data and 'assigned_object_id' in data:
-            try:
-                data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
-            except ObjectDoesNotExist:
-                raise serializers.ValidationError(
-                    f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
-                )
-
-        # Enforce model validation
-        super().validate(data)
-
-        return data
-
-    @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']}
-        return serializer(instance.assigned_object, context=context).data
-
-
-#
-# Config contexts
-#
-
-class ConfigContextSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
-    regions = SerializedPKRelatedField(
-        queryset=Region.objects.all(),
-        serializer=NestedRegionSerializer,
-        required=False,
-        many=True
-    )
-    site_groups = SerializedPKRelatedField(
-        queryset=SiteGroup.objects.all(),
-        serializer=NestedSiteGroupSerializer,
-        required=False,
-        many=True
-    )
-    sites = SerializedPKRelatedField(
-        queryset=Site.objects.all(),
-        serializer=NestedSiteSerializer,
-        required=False,
-        many=True
-    )
-    locations = SerializedPKRelatedField(
-        queryset=Location.objects.all(),
-        serializer=NestedLocationSerializer,
-        required=False,
-        many=True
-    )
-    device_types = SerializedPKRelatedField(
-        queryset=DeviceType.objects.all(),
-        serializer=NestedDeviceTypeSerializer,
-        required=False,
-        many=True
-    )
-    roles = SerializedPKRelatedField(
-        queryset=DeviceRole.objects.all(),
-        serializer=NestedDeviceRoleSerializer,
-        required=False,
-        many=True
-    )
-    platforms = SerializedPKRelatedField(
-        queryset=Platform.objects.all(),
-        serializer=NestedPlatformSerializer,
-        required=False,
-        many=True
-    )
-    cluster_types = SerializedPKRelatedField(
-        queryset=ClusterType.objects.all(),
-        serializer=NestedClusterTypeSerializer,
-        required=False,
-        many=True
-    )
-    cluster_groups = SerializedPKRelatedField(
-        queryset=ClusterGroup.objects.all(),
-        serializer=NestedClusterGroupSerializer,
-        required=False,
-        many=True
-    )
-    clusters = SerializedPKRelatedField(
-        queryset=Cluster.objects.all(),
-        serializer=NestedClusterSerializer,
-        required=False,
-        many=True
-    )
-    tenant_groups = SerializedPKRelatedField(
-        queryset=TenantGroup.objects.all(),
-        serializer=NestedTenantGroupSerializer,
-        required=False,
-        many=True
-    )
-    tenants = SerializedPKRelatedField(
-        queryset=Tenant.objects.all(),
-        serializer=NestedTenantSerializer,
-        required=False,
-        many=True
-    )
-    tags = serializers.SlugRelatedField(
-        queryset=Tag.objects.all(),
-        slug_field='slug',
-        required=False,
-        many=True
-    )
-    data_source = NestedDataSourceSerializer(
-        required=False
-    )
-    data_file = NestedDataFileSerializer(
-        read_only=True
-    )
-
-    class Meta:
-        model = ConfigContext
-        fields = [
-            'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
-            'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
-            'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
-            'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-#
-# Config templates
-#
-
-class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail')
-    data_source = NestedDataSourceSerializer(
-        required=False
-    )
-    data_file = NestedDataFileSerializer(
-        required=False
-    )
-
-    class Meta:
-        model = ConfigTemplate
-        fields = [
-            'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
-            'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-#
-# Scripts
-#
-
-class ScriptSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
-    description = serializers.SerializerMethodField(read_only=True)
-    vars = serializers.SerializerMethodField(read_only=True)
-    result = NestedJobSerializer(read_only=True)
-
-    class Meta:
-        model = Script
-        fields = [
-            'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_vars(self, obj):
-        if obj.python_class:
-            return {
-                k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
-            }
-        else:
-            return {}
-
-    @extend_schema_field(serializers.CharField())
-    def get_display(self, obj):
-        return f'{obj.name} ({obj.module})'
-
-    @extend_schema_field(serializers.CharField())
-    def get_description(self, obj):
-        if obj.python_class:
-            return obj.python_class().description
-        else:
-            return None
-
-
-class ScriptDetailSerializer(ScriptSerializer):
-    result = serializers.SerializerMethodField(read_only=True)
-
-    @extend_schema_field(JobSerializer())
-    def get_result(self, obj):
-        job = obj.jobs.all().order_by('-created').first()
-        context = {
-            'request': self.context['request']
-        }
-        data = JobSerializer(job, context=context).data
-        return data
-
-
-class ScriptInputSerializer(serializers.Serializer):
-    data = serializers.JSONField()
-    commit = serializers.BooleanField()
-    schedule_at = serializers.DateTimeField(required=False, allow_null=True)
-    interval = serializers.IntegerField(required=False, allow_null=True)
-
-    def validate_schedule_at(self, value):
-        if value and not self.context['script'].scheduling_enabled:
-            raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
-        return value
-
-    def validate_interval(self, value):
-        if value and not self.context['script'].scheduling_enabled:
-            raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
-        return value
-
-
-#
-# Change logging
-#
-
-class ObjectChangeSerializer(BaseModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
-    user = NestedUserSerializer(
-        read_only=True
-    )
-    action = ChoiceField(
-        choices=ObjectChangeActionChoices,
-        read_only=True
-    )
-    changed_object_type = ContentTypeField(
-        read_only=True
-    )
-    changed_object = serializers.SerializerMethodField(
-        read_only=True
-    )
-
-    class Meta:
-        model = ObjectChange
-        fields = [
-            'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
-            'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
-        ]
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_changed_object(self, obj):
-        """
-        Serialize a nested representation of the changed object.
-        """
-        if obj.changed_object is None:
-            return None
-
-        try:
-            serializer = get_serializer_for_model(obj.changed_object, prefix=NESTED_SERIALIZER_PREFIX)
-        except SerializerNotFound:
-            return obj.object_repr
-        context = {
-            'request': self.context['request']
-        }
-        data = serializer(obj.changed_object, context=context).data
-
-        return data
-
-
-#
-# ContentTypes
-#
-
-class ContentTypeSerializer(BaseModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail')
-
-    class Meta:
-        model = ObjectType
-        fields = ['id', 'url', 'display', 'app_label', 'model']
-
-
-#
-# User dashboard
-#
-
-class DashboardSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = Dashboard
-        fields = ('layout', 'config')

+ 0 - 0
netbox/extras/api/serializers_/__init__.py


+ 50 - 0
netbox/extras/api/serializers_/attachments.py

@@ -0,0 +1,50 @@
+from django.core.exceptions import ObjectDoesNotExist
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from core.models import ObjectType
+from extras.models import ImageAttachment
+from netbox.api.fields import ContentTypeField
+from netbox.api.serializers import ValidatedModelSerializer
+from utilities.api import get_serializer_for_model
+
+__all__ = (
+    'ImageAttachmentSerializer',
+)
+
+
+class ImageAttachmentSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
+    object_type = ContentTypeField(
+        queryset=ObjectType.objects.all()
+    )
+    parent = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = ImageAttachment
+        fields = [
+            'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image', 'image_height',
+            'image_width', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'image')
+
+    def validate(self, data):
+
+        # Validate that the parent object exists
+        try:
+            data['object_type'].get_object_for_this_type(id=data['object_id'])
+        except ObjectDoesNotExist:
+            raise serializers.ValidationError(
+                "Invalid parent object: {} ID {}".format(data['object_type'], data['object_id'])
+            )
+
+        # Enforce model validation
+        super().validate(data)
+
+        return data
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_parent(self, obj):
+        serializer = get_serializer_for_model(obj.parent)
+        context = {'request': self.context['request']}
+        return serializer(obj.parent, nested=True, context=context).data

+ 35 - 0
netbox/extras/api/serializers_/bookmarks.py

@@ -0,0 +1,35 @@
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from core.models import ObjectType
+from extras.models import Bookmark
+from netbox.api.fields import ContentTypeField
+from netbox.api.serializers import ValidatedModelSerializer
+from users.api.serializers_.users import UserSerializer
+from utilities.api import get_serializer_for_model
+
+__all__ = (
+    'BookmarkSerializer',
+)
+
+
+class BookmarkSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
+    object_type = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('bookmarks'),
+    )
+    object = serializers.SerializerMethodField(read_only=True)
+    user = UserSerializer(nested=True)
+
+    class Meta:
+        model = Bookmark
+        fields = [
+            'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
+        ]
+        brief_fields = ('id', 'url', 'display', 'object_id', 'object_type')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_object(self, instance):
+        serializer = get_serializer_for_model(instance.object)
+        context = {'request': self.context['request']}
+        return serializer(instance.object, nested=True, context=context).data

+ 55 - 0
netbox/extras/api/serializers_/change_logging.py

@@ -0,0 +1,55 @@
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from extras.choices import *
+from extras.models import ObjectChange
+from netbox.api.exceptions import SerializerNotFound
+from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.serializers import BaseModelSerializer
+from users.api.serializers_.users import UserSerializer
+from utilities.api import get_serializer_for_model
+
+__all__ = (
+    'ObjectChangeSerializer',
+)
+
+
+class ObjectChangeSerializer(BaseModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
+    user = UserSerializer(
+        nested=True,
+        read_only=True
+    )
+    action = ChoiceField(
+        choices=ObjectChangeActionChoices,
+        read_only=True
+    )
+    changed_object_type = ContentTypeField(
+        read_only=True
+    )
+    changed_object = serializers.SerializerMethodField(
+        read_only=True
+    )
+
+    class Meta:
+        model = ObjectChange
+        fields = [
+            'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
+            'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
+        ]
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_changed_object(self, obj):
+        """
+        Serialize a nested representation of the changed object.
+        """
+        if obj.changed_object is None:
+            return None
+
+        try:
+            serializer = get_serializer_for_model(obj.changed_object)
+        except SerializerNotFound:
+            return obj.object_repr
+        data = serializer(obj.changed_object, nested=True, context={'request': self.context['request']}).data
+
+        return data

+ 131 - 0
netbox/extras/api/serializers_/configcontexts.py

@@ -0,0 +1,131 @@
+from rest_framework import serializers
+
+from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer
+from dcim.api.serializers_.devicetypes import DeviceTypeSerializer
+from dcim.api.serializers_.platforms import PlatformSerializer
+from dcim.api.serializers_.roles import DeviceRoleSerializer
+from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
+from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
+from extras.models import ConfigContext, Tag
+from netbox.api.fields import SerializedPKRelatedField
+from netbox.api.serializers import ValidatedModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer
+from tenancy.models import Tenant, TenantGroup
+from virtualization.api.serializers_.clusters import ClusterSerializer, ClusterGroupSerializer, ClusterTypeSerializer
+from virtualization.models import Cluster, ClusterGroup, ClusterType
+
+__all__ = (
+    'ConfigContextSerializer',
+)
+
+
+class ConfigContextSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
+    regions = SerializedPKRelatedField(
+        queryset=Region.objects.all(),
+        serializer=RegionSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    site_groups = SerializedPKRelatedField(
+        queryset=SiteGroup.objects.all(),
+        serializer=SiteGroupSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    sites = SerializedPKRelatedField(
+        queryset=Site.objects.all(),
+        serializer=SiteSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    locations = SerializedPKRelatedField(
+        queryset=Location.objects.all(),
+        serializer=LocationSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    device_types = SerializedPKRelatedField(
+        queryset=DeviceType.objects.all(),
+        serializer=DeviceTypeSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    roles = SerializedPKRelatedField(
+        queryset=DeviceRole.objects.all(),
+        serializer=DeviceRoleSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    platforms = SerializedPKRelatedField(
+        queryset=Platform.objects.all(),
+        serializer=PlatformSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    cluster_types = SerializedPKRelatedField(
+        queryset=ClusterType.objects.all(),
+        serializer=ClusterTypeSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    cluster_groups = SerializedPKRelatedField(
+        queryset=ClusterGroup.objects.all(),
+        serializer=ClusterGroupSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    clusters = SerializedPKRelatedField(
+        queryset=Cluster.objects.all(),
+        serializer=ClusterSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    tenant_groups = SerializedPKRelatedField(
+        queryset=TenantGroup.objects.all(),
+        serializer=TenantGroupSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    tenants = SerializedPKRelatedField(
+        queryset=Tenant.objects.all(),
+        serializer=TenantSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    tags = serializers.SlugRelatedField(
+        queryset=Tag.objects.all(),
+        slug_field='slug',
+        required=False,
+        many=True
+    )
+    data_source = DataSourceSerializer(
+        nested=True,
+        required=False
+    )
+    data_file = DataFileSerializer(
+        nested=True,
+        read_only=True
+    )
+
+    class Meta:
+        model = ConfigContext
+        fields = [
+            'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
+            'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
+            'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
+            'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 30 - 0
netbox/extras/api/serializers_/configtemplates.py

@@ -0,0 +1,30 @@
+from rest_framework import serializers
+
+from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer
+from extras.models import ConfigTemplate
+from netbox.api.serializers import ValidatedModelSerializer
+from netbox.api.serializers.features import TaggableModelSerializer
+
+__all__ = (
+    'ConfigTemplateSerializer',
+)
+
+
+class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail')
+    data_source = DataSourceSerializer(
+        nested=True,
+        required=False
+    )
+    data_file = DataFileSerializer(
+        nested=True,
+        required=False
+    )
+
+    class Meta:
+        model = ConfigTemplate
+        fields = [
+            'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
+            'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 16 - 0
netbox/extras/api/serializers_/contenttypes.py

@@ -0,0 +1,16 @@
+from rest_framework import serializers
+
+from core.models import ObjectType
+from netbox.api.serializers import BaseModelSerializer
+
+__all__ = (
+    'ContentTypeSerializer',
+)
+
+
+class ContentTypeSerializer(BaseModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail')
+
+    class Meta:
+        model = ObjectType
+        fields = ['id', 'url', 'display', 'app_label', 'model']

+ 91 - 0
netbox/extras/api/serializers_/customfields.py

@@ -0,0 +1,91 @@
+from django.utils.translation import gettext as _
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from core.models import ObjectType
+from extras.choices import *
+from extras.models import CustomField, CustomFieldChoiceSet
+from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.serializers import ValidatedModelSerializer
+
+__all__ = (
+    'CustomFieldChoiceSetSerializer',
+    'CustomFieldSerializer',
+)
+
+
+class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
+    base_choices = ChoiceField(
+        choices=CustomFieldChoiceSetBaseChoices,
+        required=False
+    )
+    extra_choices = serializers.ListField(
+        child=serializers.ListField(
+            min_length=2,
+            max_length=2
+        )
+    )
+
+    class Meta:
+        model = CustomFieldChoiceSet
+        fields = [
+            'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
+            'choices_count', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')
+
+
+class CustomFieldSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('custom_fields'),
+        many=True
+    )
+    type = ChoiceField(choices=CustomFieldTypeChoices)
+    object_type = ContentTypeField(
+        queryset=ObjectType.objects.all(),
+        required=False,
+        allow_null=True
+    )
+    filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
+    data_type = serializers.SerializerMethodField()
+    choice_set = CustomFieldChoiceSetSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+    ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False)
+    ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False)
+
+    class Meta:
+        model = CustomField
+        fields = [
+            'id', 'url', 'display', 'object_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
+            'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable',
+            'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
+            'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+    def validate_type(self, value):
+        if self.instance and self.instance.type != value:
+            raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
+
+        return value
+
+    @extend_schema_field(OpenApiTypes.STR)
+    def get_data_type(self, obj):
+        types = CustomFieldTypeChoices
+        if obj.type == types.TYPE_INTEGER:
+            return 'integer'
+        if obj.type == types.TYPE_DECIMAL:
+            return 'decimal'
+        if obj.type == types.TYPE_BOOLEAN:
+            return 'boolean'
+        if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT):
+            return 'object'
+        if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT):
+            return 'array'
+        return 'string'

+ 26 - 0
netbox/extras/api/serializers_/customlinks.py

@@ -0,0 +1,26 @@
+from rest_framework import serializers
+
+from core.models import ObjectType
+from extras.models import CustomLink
+from netbox.api.fields import ContentTypeField
+from netbox.api.serializers import ValidatedModelSerializer
+
+__all__ = (
+    'CustomLinkSerializer',
+)
+
+
+class CustomLinkSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('custom_links'),
+        many=True
+    )
+
+    class Meta:
+        model = CustomLink
+        fields = [
+            'id', 'url', 'display', 'object_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
+            'button_class', 'new_window', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name')

+ 13 - 0
netbox/extras/api/serializers_/dashboard.py

@@ -0,0 +1,13 @@
+from rest_framework import serializers
+
+from extras.models import Dashboard
+
+__all__ = (
+    'DashboardSerializer',
+)
+
+
+class DashboardSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Dashboard
+        fields = ('layout', 'config')

+ 71 - 0
netbox/extras/api/serializers_/events.py

@@ -0,0 +1,71 @@
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from core.models import ObjectType
+from extras.choices import *
+from extras.models import EventRule, Webhook
+from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.serializers import NetBoxModelSerializer
+from utilities.api import get_serializer_for_model
+from .scripts import ScriptSerializer
+
+__all__ = (
+    'EventRuleSerializer',
+    'WebhookSerializer',
+)
+
+
+#
+# Event Rules
+#
+
+class EventRuleSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail')
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('event_rules'),
+        many=True
+    )
+    action_type = ChoiceField(choices=EventRuleActionChoices)
+    action_object_type = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('event_rules'),
+    )
+    action_object = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = EventRule
+        fields = [
+            'id', 'url', 'display', 'object_types', 'name', 'type_create', 'type_update', 'type_delete',
+            'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
+            'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+    @extend_schema_field(OpenApiTypes.OBJECT)
+    def get_action_object(self, instance):
+        context = {'request': self.context['request']}
+        # We need to manually instantiate the serializer for scripts
+        if instance.action_type == EventRuleActionChoices.SCRIPT:
+            script = instance.action_object
+            instance = script.python_class() if script.python_class else None
+            return ScriptSerializer(instance, nested=True, context=context).data
+        else:
+            serializer = get_serializer_for_model(instance.action_object_type.model_class())
+            return serializer(instance.action_object, nested=True, context=context).data
+
+
+#
+# Webhooks
+#
+
+class WebhookSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
+
+    class Meta:
+        model = Webhook
+        fields = [
+            'id', 'url', 'display', 'name', 'description', 'payload_url', 'http_method', 'http_content_type',
+            'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields',
+            'tags', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 36 - 0
netbox/extras/api/serializers_/exporttemplates.py

@@ -0,0 +1,36 @@
+from rest_framework import serializers
+
+from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer
+from core.models import ObjectType
+from extras.models import ExportTemplate
+from netbox.api.fields import ContentTypeField
+from netbox.api.serializers import ValidatedModelSerializer
+
+__all__ = (
+    'ExportTemplateSerializer',
+)
+
+
+class ExportTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('export_templates'),
+        many=True
+    )
+    data_source = DataSourceSerializer(
+        nested=True,
+        required=False
+    )
+    data_file = DataFileSerializer(
+        nested=True,
+        read_only=True
+    )
+
+    class Meta:
+        model = ExportTemplate
+        fields = [
+            'id', 'url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
+            'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
+            'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 63 - 0
netbox/extras/api/serializers_/journaling.py

@@ -0,0 +1,63 @@
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ObjectDoesNotExist
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from core.models import ObjectType
+from extras.choices import *
+from extras.models import JournalEntry
+from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.serializers import NetBoxModelSerializer
+from utilities.api import get_serializer_for_model
+
+__all__ = (
+    'JournalEntrySerializer',
+)
+
+
+class JournalEntrySerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
+    assigned_object_type = ContentTypeField(
+        queryset=ObjectType.objects.all()
+    )
+    assigned_object = serializers.SerializerMethodField(read_only=True)
+    created_by = serializers.PrimaryKeyRelatedField(
+        allow_null=True,
+        queryset=get_user_model().objects.all(),
+        required=False,
+        default=serializers.CurrentUserDefault()
+    )
+    kind = ChoiceField(
+        choices=JournalEntryKindChoices,
+        required=False
+    )
+
+    class Meta:
+        model = JournalEntry
+        fields = [
+            'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
+            'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'created')
+
+    def validate(self, data):
+
+        # Validate that the parent object exists
+        if 'assigned_object_type' in data and 'assigned_object_id' in data:
+            try:
+                data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
+            except ObjectDoesNotExist:
+                raise serializers.ValidationError(
+                    f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
+                )
+
+        # Enforce model validation
+        super().validate(data)
+
+        return data
+
+    @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())
+        context = {'request': self.context['request']}
+        return serializer(instance.assigned_object, nested=True, context=context).data

+ 26 - 0
netbox/extras/api/serializers_/savedfilters.py

@@ -0,0 +1,26 @@
+from rest_framework import serializers
+
+from core.models import ObjectType
+from extras.models import SavedFilter
+from netbox.api.fields import ContentTypeField
+from netbox.api.serializers import ValidatedModelSerializer
+
+__all__ = (
+    'SavedFilterSerializer',
+)
+
+
+class SavedFilterSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail')
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.all(),
+        many=True
+    )
+
+    class Meta:
+        model = SavedFilter
+        fields = [
+            'id', 'url', 'display', 'object_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
+            'shared', 'parameters', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')

+ 77 - 0
netbox/extras/api/serializers_/scripts.py

@@ -0,0 +1,77 @@
+from django.utils.translation import gettext as _
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from core.api.serializers_.jobs import JobSerializer
+from extras.models import Script
+from netbox.api.serializers import ValidatedModelSerializer
+
+__all__ = (
+    'ScriptDetailSerializer',
+    'ScriptInputSerializer',
+    'ScriptSerializer',
+)
+
+
+class ScriptSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
+    description = serializers.SerializerMethodField(read_only=True)
+    vars = serializers.SerializerMethodField(read_only=True)
+    result = JobSerializer(nested=True, read_only=True)
+
+    class Meta:
+        model = Script
+        fields = [
+            'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_vars(self, obj):
+        if obj.python_class:
+            return {
+                k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
+            }
+        else:
+            return {}
+
+    @extend_schema_field(serializers.CharField())
+    def get_display(self, obj):
+        return f'{obj.name} ({obj.module})'
+
+    @extend_schema_field(serializers.CharField())
+    def get_description(self, obj):
+        if obj.python_class:
+            return obj.python_class().description
+        else:
+            return None
+
+
+class ScriptDetailSerializer(ScriptSerializer):
+    result = serializers.SerializerMethodField(read_only=True)
+
+    @extend_schema_field(JobSerializer())
+    def get_result(self, obj):
+        job = obj.jobs.all().order_by('-created').first()
+        context = {
+            'request': self.context['request']
+        }
+        data = JobSerializer(job, context=context).data
+        return data
+
+
+class ScriptInputSerializer(serializers.Serializer):
+    data = serializers.JSONField()
+    commit = serializers.BooleanField()
+    schedule_at = serializers.DateTimeField(required=False, allow_null=True)
+    interval = serializers.IntegerField(required=False, allow_null=True)
+
+    def validate_schedule_at(self, value):
+        if value and not self.context['script'].scheduling_enabled:
+            raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
+        return value
+
+    def validate_interval(self, value):
+        if value and not self.context['script'].scheduling_enabled:
+            raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
+        return value

+ 30 - 0
netbox/extras/api/serializers_/tags.py

@@ -0,0 +1,30 @@
+from rest_framework import serializers
+
+from core.models import ObjectType
+from extras.models import Tag
+from netbox.api.fields import ContentTypeField, RelatedObjectCountField
+from netbox.api.serializers import ValidatedModelSerializer
+
+__all__ = (
+    'TagSerializer',
+)
+
+
+class TagSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('tags'),
+        many=True,
+        required=False
+    )
+
+    # Related object counts
+    tagged_items = RelatedObjectCountField('extras_taggeditem_items')
+
+    class Meta:
+        model = Tag
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
+            'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')

+ 7 - 509
netbox/ipam/api/serializers.py

@@ -1,510 +1,8 @@
-from django.contrib.contenttypes.models import ContentType
-from drf_spectacular.utils import extend_schema_field
-from rest_framework import serializers
-
-from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
-from ipam.choices import *
-from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
-from ipam.models import *
-from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
-from netbox.api.serializers import NetBoxModelSerializer
-from netbox.constants import NESTED_SERIALIZER_PREFIX
-from tenancy.api.nested_serializers import NestedTenantSerializer
-from utilities.api import get_serializer_for_model
-from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
-from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
-from .field_serializers import IPAddressField, IPNetworkField
+from .serializers_.asns import *
+from .serializers_.vrfs import *
+from .serializers_.roles import *
+from .serializers_.vlans import *
+from .serializers_.ip import *
+from .serializers_.fhrpgroups import *
+from .serializers_.services import *
 from .nested_serializers import *
-
-
-#
-# ASN ranges
-#
-
-class ASNRangeSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asnrange-detail')
-    rir = NestedRIRSerializer()
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    asn_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = ASNRange
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags',
-            'custom_fields', 'created', 'last_updated', 'asn_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-#
-# ASNs
-#
-
-class ASNSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
-    rir = NestedRIRSerializer(required=False, allow_null=True)
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-
-    # Related object counts
-    site_count = RelatedObjectCountField('sites')
-    provider_count = RelatedObjectCountField('providers')
-
-    class Meta:
-        model = ASN
-        fields = [
-            'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated', 'site_count', 'provider_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'asn', 'description')
-
-
-class AvailableASNSerializer(serializers.Serializer):
-    """
-    Representation of an ASN which does not exist in the database.
-    """
-    asn = serializers.IntegerField(read_only=True)
-    description = serializers.CharField(required=False)
-
-    def to_representation(self, asn):
-        rir = NestedRIRSerializer(self.context['range'].rir, context={
-            'request': self.context['request']
-        }).data
-        return {
-            'rir': rir,
-            'asn': asn,
-        }
-
-
-#
-# VRFs
-#
-
-class VRFSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    import_targets = SerializedPKRelatedField(
-        queryset=RouteTarget.objects.all(),
-        serializer=NestedRouteTargetSerializer,
-        required=False,
-        many=True
-    )
-    export_targets = SerializedPKRelatedField(
-        queryset=RouteTarget.objects.all(),
-        serializer=NestedRouteTargetSerializer,
-        required=False,
-        many=True
-    )
-
-    # Related object counts
-    ipaddress_count = RelatedObjectCountField('ip_addresses')
-    prefix_count = RelatedObjectCountField('prefixes')
-
-    class Meta:
-        model = VRF
-        fields = [
-            'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments',
-            'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count',
-            'prefix_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'rd', 'description', 'prefix_count')
-
-
-#
-# Route targets
-#
-
-class RouteTargetSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail')
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-
-    class Meta:
-        model = RouteTarget
-        fields = [
-            'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created',
-            'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-#
-# RIRs/aggregates
-#
-
-class RIRSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
-
-    # Related object counts
-    aggregate_count = RelatedObjectCountField('aggregates')
-
-    class Meta:
-        model = RIR
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created',
-            'last_updated', 'aggregate_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'aggregate_count')
-
-
-class AggregateSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
-    family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
-    rir = NestedRIRSerializer()
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    prefix = IPNetworkField()
-
-    class Meta:
-        model = Aggregate
-        fields = [
-            'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
-            'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description')
-
-
-#
-# FHRP Groups
-#
-
-class FHRPGroupSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail')
-    ip_addresses = NestedIPAddressSerializer(many=True, read_only=True)
-
-    class Meta:
-        model = FHRPGroup
-        fields = [
-            'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments',
-            'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses',
-        ]
-        brief_fields = ('id', 'url', 'display', 'protocol', 'group_id', 'description')
-
-
-class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
-    group = NestedFHRPGroupSerializer()
-    interface_type = ContentTypeField(
-        queryset=ContentType.objects.all()
-    )
-    interface = serializers.SerializerMethodField(read_only=True)
-
-    class Meta:
-        model = FHRPGroupAssignment
-        fields = [
-            'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created',
-            'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority')
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_interface(self, obj):
-        if obj.interface is None:
-            return None
-        serializer = get_serializer_for_model(obj.interface, prefix=NESTED_SERIALIZER_PREFIX)
-        context = {'request': self.context['request']}
-        return serializer(obj.interface, context=context).data
-
-
-#
-# VLANs
-#
-
-class RoleSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
-
-    # Related object counts
-    prefix_count = RelatedObjectCountField('prefixes')
-    vlan_count = RelatedObjectCountField('vlans')
-
-    class Meta:
-        model = Role
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
-            'last_updated', 'prefix_count', 'vlan_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count')
-
-
-class VLANGroupSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
-    scope_type = ContentTypeField(
-        queryset=ContentType.objects.filter(
-            model__in=VLANGROUP_SCOPE_TYPES
-        ),
-        allow_null=True,
-        required=False,
-        default=None
-    )
-    scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
-    scope = serializers.SerializerMethodField(read_only=True)
-    utilization = serializers.CharField(read_only=True)
-
-    # Related object counts
-    vlan_count = RelatedObjectCountField('vlans')
-
-    class Meta:
-        model = VLANGroup
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
-            'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
-        validators = []
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_scope(self, obj):
-        if obj.scope_id is None:
-            return None
-        serializer = get_serializer_for_model(obj.scope, prefix=NESTED_SERIALIZER_PREFIX)
-        context = {'request': self.context['request']}
-
-        return serializer(obj.scope, context=context).data
-
-
-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)
-    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, allow_null=True)
-
-    # Related object counts
-    prefix_count = RelatedObjectCountField('prefixes')
-
-    class Meta:
-        model = VLAN
-        fields = [
-            'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description',
-            'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')
-
-
-class AvailableVLANSerializer(serializers.Serializer):
-    """
-    Representation of a VLAN which does not exist in the database.
-    """
-    vid = serializers.IntegerField(read_only=True)
-    group = NestedVLANGroupSerializer(read_only=True)
-
-    def to_representation(self, instance):
-        return {
-            'vid': instance,
-            'group': NestedVLANGroupSerializer(
-                self.context['group'],
-                context={'request': self.context['request']}
-            ).data,
-        }
-
-
-class CreateAvailableVLANSerializer(NetBoxModelSerializer):
-    site = NestedSiteSerializer(required=False, allow_null=True)
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    status = ChoiceField(choices=VLANStatusChoices, required=False)
-    role = NestedRoleSerializer(required=False, allow_null=True)
-
-    class Meta:
-        model = VLAN
-        fields = [
-            'name', 'site', 'tenant', 'status', 'role', 'description', 'tags', 'custom_fields',
-        ]
-
-    def validate(self, data):
-        # Bypass model validation since we don't have a VID yet
-        return data
-
-
-#
-# Prefixes
-#
-
-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)
-    vrf = NestedVRFSerializer(required=False, allow_null=True)
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    vlan = NestedVLANSerializer(required=False, allow_null=True)
-    status = ChoiceField(choices=PrefixStatusChoices, required=False)
-    role = NestedRoleSerializer(required=False, allow_null=True)
-    children = serializers.IntegerField(read_only=True)
-    _depth = serializers.IntegerField(read_only=True)
-    prefix = IPNetworkField()
-
-    class Meta:
-        model = Prefix
-        fields = [
-            'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool',
-            'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
-            '_depth',
-        ]
-        brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
-
-
-class PrefixLengthSerializer(serializers.Serializer):
-
-    prefix_length = serializers.IntegerField()
-
-    def to_internal_value(self, data):
-        requested_prefix = data.get('prefix_length')
-        if requested_prefix is None:
-            raise serializers.ValidationError({
-                'prefix_length': 'this field can not be missing'
-            })
-        if not isinstance(requested_prefix, int):
-            raise serializers.ValidationError({
-                'prefix_length': 'this field must be int type'
-            })
-
-        prefix = self.context.get('prefix')
-        if prefix.family == 4 and requested_prefix > 32:
-            raise serializers.ValidationError({
-                'prefix_length': 'Invalid prefix length ({}) for IPv4'.format((requested_prefix))
-            })
-        elif prefix.family == 6 and requested_prefix > 128:
-            raise serializers.ValidationError({
-                'prefix_length': 'Invalid prefix length ({}) for IPv6'.format((requested_prefix))
-            })
-        return data
-
-
-class AvailablePrefixSerializer(serializers.Serializer):
-    """
-    Representation of a prefix which does not exist in the database.
-    """
-    family = serializers.IntegerField(read_only=True)
-    prefix = serializers.CharField(read_only=True)
-    vrf = NestedVRFSerializer(read_only=True)
-
-    def to_representation(self, instance):
-        if self.context.get('vrf'):
-            vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
-        else:
-            vrf = None
-        return {
-            'family': instance.version,
-            'prefix': str(instance),
-            'vrf': vrf,
-        }
-
-
-#
-# IP ranges
-#
-
-class IPRangeSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:iprange-detail')
-    family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
-    start_address = IPAddressField()
-    end_address = IPAddressField()
-    vrf = NestedVRFSerializer(required=False, allow_null=True)
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    status = ChoiceField(choices=IPRangeStatusChoices, required=False)
-    role = NestedRoleSerializer(required=False, allow_null=True)
-
-    class Meta:
-        model = IPRange
-        fields = [
-            'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role',
-            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-            'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description')
-
-
-#
-# IP addresses
-#
-
-class IPAddressSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
-    family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
-    address = IPAddressField()
-    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)
-    assigned_object_type = ContentTypeField(
-        queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
-        required=False,
-        allow_null=True
-    )
-    assigned_object = serializers.SerializerMethodField(read_only=True)
-    nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
-    nat_outside = NestedIPAddressSerializer(many=True, read_only=True)
-
-    class Meta:
-        model = IPAddress
-        fields = [
-            'id', 'url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type',
-            'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments',
-            'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'family', 'address', 'description')
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_assigned_object(self, obj):
-        if obj.assigned_object is None:
-            return None
-        serializer = get_serializer_for_model(obj.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
-        context = {'request': self.context['request']}
-        return serializer(obj.assigned_object, context=context).data
-
-
-class AvailableIPSerializer(serializers.Serializer):
-    """
-    Representation of an IP address which does not exist in the database.
-    """
-    family = serializers.IntegerField(read_only=True)
-    address = serializers.CharField(read_only=True)
-    vrf = NestedVRFSerializer(read_only=True)
-    description = serializers.CharField(required=False)
-
-    def to_representation(self, instance):
-        if self.context.get('vrf'):
-            vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
-        else:
-            vrf = None
-        return {
-            'family': self.context['parent'].family,
-            'address': f"{instance}/{self.context['parent'].mask_length}",
-            'vrf': vrf,
-        }
-
-
-#
-# Services
-#
-
-class ServiceTemplateSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
-    protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
-
-    class Meta:
-        model = ServiceTemplate
-        fields = [
-            'id', 'url', 'display', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')
-
-
-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)
-    protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
-    ipaddresses = SerializedPKRelatedField(
-        queryset=IPAddress.objects.all(),
-        serializer=NestedIPAddressSerializer,
-        required=False,
-        many=True
-    )
-
-    class Meta:
-        model = Service
-        fields = [
-            'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses',
-            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')

+ 0 - 0
netbox/ipam/api/serializers_/__init__.py


+ 78 - 0
netbox/ipam/api/serializers_/asns.py

@@ -0,0 +1,78 @@
+from rest_framework import serializers
+
+from ipam.models import ASN, ASNRange, RIR
+from netbox.api.fields import RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+
+__all__ = (
+    'ASNRangeSerializer',
+    'ASNSerializer',
+    'AvailableASNSerializer',
+    'RIRSerializer',
+)
+
+
+class RIRSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
+
+    # Related object counts
+    aggregate_count = RelatedObjectCountField('aggregates')
+
+    class Meta:
+        model = RIR
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'aggregate_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'aggregate_count')
+
+
+class ASNRangeSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asnrange-detail')
+    rir = RIRSerializer(nested=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    asn_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = ASNRange
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated', 'asn_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class ASNSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
+    rir = RIRSerializer(nested=True, required=False, allow_null=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+
+    # Related object counts
+    site_count = RelatedObjectCountField('sites')
+    provider_count = RelatedObjectCountField('providers')
+
+    class Meta:
+        model = ASN
+        fields = [
+            'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated', 'site_count', 'provider_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'asn', 'description')
+
+
+class AvailableASNSerializer(serializers.Serializer):
+    """
+    Representation of an ASN which does not exist in the database.
+    """
+    asn = serializers.IntegerField(read_only=True)
+    description = serializers.CharField(required=False)
+
+    def to_representation(self, asn):
+        rir = RIRSerializer(self.context['range'].rir, nested=True, context={
+            'request': self.context['request']
+        }).data
+        return {
+            'rir': rir,
+            'asn': asn,
+        }

+ 52 - 0
netbox/ipam/api/serializers_/fhrpgroups.py

@@ -0,0 +1,52 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from ipam.models import FHRPGroup, FHRPGroupAssignment
+from netbox.api.fields import ContentTypeField
+from netbox.api.serializers import NetBoxModelSerializer
+from utilities.api import get_serializer_for_model
+from .ip import IPAddressSerializer
+
+__all__ = (
+    'FHRPGroupAssignmentSerializer',
+    'FHRPGroupSerializer',
+)
+
+
+class FHRPGroupSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail')
+    ip_addresses = IPAddressSerializer(nested=True, many=True, read_only=True)
+
+    class Meta:
+        model = FHRPGroup
+        fields = [
+            'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments',
+            'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses',
+        ]
+        brief_fields = ('id', 'url', 'display', 'protocol', 'group_id', 'description')
+
+
+class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
+    group = FHRPGroupSerializer(nested=True)
+    interface_type = ContentTypeField(
+        queryset=ContentType.objects.all()
+    )
+    interface = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = FHRPGroupAssignment
+        fields = [
+            'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created',
+            'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_interface(self, obj):
+        if obj.interface is None:
+            return None
+        serializer = get_serializer_for_model(obj.interface)
+        context = {'request': self.context['request']}
+        return serializer(obj.interface, nested=True, context=context).data

+ 198 - 0
netbox/ipam/api/serializers_/ip.py

@@ -0,0 +1,198 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from dcim.api.serializers_.sites import SiteSerializer
+from ipam.choices import *
+from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
+from ipam.models import Aggregate, IPAddress, IPRange, Prefix
+from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.serializers import NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+from utilities.api import get_serializer_for_model
+from .asns import RIRSerializer
+from .roles import RoleSerializer
+from .vlans import VLANSerializer
+from .vrfs import VRFSerializer
+from ..field_serializers import IPAddressField, IPNetworkField
+from ..nested_serializers import *
+
+__all__ = (
+    'AggregateSerializer',
+    'AvailableIPSerializer',
+    'AvailablePrefixSerializer',
+    'IPAddressSerializer',
+    'IPRangeSerializer',
+    'PrefixLengthSerializer',
+    'PrefixSerializer',
+)
+
+
+class AggregateSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
+    family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
+    rir = RIRSerializer(nested=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    prefix = IPNetworkField()
+
+    class Meta:
+        model = Aggregate
+        fields = [
+            'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
+            'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description')
+
+
+class PrefixSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
+    family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
+    site = SiteSerializer(nested=True, required=False, allow_null=True)
+    vrf = VRFSerializer(nested=True, required=False, allow_null=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    vlan = VLANSerializer(nested=True, required=False, allow_null=True)
+    status = ChoiceField(choices=PrefixStatusChoices, required=False)
+    role = RoleSerializer(nested=True, required=False, allow_null=True)
+    children = serializers.IntegerField(read_only=True)
+    _depth = serializers.IntegerField(read_only=True)
+    prefix = IPNetworkField()
+
+    class Meta:
+        model = Prefix
+        fields = [
+            'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool',
+            'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
+            '_depth',
+        ]
+        brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
+
+
+class PrefixLengthSerializer(serializers.Serializer):
+
+    prefix_length = serializers.IntegerField()
+
+    def to_internal_value(self, data):
+        requested_prefix = data.get('prefix_length')
+        if requested_prefix is None:
+            raise serializers.ValidationError({
+                'prefix_length': 'this field can not be missing'
+            })
+        if not isinstance(requested_prefix, int):
+            raise serializers.ValidationError({
+                'prefix_length': 'this field must be int type'
+            })
+
+        prefix = self.context.get('prefix')
+        if prefix.family == 4 and requested_prefix > 32:
+            raise serializers.ValidationError({
+                'prefix_length': 'Invalid prefix length ({}) for IPv4'.format(requested_prefix)
+            })
+        elif prefix.family == 6 and requested_prefix > 128:
+            raise serializers.ValidationError({
+                'prefix_length': 'Invalid prefix length ({}) for IPv6'.format(requested_prefix)
+            })
+        return data
+
+
+class AvailablePrefixSerializer(serializers.Serializer):
+    """
+    Representation of a prefix which does not exist in the database.
+    """
+    family = serializers.IntegerField(read_only=True)
+    prefix = serializers.CharField(read_only=True)
+    vrf = VRFSerializer(nested=True, read_only=True)
+
+    def to_representation(self, instance):
+        if self.context.get('vrf'):
+            vrf = VRFSerializer(self.context['vrf'], nested=True, context={'request': self.context['request']}).data
+        else:
+            vrf = None
+        return {
+            'family': instance.version,
+            'prefix': str(instance),
+            'vrf': vrf,
+        }
+
+
+#
+# IP ranges
+#
+
+class IPRangeSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:iprange-detail')
+    family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
+    start_address = IPAddressField()
+    end_address = IPAddressField()
+    vrf = VRFSerializer(nested=True, required=False, allow_null=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    status = ChoiceField(choices=IPRangeStatusChoices, required=False)
+    role = RoleSerializer(nested=True, required=False, allow_null=True)
+
+    class Meta:
+        model = IPRange
+        fields = [
+            'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description')
+
+
+#
+# IP addresses
+#
+
+class IPAddressSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
+    family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
+    address = IPAddressField()
+    vrf = VRFSerializer(nested=True, required=False, allow_null=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    status = ChoiceField(choices=IPAddressStatusChoices, required=False)
+    role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
+    assigned_object_type = ContentTypeField(
+        queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
+        required=False,
+        allow_null=True
+    )
+    assigned_object = serializers.SerializerMethodField(read_only=True)
+    nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
+    nat_outside = NestedIPAddressSerializer(many=True, read_only=True)
+
+    class Meta:
+        model = IPAddress
+        fields = [
+            'id', 'url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type',
+            'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments',
+            'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'family', 'address', 'description')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_assigned_object(self, obj):
+        if obj.assigned_object is None:
+            return None
+        serializer = get_serializer_for_model(obj.assigned_object)
+        context = {'request': self.context['request']}
+        return serializer(obj.assigned_object, nested=True, context=context).data
+
+
+class AvailableIPSerializer(serializers.Serializer):
+    """
+    Representation of an IP address which does not exist in the database.
+    """
+    family = serializers.IntegerField(read_only=True)
+    address = serializers.CharField(read_only=True)
+    vrf = VRFSerializer(nested=True, read_only=True)
+    description = serializers.CharField(required=False)
+
+    def to_representation(self, instance):
+        if self.context.get('vrf'):
+            vrf = VRFSerializer(self.context['vrf'], nested=True, context={'request': self.context['request']}).data
+        else:
+            vrf = None
+        return {
+            'family': self.context['parent'].family,
+            'address': f"{instance}/{self.context['parent'].mask_length}",
+            'vrf': vrf,
+        }

+ 25 - 0
netbox/ipam/api/serializers_/roles.py

@@ -0,0 +1,25 @@
+from rest_framework import serializers
+
+from ipam.models import Role
+from netbox.api.fields import RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+
+__all__ = (
+    'RoleSerializer',
+)
+
+
+class RoleSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
+
+    # Related object counts
+    prefix_count = RelatedObjectCountField('prefixes')
+    vlan_count = RelatedObjectCountField('vlans')
+
+    class Meta:
+        model = Role
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'prefix_count', 'vlan_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count')

+ 49 - 0
netbox/ipam/api/serializers_/services.py

@@ -0,0 +1,49 @@
+from rest_framework import serializers
+
+from dcim.api.serializers_.devices import DeviceSerializer
+from ipam.choices import *
+from ipam.models import IPAddress, Service, ServiceTemplate
+from netbox.api.fields import ChoiceField, SerializedPKRelatedField
+from netbox.api.serializers import NetBoxModelSerializer
+from virtualization.api.serializers_.virtualmachines import VirtualMachineSerializer
+from .ip import IPAddressSerializer
+
+__all__ = (
+    'ServiceSerializer',
+    'ServiceTemplateSerializer',
+)
+
+
+class ServiceTemplateSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
+    protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
+
+    class Meta:
+        model = ServiceTemplate
+        fields = [
+            'id', 'url', 'display', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')
+
+
+class ServiceSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
+    device = DeviceSerializer(nested=True, required=False, allow_null=True)
+    virtual_machine = VirtualMachineSerializer(nested=True, required=False, allow_null=True)
+    protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
+    ipaddresses = SerializedPKRelatedField(
+        queryset=IPAddress.objects.all(),
+        serializer=IPAddressSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+
+    class Meta:
+        model = Service
+        fields = [
+            'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')

+ 112 - 0
netbox/ipam/api/serializers_/vlans.py

@@ -0,0 +1,112 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from dcim.api.serializers_.sites import SiteSerializer
+from ipam.choices import *
+from ipam.constants import VLANGROUP_SCOPE_TYPES
+from ipam.models import VLAN, VLANGroup
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+from utilities.api import get_serializer_for_model
+from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
+from .roles import RoleSerializer
+
+__all__ = (
+    'AvailableVLANSerializer',
+    'CreateAvailableVLANSerializer',
+    'VLANGroupSerializer',
+    'VLANSerializer',
+)
+
+
+class VLANGroupSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
+    scope_type = ContentTypeField(
+        queryset=ContentType.objects.filter(
+            model__in=VLANGROUP_SCOPE_TYPES
+        ),
+        allow_null=True,
+        required=False,
+        default=None
+    )
+    scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
+    scope = serializers.SerializerMethodField(read_only=True)
+    utilization = serializers.CharField(read_only=True)
+
+    # Related object counts
+    vlan_count = RelatedObjectCountField('vlans')
+
+    class Meta:
+        model = VLANGroup
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
+            'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
+        validators = []
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_scope(self, obj):
+        if obj.scope_id is None:
+            return None
+        serializer = get_serializer_for_model(obj.scope)
+        context = {'request': self.context['request']}
+        return serializer(obj.scope, nested=True, context=context).data
+
+
+class VLANSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
+    site = SiteSerializer(nested=True, required=False, allow_null=True)
+    group = VLANGroupSerializer(nested=True, required=False, allow_null=True, default=None)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    status = ChoiceField(choices=VLANStatusChoices, required=False)
+    role = RoleSerializer(nested=True, required=False, allow_null=True)
+    l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
+
+    # Related object counts
+    prefix_count = RelatedObjectCountField('prefixes')
+
+    class Meta:
+        model = VLAN
+        fields = [
+            'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description',
+            'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')
+
+
+class AvailableVLANSerializer(serializers.Serializer):
+    """
+    Representation of a VLAN which does not exist in the database.
+    """
+    vid = serializers.IntegerField(read_only=True)
+    group = VLANGroupSerializer(nested=True, read_only=True)
+
+    def to_representation(self, instance):
+        return {
+            'vid': instance,
+            'group': VLANGroupSerializer(
+                self.context['group'],
+                nested=True,
+                context={'request': self.context['request']}
+            ).data,
+        }
+
+
+class CreateAvailableVLANSerializer(NetBoxModelSerializer):
+    site = SiteSerializer(nested=True, required=False, allow_null=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    status = ChoiceField(choices=VLANStatusChoices, required=False)
+    role = RoleSerializer(nested=True, required=False, allow_null=True)
+
+    class Meta:
+        model = VLAN
+        fields = [
+            'name', 'site', 'tenant', 'status', 'role', 'description', 'tags', 'custom_fields',
+        ]
+
+    def validate(self, data):
+        # Bypass model validation since we don't have a VID yet
+        return data

+ 54 - 0
netbox/ipam/api/serializers_/vrfs.py

@@ -0,0 +1,54 @@
+from rest_framework import serializers
+
+from ipam.models import RouteTarget, VRF
+from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
+from netbox.api.serializers import NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+
+__all__ = (
+    'RouteTargetSerializer',
+    'VRFSerializer',
+)
+
+
+class RouteTargetSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail')
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+
+    class Meta:
+        model = RouteTarget
+        fields = [
+            'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class VRFSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    import_targets = SerializedPKRelatedField(
+        queryset=RouteTarget.objects.all(),
+        serializer=RouteTargetSerializer,
+        required=False,
+        many=True
+    )
+    export_targets = SerializedPKRelatedField(
+        queryset=RouteTarget.objects.all(),
+        serializer=RouteTargetSerializer,
+        required=False,
+        many=True
+    )
+
+    # Related object counts
+    ipaddress_count = RelatedObjectCountField('ip_addresses')
+    prefix_count = RelatedObjectCountField('prefixes')
+
+    class Meta:
+        model = VRF
+        fields = [
+            'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments',
+            'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count',
+            'prefix_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'rd', 'description', 'prefix_count')

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

@@ -132,13 +132,15 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField):
     Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
     objects in a ManyToManyField while still allowing a set of primary keys to be written.
     """
-    def __init__(self, serializer, **kwargs):
+    def __init__(self, serializer, nested=False, **kwargs):
         self.serializer = serializer
+        self.nested = nested
         self.pk_field = kwargs.pop('pk_field', None)
+
         super().__init__(**kwargs)
 
     def to_representation(self, value):
-        return self.serializer(value, context={'request': self.context['request']}).data
+        return self.serializer(value, nested=self.nested, context={'request': self.context['request']}).data
 
 
 @extend_schema_field(OpenApiTypes.INT64)

+ 50 - 7
netbox/netbox/api/serializers/base.py

@@ -1,8 +1,12 @@
-from django.db.models import ManyToManyField
+from functools import cached_property
+
 from rest_framework import serializers
+from rest_framework.utils.serializer_helpers import BindingDict
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 
+from utilities.api import get_related_object_by_attrs
+
 __all__ = (
     'BaseModelSerializer',
     'ValidatedModelSerializer',
@@ -12,14 +16,48 @@ __all__ = (
 class BaseModelSerializer(serializers.ModelSerializer):
     display = serializers.SerializerMethodField(read_only=True)
 
-    def __init__(self, *args, requested_fields=None, **kwargs):
+    def __init__(self, *args, nested=False, fields=None, **kwargs):
+        """
+        Extends the base __init__() method to support dynamic fields.
+
+        :param nested: Set to True if this serializer is being employed within a parent serializer
+        :param fields: An iterable of fields to include when rendering the serialized object, If nested is
+            True but no fields are specified, Meta.brief_fields will be used.
+        """
+        self.nested = nested
+        self._requested_fields = fields
+
+        # If this serializer is nested but no fields have been specified,
+        # default to using Meta.brief_fields (if set)
+        if nested and not fields:
+            self._requested_fields = getattr(self.Meta, 'brief_fields', None)
+
         super().__init__(*args, **kwargs)
 
-        # If specific fields have been requested, omit the others
-        if requested_fields:
-            for field in list(self.fields.keys()):
-                if field not in requested_fields:
-                    self.fields.pop(field)
+    def to_internal_value(self, data):
+
+        # If initialized as a nested serializer, we should expect to receive the attrs or PK
+        # identifying a related object.
+        if self.nested:
+            queryset = self.Meta.model.objects.all()
+            return get_related_object_by_attrs(queryset, data)
+
+        return super().to_internal_value(data)
+
+    @cached_property
+    def fields(self):
+        """
+        Override the fields property to check for requested fields. If defined,
+        return only the applicable fields.
+        """
+        if not self._requested_fields:
+            return super().fields
+
+        fields = BindingDict(self)
+        for key, value in self.get_fields().items():
+            if key in self._requested_fields:
+                fields[key] = value
+        return fields
 
     @extend_schema_field(OpenApiTypes.STR)
     def get_display(self, obj):
@@ -32,6 +70,11 @@ class ValidatedModelSerializer(BaseModelSerializer):
     validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
     """
     def validate(self, data):
+
+        # Skip validation if we're being used to represent a nested object
+        if self.nested:
+            return data
+
         attrs = data.copy()
 
         # Remove custom field data (if any) prior to model validation

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

@@ -3,7 +3,6 @@ from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from netbox.api.fields import ContentTypeField
-from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
 from utilities.utils import content_type_identifier
 
@@ -40,6 +39,5 @@ class GenericObjectSerializer(serializers.Serializer):
 
     @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']}
-        return serializer(obj, context=self.context).data
+        serializer = get_serializer_for_model(obj)
+        return serializer(obj, nested=True, context=self.context).data

+ 3 - 41
netbox/netbox/api/serializers/nested.py

@@ -1,10 +1,7 @@
-from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
-from django.utils.translation import gettext_lazy as _
 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 utilities.api import get_related_object_by_attrs
 from .base import BaseModelSerializer
 
 __all__ = (
@@ -20,43 +17,8 @@ class WritableNestedSerializer(BaseModelSerializer):
     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(
-                    _("Related object not found using the provided attributes: {params}").format(params=params))
-            except MultipleObjectsReturned:
-                raise ValidationError(
-                    _("Multiple objects match the provided attributes: {params}").format(params=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(
-                _(
-                    "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
-                    "unrecognized value: {value}"
-                ).format(value=data)
-            )
-
-        # Look up object by PK
-        try:
-            return self.Meta.model.objects.get(pk=pk)
-        except ObjectDoesNotExist:
-            raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk))
+        queryset = self.Meta.model.objects.all()
+        return get_related_object_by_attrs(queryset, data)
 
 
 # Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers

+ 1 - 1
netbox/netbox/api/viewsets/__init__.py

@@ -69,7 +69,7 @@ class BaseViewSet(GenericViewSet):
 
         # If specific fields have been requested, pass them to the serializer
         if self.requested_fields:
-            kwargs['requested_fields'] = self.requested_fields
+            kwargs['fields'] = self.requested_fields
 
         return super().get_serializer(*args, **kwargs)
 

+ 1 - 0
netbox/netbox/constants.py

@@ -1,4 +1,5 @@
 # Prefix for nested serializers
+# TODO: Remove in v4.1
 NESTED_SERIALIZER_PREFIX = 'Nested'
 
 # RQ queue names

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js.map


BIN
netbox/project-static/img/tint_20.png


+ 6 - 5
netbox/project-static/src/objectSelector.ts

@@ -18,11 +18,12 @@ function handleSelection(link: HTMLAnchorElement): void {
   const value = link.getAttribute('data-value');
 
   //@ts-ignore
-  target.slim.setData([
-    {text: label, value: value}
-  ]);
-  const change = new Event('change');
-  target.dispatchEvent(change);
+  target.tomselect.addOption({
+    id: value,
+    display: label,
+  });
+  //@ts-ignore
+  target.tomselect.addItem(value);
 
 }
 

+ 1 - 1
netbox/project-static/src/select/dynamic.ts

@@ -42,7 +42,7 @@ function renderItem(data: TomOption, escape: typeof escape_html) {
 
 // Initialize <select> elements which are populated via a REST API call
 export function initDynamicSelects(): void {
-  for (const select of getElements<HTMLSelectElement>('select.api-select')) {
+  for (const select of getElements<HTMLSelectElement>('select.api-select:not(.tomselected)')) {
     new DynamicTomSelect(select, {
       ...config,
       valueField: VALUE_FIELD,

+ 2 - 2
netbox/project-static/src/select/static.ts

@@ -7,7 +7,7 @@ import { getElements } from '../util';
 // Initialize <select> elements with statically-defined options
 export function initStaticSelects(): void {
   for (const select of getElements<HTMLSelectElement>(
-    'select:not(.api-select):not(.color-select)',
+    'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)',
   )) {
     new TomSelect(select, {
       ...config,
@@ -24,7 +24,7 @@ export function initColorSelects(): void {
     )}"></span> ${escape(item.text)}</div>`;
   }
 
-  for (const select of getElements<HTMLSelectElement>('select.color-select')) {
+  for (const select of getElements<HTMLSelectElement>('select.color-select:not(.tomselected)')) {
     new TomSelect(select, {
       ...config,
       maxOptions: undefined,

+ 0 - 23
netbox/project-static/src/util.ts

@@ -244,29 +244,6 @@ export function getSelectedOptions<E extends HTMLElement>(
   return selected;
 }
 
-/**
- * Get data that can only be accessed via Django context, and is thus already rendered in the HTML
- * template.
- *
- * @see Templates requiring Django context data have a `{% block data %}` block.
- *
- * @param key Property name, which must exist on the HTML element. If not already prefixed with
- *            `data-`, `data-` will be prepended to the property.
- * @returns Value if it exists, `null` if not.
- */
-export function getNetboxData(key: string): string | null {
-  if (!key.startsWith('data-')) {
-    key = `data-${key}`;
-  }
-  for (const element of getElements('body > div#netbox-data > *')) {
-    const value = element.getAttribute(key);
-    if (isTruthy(value)) {
-      return value;
-    }
-  }
-  return null;
-}
-
 /**
  * Toggle visibility of an element.
  */

+ 7 - 0
netbox/project-static/styles/custom/_markdown.scss

@@ -28,6 +28,13 @@
 
 }
 
+// Remove the bottom margin of <p> elements inside a table cell
+td > .rendered-markdown {
+  p:last-of-type {
+    margin-bottom: 0;
+  }
+}
+
 // Markdown preview
 .markdown-widget {
   .preview {

+ 1 - 1
netbox/project-static/styles/custom/_misc.scss

@@ -2,7 +2,7 @@
 
 // Color labels
 span.color-label {
-  display: block;
+  display: inline-block;
   width: 5rem;
   height: 1rem;
   padding: $badge-padding-y $badge-padding-x;

+ 4 - 0
netbox/project-static/styles/overrides/_tabler.scss

@@ -9,6 +9,10 @@ pre {
   // Tabler sets display: flex
   display: inline-block;
 }
+.btn-sm {
+  // $border-radius-sm (2px) is too small
+  border-radius: $border-radius;
+}
 
 // Tabs
 .nav-tabs {

+ 0 - 1
netbox/project-static/styles/transitional/_tables.scss

@@ -23,7 +23,6 @@ table.attr-table {
 
   // Restyle row header
   th {
-    color: $gray-700;
     font-weight: normal;
     width: min-content;
   }

+ 0 - 5
netbox/templates/base/base.html

@@ -70,10 +70,5 @@
     {# User messages #}
     {% include 'inc/messages.html' %}
 
-    {# Data container #}
-    <div id="netbox-data" style="display: none!important; visibility: hidden!important">
-      {% block data %}{% endblock %}
-    </div>
-
   </body>
 </html>

+ 1 - 1
netbox/templates/dcim/rack.html

@@ -163,7 +163,7 @@
 	  </div>
     <div class="col col-12 col-xl-7">
       <div class="text-end mb-4">
-        <select class="btn btn-outline-dark rack-view">
+        <select class="btn btn-outline-secondary no-ts rack-view">
           <option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
           <option value="images-only">{% trans "Images only" %}</option>
           <option value="labels-only">{% trans "Labels only" %}</option>

+ 5 - 7
netbox/templates/dcim/rack_elevation_list.html

@@ -11,13 +11,11 @@
       <a href="{% url 'dcim:rack_list' %}{% querystring request %}" class="btn btn-primary">
         <i class="mdi mdi-format-list-checkbox"></i> {% trans "View List" %}
       </a>
-      <div class="btn-group" role="group">
-        <select class="btn btn-outline-secondary rack-view">
-          <option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
-          <option value="images-only">{% trans "Images only" %}</option>
-          <option value="labels-only">{% trans "Labels only" %}</option>
-        </select>
-      </div>
+      <select class="btn btn-outline-secondary no-ts rack-view">
+        <option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
+        <option value="images-only">{% trans "Images only" %}</option>
+        <option value="labels-only">{% trans "Labels only" %}</option>
+      </select>
       <div class="btn-group" role="group">
         <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">{% trans "Front" %}</a>
         <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">{% trans "Rear" %}</a>

+ 1 - 1
netbox/templates/inc/toast.html

@@ -1,6 +1,6 @@
 {% load helpers %}
 
-<div class="toast shadow-sm" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="10000">
+<div class="toast toast-dark border-0 shadow-sm" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="10000">
   <div class="toast-header text-bg-{{ status }}">
     <i class="mdi mdi-{{ status|icon_from_status }} me-1"></i>
     {{ title }}

+ 2 - 122
netbox/tenancy/api/serializers.py

@@ -1,123 +1,3 @@
-from django.contrib.auth.models import ContentType
-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, RelatedObjectCountField
-from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
-from netbox.constants import NESTED_SERIALIZER_PREFIX
-from tenancy.choices import ContactPriorityChoices
-from tenancy.models import *
-from utilities.api import get_serializer_for_model
+from .serializers_.tenants import *
+from .serializers_.contacts import *
 from .nested_serializers import *
-
-
-#
-# Tenants
-#
-
-class TenantGroupSerializer(NestedGroupModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
-    parent = NestedTenantGroupSerializer(required=False, allow_null=True)
-    tenant_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = TenantGroup
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
-            'last_updated', 'tenant_count', '_depth',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tenant_count', '_depth')
-
-
-class TenantSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
-    group = NestedTenantGroupSerializer(required=False, allow_null=True)
-
-    # Related object counts
-    circuit_count = RelatedObjectCountField('circuits')
-    device_count = RelatedObjectCountField('devices')
-    rack_count = RelatedObjectCountField('racks')
-    site_count = RelatedObjectCountField('sites')
-    ipaddress_count = RelatedObjectCountField('ip_addresses')
-    prefix_count = RelatedObjectCountField('prefixes')
-    vlan_count = RelatedObjectCountField('vlans')
-    vrf_count = RelatedObjectCountField('vrfs')
-    virtualmachine_count = RelatedObjectCountField('virtual_machines')
-    cluster_count = RelatedObjectCountField('clusters')
-
-    class Meta:
-        model = Tenant
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count',
-            'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
-
-
-#
-# Contacts
-#
-
-class ContactGroupSerializer(NestedGroupModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
-    parent = NestedContactGroupSerializer(required=False, allow_null=True, default=None)
-    contact_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = ContactGroup
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
-            'last_updated', 'contact_count', '_depth',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'contact_count', '_depth')
-
-
-class ContactRoleSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
-
-    class Meta:
-        model = ContactRole
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
-
-
-class ContactSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail')
-    group = NestedContactGroupSerializer(required=False, allow_null=True, default=None)
-
-    class Meta:
-        model = Contact
-        fields = [
-            'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description',
-            'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class ContactAssignmentSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
-    object_type = ContentTypeField(
-        queryset=ContentType.objects.all()
-    )
-    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=lambda: '')
-
-    class Meta:
-        model = ContactAssignment
-        fields = [
-            'id', 'url', 'display', 'object_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags',
-            'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'contact', 'role', 'priority')
-
-    @extend_schema_field(OpenApiTypes.OBJECT)
-    def get_object(self, instance):
-        serializer = get_serializer_for_model(instance.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
-        context = {'request': self.context['request']}
-        return serializer(instance.object, context=context).data

+ 0 - 0
netbox/tenancy/api/serializers_/__init__.py


+ 81 - 0
netbox/tenancy/api/serializers_/contacts.py

@@ -0,0 +1,81 @@
+from django.contrib.auth.models import ContentType
+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
+from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
+from tenancy.choices import ContactPriorityChoices
+from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole
+from utilities.api import get_serializer_for_model
+from ..nested_serializers import *
+
+__all__ = (
+    'ContactAssignmentSerializer',
+    'ContactGroupSerializer',
+    'ContactRoleSerializer',
+    'ContactSerializer',
+)
+
+
+class ContactGroupSerializer(NestedGroupModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
+    parent = NestedContactGroupSerializer(required=False, allow_null=True, default=None)
+    contact_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = ContactGroup
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'contact_count', '_depth',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'contact_count', '_depth')
+
+
+class ContactRoleSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
+
+    class Meta:
+        model = ContactRole
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
+
+
+class ContactSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail')
+    group = ContactGroupSerializer(nested=True, required=False, allow_null=True, default=None)
+
+    class Meta:
+        model = Contact
+        fields = [
+            'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description',
+            'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class ContactAssignmentSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
+    object_type = ContentTypeField(
+        queryset=ContentType.objects.all()
+    )
+    object = serializers.SerializerMethodField(read_only=True)
+    contact = ContactSerializer(nested=True)
+    role = ContactRoleSerializer(nested=True, required=False, allow_null=True)
+    priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default=lambda: '')
+
+    class Meta:
+        model = ContactAssignment
+        fields = [
+            'id', 'url', 'display', 'object_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'contact', 'role', 'priority')
+
+    @extend_schema_field(OpenApiTypes.OBJECT)
+    def get_object(self, instance):
+        serializer = get_serializer_for_model(instance.object_type.model_class())
+        context = {'request': self.context['request']}
+        return serializer(instance.object, nested=True, context=context).data

+ 51 - 0
netbox/tenancy/api/serializers_/tenants.py

@@ -0,0 +1,51 @@
+from rest_framework import serializers
+
+from netbox.api.fields import RelatedObjectCountField
+from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
+from tenancy.models import Tenant, TenantGroup
+from ..nested_serializers import *
+
+__all__ = (
+    'TenantGroupSerializer',
+    'TenantSerializer',
+)
+
+
+class TenantGroupSerializer(NestedGroupModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
+    parent = NestedTenantGroupSerializer(required=False, allow_null=True)
+    tenant_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = TenantGroup
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'tenant_count', '_depth',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tenant_count', '_depth')
+
+
+class TenantSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
+    group = TenantGroupSerializer(nested=True, required=False, allow_null=True)
+
+    # Related object counts
+    circuit_count = RelatedObjectCountField('circuits')
+    device_count = RelatedObjectCountField('devices')
+    rack_count = RelatedObjectCountField('racks')
+    site_count = RelatedObjectCountField('sites')
+    ipaddress_count = RelatedObjectCountField('ip_addresses')
+    prefix_count = RelatedObjectCountField('prefixes')
+    vlan_count = RelatedObjectCountField('vlans')
+    vrf_count = RelatedObjectCountField('vrfs')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
+    cluster_count = RelatedObjectCountField('clusters')
+
+    class Meta:
+        model = Tenant
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count',
+            'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')

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

@@ -1,188 +1,4 @@
-from django.conf import settings
-from django.contrib.auth import authenticate
-from django.contrib.auth import get_user_model
-from drf_spectacular.utils import extend_schema_field
-from drf_spectacular.types import OpenApiTypes
-from rest_framework import serializers
-from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
-
-from core.models import ObjectType
-from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
-from netbox.api.serializers import ValidatedModelSerializer
-from users.models import Group, ObjectPermission, Token
+from .serializers_.users import *
+from .serializers_.permissions import *
+from .serializers_.tokens import *
 from .nested_serializers import *
-
-
-__all__ = (
-    'GroupSerializer',
-    'ObjectPermissionSerializer',
-    'TokenSerializer',
-    'UserSerializer',
-)
-
-
-class UserSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail')
-    groups = SerializedPKRelatedField(
-        queryset=Group.objects.all(),
-        serializer=NestedGroupSerializer,
-        required=False,
-        many=True
-    )
-
-    class Meta:
-        model = get_user_model()
-        fields = (
-            'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active',
-            'date_joined', 'last_login', 'groups',
-        )
-        brief_fields = ('id', 'url', 'display', 'username')
-        extra_kwargs = {
-            'password': {'write_only': True}
-        }
-
-    def create(self, validated_data):
-        """
-        Extract the password from validated data and set it separately to ensure proper hash generation.
-        """
-        password = validated_data.pop('password')
-        user = super().create(validated_data)
-        user.set_password(password)
-        user.save()
-
-        return user
-
-    def update(self, instance, validated_data):
-        """
-        Ensure proper updated password hash generation.
-        """
-        password = validated_data.pop('password', None)
-        if password is not None:
-            instance.set_password(password)
-
-        return super().update(instance, validated_data)
-
-    @extend_schema_field(OpenApiTypes.STR)
-    def get_display(self, obj):
-        if full_name := obj.get_full_name():
-            return f"{obj.username} ({full_name})"
-        return obj.username
-
-
-class GroupSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='users-api:group-detail')
-    user_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = Group
-        fields = ('id', 'url', 'display', 'name', 'user_count')
-        brief_fields = ('id', 'url', 'display', 'name')
-
-
-class TokenSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail')
-    key = serializers.CharField(
-        min_length=40,
-        max_length=40,
-        allow_blank=True,
-        required=False,
-        write_only=not settings.ALLOW_TOKEN_RETRIEVAL
-    )
-    user = NestedUserSerializer()
-    allowed_ips = serializers.ListField(
-        child=IPNetworkSerializer(),
-        required=False,
-        allow_empty=True,
-        default=[]
-    )
-
-    class Meta:
-        model = Token
-        fields = (
-            'id', 'url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', 'description',
-            'allowed_ips',
-        )
-        brief_fields = ('id', 'url', 'display', 'key', 'write_enabled', 'description')
-
-    def to_internal_value(self, data):
-        if 'key' not in data:
-            data['key'] = Token.generate_key()
-        return super().to_internal_value(data)
-
-    def validate(self, data):
-
-        # If the Token is being created on behalf of another user, enforce the grant_token permission.
-        request = self.context.get('request')
-        token_user = data.get('user')
-        if token_user and token_user != request.user and not request.user.has_perm('users.grant_token'):
-            raise PermissionDenied("This user does not have permission to create tokens for other users.")
-
-        return super().validate(data)
-
-
-class TokenProvisionSerializer(TokenSerializer):
-    user = NestedUserSerializer(
-        read_only=True
-    )
-    username = serializers.CharField(
-        write_only=True
-    )
-    password = serializers.CharField(
-        write_only=True
-    )
-    last_used = serializers.DateTimeField(
-        read_only=True
-    )
-    key = serializers.CharField(
-        read_only=True
-    )
-
-    class Meta:
-        model = Token
-        fields = (
-            'id', 'url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', 'description',
-            'allowed_ips', 'username', 'password',
-        )
-
-    def validate(self, data):
-        # Validate the username and password
-        username = data.pop('username')
-        password = data.pop('password')
-        user = authenticate(request=self.context.get('request'), username=username, password=password)
-        if user is None:
-            raise AuthenticationFailed("Invalid username/password")
-
-        # Inject the user into the validated data
-        data['user'] = user
-
-        return data
-
-
-class ObjectPermissionSerializer(ValidatedModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail')
-    object_types = ContentTypeField(
-        queryset=ObjectType.objects.all(),
-        many=True
-    )
-    groups = SerializedPKRelatedField(
-        queryset=Group.objects.all(),
-        serializer=NestedGroupSerializer,
-        required=False,
-        many=True
-    )
-    users = SerializedPKRelatedField(
-        queryset=get_user_model().objects.all(),
-        serializer=NestedUserSerializer,
-        required=False,
-        many=True
-    )
-
-    class Meta:
-        model = ObjectPermission
-        fields = (
-            'id', 'url', 'display', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions',
-            'constraints',
-        )
-        brief_fields = (
-            'id', 'url', 'display', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions',
-        )

+ 0 - 0
netbox/users/api/serializers_/__init__.py


+ 44 - 0
netbox/users/api/serializers_/permissions.py

@@ -0,0 +1,44 @@
+from django.contrib.auth import get_user_model
+from rest_framework import serializers
+
+from core.models import ObjectType
+from netbox.api.fields import ContentTypeField, SerializedPKRelatedField
+from netbox.api.serializers import ValidatedModelSerializer
+from users.models import Group, ObjectPermission
+from .users import GroupSerializer, UserSerializer
+
+__all__ = (
+    'ObjectPermissionSerializer',
+)
+
+
+class ObjectPermissionSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail')
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.all(),
+        many=True
+    )
+    groups = SerializedPKRelatedField(
+        queryset=Group.objects.all(),
+        serializer=GroupSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    users = SerializedPKRelatedField(
+        queryset=get_user_model().objects.all(),
+        serializer=UserSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+
+    class Meta:
+        model = ObjectPermission
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions',
+            'constraints',
+        )
+        brief_fields = (
+            'id', 'url', 'display', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions',
+        )

+ 94 - 0
netbox/users/api/serializers_/tokens.py

@@ -0,0 +1,94 @@
+from django.conf import settings
+from django.contrib.auth import authenticate
+from rest_framework import serializers
+from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
+
+from netbox.api.fields import IPNetworkSerializer
+from netbox.api.serializers import ValidatedModelSerializer
+from users.models import Token
+from .users import *
+
+__all__ = (
+    'TokenProvisionSerializer',
+    'TokenSerializer',
+)
+
+
+class TokenSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail')
+    key = serializers.CharField(
+        min_length=40,
+        max_length=40,
+        allow_blank=True,
+        required=False,
+        write_only=not settings.ALLOW_TOKEN_RETRIEVAL
+    )
+    user = UserSerializer(nested=True)
+    allowed_ips = serializers.ListField(
+        child=IPNetworkSerializer(),
+        required=False,
+        allow_empty=True,
+        default=[]
+    )
+
+    class Meta:
+        model = Token
+        fields = (
+            'id', 'url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', 'description',
+            'allowed_ips',
+        )
+        brief_fields = ('id', 'url', 'display', 'key', 'write_enabled', 'description')
+
+    def to_internal_value(self, data):
+        if 'key' not in data:
+            data['key'] = Token.generate_key()
+        return super().to_internal_value(data)
+
+    def validate(self, data):
+
+        # If the Token is being created on behalf of another user, enforce the grant_token permission.
+        request = self.context.get('request')
+        token_user = data.get('user')
+        if token_user and token_user != request.user and not request.user.has_perm('users.grant_token'):
+            raise PermissionDenied("This user does not have permission to create tokens for other users.")
+
+        return super().validate(data)
+
+
+class TokenProvisionSerializer(TokenSerializer):
+    user = UserSerializer(
+        nested=True,
+        read_only=True
+    )
+    username = serializers.CharField(
+        write_only=True
+    )
+    password = serializers.CharField(
+        write_only=True
+    )
+    last_used = serializers.DateTimeField(
+        read_only=True
+    )
+    key = serializers.CharField(
+        read_only=True
+    )
+
+    class Meta:
+        model = Token
+        fields = (
+            'id', 'url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', 'description',
+            'allowed_ips', 'username', 'password',
+        )
+
+    def validate(self, data):
+        # Validate the username and password
+        username = data.pop('username')
+        password = data.pop('password')
+        user = authenticate(request=self.context.get('request'), username=username, password=password)
+        if user is None:
+            raise AuthenticationFailed("Invalid username/password")
+
+        # Inject the user into the validated data
+        data['user'] = user
+
+        return data

+ 72 - 0
netbox/users/api/serializers_/users.py

@@ -0,0 +1,72 @@
+from django.contrib.auth import get_user_model
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from netbox.api.fields import SerializedPKRelatedField
+from netbox.api.serializers import ValidatedModelSerializer
+from users.models import Group
+
+__all__ = (
+    'GroupSerializer',
+    'UserSerializer',
+)
+
+
+class GroupSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='users-api:group-detail')
+    user_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = Group
+        fields = ('id', 'url', 'display', 'name', 'user_count')
+        brief_fields = ('id', 'url', 'display', 'name')
+
+
+class UserSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail')
+    groups = SerializedPKRelatedField(
+        queryset=Group.objects.all(),
+        serializer=GroupSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+
+    class Meta:
+        model = get_user_model()
+        fields = (
+            'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active',
+            'date_joined', 'last_login', 'groups',
+        )
+        brief_fields = ('id', 'url', 'display', 'username')
+        extra_kwargs = {
+            'password': {'write_only': True}
+        }
+
+    def create(self, validated_data):
+        """
+        Extract the password from validated data and set it separately to ensure proper hash generation.
+        """
+        password = validated_data.pop('password')
+        user = super().create(validated_data)
+        user.set_password(password)
+        user.save()
+
+        return user
+
+    def update(self, instance, validated_data):
+        """
+        Ensure proper updated password hash generation.
+        """
+        password = validated_data.pop('password', None)
+        if password is not None:
+            instance.set_password(password)
+
+        return super().update(instance, validated_data)
+
+    @extend_schema_field(OpenApiTypes.STR)
+    def get_display(self, obj):
+        if full_name := obj.get_full_name():
+            return f"{obj.username} ({full_name})"
+        return obj.username

+ 52 - 5
netbox/utilities/api.py

@@ -3,23 +3,26 @@ import sys
 
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.core.exceptions import FieldDoesNotExist
+from django.core.exceptions import (
+    FieldDoesNotExist, FieldError, MultipleObjectsReturned, ObjectDoesNotExist, ValidationError,
+)
 from django.db.models.fields.related import ManyToOneRel, RelatedField
 from django.http import JsonResponse
 from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
 from rest_framework import status
 from rest_framework.serializers import Serializer
 from rest_framework.utils import formatting
 
 from netbox.api.fields import RelatedObjectCountField
 from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound
-from utilities.utils import count_related
-from .utils import dynamic_import
+from .utils import count_related, dict_to_filter_params, dynamic_import
 
 __all__ = (
     'get_annotations_for_serializer',
     'get_graphql_type_for_model',
     'get_prefetches_for_serializer',
+    'get_related_object_by_attrs',
     'get_serializer_for_model',
     'get_view_name',
     'is_api_request',
@@ -93,7 +96,7 @@ def get_prefetches_for_serializer(serializer_class, fields_to_include=None):
     """
     model = serializer_class.Meta.model
 
-    # If specific fields are not specified, default to all
+    # If fields are not specified, default to all
     if not fields_to_include:
         fields_to_include = serializer_class.Meta.fields
 
@@ -118,7 +121,9 @@ def get_prefetches_for_serializer(serializer_class, fields_to_include=None):
         # for the related object.
         if serializer_field:
             if issubclass(type(serializer_field), Serializer):
-                for subfield in get_prefetches_for_serializer(type(serializer_field)):
+                # Determine which fields to prefetch for the nested object
+                subfields = serializer_field.Meta.brief_fields if serializer_field.nested else None
+                for subfield in get_prefetches_for_serializer(type(serializer_field), subfields):
                     prefetch_fields.append(f'{field_name}__{subfield}')
 
     return prefetch_fields
@@ -144,6 +149,48 @@ def get_annotations_for_serializer(serializer_class, fields_to_include=None):
     return annotations
 
 
+def get_related_object_by_attrs(queryset, attrs):
+    """
+    Return an object identified by either a dictionary of attributes or its numeric primary key (ID). This is used
+    for referencing related objects when creating/updating objects via the REST API.
+    """
+    if attrs is None:
+        return None
+
+    # Dictionary of related object attributes
+    if isinstance(attrs, dict):
+        params = dict_to_filter_params(attrs)
+        try:
+            return queryset.get(**params)
+        except ObjectDoesNotExist:
+            raise ValidationError(
+                _("Related object not found using the provided attributes: {params}").format(params=params))
+        except MultipleObjectsReturned:
+            raise ValidationError(
+                _("Multiple objects match the provided attributes: {params}").format(params=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(attrs)
+    except (TypeError, ValueError):
+        raise ValidationError(
+            _(
+                "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
+                "unrecognized value: {value}"
+            ).format(value=attrs)
+        )
+
+    # Look up object by PK
+    try:
+        return queryset.get(pk=pk)
+    except ObjectDoesNotExist:
+        raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk))
+
+
 def rest_api_server_error(request, *args, **kwargs):
     """
     Handle exceptions and return a useful error message for REST API requests.

+ 1 - 1
netbox/utilities/templates/form_helpers/render_field.html

@@ -36,7 +36,7 @@
     {% elif 'data-clipboard' in field.field.widget.attrs %}
       <div class="input-group">
         {{ field }}
-        <button type="button" title="{% trans "Copy to clipboard" %}" class="btn btn-outline-dark copy-content" data-clipboard-target="#{{ field.id_for_label }}">
+        <button type="button" title="{% trans "Copy to clipboard" %}" class="btn copy-content" data-clipboard-target="#{{ field.id_for_label }}">
           <i class="mdi mdi-content-copy"></i>
         </button>
       </div>

+ 1 - 1
netbox/utilities/templates/navigation/menu.html

@@ -19,7 +19,7 @@
         <div class="dropdown-menu-columns">
           <div class="dropdown-menu-column pb-2">
             {% for group, items in groups %}
-              <div class="text-uppercase fw-bold fs-5 ps-3 pt-3 pb-1">
+              <div class="text-uppercase text-secondary fw-bold fs-5 ps-3 pt-3 pb-1">
                 {{ group.label }}
               </div>
               {% for item, buttons in items %}

+ 3 - 1
netbox/utilities/templates/widgets/number_with_options.html

@@ -1,6 +1,8 @@
 <div class="input-group">
   {% include 'django/forms/widgets/number.html' %}
-  <button type="button" class="btn btn-outline-dark dropdown-toggle" data-bs-toggle="dropdown"></button>
+  <button type="button" class="btn" data-bs-toggle="dropdown">
+    <i class="mdi mdi-chevron-down"></i>
+  </button>
   <ul class="dropdown-menu dropdown-menu-end">
     {% for value, label in widget.options %}
       <li>

+ 2 - 188
netbox/virtualization/api/serializers.py

@@ -1,189 +1,3 @@
-from drf_spectacular.utils import extend_schema_field
-from rest_framework import serializers
-
-from dcim.api.nested_serializers import (
-    NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
-)
-from dcim.choices import InterfaceModeChoices
-from extras.api.nested_serializers import NestedConfigTemplateSerializer
-from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
-from ipam.models import VLAN
-from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
-from netbox.api.serializers import NetBoxModelSerializer
-from tenancy.api.nested_serializers import NestedTenantSerializer
-from virtualization.choices import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualDisk, VirtualMachine, VMInterface
-from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
+from .serializers_.clusters import *
+from .serializers_.virtualmachines import *
 from .nested_serializers import *
-
-
-#
-# Clusters
-#
-
-class ClusterTypeSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
-
-    # Related object counts
-    cluster_count = RelatedObjectCountField('clusters')
-
-    class Meta:
-        model = ClusterType
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
-            'cluster_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'cluster_count')
-
-
-class ClusterGroupSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
-
-    # Related object counts
-    cluster_count = RelatedObjectCountField('clusters')
-
-    class Meta:
-        model = ClusterGroup
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
-            'cluster_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'cluster_count')
-
-
-class ClusterSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
-    type = NestedClusterTypeSerializer()
-    group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None)
-    status = ChoiceField(choices=ClusterStatusChoices, required=False)
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    site = NestedSiteSerializer(required=False, allow_null=True, default=None)
-
-    # Related object counts
-    device_count = RelatedObjectCountField('devices')
-    virtualmachine_count = RelatedObjectCountField('virtual_machines')
-
-    class Meta:
-        model = Cluster
-        fields = [
-            'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'description', 'comments',
-            'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count')
-
-
-#
-# Virtual machines
-#
-
-class VirtualMachineSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
-    status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
-    site = NestedSiteSerializer(required=False, allow_null=True)
-    cluster = NestedClusterSerializer(required=False, allow_null=True)
-    device = NestedDeviceSerializer(required=False, allow_null=True)
-    role = NestedDeviceRoleSerializer(required=False, allow_null=True)
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    platform = NestedPlatformSerializer(required=False, allow_null=True)
-    primary_ip = NestedIPAddressSerializer(read_only=True)
-    primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
-    primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
-    config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
-
-    # Counter fields
-    interface_count = serializers.IntegerField(read_only=True)
-    virtual_disk_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = VirtualMachine
-        fields = [
-            'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
-            'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
-            'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
-            'interface_count', 'virtual_disk_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-        validators = []
-
-
-class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
-    config_context = serializers.SerializerMethodField()
-
-    class Meta(VirtualMachineSerializer.Meta):
-        fields = [
-            'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
-            'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
-            'config_template', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created',
-            'last_updated', 'interface_count', 'virtual_disk_count',
-        ]
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_config_context(self, obj):
-        return obj.get_config_context()
-
-
-#
-# VM interfaces
-#
-
-class VMInterfaceSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
-    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)
-    untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
-    tagged_vlans = SerializedPKRelatedField(
-        queryset=VLAN.objects.all(),
-        serializer=NestedVLANSerializer,
-        required=False,
-        many=True
-    )
-    vrf = NestedVRFSerializer(required=False, allow_null=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,
-        allow_null=True
-    )
-
-    class Meta:
-        model = VMInterface
-        fields = [
-            'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address',
-            'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields',
-            'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
-        ]
-        brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
-
-    def validate(self, data):
-
-        # Validate many-to-many VLAN assignments
-        virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
-        for vlan in data.get('tagged_vlans', []):
-            if vlan.site not in [virtual_machine.site, None]:
-                raise serializers.ValidationError({
-                    'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
-                                    f"machine, or it must be global."
-                })
-
-        return super().validate(data)
-
-
-#
-# Virtual Disk
-#
-
-class VirtualDiskSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualdisk-detail')
-    virtual_machine = NestedVirtualMachineSerializer()
-
-    class Meta:
-        model = VirtualDisk
-        fields = [
-            'id', 'url', 'display', 'virtual_machine', 'name', 'description', 'size', 'tags', 'custom_fields',
-            'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description', 'size')

+ 0 - 0
netbox/virtualization/api/serializers_/__init__.py


+ 65 - 0
netbox/virtualization/api/serializers_/clusters.py

@@ -0,0 +1,65 @@
+from rest_framework import serializers
+
+from dcim.api.serializers_.sites import SiteSerializer
+from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+from virtualization.choices import *
+from virtualization.models import Cluster, ClusterGroup, ClusterType
+
+__all__ = (
+    'ClusterGroupSerializer',
+    'ClusterSerializer',
+    'ClusterTypeSerializer',
+)
+
+
+class ClusterTypeSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
+
+    # Related object counts
+    cluster_count = RelatedObjectCountField('clusters')
+
+    class Meta:
+        model = ClusterType
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
+            'cluster_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'cluster_count')
+
+
+class ClusterGroupSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
+
+    # Related object counts
+    cluster_count = RelatedObjectCountField('clusters')
+
+    class Meta:
+        model = ClusterGroup
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
+            'cluster_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'cluster_count')
+
+
+class ClusterSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
+    type = ClusterTypeSerializer(nested=True)
+    group = ClusterGroupSerializer(nested=True, required=False, allow_null=True, default=None)
+    status = ChoiceField(choices=ClusterStatusChoices, required=False)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    site = SiteSerializer(nested=True, required=False, allow_null=True, default=None)
+
+    # Related object counts
+    device_count = RelatedObjectCountField('devices')
+    virtualmachine_count = RelatedObjectCountField('virtual_machines')
+
+    class Meta:
+        model = Cluster
+        fields = [
+            'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'description', 'comments',
+            'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count')

+ 142 - 0
netbox/virtualization/api/serializers_/virtualmachines.py

@@ -0,0 +1,142 @@
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from dcim.api.serializers_.devices import DeviceSerializer
+from dcim.api.serializers_.platforms import PlatformSerializer
+from dcim.api.serializers_.roles import DeviceRoleSerializer
+from dcim.api.serializers_.sites import SiteSerializer
+from dcim.choices import InterfaceModeChoices
+from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
+from ipam.api.serializers_.ip import IPAddressSerializer
+from ipam.api.serializers_.vlans import VLANSerializer
+from ipam.api.serializers_.vrfs import VRFSerializer
+from ipam.models import VLAN
+from netbox.api.fields import ChoiceField, SerializedPKRelatedField
+from netbox.api.serializers import NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+from virtualization.choices import *
+from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
+from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
+from .clusters import ClusterSerializer
+from ..nested_serializers import *
+
+__all__ = (
+    'VMInterfaceSerializer',
+    'VirtualDiskSerializer',
+    'VirtualMachineSerializer',
+    'VirtualMachineWithConfigContextSerializer',
+)
+
+
+class VirtualMachineSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
+    status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
+    site = SiteSerializer(nested=True, required=False, allow_null=True)
+    cluster = ClusterSerializer(nested=True, required=False, allow_null=True)
+    device = DeviceSerializer(nested=True, required=False, allow_null=True)
+    role = DeviceRoleSerializer(nested=True, required=False, allow_null=True)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    platform = PlatformSerializer(nested=True, required=False, allow_null=True)
+    primary_ip = IPAddressSerializer(nested=True, read_only=True)
+    primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
+    primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True)
+    config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
+
+    # Counter fields
+    interface_count = serializers.IntegerField(read_only=True)
+    virtual_disk_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = VirtualMachine
+        fields = [
+            'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
+            'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
+            'interface_count', 'virtual_disk_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+        validators = []
+
+
+class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
+    config_context = serializers.SerializerMethodField()
+
+    class Meta(VirtualMachineSerializer.Meta):
+        fields = [
+            'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
+            'config_template', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created',
+            'last_updated', 'interface_count', 'virtual_disk_count',
+        ]
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_config_context(self, obj):
+        return obj.get_config_context()
+
+
+#
+# VM interfaces
+#
+
+class VMInterfaceSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
+    virtual_machine = VirtualMachineSerializer(nested=True)
+    parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
+    bridge = NestedVMInterfaceSerializer(required=False, allow_null=True)
+    mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
+    untagged_vlan = VLANSerializer(nested=True, required=False, allow_null=True)
+    tagged_vlans = SerializedPKRelatedField(
+        queryset=VLAN.objects.all(),
+        serializer=VLANSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    vrf = VRFSerializer(nested=True, required=False, allow_null=True)
+    l2vpn_termination = L2VPNTerminationSerializer(nested=True, 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,
+        allow_null=True
+    )
+
+    class Meta:
+        model = VMInterface
+        fields = [
+            'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address',
+            'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields',
+            'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
+        ]
+        brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
+
+    def validate(self, data):
+
+        # Validate many-to-many VLAN assignments
+        virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
+        for vlan in data.get('tagged_vlans', []):
+            if vlan.site not in [virtual_machine.site, None]:
+                raise serializers.ValidationError({
+                    'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
+                                    f"machine, or it must be global."
+                })
+
+        return super().validate(data)
+
+
+#
+# Virtual Disk
+#
+
+class VirtualDiskSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualdisk-detail')
+    virtual_machine = VirtualMachineSerializer(nested=True)
+
+    class Meta:
+        model = VirtualDisk
+        fields = [
+            'id', 'url', 'display', 'virtual_machine', 'name', 'description', 'size', 'tags', 'custom_fields',
+            'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description', 'size')

+ 3 - 279
netbox/vpn/api/serializers.py

@@ -1,280 +1,4 @@
-from django.contrib.contenttypes.models import ContentType
-from drf_spectacular.utils import extend_schema_field
-from rest_framework import serializers
-
-from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedRouteTargetSerializer
-from ipam.models import RouteTarget
-from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField, SerializedPKRelatedField
-from netbox.api.serializers import NetBoxModelSerializer
-from netbox.constants import NESTED_SERIALIZER_PREFIX
-from tenancy.api.nested_serializers import NestedTenantSerializer
-from utilities.api import get_serializer_for_model
-from vpn.choices import *
-from vpn.models import *
+from .serializers_.crypto import *
+from .serializers_.tunnels import *
+from .serializers_.l2vpn import *
 from .nested_serializers import *
-
-__all__ = (
-    'IKEPolicySerializer',
-    'IKEProposalSerializer',
-    'IPSecPolicySerializer',
-    'IPSecProfileSerializer',
-    'IPSecProposalSerializer',
-    'L2VPNSerializer',
-    'L2VPNTerminationSerializer',
-    'TunnelGroupSerializer',
-    'TunnelSerializer',
-    'TunnelTerminationSerializer',
-)
-
-
-class TunnelGroupSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail')
-
-    # Related object counts
-    tunnel_count = RelatedObjectCountField('tunnels')
-
-    class Meta:
-        model = TunnelGroup
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
-            'tunnel_count',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tunnel_count')
-
-
-class TunnelSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='vpn-api:tunnel-detail'
-    )
-    status = ChoiceField(
-        choices=TunnelStatusChoices
-    )
-    group = NestedTunnelGroupSerializer(
-        required=False,
-        allow_null=True
-    )
-    encapsulation = ChoiceField(
-        choices=TunnelEncapsulationChoices
-    )
-    ipsec_profile = NestedIPSecProfileSerializer(
-        required=False,
-        allow_null=True
-    )
-    tenant = NestedTenantSerializer(
-        required=False,
-        allow_null=True
-    )
-
-    # Related object counts
-    terminations_count = RelatedObjectCountField('terminations')
-
-    class Meta:
-        model = Tunnel
-        fields = (
-            'id', 'url', 'display', 'name', 'status', 'group', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id',
-            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'terminations_count',
-        )
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class TunnelTerminationSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='vpn-api:tunneltermination-detail'
-    )
-    tunnel = NestedTunnelSerializer()
-    role = ChoiceField(
-        choices=TunnelTerminationRoleChoices
-    )
-    termination_type = ContentTypeField(
-        queryset=ContentType.objects.all()
-    )
-    termination = serializers.SerializerMethodField(
-        read_only=True
-    )
-    outside_ip = NestedIPAddressSerializer(
-        required=False,
-        allow_null=True
-    )
-
-    class Meta:
-        model = TunnelTermination
-        fields = (
-            'id', 'url', 'display', 'tunnel', 'role', 'termination_type', 'termination_id', 'termination', 'outside_ip',
-            'tags', 'custom_fields', 'created', 'last_updated',
-        )
-        brief_fields = ('id', 'url', 'display')
-
-    @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']}
-        return serializer(obj.termination, context=context).data
-
-
-class IKEProposalSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='vpn-api:ikeproposal-detail'
-    )
-    authentication_method = ChoiceField(
-        choices=AuthenticationMethodChoices
-    )
-    encryption_algorithm = ChoiceField(
-        choices=EncryptionAlgorithmChoices
-    )
-    authentication_algorithm = ChoiceField(
-        choices=AuthenticationAlgorithmChoices
-    )
-    group = ChoiceField(
-        choices=DHGroupChoices
-    )
-
-    class Meta:
-        model = IKEProposal
-        fields = (
-            'id', 'url', 'display', 'name', 'description', 'authentication_method', 'encryption_algorithm',
-            'authentication_algorithm', 'group', 'sa_lifetime', 'comments', 'tags', 'custom_fields', 'created',
-            'last_updated',
-        )
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class IKEPolicySerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='vpn-api:ikepolicy-detail'
-    )
-    version = ChoiceField(
-        choices=IKEVersionChoices
-    )
-    mode = ChoiceField(
-        choices=IKEModeChoices
-    )
-    proposals = SerializedPKRelatedField(
-        queryset=IKEProposal.objects.all(),
-        serializer=NestedIKEProposalSerializer,
-        required=False,
-        many=True
-    )
-
-    class Meta:
-        model = IKEPolicy
-        fields = (
-            'id', 'url', 'display', 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'comments',
-            'tags', 'custom_fields', 'created', 'last_updated',
-        )
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class IPSecProposalSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='vpn-api:ipsecproposal-detail'
-    )
-    encryption_algorithm = ChoiceField(
-        choices=EncryptionAlgorithmChoices
-    )
-    authentication_algorithm = ChoiceField(
-        choices=AuthenticationAlgorithmChoices
-    )
-
-    class Meta:
-        model = IPSecProposal
-        fields = (
-            'id', 'url', 'display', 'name', 'description', 'encryption_algorithm', 'authentication_algorithm',
-            'sa_lifetime_seconds', 'sa_lifetime_data', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-        )
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class IPSecPolicySerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='vpn-api:ipsecpolicy-detail'
-    )
-    proposals = SerializedPKRelatedField(
-        queryset=IPSecProposal.objects.all(),
-        serializer=NestedIPSecProposalSerializer,
-        required=False,
-        many=True
-    )
-    pfs_group = ChoiceField(
-        choices=DHGroupChoices,
-        required=False
-    )
-
-    class Meta:
-        model = IPSecPolicy
-        fields = (
-            'id', 'url', 'display', 'name', 'description', 'proposals', 'pfs_group', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated',
-        )
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-class IPSecProfileSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='vpn-api:ipsecprofile-detail'
-    )
-    mode = ChoiceField(
-        choices=IPSecModeChoices
-    )
-    ike_policy = NestedIKEPolicySerializer()
-    ipsec_policy = NestedIPSecPolicySerializer()
-
-    class Meta:
-        model = IPSecProfile
-        fields = (
-            'id', 'url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated',
-        )
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
-
-
-#
-# L2VPN
-#
-
-class L2VPNSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpn-detail')
-    type = ChoiceField(choices=L2VPNTypeChoices, required=False)
-    import_targets = SerializedPKRelatedField(
-        queryset=RouteTarget.objects.all(),
-        serializer=NestedRouteTargetSerializer,
-        required=False,
-        many=True
-    )
-    export_targets = SerializedPKRelatedField(
-        queryset=RouteTarget.objects.all(),
-        serializer=NestedRouteTargetSerializer,
-        required=False,
-        many=True
-    )
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-
-    class Meta:
-        model = L2VPN
-        fields = [
-            'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets',
-            'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated'
-        ]
-        brief_fields = ('id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'description')
-
-
-class L2VPNTerminationSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpntermination-detail')
-    l2vpn = NestedL2VPNSerializer()
-    assigned_object_type = ContentTypeField(
-        queryset=ContentType.objects.all()
-    )
-    assigned_object = serializers.SerializerMethodField(read_only=True)
-
-    class Meta:
-        model = L2VPNTermination
-        fields = [
-            'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id',
-            'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated'
-        ]
-        brief_fields = ('id', 'url', 'display', 'l2vpn')
-
-    @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']}
-        return serializer(instance.assigned_object, context=context).data

+ 0 - 0
netbox/vpn/api/serializers_/__init__.py


+ 136 - 0
netbox/vpn/api/serializers_/crypto.py

@@ -0,0 +1,136 @@
+from rest_framework import serializers
+
+from netbox.api.fields import ChoiceField, SerializedPKRelatedField
+from netbox.api.serializers import NetBoxModelSerializer
+from vpn.choices import *
+from vpn.models import IKEPolicy, IKEProposal, IPSecPolicy, IPSecProfile, IPSecProposal
+
+__all__ = (
+    'IKEPolicySerializer',
+    'IKEProposalSerializer',
+    'IPSecPolicySerializer',
+    'IPSecProfileSerializer',
+    'IPSecProposalSerializer',
+)
+
+
+class IKEProposalSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ikeproposal-detail'
+    )
+    authentication_method = ChoiceField(
+        choices=AuthenticationMethodChoices
+    )
+    encryption_algorithm = ChoiceField(
+        choices=EncryptionAlgorithmChoices
+    )
+    authentication_algorithm = ChoiceField(
+        choices=AuthenticationAlgorithmChoices
+    )
+    group = ChoiceField(
+        choices=DHGroupChoices
+    )
+
+    class Meta:
+        model = IKEProposal
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'authentication_method', 'encryption_algorithm',
+            'authentication_algorithm', 'group', 'sa_lifetime', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated',
+        )
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class IKEPolicySerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ikepolicy-detail'
+    )
+    version = ChoiceField(
+        choices=IKEVersionChoices
+    )
+    mode = ChoiceField(
+        choices=IKEModeChoices
+    )
+    proposals = SerializedPKRelatedField(
+        queryset=IKEProposal.objects.all(),
+        serializer=IKEProposalSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+
+    class Meta:
+        model = IKEPolicy
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'comments',
+            'tags', 'custom_fields', 'created', 'last_updated',
+        )
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class IPSecProposalSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ipsecproposal-detail'
+    )
+    encryption_algorithm = ChoiceField(
+        choices=EncryptionAlgorithmChoices
+    )
+    authentication_algorithm = ChoiceField(
+        choices=AuthenticationAlgorithmChoices
+    )
+
+    class Meta:
+        model = IPSecProposal
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'encryption_algorithm', 'authentication_algorithm',
+            'sa_lifetime_seconds', 'sa_lifetime_data', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        )
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class IPSecPolicySerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ipsecpolicy-detail'
+    )
+    proposals = SerializedPKRelatedField(
+        queryset=IPSecProposal.objects.all(),
+        serializer=IPSecProposalSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    pfs_group = ChoiceField(
+        choices=DHGroupChoices,
+        required=False
+    )
+
+    class Meta:
+        model = IPSecPolicy
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'proposals', 'pfs_group', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        )
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class IPSecProfileSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ipsecprofile-detail'
+    )
+    mode = ChoiceField(
+        choices=IPSecModeChoices
+    )
+    ike_policy = IKEPolicySerializer(
+        nested=True
+    )
+    ipsec_policy = IPSecPolicySerializer(
+        nested=True
+    )
+
+    class Meta:
+        model = IPSecProfile
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        )
+        brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 70 - 0
netbox/vpn/api/serializers_/l2vpn.py

@@ -0,0 +1,70 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from ipam.api.serializers_.vrfs import RouteTargetSerializer
+from ipam.models import RouteTarget
+from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
+from netbox.api.serializers import NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+from utilities.api import get_serializer_for_model
+from vpn.choices import *
+from vpn.models import L2VPN, L2VPNTermination
+
+__all__ = (
+    'L2VPNSerializer',
+    'L2VPNTerminationSerializer',
+)
+
+
+class L2VPNSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpn-detail')
+    type = ChoiceField(choices=L2VPNTypeChoices, required=False)
+    import_targets = SerializedPKRelatedField(
+        queryset=RouteTarget.objects.all(),
+        serializer=RouteTargetSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    export_targets = SerializedPKRelatedField(
+        queryset=RouteTarget.objects.all(),
+        serializer=RouteTargetSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+
+    class Meta:
+        model = L2VPN
+        fields = [
+            'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets',
+            'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated'
+        ]
+        brief_fields = ('id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'description')
+
+
+class L2VPNTerminationSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpntermination-detail')
+    l2vpn = L2VPNSerializer(
+        nested=True
+    )
+    assigned_object_type = ContentTypeField(
+        queryset=ContentType.objects.all()
+    )
+    assigned_object = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = L2VPNTermination
+        fields = [
+            'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id',
+            'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated'
+        ]
+        brief_fields = ('id', 'url', 'display', 'l2vpn')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_assigned_object(self, instance):
+        serializer = get_serializer_for_model(instance.assigned_object)
+        context = {'request': self.context['request']}
+        return serializer(instance.assigned_object, nested=True, context=context).data

+ 112 - 0
netbox/vpn/api/serializers_/tunnels.py

@@ -0,0 +1,112 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from ipam.api.serializers_.ip import IPAddressSerializer
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
+from netbox.api.serializers import NetBoxModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+from utilities.api import get_serializer_for_model
+from vpn.choices import *
+from vpn.models import Tunnel, TunnelGroup, TunnelTermination
+from .crypto import IPSecProfileSerializer
+
+__all__ = (
+    'TunnelGroupSerializer',
+    'TunnelSerializer',
+    'TunnelTerminationSerializer',
+)
+
+
+#
+# Tunnels
+#
+
+class TunnelGroupSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail')
+
+    # Related object counts
+    tunnel_count = RelatedObjectCountField('tunnels')
+
+    class Meta:
+        model = TunnelGroup
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
+            'tunnel_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tunnel_count')
+
+
+class TunnelSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:tunnel-detail'
+    )
+    status = ChoiceField(
+        choices=TunnelStatusChoices
+    )
+    group = TunnelGroupSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+    encapsulation = ChoiceField(
+        choices=TunnelEncapsulationChoices
+    )
+    ipsec_profile = IPSecProfileSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+    tenant = TenantSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+
+    # Related object counts
+    terminations_count = RelatedObjectCountField('terminations')
+
+    class Meta:
+        model = Tunnel
+        fields = (
+            'id', 'url', 'display', 'name', 'status', 'group', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'terminations_count',
+        )
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class TunnelTerminationSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:tunneltermination-detail'
+    )
+    tunnel = TunnelSerializer(
+        nested=True
+    )
+    role = ChoiceField(
+        choices=TunnelTerminationRoleChoices
+    )
+    termination_type = ContentTypeField(
+        queryset=ContentType.objects.all()
+    )
+    termination = serializers.SerializerMethodField(
+        read_only=True
+    )
+    outside_ip = IPAddressSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = TunnelTermination
+        fields = (
+            'id', 'url', 'display', 'tunnel', 'role', 'termination_type', 'termination_id', 'termination', 'outside_ip',
+            'tags', 'custom_fields', 'created', 'last_updated',
+        )
+        brief_fields = ('id', 'url', 'display')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_termination(self, obj):
+        serializer = get_serializer_for_model(obj.termination)
+        context = {'request': self.context['request']}
+        return serializer(obj.termination, nested=True, context=context).data

+ 2 - 66
netbox/wireless/api/serializers.py

@@ -1,67 +1,3 @@
-from rest_framework import serializers
-
-from dcim.choices import LinkStatusChoices
-from dcim.api.serializers import NestedInterfaceSerializer
-from ipam.api.serializers import NestedVLANSerializer
-from netbox.api.fields import ChoiceField
-from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
-from tenancy.api.nested_serializers import NestedTenantSerializer
-from wireless.choices import *
-from wireless.models import *
+from .serializers_.wirelesslans import *
+from .serializers_.wirelesslinks import *
 from .nested_serializers import *
-
-__all__ = (
-    'WirelessLANGroupSerializer',
-    'WirelessLANSerializer',
-    'WirelessLinkSerializer',
-)
-
-
-class WirelessLANGroupSerializer(NestedGroupModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail')
-    parent = NestedWirelessLANGroupSerializer(required=False, allow_null=True, default=None)
-    wirelesslan_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = WirelessLANGroup
-        fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
-            'last_updated', 'wirelesslan_count', '_depth',
-        ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'wirelesslan_count', '_depth')
-
-
-class WirelessLANSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail')
-    group = NestedWirelessLANGroupSerializer(required=False, allow_null=True)
-    status = ChoiceField(choices=WirelessLANStatusChoices, required=False, allow_blank=True)
-    vlan = NestedVLANSerializer(required=False, allow_null=True)
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
-    auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
-
-    class Meta:
-        model = WirelessLAN
-        fields = [
-            'id', 'url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'tenant', 'auth_type',
-            'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'ssid', 'description')
-
-
-class WirelessLinkSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail')
-    status = ChoiceField(choices=LinkStatusChoices, required=False)
-    interface_a = NestedInterfaceSerializer()
-    interface_b = NestedInterfaceSerializer()
-    tenant = NestedTenantSerializer(required=False, allow_null=True)
-    auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
-    auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
-
-    class Meta:
-        model = WirelessLink
-        fields = [
-            'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'tenant', 'auth_type',
-            'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-        ]
-        brief_fields = ('id', 'url', 'display', 'ssid', 'description')

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio