Adds a BomUpload endpoint to handle upload of complete BOM

This commit is contained in:
Oliver 2022-02-07 10:54:37 +11:00
parent 80818c464a
commit a2c48d308f
5 changed files with 169 additions and 37 deletions

View File

@ -2,7 +2,7 @@
Custom field validators for InvenTree
"""
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.core.exceptions import ValidationError
@ -138,7 +138,7 @@ def validate_overage(value):
# Looks like a number
return True
except ValueError:
except (ValueError, InvalidOperation):
pass
# Now look for a percentage value
@ -159,7 +159,7 @@ def validate_overage(value):
pass
raise ValidationError(
_("Overage must be an integer value or a percentage")
_("Invalid value for overage")
)

View File

@ -1545,7 +1545,7 @@ class BomExtract(generics.CreateAPIView):
"""
Custom create function to return the extracted data
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
@ -1556,6 +1556,16 @@ class BomExtract(generics.CreateAPIView):
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):
""" 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'^upload/', BomUpload.as_view(), name='api-bom-upload'),
# Catch-all
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
]

View File

@ -8,7 +8,7 @@ import os
import tablib
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.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
@ -465,7 +465,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
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))
@ -927,4 +933,37 @@ class BomExtractSerializer(serializers.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)

View File

@ -23,6 +23,7 @@
loadUsedInTable,
removeRowFromBomWizard,
removeColFromBomWizard,
submitBomTable
*/
@ -41,6 +42,7 @@ function constructBomUploadTable(data, options={}) {
var field_options = {
hideLabels: true,
hideClearButton: true,
};
function constructRowField(field_name) {
@ -53,7 +55,7 @@ function constructBomUploadTable(data, options={}) {
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>`;
var html = `
<tr id='bom_import_row_${idx}' class='bom-import-row'>
<td id='col_buttons_${idx}'>${buttons}</td>
<tr id='bom_import_row_${idx}' class='bom-import-row' idx='${idx}'>
<td id='col_sub_part_${idx}'>${sub_part}</td>
<td id='col_quantity_${idx}'>${quantity}</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_optional_${idx}'>${optional}</td>
<td id='col_note_${idx}'>${note}</td>
<td id='col_buttons_${idx}'>${buttons}</td>
</tr>`;
$('#bom-import-table tbody').append(html);
@ -92,7 +94,7 @@ function constructBomUploadTable(data, options={}) {
// Initialize the "part" selector for this row
initializeRelatedField(
{
name: `sub_part_${idx}`,
name: `items_sub_part_${idx}`,
value: row.part,
api_url: '{% url "api-part-list" %}',
filters: {
@ -111,15 +113,6 @@ function constructBomUploadTable(data, options={}) {
$(`#button-row-remove-${idx}`).click(function() {
$(`#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
@ -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={}) {
var format = options.format;

View File

@ -890,12 +890,13 @@ function validateFormField(name, options) {
* - field: The field specification provided from the OPTIONS request
* - options: The original options object provided by the client
*/
function getFormFieldValue(name, field, options) {
function getFormFieldValue(name, field={}, options={}) {
// Find the HTML element
var el = getFormFieldElement(name, options);
if (!el) {
console.log(`ERROR: getFormFieldValue could not locate field '{name}'`);
return null;
}
@ -981,16 +982,22 @@ function handleFormSuccess(response, options) {
/*
* Remove all error text items from the form
*/
function clearFormErrors(options) {
function clearFormErrors(options={}) {
// Remove the individual error messages
$(options.modal).find('.form-error-message').remove();
if (options && options.modal) {
// Remove the individual error messages
$(options.modal).find('.form-error-message').remove();
// Remove the "has error" class
$(options.modal).find('.form-field-error').removeClass('form-field-error');
// Remove the "has error" class
$(options.modal).find('.form-field-error').removeClass('form-field-error');
// Hide the 'non field errors'
$(options.modal).find('#non-field-errors').html('');
// Hide the 'non field errors'
$(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];
@ -1074,15 +1081,23 @@ function handleNestedErrors(errors, field_name, options) {
* - fields: The form data object
* - options: Form options provided by the client
*/
function handleFormErrors(errors, fields, options) {
function handleFormErrors(errors, fields={}, options={}) {
// 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
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
non_field_errors.append(
@ -1158,14 +1173,19 @@ function handleFormErrors(errors, fields, options) {
/*
* 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);
// Add the 'form-field-error' class
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
var field_dom = null;
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) {
@ -1492,17 +1512,20 @@ function initializeRelatedField(field, fields, options={}) {
var parent = null;
var auto_width = false;
var width = '100%';
// Special considerations if the select2 input is a child of a modal
if (options && options.modal) {
parent = $(options.modal);
auto_width = true;
width = null;
}
select.select2({
placeholder: '',
dropdownParent: parent,
dropdownAutoWidth: auto_width,
width: width,
language: {
noResults: function(query) {
if (field.noResults) {
@ -1949,7 +1972,7 @@ function constructField(name, parameters, options) {
if (extra) {
if (!parameters.required) {
if (!parameters.required && !options.hideClearButton) {
html += `
<span class='input-group-text form-clear' id='clear_${field_name}' title='{% trans "Clear input" %}'>
<span class='icon-red fas fa-backspace'></span>