Delete part via API (#3135)

* Updates for the PartRelated model

- Deleting a part also deletes the relationships
- Add unique_together requirement
- Bug fixes
- Added unit tests

* Adds JS function to delete a part instance

* Remove legacy delete view

* JS linting
This commit is contained in:
Oliver 2022-06-06 11:42:22 +10:00 committed by GitHub
parent f38386b13c
commit fe8f111a63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 151 additions and 143 deletions

View File

@ -0,0 +1,28 @@
# Generated by Django 3.2.13 on 2022-06-06 00:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0077_alter_bomitem_unique_together'),
]
operations = [
migrations.AlterField(
model_name='partrelated',
name='part_1',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_parts_1', to='part.part', verbose_name='Part 1'),
),
migrations.AlterField(
model_name='partrelated',
name='part_2',
field=models.ForeignKey(help_text='Select Related Part', on_delete=django.db.models.deletion.CASCADE, related_name='related_parts_2', to='part.part', verbose_name='Part 2'),
),
migrations.AlterUniqueTogether(
name='partrelated',
unique_together={('part_1', 'part_2')},
),
]

View File

@ -2037,27 +2037,20 @@ class Part(MetadataMixin, MPTTModel):
return filtered_parts
def get_related_parts(self):
"""Return list of tuples for all related parts.
Includes:
- first value is PartRelated object
- second value is matching Part object
"""
related_parts = []
"""Return a set of all related parts for this part"""
related_parts = set()
related_parts_1 = self.related_parts_1.filter(part_1__id=self.pk)
related_parts_2 = self.related_parts_2.filter(part_2__id=self.pk)
related_parts.append()
for related_part in related_parts_1:
# Add to related parts list
related_parts.append(related_part.part_2)
related_parts.add(related_part.part_2)
for related_part in related_parts_2:
# Add to related parts list
related_parts.append(related_part.part_1)
related_parts.add(related_part.part_1)
return related_parts
@ -2829,44 +2822,35 @@ class BomItemSubstitute(models.Model):
class PartRelated(models.Model):
"""Store and handle related parts (eg. mating connector, crimps, etc.)."""
class Meta:
"""Metaclass defines extra model properties"""
unique_together = ('part_1', 'part_2')
part_1 = models.ForeignKey(Part, related_name='related_parts_1',
verbose_name=_('Part 1'), on_delete=models.DO_NOTHING)
verbose_name=_('Part 1'), on_delete=models.CASCADE)
part_2 = models.ForeignKey(Part, related_name='related_parts_2',
on_delete=models.DO_NOTHING,
on_delete=models.CASCADE,
verbose_name=_('Part 2'), help_text=_('Select Related Part'))
def __str__(self):
"""Return a string representation of this Part-Part relationship"""
return f'{self.part_1} <--> {self.part_2}'
def validate(self, part_1, part_2):
"""Validate that the two parts relationship is unique."""
validate = True
parts = Part.objects.all()
related_parts = PartRelated.objects.all()
# Check if part exist and there are not the same part
if (part_1 in parts and part_2 in parts) and (part_1.pk != part_2.pk):
# Check if relation exists already
for relation in related_parts:
if (part_1 == relation.part_1 and part_2 == relation.part_2) \
or (part_1 == relation.part_2 and part_2 == relation.part_1):
validate = False
break
else:
validate = False
return validate
def save(self, *args, **kwargs):
"""Enforce a 'clean' operation when saving a PartRelated instance"""
self.clean()
self.validate_unique()
super().save(*args, **kwargs)
def clean(self):
"""Overwrite clean method to check that relation is unique."""
validate = self.validate(self.part_1, self.part_2)
if not validate:
error_message = _('Error creating relationship: check that '
'the part is not related to itself '
'and that the relationship is unique')
super().clean()
raise ValidationError(error_message)
if self.part_1 == self.part_2:
raise ValidationError(_("Part relationship cannot be created between a part and itself"))
# Check for inverse relationship
if PartRelated.objects.filter(part_1=self.part_2, part_2=self.part_1).exists():
raise ValidationError(_("Duplicate relationship already exists"))

View File

@ -559,13 +559,13 @@
{% if roles.part.delete %}
$("#part-delete").click(function() {
launchModalForm(
"{% url 'part-delete' part.id %}",
{
redirect: {% if part.category %}"{% url 'category-detail' part.category.id %}"{% else %}"{% url 'part-index' %}"{% endif %},
no_post: {% if part.active %}true{% else %}false{% endif %},
}
);
deletePart({{ part.pk }}, {
{% if part.category %}
redirect: '{% url "category-detail" part.category.pk %}',
{% else %}
redirect: '{% url "part-index" %}',
{% endif %}
});
});
{% endif %}

View File

@ -1,78 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{% if part.active %}
<div class='alert alert-block alert-danger'>
{% blocktrans with full_name=part.full_name %}Part '<strong>{{full_name}}</strong>' cannot be deleted as it is still marked as <strong>active</strong>.
<br>Disable the "Active" part attribute and re-try.
{% endblocktrans %}
</div>
{% else %}
<div class='alert alert-block alert-danger'>
{% blocktrans with full_name=part.full_name %}Are you sure you want to delete part '<strong>{{full_name}}</strong>'?{% endblocktrans %}
</div>
{% if part.used_in_count %}
<hr>
<p>{% blocktrans with count=part.used_in_count %}This part is used in BOMs for {{count}} other parts. If you delete this part, the BOMs for the following parts will be updated{% endblocktrans %}:
<ul class="list-group">
{% for child in part.used_in.all %}
<li class='list-group-item'>{{ child.part.full_name }} - {{ child.part.description }}</li>
{% endfor %}
</p>
{% endif %}
{% if part.stock_items.all|length > 0 %}
<hr>
<p>{% blocktrans with count=part.stock_items.all|length %}There are {{count}} stock entries defined for this part. If you delete this part, the following stock entries will also be deleted:{% endblocktrans %}
<ul class='list-group'>
{% for stock in part.stock_items.all %}
<li class='list-group-item'>{{ stock }}</li>
{% endfor %}
</ul>
</p>
{% endif %}
{% if part.manufacturer_parts.all|length > 0 %}
<hr>
<p>{% blocktrans with count=part.manufacturer_parts.all|length %}There are {{count}} manufacturers defined for this part. If you delete this part, the following manufacturer parts will also be deleted:{% endblocktrans %}
<ul class='list-group'>
{% for spart in part.manufacturer_parts.all %}
<li class='list-group-item'>{% if spart.manufacturer %}{{ spart.manufacturer.name }} - {% endif %}{{ spart.MPN }}</li>
{% endfor %}
</ul>
</p>
{% endif %}
{% if part.supplier_parts.all|length > 0 %}
<hr>
<p>{% blocktrans with count=part.supplier_parts.all|length %}There are {{count}} suppliers defined for this part. If you delete this part, the following supplier parts will also be deleted:{% endblocktrans %}
<ul class='list-group'>
{% for spart in part.supplier_parts.all %}
{% if spart.supplier %}
<li class='list-group-item'>{{ spart.supplier.name }} - {{ spart.SKU }}</li>
{% endif %}
{% endfor %}
</ul>
</p>
{% endif %}
{% if part.serials.all|length > 0 %}
<hr>
<p>{% blocktrans with count=part.serials.all|length full_name=part.full_name %}There are {{count}} unique parts tracked for '{{full_name}}'. Deleting this part will permanently remove this tracking information.{% endblocktrans %}</p>
{% endif %}
{% endif %}
{% endblock %}
{% block form %}
{% if not part.active %}
{{ block.super }}
{% endif %}
{% endblock %}

View File

@ -15,8 +15,8 @@ from common.notifications import UIMessageNotification, storage
from InvenTree import version
from InvenTree.helpers import InvenTreeTestCase
from .models import (Part, PartCategory, PartCategoryStar, PartStar,
PartTestTemplate, rename_part_image)
from .models import (Part, PartCategory, PartCategoryStar, PartRelated,
PartStar, PartTestTemplate, rename_part_image)
from .templatetags import inventree_extras
@ -280,6 +280,53 @@ class PartTest(TestCase):
self.assertEqual(len(p.metadata.keys()), 4)
def test_related(self):
"""Unit tests for the PartRelated model"""
# Create a part relationship
PartRelated.objects.create(part_1=self.r1, part_2=self.r2)
self.assertEqual(PartRelated.objects.count(), 1)
# Creating a duplicate part relationship should fail
with self.assertRaises(ValidationError):
PartRelated.objects.create(part_1=self.r1, part_2=self.r2)
# Creating an 'inverse' duplicate relationship should also fail
with self.assertRaises(ValidationError):
PartRelated.objects.create(part_1=self.r2, part_2=self.r1)
# Try to add a self-referential relationship
with self.assertRaises(ValidationError):
PartRelated.objects.create(part_1=self.r2, part_2=self.r2)
# Test relation lookup for each part
r1_relations = self.r1.get_related_parts()
self.assertEqual(len(r1_relations), 1)
self.assertIn(self.r2, r1_relations)
r2_relations = self.r2.get_related_parts()
self.assertEqual(len(r2_relations), 1)
self.assertIn(self.r1, r2_relations)
# Delete a part, ensure the relationship also gets deleted
self.r1.delete()
self.assertEqual(PartRelated.objects.count(), 0)
self.assertEqual(len(self.r2.get_related_parts()), 0)
# Add multiple part relationships to self.r2
for p in Part.objects.all().exclude(pk=self.r2.pk):
PartRelated.objects.create(part_1=p, part_2=self.r2)
n = Part.objects.count() - 1
self.assertEqual(PartRelated.objects.count(), n)
self.assertEqual(len(self.r2.get_related_parts()), n)
# Deleting r2 should remove *all* relationships
self.r2.delete()
self.assertEqual(PartRelated.objects.count(), 0)
class TestTemplateTest(TestCase):
"""Unit test for the TestTemplate class"""

View File

@ -11,7 +11,6 @@ from django.urls import include, re_path
from . import views
part_detail_urls = [
re_path(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
re_path(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
re_path(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),

View File

@ -760,23 +760,6 @@ class BomDownload(AjaxView):
}
class PartDelete(AjaxDeleteView):
"""View to delete a Part object."""
model = Part
ajax_template_name = 'part/partial_delete.html'
ajax_form_title = _('Confirm Part Deletion')
context_object_name = 'part'
success_url = '/part/'
def get_data(self):
"""Returns custom message once the part deletion has been performed"""
return {
'danger': _('Part was deleted'),
}
class PartPricing(AjaxView):
"""View for inspecting part pricing information."""

View File

@ -21,6 +21,7 @@
*/
/* exported
deletePart,
duplicateBom,
duplicatePart,
editCategory,
@ -395,6 +396,50 @@ function duplicatePart(pk, options={}) {
}
// Launch form to delete a part
function deletePart(pk, options={}) {
inventreeGet(`/api/part/${pk}/`, {}, {
success: function(part) {
if (part.active) {
showAlertDialog(
'{% trans "Active Part" %}',
'{% trans "Part cannot be deleted as it is currently active" %}',
{
alert_style: 'danger',
}
);
return;
}
var thumb = thumbnailImage(part.thumbnail || part.image);
var html = `
<div class='alert alert-block alert-danger'>
<p>${thumb} ${part.full_name} - <em>${part.description}</em></p>
{% trans "Deleting this part cannot be reversed" %}
<ul>
<li>{% trans "Any stock items for this part will be deleted" %}</li>
<li>{% trans "This part will be removed from any Bills of Material" %}</li>
<li>{% trans "All manufacturer and supplier information for this part will be deleted" %}</li>
</div>`;
constructForm(
`/api/part/${pk}/`,
{
method: 'DELETE',
title: '{% trans "Delete Part" %}',
preFormContent: html,
onSuccess: function(response) {
handleFormSuccess(response, options);
}
}
);
}
});
}
/* Toggle the 'starred' status of a part.
* Performs AJAX queries and updates the display on the button.
*