Merge pull request #5163 from netbox-community/3436-api-bulk-delete

#3436: Support for bulk deletion via REST API
This commit is contained in:
Jeremy Stretch 2020-09-22 10:29:41 -04:00 committed by GitHub
commit 961a491ea4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 108 additions and 3 deletions

View File

@ -529,3 +529,17 @@ Note that `DELETE` requests do not return any data: If successful, the API will
!!! note
You can run `curl` with the verbose (`-v`) flag to inspect the HTTP response codes.
### Deleting Multiple Objects
NetBox supports the simultaneous deletion of multiple objects of the same type by issuing a `DELETE` request to the model's list endpoint with a list of dictionaries specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request:
```no-highlight
curl -s -X DELETE \
-H "Authorization: Token $TOKEN" \
http://netbox/api/dcim/sites/ \
--data '[{"id": 10}, {"id": 11}, {"id": 12}]'
```
!!! note
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.

View File

@ -4,11 +4,22 @@ from rest_framework import authentication, exceptions
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.schemas import coreapi
from rest_framework.utils import formatting
from users.models import Token
def is_custom_action(action):
return action not in {
'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', 'bulk_destroy'
}
# Monkey-patch DRF to treat bulk_destroy() as a non-custom action (see #3436)
coreapi.is_custom_action = is_custom_action
#
# Renderers
#

View File

@ -472,6 +472,13 @@ REST_FRAMEWORK = {
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
'PAGE_SIZE': PAGINATE_COUNT,
'SCHEMA_COERCE_METHOD_NAMES': {
# Default mappings
'retrieve': 'read',
'destroy': 'delete',
# Custom operations
'bulk_destroy': 'bulk_delete',
},
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
}

View File

@ -8,13 +8,13 @@ from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDo
from django.db import transaction
from django.db.models import ManyToManyField, ProtectedError
from django.urls import reverse
from rest_framework import serializers
from rest_framework import mixins, serializers, status
from rest_framework.exceptions import APIException, ValidationError
from rest_framework.permissions import BasePermission
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.viewsets import ModelViewSet as _ModelViewSet
from rest_framework.viewsets import GenericViewSet
from .utils import dict_to_filter_params, dynamic_import
@ -291,11 +291,53 @@ class WritableNestedSerializer(serializers.ModelSerializer):
)
class BulkDeleteSerializer(serializers.Serializer):
id = serializers.IntegerField()
#
# Mixins
#
class BulkDestroyModelMixin:
"""
Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one
or more JSON objects, each specifying the numeric ID of an object to be deleted. For example:
DELETE /api/dcim/sites/
[
{"id": 123},
{"id": 456}
]
"""
def bulk_destroy(self, request):
serializer = BulkDeleteSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
pk_list = [o['id'] for o in serializer.data]
qs = self.get_queryset().filter(pk__in=pk_list)
self.perform_bulk_destroy(qs)
return Response(status=status.HTTP_204_NO_CONTENT)
def perform_bulk_destroy(self, objects):
with transaction.atomic():
for obj in objects:
self.perform_destroy(obj)
#
# Viewsets
#
class ModelViewSet(_ModelViewSet):
class ModelViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
BulkDestroyModelMixin,
GenericViewSet):
"""
Accept either a single object or a list of objects to create.
"""
@ -408,6 +450,14 @@ class ModelViewSet(_ModelViewSet):
class OrderedDefaultRouter(DefaultRouter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Extend the list view mappings to support the DELETE operation
self.routes[0].mapping.update({
'delete': 'bulk_destroy',
})
def get_api_root_view(self, api_urls=None):
"""
Wrap DRF's DefaultRouter to return an alphabetized list of endpoints.

View File

@ -300,6 +300,29 @@ class APIViewTestCases:
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertFalse(self._get_queryset().filter(pk=instance.pk).exists())
def test_bulk_delete_objects(self):
"""
DELETE a set of objects in a single request.
"""
# Add object-level permission
obj_perm = ObjectPermission(
actions=['delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Target the three most recently created objects to avoid triggering recursive deletions
# (e.g. with MPTT objects)
id_list = self._get_queryset().order_by('-id').values_list('id', flat=True)[:3]
self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk deletion")
data = [{"id": id} for id in id_list]
initial_count = self._get_queryset().count()
response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(self._get_queryset().count(), initial_count - 3)
class APIViewTestCase(
GetObjectViewTestCase,
ListObjectsViewTestCase,