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 !!! note
You can run `curl` with the verbose (`-v`) flag to inspect the HTTP response codes. 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.pagination import LimitOffsetPagination
from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS
from rest_framework.renderers import BrowsableAPIRenderer from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.schemas import coreapi
from rest_framework.utils import formatting from rest_framework.utils import formatting
from users.models import Token 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 # Renderers
# #

View File

@ -472,6 +472,13 @@ REST_FRAMEWORK = {
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
'PAGE_SIZE': PAGINATE_COUNT, '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_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 import transaction
from django.db.models import ManyToManyField, ProtectedError from django.db.models import ManyToManyField, ProtectedError
from django.urls import reverse 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.exceptions import APIException, ValidationError
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import DefaultRouter 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 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 # 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. Accept either a single object or a list of objects to create.
""" """
@ -408,6 +450,14 @@ class ModelViewSet(_ModelViewSet):
class OrderedDefaultRouter(DefaultRouter): 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): def get_api_root_view(self, api_urls=None):
""" """
Wrap DRF's DefaultRouter to return an alphabetized list of endpoints. 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.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertFalse(self._get_queryset().filter(pk=instance.pk).exists()) 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( class APIViewTestCase(
GetObjectViewTestCase, GetObjectViewTestCase,
ListObjectsViewTestCase, ListObjectsViewTestCase,