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,
+19 -42
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,28 +330,8 @@ 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,)
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
# Create a parent category # Create a parent category
parent_category = PartCategory.objects.create( parent_category = PartCategory.objects.create(
name='Parent category', name='Parent category',
@@ -361,7 +340,6 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
) )
category_count_before = PartCategory.objects.count() category_count_before = PartCategory.objects.count()
part_count_before = Part.objects.count()
# Create a category to delete # Create a category to delete
cat_to_delete = PartCategory.objects.create( cat_to_delete = PartCategory.objects.create(
@@ -370,14 +348,16 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
parent=parent_category, 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 = [] parts = []
# Create parts in the category to be deleted # Create parts in the category to be deleted
for jj in range(3): for jj in range(3):
parts.append( parts.append(
Part.objects.create( 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', description='Child part of the deleted category',
category=cat_to_delete, category=cat_to_delete,
) )
@@ -388,7 +368,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
# Create child categories under the category to be deleted # Create child categories under the category to be deleted
for ii in range(3): for ii in range(3):
child = PartCategory.objects.create( 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', description='A child category of the deleted category',
parent=cat_to_delete, parent=cat_to_delete,
) )
@@ -398,30 +378,25 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
for jj in range(3): for jj in range(3):
child_categories_parts.append( child_categories_parts.append(
Part.objects.create( 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', description='Child part in the child category of the deleted category',
category=child, category=child,
) )
) )
# Delete the created category (sub categories and their parts will be moved under the parent) # Delete the created category (sub categories and their parts will be moved under the parent)
params = {} params = {
if delete_parts: 'delete_parts': delete_parts,
params['delete_parts'] = '1' 'delete_child_categories': delete_child_categories,
if delete_child_categories: }
params['delete_child_categories'] = '1'
self.delete(url, params, expected_code=204) self.delete(url, params, expected_code=204)
if delete_parts: if delete_parts:
if i == Target.delete_subcategories_delete_parts:
# Check if all parts deleted # Check if all parts deleted
self.assertEqual(Part.objects.count(), part_count_before) for p in parts:
elif i == Target.move_subcategories_to_parent_delete_parts: with self.assertRaises(Part.DoesNotExist):
# Check if all parts deleted p.refresh_from_db()
self.assertEqual(
Part.objects.count(),
part_count_before + len(child_categories_parts),
)
else: else:
# parts moved to the parent category # parts moved to the parent category
for part in parts: for part in parts:
@@ -435,7 +410,9 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
if delete_child_categories: if delete_child_categories:
# Check if all categories are deleted # Check if all categories are deleted
self.assertEqual(PartCategory.objects.count(), category_count_before) self.assertEqual(
PartCategory.objects.count(), category_count_before
)
else: else:
# Check if all subcategories to parent moved to parent and all parts deleted # Check if all subcategories to parent moved to parent and all parts deleted
for child in child_categories: for child in child_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,
+32 -62
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,21 +149,8 @@ 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
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
# Create a parent stock location # Create a parent stock location
parent_stock_location = StockLocation.objects.create( parent_stock_location = StockLocation.objects.create(
name='Parent stock location', name='Parent stock location',
@@ -179,95 +158,86 @@ class StockLocationTest(StockAPITestCase):
parent=None, parent=None,
) )
stocklocation_count_before = StockLocation.objects.count() location_count_before = StockLocation.objects.count()
stock_location_count_before = StockItem.objects.count() item_count_before = StockItem.objects.count()
# Create a stock location to be deleted # 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', name='Stock location to delete',
description='This is the stock location to be deleted', description='This is the stock location to be deleted',
parent=parent_stock_location, parent=parent_stock_location,
) )
url = reverse( url = reverse(
'api-location-detail', kwargs={'pk': stock_location_to_delete.id} 'api-location-detail', kwargs={'pk': location_to_delete.id}
) )
stock_items = [] stock_items = []
# Create stock items in the location to be deleted # Create stock items in the location to be deleted
for jj in range(3): for jj in range(3):
stock_items.append( stock_items.append(
StockItem.objects.create( StockItem.objects.create(
batch=f'Batch xyz {jj}', batch=f'Batch xyz {jj}',
location=stock_location_to_delete, location=location_to_delete,
part=part, part=part,
) )
) )
child_stock_locations = [] child_locations = []
child_stock_locations_items = [] child_locations_items = []
# Create sub location under the stock location to be deleted # Create sub location under the stock location to be deleted
for ii in range(3): for ii in range(3):
child = StockLocation.objects.create( child = StockLocation.objects.create(
name=f'Sub-location {ii}', name=f'Sub-location {ii}',
description='A sub-location of the deleted stock location', 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 # Create stock items in the sub locations
for jj in range(3): for jj in range(3):
child_stock_locations_items.append( child_locations_items.append(
StockItem.objects.create( StockItem.objects.create(
batch=f'B xyz {jj}', part=part, location=child batch=f'B xyz {jj}', part=part, location=child
) )
) )
# Delete the created stock location # Delete the created stock location
params = {} params = {
if delete_stock_items: 'delete_stock_items': delete_stock_items,
params['delete_stock_items'] = '1' 'delete_sub_locations': delete_sub_locations,
if delete_sub_locations: }
params['delete_sub_locations'] = '1'
response = self.delete(url, params, expected_code=204) response = self.delete(url, data=params, expected_code=204)
self.assertEqual(response.status_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 delete_stock_items:
if i == Target.delete_sub_locations_delete_stockitems: extra_items = 0 if delete_sub_locations else 9
# Check if all sub-categories deleted
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:
# Check if all stock locations deleted
self.assertEqual(
StockItem.objects.count(),
stock_location_count_before + len(child_stock_locations_items),
) )
else: 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: for stock_item in stock_items:
stock_item.refresh_from_db() stock_item.refresh_from_db()
self.assertEqual(stock_item.location, parent_stock_location) 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(
child_stock_location_item.location, parent_stock_location StockLocation.objects.count(), location_count_before
)
if delete_sub_locations:
# Check if all sub-locations are deleted
self.assertEqual(
StockLocation.objects.count(), stocklocation_count_before
) )
else: else:
# Check if all sub-locations moved to the parent # Check if all sub-categories moved to the parent category
for child in child_stock_locations: for location in child_locations:
child.refresh_from_db() location.refresh_from_db()
self.assertEqual(child.parent, parent_stock_location) self.assertEqual(location.parent, parent_stock_location)
def test_output_options(self): def test_output_options(self):
"""Test output options.""" """Test output options."""