diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 935a0bed37..ac6e268f78 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,16 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 17 +INVENTREE_API_VERSION = 18 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v18 -> 2021-11-15 + - Adds the ability to filter BomItem API by "uses" field + - This returns a list of all BomItems which "use" the specified part + - Includes inherited BomItem objects + v17 -> 2021-11-09 - Adds API endpoints for GLOBAL and USER settings objects - Ref: https://github.com/inventree/InvenTree/pull/2275 diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index b08834445c..eeb8ec1255 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -832,18 +832,6 @@ class PartList(generics.ListCreateAPIView): queryset = super().filter_queryset(queryset) - # Filter by "uses" query - Limit to parts which use the provided part - uses = params.get('uses', None) - - if uses: - try: - uses = Part.objects.get(pk=uses) - - queryset = queryset.filter(uses.get_used_in_filter()) - - except (ValueError, Part.DoesNotExist): - pass - # Exclude specific part ID values? exclude_id = [] @@ -1040,13 +1028,19 @@ class PartParameterTemplateList(generics.ListCreateAPIView): serializer_class = part_serializers.PartParameterTemplateSerializer filter_backends = [ + DjangoFilterBackend, filters.OrderingFilter, + filters.SearchFilter, ] filter_fields = [ 'name', ] + search_fields = [ + 'name', + ] + class PartParameterList(generics.ListCreateAPIView): """ API endpoint for accessing a list of PartParameter objects @@ -1211,6 +1205,54 @@ class BomList(generics.ListCreateAPIView): except (ValueError, Part.DoesNotExist): pass + """ + Filter by 'uses'? + + Here we pass a part ID and return BOM items for any assemblies which "use" (or "require") that part. + + There are multiple ways that an assembly can "use" a sub-part: + + A) Directly specifying the sub_part in a BomItem field + B) Specifing a "template" part with inherited=True + C) Allowing variant parts to be substituted + D) Allowing direct substitute parts to be specified + + - BOM items which are "inherited" by parts which are variants of the master BomItem + """ + uses = params.get('uses', None) + + if uses is not None: + + try: + # Extract the part we are interested in + uses_part = Part.objects.get(pk=uses) + + # Construct the database query in multiple parts + + # A) Direct specification of sub_part + q_A = Q(sub_part=uses_part) + + # B) BomItem is inherited and points to a "parent" of this part + parents = uses_part.get_ancestors(include_self=False) + + q_B = Q( + inherited=True, + sub_part__in=parents + ) + + # C) Substitution of variant parts + # TODO + + # D) Specification of individual substitutes + # TODO + + q = q_A | q_B + + queryset = queryset.filter(q) + + except (ValueError, Part.DoesNotExist): + pass + if self.include_pricing(): queryset = self.annotate_pricing(queryset) diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 39ed011861..0d05665f7d 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -388,9 +388,7 @@ {% if part.variant_of %}
  • {% trans "Copy BOM" %}
  • {% endif %} - {% if not part.is_bom_valid %}
  • {% trans "Validate BOM" %}
  • - {% endif %} @@ -649,14 +647,10 @@ // Load the "used in" tab onPanelLoad("used-in", function() { - loadPartTable('#used-table', - '{% url "api-part-list" %}', - { - params: { - uses: {{ part.pk }}, - }, - filterTarget: '#filter-list-usedin', - } + + loadUsedInTable( + '#used-table', + {{ part.pk }}, ); }); diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index ec377bd513..b16de1b9d7 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1123,6 +1123,59 @@ class BomItemTest(InvenTreeAPITestCase): response = self.get(url, expected_code=200) self.assertEqual(len(response.data), 5) + def test_bom_item_uses(self): + """ + Tests for the 'uses' field + """ + + url = reverse('api-bom-list') + + # Test that the direct 'sub_part' association works + + assemblies = [] + + for i in range(5): + assy = Part.objects.create( + name=f"Assy_{i}", + description="An assembly made of other parts", + active=True, + assembly=True + ) + + assemblies.append(assy) + + components = [] + + # Create some sub-components + for i in range(5): + + cmp = Part.objects.create( + name=f"Component_{i}", + description="A sub component", + active=True, + component=True + ) + + for j in range(i): + # Create a BOM item + BomItem.objects.create( + quantity=10, + part=assemblies[j], + sub_part=cmp, + ) + + components.append(cmp) + + response = self.get( + url, + { + 'uses': cmp.pk, + }, + expected_code=200, + ) + + self.assertEqual(len(response.data), i) + class PartParameterTest(InvenTreeAPITestCase): """ diff --git a/InvenTree/templates/js/translated/api.js b/InvenTree/templates/js/translated/api.js index 15a74a9a71..735ce0a676 100644 --- a/InvenTree/templates/js/translated/api.js +++ b/InvenTree/templates/js/translated/api.js @@ -217,8 +217,10 @@ function showApiError(xhr, url) { break; } - message += '
    '; - message += `URL: ${url}`; + if (url) { + message += '
    '; + message += `URL: ${url}`; + } showMessage(title, { style: 'danger', diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index ee04cb8660..1885624dd8 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -16,6 +16,7 @@ /* exported newPartFromBomWizard, loadBomTable, + loadUsedInTable, removeRowFromBomWizard, removeColFromBomWizard, */ @@ -311,7 +312,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) { } -function loadBomTable(table, options) { +function loadBomTable(table, options={}) { /* Load a BOM table with some configurable options. * * Following options are available: @@ -395,7 +396,7 @@ function loadBomTable(table, options) { var sub_part = row.sub_part_detail; - html += makePartIcons(row.sub_part_detail); + html += makePartIcons(sub_part); if (row.substitutes && row.substitutes.length > 0) { html += makeIconBadge('fa-exchange-alt', '{% trans "Substitutes Available" %}'); @@ -672,8 +673,9 @@ function loadBomTable(table, options) { table.treegrid('collapseAll'); }, - error: function() { + error: function(xhr) { console.log('Error requesting BOM for part=' + part_pk); + showApiError(xhr); } } ); @@ -835,3 +837,166 @@ function loadBomTable(table, options) { }); } } + + +/* + * Load a table which shows the assemblies which "require" a certain part. + * + * Arguments: + * - table: The ID string of the table element e.g. '#used-in-table' + * - part_id: The ID (PK) of the part we are interested in + * + * Options: + * - + * + * The following "options" are available. + */ +function loadUsedInTable(table, part_id, options={}) { + + var params = options.params || {}; + + params.uses = part_id; + params.part_detail = true; + params.sub_part_detail = true, + params.show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM; + + var filters = {}; + + if (!options.disableFilters) { + filters = loadTableFilters('usedin'); + } + + for (var key in params) { + filters[key] = params[key]; + } + + setupFilterList('usedin', $(table), options.filterTarget || '#filter-list-usedin'); + + function loadVariantData(row) { + // Load variants information for inherited BOM rows + + inventreeGet( + '{% url "api-part-list" %}', + { + assembly: true, + ancestor: row.part, + }, + { + success: function(variantData) { + // Iterate through each variant item + for (var jj = 0; jj < variantData.length; jj++) { + variantData[jj].parent = row.pk; + + var variant = variantData[jj]; + + // Add this variant to the table, augmented + $(table).bootstrapTable('append', [{ + // Point the parent to the "master" assembly row + parent: row.pk, + part: variant.pk, + part_detail: variant, + sub_part: row.sub_part, + sub_part_detail: row.sub_part_detail, + quantity: row.quantity, + }]); + } + }, + error: function(xhr) { + showApiError(xhr); + } + } + ); + } + + $(table).inventreeTable({ + url: options.url || '{% url "api-bom-list" %}', + name: options.table_name || 'usedin', + sortable: true, + search: true, + showColumns: true, + queryParams: filters, + original: params, + rootParentId: 'top-level-item', + idField: 'pk', + uniqueId: 'pk', + parentIdField: 'parent', + treeShowField: 'part', + onLoadSuccess: function(tableData) { + // Once the initial data are loaded, check if there are any "inherited" BOM lines + for (var ii = 0; ii < tableData.length; ii++) { + var row = tableData[ii]; + + // This is a "top level" item in the table + row.parent = 'top-level-item'; + + // Ignore this row as it is not "inherited" by variant parts + if (!row.inherited) { + continue; + } + + loadVariantData(row); + } + }, + onPostBody: function() { + $(table).treegrid({ + treeColumn: 0, + }); + }, + columns: [ + { + field: 'pk', + title: 'ID', + visible: false, + switchable: false, + }, + { + field: 'part', + title: '{% trans "Assembly" %}', + switchable: false, + sortable: true, + formatter: function(value, row) { + var url = `/part/${value}/?display=bom`; + var html = ''; + + var part = row.part_detail; + + html += imageHoverIcon(part.thumbnail); + html += renderLink(part.full_name, url); + html += makePartIcons(part); + + return html; + } + }, + { + field: 'sub_part', + title: '{% trans "Required Part" %}', + sortable: true, + formatter: function(value, row) { + var url = `/part/${value}/`; + var html = ''; + + var sub_part = row.sub_part_detail; + + html += imageHoverIcon(sub_part.thumbnail); + html += renderLink(sub_part.full_name, url); + html += makePartIcons(sub_part); + + return html; + } + }, + { + field: 'quantity', + title: '{% trans "Required Quantity" %}', + formatter: function(value, row) { + var html = value; + + if (row.parent && row.parent != 'top-level-item') { + html += ` ({% trans "Inherited from parent BOM" %})`; + } + + return html; + } + } + ] + }); +} diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index 4383f0a096..227fbb8009 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -281,23 +281,24 @@ function setupFilterList(tableKey, table, target) { // One blank slate, please element.empty(); - element.append(``); + var buttons = ''; - // Callback for reloading the table - element.find(`#reload-${tableKey}`).click(function() { - $(table).bootstrapTable('refresh'); - }); + buttons += ``; - // If there are no filters defined for this table, exit now - if (jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) { - return; + // If there are filters defined for this table, add more buttons + if (!jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) { + buttons += ``; + + if (Object.keys(filters).length > 0) { + buttons += ``; + } } - element.append(``); - - if (Object.keys(filters).length > 0) { - element.append(``); - } + element.html(` +
    + ${buttons} +
    + `); for (var key in filters) { var value = getFilterOptionValue(tableKey, key, filters[key]); @@ -307,6 +308,11 @@ function setupFilterList(tableKey, table, target) { element.append(`
    ${title} = ${value}x
    `); } + // Callback for reloading the table + element.find(`#reload-${tableKey}`).click(function() { + $(table).bootstrapTable('refresh'); + }); + // Add a callback for adding a new filter element.find(`#${add}`).click(function clicked() { @@ -316,10 +322,12 @@ function setupFilterList(tableKey, table, target) { var html = ''; + html += `
    `; html += generateAvailableFilterList(tableKey); html += generateFilterInput(tableKey); html += ``; + html += `
    `; element.append(html); diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 2f25fef259..fd1668cc77 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -924,8 +924,8 @@ function handleFormSuccess(response, options) { var cache = (options.follow && response.url) || options.redirect || options.reload; // Display any messages - if (response && response.success) { - showAlertOrCache(response.success, cache, {style: 'success'}); + if (response && (response.success || options.successMessage)) { + showAlertOrCache(response.success || options.successMessage, cache, {style: 'success'}); } if (response && response.info) { diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index dc1adf8837..89e09a314e 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -331,6 +331,7 @@ function editPart(pk) { groups: groups, title: '{% trans "Edit Part" %}', reload: true, + successMessage: '{% trans "Part edited" %}', }); } diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 537adefee9..903774f8e5 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -77,10 +77,22 @@ function getAvailableTableFilters(tableKey) { // Filters for the "used in" table if (tableKey == 'usedin') { return { + 'inherited': { + type: 'bool', + title: '{% trans "Inherited" %}', + }, + 'optional': { + type: 'bool', + title: '{% trans "Optional" %}', + }, 'part_active': { type: 'bool', title: '{% trans "Active" %}', }, + 'part_trackable': { + type: 'bool', + title: '{% trans "Trackable" %}', + }, }; } diff --git a/InvenTree/templates/search_form.html b/InvenTree/templates/search_form.html index f3928888b5..f77d1ccf7b 100644 --- a/InvenTree/templates/search_form.html +++ b/InvenTree/templates/search_form.html @@ -2,8 +2,10 @@
    {% csrf_token %} - - +
    + + +