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:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user