Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent deletion of part which is used in an assembly #7260

Merged
merged 7 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/backend/InvenTree/InvenTree/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@
self.run_plugin_validation()
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)

Check warning on line 135 in src/backend/InvenTree/InvenTree/models.py

View check run for this annotation

Codecov / codecov/patch

src/backend/InvenTree/InvenTree/models.py#L135

Added line #L135 was not covered by tests

super().delete()


class MetadataMixin(models.Model):
"""Model mixin class which adds a JSON metadata field to a model, for use by any (and all) plugins.
Expand Down
6 changes: 6 additions & 0 deletions src/backend/InvenTree/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,12 @@ def save(self, *args, **kwargs):
'validator': bool,
'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': {
'name': _('IPN Regex'),
'description': _('Regular expression pattern for matching Part IPN'),
Expand Down
6 changes: 5 additions & 1 deletion src/backend/InvenTree/company/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,11 @@ def test_exists(self):

def test_delete(self):
"""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
self.assertEqual(ManufacturerPart.objects.count(), 3)

Expand Down
15 changes: 0 additions & 15 deletions src/backend/InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1446,21 +1446,6 @@ class PartChangeCategory(CreateAPI):
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
"""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):
"""Custom update functionality for Part instance.

Expand Down
21 changes: 21 additions & 0 deletions src/backend/InvenTree/part/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,27 @@

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(

Check warning on line 466 in src/backend/InvenTree/part/models.py

View check run for this annotation

Codecov / codecov/patch

src/backend/InvenTree/part/models.py#L466

Added line #L466 was not covered by tests
_('Cannot delete this part as it is used in an assembly')
)

super().delete()

def save(self, *args, **kwargs):
"""Overrides the save function for the Part model.

Expand Down
4 changes: 3 additions & 1 deletion src/backend/InvenTree/part/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1408,7 +1408,7 @@ def test_part_operations(self):
response = self.delete(url)

# 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
response = self.patch(url, {'active': False}, expected_code=200)
Expand Down Expand Up @@ -2586,6 +2586,8 @@ def test_create_price_breaks(self):
p.active = False
p.save()

InvenTreeSetting.set_setting('PART_ALLOW_DELETE_FROM_ASSEMBLY', True)

response = self.delete(reverse('api-part-detail', kwargs={'pk': 1}))
self.assertEqual(response.status_code, 204)

Expand Down
2 changes: 2 additions & 0 deletions src/backend/InvenTree/part/test_bom_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ def test_substitutes(self):
self.assertEqual(bom_item.substitutes.count(), 4)

for sub in subs:
sub.active = False
sub.save()
sub.delete()

# The substitution links should have been automatically removed
Expand Down
4 changes: 4 additions & 0 deletions src/backend/InvenTree/part/test_part.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ def test_related(self):
self.assertIn(self.r1, r2_relations)

# Delete a part, ensure the relationship also gets deleted
self.r1.active = False
self.r1.save()
self.r1.delete()

self.assertEqual(PartRelated.objects.count(), countbefore)
Expand All @@ -351,6 +353,8 @@ def test_related(self):
self.assertEqual(len(self.r2.get_related_parts()), n)

# Deleting r2 should remove *all* newly created relationships
self.r2.active = False
self.r2.save()
self.r2.delete()
self.assertEqual(PartRelated.objects.count(), countbefore)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@
"""Raise a ValidationError with the given 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

Check warning on line 67 in src/backend/InvenTree/plugin/base/integration/ValidationMixin.py

View check run for this annotation

Codecov / codecov/patch

src/backend/InvenTree/plugin/base/integration/ValidationMixin.py#L67

Added line #L67 was not covered by tests

def validate_model_instance(self, instance, deltas=None):
"""Run custom validation on a database model instance.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
{% 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_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_SHOW_RELATED" icon="fa-random" %}
{% include "InvenTree/settings/setting.html" with key="PART_CREATE_INITIAL" icon="fa-boxes" %}
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/components/forms/ApiForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ export function ApiForm({
{/* Form Fields */}
<Stack gap="sm">
{(!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 && (
<Stack gap="xs">
{nonFieldErrors.map((message) => (
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/pages/Index/Settings/SystemSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export default function SystemSettings() {
'PART_IPN_REGEX',
'PART_ALLOW_DUPLICATE_IPN',
'PART_ALLOW_EDIT_IPN',
'PART_ALLOW_DELETE_FROM_ASSEMBLY',
'PART_NAME_FORMAT',
'PART_SHOW_RELATED',
'PART_CREATE_INITIAL',
Expand Down
Loading