2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-28 11:59:23 +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 information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 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 - 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): def destroy(self, request, *args, **kwargs):
"""Delete a Part category instance via the API.""" """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( delete_child_categories = str2bool(
request.data.get('delete_child_categories', False) serializer.validated_data.get('delete_child_categories', False)
) )
return super().destroy( return super().destroy(
+21
View File
@@ -54,6 +54,27 @@ from .models import (
logger = structlog.get_logger('inventree') 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() @register_importer()
class CategorySerializer( class CategorySerializer(
InvenTree.serializers.FilterableSerializerMixin, InvenTree.serializers.FilterableSerializerMixin,
+75 -98
View File
@@ -3,7 +3,6 @@
import os import os
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from enum import IntEnum
from random import randint from random import randint
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -331,116 +330,94 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
def test_category_delete(self): def test_category_delete(self):
"""Test category deletion with different parameters.""" """Test category deletion with different parameters."""
for delete_child_categories in [False, True]:
class Target(IntEnum): for delete_parts in [False, True]:
move_subcategories_to_parent_move_parts_to_parent = (0,) # Create a parent category
move_subcategories_to_parent_delete_parts = (1,) parent_category = PartCategory.objects.create(
delete_subcategories_move_parts_to_parent = (2,) name='Parent category',
delete_subcategories_delete_parts = (3,) description='This is the parent category where the child categories and parts are moved to',
parent=None,
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
# Create a parent category
parent_category = PartCategory.objects.create(
name='Parent category',
description='This is the parent category where the child categories and parts are moved to',
parent=None,
)
category_count_before = PartCategory.objects.count()
part_count_before = Part.objects.count()
# Create a category to delete
cat_to_delete = PartCategory.objects.create(
name='Category to delete',
description='This is the category to be deleted',
parent=parent_category,
)
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}',
description='Child part of the deleted category',
category=cat_to_delete,
)
) )
child_categories = [] category_count_before = PartCategory.objects.count()
child_categories_parts = []
# 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}',
description='A child category of the deleted category',
parent=cat_to_delete,
)
child_categories.append(child)
# Create parts in the child categories # Create a category to delete
cat_to_delete = PartCategory.objects.create(
name='Category to delete',
description='This is the category to be deleted',
parent=parent_category,
)
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): for jj in range(3):
child_categories_parts.append( parts.append(
Part.objects.create( Part.objects.create(
name=f'Part xyz {i}_{jj}_{ii}', name=f'Part {"A" if delete_child_categories else "B"}{"C" if delete_parts else "D"}-{jj}',
description='Child part in the child category of the deleted category', description='Child part of the deleted category',
category=child, category=cat_to_delete,
) )
) )
# Delete the created category (sub categories and their parts will be moved under the parent) child_categories = []
params = {} child_categories_parts = []
if delete_parts: # Create child categories under the category to be deleted
params['delete_parts'] = '1' for ii in range(3):
if delete_child_categories: child = PartCategory.objects.create(
params['delete_child_categories'] = '1' name=f'Child parent_cat {ii}',
self.delete(url, params, expected_code=204) description='A child category of the deleted category',
parent=cat_to_delete,
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),
) )
else: child_categories.append(child)
# parts moved to the parent category
for part in parts:
part.refresh_from_db()
self.assertEqual(part.category, parent_category)
if delete_child_categories: # Create parts in the child categories
for part in child_categories_parts: for jj in range(3):
child_categories_parts.append(
Part.objects.create(
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 = {
'delete_parts': delete_parts,
'delete_child_categories': delete_child_categories,
}
self.delete(url, params, expected_code=204)
if delete_parts:
# Check if all parts deleted
for p in parts:
with self.assertRaises(Part.DoesNotExist):
p.refresh_from_db()
else:
# parts moved to the parent category
for part in parts:
part.refresh_from_db() part.refresh_from_db()
self.assertEqual(part.category, parent_category) self.assertEqual(part.category, parent_category)
if delete_child_categories: if delete_child_categories:
# Check if all categories are deleted for part in child_categories_parts:
self.assertEqual(PartCategory.objects.count(), category_count_before) part.refresh_from_db()
else: self.assertEqual(part.category, parent_category)
# Check if all subcategories to parent moved to parent and all parts deleted
for child in child_categories: if delete_child_categories:
child.refresh_from_db() # Check if all categories are deleted
self.assertEqual(child.parent, parent_category) 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:
child.refresh_from_db()
self.assertEqual(child.parent, parent_category)
def test_structural(self): def test_structural(self):
"""Test the effectiveness of structural categories. """Test the effectiveness of structural categories.
+6 -2
View File
@@ -430,11 +430,15 @@ class StockLocationDetail(
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
"""Delete a Stock location instance via the API.""" """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( 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( delete_sub_locations = InvenTree.helpers.str2bool(
request.data.get('delete_sub_locations', False) serializer.validated_data.get('delete_sub_locations', False)
) )
return super().destroy( return super().destroy(
@@ -1167,6 +1167,27 @@ class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
return queryset.annotate(sublocations=stock.filters.annotate_sub_locations()) 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() @register_importer()
class LocationSerializer( class LocationSerializer(
InvenTree.serializers.FilterableSerializerMixin, InvenTree.serializers.FilterableSerializerMixin,
+75 -105
View File
@@ -3,7 +3,6 @@
import os import os
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import IntEnum
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -142,13 +141,6 @@ class StockLocationTest(StockAPITestCase):
def test_stock_location_delete(self): def test_stock_location_delete(self):
"""Test stock location deletion with different parameters.""" """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 # First, construct a set of template / variant parts
part = Part.objects.create( part = Part.objects.create(
name='Part for stock item creation', name='Part for stock item creation',
@@ -157,117 +149,95 @@ class StockLocationTest(StockAPITestCase):
is_template=False, is_template=False,
) )
for i in range(4): for delete_sub_locations in [False, True]:
delete_sub_locations: bool = False for delete_stock_items in [False, True]:
delete_stock_items: bool = False # Create a parent stock location
parent_stock_location = StockLocation.objects.create(
if i in ( name='Parent stock location',
Target.move_sub_locations_to_parent_delete_stockitems, description='This is the parent stock location where the sub categories and stock items are moved to',
Target.delete_sub_locations_delete_stockitems, parent=None,
):
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
# 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'Batch xyz {jj}',
location=stock_location_to_delete,
part=part,
)
) )
child_stock_locations = [] location_count_before = StockLocation.objects.count()
child_stock_locations_items = [] item_count_before = StockItem.objects.count()
# 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 # Create a stock location to be deleted
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': location_to_delete.id}
)
stock_items = []
# Create stock items in the location to be deleted
for jj in range(3): for jj in range(3):
child_stock_locations_items.append( stock_items.append(
StockItem.objects.create( StockItem.objects.create(
batch=f'B xyz {jj}', part=part, location=child batch=f'Batch xyz {jj}',
location=location_to_delete,
part=part,
) )
) )
# Delete the created stock location child_locations = []
params = {} child_locations_items = []
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) # 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=location_to_delete,
)
child_locations.append(child)
if delete_stock_items: # Create stock items in the sub locations
if i == Target.delete_sub_locations_delete_stockitems: for jj in range(3):
# Check if all sub-categories deleted child_locations_items.append(
StockItem.objects.create(
batch=f'B xyz {jj}', part=part, location=child
)
)
# Delete the created stock location
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:
extra_items = 0 if delete_sub_locations else 9
self.assertEqual( self.assertEqual(
StockItem.objects.count(), stock_location_count_before StockItem.objects.count(), item_count_before + extra_items
) )
elif i == Target.move_sub_locations_to_parent_delete_stockitems: else:
# Check if all stock locations deleted # Stock items moved to the parent location
self.assertEqual( self.assertGreater(StockItem.objects.count(), item_count_before)
StockItem.objects.count(),
stock_location_count_before + len(child_stock_locations_items), for stock_item in stock_items:
) stock_item.refresh_from_db()
else: self.assertEqual(stock_item.location, parent_stock_location)
# 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: if delete_sub_locations:
for child_stock_location_item in child_stock_locations_items: # Check if all sub-categories deleted
child_stock_location_item.refresh_from_db() self.assertEqual(
self.assertEqual( StockLocation.objects.count(), location_count_before
child_stock_location_item.location, parent_stock_location )
) else:
# Check if all sub-categories moved to the parent category
if delete_sub_locations: for location in child_locations:
# Check if all sub-locations are deleted location.refresh_from_db()
self.assertEqual( self.assertEqual(location.parent, parent_stock_location)
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)
def test_output_options(self): def test_output_options(self):
"""Test output options.""" """Test output options."""