diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 51f23f88d..b520e76ff 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -19,6 +19,7 @@ from ..field_serializers import IPAddressField, IPNetworkField __all__ = ( 'AggregateSerializer', 'AvailableIPSerializer', + 'AvailableIPRequestSerializer', 'AvailablePrefixSerializer', 'IPAddressSerializer', 'IPRangeSerializer', @@ -147,6 +148,43 @@ class IPRangeSerializer(PrimaryModelSerializer): # IP addresses # +class AvailableIPRequestSerializer(serializers.Serializer): + """ + Request payload for creating IP addresses from the available-ips endpoint. + """ + prefix_length = serializers.IntegerField(required=False) + + def to_internal_value(self, data): + data = super().to_internal_value(data) + + prefix_length = data.get('prefix_length') + if prefix_length is None: + # No override requested; the parent prefix/range mask length will be used. + return data + + parent = self.context.get('parent') + if parent is None: + return data + + # Validate the requested prefix length + if prefix_length < parent.mask_length: + raise serializers.ValidationError({ + 'prefix_length': 'Prefix length must be greater than or equal to the parent mask length ({})'.format( + parent.mask_length + ) + }) + elif parent.family == 4 and prefix_length > 32: + raise serializers.ValidationError({ + 'prefix_length': 'Invalid prefix length ({}) for IPv6'.format(prefix_length) + }) + elif parent.family == 6 and prefix_length > 128: + raise serializers.ValidationError({ + 'prefix_length': 'Invalid prefix length ({}) for IPv4'.format(prefix_length) + }) + + return data + + class IPAddressSerializer(PrimaryModelSerializer): family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) address = IPAddressField() diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index b0a7ad408..c8c35fff7 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -400,7 +400,7 @@ class AvailablePrefixesView(AvailableObjectsView): class AvailableIPAddressesView(AvailableObjectsView): queryset = IPAddress.objects.all() read_serializer_class = serializers.AvailableIPSerializer - write_serializer_class = serializers.AvailableIPSerializer + write_serializer_class = serializers.AvailableIPRequestSerializer advisory_lock_key = 'available-ips' def get_available_objects(self, parent, limit=None): @@ -421,8 +421,9 @@ class AvailableIPAddressesView(AvailableObjectsView): def prep_object_data(self, requested_objects, available_objects, parent): available_ips = iter(available_objects) for i, request_data in enumerate(requested_objects): + prefix_length = request_data.pop('prefix_length', None) or parent.mask_length request_data.update({ - 'address': f'{next(available_ips)}/{parent.mask_length}', + 'address': f'{next(available_ips)}/{prefix_length}', 'vrf': parent.vrf.pk if parent.vrf else None, }) @@ -435,7 +436,7 @@ class AvailableIPAddressesView(AvailableObjectsView): @extend_schema( methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)}, - request=serializers.IPAddressSerializer(many=True), + request=serializers.AvailableIPRequestSerializer(many=True), ) def post(self, request, pk): return super().post(request, pk) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 8141a6da9..3a75347ae 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -595,6 +595,31 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(len(response.data), 8) + def test_create_available_ip_with_mask(self): + """ + Test the creation of an available IP address with a specific prefix length. + """ + prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24')) + url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) + self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress') + + # Create an available IP with a specific prefix length + data = { + 'prefix_length': 32, + 'description': 'Test IP 1', + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['address'], '192.0.2.1/32') + self.assertEqual(response.data['description'], data['description']) + + # Attempt to create an available IP with a prefix length less than its parent prefix + data = { + 'prefix_length': 23, # Prefix is a /24 + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + @tag('regression') def test_graphql_tenant_prefixes_contains_nested_skips_invalid(self): """