From 8ae0a5ea66004d292c1472bbcd9f2abade4404da Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 22 May 2026 14:37:36 +1000 Subject: [PATCH] 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 --- .../InvenTree/InvenTree/api_version.py | 6 +- src/backend/InvenTree/part/api.py | 7 +- src/backend/InvenTree/part/serializers.py | 21 ++ src/backend/InvenTree/part/test_api.py | 173 ++++++++--------- src/backend/InvenTree/stock/api.py | 8 +- src/backend/InvenTree/stock/serializers.py | 21 ++ src/backend/InvenTree/stock/test_api.py | 180 ++++++++---------- 7 files changed, 208 insertions(+), 208 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 2de7bf0df3..4448546901 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 05fc1ca57a..0eac19e221 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -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( diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index dbb5605a43..f67f802a26 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -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, diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index aea80d5e7f..011c700884 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -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,116 +330,94 @@ 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 - - # 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, - ) + 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', + description='This is the parent category where the child categories and parts are moved to', + parent=None, ) - child_categories = [] - 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) + category_count_before = PartCategory.objects.count() - # 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): - child_categories_parts.append( + parts.append( Part.objects.create( - name=f'Part xyz {i}_{jj}_{ii}', - description='Child part in the child category of the deleted category', - category=child, + 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, ) ) - # 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' - 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), + child_categories = [] + 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 {ii}', + description='A child category of the deleted category', + parent=cat_to_delete, ) - else: - # parts moved to the parent category - for part in parts: - part.refresh_from_db() - self.assertEqual(part.category, parent_category) + child_categories.append(child) - if delete_child_categories: - for part in child_categories_parts: + # Create parts in the child categories + 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() self.assertEqual(part.category, parent_category) - if delete_child_categories: - # Check if all categories are deleted - 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) + if delete_child_categories: + for part in child_categories_parts: + part.refresh_from_db() + self.assertEqual(part.category, parent_category) + + if delete_child_categories: + # Check if all categories are deleted + 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): """Test the effectiveness of structural categories. diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 1dd10a5c55..981bf6696d 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -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( diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index c1d6b5e94f..a87de263dc 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -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, diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 060eef57f7..d274fffafa 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -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,117 +149,95 @@ 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 - - # 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, - ) + 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', + description='This is the parent stock location where the sub categories and stock items are moved to', + parent=None, ) - 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) + location_count_before = StockLocation.objects.count() + item_count_before = StockItem.objects.count() - # 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): - child_stock_locations_items.append( + stock_items.append( 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 - 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) + child_locations = [] + child_locations_items = [] - 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: - if i == Target.delete_sub_locations_delete_stockitems: - # Check if all sub-categories deleted + # Create stock items in the sub locations + for jj in range(3): + 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( - 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: - # 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) + else: + # 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() - 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) + # Check if all sub-categories deleted + self.assertEqual( + StockLocation.objects.count(), location_count_before + ) + else: + # 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."""