Add support for recursively delete the stock locations (#3926)

This commit is contained in:
Miklós Márton 2022-11-13 22:07:16 +01:00 committed by GitHub
parent 60e44700a9
commit 8ceb1af3c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 187 additions and 23 deletions

View File

@ -27,7 +27,8 @@ from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull, from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull,
str2bool, str2int) str2bool, str2int)
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI, from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation
from order.serializers import PurchaseOrderSerializer from order.serializers import PurchaseOrderSerializer
@ -1357,7 +1358,7 @@ class LocationMetadata(RetrieveUpdateAPI):
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
class LocationDetail(RetrieveUpdateDestroyAPI): class LocationDetail(CustomRetrieveUpdateDestroyAPI):
"""API endpoint for detail view of StockLocation object. """API endpoint for detail view of StockLocation object.
- GET: Return a single StockLocation object - GET: Return a single StockLocation object
@ -1375,6 +1376,16 @@ class LocationDetail(RetrieveUpdateDestroyAPI):
queryset = StockSerializers.LocationSerializer.annotate_queryset(queryset) queryset = StockSerializers.LocationSerializer.annotate_queryset(queryset)
return 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 = [ stock_api_urls = [
re_path(r'^location/', include([ re_path(r'^location/', include([

View File

@ -43,9 +43,36 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
"""Organization tree for StockItem objects. """Organization tree for StockItem objects.
A "StockLocation" can be considered a warehouse, or storage location 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): def delete(self, *args, **kwargs):
"""Custom model deletion routine, which updates any child locations or items. """Custom model deletion routine, which updates any child locations or items.
@ -53,24 +80,13 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
""" """
with transaction.atomic(): with transaction.atomic():
parent = self.parent self.delete_recursive(**dict(delete_stock_items=kwargs.get('delete_stock_items', False),
tree_id = self.tree_id delete_sub_locations=kwargs.get('delete_sub_locations', False),
parent_category=self.parent))
# Update each stock item in the stock location if self.parent is not None:
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:
# Partially rebuild the tree (cheaper than a complete rebuild) # Partially rebuild the tree (cheaper than a complete rebuild)
StockLocation.objects.partial_rebuild(tree_id) StockLocation.objects.partial_rebuild(self.tree_id)
else: else:
StockLocation.objects.rebuild() StockLocation.objects.rebuild()

View File

@ -3,6 +3,7 @@
import io import io
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import IntEnum
import django.http import django.http
from django.urls import reverse from django.urls import reverse
@ -15,6 +16,7 @@ import part.models
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus
from part.models import Part
from stock.models import StockItem, StockItemTestResult, StockLocation from stock.models import StockItem, StockItemTestResult, StockLocation
@ -37,6 +39,7 @@ class StockAPITestCase(InvenTreeAPITestCase):
'stock.add', 'stock.add',
'stock_location.change', 'stock_location.change',
'stock_location.add', 'stock_location.add',
'stock_location.delete',
'stock.delete', 'stock.delete',
] ]
@ -107,6 +110,121 @@ class StockLocationTest(StockAPITestCase):
response = self.client.post(self.list_url, data, format='json') response = self.client.post(self.list_url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED) 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): class StockItemListTest(StockAPITestCase):
"""Tests for the StockItem API LIST endpoint.""" """Tests for the StockItem API LIST endpoint."""

View File

@ -171,16 +171,35 @@ function deleteStockLocation(pk, options={}) {
var html = ` var html = `
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
{% trans "Are you sure you want to delete this stock location?" %} {% 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> </div>
`; `;
var subChoices = [
{
value: 0,
display_name: '{% trans "Move to parent stock location" %}',
},
{
value: 1,
display_name: '{% trans "Delete" %}',
}
];
constructForm(url, { constructForm(url, {
title: '{% trans "Delete Stock Location" %}', title: '{% trans "Delete Stock Location" %}',
method: 'DELETE', 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, preFormContent: html,
onSuccess: function(response) { onSuccess: function(response) {
handleFormSuccess(response, options); handleFormSuccess(response, options);