mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-07 07:48:50 +00:00
Add option to recursively delete part categories (#3435)
* Add option to recursively delete part categories Fixes #3384 * - Added test (broken ATM) - Refactored parameters to booleanish * Fix styling issues reported by flake8 * Working on unit testing * Added on_commit debugging callback * Separate the recursive part of the deletion into another method to make sure that the delete operation is performed in a single transaction * Trying transactions with @transactions.atomic * Fix flake8 reported issues * Removed unused debug callback * Fixed tests for category recursive deletion * Fix flake reported issues * Fix flake reported issues Again * Remove unrelated formatting changes * Fixed a part of review comments
This commit is contained in:
parent
adcb975853
commit
f6cfc12343
@ -93,11 +93,11 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
|
|
||||||
# Add a 'DELETE' action if we are allowed to delete
|
# Add a 'DELETE' action if we are allowed to delete
|
||||||
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
|
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
|
||||||
actions['DELETE'] = True
|
actions['DELETE'] = {}
|
||||||
|
|
||||||
# Add a 'VIEW' action if we are allowed to view
|
# Add a 'VIEW' action if we are allowed to view
|
||||||
if 'GET' in view.allowed_methods and check(user, table, 'view'):
|
if 'GET' in view.allowed_methods and check(user, table, 'view'):
|
||||||
actions['GET'] = True
|
actions['GET'] = {}
|
||||||
|
|
||||||
metadata['actions'] = actions
|
metadata['actions'] = actions
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"""Mixins for (API) views in the whole project."""
|
"""Mixins for (API) views in the whole project."""
|
||||||
|
|
||||||
from rest_framework import generics, status
|
from rest_framework import generics, mixins, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
|
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
|
||||||
@ -106,6 +106,45 @@ class RetrieveUpdateAPI(CleanMixin, generics.RetrieveUpdateAPIView):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CustomDestroyModelMixin:
|
||||||
|
"""This mixin was created pass the kwargs from the API to the models."""
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""Custom destroy method to pass kwargs."""
|
||||||
|
instance = self.get_object()
|
||||||
|
self.perform_destroy(instance, **kwargs)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def perform_destroy(self, instance, **kwargs):
|
||||||
|
"""Custom destroy method to pass kwargs."""
|
||||||
|
instance.delete(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomRetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin,
|
||||||
|
mixins.UpdateModelMixin,
|
||||||
|
CustomDestroyModelMixin,
|
||||||
|
generics.GenericAPIView):
|
||||||
|
"""This APIView was created pass the kwargs from the API to the models."""
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Custom get method to pass kwargs."""
|
||||||
|
return self.retrieve(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def put(self, request, *args, **kwargs):
|
||||||
|
"""Custom put method to pass kwargs."""
|
||||||
|
return self.update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def patch(self, request, *args, **kwargs):
|
||||||
|
"""Custom patch method to pass kwargs."""
|
||||||
|
return self.partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
"""Custom delete method to pass kwargs."""
|
||||||
|
return self.destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomRetrieveUpdateDestroyAPI(CleanMixin, CustomRetrieveUpdateDestroyAPIView):
|
||||||
|
"""This APIView was created pass the kwargs from the API to the models."""
|
||||||
|
|
||||||
|
|
||||||
class RetrieveUpdateDestroyAPI(CleanMixin, generics.RetrieveUpdateDestroyAPIView):
|
class RetrieveUpdateDestroyAPI(CleanMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""View for retrieve, update and destroy API."""
|
"""View for retrieve, update and destroy API."""
|
||||||
|
|
||||||
|
@ -27,7 +27,8 @@ from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
|||||||
from InvenTree.filters import InvenTreeOrderingFilter
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull,
|
from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull,
|
||||||
str2bool, str2int)
|
str2bool, str2int)
|
||||||
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI,
|
from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
|
||||||
|
ListAPI, ListCreateAPI, RetrieveAPI,
|
||||||
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
|
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
|
||||||
UpdateAPI)
|
UpdateAPI)
|
||||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||||
@ -179,7 +180,7 @@ class CategoryList(ListCreateAPI):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class CategoryDetail(RetrieveUpdateDestroyAPI):
|
class CategoryDetail(CustomRetrieveUpdateDestroyAPI):
|
||||||
"""API endpoint for detail view of a single PartCategory object."""
|
"""API endpoint for detail view of a single PartCategory object."""
|
||||||
|
|
||||||
serializer_class = part_serializers.CategorySerializer
|
serializer_class = part_serializers.CategorySerializer
|
||||||
@ -218,6 +219,16 @@ class CategoryDetail(RetrieveUpdateDestroyAPI):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""Delete a Part category instance via the API"""
|
||||||
|
delete_parts = 'delete_parts' in request.data and request.data['delete_parts'] == '1'
|
||||||
|
delete_child_categories = 'delete_child_categories' in request.data and request.data['delete_child_categories'] == '1'
|
||||||
|
return super().destroy(request,
|
||||||
|
*args,
|
||||||
|
**dict(kwargs,
|
||||||
|
delete_parts=delete_parts,
|
||||||
|
delete_child_categories=delete_child_categories))
|
||||||
|
|
||||||
|
|
||||||
class CategoryMetadata(RetrieveUpdateAPI):
|
class CategoryMetadata(RetrieveUpdateAPI):
|
||||||
"""API endpoint for viewing / updating PartCategory metadata."""
|
"""API endpoint for viewing / updating PartCategory metadata."""
|
||||||
|
@ -63,31 +63,46 @@ class PartCategory(MetadataMixin, InvenTreeTree):
|
|||||||
default_keywords: Default keywords for parts created in this category
|
default_keywords: Default keywords for parts created in this category
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def delete_recursive(self, *args, **kwargs):
|
||||||
|
"""This function handles the recursive deletion of subcategories depending on kwargs contents"""
|
||||||
|
delete_parts = kwargs.get('delete_parts', False)
|
||||||
|
parent_category = kwargs.get('parent_category', None)
|
||||||
|
|
||||||
|
if parent_category is None:
|
||||||
|
# First iteration, (no part_category kwargs passed)
|
||||||
|
parent_category = self.parent
|
||||||
|
|
||||||
|
for child_part in self.parts.all():
|
||||||
|
if delete_parts:
|
||||||
|
child_part.delete()
|
||||||
|
else:
|
||||||
|
child_part.category = parent_category
|
||||||
|
child_part.save()
|
||||||
|
|
||||||
|
for child_category in self.children.all():
|
||||||
|
if kwargs.get('delete_child_categories', False):
|
||||||
|
child_category.delete_recursive(**dict(delete_child_categories=True,
|
||||||
|
delete_parts=delete_parts,
|
||||||
|
parent_category=parent_category))
|
||||||
|
else:
|
||||||
|
child_category.parent = parent_category
|
||||||
|
child_category.save()
|
||||||
|
|
||||||
|
super().delete(*args, **dict())
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
"""Custom model deletion routine, which updates any child categories or parts.
|
"""Custom model deletion routine, which updates any child categories or parts.
|
||||||
|
|
||||||
This must be handled within a transaction.atomic(), otherwise the tree structure is damaged
|
This must be handled within a transaction.atomic(), otherwise the tree structure is damaged
|
||||||
"""
|
"""
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
self.delete_recursive(**dict(delete_parts=kwargs.get('delete_parts', False),
|
||||||
|
delete_child_categories=kwargs.get('delete_child_categories', False),
|
||||||
|
parent_category=self.parent))
|
||||||
|
|
||||||
parent = self.parent
|
if self.parent is not None:
|
||||||
tree_id = self.tree_id
|
|
||||||
|
|
||||||
# Update each part in this category to point to the parent category
|
|
||||||
for p in self.parts.all():
|
|
||||||
p.category = self.parent
|
|
||||||
p.save()
|
|
||||||
|
|
||||||
# Update each child category
|
|
||||||
for child in self.children.all():
|
|
||||||
child.parent = self.parent
|
|
||||||
child.save()
|
|
||||||
|
|
||||||
super().delete(*args, **kwargs)
|
|
||||||
|
|
||||||
if parent is not None:
|
|
||||||
# Partially rebuild the tree (cheaper than a complete rebuild)
|
# Partially rebuild the tree (cheaper than a complete rebuild)
|
||||||
PartCategory.objects.partial_rebuild(tree_id)
|
PartCategory.objects.partial_rebuild(self.tree_id)
|
||||||
else:
|
else:
|
||||||
PartCategory.objects.rebuild()
|
PartCategory.objects.rebuild()
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""Unit tests for the various part API endpoints"""
|
"""Unit tests for the various part API endpoints"""
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from enum import IntEnum
|
||||||
from random import randint
|
from random import randint
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -294,6 +295,114 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.data['description'], 'A part category')
|
self.assertEqual(response.data['description'], 'A part category')
|
||||||
|
|
||||||
|
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 == Target.move_subcategories_to_parent_delete_parts \
|
||||||
|
or i == Target.delete_subcategories_delete_parts:
|
||||||
|
delete_parts = True
|
||||||
|
if i == Target.delete_subcategories_move_parts_to_parent \
|
||||||
|
or i == 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 {jj}",
|
||||||
|
description="Child part of the deleted category",
|
||||||
|
category=cat_to_delete
|
||||||
|
))
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
child_categories.append(child)
|
||||||
|
|
||||||
|
# Create parts in the child categories
|
||||||
|
for jj in range(3):
|
||||||
|
child_categories_parts.append(Part.objects.create(
|
||||||
|
name=f"Part xyz {jj}",
|
||||||
|
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'
|
||||||
|
response = self.delete(
|
||||||
|
url,
|
||||||
|
params,
|
||||||
|
expected_code=204,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_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))
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
class PartOptionsAPITest(InvenTreeAPITestCase):
|
class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||||
"""Tests for the various OPTIONS endpoints in the /part/ API.
|
"""Tests for the various OPTIONS endpoints in the /part/ API.
|
||||||
|
@ -333,15 +333,33 @@ function deletePartCategory(pk, options={}) {
|
|||||||
var html = `
|
var html = `
|
||||||
<div class='alert alert-block alert-danger'>
|
<div class='alert alert-block alert-danger'>
|
||||||
{% trans "Are you sure you want to delete this part category?" %}
|
{% trans "Are you sure you want to delete this part category?" %}
|
||||||
<ul>
|
|
||||||
<li>{% trans "Any child categories will be moved to the parent of this category" %}</li>
|
|
||||||
<li>{% trans "Any parts in this category will be moved to the parent of this category" %}</li>
|
|
||||||
</ul>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
var subChoices = [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
display_name: '{% trans "Move to parent category" %}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
display_name: '{% trans "Delete" %}',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
title: '{% trans "Delete Part Category" %}',
|
title: '{% trans "Delete Part Category" %}',
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
fields: {
|
||||||
|
'delete_parts': {
|
||||||
|
label: '{% trans "Action for parts in this category" %}',
|
||||||
|
choices: subChoices,
|
||||||
|
type: 'choice'
|
||||||
|
},
|
||||||
|
'delete_child_categories': {
|
||||||
|
label: '{% trans "Action for child categories" %}',
|
||||||
|
choices: subChoices,
|
||||||
|
type: 'choice'
|
||||||
|
},
|
||||||
|
},
|
||||||
preFormContent: html,
|
preFormContent: html,
|
||||||
onSuccess: function(response) {
|
onSuccess: function(response) {
|
||||||
handleFormSuccess(response, options);
|
handleFormSuccess(response, options);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user