mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-24 17:38:37 -06:00
Merge pull request #5163 from netbox-community/3436-api-bulk-delete
#3436: Support for bulk deletion via REST API
This commit is contained in:
commit
961a491ea4
@ -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.
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user