diff --git a/InvenTree/part/migrations/0078_auto_20220606_0024.py b/InvenTree/part/migrations/0078_auto_20220606_0024.py new file mode 100644 index 000000000000..385eb591ce5b --- /dev/null +++ b/InvenTree/part/migrations/0078_auto_20220606_0024.py @@ -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')}, + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 912541fbc028..ebe9f882d6f8 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2037,27 +2037,20 @@ def get_conversion_options(self): 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 @@ def get_api_url(): 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() + + if self.part_1 == self.part_2: + raise ValidationError(_("Part relationship cannot be created between a part and itself")) - raise ValidationError(error_message) + # 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")) diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index bcf98bef9c1f..e940d46926e1 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -559,13 +559,13 @@
{% 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 %}: -
{% 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 %} -
{% 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 %} -
{% 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 %} -
{% 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 %}
-{% endif %} - -{% endif %} - -{% endblock %} - -{% block form %} -{% if not part.active %} -{{ block.super }} -{% endif %} -{% endblock %} diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index fea5cf601276..97e690c53534 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -15,8 +15,8 @@ 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 @@ def test_metadata(self): 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""" diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 9c70b364c970..ac34225c0081 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -11,7 +11,6 @@ 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'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index d67e72f4cc4f..a57f6905bd79 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -760,23 +760,6 @@ def get_data(self): } -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.""" diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index dfc143977d81..36ff76d50352 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -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 = ` +${thumb} ${part.full_name} - ${part.description}
+ + {% trans "Deleting this part cannot be reversed" %} +