-
-
-{% if missing_columns and missing_columns|length > 0 %}
-
- {% trans "Missing selections for the following required columns" %}:
-
-
- {% for col in missing_columns %}
-
{{ col }}
- {% endfor %}
-
-
-{% endif %}
-
-
-
-{% 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 %}
-
- {% trans "Errors exist in the submitted data" %}
-
-{% 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" %}:
@@ -22,16 +21,31 @@
{% trans "Each part must already exist in the database" %}
+{% endblock %}
-
+{% 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):