Skip to content

Commit

Permalink
Merge pull request #5163 from netbox-community/3436-api-bulk-delete
Browse files Browse the repository at this point in the history
#3436: Support for bulk deletion via REST API
  • Loading branch information
jeremystretch authored Sep 22, 2020
2 parents 2b68923 + 54a4f84 commit 961a491
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 3 deletions.
14 changes: 14 additions & 0 deletions docs/rest-api/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 11 additions & 0 deletions netbox/netbox/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,22 @@
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
#
Expand Down
7 changes: 7 additions & 0 deletions netbox/netbox/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,13 @@ def _setting(name, default=None):
'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',
}

Expand Down
56 changes: 53 additions & 3 deletions netbox/utilities/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
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

Expand Down Expand Up @@ -291,11 +291,53 @@ def to_internal_value(self, data):
)


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.
"""
Expand Down Expand Up @@ -408,6 +450,14 @@ def perform_destroy(self, instance):

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.
Expand Down
23 changes: 23 additions & 0 deletions netbox/utilities/testing/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,29 @@ def test_delete_object(self):
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,
Expand Down

0 comments on commit 961a491

Please sign in to comment.