mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-30 04:26:44 +00:00
Adds a BomUpload endpoint to handle upload of complete BOM
This commit is contained in:
parent
80818c464a
commit
a2c48d308f
@ -2,7 +2,7 @@
|
|||||||
Custom field validators for InvenTree
|
Custom field validators for InvenTree
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@ -138,7 +138,7 @@ def validate_overage(value):
|
|||||||
|
|
||||||
# Looks like a number
|
# Looks like a number
|
||||||
return True
|
return True
|
||||||
except ValueError:
|
except (ValueError, InvalidOperation):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Now look for a percentage value
|
# Now look for a percentage value
|
||||||
@ -159,7 +159,7 @@ def validate_overage(value):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Overage must be an integer value or a percentage")
|
_("Invalid value for overage")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1545,7 +1545,7 @@ class BomExtract(generics.CreateAPIView):
|
|||||||
"""
|
"""
|
||||||
Custom create function to return the extracted data
|
Custom create function to return the extracted data
|
||||||
"""
|
"""
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
self.perform_create(serializer)
|
self.perform_create(serializer)
|
||||||
@ -1556,6 +1556,16 @@ class BomExtract(generics.CreateAPIView):
|
|||||||
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
|
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
class BomUpload(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for uploading a complete Bill of Materials.
|
||||||
|
|
||||||
|
It is assumed that the BOM has been extracted from a file using the BomExtract endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Part.objects.all()
|
||||||
|
serializer_class = part_serializers.BomUploadSerializer
|
||||||
|
|
||||||
|
|
||||||
class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a single BomItem object """
|
""" API endpoint for detail view of a single BomItem object """
|
||||||
@ -1710,6 +1720,9 @@ bom_api_urls = [
|
|||||||
])),
|
])),
|
||||||
|
|
||||||
url(r'^extract/', BomExtract.as_view(), name='api-bom-extract'),
|
url(r'^extract/', BomExtract.as_view(), name='api-bom-extract'),
|
||||||
|
|
||||||
|
url(r'^upload/', BomUpload.as_view(), name='api-bom-upload'),
|
||||||
|
|
||||||
# Catch-all
|
# Catch-all
|
||||||
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
|
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
|
||||||
]
|
]
|
||||||
|
@ -8,7 +8,7 @@ import os
|
|||||||
import tablib
|
import tablib
|
||||||
|
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
@ -465,7 +465,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
price_range = serializers.CharField(read_only=True)
|
price_range = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
quantity = InvenTreeDecimalField()
|
quantity = InvenTreeDecimalField(required=True)
|
||||||
|
|
||||||
|
def validate_quantity(self, quantity):
|
||||||
|
if quantity <= 0:
|
||||||
|
raise serializers.ValidationError(_("Quantity must be greater than zero"))
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
||||||
|
|
||||||
@ -927,4 +933,37 @@ class BomExtractSerializer(serializers.Serializer):
|
|||||||
"""
|
"""
|
||||||
There is no action associated with "saving" this serializer
|
There is no action associated with "saving" this serializer
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BomUploadSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for uploading a BOM against a specified part.
|
||||||
|
|
||||||
|
A "BOM" is a set of BomItem objects which are to be validated together as a set
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = BomItemSerializer(many=True, required=True)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
data = super().validate(data)
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
raise serializers.ValidationError(_("At least one BOM item is required"))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
print(item)
|
||||||
|
|
@ -23,6 +23,7 @@
|
|||||||
loadUsedInTable,
|
loadUsedInTable,
|
||||||
removeRowFromBomWizard,
|
removeRowFromBomWizard,
|
||||||
removeColFromBomWizard,
|
removeColFromBomWizard,
|
||||||
|
submitBomTable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ function constructBomUploadTable(data, options={}) {
|
|||||||
|
|
||||||
var field_options = {
|
var field_options = {
|
||||||
hideLabels: true,
|
hideLabels: true,
|
||||||
|
hideClearButton: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function constructRowField(field_name) {
|
function constructRowField(field_name) {
|
||||||
@ -53,7 +55,7 @@ function constructBomUploadTable(data, options={}) {
|
|||||||
|
|
||||||
field.value = row[field_name];
|
field.value = row[field_name];
|
||||||
|
|
||||||
return constructField(`${field_name}_${idx}`, field, field_options);
|
return constructField(`items_${field_name}_${idx}`, field, field_options);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,8 +77,7 @@ function constructBomUploadTable(data, options={}) {
|
|||||||
buttons += `</div>`;
|
buttons += `</div>`;
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
<tr id='bom_import_row_${idx}' class='bom-import-row'>
|
<tr id='bom_import_row_${idx}' class='bom-import-row' idx='${idx}'>
|
||||||
<td id='col_buttons_${idx}'>${buttons}</td>
|
|
||||||
<td id='col_sub_part_${idx}'>${sub_part}</td>
|
<td id='col_sub_part_${idx}'>${sub_part}</td>
|
||||||
<td id='col_quantity_${idx}'>${quantity}</td>
|
<td id='col_quantity_${idx}'>${quantity}</td>
|
||||||
<td id='col_reference_${idx}'>${reference}</td>
|
<td id='col_reference_${idx}'>${reference}</td>
|
||||||
@ -85,6 +86,7 @@ function constructBomUploadTable(data, options={}) {
|
|||||||
<td id='col_inherited_${idx}'>${inherited}</td>
|
<td id='col_inherited_${idx}'>${inherited}</td>
|
||||||
<td id='col_optional_${idx}'>${optional}</td>
|
<td id='col_optional_${idx}'>${optional}</td>
|
||||||
<td id='col_note_${idx}'>${note}</td>
|
<td id='col_note_${idx}'>${note}</td>
|
||||||
|
<td id='col_buttons_${idx}'>${buttons}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
$('#bom-import-table tbody').append(html);
|
$('#bom-import-table tbody').append(html);
|
||||||
@ -92,7 +94,7 @@ function constructBomUploadTable(data, options={}) {
|
|||||||
// Initialize the "part" selector for this row
|
// Initialize the "part" selector for this row
|
||||||
initializeRelatedField(
|
initializeRelatedField(
|
||||||
{
|
{
|
||||||
name: `sub_part_${idx}`,
|
name: `items_sub_part_${idx}`,
|
||||||
value: row.part,
|
value: row.part,
|
||||||
api_url: '{% url "api-part-list" %}',
|
api_url: '{% url "api-part-list" %}',
|
||||||
filters: {
|
filters: {
|
||||||
@ -111,15 +113,6 @@ function constructBomUploadTable(data, options={}) {
|
|||||||
$(`#button-row-remove-${idx}`).click(function() {
|
$(`#button-row-remove-${idx}`).click(function() {
|
||||||
$(`#bom_import_row_${idx}`).remove();
|
$(`#bom_import_row_${idx}`).remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add callbacks for the fields which allow it
|
|
||||||
function addRowClearCallback(field_name) {
|
|
||||||
addClearCallback(`${field_name}_${idx}`, fields[field_name]);
|
|
||||||
}
|
|
||||||
|
|
||||||
addRowClearCallback('reference');
|
|
||||||
addRowClearCallback('overage');
|
|
||||||
addRowClearCallback('note');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request API endpoint options
|
// Request API endpoint options
|
||||||
@ -134,6 +127,70 @@ function constructBomUploadTable(data, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Extract rows from the BOM upload table,
|
||||||
|
* and submit data to the server
|
||||||
|
*/
|
||||||
|
function submitBomTable(part_id, options={}) {
|
||||||
|
|
||||||
|
// Extract rows from the form
|
||||||
|
var rows = [];
|
||||||
|
|
||||||
|
var idx_values = [];
|
||||||
|
|
||||||
|
var url = '{% url "api-bom-upload" %}';
|
||||||
|
|
||||||
|
$('.bom-import-row').each(function() {
|
||||||
|
var idx = $(this).attr('idx');
|
||||||
|
|
||||||
|
idx_values.push(idx);
|
||||||
|
|
||||||
|
// Extract each field from the row
|
||||||
|
rows.push({
|
||||||
|
part: part_id,
|
||||||
|
sub_part: getFormFieldValue(`items_sub_part_${idx}`, {}),
|
||||||
|
quantity: getFormFieldValue(`items_quantity_${idx}`, {}),
|
||||||
|
reference: getFormFieldValue(`items_reference_${idx}`, {}),
|
||||||
|
overage: getFormFieldValue(`items_overage_${idx}`, {}),
|
||||||
|
allow_variants: getFormFieldValue(`items_allow_variants_${idx}`, {type: 'boolean'}),
|
||||||
|
inherited: getFormFieldValue(`items_inherited_${idx}`, {type: 'boolean'}),
|
||||||
|
optional: getFormFieldValue(`items_optional_${idx}`, {type: 'boolean'}),
|
||||||
|
note: getFormFieldValue(`items_note_${idx}`, {}),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
var data = {
|
||||||
|
items: rows,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
nested: {
|
||||||
|
items: idx_values,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getApiEndpointOptions(url, function(response) {
|
||||||
|
var fields = response.actions.POST;
|
||||||
|
|
||||||
|
inventreePut(url, data, {
|
||||||
|
method: 'POST',
|
||||||
|
success: function(response) {
|
||||||
|
// TODO: Return to the "bom" page
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
switch (xhr.status) {
|
||||||
|
case 400:
|
||||||
|
handleFormErrors(xhr.responseJSON, fields, options);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
showApiError(xhr, url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function downloadBomTemplate(options={}) {
|
function downloadBomTemplate(options={}) {
|
||||||
|
|
||||||
var format = options.format;
|
var format = options.format;
|
||||||
|
@ -890,12 +890,13 @@ function validateFormField(name, options) {
|
|||||||
* - field: The field specification provided from the OPTIONS request
|
* - field: The field specification provided from the OPTIONS request
|
||||||
* - options: The original options object provided by the client
|
* - options: The original options object provided by the client
|
||||||
*/
|
*/
|
||||||
function getFormFieldValue(name, field, options) {
|
function getFormFieldValue(name, field={}, options={}) {
|
||||||
|
|
||||||
// Find the HTML element
|
// Find the HTML element
|
||||||
var el = getFormFieldElement(name, options);
|
var el = getFormFieldElement(name, options);
|
||||||
|
|
||||||
if (!el) {
|
if (!el) {
|
||||||
|
console.log(`ERROR: getFormFieldValue could not locate field '{name}'`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -981,16 +982,22 @@ function handleFormSuccess(response, options) {
|
|||||||
/*
|
/*
|
||||||
* Remove all error text items from the form
|
* Remove all error text items from the form
|
||||||
*/
|
*/
|
||||||
function clearFormErrors(options) {
|
function clearFormErrors(options={}) {
|
||||||
|
|
||||||
// Remove the individual error messages
|
if (options && options.modal) {
|
||||||
$(options.modal).find('.form-error-message').remove();
|
// Remove the individual error messages
|
||||||
|
$(options.modal).find('.form-error-message').remove();
|
||||||
|
|
||||||
// Remove the "has error" class
|
// Remove the "has error" class
|
||||||
$(options.modal).find('.form-field-error').removeClass('form-field-error');
|
$(options.modal).find('.form-field-error').removeClass('form-field-error');
|
||||||
|
|
||||||
// Hide the 'non field errors'
|
// Hide the 'non field errors'
|
||||||
$(options.modal).find('#non-field-errors').html('');
|
$(options.modal).find('#non-field-errors').html('');
|
||||||
|
} else {
|
||||||
|
$('.form-error-message').remove();
|
||||||
|
$('.form-field-errors').removeClass('form-field-error');
|
||||||
|
$('#non-field-errors').html('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -1018,7 +1025,7 @@ function clearFormErrors(options) {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function handleNestedErrors(errors, field_name, options) {
|
function handleNestedErrors(errors, field_name, options={}) {
|
||||||
|
|
||||||
var error_list = errors[field_name];
|
var error_list = errors[field_name];
|
||||||
|
|
||||||
@ -1074,15 +1081,23 @@ function handleNestedErrors(errors, field_name, options) {
|
|||||||
* - fields: The form data object
|
* - fields: The form data object
|
||||||
* - options: Form options provided by the client
|
* - options: Form options provided by the client
|
||||||
*/
|
*/
|
||||||
function handleFormErrors(errors, fields, options) {
|
function handleFormErrors(errors, fields={}, options={}) {
|
||||||
|
|
||||||
// Reset the status of the "submit" button
|
// Reset the status of the "submit" button
|
||||||
$(options.modal).find('#modal-form-submit').prop('disabled', false);
|
if (options.modal) {
|
||||||
|
$(options.modal).find('#modal-form-submit').prop('disabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove any existing error messages from the form
|
// Remove any existing error messages from the form
|
||||||
clearFormErrors(options);
|
clearFormErrors(options);
|
||||||
|
|
||||||
var non_field_errors = $(options.modal).find('#non-field-errors');
|
var non_field_errors = null;
|
||||||
|
|
||||||
|
if (options.modal) {
|
||||||
|
non_field_errors = $(options.modal).find('#non-field-errors');
|
||||||
|
} else {
|
||||||
|
non_field_errors = $('#non-field-errors');
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Display the JSON error text when hovering over the "info" icon
|
// TODO: Display the JSON error text when hovering over the "info" icon
|
||||||
non_field_errors.append(
|
non_field_errors.append(
|
||||||
@ -1158,14 +1173,19 @@ function handleFormErrors(errors, fields, options) {
|
|||||||
/*
|
/*
|
||||||
* Add a rendered error message to the provided field
|
* Add a rendered error message to the provided field
|
||||||
*/
|
*/
|
||||||
function addFieldErrorMessage(name, error_text, error_idx, options) {
|
function addFieldErrorMessage(name, error_text, error_idx, options={}) {
|
||||||
|
|
||||||
field_name = getFieldName(name, options);
|
field_name = getFieldName(name, options);
|
||||||
|
|
||||||
// Add the 'form-field-error' class
|
var field_dom = null;
|
||||||
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
|
|
||||||
|
|
||||||
var field_dom = $(options.modal).find(`#errors-${field_name}`);
|
if (options.modal) {
|
||||||
|
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
|
||||||
|
field_dom = $(options.modal).find(`#errors-${field_name}`);
|
||||||
|
} else {
|
||||||
|
$(`#div_id_${field_name}`).addClass('form-field-error');
|
||||||
|
field_dom = $(`#errors-${field_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (field_dom) {
|
if (field_dom) {
|
||||||
|
|
||||||
@ -1492,17 +1512,20 @@ function initializeRelatedField(field, fields, options={}) {
|
|||||||
|
|
||||||
var parent = null;
|
var parent = null;
|
||||||
var auto_width = false;
|
var auto_width = false;
|
||||||
|
var width = '100%';
|
||||||
|
|
||||||
// Special considerations if the select2 input is a child of a modal
|
// Special considerations if the select2 input is a child of a modal
|
||||||
if (options && options.modal) {
|
if (options && options.modal) {
|
||||||
parent = $(options.modal);
|
parent = $(options.modal);
|
||||||
auto_width = true;
|
auto_width = true;
|
||||||
|
width = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
select.select2({
|
select.select2({
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
dropdownParent: parent,
|
dropdownParent: parent,
|
||||||
dropdownAutoWidth: auto_width,
|
dropdownAutoWidth: auto_width,
|
||||||
|
width: width,
|
||||||
language: {
|
language: {
|
||||||
noResults: function(query) {
|
noResults: function(query) {
|
||||||
if (field.noResults) {
|
if (field.noResults) {
|
||||||
@ -1949,7 +1972,7 @@ function constructField(name, parameters, options) {
|
|||||||
|
|
||||||
if (extra) {
|
if (extra) {
|
||||||
|
|
||||||
if (!parameters.required) {
|
if (!parameters.required && !options.hideClearButton) {
|
||||||
html += `
|
html += `
|
||||||
<span class='input-group-text form-clear' id='clear_${field_name}' title='{% trans "Clear input" %}'>
|
<span class='input-group-text form-clear' id='clear_${field_name}' title='{% trans "Clear input" %}'>
|
||||||
<span class='icon-red fas fa-backspace'></span>
|
<span class='icon-red fas fa-backspace'></span>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user