From 24f1491b8e2cdde37073e44c82313786e8f38268 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 11:32:40 +1100 Subject: [PATCH 01/28] Adds generic API endpoint for extracting data from a tabulated file --- InvenTree/InvenTree/serializers.py | 174 ++++++++++++++++++++++++++++- InvenTree/InvenTree/version.py | 5 +- 2 files changed, 177 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index ffc84a5f71..a4415c0826 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -5,8 +5,8 @@ Serializers used in various InvenTree apps # -*- coding: utf-8 -*- from __future__ import unicode_literals - import os +import tablib from decimal import Decimal @@ -332,3 +332,175 @@ class InvenTreeDecimalField(serializers.FloatField): return Decimal(str(data)) except: raise serializers.ValidationError(_("Invalid value")) + + +class DataFileUploadSerializer(serializers.Serializer): + """ + Generic serializer for uploading a data file, and extracting a dataset. + + - Validates uploaded file + - Extracts column names + - Extracts data rows + """ + + class Meta: + fields = [ + 'bom_file', + ] + + data_file = serializers.FileField( + label=_("Data File"), + help_text=_("Select data file for upload"), + required=True, + allow_empty_file=False, + ) + + def validate_data_file(self, data_file): + """ + Perform validation checks on the uploaded data file. + """ + + self.filename = data_file.name + + name, ext = os.path.splitext(data_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 data_file.size > max_upload_file_size: + raise serializers.ValidationError(_("File is too large")) + + # Read file data into memory (bytes object) + try: + data = data_file.read() + except Exception as e: + raise serializers.ValidationError(str(e)) + + if ext in ['csv', 'tsv', 'xml']: + try: + data = data.decode() + except Exception as e: + raise serializers.ValidationError(str(e)) + + # Convert to a tablib dataset (we expect headers) + try: + self.dataset = tablib.Dataset().load(data, ext, headers=True) + except Exception as e: + raise serializers.ValidationError(str(e)) + + if len(self.dataset) == 0: + raise serializers.ValidationError(_("No data rows found in file")) + + return data_file + + def extract_data(self): + """ + Returns dataset extracted from the file + """ + + return { + 'headers': self.dataset.headers, + 'rows': [row.values() for row in self.dataset.dict], + 'filename': self.filename, + } + + +class DataFileExtractSerializer(serializers.Serializer): + """ + Generic serializer for extracting data from an imported dataset. + + - User provides an array of matched headers + - User provides an array of raw data rows + """ + + # Provide a dict of expected columns for this importer + EXPECTED_COLUMNS = {} + + # Provide a list of required columns for this importer + REQUIRED_COLUMNS = [] + + class Meta: + fields = [ + 'raw_headers', + 'mapped_headers', + 'rows', + ] + + raw_headers = serializers.ListField( + child=serializers.CharField(), + ) + + mapped_headers = serializers.ListField( + child=serializers.CharField(), + ) + + rows = serializers.ListField( + child=serializers.ListField( + child=serializers.CharField( + allow_blank=True, + ), + ) + ) + + def validate(self, data): + + data = super().validate(data) + + self.raw_headers = data.get('raw_headers', []) + self.mapped_headers = data.get('mapped_headers', []) + self.rows = data.get('rows', []) + + if len(self.rows) == 0: + raise serializers.ValidationError(_("No data rows provided")) + + if len(self.raw_headers) == 0: + raise serializers.ValidationError(_("File headers not supplied")) + + if len(self.mapped_headers) == 0: + raise serializers.ValidationError(_("Mapped headers not supplied")) + + if len(self.raw_headers) != len(self.mapped_headers): + raise serializers.ValidationError(_("Supplied header list has incorrect length")) + + self.validate_headers() + + return self.extract_data(data) + + def extract_data(self, data): + """ + Extract row data based on the provided fields. + Returns an array of mapped column:value values + """ + + return data + + def validate_headers(self): + """ + Perform custom validation of header mapping. + """ + + print("validate_headers()") + + for col in self.REQUIRED_COLUMNS: + print("checking col:", col) + if col not in self.mapped_headers: + raise serializers.ValidationError(_("Missing required column") + f": {col}") + + + def save(self): + """ + No "save" action for this serializer + """ + ... \ No newline at end of file diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 19235f0e0a..feaf1558d1 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,14 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 24 +INVENTREE_API_VERSION = 25 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v25 -> 2022-02-16 + - Adds API endpoint for uploading a BOM file and extracting data + v24 -> 2022-02-10 - Adds API endpoint for deleting (cancelling) build order outputs From 58aa2adde7685512266ab68c766d4793df9fa6f8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 11:42:15 +1100 Subject: [PATCH 02/28] Adds model mixin for generically determining which fields can be imported on any particular model --- InvenTree/InvenTree/models.py | 56 +++++++++++++++++++++++++++++++++++ InvenTree/part/models.py | 33 +++++++++++++++++++-- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index b42d54cbe9..63a4d23ce2 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -45,6 +45,62 @@ def rename_attachment(instance, filename): return os.path.join(instance.getSubdir(), filename) +class DataImportMixin(object): + """ + Model mixin class which provides support for 'data import' functionality. + + Models which implement this mixin should provide information on the fields available for import + """ + + # Define a map of fields avaialble for import + IMPORT_FIELDS = {} + + @classmethod + def get_import_fields(cls): + """ + Return all available import fields + + Where information on a particular field is not explicitly provided, + introspect the base model to (attempt to) find that information. + + """ + fields = cls.IMPORT_FIELDS + + for name, field in fields.items(): + + # Attempt to extract base field information from the model + base_field = None + + for f in cls._meta.fields: + if f.name == name: + base_field = f + break + + if base_field: + if 'label' not in field: + field['label'] = base_field.verbose_name + + if 'help_text' not in field: + field['help_text'] = base_field.help_text + + fields[name] = field + + return fields + + @classmethod + def get_required_import_fields(cls): + """ Return all *required* import fields """ + fields = {} + + for name, field in cls.get_import_fields().items(): + required = field.get('required', False) + + if required: + fields[name] = field + + return fields + + class ReferenceIndexingMixin(models.Model): """ A mixin for keeping track of numerical copies of the "reference" field. diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index b312937e30..e45fd1a783 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -46,7 +46,7 @@ from common.models import InvenTreeSetting from InvenTree import helpers from InvenTree import validators -from InvenTree.models import InvenTreeTree, InvenTreeAttachment +from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin from InvenTree.fields import InvenTreeURLField from InvenTree.helpers import decimal2string, normalize, decimal2money import InvenTree.tasks @@ -2550,7 +2550,7 @@ class PartCategoryParameterTemplate(models.Model): help_text=_('Default Parameter Value')) -class BomItem(models.Model): +class BomItem(models.Model, DataImportMixin): """ A BomItem links a part to its component items. A part can have a BOM (bill of materials) which defines which parts are required (and in what quantity) to make it. @@ -2568,6 +2568,35 @@ class BomItem(models.Model): allow_variants: Stock for part variants can be substituted for this BomItem """ + # Fields available for bulk import + IMPORT_FIELDS = { + 'quantity': { + 'required': True + }, + 'optional': {}, + 'reference': {}, + 'overage': {}, + 'note': {}, + 'inherited': {}, + 'allow_variants': {}, + 'part': { + 'label': _('Part'), + 'help_text': _('Part ID or part name'), + }, + 'part_id': { + 'label': _('Part ID'), + 'help_text': _('Unique part ID value') + }, + 'part_name': { + 'label': _('Part Name'), + 'help_text': _('Part name'), + }, + 'part_ipn': { + 'label': _('Part IPN'), + 'help_text': _('Part IPN value'), + } + } + @staticmethod def get_api_url(): return reverse('api-bom-list') From d7adb6959d9cb72eec9e731fe1a31b1fe45688f5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 14:57:13 +1100 Subject: [PATCH 03/28] Adds functionality to map file columns to model fiels --- InvenTree/InvenTree/serializers.py | 138 ++++++++++++++++++++++------- InvenTree/part/api.py | 25 +++--- InvenTree/part/serializers.py | 130 +++++---------------------- 3 files changed, 143 insertions(+), 150 deletions(-) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index a4415c0826..4d558e50c0 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -343,6 +343,9 @@ class DataFileUploadSerializer(serializers.Serializer): - Extracts data rows """ + # Implementing class should register a target model (database model) to be used for import + TARGET_MODEL = None + class Meta: fields = [ 'bom_file', @@ -400,18 +403,81 @@ class DataFileUploadSerializer(serializers.Serializer): except Exception as e: raise serializers.ValidationError(str(e)) + if len(self.dataset.headers) == 0: + raise serializers.ValidationError(_("No columns found in file")) + if len(self.dataset) == 0: raise serializers.ValidationError(_("No data rows found in file")) return data_file + def match_column(self, column_name, field_names): + """ + Attempt to match a column name (from the file) to a field (defined in the model) + + Order of matching is: + - Direct match + - Case insensitive match + - Fuzzy match + """ + + column_name = column_name.strip() + + column_name_lower = column_name.lower() + + if column_name in field_names: + return column_name + + for field_name in field_names: + if field_name.lower() == column_name_lower: + return field_name + + # TODO: Fuzzy pattern matching + + # No matches found + return None + + def extract_data(self): """ Returns dataset extracted from the file """ + # Provide a dict of available import fields for the model + model_fields = {} + + # Keep track of columns we have already extracted + matched_columns = set() + + if self.TARGET_MODEL: + try: + model_fields = self.TARGET_MODEL.get_import_fields() + except: + pass + + # Extract a list of valid model field names + model_field_names = [key for key in model_fields.keys()] + + # Provide a dict of available columns from the dataset + file_columns = {} + + for header in self.dataset.headers: + column = {} + + # Attempt to "match" file columns to model fields + match = self.match_column(header, model_field_names) + + if match is not None and match not in matched_columns: + matched_columns.add(match) + column['value'] = match + else: + column['value'] = None + + file_columns[header] = column + return { - 'headers': self.dataset.headers, + 'file_fields': file_columns, + 'model_fields': model_fields, 'rows': [row.values() for row in self.dataset.dict], 'filename': self.filename, } @@ -425,25 +491,20 @@ class DataFileExtractSerializer(serializers.Serializer): - User provides an array of raw data rows """ - # Provide a dict of expected columns for this importer - EXPECTED_COLUMNS = {} - - # Provide a list of required columns for this importer - REQUIRED_COLUMNS = [] + # Implementing class should register a target model (database model) to be used for import + TARGET_MODEL = None class Meta: fields = [ - 'raw_headers', - 'mapped_headers', + 'columns', 'rows', ] - raw_headers = serializers.ListField( - child=serializers.CharField(), - ) - - mapped_headers = serializers.ListField( - child=serializers.CharField(), + # Mapping of columns + columns = serializers.ListField( + child=serializers.CharField( + allow_blank=True, + ), ) rows = serializers.ListField( @@ -458,23 +519,16 @@ class DataFileExtractSerializer(serializers.Serializer): data = super().validate(data) - self.raw_headers = data.get('raw_headers', []) - self.mapped_headers = data.get('mapped_headers', []) + self.columns = data.get('columns', []) self.rows = data.get('rows', []) if len(self.rows) == 0: raise serializers.ValidationError(_("No data rows provided")) - if len(self.raw_headers) == 0: - raise serializers.ValidationError(_("File headers not supplied")) + if len(self.columns) == 0: + raise serializers.ValidationError(_("No data columns supplied")) - if len(self.mapped_headers) == 0: - raise serializers.ValidationError(_("Mapped headers not supplied")) - - if len(self.raw_headers) != len(self.mapped_headers): - raise serializers.ValidationError(_("Supplied header list has incorrect length")) - - self.validate_headers() + self.validate_extracted_columns() return self.extract_data(data) @@ -486,18 +540,38 @@ class DataFileExtractSerializer(serializers.Serializer): return data - def validate_headers(self): + def validate_extracted_columns(self): """ Perform custom validation of header mapping. """ - print("validate_headers()") - - for col in self.REQUIRED_COLUMNS: - print("checking col:", col) - if col not in self.mapped_headers: - raise serializers.ValidationError(_("Missing required column") + f": {col}") + if self.TARGET_MODEL: + try: + model_fields = self.TARGET_MODEL.get_import_fields() + except: + model_fields = {} + cols_seen = set() + + for name, field in model_fields.items(): + + required = field.get('required', False) + + # Check for missing required columns + if required: + if name not in self.columns: + raise serializers.ValidationError(_("Missing required column") + f": '{name}'") + + for col in self.columns: + + if not col: + continue + + # Check for duplicated columns + if col in cols_seen: + raise serializers.ValidationError(_("Duplicate column") + f": '{col}'") + + cols_seen.add(col) def save(self): """ diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 4c52b87520..7600e32780 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1539,7 +1539,18 @@ class BomExtract(generics.CreateAPIView): """ queryset = Part.objects.none() - serializer_class = part_serializers.BomExtractSerializer + serializer_class = part_serializers.BomFileExtractSerializer + + +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.BomFileUploadSerializer def create(self, request, *args, **kwargs): """ @@ -1556,16 +1567,6 @@ 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 """ @@ -1719,9 +1720,9 @@ bom_api_urls = [ url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'), ])), + url(r'^upload/', BomUpload.as_view(), name='api-bom-upload'), 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 195ce15e4f..cde5cc5087 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -17,7 +17,9 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount, SubquerySum from djmoney.contrib.django_rest_framework import MoneyField -from InvenTree.serializers import (InvenTreeAttachmentSerializerField, +from InvenTree.serializers import (DataFileUploadSerializer, + DataFileExtractSerializer, + InvenTreeAttachmentSerializerField, InvenTreeDecimalField, InvenTreeImageSerializerField, InvenTreeModelSerializer, @@ -709,7 +711,7 @@ class PartCopyBOMSerializer(serializers.Serializer): ) -class BomExtractSerializer(serializers.Serializer): +class BomFileUploadSerializer(DataFileUploadSerializer): """ Serializer for uploading a file and extracting data from it. @@ -729,50 +731,7 @@ class BomExtractSerializer(serializers.Serializer): """ - class Meta: - fields = [ - 'bom_file', - 'part', - 'clear_existing', - ] - - # 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 + TARGET_MODEL = BomItem def find_matching_data(self, row, col_name, columns): """ @@ -783,58 +742,7 @@ class BomExtractSerializer(serializers.Serializer): return row.get(col_name, None) - 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 - """ - - self.filename = bom_file.name - - 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) - try: - data = bom_file.read() - except Exception as e: - raise serializers.ValidationError(str(e)) - - if ext in ['csv', 'tsv', 'xml']: - try: - data = data.decode() - except Exception as e: - raise serializers.ValidationError(str(e)) - - # Convert to a tablib dataset (we expect headers) - try: - self.dataset = tablib.Dataset().load(data, ext, headers=True) - except Exception as e: - raise serializers.ValidationError(str(e)) - + """ for header in self.REQUIRED_COLUMNS: match = self.find_matching_column(header, self.dataset.headers) @@ -861,11 +769,9 @@ class BomExtractSerializer(serializers.Serializer): raise serializers.ValidationError(_("No data rows found")) return bom_file + """ - def extract_data(self): - """ - Read individual rows out of the BOM file - """ + def dextract_data(self): rows = [] errors = [] @@ -880,9 +786,9 @@ class BomExtractSerializer(serializers.Serializer): row_error = {} - """ - If the "level" column is specified, and this is not a top-level BOM item, ignore the row! - """ + + # 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) @@ -989,15 +895,19 @@ class BomExtractSerializer(serializers.Serializer): 'filename': self.filename, } + """ 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): + ... + """ data = self.validated_data master_part = data['part'] @@ -1006,7 +916,15 @@ class BomExtractSerializer(serializers.Serializer): if clear_existing: # Remove all existing BOM items - master_part.bom_items.all().delete() + $ master_part.bom_items.all().delete() + """ + + +class BomFileExtractSerializer(DataFileExtractSerializer): + """ + """ + + TARGET_MODEL = BomItem class BomUploadSerializer(serializers.Serializer): From f399f4fa34c5b4aaa5ac47e50bb3bbb0d9c7df07 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 16:57:27 +1100 Subject: [PATCH 04/28] Refactoring API endpoints - Improved URL naming scheme --- InvenTree/InvenTree/models.py | 6 +- InvenTree/InvenTree/serializers.py | 91 +++++- InvenTree/part/api.py | 37 ++- InvenTree/part/models.py | 10 +- InvenTree/part/serializers.py | 299 +++++------------- InvenTree/part/templates/part/upload_bom.html | 97 +++++- InvenTree/templates/js/translated/bom.js | 27 +- InvenTree/templates/js/translated/forms.js | 2 +- 8 files changed, 295 insertions(+), 274 deletions(-) diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 63a4d23ce2..0fe3136871 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -59,7 +59,7 @@ class DataImportMixin(object): def get_import_fields(cls): """ Return all available import fields - + Where information on a particular field is not explicitly provided, introspect the base model to (attempt to) find that information. @@ -67,7 +67,7 @@ class DataImportMixin(object): fields = cls.IMPORT_FIELDS for name, field in fields.items(): - + # Attempt to extract base field information from the model base_field = None @@ -79,7 +79,7 @@ class DataImportMixin(object): if base_field: if 'label' not in field: field['label'] = base_field.verbose_name - + if 'help_text' not in field: field['help_text'] = base_field.help_text diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 4d558e50c0..472bab30b9 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -411,7 +411,7 @@ class DataFileUploadSerializer(serializers.Serializer): return data_file - def match_column(self, column_name, field_names): + def match_column(self, column_name, field_names, exact=False): """ Attempt to match a column name (from the file) to a field (defined in the model) @@ -432,12 +432,15 @@ class DataFileUploadSerializer(serializers.Serializer): if field_name.lower() == column_name_lower: return field_name - # TODO: Fuzzy pattern matching + if exact: + # Finished available 'exact' matches + return None + + # TODO: Fuzzy pattern matching for column names # No matches found return None - def extract_data(self): """ Returns dataset extracted from the file @@ -465,7 +468,7 @@ class DataFileUploadSerializer(serializers.Serializer): column = {} # Attempt to "match" file columns to model fields - match = self.match_column(header, model_field_names) + match = self.match_column(header, model_field_names, exact=True) if match is not None and match not in matched_columns: matched_columns.add(match) @@ -482,13 +485,16 @@ class DataFileUploadSerializer(serializers.Serializer): 'filename': self.filename, } + def save(self): + ... + class DataFileExtractSerializer(serializers.Serializer): """ Generic serializer for extracting data from an imported dataset. - User provides an array of matched headers - - User provides an array of raw data rows + - User provides an array of raw data rows """ # Implementing class should register a target model (database model) to be used for import @@ -500,7 +506,7 @@ class DataFileExtractSerializer(serializers.Serializer): 'rows', ] - # Mapping of columns + # Mapping of columns columns = serializers.ListField( child=serializers.CharField( allow_blank=True, @@ -530,16 +536,69 @@ class DataFileExtractSerializer(serializers.Serializer): self.validate_extracted_columns() - return self.extract_data(data) - - def extract_data(self, data): - """ - Extract row data based on the provided fields. - Returns an array of mapped column:value values - """ - return data + @property + def data(self): + + if self.TARGET_MODEL: + try: + model_fields = self.TARGET_MODEL.get_import_fields() + except: + model_fields = {} + + rows = [] + + for row in self.rows: + """ + Optionally pre-process each row, before sending back to the client + """ + + processed_row = self.process_row(self.row_to_dict(row)) + + if processed_row: + rows.append({ + "original": row, + "data": processed_row, + }) + + return { + 'fields': model_fields, + 'columns': self.columns, + 'rows': rows, + } + + def process_row(self, row): + """ + Process a 'row' of data, which is a mapped column:value dict + + Returns either a mapped column:value dict, or None. + + If the function returns None, the column is ignored! + """ + + # Default implementation simply returns the original row data + return row + + def row_to_dict(self, row): + """ + Convert a "row" to a named data dict + """ + + row_dict = { + 'errors': {}, + } + + for idx, value in enumerate(row): + + if idx < len(self.columns): + col = self.columns[idx] + + if col: + row_dict[col] = value + + return row_dict + def validate_extracted_columns(self): """ Perform custom validation of header mapping. @@ -561,7 +620,7 @@ class DataFileExtractSerializer(serializers.Serializer): if required: if name not in self.columns: raise serializers.ValidationError(_("Missing required column") + f": '{name}'") - + for col in self.columns: if not col: @@ -577,4 +636,4 @@ class DataFileExtractSerializer(serializers.Serializer): """ No "save" action for this serializer """ - ... \ No newline at end of file + ... diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 7600e32780..7f19a38183 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1533,16 +1533,7 @@ 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.BomFileExtractSerializer - - -class BomUpload(generics.CreateAPIView): +class BomImportUpload(generics.CreateAPIView): """ API endpoint for uploading a complete Bill of Materials. @@ -1550,7 +1541,7 @@ class BomUpload(generics.CreateAPIView): """ queryset = Part.objects.all() - serializer_class = part_serializers.BomFileUploadSerializer + serializer_class = part_serializers.BomImportUploadSerializer def create(self, request, *args, **kwargs): """ @@ -1567,6 +1558,23 @@ class BomUpload(generics.CreateAPIView): return Response(data, status=status.HTTP_201_CREATED, headers=headers) +class BomImportExtract(generics.CreateAPIView): + """ + API endpoint for extracting BOM data from a BOM file. + """ + + queryset = Part.objects.none() + serializer_class = part_serializers.BomImportExtractSerializer + + +class BomImportSubmit(generics.CreateAPIView): + """ + API endpoint for submitting BOM data from a BOM file + """ + + queryset = BomItem.objects.none() + serializer_class = part_serializers.BomImportSubmitSerializer + class BomDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single BomItem object """ @@ -1720,9 +1728,10 @@ bom_api_urls = [ url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'), ])), - url(r'^upload/', BomUpload.as_view(), name='api-bom-upload'), - url(r'^extract/', BomExtract.as_view(), name='api-bom-extract'), - + # API endpoint URLs for importing BOM data + url(r'^import/upload/', BomImportUpload.as_view(), name='api-bom-import-upload'), + url(r'^import/extract/', BomImportExtract.as_view(), name='api-bom-import-extract'), + url(r'^import/submit/', BomImportSubmit.as_view(), name='api-bom-import-submit'), # Catch-all url(r'^.*$', BomList.as_view(), name='api-bom-list'), diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index e45fd1a783..478c4c195c 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2573,12 +2573,12 @@ class BomItem(models.Model, DataImportMixin): 'quantity': { 'required': True }, - 'optional': {}, 'reference': {}, 'overage': {}, - 'note': {}, - 'inherited': {}, 'allow_variants': {}, + 'inherited': {}, + 'optional': {}, + 'note': {}, 'part': { 'label': _('Part'), 'help_text': _('Part ID or part name'), @@ -2594,6 +2594,10 @@ class BomItem(models.Model, DataImportMixin): 'part_ipn': { 'label': _('Part IPN'), 'help_text': _('Part IPN value'), + }, + 'level': { + 'label': _('Level'), + 'help_text': _('BOM level'), } } diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index cde5cc5087..6b0c89ad88 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -4,8 +4,6 @@ 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, transaction @@ -711,223 +709,100 @@ class PartCopyBOMSerializer(serializers.Serializer): ) -class BomFileUploadSerializer(DataFileUploadSerializer): +class BomImportUploadSerializer(DataFileUploadSerializer): """ 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 - - """ - - TARGET_MODEL = BomItem - - 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) - - """ - for header in self.REQUIRED_COLUMNS: - - match = self.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 self.PART_COLUMNS: - col_match = self.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")) - - if len(self.dataset) == 0: - raise serializers.ValidationError(_("No data rows found")) - - return bom_file - """ - - def dextract_data(self): - - rows = [] - errors = [] - - found_parts = set() - - headers = self.dataset.headers - - level_column = self.find_matching_column('level', headers) - - for row in self.dataset.dict: - - row_error = {} - - - # 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 - - # 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 or part_ipn: - queryset = Part.objects.all() - - if part_name: - queryset = queryset.filter(name=part_name) - - if part_ipn: - queryset = queryset.filter(IPN=part_ipn) - - # Only if we have a single direct match - if queryset.exists(): - if queryset.count() == 1: - part = queryset.first() - else: - # Multiple matches! - row_error['part'] = _('Multiple matching parts found') - - if part is None: - if 'part' not in row_error: - row_error['part'] = _('No matching part found') - else: - if part.pk in found_parts: - row_error['part'] = _("Duplicate part selected") - - elif not part.component: - row_error['part'] = _('Part is not designated as a component') - - found_parts.add(part.pk) - - row['part'] = part.pk if part is not None else None - - """ - Read out the 'quantity' column - check that it is valid - """ - quantity = self.find_matching_data(row, 'quantity', self.dataset.headers) - - # Ensure quantity field is provided - row['quantity'] = quantity - - if quantity is None: - row_error['quantity'] = _('Quantity not provided') - else: - try: - quantity = Decimal(quantity) - - if quantity <= 0: - row_error['quantity'] = _('Quantity must be greater than zero') - except: - row_error['quantity'] = _('Invalid quantity') - - # For each "optional" column, ensure the column names are allocated correctly - for field_name in self.OPTIONAL_COLUMNS: - if field_name not in row: - row[field_name] = self.find_matching_data(row, field_name, self.dataset.headers) - - rows.append(row) - errors.append(row_error) - - return { - 'rows': rows, - 'errors': errors, - 'headers': headers, - 'filename': self.filename, - } - - """ - 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): - - ... - """ - 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 BomFileExtractSerializer(DataFileExtractSerializer): - """ """ TARGET_MODEL = BomItem -class BomUploadSerializer(serializers.Serializer): +class BomImportExtractSerializer(DataFileExtractSerializer): + """ + """ + + TARGET_MODEL = BomItem + + def validate_extracted_columns(self): + super().validate_extracted_columns() + + part_columns = ['part', 'part_name', 'part_ipn', 'part_id'] + + if not any([col in self.columns for col in part_columns]): + # At least one part column is required! + raise serializers.ValidationError(_("No part column specified")) + + def process_row(self, row): + + # Skip any rows which are at a lower "level" + level = row.get('level', None) + + if level is not None: + try: + level = int(level) + if level != 1: + # Skip this row + return None + except: + pass + + # Attempt to extract a valid part based on the provided data + part_id = row.get('part_id', row.get('part', None)) + part_name = row.get('part_name', row.get('part', None)) + part_ipn = row.get('part_ipn', None) + + part = None + + if part_id is not None: + try: + part = Part.objects.get(pk=part_id) + except (ValueError, Part.DoesNotExist): + pass + + # No direct match, where else can we look? + if part is None: + if part_name or part_ipn: + queryset = Part.objects.all() + + if part_name: + queryset = queryset.filter(name=part_name) + + if part_ipn: + queryset = queryset.filter(IPN=part_ipn) + + if queryset.exists(): + if queryset.count() == 1: + part = queryset.first() + else: + row['errors']['part'] = _('Multiple matching parts found') + + if part is None: + row['errors']['part'] = _('No matching part found') + else: + if not part.component: + row['errors']['part'] = _('Part is not designed as a component') + + # Update the 'part' value in the row + row['part'] = part.pk if part is not None else None + + # Check the provided 'quantity' value + quantity = row.get('quantity', None) + + if quantity is None: + row['errors']['quantity'] = _('Quantity not provided') + else: + try: + quantity = Decimal(quantity) + + if quantity <= 0: + row['errors']['quantity'] = _('Quantity must be greater than zero') + except: + row['errors']['quantity'] = _('Invalid quantity') + + return row + + +class BomImportSubmitSerializer(serializers.Serializer): """ Serializer for uploading a BOM against a specified part. diff --git a/InvenTree/part/templates/part/upload_bom.html b/InvenTree/part/templates/part/upload_bom.html index 151a4b5424..bbb69e3083 100644 --- a/InvenTree/part/templates/part/upload_bom.html +++ b/InvenTree/part/templates/part/upload_bom.html @@ -77,15 +77,10 @@ $('#bom-template-download').click(function() { $('#bom-upload').click(function() { - constructForm('{% url "api-bom-extract" %}', { + constructForm('{% url "api-bom-import-upload" %}', { method: 'POST', fields: { - bom_file: {}, - part: { - value: {{ part.pk }}, - hidden: true, - }, - clear_existing: {}, + data_file: {}, }, title: '{% trans "Upload BOM File" %}', onSuccess: function(response) { @@ -96,12 +91,92 @@ $('#bom-upload').click(function() { // Disable the "submit" button $('#bom-submit').show(); - constructBomUploadTable(response); + var fields = {}; - $('#bom-submit').click(function() { - submitBomTable({{ part.pk }}, { - bom_data: response, + var choices = []; + + // Add an "empty" value + choices.push({ + value: '', + display_name: '-----', + }); + + for (const [name, field] of Object.entries(response.model_fields)) { + choices.push({ + value: name, + display_name: field.label || name, }); + } + + var field_names = Object.keys(response.file_fields); + + for (var idx = 0; idx < field_names.length; idx++) { + + var field_name = field_names[idx]; + + // Construct a new field + fields[`column_${idx}`] = { + type: 'choice', + label: field_name, + value: response.file_fields[field_name].value, + choices: choices, + inline: true, + }; + } + + constructForm('{% url "api-bom-import-extract" %}', { + method: 'POST', + title: '{% trans "Select BOM Columns" %}', + fields: fields, + onSubmit: function(fields, opts) { + var columns = []; + + for (var idx = 0; idx < field_names.length; idx++) { + columns.push( + getFormFieldValue(`column_${idx}`, {}, {}) + ); + } + + $(opts.modal).find('#modal-progress-spinner').show(); + + inventreePut( + opts.url, + { + columns: columns, + rows: response.rows, + }, + { + method: 'POST', + success: function(r) { + handleFormSuccess(r, opts); + + constructBomUploadTable(r); + + $('#bom-submit').click(function() { + submitBomTable({{ part.pk }}, { + bom_data: response, + }); + }); + }, + error: function(xhr) { + + $(opts.modal).find('#modal-progress-spinner').hide(); + + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + + console.log(`upload error at ${opts.url}`); + showApiError(xhr, opts.url); + break; + } + } + } + ); + }, }); } }); diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 0c70bd3d86..5c93c7c8f2 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -40,12 +40,6 @@ function constructBomUploadTable(data, options={}) { function constructRow(row, idx, fields) { // Construct an individual row from the provided data - var errors = {}; - - if (data.errors && data.errors.length > idx) { - errors = data.errors[idx]; - } - var field_options = { hideLabels: true, hideClearButton: true, @@ -60,7 +54,7 @@ function constructBomUploadTable(data, options={}) { return `Cannot render field '${field_name}`; } - field.value = row[field_name]; + field.value = row.data[field_name]; return constructField(`items_${field_name}_${idx}`, field, field_options); @@ -99,19 +93,19 @@ function constructBomUploadTable(data, options={}) { $('#bom-import-table tbody').append(html); // Handle any errors raised by initial data import - if (errors.part) { - addFieldErrorMessage(`items_sub_part_${idx}`, errors.part); + if (row.data.errors.part) { + addFieldErrorMessage(`items_sub_part_${idx}`, row.data.errors.part); } - if (errors.quantity) { - addFieldErrorMessage(`items_quantity_${idx}`, errors.quantity); + if (row.data.errors.quantity) { + addFieldErrorMessage(`items_quantity_${idx}`, row.data.errors.quantity); } // Initialize the "part" selector for this row initializeRelatedField( { name: `items_sub_part_${idx}`, - value: row.part, + value: row.data.part, api_url: '{% url "api-part-list" %}', filters: { component: true, @@ -140,7 +134,12 @@ function constructBomUploadTable(data, options={}) { }); // Prettify the original import data - var pretty = JSON.stringify(row, undefined, 4); + var pretty = JSON.stringify( + { + columns: data.columns, + row: row.original, + }, undefined, 4 + ); var html = `
@@ -176,7 +175,7 @@ function submitBomTable(part_id, options={}) { var idx_values = []; - var url = '{% url "api-bom-upload" %}'; + var url = '{% url "api-bom-import-submit" %}'; $('.bom-import-row').each(function() { var idx = $(this).attr('idx'); diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 7f82587d2e..b8f387328b 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1219,7 +1219,7 @@ function addFieldErrorMessage(name, error_text, error_idx=0, options={}) { field_dom.append(error_html); } else { - console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}`); + console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}'`); } } From 8f6312f7f44f7095732e1eae05bfb2561e3e4eff Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 16:57:54 +1100 Subject: [PATCH 05/28] Adds generic javascript function for mapping file columns to model fields --- InvenTree/part/templates/part/upload_bom.html | 102 +++------------ InvenTree/templates/js/translated/forms.js | 121 +++++++++++++++++- 2 files changed, 133 insertions(+), 90 deletions(-) diff --git a/InvenTree/part/templates/part/upload_bom.html b/InvenTree/part/templates/part/upload_bom.html index bbb69e3083..1646a9b70c 100644 --- a/InvenTree/part/templates/part/upload_bom.html +++ b/InvenTree/part/templates/part/upload_bom.html @@ -88,96 +88,24 @@ $('#bom-upload').click(function() { // Clear existing entries from the table $('.bom-import-row').remove(); - // Disable the "submit" button - $('#bom-submit').show(); + selectImportFields( + '{% url "api-bom-import-extract" %}', + response, + { + success: function(response) { + constructBomUploadTable(response); - var fields = {}; + // Show the "submit" button + $('#bom-submit').show(); - var choices = []; - - // Add an "empty" value - choices.push({ - value: '', - display_name: '-----', - }); - - for (const [name, field] of Object.entries(response.model_fields)) { - choices.push({ - value: name, - display_name: field.label || name, - }); - } - - var field_names = Object.keys(response.file_fields); - - for (var idx = 0; idx < field_names.length; idx++) { - - var field_name = field_names[idx]; - - // Construct a new field - fields[`column_${idx}`] = { - type: 'choice', - label: field_name, - value: response.file_fields[field_name].value, - choices: choices, - inline: true, - }; - } - - constructForm('{% url "api-bom-import-extract" %}', { - method: 'POST', - title: '{% trans "Select BOM Columns" %}', - fields: fields, - onSubmit: function(fields, opts) { - var columns = []; - - for (var idx = 0; idx < field_names.length; idx++) { - columns.push( - getFormFieldValue(`column_${idx}`, {}, {}) - ); + $('#bom-submit').click(function() { + submitBomTable({{ part.pk }}, { + bom_data: response, + }); + }); } - - $(opts.modal).find('#modal-progress-spinner').show(); - - inventreePut( - opts.url, - { - columns: columns, - rows: response.rows, - }, - { - method: 'POST', - success: function(r) { - handleFormSuccess(r, opts); - - constructBomUploadTable(r); - - $('#bom-submit').click(function() { - submitBomTable({{ part.pk }}, { - bom_data: response, - }); - }); - }, - error: function(xhr) { - - $(opts.modal).find('#modal-progress-spinner').hide(); - - switch (xhr.status) { - case 400: - handleFormErrors(xhr.responseJSON, fields, opts); - break; - default: - $(opts.modal).modal('hide'); - - console.log(`upload error at ${opts.url}`); - showApiError(xhr, opts.url); - break; - } - } - } - ); - }, - }); + } + ); } }); diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index b8f387328b..6ab183cc96 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -31,6 +31,7 @@ setFormInputPlaceholder, setFormGroupVisibility, showFormInput, + selectImportFields, */ /** @@ -895,8 +896,8 @@ 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}'`); + if (!el.exists()) { + console.log(`ERROR: getFormFieldValue could not locate field '${name}'`); return null; } @@ -2080,7 +2081,7 @@ function constructLabel(name, parameters) { * - parameters: Field parameters returned by the OPTIONS method * */ -function constructInput(name, parameters, options) { +function constructInput(name, parameters, options={}) { var html = ''; @@ -2422,3 +2423,117 @@ function constructHelpText(name, parameters) { return html; } + + +/* + * Construct a dialog to select import fields + */ +function selectImportFields(url, data={}, options={}) { + + if (!data.model_fields) { + console.log("WARNING: selectImportFields is missing 'model_fields'"); + return; + } + + if (!data.file_fields) { + console.log("WARNING: selectImportFields is missing 'file_fields'"); + return; + } + + var choices = []; + + // Add an "empty" value + choices.push({ + value: '', + display_name: '-----', + }); + + for (const [name, field] of Object.entries(data.model_fields)) { + choices.push({ + value: name, + display_name: field.label || name, + }); + } + + var rows = ''; + + var field_names = Object.keys(data.file_fields); + + for (var idx = 0; idx < field_names.length; idx++) { + + var field_name = field_names[idx]; + + var choice_input = constructInput( + `column_${idx}`, + { + type: 'choice', + label: field_name, + value: data.file_fields[field_name].value, + choices: choices, + } + ); + + rows += `${field_name}${choice_input}`; + } + + var headers = `{% trans "File Column" %}{% trans "Field Name" %}`; + + var html = ''; + + if (options.preamble) { + html += options.preamble; + } + + html += `${headers}${rows}
`; + + constructForm(url, { + method: 'POST', + title: '{% trans "Select Columns" %}', + fields: {}, + preFormContent: html, + onSubmit: function(fields, opts) { + + var columns = []; + + for (var idx = 0; idx < field_names.length; idx++) { + columns.push(getFormFieldValue(`column_${idx}`, {}, opts)); + } + + $(opts.modal).find('#modal-progress-spinner').show(); + + inventreePut( + opts.url, + { + columns: columns, + rows: data.rows, + }, + { + method: 'POST', + success: function(response) { + handleFormSuccess(response, opts); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + + $(opts.modal).find('#modal-progress-spinner').hide(); + + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + + console.log(`upload error at ${opts.url}`); + showApiError(xhr, opts.url); + break; + } + } + } + ) + }, + }); +} \ No newline at end of file From 0f0325daba1491057ee9d80c169273084b532585 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 17:16:17 +1100 Subject: [PATCH 06/28] Adds a button to quickly "pass" a test --- InvenTree/stock/templates/stock/item.html | 21 +++++++++++++++++++++ InvenTree/templates/js/translated/stock.js | 5 +++++ 2 files changed, 26 insertions(+) diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 5c3f94f72f..48fcfb8370 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -311,6 +311,27 @@ }); }); + $('#test-result-table').on('click', '.button-test-tick', function() { + var button = $(this); + + var test_name = button.attr('pk'); + + inventreePut( + '{% url "api-stock-test-result-list" %}', + { + test: test_name, + result: true, + stock_item: {{ item.pk }}, + }, + { + method: 'POST', + success: function(response) { + reloadTable(); + } + } + ); + }) + $("#test-result-table").on('click', '.button-test-add', function() { var button = $(this); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 809cd6fa8c..6fb8c911c8 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1235,6 +1235,11 @@ function loadStockTestResultsTable(table, options) { function makeButtons(row, grouped) { var html = `
`; + if (row.requires_attachment == false && row.requires_value == false && !row.result) { + // Enable a "quick tick" option for this test result + html += makeIconButton('fa-check-circle icon-green', 'button-test-tick', row.test_name, '{% trans "Pass test" %}'); + } + html += makeIconButton('fa-plus icon-green', 'button-test-add', row.test_name, '{% trans "Add test result" %}'); if (!grouped && row.result != null) { From fbef0e1ede1a56baafc3506e7c730469d675c89f Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 17:18:17 +1100 Subject: [PATCH 07/28] js linting --- InvenTree/templates/js/translated/forms.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 6ab183cc96..9ee3dcace3 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -2431,12 +2431,12 @@ function constructHelpText(name, parameters) { function selectImportFields(url, data={}, options={}) { if (!data.model_fields) { - console.log("WARNING: selectImportFields is missing 'model_fields'"); + console.log(`WARNING: selectImportFields is missing 'model_fields'`); return; } if (!data.file_fields) { - console.log("WARNING: selectImportFields is missing 'file_fields'"); + console.log(`WARNING: selectImportFields is missing 'file_fields'`); return; } @@ -2533,7 +2533,7 @@ function selectImportFields(url, data={}, options={}) { } } } - ) + ); }, }); -} \ No newline at end of file +} From 9c8f15fd56d955cd2ac99a83ed89fa99fcac256c Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 19:43:14 +1100 Subject: [PATCH 08/28] Fix field name --- InvenTree/InvenTree/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 472bab30b9..d527e0433a 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -348,7 +348,7 @@ class DataFileUploadSerializer(serializers.Serializer): class Meta: fields = [ - 'bom_file', + 'data_file', ] data_file = serializers.FileField( From 371af2a34ac9be5bea6422a7b2a28a1b93b34db3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 22:19:02 +1100 Subject: [PATCH 09/28] unit test fixes --- InvenTree/part/test_bom_import.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/InvenTree/part/test_bom_import.py b/InvenTree/part/test_bom_import.py index ce622ed991..7061da77ec 100644 --- a/InvenTree/part/test_bom_import.py +++ b/InvenTree/part/test_bom_import.py @@ -41,8 +41,6 @@ class BomUploadTest(InvenTreeAPITestCase): assembly=False, ) - self.url = reverse('api-bom-extract') - def post_bom(self, filename, file_data, part=None, clear_existing=None, expected_code=None, content_type='text/plain'): bom_file = SimpleUploadedFile( @@ -58,11 +56,9 @@ class BomUploadTest(InvenTreeAPITestCase): clear_existing = False response = self.post( - self.url, + reverse('api-bom-import-upload'), data={ - 'bom_file': bom_file, - 'part': part, - 'clear_existing': clear_existing, + 'data_file': bom_file, }, expected_code=expected_code, format='multipart', @@ -76,14 +72,12 @@ class BomUploadTest(InvenTreeAPITestCase): """ response = self.post( - self.url, + reverse('api-bom-import-upload'), data={}, expected_code=400 ) - self.assertIn('No file was submitted', str(response.data['bom_file'])) - self.assertIn('This field is required', str(response.data['part'])) - self.assertIn('This field is required', str(response.data['clear_existing'])) + self.assertIn('No file was submitted', str(response.data['data_file'])) def test_unsupported_file(self): """ @@ -96,7 +90,7 @@ class BomUploadTest(InvenTreeAPITestCase): expected_code=400, ) - self.assertIn('Unsupported file type', str(response.data['bom_file'])) + self.assertIn('Unsupported file type', str(response.data['data_file'])) def test_broken_file(self): """ @@ -109,7 +103,7 @@ class BomUploadTest(InvenTreeAPITestCase): expected_code=400, ) - self.assertIn('The submitted file is empty', str(response.data['bom_file'])) + self.assertIn('The submitted file is empty', str(response.data['data_file'])) response = self.post_bom( 'test.xls', @@ -118,11 +112,11 @@ class BomUploadTest(InvenTreeAPITestCase): content_type='application/xls', ) - self.assertIn('Unsupported format, or corrupt file', str(response.data['bom_file'])) + self.assertIn('Unsupported format, or corrupt file', str(response.data['data_file'])) - def test_invalid_upload(self): + def test_missing_rows(self): """ - Test upload of an invalid file + Test upload of an invalid file (without data rows) """ dataset = tablib.Dataset() @@ -139,7 +133,7 @@ class BomUploadTest(InvenTreeAPITestCase): expected_code=400, ) - self.assertIn("Missing required column: 'quantity'", str(response.data)) + self.assertIn('No data rows found in file', str(response.data)) # Try again, with an .xlsx file response = self.post_bom( @@ -149,7 +143,9 @@ class BomUploadTest(InvenTreeAPITestCase): expected_code=400, ) - self.assertIn("Missing required column: 'quantity'", str(response.data)) + self.assertIn('No data rows found in file', str(response.data)) + + def test_something(self): # Add the quantity field (or close enough) dataset.headers.append('quAntiTy ') From f6f512c40b14323faafcef8d7a496765af4a1c01 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 22:22:04 +1100 Subject: [PATCH 10/28] Fix breadcrumb tree for stock item page --- InvenTree/stock/templates/stock/item_base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index cb4ecc3059..f28a231f2b 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -618,7 +618,7 @@ enableBreadcrumbTree({ {% endif %} processNode: function(node) { node.text = node.name; - node.href = `/stock/item/${node.pk}/`; + node.href = `/stock/location/${node.pk}/`; return node; } From e7aff936670bacb2b5c3b8c1befec4983e53cbac Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Feb 2022 23:56:20 +1100 Subject: [PATCH 11/28] Create FUNDING.yml Add sponsor file --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..4e83a08b62 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +patreon: inventree From f068ee6c2e5da338e3aa5775e59dce454defbd49 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 08:15:24 +1100 Subject: [PATCH 12/28] Update FUNDING.yml Add ko_fi username --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 4e83a08b62..5ebf729c54 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ patreon: inventree +ko_fi: inventree From bc6839d56eeb93db9aca71c6087416b736a32f57 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 08:18:15 +1100 Subject: [PATCH 13/28] Spelling fix --- InvenTree/build/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 0b0858cb8a..c7577fa68c 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -208,7 +208,7 @@ class BuildOutputCreateSerializer(serializers.Serializer): raise ValidationError(_("Integer quantity required for trackable parts")) if part.has_trackable_parts(): - raise ValidationError(_("Integer quantity required, as the bill of materials contains tracakble parts")) + raise ValidationError(_("Integer quantity required, as the bill of materials contains trackable parts")) return quantity From e298a3adbf6995a2144b4c2fad436bb2ba9bace2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 11:45:44 +1100 Subject: [PATCH 14/28] Implement unit test for missing columns --- InvenTree/part/test_bom_import.py | 57 +++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/InvenTree/part/test_bom_import.py b/InvenTree/part/test_bom_import.py index 7061da77ec..06ff2a79d0 100644 --- a/InvenTree/part/test_bom_import.py +++ b/InvenTree/part/test_bom_import.py @@ -145,31 +145,58 @@ class BomUploadTest(InvenTreeAPITestCase): self.assertIn('No data rows found in file', str(response.data)) - def test_something(self): + def test_missing_columns(self): + """ + Upload extracted data, but with missing columns + """ - # Add the quantity field (or close enough) - dataset.headers.append('quAntiTy ') + url = reverse('api-bom-import-extract') + + rows = [ + ['1', 'test'], + ['2', 'test'], + ] - response = self.post_bom( - 'test.csv', - bytes(dataset.csv, 'utf8'), - content_type='text/csv', + # Post without columns + response = self.post( + url, + {}, expected_code=400, ) - self.assertIn('No part column found', str(response.data)) + self.assertIn('This field is required', str(response.data['rows'])) + self.assertIn('This field is required', str(response.data['columns'])) - dataset.headers.append('part_id') - dataset.headers.append('part_name') + response = self.post( + url, + { + 'rows': rows, + 'columns': ['part', 'reference'], + }, + expected_code=400 + ) - response = self.post_bom( - 'test.csv', - bytes(dataset.csv, 'utf8'), - content_type='text/csv', + self.assertIn("Missing required column: 'quantity'", str(response.data)) + + response = self.post( + url, + { + 'rows': rows, + 'columns': ['quantity', 'reference'], + }, expected_code=400, ) - self.assertIn('No data rows found', str(response.data)) + self.assertIn('No part column specified', str(response.data)) + + response = self.post( + url, + { + 'rows': rows, + 'columns': ['quantity', 'part'], + }, + expected_code=201, + ) def test_invalid_data(self): """ From 47f6b709c91b5202c603fbeae174aa9a4b8ba8c2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 12:10:48 +1100 Subject: [PATCH 15/28] Improve unit testing --- InvenTree/part/serializers.py | 2 +- InvenTree/part/test_bom_import.py | 34 ++++++++++++++++++------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 6b0c89ad88..cc489cf5c9 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -780,7 +780,7 @@ class BomImportExtractSerializer(DataFileExtractSerializer): row['errors']['part'] = _('No matching part found') else: if not part.component: - row['errors']['part'] = _('Part is not designed as a component') + row['errors']['part'] = _('Part is not designated as a component') # Update the 'part' value in the row row['part'] = part.pk if part is not None else None diff --git a/InvenTree/part/test_bom_import.py b/InvenTree/part/test_bom_import.py index 06ff2a79d0..8cf66a183b 100644 --- a/InvenTree/part/test_bom_import.py +++ b/InvenTree/part/test_bom_import.py @@ -218,25 +218,31 @@ class BomUploadTest(InvenTreeAPITestCase): dataset.append([cmp.pk, idx]) - # Add a duplicate part too - dataset.append([components.first().pk, 'invalid']) + url = reverse('api-bom-import-extract') - response = self.post_bom( - 'test.csv', - bytes(dataset.csv, 'utf8'), - content_type='text/csv', - expected_code=201 + response = self.post( + url, + { + 'columns': dataset.headers, + 'rows': [row for row in dataset], + }, ) - errors = response.data['errors'] + rows = response.data['rows'] - self.assertIn('Quantity must be greater than zero', str(errors[0])) - self.assertIn('Part is not designated as a component', str(errors[5])) - self.assertIn('Duplicate part selected', str(errors[-1])) - self.assertIn('Invalid quantity', str(errors[-1])) + # Returned data must be the same as the original dataset + self.assertEqual(len(rows), len(dataset)) - for idx, row in enumerate(response.data['rows'][:-1]): - self.assertEqual(str(row['part']), str(components[idx].pk)) + for idx, row in enumerate(rows): + data = row['data'] + cmp = components[idx] + + # Should have guessed the correct part + data['part'] = cmp.pk + + # Check some specific error messages + self.assertEqual(rows[0]['data']['errors']['quantity'], 'Quantity must be greater than zero') + self.assertEqual(rows[5]['data']['errors']['part'], 'Part is not designated as a component') def test_part_guess(self): """ From 82cfc5423ac628139151e7931f1305c9706224a4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 13:07:16 +1100 Subject: [PATCH 16/28] Further improvements to unit tests --- InvenTree/part/test_bom_import.py | 43 ++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/InvenTree/part/test_bom_import.py b/InvenTree/part/test_bom_import.py index 8cf66a183b..20d434cbf5 100644 --- a/InvenTree/part/test_bom_import.py +++ b/InvenTree/part/test_bom_import.py @@ -151,7 +151,7 @@ class BomUploadTest(InvenTreeAPITestCase): """ url = reverse('api-bom-import-extract') - + rows = [ ['1', 'test'], ['2', 'test'], @@ -262,9 +262,14 @@ class BomUploadTest(InvenTreeAPITestCase): 10, ]) - response = self.post_bom( - 'test.csv', - bytes(dataset.csv, 'utf8'), + url = reverse('api-bom-import-extract') + + response = self.post( + url, + { + 'columns': dataset.headers, + 'rows': [row for row in dataset], + }, expected_code=201, ) @@ -273,7 +278,7 @@ class BomUploadTest(InvenTreeAPITestCase): self.assertEqual(len(rows), 10) for idx in range(10): - self.assertEqual(rows[idx]['part'], components[idx].pk) + self.assertEqual(rows[idx]['data']['part'], components[idx].pk) # Should also be able to 'guess' part by the IPN value dataset = tablib.Dataset() @@ -286,9 +291,12 @@ class BomUploadTest(InvenTreeAPITestCase): 10, ]) - response = self.post_bom( - 'test.csv', - bytes(dataset.csv, 'utf8'), + response = self.post( + url, + { + 'columns': dataset.headers, + 'rows': [row for row in dataset], + }, expected_code=201, ) @@ -297,13 +305,15 @@ class BomUploadTest(InvenTreeAPITestCase): self.assertEqual(len(rows), 10) for idx in range(10): - self.assertEqual(rows[idx]['part'], components[idx].pk) + self.assertEqual(rows[idx]['data']['part'], components[idx].pk) def test_levels(self): """ Test that multi-level BOMs are correctly handled during upload """ + url = reverse('api-bom-import-extract') + dataset = tablib.Dataset() dataset.headers = ['level', 'part', 'quantity'] @@ -317,11 +327,20 @@ class BomUploadTest(InvenTreeAPITestCase): 2, ]) - response = self.post_bom( - 'test.csv', - bytes(dataset.csv, 'utf8'), + response = self.post( + url, + { + 'rows': [row for row in dataset], + 'columns': dataset.headers, + }, expected_code=201, ) + rows = response.data['rows'] + # Only parts at index 1, 4, 7 should have been returned self.assertEqual(len(response.data['rows']), 3) + + self.assertEqual(rows[0]['data']['part'], 3) + self.assertEqual(rows[1]['data']['part'], 6) + self.assertEqual(rows[2]['data']['part'], 9) From 578c161d4fb93e8403570c6117bb20a4c34a6bbe Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 13:36:00 +1100 Subject: [PATCH 17/28] Adds information on test result being deleted --- InvenTree/stock/templates/stock/item.html | 80 ---------------- InvenTree/templates/js/translated/stock.js | 104 ++++++++++++++++++++- 2 files changed, 100 insertions(+), 84 deletions(-) diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 48fcfb8370..333360a95e 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -311,86 +311,6 @@ }); }); - $('#test-result-table').on('click', '.button-test-tick', function() { - var button = $(this); - - var test_name = button.attr('pk'); - - inventreePut( - '{% url "api-stock-test-result-list" %}', - { - test: test_name, - result: true, - stock_item: {{ item.pk }}, - }, - { - method: 'POST', - success: function(response) { - reloadTable(); - } - } - ); - }) - - $("#test-result-table").on('click', '.button-test-add', function() { - var button = $(this); - - var test_name = button.attr('pk'); - - constructForm('{% url "api-stock-test-result-list" %}', { - method: 'POST', - fields: { - test: { - value: test_name, - }, - result: {}, - value: {}, - attachment: {}, - notes: {}, - stock_item: { - value: {{ item.pk }}, - hidden: true, - } - }, - title: '{% trans "Add Test Result" %}', - onSuccess: reloadTable, - }); - }); - - $("#test-result-table").on('click', '.button-test-edit', function() { - var button = $(this); - - var pk = button.attr('pk'); - - var url = `/api/stock/test/${pk}/`; - - constructForm(url, { - fields: { - test: {}, - result: {}, - value: {}, - attachment: {}, - notes: {}, - }, - title: '{% trans "Edit Test Result" %}', - onSuccess: reloadTable, - }); - }); - - $("#test-result-table").on('click', '.button-test-delete', function() { - var button = $(this); - - var pk = button.attr('pk'); - - var url = `/api/stock/test/${pk}/`; - - constructForm(url, { - method: 'DELETE', - title: '{% trans "Delete Test Result" %}', - onSuccess: reloadTable, - }); - }); - {% if item.child_count > 0 %} loadStockTable($("#childs-stock-table"), { params: { diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 6fb8c911c8..3c0511c0ae 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1227,10 +1227,10 @@ function formatDate(row) { return html; } +/* + * Load StockItemTestResult table + */ function loadStockTestResultsTable(table, options) { - /* - * Load StockItemTestResult table - */ function makeButtons(row, grouped) { var html = `
`; @@ -1263,7 +1263,7 @@ function loadStockTestResultsTable(table, options) { rootParentId: parent_node, parentIdField: 'parent', idField: 'pk', - uniqueId: 'key', + uniqueId: 'pk', treeShowField: 'test_name', formatNoMatches: function() { return '{% trans "No test results found" %}'; @@ -1406,6 +1406,102 @@ function loadStockTestResultsTable(table, options) { ); } }); + + /* Register button callbacks */ + + function reloadTestTable(response) { + $(table).bootstrapTable('refresh'); + } + + // "tick" a test result + $(table).on('click', '.button-test-tick', function() { + var button = $(this); + + var test_name = button.attr('pk'); + + inventreePut( + '{% url "api-stock-test-result-list" %}', + { + test: test_name, + result: true, + stock_item: options.stock_item, + }, + { + method: 'POST', + success: reloadTestTable, + } + ); + }); + + // Add a test result + $(table).on('click', '.button-test-add', function() { + var button = $(this); + + var test_name = button.attr('pk'); + + constructForm('{% url "api-stock-test-result-list" %}', { + method: 'POST', + fields: { + test: { + value: test_name, + }, + result: {}, + value: {}, + attachment: {}, + notes: {}, + stock_item: { + value: options.stock_item, + hidden: true, + } + }, + title: '{% trans "Add Test Result" %}', + onSuccess: reloadTestTable, + }); + }); + + // Edit a test result + $(table).on('click', '.button-test-edit', function() { + var button = $(this); + + var pk = button.attr('pk'); + + var url = `/api/stock/test/${pk}/`; + + constructForm(url, { + fields: { + test: {}, + result: {}, + value: {}, + attachment: {}, + notes: {}, + }, + title: '{% trans "Edit Test Result" %}', + onSuccess: reloadTestTable, + }); + }); + + // Delete a test result + $(table).on('click', '.button-test-delete', function() { + var button = $(this); + + var pk = button.attr('pk'); + + var url = `/api/stock/test/${pk}/`; + + var row = $(table).bootstrapTable('getRowByUniqueId', pk); + + var html = ` +
+ {% trans "Delete test result" %}: ${row.test_name || row.test || row.key} +
`; + + constructForm(url, { + method: 'DELETE', + title: '{% trans "Delete Test Result" %}', + onSuccess: reloadTestTable, + preFormContent: html, + }); + }); } From 6199223636a513de4d38b90d0a9908fb84110c6f Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 13:46:11 +1100 Subject: [PATCH 18/28] Adds "refresh" button for stock test table --- InvenTree/templates/js/translated/stock.js | 28 +++++++++++++++++-- .../templates/js/translated/table_filters.js | 7 +---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 3c0511c0ae..9be9d2afa1 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1232,7 +1232,30 @@ function formatDate(row) { */ function loadStockTestResultsTable(table, options) { + // Setup filters for the table + var filterTarget = options.filterTarget || '#filter-list-stocktests'; + + var filterKey = options.filterKey || options.name || 'stocktests'; + + var filters = loadTableFilters(filterKey); + + var params = { + part: options.part, + }; + + var original = {}; + + for (var k in params) { + original[k] = params[k]; + filters[k] = params[k]; + } + + setupFilterList(filterKey, table, filterTarget); + function makeButtons(row, grouped) { + + // Helper function for rendering buttons + var html = `
`; if (row.requires_attachment == false && row.requires_value == false && !row.result) { @@ -1268,9 +1291,8 @@ function loadStockTestResultsTable(table, options) { formatNoMatches: function() { return '{% trans "No test results found" %}'; }, - queryParams: { - part: options.part, - }, + queryParams: filters, + original: original, onPostBody: function() { table.treegrid({ treeColumn: 0, diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index d9f2014c14..a4c6a0bbac 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -265,12 +265,7 @@ function getAvailableTableFilters(tableKey) { // Filters for the 'stock test' table if (tableKey == 'stocktests') { - return { - result: { - type: 'bool', - title: '{% trans "Test result" %}', - }, - }; + return {}; } // Filters for the 'part test template' table From 31fd69fc7696ff5e7114bb0f0d88ba4e7d553355 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 14:00:16 +1100 Subject: [PATCH 19/28] Ensure unit tests are more resilient --- InvenTree/part/test_bom_import.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/test_bom_import.py b/InvenTree/part/test_bom_import.py index 20d434cbf5..8903660f39 100644 --- a/InvenTree/part/test_bom_import.py +++ b/InvenTree/part/test_bom_import.py @@ -341,6 +341,7 @@ class BomUploadTest(InvenTreeAPITestCase): # Only parts at index 1, 4, 7 should have been returned self.assertEqual(len(response.data['rows']), 3) - self.assertEqual(rows[0]['data']['part'], 3) - self.assertEqual(rows[1]['data']['part'], 6) - self.assertEqual(rows[2]['data']['part'], 9) + # Check the returned PK values + self.assertEqual(rows[0]['data']['part'], components[1].pk) + self.assertEqual(rows[1]['data']['part'], components[4].pk) + self.assertEqual(rows[2]['data']['part'], components[7].pk) From 0604e1a12753edd46e5964c04e5739b741f457d1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 17:03:17 +1100 Subject: [PATCH 20/28] Adds API endpoint for installing stock items into other stock items - Requires more filtering for the Part API - Adds more BOM related functionality for Part model - Removes old server-side form --- InvenTree/InvenTree/version.py | 5 +- InvenTree/part/api.py | 18 +++ InvenTree/part/models.py | 30 ++++ InvenTree/stock/api.py | 26 +++ InvenTree/stock/forms.py | 50 ------ InvenTree/stock/serializers.py | 58 +++++++ InvenTree/stock/templates/stock/item.html | 13 +- .../stock/templates/stock/item_base.html | 15 +- .../stock/templates/stock/item_install.html | 33 ---- InvenTree/stock/urls.py | 1 - InvenTree/stock/views.py | 149 ------------------ InvenTree/templates/js/translated/stock.js | 65 ++++++++ 12 files changed, 209 insertions(+), 254 deletions(-) delete mode 100644 InvenTree/stock/templates/stock/item_install.html diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 19235f0e0a..f7ba0f7a68 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,14 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 24 +INVENTREE_API_VERSION = 25 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v25 -> 2022-02-17 + - Adds ability to filter "part" list endpoint by "in_bom_for" argument + v24 -> 2022-02-10 - Adds API endpoint for deleting (cancelling) build order outputs diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 4c52b87520..5a9439420d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -995,6 +995,24 @@ class PartList(generics.ListCreateAPIView): except (ValueError, Part.DoesNotExist): pass + # Filter only parts which are in the "BOM" for a given part + in_bom_for = params.get('in_bom_for', None) + + if in_bom_for is not None: + try: + in_bom_for = Part.objects.get(pk=in_bom_for) + + + # Extract a list of parts within the BOM + bom_parts = in_bom_for.get_parts_in_bom() + print("bom_parts:", bom_parts) + print([p.pk for p in bom_parts]) + + queryset = queryset.filter(pk__in=[p.pk for p in bom_parts]) + + except (ValueError, Part.DoesNotExist): + pass + # Filter by whether the BOM has been validated (or not) bom_valid = params.get('bom_valid', None) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index b312937e30..eba308df68 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -483,6 +483,36 @@ class Part(MPTTModel): def __str__(self): return f"{self.full_name} - {self.description}" + def get_parts_in_bom(self): + """ + Return a list of all parts in the BOM for this part. + Takes into account substitutes, variant parts, and inherited BOM items + """ + + parts = set() + + for bom_item in self.get_bom_items(): + for part in bom_item.get_valid_parts_for_allocation(): + parts.add(part) + + return parts + + def check_if_part_in_bom(self, other_part): + """ + Check if the other_part is in the BOM for this part. + + Note: + - Accounts for substitute parts + - Accounts for variant BOMs + """ + + for bom_item in self.get_bom_items(): + if other_part in bom_item.get_valid_parts_for_allocation(): + return True + + # No matches found + return False + def check_add_to_bom(self, parent, raise_error=False, recursive=True): """ Check if this Part can be added to the BOM of another part. diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index a13c7f37c3..7bcd89623c 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -109,6 +109,31 @@ class StockItemSerialize(generics.CreateAPIView): return context +class StockItemInstall(generics.CreateAPIView): + """ + API endpoint for installing a particular stock item into this stock item. + + - stock_item.part must be in the BOM for this part + - stock_item must currently be "in stock" + - stock_item must be serialized (and not belong to another item) + """ + + queryset = StockItem.objects.none() + serializer_class = StockSerializers.InstallStockItemSerializer + + def get_serializer_context(self): + + context = super().get_serializer_context() + context['request'] = self.request + + try: + context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return context + + class StockAdjustView(generics.CreateAPIView): """ A generic class for handling stocktake actions. @@ -1256,6 +1281,7 @@ stock_api_urls = [ # Detail views for a single stock item url(r'^(?P\d+)/', include([ url(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'), + url(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'), url(r'^.*$', StockDetail.as_view(), name='api-stock-detail'), ])), diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index dcbf722997..aa1d5e510f 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -162,56 +162,6 @@ class SerializeStockForm(HelperForm): ] -class InstallStockForm(HelperForm): - """ - Form for manually installing a stock item into another stock item - - TODO: Migrate this form to the modern API forms interface - """ - - part = forms.ModelChoiceField( - queryset=Part.objects.all(), - widget=forms.HiddenInput() - ) - - stock_item = forms.ModelChoiceField( - required=True, - queryset=StockItem.objects.filter(StockItem.IN_STOCK_FILTER), - help_text=_('Stock item to install') - ) - - to_install = forms.BooleanField( - widget=forms.HiddenInput(), - required=False, - ) - - notes = forms.CharField( - required=False, - help_text=_('Notes') - ) - - class Meta: - model = StockItem - fields = [ - 'part', - 'stock_item', - # 'quantity_to_install', - 'notes', - ] - - def clean(self): - - data = super().clean() - - stock_item = data.get('stock_item', None) - quantity = data.get('quantity_to_install', None) - - if stock_item and quantity and quantity > stock_item.quantity: - raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')}) - - return data - - class UninstallStockForm(forms.ModelForm): """ Form for uninstalling a stock item which is installed in another item. diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index cdc844095b..90a08a536a 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -36,6 +36,7 @@ import InvenTree.helpers import InvenTree.serializers from InvenTree.serializers import InvenTreeDecimalField, extract_int +import part.models as part_models from part.serializers import PartBriefSerializer @@ -391,6 +392,63 @@ class SerializeStockItemSerializer(serializers.Serializer): ) +class InstallStockItemSerializer(serializers.Serializer): + """ + Serializer for installing a stock item into a given part + """ + + stock_item = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Stock Item'), + help_text=_('Select stock item to install'), + ) + + note = serializers.CharField( + label=_('Note'), + required=False, + allow_blank=True, + ) + + def validate_stock_item(self, stock_item): + """ + Validate the selected stock item + """ + + if not stock_item.in_stock: + # StockItem must be in stock to be "installed" + raise ValidationError(_("Stock item is unavailable")) + + # Extract the "parent" item - the item into which the stock item will be installed + parent_item = self.context['item'] + parent_part = parent_item.part + + if not parent_part.check_if_part_in_bom(stock_item.part): + raise ValidationError(_("Selected part is not in the Bill of Materials")) + + return stock_item + + def save(self): + """ Install the selected stock item into this one """ + + data = self.validated_data + + stock_item = data['stock_item'] + note = data.get('note', '') + + parent_item = self.context['item'] + request = self.context['request'] + + parent_item.installStockItem( + stock_item, + stock_item.quantity, + request.user, + note, + ) + + class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for a simple tree view diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 333360a95e..f42a768069 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -183,16 +183,11 @@ $('#stock-item-install').click(function() { - launchModalForm( - "{% url 'stock-item-install' item.pk %}", - { - data: { - 'part': {{ item.part.pk }}, - 'install_item': true, - }, - reload: true, + installStockItem({{ item.pk }}, {{ item.part.pk }}, { + onSuccess: function(response) { + $("#installed-table").bootstrapTable('refresh'); } - ); + }); }); loadInstalledInTable( diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index f28a231f2b..7692d632f0 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -98,7 +98,9 @@
  • {% trans "Uninstall" %}
  • {% else %} {% if item.part.get_used_in %} -
  • {% trans "Install" %}
  • + {% endif %} {% endif %} @@ -442,16 +444,7 @@ $("#stock-serialize").click(function() { $('#stock-install-in').click(function() { - launchModalForm( - "{% url 'stock-item-install' item.pk %}", - { - data: { - 'part': {{ item.part.pk }}, - 'install_in': true, - }, - reload: true, - } - ); + // TODO - Launch dialog to install this item *into* another stock item }); $('#stock-uninstall').click(function() { diff --git a/InvenTree/stock/templates/stock/item_install.html b/InvenTree/stock/templates/stock/item_install.html deleted file mode 100644 index 8a94f304d3..0000000000 --- a/InvenTree/stock/templates/stock/item_install.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -{% if install_item %} -

    - {% trans "Install another Stock Item into this item." %} -

    -

    - {% trans "Stock items can only be installed if they meet the following criteria" %}: - -

      -
    • {% trans "The Stock Item links to a Part which is in the BOM for this Stock Item" %}
    • -
    • {% trans "The Stock Item is currently in stock" %}
    • -
    • {% trans "The Stock Item is serialized and does not belong to another item" %}
    • -
    -

    -{% elif install_in %} -

    - {% trans "Install this Stock Item in another stock item." %} -

    -

    - {% trans "Stock items can only be installed if they meet the following criteria" %}: - -

      -
    • {% trans "The part associated to this Stock Item belongs to another part's BOM" %}
    • -
    • {% trans "This Stock Item is serialized and does not belong to another item" %}
    • -
    -

    -{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 7f35904b51..b2536e0b97 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -24,7 +24,6 @@ stock_item_detail_urls = [ url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'), - url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'), url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 6c89db0f2f..9aa70255b1 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -465,155 +465,6 @@ class StockItemQRCode(QRCodeView): return None -class StockItemInstall(AjaxUpdateView): - """ - View for manually installing stock items into - a particular stock item. - - In contrast to the StockItemUninstall view, - only a single stock item can be installed at once. - - The "part" to be installed must be provided in the GET query parameters. - - """ - - model = StockItem - form_class = StockForms.InstallStockForm - ajax_form_title = _('Install Stock Item') - ajax_template_name = "stock/item_install.html" - - part = None - - def get_params(self): - """ Retrieve GET parameters """ - - # Look at GET params - self.part_id = self.request.GET.get('part', None) - self.install_in = self.request.GET.get('install_in', False) - self.install_item = self.request.GET.get('install_item', False) - - if self.part_id is None: - # Look at POST params - self.part_id = self.request.POST.get('part', None) - - try: - self.part = Part.objects.get(pk=self.part_id) - except (ValueError, Part.DoesNotExist): - self.part = None - - def get_stock_items(self): - """ - Return a list of stock items suitable for displaying to the user. - - Requirements: - - Items must be in stock - - Items must be in BOM of stock item - - Items must be serialized - """ - - # Filter items in stock - items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) - - # Filter serialized stock items - items = items.exclude(serial__isnull=True).exclude(serial__exact='') - - if self.part: - # Filter for parts to install this item in - if self.install_in: - # Get parts using this part - allowed_parts = self.part.get_used_in() - # Filter - items = items.filter(part__in=allowed_parts) - - # Filter for parts to install in this item - if self.install_item: - # Get all parts which can be installed into this part - allowed_parts = self.part.get_installed_part_options() - # Filter - items = items.filter(part__in=allowed_parts) - - return items - - def get_context_data(self, **kwargs): - """ Retrieve parameters and update context """ - - ctx = super().get_context_data(**kwargs) - - # Get request parameters - self.get_params() - - ctx.update({ - 'part': self.part, - 'install_in': self.install_in, - 'install_item': self.install_item, - }) - - return ctx - - def get_initial(self): - - initials = super().get_initial() - - items = self.get_stock_items() - - # If there is a single stock item available, we can use it! - if items.count() == 1: - item = items.first() - initials['stock_item'] = item.pk - - if self.part: - initials['part'] = self.part - - try: - # Is this stock item being installed in the other stock item? - initials['to_install'] = self.install_in or not self.install_item - except AttributeError: - pass - - return initials - - def get_form(self): - - form = super().get_form() - - form.fields['stock_item'].queryset = self.get_stock_items() - - return form - - def post(self, request, *args, **kwargs): - - self.get_params() - - form = self.get_form() - - valid = form.is_valid() - - if valid: - # We assume by this point that we have a valid stock_item and quantity values - data = form.cleaned_data - - other_stock_item = data['stock_item'] - # Quantity will always be 1 for serialized item - quantity = 1 - notes = data['notes'] - - # Get stock item - this_stock_item = self.get_object() - - if data['to_install']: - # Install this stock item into the other stock item - other_stock_item.installStockItem(this_stock_item, quantity, request.user, notes) - else: - # Install the other stock item into this one - this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes) - - data = { - 'form_valid': valid, - } - - return self.renderJsonResponse(request, form, data=data) - - class StockItemUninstall(AjaxView, FormMixin): """ View for uninstalling one or more StockItems, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 9be9d2afa1..10b1b71073 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -46,6 +46,7 @@ editStockLocation, exportStock, findStockItemBySerialNumber, + installStockItem, loadInstalledInTable, loadStockAllocationTable, loadStockLocationTable, @@ -2960,3 +2961,67 @@ function loadInstalledInTable(table, options) { } }); } + + +/* + * Launch a dialog to install a stock item into another stock item + */ +function installStockItem(stock_item_id, part_id, options={}) { + + var html = ` +
    + {% trans "Install another stock item into this item" %}
    + {% trans "Stock items can only be installed if they meet the following criteria" %}:
    +
      +
    • {% trans "The Stock Item links to a Part which is the BOM for this Stock Item" %}
    • +
    • {% trans "The Stock Item is currently available in stock" %}
    • +
    • {% trans "The Stock Item is serialized and does not belong to another item" %}
    • +
    +
    `; + + constructForm( + `/api/stock/${stock_item_id}/install/`, + { + method: 'POST', + fields: { + part: { + type: 'related field', + required: 'true', + label: '{% trans "Part" %}', + help_text: '{% trans "Select part to install" %}', + model: 'part', + api_url: '{% url "api-part-list" %}', + auto_fill: true, + filters: { + trackable: true, + in_bom_for: part_id, + } + }, + stock_item: { + filters: { + part_detail: true, + in_stock: true, + serialized: true, + }, + adjustFilters: function(filters, opts) { + var part = getFormFieldValue('part', {}, opts); + + if (part) { + filters.part = part; + } + + return filters; + } + } + }, + confirm: true, + title: '{% trans "Install Stock Item" %}', + preFormContent: html, + onSuccess: function(response) { + if (options.onSuccess) { + options.onSuccess(response); + } + } + } + ); +} From f485bc7d53500b75803ed82c871546634ab61c3c Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 17:04:51 +1100 Subject: [PATCH 21/28] PEP fixes --- InvenTree/part/api.py | 1 - InvenTree/stock/forms.py | 3 --- InvenTree/stock/serializers.py | 3 +-- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 5a9439420d..c179cb1665 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1002,7 +1002,6 @@ class PartList(generics.ListCreateAPIView): try: in_bom_for = Part.objects.get(pk=in_bom_for) - # Extract a list of parts within the BOM bom_parts = in_bom_for.get_parts_in_bom() print("bom_parts:", bom_parts) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index aa1d5e510f..ef65b25cd9 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -8,7 +8,6 @@ from __future__ import unicode_literals from django import forms from django.forms.utils import ErrorDict from django.utils.translation import ugettext_lazy as _ -from django.core.exceptions import ValidationError from mptt.fields import TreeNodeChoiceField @@ -16,8 +15,6 @@ from InvenTree.forms import HelperForm from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import DatePickerFormField -from part.models import Part - from .models import StockLocation, StockItem, StockItemTracking diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 90a08a536a..941219da6c 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -36,7 +36,6 @@ import InvenTree.helpers import InvenTree.serializers from InvenTree.serializers import InvenTreeDecimalField, extract_int -import part.models as part_models from part.serializers import PartBriefSerializer @@ -432,7 +431,7 @@ class InstallStockItemSerializer(serializers.Serializer): def save(self): """ Install the selected stock item into this one """ - + data = self.validated_data stock_item = data['stock_item'] From 3226a1906fa437a98290d6096ee8101e83a42164 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 17:17:09 +1100 Subject: [PATCH 22/28] Critical bug fix: Check if serial numbers already exist when creating new StockItem --- InvenTree/stock/api.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 7bcd89623c..9723e01c09 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -528,11 +528,34 @@ class StockList(generics.ListCreateAPIView): serial_numbers = data.get('serial_numbers', '') # Assign serial numbers for a trackable part - if serial_numbers and part.trackable: + if serial_numbers: + + if not part.trackable: + raise ValidationError({ + 'serial_numbers': [_("Serial numbers cannot be supplied for a non-trackable part")] + }) # If serial numbers are specified, check that they match! try: serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt()) + + # Determine if any of the specified serial numbers already exist! + existing = [] + + for serial in serials: + if part.checkIfSerialNumberExists(serial): + existing.append(serial) + + if len(existing) > 0: + + msg = _("The following serial numbers already exist") + msg += " : " + msg += ",".join([str(e) for e in existing]) + + raise ValidationError({ + 'serial_numbers': [msg], + }) + except DjangoValidationError as e: raise ValidationError({ 'quantity': e.messages, From 1b6dacd5bae353db2d44f9ef296f743067485bf1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 22:48:15 +1100 Subject: [PATCH 23/28] Allow processing of "null" cells (caused by xls / xlsx import) --- InvenTree/InvenTree/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index d527e0433a..98e03fdc0e 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -517,6 +517,7 @@ class DataFileExtractSerializer(serializers.Serializer): child=serializers.ListField( child=serializers.CharField( allow_blank=True, + allow_null=True, ), ) ) From 6e6f9d6c2f3e4a2a43e4c9fe9856c464b4f89dcb Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 22:54:02 +1100 Subject: [PATCH 24/28] Reintroduce option to clear (delete) BOM before uploading new data --- InvenTree/part/serializers.py | 29 +++++++++++++++++++ InvenTree/part/templates/part/upload_bom.html | 5 ++++ 2 files changed, 34 insertions(+) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index cc489cf5c9..549b546a5b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -716,6 +716,35 @@ class BomImportUploadSerializer(DataFileUploadSerializer): TARGET_MODEL = BomItem + class Meta: + fields = [ + 'data_file', + 'part', + 'clear_existing_bom', + ] + + part = serializers.PrimaryKeyRelatedField( + queryset=Part.objects.all(), + required=True, + allow_null=False, + many=False, + ) + + clear_existing_bom = serializers.BooleanField( + label=_('Clear Existing BOM'), + help_text=_('Delete existing BOM items before uploading') + ) + + def save(self): + + data = self.validated_data + + if data.get('clear_existing_bom', False): + part = data['part'] + + with transaction.atomic(): + part.bom_items.all().delete() + class BomImportExtractSerializer(DataFileExtractSerializer): """ diff --git a/InvenTree/part/templates/part/upload_bom.html b/InvenTree/part/templates/part/upload_bom.html index 1646a9b70c..9db26f7b39 100644 --- a/InvenTree/part/templates/part/upload_bom.html +++ b/InvenTree/part/templates/part/upload_bom.html @@ -81,6 +81,11 @@ $('#bom-upload').click(function() { method: 'POST', fields: { data_file: {}, + part: { + value: {{ part.pk }}, + hidden: true, + }, + clear_existing_bom: {}, }, title: '{% trans "Upload BOM File" %}', onSuccess: function(response) { From efc749c695193d9704216e3113156902555a79cd Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 23:30:55 +1100 Subject: [PATCH 25/28] When uploading a report template, keep the existing filename (if it is the same report!) --- InvenTree/report/models.py | 45 ++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index be8d803edf..c2766ec959 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -43,32 +43,12 @@ except OSError as err: # pragma: no cover logger = logging.getLogger("inventree") -class ReportFileUpload(FileSystemStorage): - """ - Custom implementation of FileSystemStorage class. - - When uploading a report (or a snippet / asset / etc), - it is often important to ensure the filename is not arbitrarily *changed*, - if the name of the uploaded file is identical to the currently stored file. - - For example, a snippet or asset file is referenced in a template by filename, - and we do not want that filename to change when we upload a new *version* - of the snippet or asset file. - - This uploader class performs the following pseudo-code function: - - - If the model is *new*, proceed as normal - - If the model is being updated: - a) If the new filename is *different* from the existing filename, proceed as normal - b) If the new filename is *identical* to the existing filename, we want to overwrite the existing file - """ - - def get_available_name(self, name, max_length=None): - - return super().get_available_name(name, max_length) - - def rename_template(instance, filename): + """ + Helper function for 'renaming' uploaded report files. + Pass responsibility back to the calling class, + to ensure that files are uploaded to the correct directory. + """ return instance.rename_file(filename) @@ -155,7 +135,20 @@ class ReportBase(models.Model): filename = os.path.basename(filename) - return os.path.join('report', 'report_template', self.getSubdir(), filename) + path = os.path.join('report', 'report_template', self.getSubdir(), filename) + + # If the report file is the *same* filename as the one being uploaded, + # remove the original one from the media directory + if str(filename) == str(self.template): + + fullpath = os.path.join(settings.MEDIA_ROOT, path) + fullpath = os.path.abspath(fullpath) + + if os.path.exists(fullpath): + logger.info(f"Deleting existing report template: '{filename}'") + os.remove(fullpath) + + return path @property def extension(self): From 6aec40b9ba647ea1352113132e97d82d4f42073c Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 17 Feb 2022 23:46:43 +1100 Subject: [PATCH 26/28] Improved error messages when report templates (or snippets) are missing! --- InvenTree/report/api.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index af88f4799f..710f0562b1 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -225,13 +225,15 @@ class ReportPrintMixin: outputs.append(report.render_as_string(request)) else: outputs.append(report.render(request)) - except TemplateDoesNotExist: - - filename = report.template - + except TemplateDoesNotExist as e: + + template = str(e) + if not template: + template = report.template + return Response( { - 'error': _(f"Template file '{filename}' is missing or does not exist"), + 'error': _(f"Template file '{template}' is missing or does not exist"), }, status=400, ) @@ -269,13 +271,16 @@ class ReportPrintMixin: else: pdf = outputs[0].get_document().write_pdf() - except TemplateDoesNotExist: + except TemplateDoesNotExist as e: - filename = report.template + template = str(e) + + if not template: + template = report.template return Response( { - 'error': _(f"Template file '{filename}' is missing or does not exist"), + 'error': _(f"Template file '{template}' is missing or does not exist"), }, status=400, ) From 7c64cb950c4f0bec5623f72552654f2b9d34baea Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 18 Feb 2022 00:01:49 +1100 Subject: [PATCH 27/28] Delete template files from cache as they are uploaded --- InvenTree/report/api.py | 3 +-- InvenTree/report/models.py | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 710f0562b1..c7d2b15e4d 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -226,11 +226,10 @@ class ReportPrintMixin: else: outputs.append(report.render(request)) except TemplateDoesNotExist as e: - template = str(e) if not template: template = report.template - + return Response( { 'error': _(f"Template file '{template}' is missing or does not exist"), diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index c2766ec959..3ee19bd5e6 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -14,12 +14,12 @@ import datetime from django.urls import reverse from django.db import models from django.conf import settings +from django.core.cache import cache from django.core.exceptions import ValidationError, FieldError from django.template.loader import render_to_string from django.template import Template, Context -from django.core.files.storage import FileSystemStorage from django.core.validators import FileExtensionValidator import build.models @@ -137,17 +137,20 @@ class ReportBase(models.Model): path = os.path.join('report', 'report_template', self.getSubdir(), filename) + fullpath = os.path.join(settings.MEDIA_ROOT, path) + fullpath = os.path.abspath(fullpath) + # If the report file is the *same* filename as the one being uploaded, # remove the original one from the media directory if str(filename) == str(self.template): - fullpath = os.path.join(settings.MEDIA_ROOT, path) - fullpath = os.path.abspath(fullpath) - if os.path.exists(fullpath): logger.info(f"Deleting existing report template: '{filename}'") os.remove(fullpath) + # Ensure that the cache is cleared for this template! + cache.delete(fullpath) + return path @property @@ -515,16 +518,20 @@ def rename_snippet(instance, filename): path = os.path.join('report', 'snippets', filename) + fullpath = os.path.join(settings.MEDIA_ROOT, path) + fullpath = os.path.abspath(fullpath) + # If the snippet file is the *same* filename as the one being uploaded, # delete the original one from the media directory if str(filename) == str(instance.snippet): - fullpath = os.path.join(settings.MEDIA_ROOT, path) - fullpath = os.path.abspath(fullpath) if os.path.exists(fullpath): logger.info(f"Deleting existing snippet file: '{filename}'") os.remove(fullpath) + # Ensure that the cache is deleted for this snippet + cache.delete(fullpath) + return path From 05b194388630dad86c5907c187a5c483d7cb0c0b Mon Sep 17 00:00:00 2001 From: eeintech Date: Fri, 18 Feb 2022 12:25:50 -0500 Subject: [PATCH 28/28] Set default error message visibility in modal options --- InvenTree/templates/js/translated/modals.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/modals.js b/InvenTree/templates/js/translated/modals.js index 539ce61912..f114f6f419 100644 --- a/InvenTree/templates/js/translated/modals.js +++ b/InvenTree/templates/js/translated/modals.js @@ -893,6 +893,9 @@ function handleModalForm(url, options) { // Re-enable the modal modalEnable(modal, true); if ('form_valid' in response) { + // Get visibility option of error message + var hideErrorMessage = (options.hideErrorMessage === undefined) ? true : options.hideErrorMessage; + // Form data was validated correctly if (response.form_valid) { $(modal).modal('hide'); @@ -901,7 +904,7 @@ function handleModalForm(url, options) { // Form was returned, invalid! // Disable error message with option or response - if (!options.hideErrorMessage && !response.hideErrorMessage) { + if (!hideErrorMessage && !response.hideErrorMessage) { var warningDiv = $(modal).find('#form-validation-warning'); warningDiv.css('display', 'block'); }