From 8ceb1af3c33f5912b49a1dcc8952dfa4ce7a44fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20M=C3=A1rton?= Date: Sun, 13 Nov 2022 22:07:16 +0100 Subject: [PATCH] Add support for recursively delete the stock locations (#3926) --- InvenTree/stock/api.py | 15 ++- InvenTree/stock/models.py | 50 ++++++--- InvenTree/stock/test_api.py | 118 +++++++++++++++++++++ InvenTree/templates/js/translated/stock.js | 27 ++++- 4 files changed, 187 insertions(+), 23 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 287b76ce8d..e2a9604ec4 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -27,7 +27,8 @@ from InvenTree.api import (APIDownloadMixin, AttachmentMixin, from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull, str2bool, str2int) -from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI, +from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI, + ListAPI, ListCreateAPI, RetrieveAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation from order.serializers import PurchaseOrderSerializer @@ -1357,7 +1358,7 @@ class LocationMetadata(RetrieveUpdateAPI): queryset = StockLocation.objects.all() -class LocationDetail(RetrieveUpdateDestroyAPI): +class LocationDetail(CustomRetrieveUpdateDestroyAPI): """API endpoint for detail view of StockLocation object. - GET: Return a single StockLocation object @@ -1375,6 +1376,16 @@ class LocationDetail(RetrieveUpdateDestroyAPI): queryset = StockSerializers.LocationSerializer.annotate_queryset(queryset) return queryset + def destroy(self, request, *args, **kwargs): + """Delete a Stock location instance via the API""" + delete_stock_items = 'delete_stock_items' in request.data and request.data['delete_stock_items'] == '1' + delete_sub_locations = 'delete_sub_locations' in request.data and request.data['delete_sub_locations'] == '1' + return super().destroy(request, + *args, + **dict(kwargs, + delete_sub_locations=delete_sub_locations, + delete_stock_items=delete_stock_items)) + stock_api_urls = [ re_path(r'^location/', include([ diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index aeae77fbb7..7c513bb694 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -43,9 +43,36 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree): """Organization tree for StockItem objects. A "StockLocation" can be considered a warehouse, or storage location - Stock locations can be heirarchical as required + Stock locations can be hierarchical as required """ + def delete_recursive(self, *args, **kwargs): + """This function handles the recursive deletion of sub-locations depending on kwargs contents""" + delete_stock_items = kwargs.get('delete_stock_items', False) + parent_location = kwargs.get('parent_location', None) + + if parent_location is None: + # First iteration, (no parent_location kwargs passed) + parent_location = self.parent + + for child_item in self.get_stock_items(False): + if delete_stock_items: + child_item.delete() + else: + child_item.location = parent_location + child_item.save() + + for child_location in self.children.all(): + if kwargs.get('delete_sub_locations', False): + child_location.delete_recursive(**dict(delete_sub_locations=True, + delete_stock_items=delete_stock_items, + parent_location=parent_location)) + else: + child_location.parent = parent_location + child_location.save() + + super().delete(*args, **dict()) + def delete(self, *args, **kwargs): """Custom model deletion routine, which updates any child locations or items. @@ -53,24 +80,13 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree): """ with transaction.atomic(): - parent = self.parent - tree_id = self.tree_id + self.delete_recursive(**dict(delete_stock_items=kwargs.get('delete_stock_items', False), + delete_sub_locations=kwargs.get('delete_sub_locations', False), + parent_category=self.parent)) - # Update each stock item in the stock location - for item in self.stock_items.all(): - item.location = self.parent - item.save() - - # Update each child category - for child in self.children.all(): - child.parent = self.parent - child.save() - - super().delete(*args, **kwargs) - - if parent is not None: + if self.parent is not None: # Partially rebuild the tree (cheaper than a complete rebuild) - StockLocation.objects.partial_rebuild(tree_id) + StockLocation.objects.partial_rebuild(self.tree_id) else: StockLocation.objects.rebuild() diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 17e96ca7c6..1d846e2725 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -3,6 +3,7 @@ import io import os from datetime import datetime, timedelta +from enum import IntEnum import django.http from django.urls import reverse @@ -15,6 +16,7 @@ import part.models from common.models import InvenTreeSetting from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.status_codes import StockStatus +from part.models import Part from stock.models import StockItem, StockItemTestResult, StockLocation @@ -37,6 +39,7 @@ class StockAPITestCase(InvenTreeAPITestCase): 'stock.add', 'stock_location.change', 'stock_location.add', + 'stock_location.delete', 'stock.delete', ] @@ -107,6 +110,121 @@ class StockLocationTest(StockAPITestCase): response = self.client.post(self.list_url, data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def test_stock_location_delete(self): + """Test stock location deletion with different parameters""" + + class Target(IntEnum): + move_sub_locations_to_parent_move_stockitems_to_parent = 0, + move_sub_locations_to_parent_delete_stockitems = 1, + delete_sub_locations_move_stockitems_to_parent = 2, + delete_sub_locations_delete_stockitems = 3, + + # First, construct a set of template / variant parts + part = Part.objects.create( + name='Part for stock item creation', description='Part for stock item creation', + category=None, + is_template=False, + ) + + for i in range(4): + delete_sub_locations: bool = False + delete_stock_items: bool = False + + if i == Target.move_sub_locations_to_parent_delete_stockitems \ + or i == Target.delete_sub_locations_delete_stockitems: + delete_stock_items = True + if i == Target.delete_sub_locations_move_stockitems_to_parent \ + or i == Target.delete_sub_locations_delete_stockitems: + delete_sub_locations = True + + # Create a parent stock location + parent_stock_location = StockLocation.objects.create( + name='Parent stock location', + description='This is the parent stock location where the sub categories and stock items are moved to', + parent=None + ) + + stocklocation_count_before = StockLocation.objects.count() + stock_location_count_before = StockItem.objects.count() + + # Create a stock location to be deleted + stock_location_to_delete = StockLocation.objects.create( + name='Stock location to delete', + description='This is the stock location to be deleted', + parent=parent_stock_location + ) + + url = reverse('api-location-detail', kwargs={'pk': stock_location_to_delete.id}) + + stock_items = [] + # Create stock items in the location to be deleted + for jj in range(3): + stock_items.append(StockItem.objects.create( + batch=f"Stock Item xyz {jj}", + location=stock_location_to_delete, + part=part + )) + + child_stock_locations = [] + child_stock_locations_items = [] + # Create sub location under the stock location to be deleted + for ii in range(3): + child = StockLocation.objects.create( + name=f"Sub-location {ii}", + description="A sub-location of the deleted stock location", + parent=stock_location_to_delete + ) + child_stock_locations.append(child) + + # Create stock items in the sub locations + for jj in range(3): + child_stock_locations_items.append(StockItem.objects.create( + batch=f"Stock item in sub location xyz {jj}", + part=part, + location=child + )) + + # Delete the created stock location + params = {} + if delete_stock_items: + params['delete_stock_items'] = '1' + if delete_sub_locations: + params['delete_sub_locations'] = '1' + response = self.delete( + url, + params, + expected_code=204, + ) + + self.assertEqual(response.status_code, 204) + + if delete_stock_items: + if i == Target.delete_sub_locations_delete_stockitems: + # Check if all sub-categories deleted + self.assertEqual(StockItem.objects.count(), stock_location_count_before) + elif i == Target.move_sub_locations_to_parent_delete_stockitems: + # Check if all stock locations deleted + self.assertEqual(StockItem.objects.count(), stock_location_count_before + len(child_stock_locations_items)) + else: + # Stock locations moved to the parent location + for stock_item in stock_items: + stock_item.refresh_from_db() + self.assertEqual(stock_item.location, parent_stock_location) + + if delete_sub_locations: + for child_stock_location_item in child_stock_locations_items: + child_stock_location_item.refresh_from_db() + self.assertEqual(child_stock_location_item.location, parent_stock_location) + + if delete_sub_locations: + # Check if all sub-locations are deleted + self.assertEqual(StockLocation.objects.count(), stocklocation_count_before) + else: + # Check if all sub-locations moved to the parent + for child in child_stock_locations: + child.refresh_from_db() + self.assertEqual(child.parent, parent_stock_location) + class StockItemListTest(StockAPITestCase): """Tests for the StockItem API LIST endpoint.""" diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 90d566752f..b9aa788d06 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -171,16 +171,35 @@ function deleteStockLocation(pk, options={}) { var html = `
{% trans "Are you sure you want to delete this stock location?" %} -
`; + var subChoices = [ + { + value: 0, + display_name: '{% trans "Move to parent stock location" %}', + }, + { + value: 1, + display_name: '{% trans "Delete" %}', + } + ]; + constructForm(url, { title: '{% trans "Delete Stock Location" %}', method: 'DELETE', + fields: { + 'delete_stock_items': { + label: '{% trans "Action for stock items in this stock location" %}', + choices: subChoices, + type: 'choice' + }, + 'delete_sub_locations': { + label: '{% trans "Action for sub-locations" %}', + choices: subChoices, + type: 'choice' + }, + }, preFormContent: html, onSuccess: function(response) { handleFormSuccess(response, options);