diff --git a/InvenTree/part/templates/part/bom_upload/match_fields.html b/InvenTree/part/templates/part/bom_upload/match_fields.html deleted file mode 100644 index b09260cf46..0000000000 --- a/InvenTree/part/templates/part/bom_upload/match_fields.html +++ /dev/null @@ -1,99 +0,0 @@ -{% extends "part/bom_upload/upload_file.html" %} -{% load inventree_extras %} -{% load i18n %} -{% load static %} - -{% block form_alert %} -{% if missing_columns and missing_columns|length > 0 %} - -{% endif %} -{% if duplicates and duplicates|length > 0 %} - -{% endif %} -{% endblock form_alert %} - -{% block form_buttons_top %} - {% if wizard.steps.prev %} - - {% endif %} - -{% endblock form_buttons_top %} - -{% block form_content %} - - - {% trans "File Fields" %} - - {% for col in form %} - -
- - {{ col.name }} - -
- - {% endfor %} - - - - - {% trans "Match Fields" %} - - {% for col in form %} - - {{ col }} - {% for duplicate in duplicates %} - {% if duplicate == col.value %} - - {% endif %} - {% endfor %} - - {% endfor %} - - {% for row in rows %} - {% with forloop.counter as row_index %} - - - - - {{ row_index }} - {% for item in row.data %} - - - {{ item }} - - {% endfor %} - - {% endwith %} - {% endfor %} - -{% endblock form_content %} - -{% block form_buttons_bottom %} -{% endblock form_buttons_bottom %} - -{% block js_ready %} -{{ block.super }} - -$('.fieldselect').select2({ - width: '100%', - matcher: partialMatcher, -}); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/match_parts.html b/InvenTree/part/templates/part/bom_upload/match_parts.html deleted file mode 100644 index 0345fa309e..0000000000 --- a/InvenTree/part/templates/part/bom_upload/match_parts.html +++ /dev/null @@ -1,127 +0,0 @@ -{% extends "part/bom_upload/upload_file.html" %} -{% load inventree_extras %} -{% load i18n %} -{% load static %} -{% load crispy_forms_tags %} - -{% block form_alert %} -{% if form.errors %} -{% endif %} -{% if form_errors %} - -{% endif %} -{% endblock form_alert %} - -{% block form_buttons_top %} - {% if wizard.steps.prev %} - - {% endif %} - -{% endblock form_buttons_top %} - -{% block form_content %} - - - - {% trans "Row" %} - {% trans "Select Part" %} - {% trans "Reference" %} - {% trans "Quantity" %} - {% for col in columns %} - {% if col.guess != 'Quantity' %} - - - - {% if col.guess %} - {{ col.guess }} - {% else %} - {{ col.name }} - {% endif %} - - {% endif %} - {% endfor %} - - - - {% comment %} Dummy row for javascript del_row method {% endcomment %} - {% for row in rows %} - - - - - - {% add row.index 1 %} - - - {% for field in form.visible_fields %} - {% if field.name == row.item_select %} - {{ field }} - {% endif %} - {% endfor %} - {% if row.errors.part %} -

{{ row.errors.part }}

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

{{ row.errors.reference }}

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

{{ row.errors.quantity }}

- {% endif %} - - {% for item in row.data %} - {% if item.column.guess != 'Quantity' %} - - {% if item.column.guess == 'Overage' %} - {% for field in form.visible_fields %} - {% if field.name == row.overage %} - {{ field|as_crispy_field }} - {% endif %} - {% endfor %} - {% elif item.column.guess == 'Note' %} - {% for field in form.visible_fields %} - {% if field.name == row.note %} - {{ field|as_crispy_field }} - {% endif %} - {% endfor %} - {% else %} - {{ item.cell }} - {% endif %} - - - {% endif %} - {% endfor %} - - {% endfor %} - -{% endblock form_content %} - -{% block form_buttons_bottom %} -{% endblock form_buttons_bottom %} - -{% block js_ready %} -{{ block.super }} - -$('.bomselect').select2({ - dropdownAutoWidth: true, - matcher: partialMatcher, -}); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/upload_file.html b/InvenTree/part/templates/part/upload_bom.html similarity index 100% rename from InvenTree/part/templates/part/bom_upload/upload_file.html rename to InvenTree/part/templates/part/upload_bom.html diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 03465a1838..19e72ea069 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -704,270 +704,12 @@ class PartImageSelect(AjaxUpdateView): return self.renderJsonResponse(request, form, data) -class BomUpload(InvenTreeRoleMixin, FileManagementFormView): - """ View for uploading a BOM file, and handling BOM data importing. +class BomUpload(InvenTreeRoleMixin, DetailView): + """ View for uploading a BOM file, and handling BOM data importing. """ - The BOM upload process is as follows: - - 1. (Client) Select and upload BOM file - 2. (Server) Verify that supplied file is a file compatible with tablib library - 3. (Server) Introspect data file, try to find sensible columns / values / etc - 4. (Server) Send suggestions back to the client - 5. (Client) Makes choices based on suggestions: - - Accept automatic matching to parts found in database - - Accept suggestions for 'partial' or 'fuzzy' matches - - Create new parts in case of parts not being available - 6. (Client) Sends updated dataset back to server - 7. (Server) Check POST data for validity, sanity checking, etc. - 8. (Server) Respond to POST request - - If data are valid, proceed to 9. - - If data not valid, return to 4. - 9. (Server) Send confirmation form to user - - Display the actions which will occur - - Provide final "CONFIRM" button - 10. (Client) Confirm final changes - 11. (Server) Apply changes to database, update BOM items. - - During these steps, data are passed between the server/client as JSON objects. - """ - - role_required = ('part.change', 'part.add') - - class BomFileManager(FileManager): - # Fields which are absolutely necessary for valid upload - REQUIRED_HEADERS = [ - 'Quantity' - ] - - # Fields which are used for part matching (only one of them is needed) - ITEM_MATCH_HEADERS = [ - 'Part_Name', - 'Part_IPN', - 'Part_ID', - ] - - # Fields which would be helpful but are not required - OPTIONAL_HEADERS = [ - 'Reference', - 'Note', - 'Overage', - ] - - EDITABLE_HEADERS = [ - 'Reference', - 'Note', - 'Overage' - ] - - name = 'order' - form_list = [ - ('upload', UploadFileForm), - ('fields', MatchFieldForm), - ('items', part_forms.BomMatchItemForm), - ] - form_steps_template = [ - 'part/bom_upload/upload_file.html', - 'part/bom_upload/match_fields.html', - 'part/bom_upload/match_parts.html', - ] - form_steps_description = [ - _("Upload File"), - _("Match Fields"), - _("Match Parts"), - ] - form_field_map = { - 'item_select': 'part', - 'quantity': 'quantity', - 'overage': 'overage', - 'reference': 'reference', - 'note': 'note', - } - file_manager_class = BomFileManager - - def get_part(self): - """ Get part or return 404 """ - - return get_object_or_404(Part, pk=self.kwargs['pk']) - - def get_context_data(self, form, **kwargs): - """ Handle context data for order """ - - context = super().get_context_data(form=form, **kwargs) - - part = self.get_part() - - context.update({'part': part}) - - return context - - def get_allowed_parts(self): - """ Return a queryset of parts which are allowed to be added to this BOM. - """ - - return self.get_part().get_allowed_bom_items() - - def get_field_selection(self): - """ Once data columns have been selected, attempt to pre-select the proper data from the database. - This function is called once the field selection has been validated. - The pre-fill data are then passed through to the part selection form. - """ - - self.allowed_items = self.get_allowed_parts() - - # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database - k_idx = self.get_column_index('Part_ID') - p_idx = self.get_column_index('Part_Name') - i_idx = self.get_column_index('Part_IPN') - - q_idx = self.get_column_index('Quantity') - r_idx = self.get_column_index('Reference') - o_idx = self.get_column_index('Overage') - n_idx = self.get_column_index('Note') - - for row in self.rows: - """ - Iterate through each row in the uploaded data, - and see if we can match the row to a "Part" object in the database. - There are three potential ways to match, based on the uploaded data: - a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field - b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field - c) Use the name of the part, uploaded in the "Part_Name" field - Notes: - - If using the Part_ID field, we can do an exact match against the PK field - - If using the Part_IPN field, we can do an exact match against the IPN field - - If using the Part_Name field, we can use fuzzy string matching to match "close" values - We also extract other information from the row, for the other non-matched fields: - - Quantity - - Reference - - Overage - - Note - """ - - # Initially use a quantity of zero - quantity = Decimal(0) - - # Initially we do not have a part to reference - exact_match_part = None - - # A list of potential Part matches - part_options = self.allowed_items - - # Check if there is a column corresponding to "quantity" - if q_idx >= 0: - q_val = row['data'][q_idx]['cell'] - - if q_val: - # Delete commas - q_val = q_val.replace(',', '') - - try: - # Attempt to extract a valid quantity from the field - quantity = Decimal(q_val) - # Store the 'quantity' value - row['quantity'] = quantity - except (ValueError, InvalidOperation): - pass - - # Check if there is a column corresponding to "PK" - if k_idx >= 0: - pk = row['data'][k_idx]['cell'] - - if pk: - try: - # Attempt Part lookup based on PK value - exact_match_part = self.allowed_items.get(pk=pk) - except (ValueError, Part.DoesNotExist): - exact_match_part = None - - # Check if there is a column corresponding to "Part IPN" and no exact match found yet - if i_idx >= 0 and not exact_match_part: - part_ipn = row['data'][i_idx]['cell'] - - if part_ipn: - part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())] - - # Check for single match - if len(part_matches) == 1: - exact_match_part = part_matches[0] - - # Check if there is a column corresponding to "Part Name" and no exact match found yet - if p_idx >= 0 and not exact_match_part: - part_name = row['data'][p_idx]['cell'] - - row['part_name'] = part_name - - matches = [] - - for part in self.allowed_items: - ratio = fuzz.partial_ratio(part.name + part.description, part_name) - matches.append({'part': part, 'match': ratio}) - - # Sort matches by the 'strength' of the match ratio - if len(matches) > 0: - matches = sorted(matches, key=lambda item: item['match'], reverse=True) - - part_options = [m['part'] for m in matches] - - # Supply list of part options for each row, sorted by how closely they match the part name - row['item_options'] = part_options - - # Unless found, the 'item_match' is blank - row['item_match'] = None - - if exact_match_part: - # If there is an exact match based on PK or IPN, use that - row['item_match'] = exact_match_part - - # Check if there is a column corresponding to "Overage" field - if o_idx >= 0: - row['overage'] = row['data'][o_idx]['cell'] - - # Check if there is a column corresponding to "Reference" field - if r_idx >= 0: - row['reference'] = row['data'][r_idx]['cell'] - - # Check if there is a column corresponding to "Note" field - if n_idx >= 0: - row['note'] = row['data'][n_idx]['cell'] - - def done(self, form_list, **kwargs): - """ Once all the data is in, process it to add BomItem instances to the part """ - - self.part = self.get_part() - items = self.get_clean_items() - - # Clear BOM - self.part.clear_bom() - - # Generate new BOM items - for bom_item in items.values(): - try: - part = Part.objects.get(pk=int(bom_item.get('part'))) - except (ValueError, Part.DoesNotExist): - continue - - quantity = bom_item.get('quantity') - overage = bom_item.get('overage', '') - reference = bom_item.get('reference', '') - note = bom_item.get('note', '') - - # Create a new BOM item - item = BomItem( - part=self.part, - sub_part=part, - quantity=quantity, - overage=overage, - reference=reference, - note=note, - ) - - try: - item.save() - except IntegrityError: - # BomItem already exists - pass - - return HttpResponseRedirect(reverse('part-detail', kwargs={'pk': self.kwargs['pk']})) + context_object_name = 'part' + queryset = Part.objects.all() + template_name = 'part/upload_bom.html' class PartExport(AjaxView):