mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Adds a BomUpload endpoint to handle upload of complete BOM
This commit is contained in:
		| @@ -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") | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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'), | ||||
| ] | ||||
|   | ||||
| @@ -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)) | ||||
|  | ||||
| @@ -928,3 +934,36 @@ class BomExtractSerializer(serializers.Serializer): | ||||
|         There is no action associated with "saving" this serializer | ||||
|         """ | ||||
|         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, | ||||
|     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; | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user