Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds API mixin for "bulk delete" #3146

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion InvenTree/InvenTree/api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""Main JSON interface views."""

from django.conf import settings
from django.db import transaction
from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _

from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions
from rest_framework import filters, generics, permissions
from rest_framework.response import Response
from rest_framework.serializers import ValidationError

from .status import is_worker_running
from .version import (inventreeApiVersion, inventreeInstanceName,
Expand Down Expand Up @@ -50,6 +53,60 @@ def get(self, request, *args, **kwargs):
return JsonResponse(data, status=404)


class BulkDeleteMixin:
"""Mixin class for enabling 'bulk delete' operations for various models.

Bulk delete allows for multiple items to be deleted in a single API query,
rather than using multiple API calls to the various detail endpoints.

This is implemented for two major reasons:
- Atomicity (guaranteed that either *all* items are deleted, or *none*)
- Speed (single API call and DB query)
"""

def delete(self, request, *args, **kwargs):
"""Perform a DELETE operation against this list endpoint.

We expect a list of primary-key (ID) values to be supplied as a JSON object, e.g.
{
items: [4, 8, 15, 16, 23, 42]
}

"""
model = self.serializer_class.Meta.model

# Extract the items from the request body
try:
items = request.data.getlist('items', None)
except AttributeError:
items = request.data.get('items', None)

if items is None or type(items) is not list or not items:
raise ValidationError({
"non_field_errors": ["List of items must be provided for bulk deletion"]
})

# Keep track of how many items we deleted
n_deleted = 0

with transaction.atomic():
objects = model.objects.filter(id__in=items)
n_deleted = objects.count()
objects.delete()

return Response(
{
'success': f"Deleted {n_deleted} items",
},
status=204
)


class ListCreateDestroyAPIView(BulkDeleteMixin, generics.ListCreateAPIView):
"""Custom API endpoint which provides BulkDelete functionality in addition to List and Create"""
...


class APIDownloadMixin:
"""Mixin for enabling a LIST endpoint to be downloaded a file.

Expand Down
23 changes: 20 additions & 3 deletions InvenTree/InvenTree/api_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,30 @@ def get(self, url, data=None, expected_code=200):

return response

def post(self, url, data, expected_code=None, format='json'):
def post(self, url, data=None, expected_code=None, format='json'):
"""Issue a POST request."""
response = self.client.post(url, data=data, format=format)

if data is None:
data = {}

if expected_code is not None:

if response.status_code != expected_code:
print(f"Unexpected response at '{url}':")
print(response.data)

self.assertEqual(response.status_code, expected_code)

return response

def delete(self, url, expected_code=None):
def delete(self, url, data=None, expected_code=None, format='json'):
"""Issue a DELETE request."""
response = self.client.delete(url)

if data is None:
data = {}

response = self.client.delete(url, data=data, foramt=format)

if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
Expand All @@ -155,6 +167,11 @@ def put(self, url, data, expected_code=None, format='json'):
response = self.client.put(url, data=data, format=format)

if expected_code is not None:

if response.status_code != expected_code:
print(f"Unexpected response at '{url}':")
print(response.data)

self.assertEqual(response.status_code, expected_code)

return response
Expand Down
5 changes: 4 additions & 1 deletion InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@


# InvenTree API version
INVENTREE_API_VERSION = 57
INVENTREE_API_VERSION = 58

"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about

v58 -> 2022-06-06 : https://github.com/inventree/InvenTree/pull/3146
- Adds a BulkDelete API mixin class for fast, safe deletion of multiple objects with a single API request

v57 -> 2022-06-05 : https://github.com/inventree/InvenTree/pull/3130
- Transfer PartCategoryTemplateParameter actions to the API

Expand Down
4 changes: 2 additions & 2 deletions InvenTree/build/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters

from InvenTree.api import AttachmentMixin, APIDownloadMixin
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView
from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.status_codes import BuildStatus
Expand Down Expand Up @@ -413,7 +413,7 @@ def filter_queryset(self, queryset):
]


class BuildAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""

queryset = BuildOrderAttachment.objects.all()
Expand Down
10 changes: 5 additions & 5 deletions InvenTree/company/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, generics

from InvenTree.api import AttachmentMixin
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView
from InvenTree.helpers import str2bool

from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
Expand Down Expand Up @@ -98,7 +98,7 @@ class Meta:
active = rest_filters.BooleanFilter(field_name='part__active')


class ManufacturerPartList(generics.ListCreateAPIView):
class ManufacturerPartList(ListCreateDestroyAPIView):
"""API endpoint for list view of ManufacturerPart object.

- GET: Return list of ManufacturerPart objects
Expand Down Expand Up @@ -158,7 +158,7 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = ManufacturerPartSerializer


class ManufacturerPartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload)."""

queryset = ManufacturerPartAttachment.objects.all()
Expand All @@ -180,7 +180,7 @@ class ManufacturerPartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateD
serializer_class = ManufacturerPartAttachmentSerializer


class ManufacturerPartParameterList(generics.ListCreateAPIView):
class ManufacturerPartParameterList(ListCreateDestroyAPIView):
"""API endpoint for list view of ManufacturerPartParamater model."""

queryset = ManufacturerPartParameter.objects.all()
Expand Down Expand Up @@ -253,7 +253,7 @@ class ManufacturerPartParameterDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = ManufacturerPartParameterSerializer


class SupplierPartList(generics.ListCreateAPIView):
class SupplierPartList(ListCreateDestroyAPIView):
"""API endpoint for list view of SupplierPart object.

- GET: Return list of SupplierPart objects
Expand Down
7 changes: 4 additions & 3 deletions InvenTree/order/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import order.models as models
import order.serializers as serializers
from company.models import SupplierPart
from InvenTree.api import APIDownloadMixin, AttachmentMixin
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView)
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
Expand Down Expand Up @@ -527,7 +528,7 @@ class PurchaseOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = serializers.PurchaseOrderExtraLineSerializer


class SalesOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""

queryset = models.SalesOrderAttachment.objects.all()
Expand Down Expand Up @@ -1056,7 +1057,7 @@ def get_serializer_context(self):
return ctx


class PurchaseOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""

queryset = models.PurchaseOrderAttachment.objects.all()
Expand Down
7 changes: 4 additions & 3 deletions InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
from build.models import Build, BuildItem
from common.models import InvenTreeSetting
from company.models import Company, ManufacturerPart, SupplierPart
from InvenTree.api import APIDownloadMixin, AttachmentMixin
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView)
from InvenTree.helpers import DownloadFile, increment, isNull, str2bool
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus)
Expand Down Expand Up @@ -302,7 +303,7 @@ class PartInternalPriceList(generics.ListCreateAPIView):
]


class PartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a PartAttachment (file upload)."""

queryset = PartAttachment.objects.all()
Expand Down Expand Up @@ -1522,7 +1523,7 @@ def filter_validated(self, queryset, name, value):
return queryset


class BomList(generics.ListCreateAPIView):
class BomList(ListCreateDestroyAPIView):
"""API endpoint for accessing a list of BomItem objects.

- GET: Return list of BomItem objects
Expand Down
9 changes: 5 additions & 4 deletions InvenTree/stock/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
from build.models import Build
from company.models import Company, SupplierPart
from company.serializers import CompanySerializer, SupplierPartSerializer
from InvenTree.api import APIDownloadMixin, AttachmentMixin
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView)
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull,
str2bool)
Expand Down Expand Up @@ -465,7 +466,7 @@ def filter_has_purchase_price(self, queryset, name, value):
updated_after = rest_filters.DateFilter(label='Updated after', field_name='updated', lookup_expr='gte')


class StockList(APIDownloadMixin, generics.ListCreateAPIView):
class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of Stock objects.

- GET: Return a list of all StockItem objects (with optional query filters)
Expand Down Expand Up @@ -1043,7 +1044,7 @@ def filter_queryset(self, queryset):
]


class StockAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
class StockAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a StockItemAttachment (file upload)."""

queryset = StockItemAttachment.objects.all()
Expand Down Expand Up @@ -1074,7 +1075,7 @@ class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = StockSerializers.StockItemTestResultSerializer


class StockItemTestResultList(generics.ListCreateAPIView):
class StockItemTestResultList(ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a StockItemTestResult object."""

queryset = StockItemTestResult.objects.all()
Expand Down
26 changes: 9 additions & 17 deletions InvenTree/stock/templates/stock/item.html
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,12 @@ <h4>{% trans "Installed Stock Items" %}</h4>
{
success: function(response) {

var results = [];
var items = [];

// Ensure that we are only deleting the correct test results
response.forEach(function(item) {
if (item.stock_item == {{ item.pk }}) {
results.push(item);
response.forEach(function(result) {
if (result.stock_item == {{ item.pk }}) {
items.push(result.pk);
}
});

Expand All @@ -290,22 +290,14 @@ <h4>{% trans "Installed Stock Items" %}</h4>
{% trans "Delete all test results for this stock item" %}
</div>`;

constructFormBody({}, {
constructForm(url, {
form_data: {
items: items,
},
method: 'DELETE',
title: '{% trans "Delete Test Data" %}',
preFormContent: html,
onSubmit: function(fields, opts) {
inventreeMultiDelete(
url,
results,
{
modal: opts.modal,
success: function() {
reloadTable();
}
}
)
}
onSuccess: reloadTable,
});
}
}
Expand Down
46 changes: 45 additions & 1 deletion InvenTree/stock/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from common.models import InvenTreeSetting
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus
from stock.models import StockItem, StockLocation
from stock.models import StockItem, StockItemTestResult, StockLocation


class StockAPITestCase(InvenTreeAPITestCase):
Expand Down Expand Up @@ -934,6 +934,50 @@ def test_post_bitmap(self):
# Check that an attachment has been uploaded
self.assertIsNotNone(response.data['attachment'])

def test_bulk_delete(self):
"""Test that the BulkDelete endpoint works for this model"""

n = StockItemTestResult.objects.count()

tests = []

url = reverse('api-stock-test-result-list')

# Create some objects (via the API)
for _ii in range(50):
response = self.post(
url,
{
'stock_item': 1,
'test': f"Some test {_ii}",
'result': True,
'value': 'Test result value'
},
expected_code=201
)

tests.append(response.data['pk'])

self.assertEqual(StockItemTestResult.objects.count(), n + 50)

# Attempt a delete without providing items
self.delete(
url,
{},
expected_code=400,
)

# Now, let's delete all the newly created items with a single API request
response = self.delete(
url,
{
'items': tests,
},
expected_code=204
)

self.assertEqual(StockItemTestResult.objects.count(), n)


class StockAssignTest(StockAPITestCase):
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""
Expand Down
Loading