mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +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:
		
							
								
								
									
										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."""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user