2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-28 03:49:20 +00:00

Tree delete API (#11979)

* Add API serializer for deleting a location

* Add serializer for part category delete

* Bump API version

* Fix unit tests

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2026-05-22 14:37:36 +10:00
committed by GitHub
parent 8b9ea43b5b
commit 8ae0a5ea66
7 changed files with 208 additions and 208 deletions
@@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 490
INVENTREE_API_VERSION = 491
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v491 -> 2026-05-21 : https://github.com/inventree/InvenTree/pull/11979
- Add API serializer for deleting a part category
- Add API serializer for deleting a stock location
v490 -> 2026-05-19 : https://github.com/inventree/InvenTree/pull/11963
- moves user-self-filtered endpoints to /user/me/ to make their security boundaries clearer
+5 -2
View File
@@ -264,9 +264,12 @@ class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDest
def destroy(self, request, *args, **kwargs):
"""Delete a Part category instance via the API."""
delete_parts = str2bool(request.data.get('delete_parts', False))
serializer = part_serializers.CategoryDeleteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
delete_parts = str2bool(serializer.validated_data.get('delete_parts', False))
delete_child_categories = str2bool(
request.data.get('delete_child_categories', False)
serializer.validated_data.get('delete_child_categories', False)
)
return super().destroy(
+21
View File
@@ -54,6 +54,27 @@ from .models import (
logger = structlog.get_logger('inventree')
class CategoryDeleteSerializer(serializers.Serializer):
"""Serializer for deleting a PartCategory instance."""
class Meta:
"""Metaclass options."""
fields = ['delete_child_categories', 'delete_parts']
delete_child_categories = serializers.BooleanField(
label=_('Delete Subcategories'),
help_text=_('Delete all sub-categories contained within this category'),
required=True,
)
delete_parts = serializers.BooleanField(
label=_('Delete Parts'),
help_text=_('Delete all parts contained within this category'),
required=True,
)
@register_importer()
class CategorySerializer(
InvenTree.serializers.FilterableSerializerMixin,
+19 -42
View File
@@ -3,7 +3,6 @@
import os
from datetime import datetime
from decimal import Decimal
from enum import IntEnum
from random import randint
from django.core.exceptions import ValidationError
@@ -331,28 +330,8 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
def test_category_delete(self):
"""Test category deletion with different parameters."""
class Target(IntEnum):
move_subcategories_to_parent_move_parts_to_parent = (0,)
move_subcategories_to_parent_delete_parts = (1,)
delete_subcategories_move_parts_to_parent = (2,)
delete_subcategories_delete_parts = (3,)
for i in range(4):
delete_child_categories: bool = False
delete_parts: bool = False
if i in (
Target.move_subcategories_to_parent_delete_parts,
Target.delete_subcategories_delete_parts,
):
delete_parts = True
if i in (
Target.delete_subcategories_move_parts_to_parent,
Target.delete_subcategories_delete_parts,
):
delete_child_categories = True
for delete_child_categories in [False, True]:
for delete_parts in [False, True]:
# Create a parent category
parent_category = PartCategory.objects.create(
name='Parent category',
@@ -361,7 +340,6 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
)
category_count_before = PartCategory.objects.count()
part_count_before = Part.objects.count()
# Create a category to delete
cat_to_delete = PartCategory.objects.create(
@@ -370,14 +348,16 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
parent=parent_category,
)
url = reverse('api-part-category-detail', kwargs={'pk': cat_to_delete.id})
url = reverse(
'api-part-category-detail', kwargs={'pk': cat_to_delete.id}
)
parts = []
# Create parts in the category to be deleted
for jj in range(3):
parts.append(
Part.objects.create(
name=f'Part xyz {i}_{jj}',
name=f'Part {"A" if delete_child_categories else "B"}{"C" if delete_parts else "D"}-{jj}',
description='Child part of the deleted category',
category=cat_to_delete,
)
@@ -388,7 +368,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
# Create child categories under the category to be deleted
for ii in range(3):
child = PartCategory.objects.create(
name=f'Child parent_cat {i}_{ii}',
name=f'Child parent_cat {ii}',
description='A child category of the deleted category',
parent=cat_to_delete,
)
@@ -398,30 +378,25 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
for jj in range(3):
child_categories_parts.append(
Part.objects.create(
name=f'Part xyz {i}_{jj}_{ii}',
name=f'Part xyz {jj}_{ii}-{"E" if delete_child_categories else "F"}{"G" if delete_parts else "H"}',
description='Child part in the child category of the deleted category',
category=child,
)
)
# Delete the created category (sub categories and their parts will be moved under the parent)
params = {}
if delete_parts:
params['delete_parts'] = '1'
if delete_child_categories:
params['delete_child_categories'] = '1'
params = {
'delete_parts': delete_parts,
'delete_child_categories': delete_child_categories,
}
self.delete(url, params, expected_code=204)
if delete_parts:
if i == Target.delete_subcategories_delete_parts:
# Check if all parts deleted
self.assertEqual(Part.objects.count(), part_count_before)
elif i == Target.move_subcategories_to_parent_delete_parts:
# Check if all parts deleted
self.assertEqual(
Part.objects.count(),
part_count_before + len(child_categories_parts),
)
for p in parts:
with self.assertRaises(Part.DoesNotExist):
p.refresh_from_db()
else:
# parts moved to the parent category
for part in parts:
@@ -435,7 +410,9 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
if delete_child_categories:
# Check if all categories are deleted
self.assertEqual(PartCategory.objects.count(), category_count_before)
self.assertEqual(
PartCategory.objects.count(), category_count_before
)
else:
# Check if all subcategories to parent moved to parent and all parts deleted
for child in child_categories:
+6 -2
View File
@@ -430,11 +430,15 @@ class StockLocationDetail(
def destroy(self, request, *args, **kwargs):
"""Delete a Stock location instance via the API."""
serializer = StockSerializers.LocationDeleteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
delete_stock_items = InvenTree.helpers.str2bool(
request.data.get('delete_stock_items', False)
serializer.validated_data.get('delete_stock_items', False)
)
delete_sub_locations = InvenTree.helpers.str2bool(
request.data.get('delete_sub_locations', False)
serializer.validated_data.get('delete_sub_locations', False)
)
return super().destroy(
@@ -1167,6 +1167,27 @@ class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
return queryset.annotate(sublocations=stock.filters.annotate_sub_locations())
class LocationDeleteSerializer(serializers.Serializer):
"""Serializer for deleting a stock location."""
class Meta:
"""Metaclass options."""
fields = ['delete_stock_items', 'delete_sub_locations']
delete_stock_items = serializers.BooleanField(
required=True,
label=_('Delete Stock Items'),
help_text=_('Delete all stock items contained within this location'),
)
delete_sub_locations = serializers.BooleanField(
required=True,
label=_('Delete Sublocations'),
help_text=_('Delete all sub-locations contained within this location'),
)
@register_importer()
class LocationSerializer(
InvenTree.serializers.FilterableSerializerMixin,
+32 -62
View File
@@ -3,7 +3,6 @@
import os
import random
from datetime import datetime, timedelta
from enum import IntEnum
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
@@ -142,13 +141,6 @@ class StockLocationTest(StockAPITestCase):
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',
@@ -157,21 +149,8 @@ class StockLocationTest(StockAPITestCase):
is_template=False,
)
for i in range(4):
delete_sub_locations: bool = False
delete_stock_items: bool = False
if i in (
Target.move_sub_locations_to_parent_delete_stockitems,
Target.delete_sub_locations_delete_stockitems,
):
delete_stock_items = True
if i in (
Target.delete_sub_locations_move_stockitems_to_parent,
Target.delete_sub_locations_delete_stockitems,
):
delete_sub_locations = True
for delete_sub_locations in [False, True]:
for delete_stock_items in [False, True]:
# Create a parent stock location
parent_stock_location = StockLocation.objects.create(
name='Parent stock location',
@@ -179,95 +158,86 @@ class StockLocationTest(StockAPITestCase):
parent=None,
)
stocklocation_count_before = StockLocation.objects.count()
stock_location_count_before = StockItem.objects.count()
location_count_before = StockLocation.objects.count()
item_count_before = StockItem.objects.count()
# Create a stock location to be deleted
stock_location_to_delete = StockLocation.objects.create(
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}
'api-location-detail', kwargs={'pk': 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'Batch xyz {jj}',
location=stock_location_to_delete,
location=location_to_delete,
part=part,
)
)
child_stock_locations = []
child_stock_locations_items = []
child_locations = []
child_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,
parent=location_to_delete,
)
child_stock_locations.append(child)
child_locations.append(child)
# Create stock items in the sub locations
for jj in range(3):
child_stock_locations_items.append(
child_locations_items.append(
StockItem.objects.create(
batch=f'B 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)
params = {
'delete_stock_items': delete_stock_items,
'delete_sub_locations': delete_sub_locations,
}
response = self.delete(url, data=params, expected_code=204)
self.assertEqual(response.status_code, 204)
# If we were deleting stock items, the count must not have changed
if delete_stock_items:
if i == Target.delete_sub_locations_delete_stockitems:
# Check if all sub-categories deleted
extra_items = 0 if delete_sub_locations else 9
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),
StockItem.objects.count(), item_count_before + extra_items
)
else:
# Stock locations moved to the parent location
# Stock items moved to the parent location
self.assertGreater(StockItem.objects.count(), item_count_before)
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()
# Check if all sub-categories deleted
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
StockLocation.objects.count(), location_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)
# Check if all sub-categories moved to the parent category
for location in child_locations:
location.refresh_from_db()
self.assertEqual(location.parent, parent_stock_location)
def test_output_options(self):
"""Test output options."""