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

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

#3436: Support for bulk deletion via REST API
Jeremy Stretch 5 лет назад
Родитель
Сommit
961a491ea4
5 измененных файлов с 108 добавлено и 3 удалено
  1. 14 0
      docs/rest-api/overview.md
  2. 11 0
      netbox/netbox/api.py
  3. 7 0
      netbox/netbox/settings.py
  4. 53 3
      netbox/utilities/api.py
  5. 23 0
      netbox/utilities/testing/api.py

+ 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
 !!! 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.

+ 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.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
 #
 #

+ 7 - 0
netbox/netbox/settings.py

@@ -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',
 }
 }
 
 

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

+ 23 - 0
netbox/utilities/testing/api.py

@@ -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,