diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index e61e8cde74..00b5dfa7de 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) @@ -1017,6 +1051,44 @@ 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 + + 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): + """ + 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. @@ -1441,6 +1513,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\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/forms.py b/InvenTree/part/forms.py index ddcb78ac2a..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 @@ -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/serializers.py b/InvenTree/part/serializers.py index 49460c83a6..388faf1ca2 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) @@ -388,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_2', 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 """ diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 0d05665f7d..f8a2c4ba01 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -329,34 +329,8 @@ {% include "filter_list.html" with id="related" %} - - - - - - - - - {% for item in part.get_related_parts %} - {% with part_related=item.0 part=item.1 %} - - - - {% endwith %} - {% endfor %} - - + + @@ -771,15 +745,32 @@ // Load the "related parts" tab onPanelLoad("related-parts", function() { - $('#table-related-part').inventreeTable({ - }); + + loadRelatedPartsTable( + "#related-parts-table", + {{ part.pk }} + ); $("#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..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): @@ -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\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 """ 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; } diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 9f67794e4c..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, @@ -705,6 +706,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 = `
`; + + html += makeIconButton('fa-trash-alt icon-red', 'button-related-delete', row.pk, '{% trans "Delete part relationship" %}'); + + html += '
'; + + 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 +928,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 +988,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 +1086,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 +1128,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 {