mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Add support for recursively delete the stock locations (#3926)
This commit is contained in:
parent
60e44700a9
commit
8ceb1af3c3
@ -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([
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
@ -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);
|
||||||
|
Loading…
Reference in New Issue
Block a user