From 611592694ba50643dc14913b0a316a2dc8333a4b Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 5 Feb 2022 00:12:11 +1100 Subject: [PATCH 01/21] Adds serializer for uploading a BOM file and extracting fields --- InvenTree/part/api.py | 10 +++ InvenTree/part/serializers.py | 138 ++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index d146c3d7b3..0bebef57c6 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1533,6 +1533,15 @@ class BomList(generics.ListCreateAPIView): ] +class BomExtract(generics.CreateAPIView): + """ + API endpoint for extracting BOM data from a BOM file. + """ + + queryset = Part.objects.none() + serializer_class = part_serializers.BomExtractSerializer + + class BomDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single BomItem object """ @@ -1685,6 +1694,7 @@ bom_api_urls = [ url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'), ])), + url(r'^extract/', BomExtract.as_view(), name='api-bom-extract'), # Catch-all url(r'^.*$', BomList.as_view(), name='api-bom-list'), ] diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 16ecc8da21..b6e6934cbc 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -4,6 +4,8 @@ JSON serializers for Part app import imghdr from decimal import Decimal +import os +import tablib from django.urls import reverse_lazy from django.db import models @@ -25,6 +27,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField, from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from stock.models import StockItem +from .admin import BomItemResource from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, PartCategory, PartRelated, PartParameter, PartParameterTemplate, PartSellPriceBreak, @@ -699,3 +702,138 @@ class PartCopyBOMSerializer(serializers.Serializer): skip_invalid=data.get('skip_invalid', False), include_inherited=data.get('include_inherited', False), ) + + +class BomExtractSerializer(serializers.Serializer): + """ + Serializer for uploading a file and extracting data from it. + + Note: 2022-02-04 - This needs a *serious* refactor in future, probably + + When parsing the file, the following things happen: + + a) Check file format and validity + b) Look for "required" fields + c) Look for "part" fields - used to "infer" part + + Once the file itself has been validated, we iterate through each data row: + + - If the "level" column is provided, ignore anything below level 1 + - Try to "guess" the part based on part_id / part_name / part_ipn + - Extract other fields as required + + """ + + bom_file = serializers.FileField( + label=_("BOM File"), + help_text=_("Select Bill of Materials file"), + required=True, + allow_empty_file=False, + ) + + def validate_bom_file(self, bom_file): + """ + Perform validation checks on the uploaded BOM file + """ + + name, ext = os.path.splitext(bom_file.name) + + # Remove the leading . from the extension + ext = ext[1:] + + accepted_file_types = [ + 'xls', 'xlsx', + 'csv', 'tsv', + 'xml', + ] + + if ext not in accepted_file_types: + raise serializers.ValidationError(_("Unsupported file type")) + + # Impose a 50MB limit on uploaded BOM files + max_upload_file_size = 50 * 1024 * 1024 + + if bom_file.size > max_upload_file_size: + raise serializers.ValidationError(_("File is too large")) + + # Read file data into memory (bytes object) + data = bom_file.read() + + if ext in ['csv', 'tsv', 'xml']: + data = data.decode() + + # Convert to a tablib dataset (we expect headers) + self.dataset = tablib.Dataset().load(data, ext, headers=True) + + # These columns must be present + required_columns = [ + 'quantity', + ] + + # We need at least one column to specify a "part" + part_columns = [ + 'part', + 'part_id', + 'part_name', + 'part_ipn', + ] + + # These columns are "optional" + optional_columns = [ + 'allow_variants', + 'inherited', + 'optional', + 'overage', + 'note', + 'reference', + ] + + def find_matching_column(col_name, columns): + + # Direct match + if col_name in columns: + return col_name + + col_name = col_name.lower().strip() + + for col in columns: + if col.lower().strip() == col_name: + return col + + # No match + return None + + for header in required_columns: + + match = find_matching_column(header, self.dataset.headers) + + if match is None: + raise serializers.ValidationError(_("Missing required column") + f": '{header}'") + + part_column_matches = {} + + part_match = False + + for col in part_columns: + col_match = find_matching_column(col, self.dataset.headers) + + part_column_matches[col] = col_match + + if col_match is not None: + part_match = True + + if not part_match: + raise serializers.ValidationError(_("No part column found")) + + return bom_file + + class Meta: + fields = [ + 'bom_file', + ] + + def save(self): + """ + There is no action associated with "saving" this serializer + """ + pass \ No newline at end of file From 707787d82c4616d63e6b469d465ccfd72cb429be Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 5 Feb 2022 00:12:40 +1100 Subject: [PATCH 02/21] Fix existing bug with BomExport functionality - could not select BOM format --- InvenTree/part/urls.py | 1 - InvenTree/part/views.py | 51 +---------------------------------------- 2 files changed, 1 insertion(+), 51 deletions(-) diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 14f3e28b24..ba843f7d4b 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -33,7 +33,6 @@ part_parameter_urls = [ part_detail_urls = [ url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'), - url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'), url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 97485ebe32..03465a1838 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -1060,7 +1060,7 @@ class BomDownload(AjaxView): part = get_object_or_404(Part, pk=self.kwargs['pk']) - export_format = request.GET.get('file_format', 'csv') + export_format = request.GET.get('format', 'csv') cascade = str2bool(request.GET.get('cascade', False)) @@ -1103,55 +1103,6 @@ class BomDownload(AjaxView): } -class BomExport(AjaxView): - """ Provide a simple form to allow the user to select BOM download options. - """ - - model = Part - ajax_form_title = _("Export Bill of Materials") - - role_required = 'part.view' - - def post(self, request, *args, **kwargs): - - # Extract POSTed form data - fmt = request.POST.get('file_format', 'csv').lower() - cascade = str2bool(request.POST.get('cascading', False)) - levels = request.POST.get('levels', None) - parameter_data = str2bool(request.POST.get('parameter_data', False)) - stock_data = str2bool(request.POST.get('stock_data', False)) - supplier_data = str2bool(request.POST.get('supplier_data', False)) - manufacturer_data = str2bool(request.POST.get('manufacturer_data', False)) - - try: - part = Part.objects.get(pk=self.kwargs['pk']) - except: - part = None - - # Format a URL to redirect to - if part: - url = reverse('bom-download', kwargs={'pk': part.pk}) - else: - url = '' - - url += '?file_format=' + fmt - url += '&cascade=' + str(cascade) - url += '¶meter_data=' + str(parameter_data) - url += '&stock_data=' + str(stock_data) - url += '&supplier_data=' + str(supplier_data) - url += '&manufacturer_data=' + str(manufacturer_data) - - if levels: - url += '&levels=' + str(levels) - - data = { - 'form_valid': part is not None, - 'url': url, - } - - return self.renderJsonResponse(request, self.form_class(), data=data) - - class PartDelete(AjaxDeleteView): """ View to delete a Part object """ From 137c9ff2f2391b6df8ccf0dd3d0f2de40b5b8f10 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 5 Feb 2022 00:30:00 +1100 Subject: [PATCH 03/21] POST request now returns extracted data rows (as an array of dicts) --- InvenTree/part/api.py | 15 +++++ InvenTree/part/serializers.py | 102 ++++++++++++++++++++-------------- 2 files changed, 75 insertions(+), 42 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 0bebef57c6..9c0556eb60 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1541,6 +1541,21 @@ class BomExtract(generics.CreateAPIView): queryset = Part.objects.none() serializer_class = part_serializers.BomExtractSerializer + def create(self, request, *args, **kwargs): + """ + 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) + headers = self.get_success_headers(serializer.data) + + data = serializer.extract_data() + + return Response(data, status=status.HTTP_201_CREATED, headers=headers) + + class BomDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single BomItem object """ diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index b6e6934cbc..b9e00be7f5 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -724,6 +724,44 @@ class BomExtractSerializer(serializers.Serializer): """ + # These columns must be present + REQUIRED_COLUMNS = [ + 'quantity', + ] + + # We need at least one column to specify a "part" + PART_COLUMNS = [ + 'part', + 'part_id', + 'part_name', + 'part_ipn', + ] + + # These columns are "optional" + OPTIONAL_COLUMNS = [ + 'allow_variants', + 'inherited', + 'optional', + 'overage', + 'note', + 'reference', + ] + + def find_matching_column(self, col_name, columns): + + # Direct match + if col_name in columns: + return col_name + + col_name = col_name.lower().strip() + + for col in columns: + if col.lower().strip() == col_name: + return col + + # No match + return None + bom_file = serializers.FileField( label=_("BOM File"), help_text=_("Select Bill of Materials file"), @@ -736,6 +774,8 @@ class BomExtractSerializer(serializers.Serializer): Perform validation checks on the uploaded BOM file """ + self.filename = bom_file.name + name, ext = os.path.splitext(bom_file.name) # Remove the leading . from the extension @@ -765,47 +805,9 @@ class BomExtractSerializer(serializers.Serializer): # Convert to a tablib dataset (we expect headers) self.dataset = tablib.Dataset().load(data, ext, headers=True) - # These columns must be present - required_columns = [ - 'quantity', - ] + for header in self.REQUIRED_COLUMNS: - # We need at least one column to specify a "part" - part_columns = [ - 'part', - 'part_id', - 'part_name', - 'part_ipn', - ] - - # These columns are "optional" - optional_columns = [ - 'allow_variants', - 'inherited', - 'optional', - 'overage', - 'note', - 'reference', - ] - - def find_matching_column(col_name, columns): - - # Direct match - if col_name in columns: - return col_name - - col_name = col_name.lower().strip() - - for col in columns: - if col.lower().strip() == col_name: - return col - - # No match - return None - - for header in required_columns: - - match = find_matching_column(header, self.dataset.headers) + match = self.find_matching_column(header, self.dataset.headers) if match is None: raise serializers.ValidationError(_("Missing required column") + f": '{header}'") @@ -814,8 +816,8 @@ class BomExtractSerializer(serializers.Serializer): part_match = False - for col in part_columns: - col_match = find_matching_column(col, self.dataset.headers) + for col in self.PART_COLUMNS: + col_match = self.find_matching_column(col, self.dataset.headers) part_column_matches[col] = col_match @@ -827,6 +829,22 @@ class BomExtractSerializer(serializers.Serializer): return bom_file + def extract_data(self): + """ + Read individual rows out of the BOM file + """ + + rows = [] + + for row in self.dataset.dict: + rows.append(row) + + return { + 'rows': rows, + 'headers': self.dataset.headers, + 'filename': self.filename, + } + class Meta: fields = [ 'bom_file', From 3bd39ec752d6bf75c8a9083287cbe89117cde3ad Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 5 Feb 2022 01:26:08 +1100 Subject: [PATCH 04/21] Attempt to auto-extract part information based on provided data --- InvenTree/part/serializers.py | 67 ++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index b9e00be7f5..fd0b0c8e52 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -762,6 +762,15 @@ class BomExtractSerializer(serializers.Serializer): # No match return None + def find_matching_data(self, row, col_name, columns): + """ + Extract data from the row, based on the "expected" column name + """ + + col_name = self.find_matching_column(col_name, columns) + + return row.get(col_name, None) + bom_file = serializers.FileField( label=_("BOM File"), help_text=_("Select Bill of Materials file"), @@ -836,12 +845,68 @@ class BomExtractSerializer(serializers.Serializer): rows = [] + headers = self.dataset.headers + + level_column = self.find_matching_column('level', headers) + for row in self.dataset.dict: + + """ + If the "level" column is specified, and this is not a top-level BOM item, ignore the row! + """ + if level_column is not None: + level = row.get('level', None) + + if level is not None: + try: + level = int(level) + if level != 1: + continue + except: + pass + + """ + Next, we try to "guess" the part, based on the provided data. + + A) If the part_id is supplied, use that! + B) If the part name and/or part_ipn are supplied, maybe we can use those? + """ + part_id = self.find_matching_data(row, 'part_id', headers) + part_name = self.find_matching_data(row, 'part_name', headers) + part_ipn = self.find_matching_data(row, 'part_ipn', headers) + + part = None + + if part_id is not None: + try: + part = Part.objects.get(pk=part_id) + except (ValueError, Part.DoesNotExist): + pass + + if part is None: + + if part_name is not None or part_ipn is not None: + queryset = Part.objects.all() + + if part_name is not None: + queryset = queryset.filter(name=part_name) + + if part_ipn is not None: + queryset = queryset.filter(IPN=part_ipn) + + # Only if we have a single direct match + if queryset.exists() and queryset.count() == 1: + part = queryset.first() + + row['part'] = part.pk if part is not None else None + + print("part:", part) + rows.append(row) return { 'rows': rows, - 'headers': self.dataset.headers, + 'headers': headers, 'filename': self.filename, } From 8a8d718534f985be08284ab95464f84f4e951690 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 5 Feb 2022 01:26:34 +1100 Subject: [PATCH 05/21] Basic javascript function to construct BOM table from extracted data --- InvenTree/templates/js/translated/bom.js | 64 ++++++++++++++++++++++ InvenTree/templates/js/translated/forms.js | 23 +++++--- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 71c6b0b387..798c6749ec 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -15,6 +15,7 @@ */ /* exported + constructBomUploadTable, downloadBomTemplate, exportBom, newPartFromBomWizard, @@ -24,6 +25,69 @@ removeColFromBomWizard, */ + +/* Construct a table of data extracted from a BOM file. + * This data is used to import a BOM interactively. + */ +function constructBomUploadTable(data, options={}) { + + if (!data.rows) { + // TODO: Error message! + return; + } + + function constructRow(row, idx) { + // Construct an individual row from the provided data + + var part_input = constructField( + `part_${idx}`, + { + type: 'related field', + required: 'true', + }, + { + hideLabels: true, + } + ); + + var html = ` + + ${part_input} + quantity + reference + overage + variants + inherited + optional + note + `; + + $('#bom-import-table tbody').append(html); + + // Initialize the "part" selector for this row + initializeRelatedField( + { + name: `part_${idx}`, + api_url: '{% url "api-part-list" %}', + filters: { + component: true, + }, + model: 'part', + required: true, + auto_fill: false, + onSelect: function(data, field, opts) { + // TODO? + }, + } + ); + } + + data.rows.forEach(function(row, idx) { + constructRow(row, idx); + }); +} + + function downloadBomTemplate(options={}) { var format = options.format; diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 2f2d1f8ae4..ff4415404f 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -837,7 +837,15 @@ function getFormFieldElement(name, options) { var field_name = getFieldName(name, options); - var el = $(options.modal).find(`#id_${field_name}`); + var el = null; + + if (options && options.modal) { + // Field element is associated with a model? + el = $(options.modal).find(`#id_${field_name}`); + } else { + // Field element is top-level + el = $(`#id_${field_name}`); + } if (!el.exists) { console.log(`ERROR: Could not find form element for field '${name}'`); @@ -1330,11 +1338,13 @@ function hideFormGroup(group, options) { $(options.modal).find(`#form-panel-${group}`).hide(); } + // Show a form group function showFormGroup(group, options) { $(options.modal).find(`#form-panel-${group}`).show(); } + function setFormGroupVisibility(group, vis, options) { if (vis) { showFormGroup(group, options); @@ -1344,7 +1354,7 @@ function setFormGroupVisibility(group, vis, options) { } -function initializeRelatedFields(fields, options) { +function initializeRelatedFields(fields, options={}) { var field_names = options.field_names; @@ -1452,12 +1462,11 @@ function addSecondaryModal(field, fields, options) { * - field: Field definition from the OPTIONS request * - options: Original options object provided by the client */ -function initializeRelatedField(field, fields, options) { +function initializeRelatedField(field, fields, options={}) { var name = field.name; if (!field.api_url) { - // TODO: Provide manual api_url option? console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`); return; } @@ -1654,7 +1663,7 @@ function initializeRelatedField(field, fields, options) { * - data: JSON data representing the model instance * - options: The modal form specifications */ -function setRelatedFieldData(name, data, options) { +function setRelatedFieldData(name, data, options={}) { var select = getFormFieldElement(name, options); @@ -1779,10 +1788,10 @@ function renderModelData(name, model, data, parameters, options) { /* * Construct a field name for the given field */ -function getFieldName(name, options) { +function getFieldName(name, options={}) { var field_name = name; - if (options.depth) { + if (options && options.depth) { field_name += `_${options.depth}`; } From 8a86932c7b1d69f36821ffa9410e5ee9e1843209 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 5 Feb 2022 01:36:46 +1100 Subject: [PATCH 06/21] Initialize related field for "part" selection --- InvenTree/templates/js/translated/bom.js | 9 +++++++++ InvenTree/templates/js/translated/forms.js | 13 +++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 798c6749ec..5b1c7e2bb2 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -50,6 +50,13 @@ function constructBomUploadTable(data, options={}) { } ); + var buttons = `
`; + + buttons += makeIconButton('fa-file-alt', 'button-row-data', idx, '{% trans "Display row data" %}'); + buttons += makeIconButton('fa-times icon-red', 'button-row-remove', idx, '{% trans "Remove row" %}'); + + buttons += `
`; + var html = ` ${part_input} @@ -60,6 +67,7 @@ function constructBomUploadTable(data, options={}) { inherited optional note + ${buttons} `; $('#bom-import-table tbody').append(html); @@ -68,6 +76,7 @@ function constructBomUploadTable(data, options={}) { initializeRelatedField( { name: `part_${idx}`, + value: row.part, api_url: '{% url "api-part-list" %}', filters: { component: true, diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index ff4415404f..c2addc3594 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1484,10 +1484,19 @@ function initializeRelatedField(field, fields, options={}) { // limit size for AJAX requests var pageSize = options.pageSize || 25; + var parent = null; + var auto_width = true; + + // Special considerations if the select2 input is a child of a modal + if (options && options.modal) { + parent = $(options.modal); + auto_width = true; + } + select.select2({ placeholder: '', - dropdownParent: $(options.modal), - dropdownAutoWidth: false, + dropdownParent: parent, + dropdownAutoWidth: auto_width, language: { noResults: function(query) { if (field.noResults) { From c620107625d63a76647df1b9ef3420ac888747fe Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 5 Feb 2022 09:11:01 +1100 Subject: [PATCH 07/21] Add callback for "remove row" button --- InvenTree/templates/js/translated/bom.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 5b1c7e2bb2..7bb160eccb 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -89,6 +89,11 @@ function constructBomUploadTable(data, options={}) { }, } ); + + // Add callback for "remove row" button + $(`#button-row-remove-${idx}`).click(function() { + $(`#bom_import_row_${idx}`).remove(); + }); } data.rows.forEach(function(row, idx) { From bdf0b5b4461caffddeea42fd8958678567ff09f3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 5 Feb 2022 09:53:35 +1100 Subject: [PATCH 08/21] Construct required form fields - required some additional functionality in forms.js --- InvenTree/templates/js/translated/bom.js | 67 +++++++++++++++------- InvenTree/templates/js/translated/forms.js | 16 ++++-- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 7bb160eccb..4fa178923c 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -35,20 +35,37 @@ function constructBomUploadTable(data, options={}) { // TODO: Error message! return; } - - function constructRow(row, idx) { + + function constructRow(row, idx, fields) { // Construct an individual row from the provided data - var part_input = constructField( - `part_${idx}`, - { - type: 'related field', - required: 'true', - }, - { - hideLabels: true, + var field_options = { + hideLabels: true, + }; + + function constructRowField(field_name) { + + var field = fields[field_name] || null; + + if (!field) { + return `Cannot render field '${field_name}`; } - ); + + field.value = row[field_name]; + + return constructField(`${field_name}_${idx}`, field, field_options); + + } + + // Construct form inputs + var sub_part = constructRowField('sub_part'); + var quantity = constructRowField('quantity'); + var reference = constructRowField('reference'); + var overage = constructRowField('overage'); + var variants = constructRowField('allow_variants'); + var inherited = constructRowField('inherited'); + var optional = constructRowField('optional'); + var note = constructRowField('note'); var buttons = `
`; @@ -59,14 +76,14 @@ function constructBomUploadTable(data, options={}) { var html = ` - ${part_input} - quantity - reference - overage - variants - inherited - optional - note + ${sub_part} + ${quantity} + ${reference} + ${overage} + ${variants} + ${inherited} + ${optional} + ${note} ${buttons} `; @@ -75,7 +92,7 @@ function constructBomUploadTable(data, options={}) { // Initialize the "part" selector for this row initializeRelatedField( { - name: `part_${idx}`, + name: `sub_part_${idx}`, value: row.part, api_url: '{% url "api-part-list" %}', filters: { @@ -96,8 +113,14 @@ function constructBomUploadTable(data, options={}) { }); } - data.rows.forEach(function(row, idx) { - constructRow(row, idx); + // Request API endpoint options + getApiEndpointOptions('{% url "api-bom-list" %}', function(response) { + + var fields = response.actions.POST; + + data.rows.forEach(function(row, idx) { + constructRow(row, idx, fields); + }); }); } diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index c2addc3594..dda79f2737 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -2071,7 +2071,7 @@ function constructInput(name, parameters, options) { // Construct a set of default input options which apply to all input types -function constructInputOptions(name, classes, type, parameters) { +function constructInputOptions(name, classes, type, parameters, options={}) { var opts = []; @@ -2153,11 +2153,18 @@ function constructInputOptions(name, classes, type, parameters) { if (parameters.multiline) { return ``; } else if (parameters.type == 'boolean') { + + var help_text = ''; + + if (!options.hideLabels && parameters.help_text) { + help_text = `${parameters.help_text}`; + } + return `
`; @@ -2180,13 +2187,14 @@ function constructHiddenInput(name, parameters) { // Construct a "checkbox" input -function constructCheckboxInput(name, parameters) { +function constructCheckboxInput(name, parameters, options={}) { return constructInputOptions( name, 'form-check-input', 'checkbox', - parameters + parameters, + options ); } From 81271bf6b970e95753e1f70f3b65d853b0c29bfc Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 5 Feb 2022 10:01:07 +1100 Subject: [PATCH 09/21] Add "clear input" callback function --- InvenTree/templates/js/translated/bom.js | 13 +++++++++++-- InvenTree/templates/js/translated/forms.js | 10 ++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 4fa178923c..3106c3b9f0 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -69,13 +69,14 @@ function constructBomUploadTable(data, options={}) { var buttons = `
`; - buttons += makeIconButton('fa-file-alt', 'button-row-data', idx, '{% trans "Display row data" %}'); + // buttons += makeIconButton('fa-file-alt', 'button-row-data', idx, '{% trans "Display row data" %}'); buttons += makeIconButton('fa-times icon-red', 'button-row-remove', idx, '{% trans "Remove row" %}'); buttons += `
`; var html = ` + ${buttons} ${sub_part} ${quantity} ${reference} @@ -84,7 +85,6 @@ function constructBomUploadTable(data, options={}) { ${inherited} ${optional} ${note} - ${buttons} `; $('#bom-import-table tbody').append(html); @@ -111,6 +111,15 @@ 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 diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index dda79f2737..e5cb571a58 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1240,8 +1240,14 @@ function addClearCallback(name, field, options) { var field_name = getFieldName(name, options); - var el = $(options.modal).find(`#clear_${field_name}`); - + var el = null; + + if (options && options.modal) { + el = $(options.modal).find(`#clear_${field_name}`); + } else { + el = $(`#clear_${field_name}`); + } + if (!el) { console.log(`WARNING: addClearCallback could not find field '${name}'`); return; From 0963602bea611d7dd8989fc6d555e47d9f000c10 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 08:37:39 +1100 Subject: [PATCH 10/21] Add optional part lookup by "part" field --- InvenTree/part/serializers.py | 12 ++++++++++-- InvenTree/templates/js/translated/forms.js | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index fd0b0c8e52..cb050c82f8 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -883,6 +883,16 @@ class BomExtractSerializer(serializers.Serializer): except (ValueError, Part.DoesNotExist): pass + # Optionally, specify using field "part" + if part is None: + pk = self.find_matching_data(row, 'part', headers) + + if pk is not None: + try: + part = Part.objects.get(pk=pk) + except (ValueError, Part.DoesNotExist): + pass + if part is None: if part_name is not None or part_ipn is not None: @@ -900,8 +910,6 @@ class BomExtractSerializer(serializers.Serializer): row['part'] = part.pk if part is not None else None - print("part:", part) - rows.append(row) return { diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index e5cb571a58..181b4436e1 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1236,7 +1236,7 @@ function addClearCallbacks(fields, options) { } -function addClearCallback(name, field, options) { +function addClearCallback(name, field, options={}) { var field_name = getFieldName(name, options); @@ -1491,7 +1491,7 @@ function initializeRelatedField(field, fields, options={}) { var pageSize = options.pageSize || 25; var parent = null; - var auto_width = true; + var auto_width = false; // Special considerations if the select2 input is a child of a modal if (options && options.modal) { From 80818c464a3fe696944cf7ef232c7827d6622ec0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 09:24:23 +1100 Subject: [PATCH 11/21] Allow decimal values for BOM overage --- InvenTree/InvenTree/validators.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index 76d485cef9..3603762faa 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -2,6 +2,8 @@ Custom field validators for InvenTree """ +from decimal import Decimal + from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -115,24 +117,26 @@ def validate_tree_name(value): def validate_overage(value): - """ Validate that a BOM overage string is properly formatted. + """ + Validate that a BOM overage string is properly formatted. An overage string can look like: - An integer number ('1' / 3 / 4) + - A decimal number ('0.123') - A percentage ('5%' / '10 %') """ value = str(value).lower().strip() - # First look for a simple integer value + # First look for a simple numerical value try: - i = int(value) + i = Decimal(value) if i < 0: raise ValidationError(_("Overage value must not be negative")) - # Looks like an integer! + # Looks like a number return True except ValueError: pass From a2c48d308fe139218173e473db3f01591f39eae0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 10:54:37 +1100 Subject: [PATCH 12/21] 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 += ` From 2ade14c47bcee91cefb697aefd4c3859341abbde Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 11:35:07 +1100 Subject: [PATCH 13/21] Check for duplicate BOM items as part of serializer validation --- InvenTree/part/serializers.py | 36 ++++++++++++++++--- InvenTree/templates/js/translated/bom.js | 3 +- InvenTree/templates/js/translated/forms.js | 5 +-- .../js/translated/model_renderers.js | 2 +- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index d18b1a2a8b..89f9103187 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -493,6 +493,22 @@ class BomItemSerializer(InvenTreeModelSerializer): purchase_price_range = serializers.SerializerMethodField() + def validate(self, data): + + # Check for duplicate BOM items + part = data['part'] + sub_part = data['sub_part'] + + if BomItem.objects.get(part=part, sub_part=sub_part).exists(): + raise serializers.ValidationError({ + 'part': _("Duplicate BOM item already exists"), + 'sub_part': _("Duplicate BOM items already exists"), + }) + + data = super().validate(data) + + return data + def __init__(self, *args, **kwargs): # part_detail and sub_part_detail serializers are only included if requested. # This saves a bunch of database requests @@ -962,8 +978,20 @@ class BomUploadSerializer(serializers.Serializer): items = data['items'] - with transaction.atomic(): + try: + with transaction.atomic(): - for item in items: - print(item) - \ No newline at end of file + for item in items: + + part = item['part'] + sub_part = item['sub_part'] + + # Ignore duplicate BOM items + if BomItem.objects.filter(part=part, sub_part=sub_part).exists(): + continue + + # Create a new BomItem object + BomItem.objects.create(**item) + + except Exception as e: + raise serializers.ValidationError(detail=serializers.as_serializer_error(e)) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 512cb0c46a..d5391ab70a 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -43,6 +43,7 @@ function constructBomUploadTable(data, options={}) { var field_options = { hideLabels: true, hideClearButton: true, + form_classes: 'bom-form-group', }; function constructRowField(field_name) { @@ -77,7 +78,7 @@ function constructBomUploadTable(data, options={}) { buttons += ``; var html = ` - + ${sub_part} ${quantity} ${reference} diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 67a162ff2b..2742e3f0ca 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1056,6 +1056,7 @@ function handleNestedErrors(errors, field_name, options={}) { // Here, error_item is a map of field names to error messages for (sub_field_name in error_item) { + var errors = error_item[sub_field_name]; // Find the target (nested) field @@ -1919,12 +1920,12 @@ function constructField(name, parameters, options) { options.current_group = group; } - var form_classes = 'form-group'; + var form_classes = options.form_classes || 'form-group'; if (parameters.errors) { form_classes += ' form-field-error'; } - + // Optional content to render before the field if (parameters.before) { html += parameters.before; diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index ee4c4cb5ef..68ba496309 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -161,7 +161,7 @@ function renderPart(name, data, parameters, options) { html += ` ${data.full_name || data.name}`; if (data.description) { - html += ` - ${data.description}`; + html += ` - ${data.description}`; } var extra = ''; From 4f26df3124238adc2ae7c850b3246b842b624885 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 11:35:51 +1100 Subject: [PATCH 14/21] bug fix --- InvenTree/part/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 89f9103187..fb90547a04 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -499,7 +499,7 @@ class BomItemSerializer(InvenTreeModelSerializer): part = data['part'] sub_part = data['sub_part'] - if BomItem.objects.get(part=part, sub_part=sub_part).exists(): + if BomItem.objects.filter(part=part, sub_part=sub_part).exists(): raise serializers.ValidationError({ 'part': _("Duplicate BOM item already exists"), 'sub_part': _("Duplicate BOM items already exists"), From 131663cecc8491f462701263da84eb630be56a15 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 12:20:18 +1100 Subject: [PATCH 15/21] Adds options to clear existing BOM data when uploading --- InvenTree/part/serializers.py | 34 ++++++++++++----- InvenTree/templates/js/translated/bom.js | 44 ++++++++++++++-------- InvenTree/templates/js/translated/forms.js | 22 +++++++++++ 3 files changed, 76 insertions(+), 24 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index fb90547a04..c9c78eb46b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -746,6 +746,13 @@ class BomExtractSerializer(serializers.Serializer): """ + class Meta: + fields = [ + 'bom_file', + 'part', + 'clear_existing', + ] + # These columns must be present REQUIRED_COLUMNS = [ 'quantity', @@ -940,16 +947,24 @@ class BomExtractSerializer(serializers.Serializer): 'filename': self.filename, } - class Meta: - fields = [ - 'bom_file', - ] + part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True), required=True) + + clear_existing = serializers.BooleanField( + label=_("Clear Existing BOM"), + help_text=_("Delete existing BOM data first"), + ) def save(self): - """ - There is no action associated with "saving" this serializer - """ - pass + + data = self.validated_data + + master_part = data['part'] + clear_existing = data['clear_existing'] + + if clear_existing: + + # Remove all existing BOM items + master_part.bom_items.all().delete() class BomUploadSerializer(serializers.Serializer): @@ -963,13 +978,14 @@ class BomUploadSerializer(serializers.Serializer): 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")) + data = super().validate(data) + return data def save(self): diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index d5391ab70a..c80f9a2694 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -112,7 +112,7 @@ function constructBomUploadTable(data, options={}) { // Add callback for "remove row" button $(`#button-row-remove-${idx}`).click(function() { - $(`#bom_import_row_${idx}`).remove(); + $(`#items_${idx}`).remove(); }); } @@ -172,22 +172,36 @@ function submitBomTable(part_id, options={}) { getApiEndpointOptions(url, function(response) { var fields = response.actions.POST; - inventreePut(url, data, { + constructForm(url, { 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; - } + fields: { + clear_existing: {}, + }, + title: '{% trans "Submit BOM Data" %}', + onSubmit: function(fields, opts) { + + data.clear_existing = getFormFieldValue('clear_existing', {}, opts); + + $(opts.modal).modal('hide'); + + 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; + } + } + }); } - }); + }); }); } diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 2742e3f0ca..fe912b3358 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1059,6 +1059,28 @@ function handleNestedErrors(errors, field_name, options={}) { var errors = error_item[sub_field_name]; + if (sub_field_name == 'non_field_errors') { + + var row = null; + + if (options.modal) { + row = $(options.modal).find(`#items_${nest_id}`); + } else { + row = $(`#items_${nest_id}`); + } + + for (var ii = errors.length - 1; ii >= 0; ii--) { + + var html = ` +
+ ${errors[ii]} +
`; + + row.after(html); + } + + } + // Find the target (nested) field var target = `${field_name}_${sub_field_name}_${nest_id}`; From 11d5900b69b4afe7fe340302997f8a47b1bc0f48 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 12:25:09 +1100 Subject: [PATCH 16/21] Update upload file template --- .../part/bom_upload/upload_file.html | 84 ++++++++++++++----- InvenTree/templates/js/translated/bom.js | 42 ++++------ 2 files changed, 75 insertions(+), 51 deletions(-) diff --git a/InvenTree/part/templates/part/bom_upload/upload_file.html b/InvenTree/part/templates/part/bom_upload/upload_file.html index 40411f074a..27f681acae 100644 --- a/InvenTree/part/templates/part/bom_upload/upload_file.html +++ b/InvenTree/part/templates/part/bom_upload/upload_file.html @@ -14,21 +14,22 @@ {% endblock %} {% block actions %} + + + {% endblock %} {% block page_info %}
-

{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} - {% if description %}- {{ description }}{% endif %}

-
- {% csrf_token %} - {% load crispy_forms_tags %} - - {% block form_buttons_top %} - {% endblock form_buttons_top %} - - {% block form_alert %}
{% trans "Requirements for BOM upload" %}:
    @@ -36,22 +37,29 @@
  • {% trans "Each part must already exist in the database" %}
- {% endblock %} - - {{ wizard.management_form }} - {% block form_content %} - {% crispy wizard.form %} - {% endblock form_content %} +
+ +
+ + +
+ + + + + + + + + + + + + +
{% trans "Part" %}{% trans "Quantity" %}{% trans "Reference" %}{% trans "Overage" %}{% trans "Allow Variants" %}{% trans "Inherited" %}{% trans "Optional" %}{% trans "Note" %}
- {% block form_buttons_bottom %} - {% if wizard.steps.prev %} - - {% endif %} - -
- {% endblock form_buttons_bottom %}
{% endblock page_info %} @@ -64,4 +72,34 @@ $('#bom-template-download').click(function() { downloadBomTemplate(); }); +$('#bom-upload').click(function() { + + constructForm('{% url "api-bom-extract" %}', { + method: 'POST', + fields: { + bom_file: {}, + part: { + value: {{ part.pk }}, + hidden: true, + }, + clear_existing: {}, + }, + title: '{% trans "Upload BOM File" %}', + onSuccess: function(response) { + $('#bom-upload').hide(); + + $('#bom-submit').show(); + + constructBomUploadTable(response); + + $('#bom-submit').click(function() { + submitBomTable({{ part.pk }}, { + bom_data: response, + }); + }); + } + }); + +}); + {% endblock js_ready %} \ No newline at end of file diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index c80f9a2694..cb07f93a38 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -172,36 +172,22 @@ function submitBomTable(part_id, options={}) { getApiEndpointOptions(url, function(response) { var fields = response.actions.POST; - constructForm(url, { + inventreePut(url, data, { method: 'POST', - fields: { - clear_existing: {}, - }, - title: '{% trans "Submit BOM Data" %}', - onSubmit: function(fields, opts) { - - data.clear_existing = getFormFieldValue('clear_existing', {}, opts); - - $(opts.modal).modal('hide'); - - 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; - } - } - }); + success: function(response) { + window.location.href = `/part/${part_id}/?display=bom`; + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, options); + break; + default: + showApiError(xhr, url); + break; + } } - }); + }); }); } From 509d58979e5c49f01673cd999eb49266b3e9b07b Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 12:29:15 +1100 Subject: [PATCH 17/21] Remove old templates --- .../part/bom_upload/match_fields.html | 99 ------- .../part/bom_upload/match_parts.html | 127 --------- .../upload_file.html => upload_bom.html} | 0 InvenTree/part/views.py | 268 +----------------- 4 files changed, 5 insertions(+), 489 deletions(-) delete mode 100644 InvenTree/part/templates/part/bom_upload/match_fields.html delete mode 100644 InvenTree/part/templates/part/bom_upload/match_parts.html rename InvenTree/part/templates/part/{bom_upload/upload_file.html => upload_bom.html} (100%) diff --git a/InvenTree/part/templates/part/bom_upload/match_fields.html b/InvenTree/part/templates/part/bom_upload/match_fields.html deleted file mode 100644 index b09260cf46..0000000000 --- a/InvenTree/part/templates/part/bom_upload/match_fields.html +++ /dev/null @@ -1,99 +0,0 @@ -{% extends "part/bom_upload/upload_file.html" %} -{% load inventree_extras %} -{% load i18n %} -{% load static %} - -{% block form_alert %} -{% if missing_columns and missing_columns|length > 0 %} - -{% endif %} -{% if duplicates and duplicates|length > 0 %} - -{% endif %} -{% endblock form_alert %} - -{% block form_buttons_top %} - {% if wizard.steps.prev %} - - {% endif %} - -{% endblock form_buttons_top %} - -{% block form_content %} - - - {% trans "File Fields" %} - - {% for col in form %} - -
- - {{ col.name }} - -
- - {% endfor %} - - - - - {% trans "Match Fields" %} - - {% for col in form %} - - {{ col }} - {% for duplicate in duplicates %} - {% if duplicate == col.value %} - - {% endif %} - {% endfor %} - - {% endfor %} - - {% for row in rows %} - {% with forloop.counter as row_index %} - - - - - {{ row_index }} - {% for item in row.data %} - - - {{ item }} - - {% endfor %} - - {% endwith %} - {% endfor %} - -{% endblock form_content %} - -{% block form_buttons_bottom %} -{% endblock form_buttons_bottom %} - -{% block js_ready %} -{{ block.super }} - -$('.fieldselect').select2({ - width: '100%', - matcher: partialMatcher, -}); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/match_parts.html b/InvenTree/part/templates/part/bom_upload/match_parts.html deleted file mode 100644 index 0345fa309e..0000000000 --- a/InvenTree/part/templates/part/bom_upload/match_parts.html +++ /dev/null @@ -1,127 +0,0 @@ -{% extends "part/bom_upload/upload_file.html" %} -{% load inventree_extras %} -{% load i18n %} -{% load static %} -{% load crispy_forms_tags %} - -{% block form_alert %} -{% if form.errors %} -{% endif %} -{% if form_errors %} - -{% endif %} -{% endblock form_alert %} - -{% block form_buttons_top %} - {% if wizard.steps.prev %} - - {% endif %} - -{% endblock form_buttons_top %} - -{% block form_content %} - - - - {% trans "Row" %} - {% trans "Select Part" %} - {% trans "Reference" %} - {% trans "Quantity" %} - {% for col in columns %} - {% if col.guess != 'Quantity' %} - - - - {% if col.guess %} - {{ col.guess }} - {% else %} - {{ col.name }} - {% endif %} - - {% endif %} - {% endfor %} - - - - {% comment %} Dummy row for javascript del_row method {% endcomment %} - {% for row in rows %} - - - - - - {% add row.index 1 %} - - - {% for field in form.visible_fields %} - {% if field.name == row.item_select %} - {{ field }} - {% endif %} - {% endfor %} - {% if row.errors.part %} -

{{ row.errors.part }}

- {% endif %} - - - {% for field in form.visible_fields %} - {% if field.name == row.reference %} - {{ field|as_crispy_field }} - {% endif %} - {% endfor %} - {% if row.errors.reference %} -

{{ row.errors.reference }}

- {% endif %} - - - {% for field in form.visible_fields %} - {% if field.name == row.quantity %} - {{ field|as_crispy_field }} - {% endif %} - {% endfor %} - {% if row.errors.quantity %} -

{{ row.errors.quantity }}

- {% endif %} - - {% for item in row.data %} - {% if item.column.guess != 'Quantity' %} - - {% if item.column.guess == 'Overage' %} - {% for field in form.visible_fields %} - {% if field.name == row.overage %} - {{ field|as_crispy_field }} - {% endif %} - {% endfor %} - {% elif item.column.guess == 'Note' %} - {% for field in form.visible_fields %} - {% if field.name == row.note %} - {{ field|as_crispy_field }} - {% endif %} - {% endfor %} - {% else %} - {{ item.cell }} - {% endif %} - - - {% endif %} - {% endfor %} - - {% endfor %} - -{% endblock form_content %} - -{% block form_buttons_bottom %} -{% endblock form_buttons_bottom %} - -{% block js_ready %} -{{ block.super }} - -$('.bomselect').select2({ - dropdownAutoWidth: true, - matcher: partialMatcher, -}); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/upload_file.html b/InvenTree/part/templates/part/upload_bom.html similarity index 100% rename from InvenTree/part/templates/part/bom_upload/upload_file.html rename to InvenTree/part/templates/part/upload_bom.html diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 03465a1838..19e72ea069 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -704,270 +704,12 @@ class PartImageSelect(AjaxUpdateView): return self.renderJsonResponse(request, form, data) -class BomUpload(InvenTreeRoleMixin, FileManagementFormView): - """ View for uploading a BOM file, and handling BOM data importing. +class BomUpload(InvenTreeRoleMixin, DetailView): + """ View for uploading a BOM file, and handling BOM data importing. """ - The BOM upload process is as follows: - - 1. (Client) Select and upload BOM file - 2. (Server) Verify that supplied file is a file compatible with tablib library - 3. (Server) Introspect data file, try to find sensible columns / values / etc - 4. (Server) Send suggestions back to the client - 5. (Client) Makes choices based on suggestions: - - Accept automatic matching to parts found in database - - Accept suggestions for 'partial' or 'fuzzy' matches - - Create new parts in case of parts not being available - 6. (Client) Sends updated dataset back to server - 7. (Server) Check POST data for validity, sanity checking, etc. - 8. (Server) Respond to POST request - - If data are valid, proceed to 9. - - If data not valid, return to 4. - 9. (Server) Send confirmation form to user - - Display the actions which will occur - - Provide final "CONFIRM" button - 10. (Client) Confirm final changes - 11. (Server) Apply changes to database, update BOM items. - - During these steps, data are passed between the server/client as JSON objects. - """ - - role_required = ('part.change', 'part.add') - - class BomFileManager(FileManager): - # Fields which are absolutely necessary for valid upload - REQUIRED_HEADERS = [ - 'Quantity' - ] - - # Fields which are used for part matching (only one of them is needed) - ITEM_MATCH_HEADERS = [ - 'Part_Name', - 'Part_IPN', - 'Part_ID', - ] - - # Fields which would be helpful but are not required - OPTIONAL_HEADERS = [ - 'Reference', - 'Note', - 'Overage', - ] - - EDITABLE_HEADERS = [ - 'Reference', - 'Note', - 'Overage' - ] - - name = 'order' - form_list = [ - ('upload', UploadFileForm), - ('fields', MatchFieldForm), - ('items', part_forms.BomMatchItemForm), - ] - form_steps_template = [ - 'part/bom_upload/upload_file.html', - 'part/bom_upload/match_fields.html', - 'part/bom_upload/match_parts.html', - ] - form_steps_description = [ - _("Upload File"), - _("Match Fields"), - _("Match Parts"), - ] - form_field_map = { - 'item_select': 'part', - 'quantity': 'quantity', - 'overage': 'overage', - 'reference': 'reference', - 'note': 'note', - } - file_manager_class = BomFileManager - - def get_part(self): - """ Get part or return 404 """ - - return get_object_or_404(Part, pk=self.kwargs['pk']) - - def get_context_data(self, form, **kwargs): - """ Handle context data for order """ - - context = super().get_context_data(form=form, **kwargs) - - part = self.get_part() - - context.update({'part': part}) - - return context - - def get_allowed_parts(self): - """ Return a queryset of parts which are allowed to be added to this BOM. - """ - - return self.get_part().get_allowed_bom_items() - - def get_field_selection(self): - """ Once data columns have been selected, attempt to pre-select the proper data from the database. - This function is called once the field selection has been validated. - The pre-fill data are then passed through to the part selection form. - """ - - self.allowed_items = self.get_allowed_parts() - - # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database - k_idx = self.get_column_index('Part_ID') - p_idx = self.get_column_index('Part_Name') - i_idx = self.get_column_index('Part_IPN') - - q_idx = self.get_column_index('Quantity') - r_idx = self.get_column_index('Reference') - o_idx = self.get_column_index('Overage') - n_idx = self.get_column_index('Note') - - for row in self.rows: - """ - Iterate through each row in the uploaded data, - and see if we can match the row to a "Part" object in the database. - There are three potential ways to match, based on the uploaded data: - a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field - b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field - c) Use the name of the part, uploaded in the "Part_Name" field - Notes: - - If using the Part_ID field, we can do an exact match against the PK field - - If using the Part_IPN field, we can do an exact match against the IPN field - - If using the Part_Name field, we can use fuzzy string matching to match "close" values - We also extract other information from the row, for the other non-matched fields: - - Quantity - - Reference - - Overage - - Note - """ - - # Initially use a quantity of zero - quantity = Decimal(0) - - # Initially we do not have a part to reference - exact_match_part = None - - # A list of potential Part matches - part_options = self.allowed_items - - # Check if there is a column corresponding to "quantity" - if q_idx >= 0: - q_val = row['data'][q_idx]['cell'] - - if q_val: - # Delete commas - q_val = q_val.replace(',', '') - - try: - # Attempt to extract a valid quantity from the field - quantity = Decimal(q_val) - # Store the 'quantity' value - row['quantity'] = quantity - except (ValueError, InvalidOperation): - pass - - # Check if there is a column corresponding to "PK" - if k_idx >= 0: - pk = row['data'][k_idx]['cell'] - - if pk: - try: - # Attempt Part lookup based on PK value - exact_match_part = self.allowed_items.get(pk=pk) - except (ValueError, Part.DoesNotExist): - exact_match_part = None - - # Check if there is a column corresponding to "Part IPN" and no exact match found yet - if i_idx >= 0 and not exact_match_part: - part_ipn = row['data'][i_idx]['cell'] - - if part_ipn: - part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())] - - # Check for single match - if len(part_matches) == 1: - exact_match_part = part_matches[0] - - # Check if there is a column corresponding to "Part Name" and no exact match found yet - if p_idx >= 0 and not exact_match_part: - part_name = row['data'][p_idx]['cell'] - - row['part_name'] = part_name - - matches = [] - - for part in self.allowed_items: - ratio = fuzz.partial_ratio(part.name + part.description, part_name) - matches.append({'part': part, 'match': ratio}) - - # Sort matches by the 'strength' of the match ratio - if len(matches) > 0: - matches = sorted(matches, key=lambda item: item['match'], reverse=True) - - part_options = [m['part'] for m in matches] - - # Supply list of part options for each row, sorted by how closely they match the part name - row['item_options'] = part_options - - # Unless found, the 'item_match' is blank - row['item_match'] = None - - if exact_match_part: - # If there is an exact match based on PK or IPN, use that - row['item_match'] = exact_match_part - - # Check if there is a column corresponding to "Overage" field - if o_idx >= 0: - row['overage'] = row['data'][o_idx]['cell'] - - # Check if there is a column corresponding to "Reference" field - if r_idx >= 0: - row['reference'] = row['data'][r_idx]['cell'] - - # Check if there is a column corresponding to "Note" field - if n_idx >= 0: - row['note'] = row['data'][n_idx]['cell'] - - def done(self, form_list, **kwargs): - """ Once all the data is in, process it to add BomItem instances to the part """ - - self.part = self.get_part() - items = self.get_clean_items() - - # Clear BOM - self.part.clear_bom() - - # Generate new BOM items - for bom_item in items.values(): - try: - part = Part.objects.get(pk=int(bom_item.get('part'))) - except (ValueError, Part.DoesNotExist): - continue - - quantity = bom_item.get('quantity') - overage = bom_item.get('overage', '') - reference = bom_item.get('reference', '') - note = bom_item.get('note', '') - - # Create a new BOM item - item = BomItem( - part=self.part, - sub_part=part, - quantity=quantity, - overage=overage, - reference=reference, - note=note, - ) - - try: - item.save() - except IntegrityError: - # BomItem already exists - pass - - return HttpResponseRedirect(reverse('part-detail', kwargs={'pk': self.kwargs['pk']})) + context_object_name = 'part' + queryset = Part.objects.all() + template_name = 'part/upload_bom.html' class PartExport(AjaxView): From c6dc196053762bf22b70a5de999ee24a3f37f57f Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 12:32:50 +1100 Subject: [PATCH 18/21] PEP fixes --- InvenTree/part/serializers.py | 8 +++----- InvenTree/part/views.py | 5 +---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index c9c78eb46b..a53d141a49 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -27,7 +27,6 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField, from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from stock.models import StockItem -from .admin import BomItemResource from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, PartCategory, PartRelated, PartParameter, PartParameterTemplate, PartSellPriceBreak, @@ -470,7 +469,7 @@ class BomItemSerializer(InvenTreeModelSerializer): 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)) @@ -955,7 +954,7 @@ class BomExtractSerializer(serializers.Serializer): ) def save(self): - + data = self.validated_data master_part = data['part'] @@ -978,7 +977,6 @@ class BomUploadSerializer(serializers.Serializer): def validate(self, data): - items = data['items'] if len(items) == 0: @@ -1008,6 +1006,6 @@ class BomUploadSerializer(serializers.Serializer): # Create a new BomItem object BomItem.objects.create(**item) - + except Exception as e: raise serializers.ValidationError(detail=serializers.as_serializer_error(e)) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 19e72ea069..e0992364dd 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -28,20 +28,17 @@ import requests import os import io -from rapidfuzz import fuzz -from decimal import Decimal, InvalidOperation +from decimal import Decimal from .models import PartCategory, Part from .models import PartParameterTemplate from .models import PartCategoryParameterTemplate -from .models import BomItem from .models import PartSellPriceBreak, PartInternalPriceBreak from common.models import InvenTreeSetting from company.models import SupplierPart from common.files import FileManager from common.views import FileManagementFormView, FileManagementAjaxView -from common.forms import UploadFileForm, MatchFieldForm from stock.models import StockItem, StockLocation From 7265360648a8522dc1edadd1fc9655cb16609a4b Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 13:07:03 +1100 Subject: [PATCH 19/21] JS linting --- InvenTree/templates/js/translated/bom.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index cb07f93a38..fd23e70ad0 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -156,7 +156,7 @@ function submitBomTable(part_id, options={}) { inherited: getFormFieldValue(`items_inherited_${idx}`, {type: 'boolean'}), optional: getFormFieldValue(`items_optional_${idx}`, {type: 'boolean'}), note: getFormFieldValue(`items_note_${idx}`, {}), - }) + }); }); var data = { From 11f541303baf2ffbfaa987a24798d3b57b4cd4d7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 13:32:35 +1100 Subject: [PATCH 20/21] unit test fixes --- InvenTree/part/test_bom_export.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py index e6b2a7c255..4ae0b88269 100644 --- a/InvenTree/part/test_bom_export.py +++ b/InvenTree/part/test_bom_export.py @@ -107,7 +107,7 @@ class BomExportTest(TestCase): """ params = { - 'file_format': 'csv', + 'format': 'csv', 'cascade': True, 'parameter_data': True, 'stock_data': True, @@ -171,7 +171,7 @@ class BomExportTest(TestCase): """ params = { - 'file_format': 'xls', + 'format': 'xls', 'cascade': True, 'parameter_data': True, 'stock_data': True, @@ -192,7 +192,7 @@ class BomExportTest(TestCase): """ params = { - 'file_format': 'xlsx', + 'format': 'xlsx', 'cascade': True, 'parameter_data': True, 'stock_data': True, @@ -210,7 +210,7 @@ class BomExportTest(TestCase): """ params = { - 'file_format': 'json', + 'format': 'json', 'cascade': True, 'parameter_data': True, 'stock_data': True, From 55ff026696d236141d42e4b81c9cff4d673355af Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 7 Feb 2022 14:24:40 +1100 Subject: [PATCH 21/21] Remove incorrect validation routine --- InvenTree/part/serializers.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index a53d141a49..351348c6bc 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -492,22 +492,6 @@ class BomItemSerializer(InvenTreeModelSerializer): purchase_price_range = serializers.SerializerMethodField() - def validate(self, data): - - # Check for duplicate BOM items - part = data['part'] - sub_part = data['sub_part'] - - if BomItem.objects.filter(part=part, sub_part=sub_part).exists(): - raise serializers.ValidationError({ - 'part': _("Duplicate BOM item already exists"), - 'sub_part': _("Duplicate BOM items already exists"), - }) - - data = super().validate(data) - - return data - def __init__(self, *args, **kwargs): # part_detail and sub_part_detail serializers are only included if requested. # This saves a bunch of database requests