diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py
index 3603762faa..6bb2c1b350 100644
--- a/InvenTree/InvenTree/validators.py
+++ b/InvenTree/InvenTree/validators.py
@@ -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")
)
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 9c0556eb60..4c52b87520 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -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'),
]
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index cb050c82f8..d18b1a2a8b 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -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
\ No newline at end of file
+ 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)
+
\ No newline at end of file
diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js
index 3106c3b9f0..512cb0c46a 100644
--- a/InvenTree/templates/js/translated/bom.js
+++ b/InvenTree/templates/js/translated/bom.js
@@ -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 += ``;
var html = `
-
- ${buttons} |
+
${sub_part} |
${quantity} |
${reference} |
@@ -85,6 +86,7 @@ function constructBomUploadTable(data, options={}) {
${inherited} |
${optional} |
${note} |
+ ${buttons} |
`;
$('#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;
diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index 181b4436e1..67a162ff2b 100644
--- a/InvenTree/templates/js/translated/forms.js
+++ b/InvenTree/templates/js/translated/forms.js
@@ -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 += `