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.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([

View File

@ -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()

View File

@ -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."""

View File

@ -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);