diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py index 42f49f9dde..f52b295235 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -3,14 +3,9 @@ Functionality for Bill of Material (BOM) management. Primarily BOM upload tools. """ -from rapidfuzz import fuzz -import tablib -import os - from collections import OrderedDict from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import ValidationError from InvenTree.helpers import DownloadFile, GetExportFormats @@ -326,174 +321,3 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt) return DownloadFile(data, filename) - - -class BomUploadManager: - """ Class for managing an uploaded BOM file """ - - # Fields which are absolutely necessary for valid upload - REQUIRED_HEADERS = [ - 'Quantity' - ] - - # Fields which are used for part matching (only one of them is needed) - PART_MATCH_HEADERS = [ - 'Part_Name', - 'Part_IPN', - 'Part_ID', - ] - - # Fields which would be helpful but are not required - OPTIONAL_HEADERS = [ - 'Reference', - 'Note', - 'Overage', - ] - - EDITABLE_HEADERS = [ - 'Reference', - 'Note', - 'Overage' - ] - - HEADERS = REQUIRED_HEADERS + PART_MATCH_HEADERS + OPTIONAL_HEADERS - - def __init__(self, bom_file): - """ Initialize the BomUpload class with a user-uploaded file object """ - - self.process(bom_file) - - def process(self, bom_file): - """ Process a BOM file """ - - self.data = None - - ext = os.path.splitext(bom_file.name)[-1].lower() - - if ext in ['.csv', '.tsv', ]: - # These file formats need string decoding - raw_data = bom_file.read().decode('utf-8') - elif ext in ['.xls', '.xlsx']: - raw_data = bom_file.read() - else: - raise ValidationError({'bom_file': _('Unsupported file format: {f}').format(f=ext)}) - - try: - self.data = tablib.Dataset().load(raw_data) - except tablib.UnsupportedFormat: - raise ValidationError({'bom_file': _('Error reading BOM file (invalid data)')}) - except tablib.core.InvalidDimensions: - raise ValidationError({'bom_file': _('Error reading BOM file (incorrect row size)')}) - - def guess_header(self, header, threshold=80): - """ Try to match a header (from the file) to a list of known headers - - Args: - header - Header name to look for - threshold - Match threshold for fuzzy search - """ - - # Try for an exact match - for h in self.HEADERS: - if h == header: - return h - - # Try for a case-insensitive match - for h in self.HEADERS: - if h.lower() == header.lower(): - return h - - # Try for a case-insensitive match with space replacement - for h in self.HEADERS: - if h.lower() == header.lower().replace(' ', '_'): - return h - - # Finally, look for a close match using fuzzy matching - matches = [] - - for h in self.HEADERS: - ratio = fuzz.partial_ratio(header, h) - if ratio > threshold: - matches.append({'header': h, 'match': ratio}) - - if len(matches) > 0: - matches = sorted(matches, key=lambda item: item['match'], reverse=True) - return matches[0]['header'] - - return None - - def columns(self): - """ Return a list of headers for the thingy """ - headers = [] - - for header in self.data.headers: - headers.append({ - 'name': header, - 'guess': self.guess_header(header) - }) - - return headers - - def col_count(self): - if self.data is None: - return 0 - - return len(self.data.headers) - - def row_count(self): - """ Return the number of rows in the file. """ - - if self.data is None: - return 0 - - return len(self.data) - - def rows(self): - """ Return a list of all rows """ - rows = [] - - for i in range(self.row_count()): - - data = [item for item in self.get_row_data(i)] - - # Is the row completely empty? Skip! - empty = True - - for idx, item in enumerate(data): - if len(str(item).strip()) > 0: - empty = False - - try: - # Excel import casts number-looking-items into floats, which is annoying - if item == int(item) and not str(item) == str(int(item)): - data[idx] = int(item) - except ValueError: - pass - - # Skip empty rows - if empty: - continue - - row = { - 'data': data, - 'index': i - } - - rows.append(row) - - return rows - - def get_row_data(self, index): - """ Retrieve row data at a particular index """ - if self.data is None or index >= len(self.data): - return None - - return self.data[index] - - def get_row_dict(self, index): - """ Retrieve a dict object representing the data row at a particular offset """ - - if self.data is None or index >= len(self.data): - return None - - return self.data.dict[index] diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 36a49006b0..3b60567695 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -11,10 +11,11 @@ from django.utils.translation import ugettext_lazy as _ from mptt.fields import TreeNodeChoiceField from InvenTree.forms import HelperForm -from InvenTree.helpers import GetExportFormats +from InvenTree.helpers import GetExportFormats, clean_decimal from InvenTree.fields import RoundingDecimalFormField import common.models +from common.forms import MatchItemForm from .models import Part, PartCategory, PartRelated from .models import BomItem @@ -143,16 +144,28 @@ class BomValidateForm(HelperForm): ] -class BomUploadSelectFile(HelperForm): - """ Form for importing a BOM. Provides a file input box for upload """ +class BomMatchItemForm(MatchItemForm): + """ Override MatchItemForm fields """ - bom_file = forms.FileField(label=_('BOM file'), required=True, help_text=_("Select BOM file to upload")) + def get_special_field(self, col_guess, row, file_manager): + """ Set special fields """ - class Meta: - model = Part - fields = [ - 'bom_file', - ] + # set quantity field + if 'quantity' in col_guess.lower(): + return forms.CharField( + required=False, + widget=forms.NumberInput(attrs={ + 'name': 'quantity' + str(row['index']), + 'class': 'numberinput', + 'type': 'number', + 'min': '0', + 'step': 'any', + 'value': clean_decimal(row.get('quantity', '')), + }) + ) + + # return default + return super().get_special_field(col_guess, row, file_manager) class CreatePartRelatedForm(HelperForm): diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index ba3e9be2b9..048b98fc01 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -11,6 +11,12 @@ {% endblock %} {% block details %} + +{% if roles.part.change != True and editing_enabled %} +
+ {% trans "You do not have permission to edit the BOM." %} +
+{% else %} {% if part.bom_checked_date %} {% if part.is_bom_valid %}
@@ -72,6 +78,7 @@
+{% endif %} {% endblock %} diff --git a/InvenTree/part/templates/part/bom_upload/match_fields.html b/InvenTree/part/templates/part/bom_upload/match_fields.html new file mode 100644 index 0000000000..d1f325aaee --- /dev/null +++ b/InvenTree/part/templates/part/bom_upload/match_fields.html @@ -0,0 +1,99 @@ +{% extends "part/bom_upload/upload_file.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block form_alert %} +{% if missing_columns and missing_columns|length > 0 %} + +{% endif %} +{% if duplicates and duplicates|length > 0 %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + {% trans "File Fields" %} + + {% for col in form %} + +
+ + {{ col.name }} + +
+ + {% endfor %} + + + + + {% trans "Match Fields" %} + + {% for col in form %} + + {{ col }} + {% for duplicate in duplicates %} + {% if duplicate == col.value %} + + {% endif %} + {% endfor %} + + {% endfor %} + + {% for row in rows %} + {% with forloop.counter as row_index %} + + + + + {{ row_index }} + {% for item in row.data %} + + + {{ item }} + + {% endfor %} + + {% endwith %} + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.fieldselect').select2({ + width: '100%', + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/match_parts.html b/InvenTree/part/templates/part/bom_upload/match_parts.html new file mode 100644 index 0000000000..078ae8122f --- /dev/null +++ b/InvenTree/part/templates/part/bom_upload/match_parts.html @@ -0,0 +1,127 @@ +{% extends "part/bom_upload/upload_file.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} +{% load crispy_forms_tags %} + +{% block form_alert %} +{% if form.errors %} +{% endif %} +{% if form_errors %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + + {% trans "Row" %} + {% trans "Select Part" %} + {% trans "Reference" %} + {% trans "Quantity" %} + {% for col in columns %} + {% if col.guess != 'Quantity' %} + + + + {% if col.guess %} + {{ col.guess }} + {% else %} + {{ col.name }} + {% endif %} + + {% endif %} + {% endfor %} + + + + {% comment %} Dummy row for javascript del_row method {% endcomment %} + {% for row in rows %} + + + + + + {% add row.index 1 %} + + + {% for field in form.visible_fields %} + {% if field.name == row.item_select %} + {{ field }} + {% endif %} + {% endfor %} + {% if row.errors.part %} +

{{ row.errors.part }}

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

{{ row.errors.reference }}

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

{{ row.errors.quantity }}

+ {% endif %} + + {% for item in row.data %} + {% if item.column.guess != 'Quantity' %} + + {% if item.column.guess == 'Overage' %} + {% for field in form.visible_fields %} + {% if field.name == row.overage %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% elif item.column.guess == 'Note' %} + {% for field in form.visible_fields %} + {% if field.name == row.note %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% else %} + {{ item.cell }} + {% endif %} + + + {% endif %} + {% endfor %} + + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.bomselect').select2({ + dropdownAutoWidth: true, + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/select_fields.html b/InvenTree/part/templates/part/bom_upload/select_fields.html deleted file mode 100644 index e82223da88..0000000000 --- a/InvenTree/part/templates/part/bom_upload/select_fields.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} -{% load inventree_extras %} - -{% block menubar %} -{% include "part/navbar.html" with tab='bom' %} -{% endblock %} - -{% block heading %} -{% trans "Upload Bill of Materials" %} -{% endblock %} - -{% block details %} - -

{% trans "Step 2 - Select Fields" %}

-
- -{% if missing_columns and missing_columns|length > 0 %} - -{% endif %} - -
- - {% csrf_token %} - - - - - - - - - {% for col in bom_columns %} - - {% endfor %} - - - - - - - {% for col in bom_columns %} - - {% endfor %} - - {% for row in bom_rows %} - - - - {% for item in row.data %} - - {% endfor %} - - {% endfor %} - -
{% trans "File Fields" %} -
- - {{ col.name }} - -
-
{% trans "Match Fields" %} - - {% if col.duplicate %} -

{% trans "Duplicate column selection" %}

- {% endif %} -
- - {{ forloop.counter }} - - {{ item.cell }} -
- -
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/select_parts.html b/InvenTree/part/templates/part/bom_upload/select_parts.html deleted file mode 100644 index 41530e3c55..0000000000 --- a/InvenTree/part/templates/part/bom_upload/select_parts.html +++ /dev/null @@ -1,121 +0,0 @@ -{% extends "part/part_base.html" %} -{% load static %} -{% load i18n %} -{% load inventree_extras %} - -{% block menubar %} -{% include "part/navbar.html" with tab="bom" %} -{% endblock %} - -{% block heading %} -{% trans "Upload Bill of Materials" %} -{% endblock %} - -{% block details %} - -

{% trans "Step 3 - Select Parts" %}

-
- -{% if form_errors %} - -{% endif %} - -
- - - - {% csrf_token %} - {% load crispy_forms_tags %} - - - - - - - - - - - {% for col in bom_columns %} - - {% endfor %} - - - - {% for row in bom_rows %} - - - - - - {% for item in row.data %} - - {% endfor %} - - {% endfor %} - -
{% trans "Row" %}{% trans "Select Part" %} - - - {% if col.guess %} - {{ col.guess }} - {% else %} - {{ col.name }} - {% endif %} -
- - {% add row.index 1 %} - - - {% if row.errors.part %} -

{{ row.errors.part }}

- {% endif %} -
- {% if item.column.guess == 'Part' %} - {{ item.cell }} - {% if row.errors.part %} -

{{ row.errors.part }}

- {% endif %} - {% elif item.column.guess == 'Quantity' %} - - {% if row.errors.quantity %} -

{{ row.errors.quantity }}

- {% endif %} - {% elif item.column.guess == 'Reference' %} - - {% elif item.column.guess == 'Note' %} - - {% elif item.column.guess == 'Overage' %} - - {% else %} - {{ item.cell }} - {% endif %} - -
- -
- -{% endblock %} - -{% block js_ready %} -{{ block.super }} - -$('.bomselect').select2({ - dropdownAutoWidth: true, - matcher: partialMatcher, -}); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/upload_file.html b/InvenTree/part/templates/part/bom_upload/upload_file.html index 148d32f5da..88592c5ffc 100644 --- a/InvenTree/part/templates/part/bom_upload/upload_file.html +++ b/InvenTree/part/templates/part/bom_upload/upload_file.html @@ -8,13 +8,12 @@ {% endblock %} {% block heading %} -{% trans "Upload Bill of Materials" %} +{% trans "Upload BOM File" %} {% endblock %} {% block details %} -

{% trans "Step 1 - Select BOM File" %}

- +{% block form_alert %}
{% trans "Requirements for BOM upload" %}:
+{% endblock %} -
- - {% csrf_token %} - {% load crispy_forms_tags %} +

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

- + +{% csrf_token %} +{% load crispy_forms_tags %} - {% crispy form %} +{% block form_buttons_top %} +{% endblock form_buttons_top %} + +{{ wizard.management_form }} +{% block form_content %} +{% crispy wizard.form %} +{% endblock form_content %} +
+ +{% block form_buttons_bottom %} +{% if wizard.steps.prev %} + +{% endif %} +
+{% endblock form_buttons_bottom %} {% endblock %} \ No newline at end of file diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index d9f79262d1..343b2a6310 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -13,7 +13,7 @@ from django.shortcuts import get_object_or_404 from django.shortcuts import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ from django.urls import reverse, reverse_lazy -from django.views.generic import DetailView, ListView, FormView, UpdateView +from django.views.generic import DetailView, ListView, UpdateView from django.forms.models import model_to_dict from django.forms import HiddenInput, CheckboxInput from django.conf import settings @@ -42,13 +42,14 @@ from common.models import InvenTreeSetting from company.models import SupplierPart from common.files import FileManager from common.views import FileManagementFormView, FileManagementAjaxView +from common.forms import UploadFileForm, MatchFieldForm from stock.models import StockLocation import common.settings as inventree_settings from . import forms as part_forms -from .bom import MakeBomTemplate, BomUploadManager, ExportBom, IsValidBOMFormat +from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat from order.models import PurchaseOrderLineItem from .admin import PartResource @@ -1245,7 +1246,7 @@ class BomValidate(AjaxUpdateView): } -class BomUpload(InvenTreeRoleMixin, FormView): +class BomUpload(InvenTreeRoleMixin, FileManagementFormView): """ View for uploading a BOM file, and handling BOM data importing. The BOM upload process is as follows: @@ -1272,184 +1273,116 @@ class BomUpload(InvenTreeRoleMixin, FormView): During these steps, data are passed between the server/client as JSON objects. """ - template_name = 'part/bom_upload/upload_file.html' - - # Context data passed to the forms (initially empty, extracted from uploaded file) - bom_headers = [] - bom_columns = [] - bom_rows = [] - missing_columns = [] - allowed_parts = [] - role_required = ('part.change', 'part.add') - def get_success_url(self): - part = self.get_object() - return reverse('upload-bom', kwargs={'pk': part.id}) + class BomFileManager(FileManager): + # Fields which are absolutely necessary for valid upload + REQUIRED_HEADERS = [ + 'Quantity' + ] - def get_form_class(self): + # Fields which are used for part matching (only one of them is needed) + ITEM_MATCH_HEADERS = [ + 'Part_Name', + 'Part_IPN', + 'Part_ID', + ] - # Default form is the starting point - return part_forms.BomUploadSelectFile + # Fields which would be helpful but are not required + OPTIONAL_HEADERS = [ + 'Reference', + 'Note', + 'Overage', + ] - def get_context_data(self, *args, **kwargs): + EDITABLE_HEADERS = [ + 'Reference', + 'Note', + 'Overage' + ] - ctx = super().get_context_data(*args, **kwargs) + name = 'order' + form_list = [ + ('upload', UploadFileForm), + ('fields', MatchFieldForm), + ('items', part_forms.BomMatchItemForm), + ] + form_steps_template = [ + 'part/bom_upload/upload_file.html', + 'part/bom_upload/match_fields.html', + 'part/bom_upload/match_parts.html', + ] + form_steps_description = [ + _("Upload File"), + _("Match Fields"), + _("Match Parts"), + ] + form_field_map = { + 'item_select': 'part', + 'quantity': 'quantity', + 'overage': 'overage', + 'reference': 'reference', + 'note': 'note', + } + file_manager_class = BomFileManager - # Give each row item access to the column it is in - # This provides for much simpler template rendering + def get_part(self): + """ Get part or return 404 """ - rows = [] - for row in self.bom_rows: - row_data = row['data'] + return get_object_or_404(Part, pk=self.kwargs['pk']) - data = [] + def get_context_data(self, form, **kwargs): + """ Handle context data for order """ - for idx, item in enumerate(row_data): + context = super().get_context_data(form=form, **kwargs) - data.append({ - 'cell': item, - 'idx': idx, - 'column': self.bom_columns[idx] - }) + part = self.get_part() - rows.append({ - 'index': row.get('index', -1), - 'data': data, - 'part_match': row.get('part_match', None), - 'part_options': row.get('part_options', self.allowed_parts), + context.update({'part': part}) - # User-input (passed between client and server) - 'quantity': row.get('quantity', None), - 'description': row.get('description', ''), - 'part_name': row.get('part_name', ''), - 'part': row.get('part', None), - 'reference': row.get('reference', ''), - 'notes': row.get('notes', ''), - 'errors': row.get('errors', ''), - }) + return context - ctx['part'] = self.part - ctx['bom_headers'] = BomUploadManager.HEADERS - ctx['bom_columns'] = self.bom_columns - ctx['bom_rows'] = rows - ctx['missing_columns'] = self.missing_columns - ctx['allowed_parts_list'] = self.allowed_parts - - return ctx - - def getAllowedParts(self): + def get_allowed_parts(self): """ Return a queryset of parts which are allowed to be added to this BOM. """ - return self.part.get_allowed_bom_items() + return self.get_part().get_allowed_bom_items() - def get(self, request, *args, **kwargs): - """ Perform the initial 'GET' request. - - Initially returns a form for file upload """ - - self.request = request - - # A valid Part object must be supplied. This is the 'parent' part for the BOM - self.part = get_object_or_404(Part, pk=self.kwargs['pk']) - - self.form = self.get_form() - - form_class = self.get_form_class() - form = self.get_form(form_class) - return self.render_to_response(self.get_context_data(form=form)) - - def handleBomFileUpload(self): - """ Process a BOM file upload form. - - This function validates that the uploaded file was valid, - and contains tabulated data that can be extracted. - If the file does not satisfy these requirements, - the "upload file" form is again shown to the user. - """ - - bom_file = self.request.FILES.get('bom_file', None) - - manager = None - bom_file_valid = False - - if bom_file is None: - self.form.add_error('bom_file', _('No BOM file provided')) - else: - # Create a BomUploadManager object - will perform initial data validation - # (and raise a ValidationError if there is something wrong with the file) - try: - manager = BomUploadManager(bom_file) - bom_file_valid = True - except ValidationError as e: - errors = e.error_dict - - for k, v in errors.items(): - self.form.add_error(k, v) - - if bom_file_valid: - # BOM file is valid? Proceed to the next step! - form = None - self.template_name = 'part/bom_upload/select_fields.html' - - self.extractDataFromFile(manager) - else: - form = self.form - - return self.render_to_response(self.get_context_data(form=form)) - - def getColumnIndex(self, name): - """ Return the index of the column with the given name. - It named column is not found, return -1 - """ - - try: - idx = list(self.column_selections.values()).index(name) - except ValueError: - idx = -1 - - return idx - - def preFillSelections(self): + def get_field_selection(self): """ Once data columns have been selected, attempt to pre-select the proper data from the database. This function is called once the field selection has been validated. The pre-fill data are then passed through to the part selection form. """ + self.allowed_items = self.get_allowed_parts() + # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database - k_idx = self.getColumnIndex('Part_ID') - p_idx = self.getColumnIndex('Part_Name') - i_idx = self.getColumnIndex('Part_IPN') + k_idx = self.get_column_index('Part_ID') + p_idx = self.get_column_index('Part_Name') + i_idx = self.get_column_index('Part_IPN') - q_idx = self.getColumnIndex('Quantity') - r_idx = self.getColumnIndex('Reference') - o_idx = self.getColumnIndex('Overage') - n_idx = self.getColumnIndex('Note') + q_idx = self.get_column_index('Quantity') + r_idx = self.get_column_index('Reference') + o_idx = self.get_column_index('Overage') + n_idx = self.get_column_index('Note') - for row in self.bom_rows: + for row in self.rows: """ - Iterate through each row in the uploaded data, and see if we can match the row to a "Part" object in the database. - There are three potential ways to match, based on the uploaded data: - a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field c) Use the name of the part, uploaded in the "Part_Name" field - Notes: - If using the Part_ID field, we can do an exact match against the PK field - If using the Part_IPN field, we can do an exact match against the IPN field - If using the Part_Name field, we can use fuzzy string matching to match "close" values - We also extract other information from the row, for the other non-matched fields: - Quantity - Reference - Overage - Note - """ # Initially use a quantity of zero @@ -1459,42 +1392,55 @@ class BomUpload(InvenTreeRoleMixin, FormView): exact_match_part = None # A list of potential Part matches - part_options = self.allowed_parts + part_options = self.allowed_items # Check if there is a column corresponding to "quantity" if q_idx >= 0: - q_val = row['data'][q_idx] + q_val = row['data'][q_idx]['cell'] if q_val: + # Delete commas + q_val = q_val.replace(',', '') + try: # Attempt to extract a valid quantity from the field quantity = Decimal(q_val) + # Store the 'quantity' value + row['quantity'] = quantity except (ValueError, InvalidOperation): pass - # Store the 'quantity' value - row['quantity'] = quantity - # Check if there is a column corresponding to "PK" if k_idx >= 0: - pk = row['data'][k_idx] + pk = row['data'][k_idx]['cell'] if pk: try: # Attempt Part lookup based on PK value - exact_match_part = Part.objects.get(pk=pk) + exact_match_part = self.allowed_items.get(pk=pk) except (ValueError, Part.DoesNotExist): exact_match_part = None - # Check if there is a column corresponding to "Part Name" - if p_idx >= 0: - part_name = row['data'][p_idx] + # Check if there is a column corresponding to "Part IPN" and no exact match found yet + if i_idx >= 0 and not exact_match_part: + part_ipn = row['data'][i_idx]['cell'] + + if part_ipn: + part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())] + + # Check for single match + if len(part_matches) == 1: + exact_match_part = part_matches[0] + + # Check if there is a column corresponding to "Part Name" and no exact match found yet + if p_idx >= 0 and not exact_match_part: + part_name = row['data'][p_idx]['cell'] row['part_name'] = part_name matches = [] - for part in self.allowed_parts: + for part in self.allowed_items: ratio = fuzz.partial_ratio(part.name + part.description, part_name) matches.append({'part': part, 'match': ratio}) @@ -1503,390 +1449,67 @@ class BomUpload(InvenTreeRoleMixin, FormView): matches = sorted(matches, key=lambda item: item['match'], reverse=True) part_options = [m['part'] for m in matches] + + # Supply list of part options for each row, sorted by how closely they match the part name + row['item_options'] = part_options - # Check if there is a column corresponding to "Part IPN" - if i_idx >= 0: - row['part_ipn'] = row['data'][i_idx] + # Unless found, the 'item_match' is blank + row['item_match'] = None + + if exact_match_part: + # If there is an exact match based on PK or IPN, use that + row['item_match'] = exact_match_part # Check if there is a column corresponding to "Overage" field if o_idx >= 0: - row['overage'] = row['data'][o_idx] + row['overage'] = row['data'][o_idx]['cell'] # Check if there is a column corresponding to "Reference" field if r_idx >= 0: - row['reference'] = row['data'][r_idx] + row['reference'] = row['data'][r_idx]['cell'] # Check if there is a column corresponding to "Note" field if n_idx >= 0: - row['note'] = row['data'][n_idx] - - # Supply list of part options for each row, sorted by how closely they match the part name - row['part_options'] = part_options - - # Unless found, the 'part_match' is blank - row['part_match'] = None - - if exact_match_part: - # If there is an exact match based on PK, use that - row['part_match'] = exact_match_part - else: - # Otherwise, check to see if there is a matching IPN - try: - if row['part_ipn']: - part_matches = [part for part in self.allowed_parts if part.IPN and row['part_ipn'].lower() == str(part.IPN.lower())] - - # Check for single match - if len(part_matches) == 1: - row['part_match'] = part_matches[0] - - continue - except KeyError: - pass - - def extractDataFromFile(self, bom): - """ Read data from the BOM file """ - - self.bom_columns = bom.columns() - self.bom_rows = bom.rows() - - def getTableDataFromPost(self): - """ Extract table cell data from POST request. - These data are used to maintain state between sessions. - - Table data keys are as follows: - - col_name_ - Column name at idx as provided in the uploaded file - col_guess_ - Column guess at idx as selected in the BOM - row__col - Cell data as provided in the uploaded file - - """ - - # Map the columns - self.column_names = {} - self.column_selections = {} - - self.row_data = {} - - for item in self.request.POST: - value = self.request.POST[item] - - # Column names as passed as col_name_ where idx is an integer - - # Extract the column names - if item.startswith('col_name_'): - try: - col_id = int(item.replace('col_name_', '')) - except ValueError: - continue - col_name = value - - self.column_names[col_id] = col_name - - # Extract the column selections (in the 'select fields' view) - if item.startswith('col_guess_'): - - try: - col_id = int(item.replace('col_guess_', '')) - except ValueError: - continue - - col_name = value - - self.column_selections[col_id] = value - - # Extract the row data - if item.startswith('row_'): - # Item should be of the format row__col_ - s = item.split('_') - - if len(s) < 4: - continue - - # Ignore row/col IDs which are not correct numeric values - try: - row_id = int(s[1]) - col_id = int(s[3]) - except ValueError: - continue - - if row_id not in self.row_data: - self.row_data[row_id] = {} - - self.row_data[row_id][col_id] = value - - self.col_ids = sorted(self.column_names.keys()) - - # Re-construct the data table - self.bom_rows = [] - - for row_idx in sorted(self.row_data.keys()): - row = self.row_data[row_idx] - items = [] - - for col_idx in sorted(row.keys()): - - value = row[col_idx] - items.append(value) - - self.bom_rows.append({ - 'index': row_idx, - 'data': items, - 'errors': {}, - }) - - # Construct the column data - self.bom_columns = [] - - # Track any duplicate column selections - self.duplicates = False - - for col in self.col_ids: - - if col in self.column_selections: - guess = self.column_selections[col] - else: - guess = None - - header = ({ - 'name': self.column_names[col], - 'guess': guess - }) - - if guess: - n = list(self.column_selections.values()).count(self.column_selections[col]) - if n > 1: - header['duplicate'] = True - self.duplicates = True - - self.bom_columns.append(header) - - # Are there any missing columns? - self.missing_columns = [] - - # Check that all required fields are present - for col in BomUploadManager.REQUIRED_HEADERS: - if col not in self.column_selections.values(): - self.missing_columns.append(col) - - # Check that at least one of the part match field is present - part_match_found = False - for col in BomUploadManager.PART_MATCH_HEADERS: - if col in self.column_selections.values(): - part_match_found = True - break - - # If not, notify user - if not part_match_found: - for col in BomUploadManager.PART_MATCH_HEADERS: - self.missing_columns.append(col) - - def handleFieldSelection(self): - """ Handle the output of the field selection form. - Here the user is presented with the raw data and must select the - column names and which rows to process. - """ - - # Extract POST data - self.getTableDataFromPost() - - valid = len(self.missing_columns) == 0 and not self.duplicates - - if valid: - # Try to extract meaningful data - self.preFillSelections() - self.template_name = 'part/bom_upload/select_parts.html' - else: - self.template_name = 'part/bom_upload/select_fields.html' - - return self.render_to_response(self.get_context_data(form=None)) - - def handlePartSelection(self): - - # Extract basic table data from POST request - self.getTableDataFromPost() - - # Keep track of the parts that have been selected - parts = {} - - # Extract other data (part selections, etc) - for key in self.request.POST: - value = self.request.POST[key] - - # Extract quantity from each row - if key.startswith('quantity_'): - try: - row_id = int(key.replace('quantity_', '')) - - row = self.getRowByIndex(row_id) - - if row is None: - continue - - q = Decimal(1) - - try: - q = Decimal(value) - if q < 0: - row['errors']['quantity'] = _('Quantity must be greater than zero') - - if 'part' in row.keys(): - if row['part'].trackable: - # Trackable parts must use integer quantities - if not q == int(q): - row['errors']['quantity'] = _('Quantity must be integer value for trackable parts') - - except (ValueError, InvalidOperation): - row['errors']['quantity'] = _('Enter a valid quantity') - - row['quantity'] = q - - except ValueError: - continue - - # Extract part from each row - if key.startswith('part_'): - - try: - row_id = int(key.replace('part_', '')) - - row = self.getRowByIndex(row_id) - - if row is None: - continue - except ValueError: - # Row ID non integer value - continue - - try: - part_id = int(value) - part = Part.objects.get(id=part_id) - except ValueError: - row['errors']['part'] = _('Select valid part') - continue - except Part.DoesNotExist: - row['errors']['part'] = _('Select valid part') - continue - - # Keep track of how many of each part we have seen - if part_id in parts: - parts[part_id]['quantity'] += 1 - row['errors']['part'] = _('Duplicate part selected') - else: - parts[part_id] = { - 'part': part, - 'quantity': 1, - } - - row['part'] = part - - if part.trackable: - # For trackable parts, ensure the quantity is an integer value! - if 'quantity' in row.keys(): - q = row['quantity'] - - if not q == int(q): - row['errors']['quantity'] = _('Quantity must be integer value for trackable parts') - - # Extract other fields which do not require further validation - for field in ['reference', 'notes']: - if key.startswith(field + '_'): - try: - row_id = int(key.replace(field + '_', '')) - - row = self.getRowByIndex(row_id) - - if row: - row[field] = value - except: - continue - - # Are there any errors after form handling? - valid = True - - for row in self.bom_rows: - # Has a part been selected for the given row? - part = row.get('part', None) - - if part is None: - row['errors']['part'] = _('Select a part') - else: - # Will the selected part result in a recursive BOM? - try: - part.checkAddToBOM(self.part) - except ValidationError: - row['errors']['part'] = _('Selected part creates a circular BOM') - - # Has a quantity been specified? - if row.get('quantity', None) is None: - row['errors']['quantity'] = _('Specify quantity') - - errors = row.get('errors', []) - - if len(errors) > 0: - valid = False - - self.template_name = 'part/bom_upload/select_parts.html' - - ctx = self.get_context_data(form=None) - - if valid: - self.part.clear_bom() - - # Generate new BOM items - for row in self.bom_rows: - part = row.get('part') - quantity = row.get('quantity') - reference = row.get('reference', '') - notes = row.get('notes', '') - - # Create a new BOM item! - item = BomItem( - part=self.part, - sub_part=part, - quantity=quantity, - reference=reference, - note=notes - ) - + row['note'] = row['data'][n_idx]['cell'] + + def done(self, form_list, **kwargs): + """ Once all the data is in, process it to add BomItem instances to the part """ + + self.part = self.get_part() + items = self.get_clean_items() + + # Clear BOM + self.part.clear_bom() + + # Generate new BOM items + for bom_item in items.values(): + try: + part = Part.objects.get(pk=int(bom_item.get('part'))) + except (ValueError, Part.DoesNotExist): + continue + + quantity = bom_item.get('quantity') + overage = bom_item.get('overage', '') + reference = bom_item.get('reference', '') + note = bom_item.get('note', '') + + # Create a new BOM item + item = BomItem( + part=self.part, + sub_part=part, + quantity=quantity, + overage=overage, + reference=reference, + note=note, + ) + + try: item.save() + except IntegrityError: + # BomItem already exists + pass - # Redirect to the BOM view - return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.part.id})) - else: - ctx['form_errors'] = True - - return self.render_to_response(ctx) - - def getRowByIndex(self, idx): - - for row in self.bom_rows: - if row['index'] == idx: - return row - - return None - - def post(self, request, *args, **kwargs): - """ Perform the various 'POST' requests required. - """ - - self.request = request - - self.part = get_object_or_404(Part, pk=self.kwargs['pk']) - self.allowed_parts = self.getAllowedParts() - self.form = self.get_form(self.get_form_class()) - - # Did the user POST a file named bom_file? - - form_step = request.POST.get('form_step', None) - - if form_step == 'select_file': - return self.handleBomFileUpload() - elif form_step == 'select_fields': - return self.handleFieldSelection() - elif form_step == 'select_parts': - return self.handlePartSelection() - - return self.render_to_response(self.get_context_data(form=self.form)) + return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.kwargs['pk']})) class PartExport(AjaxView):