mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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
|
||||
|
||||
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"))
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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.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"""
|
||||
|
@ -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'),
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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.
|
||||
*
|
||||
|
Loading…
Reference in New Issue
Block a user