From 210b8d274f794fd7ca6b2efe00a8eb34ff04c068 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Nov 2023 10:19:22 -0400 Subject: [PATCH] Add support for the bulk deletion of restricted objects via REST API --- netbox/dcim/api/views.py | 5 +++++ netbox/dcim/tests/test_api.py | 27 +++++++++++++++++++++++++ netbox/netbox/api/viewsets/__init__.py | 9 ++++++--- netbox/netbox/api/viewsets/mixins.py | 10 +++++++-- netbox/virtualization/api/views.py | 5 +++++ netbox/virtualization/tests/test_api.py | 26 ++++++++++++++++++++++++ 6 files changed, 77 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 80a991736..a3e532f0b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -24,6 +24,7 @@ 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 virtualization.models import VirtualMachine from . import serializers @@ -505,6 +506,10 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): filterset_class = filtersets.InterfaceFilterSet brief_prefetch_fields = ['device'] + def get_bulk_destroy_queryset(self): + # Ensure child interfaces are deleted prior to their parents + return self.get_queryset().order_by('device', 'parent', CollateAsChar('_name')) + class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = FrontPort.objects.prefetch_related( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1ce362963..d3211a75f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1607,6 +1607,33 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, ] + def test_bulk_delete_child_interfaces(self): + interface1 = Interface.objects.get(name='Interface 1') + device = interface1.device + self.add_permissions('dcim.delete_interface') + + # Create a child interface + child = Interface.objects.create( + device=device, + name='Interface 1A', + type=InterfaceTypeChoices.TYPE_VIRTUAL, + parent=interface1 + ) + self.assertEqual(device.interfaces.count(), 4) + + # Attempt to delete only the parent interface + url = self._get_detail_url(interface1) + self.client.delete(url, **self.header) + self.assertEqual(device.interfaces.count(), 4) # Parent was not deleted + + # Attempt to bulk delete parent & child together + data = [ + {"id": interface1.pk}, + {"id": child.pk}, + ] + self.client.delete(self._get_list_url(), data, format='json', **self.header) + self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted + class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index c6794bb61..522bcf77b 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -2,7 +2,7 @@ import logging from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction -from django.db.models import ProtectedError +from django.db.models import ProtectedError, RestrictedError from django_pglocks import advisory_lock from netbox.constants import ADVISORY_LOCK_KEYS from rest_framework import mixins as drf_mixins @@ -91,8 +91,11 @@ class NetBoxModelViewSet( try: return super().dispatch(request, *args, **kwargs) - except ProtectedError as e: - protected_objects = list(e.protected_objects) + except (ProtectedError, RestrictedError) as e: + if type(e) is ProtectedError: + protected_objects = list(e.protected_objects) + else: + protected_objects = list(e.restricted_objects) msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) logger.warning(msg) diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index fde486fe9..7b6c00843 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -137,11 +137,14 @@ class BulkUpdateModelMixin: } ] """ + def get_bulk_update_queryset(self): + return self.get_queryset() + def bulk_update(self, request, *args, **kwargs): partial = kwargs.pop('partial', False) serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( + qs = self.get_bulk_update_queryset().filter( pk__in=[o['id'] for o in serializer.data] ) @@ -184,10 +187,13 @@ class BulkDestroyModelMixin: {"id": 456} ] """ + def get_bulk_destroy_queryset(self): + return self.get_queryset() + def bulk_destroy(self, request, *args, **kwargs): serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( + qs = self.get_bulk_destroy_queryset().filter( pk__in=[o['id'] for o in serializer.data] ) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 5b9cf4117..04e8f2167 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -3,6 +3,7 @@ from rest_framework.routers import APIRootView from dcim.models import Device from extras.api.mixins import ConfigContextQuerySetMixin from netbox.api.viewsets import NetBoxModelViewSet +from utilities.query_functions import CollateAsChar from utilities.utils import count_related from virtualization import filtersets from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -87,3 +88,7 @@ class VMInterfaceViewSet(NetBoxModelViewSet): serializer_class = serializers.VMInterfaceSerializer filterset_class = filtersets.VMInterfaceFilterSet brief_prefetch_fields = ['virtual_machine'] + + def get_bulk_destroy_queryset(self): + # Ensure child interfaces are deleted prior to their parents + return self.get_queryset().order_by('virtual_machine', 'parent', CollateAsChar('_name')) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index b2ae68860..3fb46fbb9 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -293,3 +293,29 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'vrf': vrfs[2].pk, }, ] + + def test_bulk_delete_child_interfaces(self): + interface1 = VMInterface.objects.get(name='Interface 1') + virtual_machine = interface1.virtual_machine + self.add_permissions('virtualization.delete_vminterface') + + # Create a child interface + child = VMInterface.objects.create( + virtual_machine=virtual_machine, + name='Interface 1A', + parent=interface1 + ) + self.assertEqual(virtual_machine.interfaces.count(), 4) + + # Attempt to delete only the parent interface + url = self._get_detail_url(interface1) + self.client.delete(url, **self.header) + self.assertEqual(virtual_machine.interfaces.count(), 4) # Parent was not deleted + + # Attempt to bulk delete parent & child together + data = [ + {"id": interface1.pk}, + {"id": child.pk}, + ] + self.client.delete(self._get_list_url(), data, format='json', **self.header) + self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted