From a2c48d308fe139218173e473db3f01591f39eae0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 10:54:37 +1100 Subject: [PATCH] Adds a BomUpload endpoint to handle upload of complete BOM --- InvenTree/InvenTree/validators.py | 6 +- InvenTree/part/api.py | 15 +++- InvenTree/part/serializers.py | 45 +++++++++++- InvenTree/templates/js/translated/bom.js | 83 ++++++++++++++++++---- InvenTree/templates/js/translated/forms.js | 57 ++++++++++----- 5 files changed, 169 insertions(+), 37 deletions(-) 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 += `