Просмотр исходного кода

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

#3436: Support for bulk deletion via REST API
Jeremy Stretch 5 лет назад
Родитель
Сommit
961a491ea4

+ 14 - 0
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.

+ 11 - 0
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
 #

+ 7 - 0
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',
 }
 

+ 53 - 3
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.

+ 23 - 0
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,