From b497569228cf85869492db80a8760dbe7918c528 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 25 Nov 2021 14:00:04 +1100
Subject: [PATCH 01/10] Add Part list API filter for "related" status

- Adds "related" filter
- Adds "exclude_related" filter
---
 InvenTree/part/api.py | 36 +++++++++++++++++++++++++++++++++++-
 1 file changed, 35 insertions(+), 1 deletion(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index e61e8cde74..9c4fdd6ba0 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -26,7 +26,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
 
 from decimal import Decimal, InvalidOperation
 
-from .models import Part, PartCategory
+from .models import Part, PartCategory, PartRelated
 from .models import BomItem, BomItemSubstitute
 from .models import PartParameter, PartParameterTemplate
 from .models import PartAttachment, PartTestTemplate
@@ -901,6 +901,40 @@ class PartList(generics.ListCreateAPIView):
 
             queryset = queryset.filter(pk__in=pks)
 
+        # Filter by 'related' parts?
+        related = params.get('related', None)
+        exclude_related = params.get('exclude_related', None)
+
+        if related is not None or exclude_related is not None:
+            try:
+                pk = related if related is not None else exclude_related
+                pk = int(pk)
+
+                related_part = Part.objects.get(pk=pk)
+
+                part_ids = set()
+
+                # Return any relationship which points to the part in question
+                relation_filter = Q(part_1=related_part) | Q(part_2=related_part)
+
+                for relation in PartRelated.objects.filter(relation_filter):
+
+                    if relation.part_1.pk != pk:
+                        part_ids.add(relation.part_1.pk)
+
+                    if relation.part_2.pk != pk:
+                        part_ids.add(relation.part_2.pk)
+
+                if related is not None:
+                    # Only return related results
+                    queryset = queryset.filter(pk__in=[pk for pk in part_ids])
+                elif exclude_related is not None:
+                    # Exclude related results
+                    queryset = queryset.exclude(pk__in=[pk for pk in part_ids])
+
+            except (ValueError, Part.DoesNotExist):
+                pass
+
         # Filter by 'starred' parts?
         starred = params.get('starred', None)
 

From a532babde8b6194e82c1fd707720164e44b42d83 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 25 Nov 2021 14:13:49 +1100
Subject: [PATCH 02/10] Related part table now uses "loadPartTable" function
 call

---
 InvenTree/part/templates/part/detail.html    | 46 +++++++-------------
 InvenTree/templates/js/translated/filters.js |  2 +-
 2 files changed, 16 insertions(+), 32 deletions(-)

diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 0d05665f7d..0e356fa061 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -326,37 +326,11 @@
     <div class='panel-content'>
         <div id='related-button-bar'>
             <div class='btn-group' role='group'>
-                {% include "filter_list.html" with id="related" %}
+                {% include "filter_list.html" with id="parts" %}
             </div>
         </div>
-        
-        <table id='table-related-part' class='table table-condensed table-striped' data-toolbar='#related-button-toolbar'>
-            <thead>
-                <tr>
-                    <th data-field='part' data-serachable='true'>{% trans "Part" %}</th>
-                </tr>
-            </thead>
-            <tbody>
-                {% for item in part.get_related_parts %}
-                {% with part_related=item.0 part=item.1 %}
-                    <tr>
-                        <td>
-                            <a class='hover-icon'>
-                                <img class='hover-img-thumb' src='{{ part.get_thumbnail_url }}'>
-                                <img class='hover-img-large' src='{{ part.get_thumbnail_url }}'>
-                            </a>
-                            <a href='/part/{{ part.id }}/'>{{ part }}</a>
-                            <div class='btn-group' style='float: right;'>
-                                {% if roles.part.change %}
-                                <button title='{% trans "Delete" %}' class='btn btn-outline-secondary delete-related-part' url="{% url 'part-related-delete' part_related.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
-                                {% endif %}
-                            </div>
-                        </td>
-                    </tr>
-                {% endwith %}
-                {% endfor %}
-            </tbody>
-        </table>
+
+        <table id='related-parts-table' class='table table-striped table-condensed' data-toolbar='#related-button-toolbar'></table>
     </div>
 </div>
 
@@ -771,8 +745,18 @@
 
     // Load the "related parts" tab
     onPanelLoad("related-parts", function() {
-        $('#table-related-part').inventreeTable({
-        });
+
+        loadPartTable(
+            '#related-parts-table',
+            '{% url "api-part-list" %}',
+            {
+                params: {
+                    related: {{ part.pk }},
+                },
+                gridView: true,
+                checkbox: false,
+            }
+        );
 
         $("#add-related-part").click(function() {
             launchModalForm("{% url 'part-related-create' %}", {
diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js
index 227fbb8009..7ae8c3e4b4 100644
--- a/InvenTree/templates/js/translated/filters.js
+++ b/InvenTree/templates/js/translated/filters.js
@@ -273,7 +273,7 @@ function setupFilterList(tableKey, table, target) {
 
     var element = $(target);
 
-    if (!element) {
+    if (!element || !element.exists()) {
         console.log(`WARNING: setupFilterList could not find target '${target}'`);
         return;
     }

From 2065c05519c16b7b22981c4359faa3046fcce883 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 25 Nov 2021 14:18:21 +1100
Subject: [PATCH 03/10] Adds API endpoints and serializers

---
 InvenTree/part/api.py         | 24 ++++++++++++++++++++++++
 InvenTree/part/serializers.py | 16 +++++++++++++++-
 2 files changed, 39 insertions(+), 1 deletion(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 9c4fdd6ba0..05f803f196 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -1051,6 +1051,24 @@ class PartList(generics.ListCreateAPIView):
     ]
 
 
+class PartRelatedList(generics.ListCreateAPIView):
+    """
+    API endpoint for accessing a list of PartRelated objects
+    """
+
+    queryset = PartRelated.objects.all()
+    serializer_class = part_serializers.PartRelationSerializer
+
+
+class PartRelatedDetail(generics.RetrieveUpdateDestroyAPIView):
+    """
+    API endpoint for accessing detail view of a PartRelated object
+    """
+
+    queryset = PartRelated.objects.all()
+    serializer_class = part_serializers.PartRelationSerializer
+
+
 class PartParameterTemplateList(generics.ListCreateAPIView):
     """ API endpoint for accessing a list of PartParameterTemplate objects.
 
@@ -1475,6 +1493,12 @@ part_api_urls = [
         url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
     ])),
 
+    # Base URL for PartRelated API endpoints
+    url(r'^related/', include([
+        url(r'^(?P<pk>\d+)/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
+        url(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'),
+    ])),
+
     # Base URL for PartParameter API endpoints
     url(r'^parameter/', include([
         url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 49460c83a6..c7c89834fb 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -25,7 +25,7 @@ from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
 from stock.models import StockItem
 
 from .models import (BomItem, BomItemSubstitute,
-                     Part, PartAttachment, PartCategory,
+                     Part, PartAttachment, PartCategory, PartRelated,
                      PartParameter, PartParameterTemplate, PartSellPriceBreak,
                      PartStar, PartTestTemplate, PartCategoryParameterTemplate,
                      PartInternalPriceBreak)
@@ -225,6 +225,20 @@ class PartBriefSerializer(InvenTreeModelSerializer):
         ]
 
 
+class PartRelationSerializer(InvenTreeModelSerializer):
+    """
+    Serializer for a PartRelated model
+    """
+
+    class Meta:
+        model = PartRelated
+        fields = [
+            'pk',
+            'part_1',
+            'part_2',
+        ]
+
+
 class PartSerializer(InvenTreeModelSerializer):
     """ Serializer for complete detail information of a part.
     Used when displaying all details of a single component.

From 68f78873796faf62d0651b747d2b4a48d3c66420 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 25 Nov 2021 14:23:27 +1100
Subject: [PATCH 04/10] Related parts are now created via the API

---
 InvenTree/part/forms.py                   | 14 -----
 InvenTree/part/templates/part/detail.html | 22 +++++--
 InvenTree/part/test_views.py              | 30 ----------
 InvenTree/part/urls.py                    |  7 ---
 InvenTree/part/views.py                   | 71 +----------------------
 5 files changed, 19 insertions(+), 125 deletions(-)

diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index ddcb78ac2a..9e3d82fe84 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -157,20 +157,6 @@ class BomMatchItemForm(MatchItemForm):
         return super().get_special_field(col_guess, row, file_manager)
 
 
-class CreatePartRelatedForm(HelperForm):
-    """ Form for creating a PartRelated object """
-
-    class Meta:
-        model = PartRelated
-        fields = [
-            'part_1',
-            'part_2',
-        ]
-        labels = {
-            'part_2': _('Related Part'),
-        }
-
-
 class SetPartCategoryForm(forms.Form):
     """ Form for setting the category of multiple Part objects """
 
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 0e356fa061..57d59a2942 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -759,11 +759,25 @@
         );
 
         $("#add-related-part").click(function() {
-            launchModalForm("{% url 'part-related-create' %}", {
-                data: {
-                    part: {{ part.id }},
+
+            constructForm('{% url "api-part-related-list" %}', {
+                method: 'POST',
+                fields: {
+                    part_1: {
+                        hidden: true,
+                        value: {{ part.pk }},
+                    },
+                    part_2: {
+                        label: '{% trans "Related Part" %}',
+                        filters: {
+                            exclude_related: {{ part.pk }},
+                        }
+                    }
                 },
-                reload: true,
+                title: '{% trans "Add Related Part" %}',
+                onSuccess: function() {
+                    $('#related-parts-table').bootstrapTable('refresh');
+                }
             });
         });
 
diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py
index 2995d45811..f5392e1f75 100644
--- a/InvenTree/part/test_views.py
+++ b/InvenTree/part/test_views.py
@@ -145,36 +145,6 @@ class PartDetailTest(PartViewTestCase):
         self.assertIn('streaming_content', dir(response))
 
 
-class PartRelatedTests(PartViewTestCase):
-
-    def test_valid_create(self):
-        """ test creation of a related part """
-
-        # Test GET view
-        response = self.client.get(reverse('part-related-create'), {'part': 1},
-                                   HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)
-
-        # Test POST view with valid form data
-        response = self.client.post(reverse('part-related-create'), {'part_1': 1, 'part_2': 2},
-                                    HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertContains(response, '"form_valid": true', status_code=200)
-
-        # Try to create the same relationship with part_1 and part_2 pks reversed
-        response = self.client.post(reverse('part-related-create'), {'part_1': 2, 'part_2': 1},
-                                    HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertContains(response, '"form_valid": false', status_code=200)
-
-        # Try to create part related to itself
-        response = self.client.post(reverse('part-related-create'), {'part_1': 1, 'part_2': 1},
-                                    HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertContains(response, '"form_valid": false', status_code=200)
-
-        # Check final count
-        n = PartRelated.objects.all().count()
-        self.assertEqual(n, 1)
-
-
 class PartQRTest(PartViewTestCase):
     """ Tests for the Part QR Code AJAX view """
 
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 46f8094fff..e5907e15e2 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -12,10 +12,6 @@ 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<pk>\d+)/delete/?', views.PartRelatedDelete.as_view(), name='part-related-delete'),
-]
 
 sale_price_break_urls = [
     url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
@@ -96,9 +92,6 @@ part_urls = [
     # Part category
     url(r'^category/', include(category_urls)),
 
-    # Part related
-    url(r'^related-parts/', include(part_related_urls)),
-
     # Part price breaks
     url(r'^sale-price/', include(sale_price_break_urls)),
 
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 62433084a6..cd9ea6b41a 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -30,7 +30,7 @@ import io
 from rapidfuzz import fuzz
 from decimal import Decimal, InvalidOperation
 
-from .models import PartCategory, Part, PartRelated
+from .models import PartCategory, Part
 from .models import PartParameterTemplate
 from .models import PartCategoryParameterTemplate
 from .models import BomItem
@@ -85,75 +85,6 @@ 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"
-
-    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
-
-
-class PartRelatedDelete(AjaxDeleteView):
-    """ View for deleting a PartRelated object """
-
-    model = PartRelated
-    ajax_form_title = _("Delete Related Part")
-    context_object_name = "related"
-
-    # Explicit role requirement
-    role_required = 'part.change'
-
-
 class PartSetCategory(AjaxUpdateView):
     """ View for settings the part category for multiple parts at once """
 

From 4704845a7bd7467ee586945115a46e9c307d48ad Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 25 Nov 2021 14:42:31 +1100
Subject: [PATCH 05/10] Add filter for "relatedpart" API endpoint

---
 InvenTree/part/api.py | 20 ++++++++++++++++++++
 1 file changed, 20 insertions(+)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 05f803f196..9fb0df41c4 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -1059,6 +1059,26 @@ class PartRelatedList(generics.ListCreateAPIView):
     queryset = PartRelated.objects.all()
     serializer_class = part_serializers.PartRelationSerializer
 
+    def filter_queryset(self, queryset):
+
+        queryset = super().filter_queryset(queryset)
+
+        params = self.request.query_params
+
+        # Add a filter for "part" - we can filter either part_1 or part_2
+        part = params.get('part', None)
+
+        if part is not None:
+            try:
+                part = Part.objects.get(pk=part)
+                
+                queryset = queryset.filter(Q(part_1=part) | Q(part_2=part))
+
+            except (ValueError, Part.DoesNotExist):
+                pass
+
+        return queryset
+
 
 class PartRelatedDetail(generics.RetrieveUpdateDestroyAPIView):
     """

From 88df774aefce8798d925ffc988f5d18528958d24 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 25 Nov 2021 14:42:44 +1100
Subject: [PATCH 06/10] Add part detail filter to PartRelated serializer

---
 InvenTree/part/serializers.py | 33 +++++++++++++++++++--------------
 1 file changed, 19 insertions(+), 14 deletions(-)

diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index c7c89834fb..e30b0ba4de 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -225,20 +225,6 @@ class PartBriefSerializer(InvenTreeModelSerializer):
         ]
 
 
-class PartRelationSerializer(InvenTreeModelSerializer):
-    """
-    Serializer for a PartRelated model
-    """
-
-    class Meta:
-        model = PartRelated
-        fields = [
-            'pk',
-            'part_1',
-            'part_2',
-        ]
-
-
 class PartSerializer(InvenTreeModelSerializer):
     """ Serializer for complete detail information of a part.
     Used when displaying all details of a single component.
@@ -402,6 +388,25 @@ class PartSerializer(InvenTreeModelSerializer):
         ]
 
 
+class PartRelationSerializer(InvenTreeModelSerializer):
+    """
+    Serializer for a PartRelated model
+    """
+
+    part_1_detail = PartSerializer(source='part_1', read_only=True, many=False)
+    part_2_detail = PartSerializer(source='part_1', read_only=True, many=False)
+
+    class Meta:
+        model = PartRelated
+        fields = [
+            'pk',
+            'part_1',
+            'part_1_detail',
+            'part_2',
+            'part_2_detail',
+        ]
+
+
 class PartStarSerializer(InvenTreeModelSerializer):
     """ Serializer for a PartStar object """
 

From 9e01bc8ff2622443c9d083ae2b6ee72780a943f3 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 25 Nov 2021 14:45:28 +1100
Subject: [PATCH 07/10] Bug fix for serializer detail

---
 InvenTree/part/serializers.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index e30b0ba4de..388faf1ca2 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -394,7 +394,7 @@ class PartRelationSerializer(InvenTreeModelSerializer):
     """
 
     part_1_detail = PartSerializer(source='part_1', read_only=True, many=False)
-    part_2_detail = PartSerializer(source='part_1', read_only=True, many=False)
+    part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
 
     class Meta:
         model = PartRelated

From e6dfe27c579640b0d58cc472709b11ed8f1ffe68 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 25 Nov 2021 14:54:21 +1100
Subject: [PATCH 08/10] Add a "related parts table" function

---
 InvenTree/part/templates/part/detail.html     |  15 +--
 InvenTree/templates/js/translated/part.js     | 113 +++++++++++++++++-
 .../templates/js/translated/table_filters.js  |   6 +
 3 files changed, 122 insertions(+), 12 deletions(-)

diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 57d59a2942..f8a2c4ba01 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -326,7 +326,7 @@
     <div class='panel-content'>
         <div id='related-button-bar'>
             <div class='btn-group' role='group'>
-                {% include "filter_list.html" with id="parts" %}
+                {% include "filter_list.html" with id="related" %}
             </div>
         </div>
 
@@ -746,16 +746,9 @@
     // Load the "related parts" tab
     onPanelLoad("related-parts", function() {
 
-        loadPartTable(
-            '#related-parts-table',
-            '{% url "api-part-list" %}',
-            {
-                params: {
-                    related: {{ part.pk }},
-                },
-                gridView: true,
-                checkbox: false,
-            }
+        loadRelatedPartsTable(
+            "#related-parts-table",
+            {{ part.pk }}
         );
 
         $("#add-related-part").click(function() {
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 9f67794e4c..855aa05385 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -705,6 +705,97 @@ function loadPartParameterTable(table, url, options) {
 }
 
 
+function loadRelatedPartsTable(table, part_id, options={}) {
+    /*
+     * Load table of "related" parts
+     */
+
+    options.params = options.params || {};
+
+    options.params.part = part_id;
+
+    var filters = {};
+
+    for (var key in options.params) {
+        filters[key] = options.params[key];
+    }
+
+    setupFilterList('related', $(table), options.filterTarget);
+
+    function getPart(row) {
+        if (row.part_1 == part_id) {
+            return row.part_2_detail;
+        } else {
+            return row.part_1_detail;
+        }
+    }
+
+    var columns = [
+        {
+            field: 'name',
+            title: '{% trans "Part" %}',
+            switchable: false,
+            formatter: function(value, row) {
+
+                var part = getPart(row);
+
+                var html = imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`)
+
+                html += makePartIcons(part);
+
+                return html;
+            }
+        },
+        {
+            field: 'description',
+            title: '{% trans "Description" %}',
+            formatter: function(value, row) {
+                return getPart(row).description;
+            }
+        },
+        {
+            field: 'actions',
+            title: '',
+            switchable: false,
+            formatter: function(value, row) {
+                
+                var html = `<div class='btn-group float-right' role='group'>`;
+
+                html += makeIconButton('fa-trash-alt icon-red', 'button-related-delete', row.pk, '{% trans "Delete part relationship" %}');
+
+                html += '</div>';
+
+                return html;
+            }
+        }
+    ];
+
+    $(table).inventreeTable({
+        url: '{% url "api-part-related-list" %}',
+        groupBy: false,
+        name: 'related',
+        original: options.params,
+        queryParams: filters,
+        columns: columns,
+        showColumns: false,
+        search: true,
+        onPostBody: function() {
+            $(table).find('.button-related-delete').click(function() {
+                var pk = $(this).attr('pk');
+
+                constructForm(`/api/part/related/${pk}/`, {
+                    method: 'DELETE',
+                    title: '{% trans "Delete Part Relationship" %}',
+                    onSuccess: function() {
+                        $(table).bootstrapTable('refresh');
+                    }
+                });
+            });
+        },
+    });
+}
+
+
 function loadParametricPartTable(table, options={}) {
     /* Load parametric table for part parameters
      * 
@@ -836,6 +927,7 @@ function loadPartTable(table, url, options={}) {
      *      query: extra query params for API request
      *      buttons: If provided, link buttons to selection status of this table
      *      disableFilters: If true, disable custom filters
+     *      actions: Provide a callback function to construct an "actions" column
      */
 
     // Ensure category detail is included
@@ -895,7 +987,7 @@ function loadPartTable(table, url, options={}) {
 
             var name = row.full_name;
 
-            var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
+            var display = imageHoverIcon(row.thumbnail) + renderLink(name, `/part/${row.pk}/`);
 
             display += makePartIcons(row);
 
@@ -993,6 +1085,21 @@ function loadPartTable(table, url, options={}) {
         }
     });
 
+    // Push an "actions" column
+    if (options.actions) {
+        columns.push({
+            field: 'actions',
+            title: '',
+            switchable: false,
+            visible: true,
+            searchable: false,
+            sortable: false,
+            formatter: function(value, row) {
+                return options.actions(value, row);
+            }
+        });
+    }
+
     var grid_view = options.gridView && inventreeLoad('part-grid-view') == 1;
 
     $(table).inventreeTable({
@@ -1020,6 +1127,10 @@ function loadPartTable(table, url, options={}) {
                 $('#view-part-grid').removeClass('btn-secondary').addClass('btn-outline-secondary');
                 $('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary');
             }
+
+            if (options.onPostBody) {
+                options.onPostBody();
+            }
         },
         buttons: options.gridView ? [
             {
diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js
index 903774f8e5..409192f74d 100644
--- a/InvenTree/templates/js/translated/table_filters.js
+++ b/InvenTree/templates/js/translated/table_filters.js
@@ -74,6 +74,12 @@ function getAvailableTableFilters(tableKey) {
         };
     }
 
+    // Filters for the "related parts" table
+    if (tableKey == 'related') {
+        return {
+        };
+    }
+
     // Filters for the "used in" table
     if (tableKey == 'usedin') {
         return {

From 4a907862963b2e73f65e920c4479dc159618044e Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 25 Nov 2021 14:55:24 +1100
Subject: [PATCH 09/10] PEP code style fixes

---
 InvenTree/part/api.py        | 2 +-
 InvenTree/part/forms.py      | 2 +-
 InvenTree/part/test_views.py | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 9fb0df41c4..00b5dfa7de 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -1071,7 +1071,7 @@ class PartRelatedList(generics.ListCreateAPIView):
         if part is not None:
             try:
                 part = Part.objects.get(pk=part)
-                
+
                 queryset = queryset.filter(Q(part_1=part) | Q(part_2=part))
 
             except (ValueError, Part.DoesNotExist):
diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 9e3d82fe84..609acec917 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -17,7 +17,7 @@ from InvenTree.fields import RoundingDecimalFormField
 import common.models
 from common.forms import MatchItemForm
 
-from .models import Part, PartCategory, PartRelated
+from .models import Part, PartCategory
 from .models import PartParameterTemplate
 from .models import PartCategoryParameterTemplate
 from .models import PartSellPriceBreak, PartInternalPriceBreak
diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py
index f5392e1f75..5b6c460e1b 100644
--- a/InvenTree/part/test_views.py
+++ b/InvenTree/part/test_views.py
@@ -5,7 +5,7 @@ from django.urls import reverse
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Group
 
-from .models import Part, PartRelated
+from .models import Part
 
 
 class PartViewTestCase(TestCase):

From 6a948a1a2021dca92b7097b3e5f76bbe866da770 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 25 Nov 2021 15:14:16 +1100
Subject: [PATCH 10/10] javascript linting

---
 InvenTree/templates/js/translated/part.js | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 855aa05385..1bf025b629 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -32,6 +32,7 @@
     loadPartTable,
     loadPartTestTemplateTable,
     loadPartVariantTable,
+    loadRelatedPartsTable,
     loadSellPricingChart,
     loadSimplePartTable,
     loadStockPricingChart,
@@ -739,7 +740,7 @@ function loadRelatedPartsTable(table, part_id, options={}) {
 
                 var part = getPart(row);
 
-                var html = imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`)
+                var html = imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`);
 
                 html += makePartIcons(part);