2
0
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:
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 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"))

View File

@ -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 %}

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 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"""

View File

@ -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'),

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): class PartPricing(AjaxView):
"""View for inspecting part pricing information.""" """View for inspecting part pricing information."""

View File

@ -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.
* *