From 6694ec78bc1723c2e339f53a1e26d936f541d5b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 15 Sep 2020 13:36:36 -0400 Subject: [PATCH 1/4] Implement support for bulk deletion of objects via a single REST API request --- netbox/utilities/api.py | 56 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index cc9789161..acafd81bd 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -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. From eba2ea06ffd58807e854df761884bac50a506e54 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 15 Sep 2020 14:36:38 -0400 Subject: [PATCH 2/4] Add test for bulk API deletions --- netbox/utilities/testing/api.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index bf6ebd7ff..2c6c70fea 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -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, From c1b57af7718f08bcef4108cb365d35a4fbd1bcbc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 10:06:13 -0400 Subject: [PATCH 3/4] Monkey-patch DRF to treat bulk_destroy as a built-in operation --- netbox/netbox/api.py | 11 +++++++++++ netbox/netbox/settings.py | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index 28403f181..9d65ba8b8 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -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 # diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bd247070f..7e1966edb 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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', } From 54a4f847081e8949717946434872b5a7fc4e1d0f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 10:18:15 -0400 Subject: [PATCH 4/4] Add REST API documentation for bulk object deletion --- docs/rest-api/overview.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md index d16cd059d..34ea7c12f 100644 --- a/docs/rest-api/overview.md +++ b/docs/rest-api/overview.md @@ -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.