mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Prevent deletion of part which is used in an assembly (#7260)
* Prevent deletion of part which is used in an assembly * add 'validate_model_deletion' option to ValidationMixin plugun * Add global setting to control part delete behaviour * Update settings location * Unit test updates * Further unit test updates * Fix typos
This commit is contained in:
parent
b26640fb36
commit
c540b12c97
@ -122,6 +122,20 @@ class PluginValidationMixin(DiffMixin):
|
|||||||
self.run_plugin_validation()
|
self.run_plugin_validation()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""Run plugin validation on model delete.
|
||||||
|
|
||||||
|
Allows plugins to prevent model instances from being deleted.
|
||||||
|
|
||||||
|
Note: Each plugin may raise a ValidationError to prevent deletion.
|
||||||
|
"""
|
||||||
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
for plugin in registry.with_mixin('validation'):
|
||||||
|
plugin.validate_model_deletion(self)
|
||||||
|
|
||||||
|
super().delete()
|
||||||
|
|
||||||
|
|
||||||
class MetadataMixin(models.Model):
|
class MetadataMixin(models.Model):
|
||||||
"""Model mixin class which adds a JSON metadata field to a model, for use by any (and all) plugins.
|
"""Model mixin class which adds a JSON metadata field to a model, for use by any (and all) plugins.
|
||||||
|
@ -1436,6 +1436,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
'default': True,
|
'default': True,
|
||||||
},
|
},
|
||||||
|
'PART_ALLOW_DELETE_FROM_ASSEMBLY': {
|
||||||
|
'name': _('Allow Deletion from Assembly'),
|
||||||
|
'description': _('Allow deletion of parts which are used in an assembly'),
|
||||||
|
'validator': bool,
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
'PART_IPN_REGEX': {
|
'PART_IPN_REGEX': {
|
||||||
'name': _('IPN Regex'),
|
'name': _('IPN Regex'),
|
||||||
'description': _('Regular expression pattern for matching Part IPN'),
|
'description': _('Regular expression pattern for matching Part IPN'),
|
||||||
|
@ -286,7 +286,11 @@ class ManufacturerPartSimpleTest(TestCase):
|
|||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
"""Test deletion of a ManufacturerPart."""
|
"""Test deletion of a ManufacturerPart."""
|
||||||
Part.objects.get(pk=self.part.id).delete()
|
part = Part.objects.get(pk=self.part.id)
|
||||||
|
part.active = False
|
||||||
|
part.save()
|
||||||
|
part.delete()
|
||||||
|
|
||||||
# Check that ManufacturerPart was deleted
|
# Check that ManufacturerPart was deleted
|
||||||
self.assertEqual(ManufacturerPart.objects.count(), 3)
|
self.assertEqual(ManufacturerPart.objects.count(), 3)
|
||||||
|
|
||||||
|
@ -1446,21 +1446,6 @@ class PartChangeCategory(CreateAPI):
|
|||||||
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
|
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
|
||||||
"""API endpoint for detail view of a single Part object."""
|
"""API endpoint for detail view of a single Part object."""
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
|
||||||
"""Delete a Part instance via the API.
|
|
||||||
|
|
||||||
- If the part is 'active' it cannot be deleted
|
|
||||||
- It must first be marked as 'inactive'
|
|
||||||
"""
|
|
||||||
part = Part.objects.get(pk=int(kwargs['pk']))
|
|
||||||
# Check if inactive
|
|
||||||
if not part.active:
|
|
||||||
# Delete
|
|
||||||
return super(PartDetail, self).destroy(request, *args, **kwargs)
|
|
||||||
# Return 405 error
|
|
||||||
message = 'Part is active: cannot delete'
|
|
||||||
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message)
|
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
"""Custom update functionality for Part instance.
|
"""Custom update functionality for Part instance.
|
||||||
|
|
||||||
|
@ -448,6 +448,27 @@ class Part(
|
|||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
def delete(self, **kwargs):
|
||||||
|
"""Custom delete method for the Part model.
|
||||||
|
|
||||||
|
Prevents deletion of a Part if any of the following conditions are met:
|
||||||
|
|
||||||
|
- The part is still active
|
||||||
|
- The part is used in a BOM for a different part.
|
||||||
|
"""
|
||||||
|
if self.active:
|
||||||
|
raise ValidationError(_('Cannot delete this part as it is still active'))
|
||||||
|
|
||||||
|
if not common.models.InvenTreeSetting.get_setting(
|
||||||
|
'PART_ALLOW_DELETE_FROM_ASSEMBLY', cache=False
|
||||||
|
):
|
||||||
|
if BomItem.objects.filter(sub_part=self).exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_('Cannot delete this part as it is used in an assembly')
|
||||||
|
)
|
||||||
|
|
||||||
|
super().delete()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Overrides the save function for the Part model.
|
"""Overrides the save function for the Part model.
|
||||||
|
|
||||||
|
@ -1408,7 +1408,7 @@ class PartDetailTests(PartAPITestBase):
|
|||||||
response = self.delete(url)
|
response = self.delete(url)
|
||||||
|
|
||||||
# As the part is 'active' we cannot delete it
|
# As the part is 'active' we cannot delete it
|
||||||
self.assertEqual(response.status_code, 405)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
# So, let's make it not active
|
# So, let's make it not active
|
||||||
response = self.patch(url, {'active': False}, expected_code=200)
|
response = self.patch(url, {'active': False}, expected_code=200)
|
||||||
@ -2586,6 +2586,8 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase):
|
|||||||
p.active = False
|
p.active = False
|
||||||
p.save()
|
p.save()
|
||||||
|
|
||||||
|
InvenTreeSetting.set_setting('PART_ALLOW_DELETE_FROM_ASSEMBLY', True)
|
||||||
|
|
||||||
response = self.delete(reverse('api-part-detail', kwargs={'pk': 1}))
|
response = self.delete(reverse('api-part-detail', kwargs={'pk': 1}))
|
||||||
self.assertEqual(response.status_code, 204)
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
@ -187,6 +187,8 @@ class BomItemTest(TestCase):
|
|||||||
self.assertEqual(bom_item.substitutes.count(), 4)
|
self.assertEqual(bom_item.substitutes.count(), 4)
|
||||||
|
|
||||||
for sub in subs:
|
for sub in subs:
|
||||||
|
sub.active = False
|
||||||
|
sub.save()
|
||||||
sub.delete()
|
sub.delete()
|
||||||
|
|
||||||
# The substitution links should have been automatically removed
|
# The substitution links should have been automatically removed
|
||||||
|
@ -336,6 +336,8 @@ class PartTest(TestCase):
|
|||||||
self.assertIn(self.r1, r2_relations)
|
self.assertIn(self.r1, r2_relations)
|
||||||
|
|
||||||
# Delete a part, ensure the relationship also gets deleted
|
# Delete a part, ensure the relationship also gets deleted
|
||||||
|
self.r1.active = False
|
||||||
|
self.r1.save()
|
||||||
self.r1.delete()
|
self.r1.delete()
|
||||||
|
|
||||||
self.assertEqual(PartRelated.objects.count(), countbefore)
|
self.assertEqual(PartRelated.objects.count(), countbefore)
|
||||||
@ -351,6 +353,8 @@ class PartTest(TestCase):
|
|||||||
self.assertEqual(len(self.r2.get_related_parts()), n)
|
self.assertEqual(len(self.r2.get_related_parts()), n)
|
||||||
|
|
||||||
# Deleting r2 should remove *all* newly created relationships
|
# Deleting r2 should remove *all* newly created relationships
|
||||||
|
self.r2.active = False
|
||||||
|
self.r2.save()
|
||||||
self.r2.delete()
|
self.r2.delete()
|
||||||
self.assertEqual(PartRelated.objects.count(), countbefore)
|
self.assertEqual(PartRelated.objects.count(), countbefore)
|
||||||
|
|
||||||
|
@ -49,6 +49,23 @@ class ValidationMixin:
|
|||||||
"""Raise a ValidationError with the given message."""
|
"""Raise a ValidationError with the given message."""
|
||||||
raise ValidationError(message)
|
raise ValidationError(message)
|
||||||
|
|
||||||
|
def validate_model_deletion(self, instance):
|
||||||
|
"""Run custom validation when a model instance is being deleted.
|
||||||
|
|
||||||
|
This method is called when a model instance is being deleted.
|
||||||
|
It allows the plugin to raise a ValidationError if the instance cannot be deleted.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
instance: The model instance to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None or True (refer to class docstring)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError if the instance cannot be deleted
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
def validate_model_instance(self, instance, deltas=None):
|
def validate_model_instance(self, instance, deltas=None):
|
||||||
"""Run custom validation on a database model instance.
|
"""Run custom validation on a database model instance.
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DELETE_FROM_ASSEMBLY" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_NAME_FORMAT" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_NAME_FORMAT" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_CREATE_INITIAL" icon="fa-boxes" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_CREATE_INITIAL" icon="fa-boxes" %}
|
||||||
|
@ -497,7 +497,7 @@ export function ApiForm({
|
|||||||
{/* Form Fields */}
|
{/* Form Fields */}
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{(!isValid || nonFieldErrors.length > 0) && (
|
{(!isValid || nonFieldErrors.length > 0) && (
|
||||||
<Alert radius="sm" color="red" title={t`Form Errors Exist`}>
|
<Alert radius="sm" color="red" title={t`Error`}>
|
||||||
{nonFieldErrors.length > 0 && (
|
{nonFieldErrors.length > 0 && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{nonFieldErrors.map((message) => (
|
{nonFieldErrors.map((message) => (
|
||||||
|
@ -173,6 +173,7 @@ export default function SystemSettings() {
|
|||||||
'PART_IPN_REGEX',
|
'PART_IPN_REGEX',
|
||||||
'PART_ALLOW_DUPLICATE_IPN',
|
'PART_ALLOW_DUPLICATE_IPN',
|
||||||
'PART_ALLOW_EDIT_IPN',
|
'PART_ALLOW_EDIT_IPN',
|
||||||
|
'PART_ALLOW_DELETE_FROM_ASSEMBLY',
|
||||||
'PART_NAME_FORMAT',
|
'PART_NAME_FORMAT',
|
||||||
'PART_SHOW_RELATED',
|
'PART_SHOW_RELATED',
|
||||||
'PART_CREATE_INITIAL',
|
'PART_CREATE_INITIAL',
|
||||||
|
Loading…
Reference in New Issue
Block a user