From 611592694ba50643dc14913b0a316a2dc8333a4b Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 5 Feb 2022 00:12:11 +1100 Subject: [PATCH] Adds serializer for uploading a BOM file and extracting fields --- InvenTree/part/api.py | 10 +++ InvenTree/part/serializers.py | 138 ++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index d146c3d7b3..0bebef57c6 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1533,6 +1533,15 @@ class BomList(generics.ListCreateAPIView): ] +class BomExtract(generics.CreateAPIView): + """ + API endpoint for extracting BOM data from a BOM file. + """ + + queryset = Part.objects.none() + serializer_class = part_serializers.BomExtractSerializer + + class BomDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single BomItem object """ @@ -1685,6 +1694,7 @@ bom_api_urls = [ url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'), ])), + url(r'^extract/', BomExtract.as_view(), name='api-bom-extract'), # Catch-all url(r'^.*$', BomList.as_view(), name='api-bom-list'), ] diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 16ecc8da21..b6e6934cbc 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -4,6 +4,8 @@ JSON serializers for Part app import imghdr from decimal import Decimal +import os +import tablib from django.urls import reverse_lazy from django.db import models @@ -25,6 +27,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField, from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from stock.models import StockItem +from .admin import BomItemResource from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, PartCategory, PartRelated, PartParameter, PartParameterTemplate, PartSellPriceBreak, @@ -699,3 +702,138 @@ class PartCopyBOMSerializer(serializers.Serializer): skip_invalid=data.get('skip_invalid', False), include_inherited=data.get('include_inherited', False), ) + + +class BomExtractSerializer(serializers.Serializer): + """ + Serializer for uploading a file and extracting data from it. + + Note: 2022-02-04 - This needs a *serious* refactor in future, probably + + When parsing the file, the following things happen: + + a) Check file format and validity + b) Look for "required" fields + c) Look for "part" fields - used to "infer" part + + Once the file itself has been validated, we iterate through each data row: + + - If the "level" column is provided, ignore anything below level 1 + - Try to "guess" the part based on part_id / part_name / part_ipn + - Extract other fields as required + + """ + + bom_file = serializers.FileField( + label=_("BOM File"), + help_text=_("Select Bill of Materials file"), + required=True, + allow_empty_file=False, + ) + + def validate_bom_file(self, bom_file): + """ + Perform validation checks on the uploaded BOM file + """ + + name, ext = os.path.splitext(bom_file.name) + + # Remove the leading . from the extension + ext = ext[1:] + + accepted_file_types = [ + 'xls', 'xlsx', + 'csv', 'tsv', + 'xml', + ] + + if ext not in accepted_file_types: + raise serializers.ValidationError(_("Unsupported file type")) + + # Impose a 50MB limit on uploaded BOM files + max_upload_file_size = 50 * 1024 * 1024 + + if bom_file.size > max_upload_file_size: + raise serializers.ValidationError(_("File is too large")) + + # Read file data into memory (bytes object) + data = bom_file.read() + + if ext in ['csv', 'tsv', 'xml']: + data = data.decode() + + # Convert to a tablib dataset (we expect headers) + self.dataset = tablib.Dataset().load(data, ext, headers=True) + + # These columns must be present + required_columns = [ + 'quantity', + ] + + # We need at least one column to specify a "part" + part_columns = [ + 'part', + 'part_id', + 'part_name', + 'part_ipn', + ] + + # These columns are "optional" + optional_columns = [ + 'allow_variants', + 'inherited', + 'optional', + 'overage', + 'note', + 'reference', + ] + + def find_matching_column(col_name, columns): + + # Direct match + if col_name in columns: + return col_name + + col_name = col_name.lower().strip() + + for col in columns: + if col.lower().strip() == col_name: + return col + + # No match + return None + + for header in required_columns: + + match = find_matching_column(header, self.dataset.headers) + + if match is None: + raise serializers.ValidationError(_("Missing required column") + f": '{header}'") + + part_column_matches = {} + + part_match = False + + for col in part_columns: + col_match = find_matching_column(col, self.dataset.headers) + + part_column_matches[col] = col_match + + if col_match is not None: + part_match = True + + if not part_match: + raise serializers.ValidationError(_("No part column found")) + + return bom_file + + class Meta: + fields = [ + 'bom_file', + ] + + def save(self): + """ + There is no action associated with "saving" this serializer + """ + pass \ No newline at end of file