Add support for the bulk deletion of restricted objects via REST API

This commit is contained in:
Jeremy Stretch 2023-11-01 10:19:22 -04:00
parent 9eb1ce7791
commit 210b8d274f
6 changed files with 77 additions and 5 deletions

View File

@ -24,6 +24,7 @@ from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.query_functions import CollateAsChar
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import serializers from . import serializers
@ -505,6 +506,10 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
filterset_class = filtersets.InterfaceFilterSet filterset_class = filtersets.InterfaceFilterSet
brief_prefetch_fields = ['device'] 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): class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = FrontPort.objects.prefetch_related( queryset = FrontPort.objects.prefetch_related(

View File

@ -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): class FrontPortTest(APIViewTestCases.APIViewTestCase):
model = FrontPort model = FrontPort

View File

@ -2,7 +2,7 @@ import logging
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction 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 django_pglocks import advisory_lock
from netbox.constants import ADVISORY_LOCK_KEYS from netbox.constants import ADVISORY_LOCK_KEYS
from rest_framework import mixins as drf_mixins from rest_framework import mixins as drf_mixins
@ -91,8 +91,11 @@ class NetBoxModelViewSet(
try: try:
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
except ProtectedError as e: except (ProtectedError, RestrictedError) as e:
protected_objects = list(e.protected_objects) 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 = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
logger.warning(msg) logger.warning(msg)

View File

@ -137,11 +137,14 @@ class BulkUpdateModelMixin:
} }
] ]
""" """
def get_bulk_update_queryset(self):
return self.get_queryset()
def bulk_update(self, request, *args, **kwargs): def bulk_update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False) partial = kwargs.pop('partial', False)
serializer = BulkOperationSerializer(data=request.data, many=True) serializer = BulkOperationSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=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] pk__in=[o['id'] for o in serializer.data]
) )
@ -184,10 +187,13 @@ class BulkDestroyModelMixin:
{"id": 456} {"id": 456}
] ]
""" """
def get_bulk_destroy_queryset(self):
return self.get_queryset()
def bulk_destroy(self, request, *args, **kwargs): def bulk_destroy(self, request, *args, **kwargs):
serializer = BulkOperationSerializer(data=request.data, many=True) serializer = BulkOperationSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=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] pk__in=[o['id'] for o in serializer.data]
) )

View File

@ -3,6 +3,7 @@ from rest_framework.routers import APIRootView
from dcim.models import Device from dcim.models import Device
from extras.api.mixins import ConfigContextQuerySetMixin from extras.api.mixins import ConfigContextQuerySetMixin
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from utilities.query_functions import CollateAsChar
from utilities.utils import count_related from utilities.utils import count_related
from virtualization import filtersets from virtualization import filtersets
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -87,3 +88,7 @@ class VMInterfaceViewSet(NetBoxModelViewSet):
serializer_class = serializers.VMInterfaceSerializer serializer_class = serializers.VMInterfaceSerializer
filterset_class = filtersets.VMInterfaceFilterSet filterset_class = filtersets.VMInterfaceFilterSet
brief_prefetch_fields = ['virtual_machine'] 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'))

View File

@ -293,3 +293,29 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
'vrf': vrfs[2].pk, '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