mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
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:
parent
f38386b13c
commit
fe8f111a63
28
InvenTree/part/migrations/0078_auto_20220606_0024.py
Normal file
28
InvenTree/part/migrations/0078_auto_20220606_0024.py
Normal 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')},
|
||||||
|
),
|
||||||
|
]
|
@ -2037,27 +2037,20 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
return filtered_parts
|
return filtered_parts
|
||||||
|
|
||||||
def get_related_parts(self):
|
def get_related_parts(self):
|
||||||
"""Return list of tuples for all related parts.
|
"""Return a set of all related parts for this part"""
|
||||||
|
related_parts = set()
|
||||||
Includes:
|
|
||||||
- first value is PartRelated object
|
|
||||||
- second value is matching Part object
|
|
||||||
"""
|
|
||||||
related_parts = []
|
|
||||||
|
|
||||||
related_parts_1 = self.related_parts_1.filter(part_1__id=self.pk)
|
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_2 = self.related_parts_2.filter(part_2__id=self.pk)
|
||||||
|
|
||||||
related_parts.append()
|
|
||||||
|
|
||||||
for related_part in related_parts_1:
|
for related_part in related_parts_1:
|
||||||
# Add to related parts list
|
# 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:
|
for related_part in related_parts_2:
|
||||||
# Add to related parts list
|
# Add to related parts list
|
||||||
related_parts.append(related_part.part_1)
|
related_parts.add(related_part.part_1)
|
||||||
|
|
||||||
return related_parts
|
return related_parts
|
||||||
|
|
||||||
@ -2829,44 +2822,35 @@ class BomItemSubstitute(models.Model):
|
|||||||
class PartRelated(models.Model):
|
class PartRelated(models.Model):
|
||||||
"""Store and handle related parts (eg. mating connector, crimps, etc.)."""
|
"""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',
|
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',
|
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'))
|
verbose_name=_('Part 2'), help_text=_('Select Related Part'))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return a string representation of this Part-Part relationship"""
|
"""Return a string representation of this Part-Part relationship"""
|
||||||
return f'{self.part_1} <--> {self.part_2}'
|
return f'{self.part_1} <--> {self.part_2}'
|
||||||
|
|
||||||
def validate(self, part_1, part_2):
|
def save(self, *args, **kwargs):
|
||||||
"""Validate that the two parts relationship is unique."""
|
"""Enforce a 'clean' operation when saving a PartRelated instance"""
|
||||||
validate = True
|
self.clean()
|
||||||
|
self.validate_unique()
|
||||||
parts = Part.objects.all()
|
super().save(*args, **kwargs)
|
||||||
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 clean(self):
|
def clean(self):
|
||||||
"""Overwrite clean method to check that relation is unique."""
|
"""Overwrite clean method to check that relation is unique."""
|
||||||
validate = self.validate(self.part_1, self.part_2)
|
|
||||||
|
|
||||||
if not validate:
|
super().clean()
|
||||||
error_message = _('Error creating relationship: check that '
|
|
||||||
'the part is not related to itself '
|
|
||||||
'and that the relationship is unique')
|
|
||||||
|
|
||||||
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"))
|
||||||
|
@ -559,13 +559,13 @@
|
|||||||
|
|
||||||
{% if roles.part.delete %}
|
{% if roles.part.delete %}
|
||||||
$("#part-delete").click(function() {
|
$("#part-delete").click(function() {
|
||||||
launchModalForm(
|
deletePart({{ part.pk }}, {
|
||||||
"{% url 'part-delete' part.id %}",
|
{% if part.category %}
|
||||||
{
|
redirect: '{% url "category-detail" part.category.pk %}',
|
||||||
redirect: {% if part.category %}"{% url 'category-detail' part.category.id %}"{% else %}"{% url 'part-index' %}"{% endif %},
|
{% else %}
|
||||||
no_post: {% if part.active %}true{% else %}false{% endif %},
|
redirect: '{% url "part-index" %}',
|
||||||
}
|
{% endif %}
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -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 %}
|
|
@ -15,8 +15,8 @@ from common.notifications import UIMessageNotification, storage
|
|||||||
from InvenTree import version
|
from InvenTree import version
|
||||||
from InvenTree.helpers import InvenTreeTestCase
|
from InvenTree.helpers import InvenTreeTestCase
|
||||||
|
|
||||||
from .models import (Part, PartCategory, PartCategoryStar, PartStar,
|
from .models import (Part, PartCategory, PartCategoryStar, PartRelated,
|
||||||
PartTestTemplate, rename_part_image)
|
PartStar, PartTestTemplate, rename_part_image)
|
||||||
from .templatetags import inventree_extras
|
from .templatetags import inventree_extras
|
||||||
|
|
||||||
|
|
||||||
@ -280,6 +280,53 @@ class PartTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(p.metadata.keys()), 4)
|
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):
|
class TestTemplateTest(TestCase):
|
||||||
"""Unit test for the TestTemplate class"""
|
"""Unit test for the TestTemplate class"""
|
||||||
|
@ -11,7 +11,6 @@ from django.urls import include, re_path
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
part_detail_urls = [
|
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'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
||||||
|
|
||||||
re_path(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
re_path(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
||||||
|
@ -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):
|
class PartPricing(AjaxView):
|
||||||
"""View for inspecting part pricing information."""
|
"""View for inspecting part pricing information."""
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
|
deletePart,
|
||||||
duplicateBom,
|
duplicateBom,
|
||||||
duplicatePart,
|
duplicatePart,
|
||||||
editCategory,
|
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.
|
/* Toggle the 'starred' status of a part.
|
||||||
* Performs AJAX queries and updates the display on the button.
|
* Performs AJAX queries and updates the display on the button.
|
||||||
*
|
*
|
||||||
|
Loading…
x
Reference in New Issue
Block a user