diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 064452667..f59850aa2 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -58,6 +58,7 @@ 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={ @@ -432,6 +433,7 @@ class AvailableIPSerializer(serializers.Serializer): 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'): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f432e0e6b..63d097f72 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -2,8 +2,9 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock -from drf_spectacular.utils import extend_schema +from netaddr import IPSet from rest_framework import status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.views import APIView @@ -16,6 +17,7 @@ from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets.mixins import ObjectValidationMixin from netbox.config import get_config from netbox.constants import ADVISORY_LOCK_KEYS +from utilities.api import get_serializer_for_model from utilities.utils import count_related from . import serializers from ipam.models import L2VPN, L2VPNTermination @@ -207,219 +209,101 @@ def get_results_limit(request): return limit -class AvailableASNsView(ObjectValidationMixin, APIView): - queryset = ASN.objects.all() +class AvailableObjectsView(ObjectValidationMixin, APIView): + """ + Return a list of dicts representing child objects that have not yet been created for a parent object. + """ + read_serializer_class = None + write_serializer_class = None - @extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)}) + def get_parent(self, request, pk): + """ + Return the parent object. + """ + raise NotImplemented() + + def get_available_objects(self, parent, limit=None): + """ + Return all available objects for the parent. + """ + raise NotImplemented() + + def get_extra_context(self, parent): + """ + Return any extra context data for the serializer. + """ + return {} + + def check_sufficient_available(self, requested_objects, available_objects): + """ + Check if there exist a sufficient number of available objects to satisfy the request. + """ + return len(requested_objects) <= len(available_objects) + + def prep_object_data(self, requested_objects, available_objects, parent): + """ + Prepare data by setting any programmatically determined object attributes (e.g. next available VLAN ID) + on the request data. + """ + return requested_objects + + # TODO: Fix OpenAPI schema + # @extend_schema(methods=["get"], responses={200: serializer(many=True)}) def get(self, request, pk): - asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + parent = self.get_parent(request, pk) limit = get_results_limit(request) + available_objects = self.get_available_objects(parent, limit) - available_asns = asnrange.get_available_asns()[:limit] - - serializer = serializers.AvailableASNSerializer(available_asns, many=True, context={ + serializer = self.read_serializer_class(available_objects, many=True, context={ 'request': request, - 'range': asnrange, + **self.get_extra_context(parent), }) return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-asns']) + # TODO: Fix OpenAPI schema + # @extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)}) + # TODO: Restore advisory lock + # @advisory_lock(ADVISORY_LOCK_KEYS['available-asns']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') - asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + parent = self.get_parent(request, pk) + available_objects = self.get_available_objects(parent) # Normalize to a list of objects - requested_asns = request.data if isinstance(request.data, list) else [request.data] + requested_objects = request.data if isinstance(request.data, list) else [request.data] - # Determine if the requested number of IPs is available - available_asns = asnrange.get_available_asns() - if len(available_asns) < len(requested_asns): - return Response( - { - "detail": f"An insufficient number of ASNs are available within {asnrange} " - f"({len(requested_asns)} requested, {len(available_asns)} available)" - }, - status=status.HTTP_409_CONFLICT - ) - - # Assign ASNs from the list of available IPs and copy VRF assignment from the parent - for i, requested_asn in enumerate(requested_asns): - requested_asn.update({ - 'rir': asnrange.rir.pk, - 'range': asnrange.pk, - 'asn': available_asns[i], - }) - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.ASNSerializer(data=requested_asns, many=True, context=context) - else: - serializer = serializers.ASNSerializer(data=requested_asns[0], context=context) - - # Create the new IP address(es) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableASNSerializer - - return serializers.ASNSerializer - - -class AvailablePrefixesView(ObjectValidationMixin, APIView): - queryset = Prefix.objects.all() - - @extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)}) - def get(self, request, pk): - prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) - available_prefixes = prefix.get_available_prefixes() - - serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={ + # Serialize and validate the request data + serializer = self.write_serializer_class(data=requested_objects, many=True, context={ 'request': request, - 'vrf': prefix.vrf, + **self.get_extra_context(parent), }) - - return Response(serializer.data) - - @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) - def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) - available_prefixes = prefix.get_available_prefixes() - - # Validate Requested Prefixes' length - serializer = serializers.PrefixLengthSerializer( - data=request.data if isinstance(request.data, list) else [request.data], - many=True, - context={ - 'request': request, - 'prefix': prefix, - } - ) if not serializer.is_valid(): return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - requested_prefixes = serializer.validated_data - # Allocate prefixes to the requested objects based on availability within the parent - for i, requested_prefix in enumerate(requested_prefixes): - - # Find the first available prefix equal to or larger than the requested size - for available_prefix in available_prefixes.iter_cidrs(): - if requested_prefix['prefix_length'] >= available_prefix.prefixlen: - allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length']) - requested_prefix['prefix'] = allocated_prefix - requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None - break - else: - return Response( - { - "detail": "Insufficient space is available to accommodate the requested prefix size(s)" - }, - status=status.HTTP_409_CONFLICT - ) - - # Remove the allocated prefix from the list of available prefixes - available_prefixes.remove(allocated_prefix) - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context) - else: - serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context) - - # Create the new Prefix(es) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailablePrefixSerializer - - return serializers.PrefixLengthSerializer - - -class AvailableIPAddressesView(ObjectValidationMixin, APIView): - queryset = IPAddress.objects.all() - - def get_parent(self, request, pk): - raise NotImplemented() - - @extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)}) - def get(self, request, pk): - parent = self.get_parent(request, pk) - limit = get_results_limit(request) - - # Calculate available IPs within the parent - ip_list = [] - for index, ip in enumerate(parent.get_available_ips(), start=1): - ip_list.append(ip) - if index == limit: - break - serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={ - 'request': request, - 'parent': parent, - 'vrf': parent.vrf, - }) - - return Response(serializer.data) - - @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) - def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - parent = self.get_parent(request, pk) - - # Normalize to a list of objects - requested_ips = request.data if isinstance(request.data, list) else [request.data] - - # Determine if the requested number of IPs is available - available_ips = parent.get_available_ips() - if available_ips.size < len(requested_ips): + # Determine if the requested number of objects is available + if not self.check_sufficient_available(serializer.validated_data, available_objects): + # TODO: Raise exception instead? return Response( { - "detail": f"An insufficient number of IP addresses are available within {parent} " - f"({len(requested_ips)} requested, {len(available_ips)} available)" + "detail": f"Insufficient resources are available to satisfy the request" }, status=status.HTTP_409_CONFLICT ) - # Assign addresses from the list of available IPs and copy VRF assignment from the parent - available_ips = iter(available_ips) - for requested_ip in requested_ips: - requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}' - requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None + # Prepare object data for deserialization + requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent) # Initialize the serializer with a list or a single object depending on what was requested + serializer_class = get_serializer_for_model(self.queryset.model) context = {'request': request} if isinstance(request.data, list): - serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context) + serializer = serializer_class(data=requested_objects, many=True, context=context) else: - serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context) + serializer = serializer_class(data=requested_objects[0], context=context) # Create the new IP address(es) if serializer.is_valid(): @@ -433,11 +317,113 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableIPSerializer - return serializers.IPAddressSerializer +class AvailableASNsView(AvailableObjectsView): + queryset = ASN.objects.all() + read_serializer_class = serializers.AvailableASNSerializer + write_serializer_class = serializers.AvailableASNSerializer + + def get_parent(self, request, pk): + return get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + + def get_available_objects(self, parent, limit=None): + return parent.get_available_asns()[:limit] + + def get_extra_context(self, parent): + return { + 'range': parent, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'rir': parent.rir.pk, + 'range': parent.pk, + 'asn': available_objects[i], + }) + + return requested_objects + + +def get_next_available(ipset, prefix_size): + for available_prefix in ipset.iter_cidrs(): + if prefix_size >= available_prefix.prefixlen: + allocated_prefix = f"{available_prefix.network}/{prefix_size}" + ipset.remove(allocated_prefix) + return allocated_prefix + return None + + +class AvailablePrefixesView(AvailableObjectsView): + queryset = Prefix.objects.all() + read_serializer_class = serializers.AvailablePrefixSerializer + write_serializer_class = serializers.PrefixLengthSerializer + + def get_parent(self, request, pk): + return get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) + + def get_available_objects(self, parent, limit=None): + return parent.get_available_prefixes().iter_cidrs() + + def check_sufficient_available(self, requested_objects, available_objects): + available_prefixes = IPSet(available_objects) + for requested_object in requested_objects: + if not get_next_available(available_prefixes, requested_object['prefix_length']): + return False + return True + + def get_extra_context(self, parent): + return { + 'prefix': parent, + 'vrf': parent.vrf, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + available_prefixes = IPSet(available_objects) + for i, request_data in enumerate(requested_objects): + + # Find the first available prefix equal to or larger than the requested size + if allocated_prefix := get_next_available(available_prefixes, request_data['prefix_length']): + request_data.update({ + 'prefix': allocated_prefix, + 'vrf': parent.vrf.pk if parent.vrf else None, + }) + else: + # TODO: Handle this + raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)") + + return requested_objects + + +class AvailableIPAddressesView(AvailableObjectsView): + queryset = IPAddress.objects.all() + read_serializer_class = serializers.AvailableIPSerializer + write_serializer_class = serializers.AvailableIPSerializer + + def get_available_objects(self, parent, limit=None): + # Calculate available IPs within the parent + ip_list = [] + for index, ip in enumerate(parent.get_available_ips(), start=1): + ip_list.append(ip) + if index == limit: + break + return ip_list + + def get_extra_context(self, parent): + return { + 'parent': parent, + 'vrf': parent.vrf, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + available_ips = iter(available_objects) + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'address': f'{next(available_ips)}/{parent.mask_length}', + 'vrf': parent.vrf.pk if parent.vrf else None, + }) + + return requested_objects class PrefixAvailableIPAddressesView(AvailableIPAddressesView): @@ -452,77 +438,27 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView): return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk) -class AvailableVLANsView(ObjectValidationMixin, APIView): +class AvailableVLANsView(AvailableObjectsView): queryset = VLAN.objects.all() + read_serializer_class = serializers.AvailableVLANSerializer + write_serializer_class = serializers.CreateAvailableVLANSerializer - @extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)}) - def get(self, request, pk): - vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) - limit = get_results_limit(request) + def get_parent(self, request, pk): + return get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) - available_vlans = vlangroup.get_available_vids()[:limit] - serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={ - 'request': request, - 'group': vlangroup, - }) + def get_available_objects(self, parent, limit=None): + return parent.get_available_vids()[:limit] - return Response(serializer.data) + def get_extra_context(self, parent): + return { + 'group': parent, + } - @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans']) - def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) - available_vlans = vlangroup.get_available_vids() - many = isinstance(request.data, list) + def prep_object_data(self, requested_objects, available_objects, parent): + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'vid': available_objects.pop(0), + 'group': parent.pk, + }) - # Validate requested VLANs - serializer = serializers.CreateAvailableVLANSerializer( - data=request.data if many else [request.data], - many=True, - context={ - 'request': request, - 'group': vlangroup, - } - ) - if not serializer.is_valid(): - return Response( - serializer.errors, - status=status.HTTP_400_BAD_REQUEST - ) - - requested_vlans = serializer.validated_data - - for i, requested_vlan in enumerate(requested_vlans): - try: - requested_vlan['vid'] = available_vlans.pop(0) - requested_vlan['group'] = vlangroup.pk - except IndexError: - return Response({ - "detail": "The requested number of VLANs is not available" - }, status=status.HTTP_409_CONFLICT) - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if many: - serializer = serializers.VLANSerializer(data=requested_vlans, many=True, context=context) - else: - serializer = serializers.VLANSerializer(data=requested_vlans[0], context=context) - - # Create the new VLAN(s) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableVLANSerializer - - return serializers.VLANSerializer + return requested_objects