mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 21:25:42 +00:00 
			
		
		
		
	Add support for recursively delete the stock locations (#3926)
This commit is contained in:
		| @@ -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([ | ||||
|   | ||||
| @@ -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() | ||||
|  | ||||
|   | ||||
| @@ -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.""" | ||||
|   | ||||
| @@ -171,16 +171,35 @@ function deleteStockLocation(pk, options={}) { | ||||
|     var html = ` | ||||
|     <div class='alert alert-block alert-danger'> | ||||
|     {% trans "Are you sure you want to delete this stock location?" %} | ||||
|     <ul> | ||||
|         <li>{% trans "Any child locations will be moved to the parent of this location" %}</li> | ||||
|         <li>{% trans "Any stock items in this location will be moved to the parent of this location" %}</li> | ||||
|     </ul> | ||||
|     </div> | ||||
|     `; | ||||
|  | ||||
|     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); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user