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 %}
- {% 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 @@