diff --git a/netbox/ipam/api/mixins.py b/netbox/ipam/api/mixins.py index 552c77d57..9d7f4e4d0 100644 --- a/netbox/ipam/api/mixins.py +++ b/netbox/ipam/api/mixins.py @@ -13,90 +13,6 @@ from utilities.constants import ADVISORY_LOCK_KEYS from . import serializers -class AvailablePrefixesMixin: - - @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)}) - @swagger_auto_schema(method='post', responses={201: serializers.PrefixSerializer(many=False)}) - @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) - @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) - def available_prefixes(self, request, pk=None): - """ - A convenience method for returning available child prefixes within a parent. - - The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being - invoked in parallel, which results in a race condition where multiple insertions can occur. - """ - prefix = get_object_or_404(self.queryset, pk=pk) - available_prefixes = prefix.get_available_prefixes() - - if request.method == 'POST': - - # 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_204_NO_CONTENT - ) - - # 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) - - else: - - serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={ - 'request': request, - 'vrf': prefix.vrf, - }) - - return Response(serializer.data) - - class AvailableIPsMixin: parent_model = Prefix diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index e465fbd89..a3bfcb330 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,3 +1,5 @@ +from django.urls import path + from netbox.api import OrderedDefaultRouter from . import views @@ -42,4 +44,9 @@ router.register('vlans', views.VLANViewSet) router.register('services', views.ServiceViewSet) app_name = 'ipam-api' -urlpatterns = router.urls + +urlpatterns = [ + path('prefixes//available-prefixes/', views.AvailablePrefixesView.as_view(), name='prefix-available-prefixes'), +] + +urlpatterns += router.urls diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index cdb40333d..b410c7b74 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,10 +1,19 @@ +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.db import transaction +from django_pglocks import advisory_lock +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.response import Response from rest_framework.routers import APIRootView +from rest_framework.views import APIView + from dcim.models import Site from extras.api.views import CustomFieldModelViewSet from ipam import filtersets from ipam.models import * -from netbox.api.views import ModelViewSet +from netbox.api.views import ModelViewSet, ObjectValidationMixin +from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import count_related from . import mixins, serializers @@ -18,7 +27,7 @@ class IPAMRootView(APIRootView): # -# ASNs +# Viewsets # class ASNViewSet(CustomFieldModelViewSet): @@ -27,10 +36,6 @@ class ASNViewSet(CustomFieldModelViewSet): filterset_class = filtersets.ASNFilterSet -# -# VRFs -# - class VRFViewSet(CustomFieldModelViewSet): queryset = VRF.objects.prefetch_related('tenant').prefetch_related( 'import_targets', 'export_targets', 'tags' @@ -42,20 +47,12 @@ class VRFViewSet(CustomFieldModelViewSet): filterset_class = filtersets.VRFFilterSet -# -# Route targets -# - class RouteTargetViewSet(CustomFieldModelViewSet): queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags') serializer_class = serializers.RouteTargetSerializer filterset_class = filtersets.RouteTargetFilterSet -# -# RIRs -# - class RIRViewSet(CustomFieldModelViewSet): queryset = RIR.objects.annotate( aggregate_count=count_related(Aggregate, 'rir') @@ -64,20 +61,12 @@ class RIRViewSet(CustomFieldModelViewSet): filterset_class = filtersets.RIRFilterSet -# -# Aggregates -# - class AggregateViewSet(CustomFieldModelViewSet): queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags') serializer_class = serializers.AggregateSerializer filterset_class = filtersets.AggregateFilterSet -# -# Roles -# - class RoleViewSet(CustomFieldModelViewSet): queryset = Role.objects.annotate( prefix_count=count_related(Prefix, 'role'), @@ -87,11 +76,7 @@ class RoleViewSet(CustomFieldModelViewSet): filterset_class = filtersets.RoleFilterSet -# -# Prefixes -# - -class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, CustomFieldModelViewSet): +class PrefixViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet): queryset = Prefix.objects.prefetch_related( 'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags' ) @@ -106,10 +91,6 @@ class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, Cus return super().get_serializer_class() -# -# IP ranges -# - class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet): queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags') serializer_class = serializers.IPRangeSerializer @@ -118,10 +99,6 @@ class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet): parent_model = IPRange # AvailableIPsMixin -# -# IP addresses -# - class IPAddressViewSet(CustomFieldModelViewSet): queryset = IPAddress.objects.prefetch_related( 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object' @@ -130,10 +107,6 @@ class IPAddressViewSet(CustomFieldModelViewSet): filterset_class = filtersets.IPAddressFilterSet -# -# FHRP groups -# - class FHRPGroupViewSet(CustomFieldModelViewSet): queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') serializer_class = serializers.FHRPGroupSerializer @@ -147,10 +120,6 @@ class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet): filterset_class = filtersets.FHRPGroupAssignmentFilterSet -# -# VLAN groups -# - class VLANGroupViewSet(CustomFieldModelViewSet): queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') @@ -159,10 +128,6 @@ class VLANGroupViewSet(CustomFieldModelViewSet): filterset_class = filtersets.VLANGroupFilterSet -# -# VLANs -# - class VLANViewSet(CustomFieldModelViewSet): queryset = VLAN.objects.prefetch_related( 'site', 'group', 'tenant', 'role', 'tags' @@ -173,13 +138,89 @@ class VLANViewSet(CustomFieldModelViewSet): filterset_class = filtersets.VLANFilterSet -# -# Services -# - class ServiceViewSet(ModelViewSet): queryset = Service.objects.prefetch_related( 'device', 'virtual_machine', 'tags', 'ipaddresses' ) serializer_class = serializers.ServiceSerializer filterset_class = filtersets.ServiceFilterSet + + +# +# Views +# + +class AvailablePrefixesView(ObjectValidationMixin, APIView): + queryset = Prefix.objects.all() + + def get(self, request, pk): + prefix = get_object_or_404(self.queryset, pk=pk) + available_prefixes = prefix.get_available_prefixes() + + serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={ + 'request': request, + 'vrf': prefix.vrf, + }) + + return Response(serializer.data) + + @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) + def post(self, request, pk): + prefix = get_object_or_404(self.queryset, 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_204_NO_CONTENT + ) + + # 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) diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 7ad64aeae..2df0a4c83 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -123,11 +123,28 @@ class BulkDestroyModelMixin: self.perform_destroy(obj) +class ObjectValidationMixin: + + def _validate_objects(self, instance): + """ + Check that the provided instance or list of instances are matched by the current queryset. This confirms that + any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions. + """ + if type(instance) is list: + # Check that all instances are still included in the view's queryset + conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() + if conforming_count != len(instance): + raise ObjectDoesNotExist + else: + # Check that the instance is matched by the view's queryset + self.queryset.get(pk=instance.pk) + + # # Viewsets # -class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): +class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet_): """ Extend DRF's ModelViewSet to support bulk update and delete functions. """ @@ -211,20 +228,6 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): **kwargs ) - def _validate_objects(self, instance): - """ - Check that the provided instance or list of instances are matched by the current queryset. This confirms that - any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions. - """ - if type(instance) is list: - # Check that all instances are still included in the view's queryset - conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() - if conforming_count != len(instance): - raise ObjectDoesNotExist - else: - # Check that the instance is matched by the view's queryset - self.queryset.get(pk=instance.pk) - def list(self, request, *args, **kwargs): """ Overrides ListModelMixin to allow processing ExportTemplates.