diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index c64bbb8362..c463fda891 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
@@ -104,6 +104,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/models.py b/InvenTree/part/models.py
index 26ea555ed9..aeef72da70 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -1264,11 +1264,11 @@ class Part(MPTTModel):
parts_2 = self.related_parts_2.filter(part_2__id=self.pk)
for part in parts_1:
- # Append
+ # Append
related_parts.append(part.part_2)
for part in parts_2:
- # Append
+ # Append
related_parts.append(part.part_1)
return related_parts
@@ -1749,28 +1749,41 @@ class PartRelated(models.Model):
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)
+ part_2 = models.ForeignKey(Part, related_name='related_parts_2', on_delete=models.DO_NOTHING,
+ help_text=_('Choose Related Part'))
+
+ def __str__(self):
+ return f'{self.part_1} <-> {self.part_2}'
def create_relationship(self, part_1, part_2):
+ ''' Create relationship between two parts '''
+
+ validate = True
parts = Part.objects.all()
+ related_parts = PartRelated.objects.all()
- # Check if part exist
+ # Check if part exist and there are not the same part
if (part_1 in parts and part_2 in parts) and (part_1 is not part_2):
+ # 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
+ else:
+ validate = False
+
+ if validate:
# Add relationship
self.part_1 = part_1
self.part_2 = part_2
self.save()
- return True
- else:
- return False
+ return validate
@classmethod
def create(cls, part_1, part_2):
+ ''' Create PartRelated object '''
related_part = cls()
related_part.create_relationship(part_1, part_2)
return related_part
-
- def __str__(self):
- return f'{part_1} <-> {part_2}'
diff --git a/InvenTree/part/templates/part/related.html b/InvenTree/part/templates/part/related.html
new file mode 100644
index 0000000000..f9175ed590
--- /dev/null
+++ b/InvenTree/part/templates/part/related.html
@@ -0,0 +1,46 @@
+{% extends "part/part_base.html" %}
+{% load static %}
+{% load i18n %}
+
+{% block details %}
+
+{% include 'part/tabs.html' with tab='related-parts' %}
+
+
{% trans "Related Parts" %}
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+loadPartRelatedTable($("#table-related-part"), {
+ url: "{% url 'api-part-list' %}",
+ params: {
+ part: {{ part.id }},
+ },
+});
+
+$("#add-related-part").click(function() {
+ launchModalForm("{% url 'part-related-create' %}", {
+ data: {
+ part: {{ part.id }},
+ },
+ 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..c96de9b825 100644
--- a/InvenTree/part/templates/part/tabs.html
+++ b/InvenTree/part/templates/part/tabs.html
@@ -63,6 +63,9 @@
{% endif %}
+
+ {% trans "Related Parts" %} {% 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/urls.py b/InvenTree/part/urls.py
index 32cd1b0615..e858e0583c 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'),
@@ -60,6 +65,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'),
@@ -112,6 +118,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 6498774285..f49d57bbe3 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,73 @@ 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'
+
+ # TODO: QuerySet should not show parts already related to object
+
+ def get_initial(self):
+ """ Point part_1 to parent part """
+
+ 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)
+ """
+
+ form = super(AjaxCreateView, self).get_form()
+
+ form.fields['part_1'].widget = HiddenInput()
+
+ return form
+
+ def post_save(self):
+ """ Save PartRelated model (POST method does not) """
+
+ form = self.get_form()
+
+ if form.is_valid():
+ print('form is valid!')
+
+ part_1 = form.cleaned_data['part_1']
+ part_2 = form.cleaned_data['part_2']
+
+ print(f'{part_1=}')
+ print(f'{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")
+ ajax_template_name = "related_delete.html"
+ context_object_name = "related"
+ role_required = 'part.change'
+
+
class PartAttachmentCreate(AjaxCreateView):
""" View for creating a new PartAttachment object
diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html
index e5fafef070..fbe1c945e0 100644
--- a/InvenTree/templates/js/part.html
+++ b/InvenTree/templates/js/part.html
@@ -163,6 +163,41 @@ function loadSimplePartTable(table, url, options={}) {
}
+function loadPartRelatedTable(table, options={}) {
+ /* Load related parts table */
+
+ var columns = [
+ {
+ field: options.params['part'],
+ title: '{% trans 'Part' %}',
+ sortable: true,
+ sortName: 'name',
+ formatter: function(value, row, index, field) {
+
+ var name = '';
+
+ if (row.IPN) {
+ name += row.IPN + ' | ' + row.name;
+ } else {
+ name += row.name;
+ }
+
+ return renderLink(name, '/part/' + row.pk + '/');
+ }
+ }
+ ];
+
+ $(table).inventreeTable({
+ sortName: 'part',
+ groupBy: false,
+ name: options.name || 'related_parts',
+ formatNoMatches: function() { return "{% trans "No parts found" %}"; },
+ columns: columns,
+ showColumns: true,
+ });
+}
+
+
function loadParametricPartTable(table, options={}) {
/* Load parametric table for part parameters
*