2
0
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:
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 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")
) )

View File

@ -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'),
] ]

View File

@ -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)

View File

@ -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;

View File

@ -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>