From 7bf6980b05d81304a52d794277420fbcc556244a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 6 Jun 2022 10:15:33 +1000 Subject: [PATCH 1/4] Updates for the PartRelated model - Deleting a part also deletes the relationships - Add unique_together requirement - Bug fixes - Added unit tests --- .../migrations/0078_auto_20220606_0024.py | 28 +++++++++ InvenTree/part/models.py | 60 +++++++------------ InvenTree/part/test_part.py | 51 +++++++++++++++- 3 files changed, 99 insertions(+), 40 deletions(-) create mode 100644 InvenTree/part/migrations/0078_auto_20220606_0024.py 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/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""" From 5db1c11b2295e22078bb5f843e4c65fe91825e90 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 6 Jun 2022 10:46:57 +1000 Subject: [PATCH 2/4] Adds JS function to delete a part instance --- InvenTree/part/templates/part/part_base.html | 14 +++--- InvenTree/templates/js/translated/part.js | 46 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) 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 @@
{% 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 %} diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index dfc143977d81..4c7a593f5ee4 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,51 @@ 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" %} +
    +
  • {% trans "Any stock items for this part will be deleted" %}
  • +
  • {% trans "This part will be removed from any Bills of Material" %}
  • +
  • {% trans "All manufacturer and supplier information for this part will be deleted" %}
  • +
`; + + 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. * From 6b9cd3f9f9b7ef3e7f2f790f282dc3cac663c896 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 6 Jun 2022 10:48:06 +1000 Subject: [PATCH 3/4] Remove legacy delete view --- .../part/templates/part/partial_delete.html | 78 ------------------- InvenTree/part/urls.py | 1 - InvenTree/part/views.py | 17 ---- 3 files changed, 96 deletions(-) delete mode 100644 InvenTree/part/templates/part/partial_delete.html diff --git a/InvenTree/part/templates/part/partial_delete.html b/InvenTree/part/templates/part/partial_delete.html deleted file mode 100644 index cd83f8df4f9b..000000000000 --- a/InvenTree/part/templates/part/partial_delete.html +++ /dev/null @@ -1,78 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -{% if part.active %} - -
- {% blocktrans with full_name=part.full_name %}Part '{{full_name}}' cannot be deleted as it is still marked as active. -
Disable the "Active" part attribute and re-try. - {% endblocktrans %} -
- -{% else %} - -
- {% blocktrans with full_name=part.full_name %}Are you sure you want to delete part '{{full_name}}'?{% endblocktrans %} -
- -{% if part.used_in_count %} -
-

{% 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 %}: -

    - {% for child in part.used_in.all %} -
  • {{ child.part.full_name }} - {{ child.part.description }}
  • - {% endfor %} -

    -{% endif %} - -{% if part.stock_items.all|length > 0 %} -
    -

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

      - {% for stock in part.stock_items.all %} -
    • {{ stock }}
    • - {% endfor %} -
    -

    -{% endif %} - -{% if part.manufacturer_parts.all|length > 0 %} -
    -

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

      - {% for spart in part.manufacturer_parts.all %} -
    • {% if spart.manufacturer %}{{ spart.manufacturer.name }} - {% endif %}{{ spart.MPN }}
    • - {% endfor %} -
    -

    -{% endif %} - -{% if part.supplier_parts.all|length > 0 %} -
    -

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

      - {% for spart in part.supplier_parts.all %} - {% if spart.supplier %} -
    • {{ spart.supplier.name }} - {{ spart.SKU }}
    • - {% endif %} - {% endfor %} -
    -

    -{% endif %} - -{% if part.serials.all|length > 0 %} -
    -

    {% 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/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.""" From c2f905de2aee8b46cbec9582b792a3dca1acb67d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 6 Jun 2022 10:57:48 +1000 Subject: [PATCH 4/4] JS linting --- InvenTree/templates/js/translated/part.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 4c7a593f5ee4..36ff76d50352 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -435,8 +435,7 @@ function deletePart(pk, options={}) { handleFormSuccess(response, options); } } - ) - + ); } }); }