diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index f0c9e3f233..7476197547 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -9,7 +9,7 @@ from import_export.fields import Field import import_export.widgets as widgets from .models import PartCategory, Part -from .models import PartAttachment, PartStar +from .models import PartAttachment, PartStar, PartRelated from .models import BomItem from .models import PartParameterTemplate, PartParameter from .models import PartTestTemplate @@ -121,6 +121,11 @@ class PartCategoryAdmin(ImportExportModelAdmin): search_fields = ('name', 'description') +class PartRelatedAdmin(admin.ModelAdmin): + ''' Class to manage PartRelated objects ''' + pass + + class PartAttachmentAdmin(admin.ModelAdmin): list_display = ('part', 'attachment', 'comment') @@ -279,6 +284,7 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin): admin.site.register(Part, PartAdmin) admin.site.register(PartCategory, PartCategoryAdmin) +admin.site.register(PartRelated, PartRelatedAdmin) admin.site.register(PartAttachment, PartAttachmentAdmin) admin.site.register(PartStar, PartStarAdmin) admin.site.register(BomItem, BomItemAdmin) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 52c39bf3ba..c4523113e5 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -13,7 +13,7 @@ from mptt.fields import TreeNodeChoiceField from django import forms from django.utils.translation import ugettext as _ -from .models import Part, PartCategory, PartAttachment +from .models import Part, PartCategory, PartAttachment, PartRelated from .models import BomItem from .models import PartParameterTemplate, PartParameter from .models import PartTestTemplate @@ -141,6 +141,25 @@ class BomUploadSelectFile(HelperForm): ] +class CreatePartRelatedForm(HelperForm): + """ Form for creating a PartRelated object """ + + class Meta: + model = PartRelated + fields = [ + 'part_1', + 'part_2', + ] + labels = { + 'part_2': _('Related Part'), + } + + def save(self): + """ Disable model saving """ + + return super(CreatePartRelatedForm, self).save(commit=False) + + class EditPartAttachmentForm(HelperForm): """ Form for editing a PartAttachment object """ diff --git a/InvenTree/part/migrations/0052_partrelated.py b/InvenTree/part/migrations/0052_partrelated.py new file mode 100644 index 0000000000..a8672ba7dc --- /dev/null +++ b/InvenTree/part/migrations/0052_partrelated.py @@ -0,0 +1,22 @@ +# Generated by Django 3.0.7 on 2020-10-16 20:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0051_bomitem_optional'), + ] + + operations = [ + migrations.CreateModel( + name='PartRelated', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('part_1', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='related_parts_1', to='part.Part')), + ('part_2', models.ForeignKey(help_text='Select Related Part', on_delete=django.db.models.deletion.DO_NOTHING, related_name='related_parts_2', to='part.Part')), + ], + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 715d1423eb..707bc34ca2 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1313,6 +1313,32 @@ class Part(MPTTModel): return self.get_descendants(include_self=False) + def get_related_parts(self): + """ Return list of tuples for all related parts: + - 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_2 = self.related_parts_2.filter(part_2__id=self.pk) + + for related_part in related_parts_1: + # Add to related parts list + related_parts.append((related_part, related_part.part_2)) + + for related_part in related_parts_2: + # Add to related parts list + related_parts.append((related_part, related_part.part_1)) + + return related_parts + + @property + def related_count(self): + return len(self.get_related_parts()) + def attach_file(instance, filename): """ Function for storing a file for a PartAttachment @@ -1782,3 +1808,71 @@ class BomItem(models.Model): pmax = decimal2string(pmax) return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax) + + +class PartRelated(models.Model): + """ Store and handle related parts (eg. mating connector, crimps, etc.) """ + + part_1 = models.ForeignKey(Part, related_name='related_parts_1', + on_delete=models.DO_NOTHING) + + part_2 = models.ForeignKey(Part, related_name='related_parts_2', + on_delete=models.DO_NOTHING, + help_text=_('Select Related Part')) + + def __str__(self): + 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 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') + + raise ValidationError(error_message) + + def create_relationship(self, part_1, part_2): + ''' Create relationship between two parts ''' + + validate = self.validate(part_1, part_2) + + if validate: + # Add relationship + self.part_1 = part_1 + self.part_2 = part_2 + self.save() + + return validate + + @classmethod + def create(cls, part_1, part_2): + ''' Create PartRelated object and relationship between two parts ''' + + related_part = cls() + related_part.create_relationship(part_1, part_2) + return related_part diff --git a/InvenTree/part/templates/part/related.html b/InvenTree/part/templates/part/related.html new file mode 100644 index 0000000000..8c5cc074c8 --- /dev/null +++ b/InvenTree/part/templates/part/related.html @@ -0,0 +1,77 @@ +{% extends "part/part_base.html" %} +{% load static %} +{% load i18n %} + +{% block details %} + +{% include 'part/tabs.html' with tab='related-parts' %} + +

{% trans "Related Parts" %}

+
+ +
+
+ {% if roles.part.change %} + + + {% endif %} +
+
+ + + + + + + + + {% for item in part.get_related_parts %} + {% with part_related=item.0 part=item.1 %} + + + + {% endwith %} + {% endfor %} + + + + +{% endblock %} + +{% block js_ready %} +{{ block.super }} + + $('#table-related-part').inventreeTable({ + }); + + $("#add-related-part").click(function() { + launchModalForm("{% url 'part-related-create' %}", { + data: { + part: {{ part.id }}, + }, + reload: true, + }); + }); + + $('.delete-related-part').click(function() { + var button = $(this); + + launchModalForm(button.attr('url'), { + reload: true, + }); + }); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html index 8322a225bc..8bfaba4d89 100644 --- a/InvenTree/part/templates/part/tabs.html +++ b/InvenTree/part/templates/part/tabs.html @@ -63,6 +63,9 @@ {% endif %} + + {% trans "Related" %} {% if part.related_count > 0 %}{{ part.related_count }}{% endif %} + {% trans "Attachments" %} {% if part.attachment_count > 0 %}{{ part.attachment_count }}{% endif %} diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index d8c345d243..1ad5c33e45 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -201,6 +201,29 @@ class PartTests(PartViewTestCase): self.assertEqual(response.status_code, 200) +class PartRelatedTests(PartViewTestCase): + + def test_valid_create(self): + """ test creation of an attachment for a valid part """ + + response = self.client.get(reverse('part-related-create'), {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(response.status_code, 200) + + # TODO - Create a new attachment using this view + + def test_invalid_create(self): + """ test creation of an attachment for an invalid part """ + + # TODO + pass + + def test_edit(self): + """ test editing an attachment """ + + # TODO + pass + + class PartAttachmentTests(PartViewTestCase): def test_valid_create(self): diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 4d81faa2d6..624a94b0b6 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -12,6 +12,11 @@ from django.conf.urls import url, include from . import views +part_related_urls = [ + url(r'^new/?', views.PartRelatedCreate.as_view(), name='part-related-create'), + url(r'^(?P\d+)/delete/?', views.PartRelatedDelete.as_view(), name='part-related-delete'), +] + part_attachment_urls = [ url(r'^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'), url(r'^(?P\d+)/edit/?', views.PartAttachmentEdit.as_view(), name='part-attachment-edit'), @@ -61,6 +66,7 @@ part_detail_urls = [ url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'), url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'), url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'), + url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'), url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'), url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'), @@ -113,6 +119,9 @@ part_urls = [ # Part category url(r'^category/(?P\d+)/', include(part_category_urls)), + # Part related + url(r'^related-parts/', include(part_related_urls)), + # Part attachments url(r'^attachment/', include(part_attachment_urls)), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 0b96addc84..0c814edeb7 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -21,7 +21,7 @@ import os from rapidfuzz import fuzz from decimal import Decimal, InvalidOperation -from .models import PartCategory, Part, PartAttachment +from .models import PartCategory, Part, PartAttachment, PartRelated from .models import PartParameterTemplate, PartParameter from .models import BomItem from .models import match_part_names @@ -70,6 +70,85 @@ class PartIndex(InvenTreeRoleMixin, ListView): return context +class PartRelatedCreate(AjaxCreateView): + """ View for creating a new PartRelated object + + - The view only makes sense if a Part object is passed to it + """ + model = PartRelated + form_class = part_forms.CreatePartRelatedForm + ajax_form_title = _("Add Related Part") + ajax_template_name = "modal_form.html" + role_required = 'part.change' + + def get_initial(self): + """ Set parent part as part_1 field """ + + initials = {} + + part_id = self.request.GET.get('part', None) + + if part_id: + try: + initials['part_1'] = Part.objects.get(pk=part_id) + except (Part.DoesNotExist, ValueError): + pass + + return initials + + def get_form(self): + """ Create a form to upload a new PartRelated + + - Hide the 'part_1' field (parent part) + - Display parts which are not yet related + """ + + form = super(AjaxCreateView, self).get_form() + + form.fields['part_1'].widget = HiddenInput() + + try: + # Get parent part + parent_part = self.get_initial()['part_1'] + # Get existing related parts + related_parts = [related_part[1].pk for related_part in parent_part.get_related_parts()] + + # Build updated choice list excluding + # - parts already related to parent part + # - the parent part itself + updated_choices = [] + for choice in form.fields["part_2"].choices: + if (choice[0] not in related_parts) and (choice[0] != parent_part.pk): + updated_choices.append(choice) + + # Update choices for related part + form.fields['part_2'].choices = updated_choices + except KeyError: + pass + + return form + + def post_save(self): + """ Save PartRelated model (POST method does not) """ + + form = self.get_form() + + if form.is_valid(): + part_1 = form.cleaned_data['part_1'] + part_2 = form.cleaned_data['part_2'] + + PartRelated.create(part_1, part_2) + + +class PartRelatedDelete(AjaxDeleteView): + """ View for deleting a PartRelated object """ + + model = PartRelated + ajax_form_title = _("Delete Related Part") + context_object_name = "related" + role_required = 'part.change' + + class PartAttachmentCreate(AjaxCreateView): """ View for creating a new PartAttachment object diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index d3c713d07d..d1a3a3b084 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -57,6 +57,7 @@ class RuleSet(models.Model): 'part_parttesttemplate', 'part_partparametertemplate', 'part_partparameter', + 'part_partrelated', ], 'stock': [ 'stock_stockitem',