Merge remote-tracking branch 'inventree/master' into partial-shipment

# Conflicts:
#	InvenTree/InvenTree/version.py
This commit is contained in:
Oliver 2021-11-16 17:06:38 +11:00
commit 192cfb80b0
11 changed files with 332 additions and 49 deletions

View File

@ -12,15 +12,19 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 18 INVENTREE_API_VERSION = 19
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v19 -> 2021-11-16
v18 -> 2021-11-11
- Adds support for multiple "Shipments" against a SalesOrder - Adds support for multiple "Shipments" against a SalesOrder
- Refactors process for stock allocation against a SalesOrder - Refactors process for stock allocation against a SalesOrder
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 v17 -> 2021-11-09
- Adds API endpoints for GLOBAL and USER settings objects - Adds API endpoints for GLOBAL and USER settings objects
- Ref: https://github.com/inventree/InvenTree/pull/2275 - Ref: https://github.com/inventree/InvenTree/pull/2275

View File

@ -832,18 +832,6 @@ class PartList(generics.ListCreateAPIView):
queryset = super().filter_queryset(queryset) 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 specific part ID values?
exclude_id = [] exclude_id = []
@ -1040,13 +1028,19 @@ class PartParameterTemplateList(generics.ListCreateAPIView):
serializer_class = part_serializers.PartParameterTemplateSerializer serializer_class = part_serializers.PartParameterTemplateSerializer
filter_backends = [ filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter, filters.OrderingFilter,
filters.SearchFilter,
] ]
filter_fields = [ filter_fields = [
'name', 'name',
] ]
search_fields = [
'name',
]
class PartParameterList(generics.ListCreateAPIView): class PartParameterList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartParameter objects """ API endpoint for accessing a list of PartParameter objects
@ -1211,6 +1205,54 @@ class BomList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass 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(): if self.include_pricing():
queryset = self.annotate_pricing(queryset) queryset = self.annotate_pricing(queryset)

View File

@ -388,9 +388,7 @@
{% if part.variant_of %} {% if part.variant_of %}
<li><a class='dropdown-item' href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li> <li><a class='dropdown-item' href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li>
{% endif %} {% endif %}
{% if not part.is_bom_valid %}
<li><a class='dropdown-item' href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li> <li><a class='dropdown-item' href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li>
{% endif %}
</ul> </ul>
</div> </div>
@ -649,14 +647,10 @@
// Load the "used in" tab // Load the "used in" tab
onPanelLoad("used-in", function() { onPanelLoad("used-in", function() {
loadPartTable('#used-table',
'{% url "api-part-list" %}', loadUsedInTable(
{ '#used-table',
params: { {{ part.pk }},
uses: {{ part.pk }},
},
filterTarget: '#filter-list-usedin',
}
); );
}); });

View File

@ -1123,6 +1123,59 @@ class BomItemTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200) response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 5) 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): class PartParameterTest(InvenTreeAPITestCase):
""" """

View File

@ -217,8 +217,10 @@ function showApiError(xhr, url) {
break; break;
} }
message += '<hr>'; if (url) {
message += `URL: ${url}`; message += '<hr>';
message += `URL: ${url}`;
}
showMessage(title, { showMessage(title, {
style: 'danger', style: 'danger',

View File

@ -16,6 +16,7 @@
/* exported /* exported
newPartFromBomWizard, newPartFromBomWizard,
loadBomTable, loadBomTable,
loadUsedInTable,
removeRowFromBomWizard, removeRowFromBomWizard,
removeColFromBomWizard, 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. /* Load a BOM table with some configurable options.
* *
* Following options are available: * Following options are available:
@ -395,7 +396,7 @@ function loadBomTable(table, options) {
var sub_part = row.sub_part_detail; var sub_part = row.sub_part_detail;
html += makePartIcons(row.sub_part_detail); html += makePartIcons(sub_part);
if (row.substitutes && row.substitutes.length > 0) { if (row.substitutes && row.substitutes.length > 0) {
html += makeIconBadge('fa-exchange-alt', '{% trans "Substitutes Available" %}'); html += makeIconBadge('fa-exchange-alt', '{% trans "Substitutes Available" %}');
@ -672,8 +673,9 @@ function loadBomTable(table, options) {
table.treegrid('collapseAll'); table.treegrid('collapseAll');
}, },
error: function() { error: function(xhr) {
console.log('Error requesting BOM for part=' + part_pk); 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 += ` <em>({% trans "Inherited from parent BOM" %})</em>`;
}
return html;
}
}
]
});
}

View File

@ -281,23 +281,24 @@ function setupFilterList(tableKey, table, target) {
// One blank slate, please // One blank slate, please
element.empty(); element.empty();
element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`); var buttons = '';
// Callback for reloading the table buttons += `<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`;
element.find(`#reload-${tableKey}`).click(function() {
$(table).bootstrapTable('refresh');
});
// If there are no filters defined for this table, exit now // If there are filters defined for this table, add more buttons
if (jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) { if (!jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) {
return; buttons += `<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-filter'></span></button>`;
if (Object.keys(filters).length > 0) {
buttons += `<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-backspace icon-red'></span></button>`;
}
} }
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-filter'></span></button>`); element.html(`
<div class='btn-group' role='group'>
if (Object.keys(filters).length > 0) { ${buttons}
element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-backspace icon-red'></span></button>`); </div>
} `);
for (var key in filters) { for (var key in filters) {
var value = getFilterOptionValue(tableKey, key, filters[key]); var value = getFilterOptionValue(tableKey, key, filters[key]);
@ -307,6 +308,11 @@ function setupFilterList(tableKey, table, target) {
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`); element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
} }
// Callback for reloading the table
element.find(`#reload-${tableKey}`).click(function() {
$(table).bootstrapTable('refresh');
});
// Add a callback for adding a new filter // Add a callback for adding a new filter
element.find(`#${add}`).click(function clicked() { element.find(`#${add}`).click(function clicked() {
@ -316,10 +322,12 @@ function setupFilterList(tableKey, table, target) {
var html = ''; var html = '';
html += `<div class='input-group'>`;
html += generateAvailableFilterList(tableKey); html += generateAvailableFilterList(tableKey);
html += generateFilterInput(tableKey); html += generateFilterInput(tableKey);
html += `<button title='{% trans "Create filter" %}' class='btn btn-outline-secondary filter-button' id='${make}'><span class='fas fa-plus'></span></button>`; html += `<button title='{% trans "Create filter" %}' class='btn btn-outline-secondary filter-button' id='${make}'><span class='fas fa-plus'></span></button>`;
html += `</div>`;
element.append(html); element.append(html);

View File

@ -924,8 +924,8 @@ function handleFormSuccess(response, options) {
var cache = (options.follow && response.url) || options.redirect || options.reload; var cache = (options.follow && response.url) || options.redirect || options.reload;
// Display any messages // Display any messages
if (response && response.success) { if (response && (response.success || options.successMessage)) {
showAlertOrCache(response.success, cache, {style: 'success'}); showAlertOrCache(response.success || options.successMessage, cache, {style: 'success'});
} }
if (response && response.info) { if (response && response.info) {

View File

@ -331,6 +331,7 @@ function editPart(pk) {
groups: groups, groups: groups,
title: '{% trans "Edit Part" %}', title: '{% trans "Edit Part" %}',
reload: true, reload: true,
successMessage: '{% trans "Part edited" %}',
}); });
} }

View File

@ -77,10 +77,22 @@ function getAvailableTableFilters(tableKey) {
// Filters for the "used in" table // Filters for the "used in" table
if (tableKey == 'usedin') { if (tableKey == 'usedin') {
return { return {
'inherited': {
type: 'bool',
title: '{% trans "Inherited" %}',
},
'optional': {
type: 'bool',
title: '{% trans "Optional" %}',
},
'part_active': { 'part_active': {
type: 'bool', type: 'bool',
title: '{% trans "Active" %}', title: '{% trans "Active" %}',
}, },
'part_trackable': {
type: 'bool',
title: '{% trans "Trackable" %}',
},
}; };
} }

View File

@ -2,8 +2,10 @@
<form class="d-flex" action="{% url 'search' %}" method='post'> <form class="d-flex" action="{% url 'search' %}" method='post'>
{% csrf_token %} {% csrf_token %}
<input type="text" name='search' class="form-control" aria-label='{% trans "Search" %}' id="search-bar" placeholder="{% trans 'Search' %}"{% if query_text %} value="{{ query }}"{% endif %}> <div class='input-group'>
<button type="submit" id='search-submit' class="btn btn-secondary" title='{% trans "Search" %}'> <input type="text" name='search' class="form-control" aria-label='{% trans "Search" %}' id="search-bar" placeholder="{% trans 'Search' %}"{% if query_text %} value="{{ query }}"{% endif %}>
<span class='fas fa-search'></span> <button type="submit" id='search-submit' class="btn btn-secondary" title='{% trans "Search" %}'>
</button> <span class='fas fa-search'></span>
</button>
</div>
</form> </form>