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

Fixes #22802: Correct the OpenAPI schema for the available-prefixes endpoint request body

The POST /api/ipam/prefixes/{id}/available-prefixes/ endpoint accepts full
writable prefix fields (description, status, tenant, role, etc.) alongside
prefix_length, but the schema advertised only prefix_length via
PrefixLengthSerializer. Add CreateAvailablePrefixSerializer, mirroring the
writable fields of PrefixSerializer (minus the parent-supplied prefix) plus
prefix_length, and point the endpoint's request schema at it. Runtime behavior
is unchanged; only the published schema is corrected.
Jason Novinger 1 день назад
Родитель
Сommit
688e0b9656
3 измененных файлов с 57 добавлено и 5 удалено
  1. 36 1
      netbox/ipam/api/serializers_/ip.py
  2. 1 1
      netbox/ipam/api/views.py
  3. 20 3
      netbox/ipam/tests/test_api.py

+ 36 - 1
netbox/ipam/api/serializers_/ip.py

@@ -7,7 +7,7 @@ from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.gfk_fields import GFKSerializerField
-from netbox.api.serializers import PrimaryModelSerializer
+from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 
 
 from ..field_serializers import IPAddressField, IPNetworkField
 from ..field_serializers import IPAddressField, IPNetworkField
@@ -22,6 +22,7 @@ __all__ = (
     'AvailableIPRequestSerializer',
     'AvailableIPRequestSerializer',
     'AvailableIPSerializer',
     'AvailableIPSerializer',
     'AvailablePrefixSerializer',
     'AvailablePrefixSerializer',
+    'CreateAvailablePrefixSerializer',
     'IPAddressSerializer',
     'IPAddressSerializer',
     'IPRangeSerializer',
     'IPRangeSerializer',
     'PrefixLengthSerializer',
     'PrefixLengthSerializer',
@@ -102,6 +103,40 @@ class PrefixLengthSerializer(serializers.Serializer):
         return data
         return data
 
 
 
 
+class CreateAvailablePrefixSerializer(NetBoxModelSerializer):
+    """
+    Request payload for creating prefixes from the available-prefixes endpoint. The parent prefix supplies the
+    `prefix` value (via a requested `prefix_length`), so `prefix` is omitted here. The writable fields mirror
+    those of PrefixSerializer (minus read-only/computed fields); keep them in sync if PrefixSerializer changes.
+    """
+    prefix_length = serializers.IntegerField()
+    vrf = VRFSerializer(nested=True, required=False, allow_null=True)
+    scope_type = ContentTypeField(
+        queryset=ContentType.objects.filter(
+            model__in=LOCATION_SCOPE_TYPES
+        ),
+        allow_null=True,
+        required=False,
+        default=None
+    )
+    scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
+    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)
+
+    class Meta:
+        model = Prefix
+        fields = [
+            'prefix_length', 'vrf', 'scope_type', 'scope_id', 'tenant', 'vlan', 'status', 'role', 'is_pool',
+            'mark_utilized', 'description', 'owner', 'comments', 'tags', 'custom_fields',
+        ]
+
+    def validate(self, data):
+        # Bypass model validation since we don't have an allocated prefix yet
+        return data
+
+
 class AvailablePrefixSerializer(serializers.Serializer):
 class AvailablePrefixSerializer(serializers.Serializer):
     """
     """
     Representation of a prefix which does not exist in the database.
     Representation of a prefix which does not exist in the database.

+ 1 - 1
netbox/ipam/api/views.py

@@ -393,7 +393,7 @@ class AvailablePrefixesView(AvailableObjectsView):
     @extend_schema(
     @extend_schema(
         methods=["post"],
         methods=["post"],
         responses={201: serializers.PrefixSerializer(many=True)},
         responses={201: serializers.PrefixSerializer(many=True)},
-        request=serializers.PrefixLengthSerializer(many=True),
+        request=serializers.CreateAvailablePrefixSerializer(many=True),
     )
     )
     def post(self, request, pk):
     def post(self, request, pk):
         return super().post(request, pk)
         return super().post(request, pk)

+ 20 - 3
netbox/ipam/tests/test_api.py

@@ -499,6 +499,8 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         Test retrieval of the first available prefix within a parent prefix.
         Test retrieval of the first available prefix within a parent prefix.
         """
         """
         vrf = VRF.objects.create(name='VRF 1')
         vrf = VRF.objects.create(name='VRF 1')
+        tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
+        role = Role.objects.create(name='Role 1', slug='role-1')
         prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
         prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
         url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
         url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
         self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
         self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
@@ -513,13 +515,22 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         for i in range(4):
         for i in range(4):
             data = {
             data = {
                 'prefix_length': 30,
                 'prefix_length': 30,
-                'description': 'Test Prefix {}'.format(i + 1)
+                'description': 'Test Prefix {}'.format(i + 1),
+                'status': 'reserved',
+                'is_pool': True,
+                'tenant': tenant.pk,
+                'role': role.pk,
             }
             }
             response = self.client.post(url, data, format='json', **self.header)
             response = self.client.post(url, data, format='json', **self.header)
             self.assertHttpStatus(response, status.HTTP_201_CREATED)
             self.assertHttpStatus(response, status.HTTP_201_CREATED)
             self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
             self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
             self.assertEqual(response.data['vrf']['id'], vrf.pk)
             self.assertEqual(response.data['vrf']['id'], vrf.pk)
             self.assertEqual(response.data['description'], data['description'])
             self.assertEqual(response.data['description'], data['description'])
+            # Verify that additional writable fields advertised by the schema are accepted and persisted
+            self.assertEqual(response.data['status']['value'], 'reserved')
+            self.assertEqual(response.data['is_pool'], True)
+            self.assertEqual(response.data['tenant']['id'], tenant.pk)
+            self.assertEqual(response.data['role']['id'], role.pk)
 
 
         # Try to create one more prefix
         # Try to create one more prefix
         response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header)
         response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header)
@@ -536,14 +547,15 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         Test the creation of available prefixes within a parent prefix.
         Test the creation of available prefixes within a parent prefix.
         """
         """
         vrf = VRF.objects.create(name='VRF 1')
         vrf = VRF.objects.create(name='VRF 1')
+        tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
         prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
         prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
         url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
         url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
         self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
         self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
 
 
         # Try to create five /30s (only four are available)
         # Try to create five /30s (only four are available)
         data = [
         data = [
-            {'prefix_length': 30, 'description': 'Prefix 1'},
-            {'prefix_length': 30, 'description': 'Prefix 2'},
+            {'prefix_length': 30, 'description': 'Prefix 1', 'status': 'reserved', 'tenant': tenant.pk},
+            {'prefix_length': 30, 'description': 'Prefix 2', 'status': 'reserved', 'tenant': tenant.pk},
             {'prefix_length': 30, 'description': 'Prefix 3'},
             {'prefix_length': 30, 'description': 'Prefix 3'},
             {'prefix_length': 30, 'description': 'Prefix 4'},
             {'prefix_length': 30, 'description': 'Prefix 4'},
             {'prefix_length': 30, 'description': 'Prefix 5'},
             {'prefix_length': 30, 'description': 'Prefix 5'},
@@ -561,6 +573,11 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         response = self.client.post(url, data[:4], format='json', **self.header)
         response = self.client.post(url, data[:4], format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(len(response.data), 4)
         self.assertEqual(len(response.data), 4)
+        # Verify that additional writable fields advertised by the schema round-trip in list mode
+        self.assertEqual(response.data[0]['status']['value'], 'reserved')
+        self.assertEqual(response.data[0]['tenant']['id'], tenant.pk)
+        self.assertEqual(response.data[1]['status']['value'], 'reserved')
+        self.assertEqual(response.data[1]['tenant']['id'], tenant.pk)
 
 
     def test_list_available_ips(self):
     def test_list_available_ips(self):
         """
         """