From b847604f159a5c6fddc3954ecdcc6430ad8f8f36 Mon Sep 17 00:00:00 2001 From: eeintech Date: Mon, 3 May 2021 17:30:33 -0400 Subject: [PATCH 01/48] Started implementation of multi-step form for purchase order file uplod --- InvenTree/InvenTree/settings.py | 1 + InvenTree/common/forms.py | 17 ++++++++ InvenTree/common/views.py | 11 +++++ .../order/templates/order/po_navbar.html | 6 +++ .../order/templates/order/po_upload.html | 42 +++++++++++++++++++ InvenTree/order/urls.py | 1 + InvenTree/order/views.py | 20 +++++++++ requirements.txt | 1 + 8 files changed, 99 insertions(+) create mode 100644 InvenTree/order/templates/order/po_upload.html diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index cc61748372..680389272a 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -263,6 +263,7 @@ INSTALLED_APPS = [ 'djmoney.contrib.exchange', # django-money exchange rates 'error_report', # Error reporting in the admin interface 'django_q', + 'formtools', # Form wizard tools ] MIDDLEWARE = CONFIG.get('middleware', [ diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index 84e44f3a31..0ca98ad76a 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -5,6 +5,8 @@ Django forms for interacting with common objects # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django import forms + from InvenTree.forms import HelperForm from .models import InvenTreeSetting @@ -21,3 +23,18 @@ class SettingEditForm(HelperForm): fields = [ 'value' ] + + +class UploadFile(forms.Form): + ''' Step 1 ''' + first_name = forms.CharField(max_length=100) + + +class MatchField(forms.Form): + ''' Step 2 ''' + last_name = forms.CharField(max_length=100) + + +class MatchPart(forms.Form): + ''' Step 3 ''' + age = forms.IntegerField() diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 31d11e30cc..f25760990e 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -8,6 +8,8 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ from django.forms import CheckboxInput, Select +from formtools.wizard.views import SessionWizardView + from InvenTree.views import AjaxUpdateView from InvenTree.helpers import str2bool @@ -101,3 +103,12 @@ class SettingEdit(AjaxUpdateView): if not str2bool(value, test=True) and not str2bool(value, test=False): form.add_error('value', _('Supplied value must be a boolean')) + + +class FileUploadWizardView(SessionWizardView): + # file_storage = FileSystemStorage(location=os.path.join(settings.MEDIA_ROOT, 'file_uploads')) + form_list = [ + forms.UploadFile, + forms.MatchField, + forms.MatchPart, + ] diff --git a/InvenTree/order/templates/order/po_navbar.html b/InvenTree/order/templates/order/po_navbar.html index eac24d47b3..d49af69c84 100644 --- a/InvenTree/order/templates/order/po_navbar.html +++ b/InvenTree/order/templates/order/po_navbar.html @@ -14,6 +14,12 @@ {% trans "Details" %} +
  • + + + {% trans "Upload File" %} + +
  • diff --git a/InvenTree/order/templates/order/po_upload.html b/InvenTree/order/templates/order/po_upload.html new file mode 100644 index 0000000000..b221800d3c --- /dev/null +++ b/InvenTree/order/templates/order/po_upload.html @@ -0,0 +1,42 @@ +{% extends "order/order_base.html" %} + +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block menubar %} +{% include 'order/po_navbar.html' with tab='upload' %} +{% endblock %} + +{% block heading %} +{{ wizard.form.media }} +{% endblock %} + +{% block details %} + +

    Step {{ wizard.steps.step1 }} of {{ wizard.steps.count }}

    +
    {% csrf_token %} + +{{ wizard.management_form }} +{% if wizard.form.forms %} + {{ wizard.form.management_form }} + {% for form in wizard.form.forms %} + {{ form }} + {% endfor %} +{% else %} + {{ wizard.form }} +{% endif %} +
    +{% if wizard.steps.prev %} + + +{% endif %} + +
    + +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 97903d81c1..9e25c2e870 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -17,6 +17,7 @@ purchase_order_detail_urls = [ url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'), url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'), + url(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'), url(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'), url(r'^notes/', views.PurchaseOrderNotes.as_view(), name='po-notes'), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 284a24fcf5..d221f7bc02 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -9,6 +9,7 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from django.core.exceptions import ValidationError from django.urls import reverse +from django.http import HttpResponseRedirect from django.utils.translation import ugettext_lazy as _ from django.views.generic import DetailView, ListView, UpdateView from django.views.generic.edit import FormMixin @@ -27,6 +28,7 @@ from stock.models import StockItem, StockLocation from part.models import Part from common.models import InvenTreeSetting +from common.views import FileUploadWizardView from . import forms as order_forms @@ -564,6 +566,24 @@ class SalesOrderShip(AjaxUpdateView): return self.renderJsonResponse(request, form, data, context) +class PurchaseOrderUpload(FileUploadWizardView): + ''' Upload File Wizard View ''' + + template_name = "order/po_upload.html" + + def get_context_data(self, form, **kwargs): + context = super().get_context_data(form=form, **kwargs) + + order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) + + context.update({'order': order}) + + return context + + def done(self, form_list, **kwargs): + return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) + + class PurchaseOrderExport(AjaxView): """ File download for a purchase order diff --git a/requirements.txt b/requirements.txt index 3291574084..beaa30c4fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,5 +32,6 @@ python-barcode[images]==0.13.1 # Barcode generator qrcode[pil]==6.1 # QR code generator django-q==1.3.4 # Background task scheduling gunicorn>=20.0.4 # Gunicorn web server +django-formtools==2.3 # Form wizard tools inventree # Install the latest version of the InvenTree API python library From 373898d43e777f1d33c1644b6b53498f54e57c1a Mon Sep 17 00:00:00 2001 From: eeintech Date: Tue, 4 May 2021 10:28:00 -0400 Subject: [PATCH 02/48] Added step description and crispy form tag --- InvenTree/common/forms.py | 15 ---------- InvenTree/common/views.py | 29 ++++++++++++++----- InvenTree/order/forms.py | 15 ++++++++++ .../order/templates/order/po_upload.html | 15 ++++++---- InvenTree/order/views.py | 17 +++++++++-- 5 files changed, 60 insertions(+), 31 deletions(-) diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index 0ca98ad76a..f4ff082019 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -23,18 +23,3 @@ class SettingEditForm(HelperForm): fields = [ 'value' ] - - -class UploadFile(forms.Form): - ''' Step 1 ''' - first_name = forms.CharField(max_length=100) - - -class MatchField(forms.Form): - ''' Step 2 ''' - last_name = forms.CharField(max_length=100) - - -class MatchPart(forms.Form): - ''' Step 3 ''' - age = forms.IntegerField() diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index f25760990e..056cbec32b 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -105,10 +105,25 @@ class SettingEdit(AjaxUpdateView): form.add_error('value', _('Supplied value must be a boolean')) -class FileUploadWizardView(SessionWizardView): - # file_storage = FileSystemStorage(location=os.path.join(settings.MEDIA_ROOT, 'file_uploads')) - form_list = [ - forms.UploadFile, - forms.MatchField, - forms.MatchPart, - ] +class MultiStepFormView(SessionWizardView): + """ Setup basic methods of multi-step form + + form_list: list of forms + form_steps_description: description for each form + """ + + form_list = [] + form_steps_description = [] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Get description + try: + description = self.form_steps_description[int(self.steps.current)] + except IndexError: + description = '' + # Add description to form steps + context.update({'description': description}) + + return context + \ No newline at end of file diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 4c9caf3b53..b5b1aa2c96 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -284,3 +284,18 @@ class EditSalesOrderAllocationForm(HelperForm): 'line', 'item', 'quantity'] + + +class UploadFile(forms.Form): + ''' Step 1 ''' + first_name = forms.CharField(max_length=100) + + +class MatchField(forms.Form): + ''' Step 2 ''' + last_name = forms.CharField(max_length=100) + + +class MatchPart(forms.Form): + ''' Step 3 ''' + age = forms.IntegerField() \ No newline at end of file diff --git a/InvenTree/order/templates/order/po_upload.html b/InvenTree/order/templates/order/po_upload.html index b221800d3c..8563b70ff5 100644 --- a/InvenTree/order/templates/order/po_upload.html +++ b/InvenTree/order/templates/order/po_upload.html @@ -9,29 +9,32 @@ {% endblock %} {% block heading %} +{% trans "Upload File for Purchase Order" %} {{ wizard.form.media }} {% endblock %} {% block details %} -

    Step {{ wizard.steps.step1 }} of {{ wizard.steps.count }}

    +

    {% trans "Step" %} {{ wizard.steps.step1 }} {% trans "of" %} {{ wizard.steps.count }} +{% if description %}- {{ description }}{% endif %}

    {% csrf_token %} +{% load crispy_forms_tags %} {{ wizard.management_form }} {% if wizard.form.forms %} {{ wizard.form.management_form }} {% for form in wizard.form.forms %} - {{ form }} + {% crispy form %} {% endfor %} {% else %} - {{ wizard.form }} + {% crispy wizard.form %} {% endif %}
    {% if wizard.steps.prev %} - - + + {% endif %} - +
    {% endblock %} diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index d221f7bc02..92f5d54c33 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -28,7 +28,7 @@ from stock.models import StockItem, StockLocation from part.models import Part from common.models import InvenTreeSetting -from common.views import FileUploadWizardView +from common.views import MultiStepFormView from . import forms as order_forms @@ -566,10 +566,21 @@ class SalesOrderShip(AjaxUpdateView): return self.renderJsonResponse(request, form, data, context) -class PurchaseOrderUpload(FileUploadWizardView): - ''' Upload File Wizard View ''' +class PurchaseOrderUpload(MultiStepFormView): + ''' PurchaseOrder: Upload file and match to parts, using multi-Step form ''' + form_list = [ + order_forms.UploadFile, + order_forms.MatchField, + order_forms.MatchPart, + ] + form_steps_description = [ + _("Upload File"), + _("Select Fields"), + _("Select Parts"), + ] template_name = "order/po_upload.html" + # file_storage = FileSystemStorage(location=os.path.join(settings.MEDIA_ROOT, 'file_uploads')) def get_context_data(self, form, **kwargs): context = super().get_context_data(form=form, **kwargs) From 7cdf0af04a721a695975808d982596190d00b4a5 Mon Sep 17 00:00:00 2001 From: eeintech Date: Tue, 4 May 2021 12:20:57 -0400 Subject: [PATCH 03/48] Added upload file step --- InvenTree/common/files.py | 184 ++++++++++++++++++ InvenTree/common/forms.py | 2 - InvenTree/common/views.py | 18 +- InvenTree/order/forms.py | 26 ++- .../order/templates/order/po_upload.html | 2 +- InvenTree/order/views.py | 20 +- 6 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 InvenTree/common/files.py diff --git a/InvenTree/common/files.py b/InvenTree/common/files.py new file mode 100644 index 0000000000..1964e662e2 --- /dev/null +++ b/InvenTree/common/files.py @@ -0,0 +1,184 @@ +""" +Files management tools. +""" + +from rapidfuzz import fuzz +import tablib +import os + +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError + +# from company.models import ManufacturerPart, SupplierPart + + +class FileManager: + """ Class for managing an uploaded file """ + + name = '' + + # 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 = [ + ] + + EDITABLE_HEADERS = [ + ] + + HEADERS = REQUIRED_HEADERS + PART_MATCH_HEADERS + OPTIONAL_HEADERS + + def __init__(self, file, name=None): + """ Initialize the FileManager class with a user-uploaded file object """ + + # Set name + if name: + self.name = name + + # Process initial file + self.process(file) + + def process(self, file): + """ Process file """ + + self.data = None + + ext = os.path.splitext(file.name)[-1].lower() + + if ext in ['.csv', '.tsv', ]: + # These file formats need string decoding + raw_data = file.read().decode('utf-8') + elif ext in ['.xls', '.xlsx']: + raw_data = file.read() + else: + raise ValidationError(_(f'Unsupported file format: {ext}')) + + try: + self.data = tablib.Dataset().load(raw_data) + except tablib.UnsupportedFormat: + raise ValidationError(_(f'Error reading {self.name} file (invalid format)')) + except tablib.core.InvalidDimensions: + raise ValidationError(_(f'Error reading {self.name} file (incorrect dimension)')) + + 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/common/forms.py b/InvenTree/common/forms.py index f4ff082019..84e44f3a31 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -5,8 +5,6 @@ Django forms for interacting with common objects # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django import forms - from InvenTree.forms import HelperForm from .models import InvenTreeSetting diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 056cbec32b..8220b00554 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -5,8 +5,12 @@ Django views for interacting with common models # -*- coding: utf-8 -*- from __future__ import unicode_literals +import os + from django.utils.translation import ugettext_lazy as _ from django.forms import CheckboxInput, Select +from django.conf import settings +from django.core.files.storage import FileSystemStorage from formtools.wizard.views import SessionWizardView @@ -106,7 +110,7 @@ class SettingEdit(AjaxUpdateView): class MultiStepFormView(SessionWizardView): - """ Setup basic methods of multi-step form + """ Setup basic methods of multi-step form form_list: list of forms form_steps_description: description for each form @@ -114,6 +118,17 @@ class MultiStepFormView(SessionWizardView): form_list = [] form_steps_description = [] + media_folder = '' + file_storage = FileSystemStorage(settings.MEDIA_ROOT) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.media_folder: + media_folder_abs = os.path.join(settings.MEDIA_ROOT, self.media_folder) + if not os.path.exists(media_folder_abs): + os.mkdir(media_folder_abs) + self.file_storage = FileSystemStorage(location=media_folder_abs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -126,4 +141,3 @@ class MultiStepFormView(SessionWizardView): context.update({'description': description}) return context - \ No newline at end of file diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index b5b1aa2c96..5f6253217c 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -14,6 +14,8 @@ from InvenTree.forms import HelperForm from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import DatePickerFormField +from common.files import FileManager + import part.models from stock.models import StockLocation @@ -287,15 +289,29 @@ class EditSalesOrderAllocationForm(HelperForm): class UploadFile(forms.Form): - ''' Step 1 ''' - first_name = forms.CharField(max_length=100) + """ Step 1 """ + file = forms.FileField( + label=_('Order File'), + help_text=_('Select order file to upload'), + ) + + file_manager = None + + def clean_file(self): + file = self.cleaned_data['file'] + + # Create a FileManager object - will perform initial data validation + # (and raise a ValidationError if there is something wrong with the file) + self.file_manager = FileManager(file=file, name='order') + + return file class MatchField(forms.Form): - ''' Step 2 ''' + """ Step 2 """ last_name = forms.CharField(max_length=100) class MatchPart(forms.Form): - ''' Step 3 ''' - age = forms.IntegerField() \ No newline at end of file + """ Step 3 """ + age = forms.IntegerField() diff --git a/InvenTree/order/templates/order/po_upload.html b/InvenTree/order/templates/order/po_upload.html index 8563b70ff5..9650ee763d 100644 --- a/InvenTree/order/templates/order/po_upload.html +++ b/InvenTree/order/templates/order/po_upload.html @@ -17,7 +17,7 @@

    {% trans "Step" %} {{ wizard.steps.step1 }} {% trans "of" %} {{ wizard.steps.count }} {% if description %}- {{ description }}{% endif %}

    -
    {% csrf_token %} +{% csrf_token %} {% load crispy_forms_tags %} {{ wizard.management_form }} diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 92f5d54c33..ec9a4a4742 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -580,7 +580,7 @@ class PurchaseOrderUpload(MultiStepFormView): _("Select Parts"), ] template_name = "order/po_upload.html" - # file_storage = FileSystemStorage(location=os.path.join(settings.MEDIA_ROOT, 'file_uploads')) + media_folder = 'order_uploads/' def get_context_data(self, form, **kwargs): context = super().get_context_data(form=form, **kwargs) @@ -591,6 +591,24 @@ class PurchaseOrderUpload(MultiStepFormView): return context + def get_form_step_data(self, form): + # print(f'{self.steps.current=}\n{form.data=}') + return form.data + + def get_form_step_files(self, form): + # Check if user completed file upload + if self.steps.current == '0': + # Extract columns and rows from FileManager + self.extractDataFromFile(form.file_manager) + + return form.files + + def extractDataFromFile(self, file_manager): + """ Read data from the file """ + + self.columns = file_manager.columns() + self.rows = file_manager.rows() + def done(self, form_list, **kwargs): return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) From 64fb492b9711977f325bd10f7c036dcd967af166 Mon Sep 17 00:00:00 2001 From: eeintech Date: Tue, 4 May 2021 14:45:52 -0400 Subject: [PATCH 04/48] Added match parts step, need to process fields data --- InvenTree/common/files.py | 52 ++++++-- InvenTree/common/views.py | 25 +++- InvenTree/order/forms.py | 4 +- .../order/order_wizard/match_fields.html | 89 ++++++++++++++ .../order/order_wizard/match_parts.html | 112 ++++++++++++++++++ .../order/{ => order_wizard}/po_upload.html | 16 ++- InvenTree/order/views.py | 64 +++++++--- 7 files changed, 326 insertions(+), 36 deletions(-) create mode 100644 InvenTree/order/templates/order/order_wizard/match_fields.html create mode 100644 InvenTree/order/templates/order/order_wizard/match_parts.html rename InvenTree/order/templates/order/{ => order_wizard}/po_upload.html (80%) diff --git a/InvenTree/common/files.py b/InvenTree/common/files.py index 1964e662e2..ab73ec4f1e 100644 --- a/InvenTree/common/files.py +++ b/InvenTree/common/files.py @@ -18,25 +18,17 @@ class FileManager: name = '' # Fields which are absolutely necessary for valid upload - REQUIRED_HEADERS = [ - 'Quantity' - ] + REQUIRED_HEADERS = [] # Fields which are used for part matching (only one of them is needed) - PART_MATCH_HEADERS = [ - 'Part_Name', - 'Part_IPN', - 'Part_ID', - ] + PART_MATCH_HEADERS = [] # Fields which would be helpful but are not required - OPTIONAL_HEADERS = [ - ] + OPTIONAL_HEADERS = [] - EDITABLE_HEADERS = [ - ] + EDITABLE_HEADERS = [] - HEADERS = REQUIRED_HEADERS + PART_MATCH_HEADERS + OPTIONAL_HEADERS + HEADERS = [] def __init__(self, file, name=None): """ Initialize the FileManager class with a user-uploaded file object """ @@ -48,6 +40,9 @@ class FileManager: # Process initial file self.process(file) + # Update headers + self.update_headers() + def process(self, file): """ Process file """ @@ -69,6 +64,37 @@ class FileManager: raise ValidationError(_(f'Error reading {self.name} file (invalid format)')) except tablib.core.InvalidDimensions: raise ValidationError(_(f'Error reading {self.name} file (incorrect dimension)')) + + def update_headers(self): + """ Update headers """ + + self.HEADERS = self.REQUIRED_HEADERS + self.PART_MATCH_HEADERS + self.OPTIONAL_HEADERS + + def setup(self): + """ Setup headers depending on the file name """ + + if not self.name: + return False + + if self.name == 'order': + self.REQUIRED_HEADERS = [ + 'Quantity', + ] + + self.PART_MATCH_HEADERS = [ + 'Manufacturer_MPN', + 'Supplier_SKU', + ] + + self.OPTIONAL_HEADERS = [ + 'Unit_Price', + 'Extended_Price', + ] + + # Update headers + self.update_headers() + + return True def guess_header(self, header, threshold=80): """ Try to match a header (from the file) to a list of known headers diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 8220b00554..af3f7fea94 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -117,22 +117,45 @@ class MultiStepFormView(SessionWizardView): """ form_list = [] + form_steps_template = [] form_steps_description = [] + file_manager = None media_folder = '' file_storage = FileSystemStorage(settings.MEDIA_ROOT) def __init__(self, *args, **kwargs): + """ Override init method to set media folder """ super().__init__(*args, **kwargs) + self.process_media_folder() + + def process_media_folder(self): + """ Process media folder """ + if self.media_folder: media_folder_abs = os.path.join(settings.MEDIA_ROOT, self.media_folder) if not os.path.exists(media_folder_abs): os.mkdir(media_folder_abs) self.file_storage = FileSystemStorage(location=media_folder_abs) + def get_template_names(self): + """ Select template """ + + try: + # Get template + template = self.form_steps_template[int(self.steps.current)] + except IndexError: + return self.template_name + + return template + def get_context_data(self, **kwargs): + """ Update context data """ + + # Retrieve current context context = super().get_context_data(**kwargs) - # Get description + + # Get form description try: description = self.form_steps_description[int(self.steps.current)] except IndexError: diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 5f6253217c..b1e5745937 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -309,9 +309,9 @@ class UploadFile(forms.Form): class MatchField(forms.Form): """ Step 2 """ - last_name = forms.CharField(max_length=100) + pass class MatchPart(forms.Form): """ Step 3 """ - age = forms.IntegerField() + pass diff --git a/InvenTree/order/templates/order/order_wizard/match_fields.html b/InvenTree/order/templates/order/order_wizard/match_fields.html new file mode 100644 index 0000000000..818f986850 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/match_fields.html @@ -0,0 +1,89 @@ +{% extends "order/order_wizard/po_upload.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block form %} + +{% if missing_columns and missing_columns|length > 0 %} + +{% endif %} + + + {% csrf_token %} + {% load crispy_forms_tags %} + {{ wizard.management_form }} + + {% if wizard.steps.prev %} + + {% endif %} + + +
    + + + + + {% for col in columns %} + + {% endfor %} + + + + + + + {% for col in columns %} + + {% endfor %} + + {% for row in rows %} + {% with forloop.counter as row_index %} + + + + {% for item in row.data %} + + {% endfor %} + + {% endwith %} + {% endfor %} + +
    {% trans "File Fields" %} +
    + + {{ col.name }} + +
    +
    {% trans "Match Fields" %} + + {% if col.duplicate %} +

    {% trans "Duplicate column selection" %}

    + {% endif %} +
    + + {{ row_index }} + + {{ item }} +
    + +
    + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html new file mode 100644 index 0000000000..a1c5103e9e --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -0,0 +1,112 @@ +{% extends "order/order_wizard/po_upload.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block form %} + +{% if form_errors %} + +{% endif %} + +
    + {% csrf_token %} + {% load crispy_forms_tags %} + {{ wizard.management_form }} + + {% if wizard.steps.prev %} + + {% endif %} + + {% csrf_token %} + + + + + + + + + {% 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/order/templates/order/po_upload.html b/InvenTree/order/templates/order/order_wizard/po_upload.html similarity index 80% rename from InvenTree/order/templates/order/po_upload.html rename to InvenTree/order/templates/order/order_wizard/po_upload.html index 9650ee763d..2c7c49b662 100644 --- a/InvenTree/order/templates/order/po_upload.html +++ b/InvenTree/order/templates/order/order_wizard/po_upload.html @@ -1,5 +1,4 @@ {% extends "order/order_base.html" %} - {% load inventree_extras %} {% load i18n %} {% load static %} @@ -17,7 +16,11 @@

    {% trans "Step" %} {{ wizard.steps.step1 }} {% trans "of" %} {{ wizard.steps.count }} {% if description %}- {{ description }}{% endif %}

    -
    {% csrf_token %} + +{% block form %} + + +{% csrf_token %} {% load crispy_forms_tags %} {{ wizard.management_form }} @@ -29,15 +32,16 @@ {% else %} {% crispy wizard.form %} {% endif %} -
    -{% if wizard.steps.prev %} +{% if wizard.steps.prev %} {% endif %} - +
    + -{% endblock %} +{% endblock form %} +{% endblock details %} {% block js_ready %} {{ block.super }} diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index ec9a4a4742..f9eb94eaa2 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -567,21 +567,30 @@ class SalesOrderShip(AjaxUpdateView): class PurchaseOrderUpload(MultiStepFormView): - ''' PurchaseOrder: Upload file and match to parts, using multi-Step form ''' + ''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) ''' form_list = [ order_forms.UploadFile, order_forms.MatchField, order_forms.MatchPart, ] + form_steps_template = [ + 'order/order_wizard/po_upload.html', + 'order/order_wizard/match_fields.html', + 'order/order_wizard/match_parts.html', + ] form_steps_description = [ _("Upload File"), - _("Select Fields"), - _("Select Parts"), + _("Match Fields"), + _("Match Parts"), ] - template_name = "order/po_upload.html" media_folder = 'order_uploads/' + # Used for data table + headers = None + rows = None + columns = None + def get_context_data(self, form, **kwargs): context = super().get_context_data(form=form, **kwargs) @@ -589,25 +598,52 @@ class PurchaseOrderUpload(MultiStepFormView): context.update({'order': order}) + if self.headers: + context.update({'headers': self.headers}) + # print(f'{self.headers}') + if self.columns: + context.update({'columns': self.columns}) + # print(f'{self.columns}') + if self.rows: + context.update({'rows': self.rows}) + # print(f'{self.rows}') + return context - def get_form_step_data(self, form): - # print(f'{self.steps.current=}\n{form.data=}') - return form.data + def process_step(self, form): + print(f'{self.steps.current=} | {form.data}') + return self.get_form_step_data(form) + + # def get_all_cleaned_data(self): + # cleaned_data = super().get_all_cleaned_data() + # print(f'{self.steps.current=} | {cleaned_data}') + # return cleaned_data + + # def get_form_step_data(self, form): + # print(f'{self.steps.current=} | {form.data}') + # return form.data def get_form_step_files(self, form): # Check if user completed file upload if self.steps.current == '0': - # Extract columns and rows from FileManager - self.extractDataFromFile(form.file_manager) + # Retrieve FileManager instance from form + self.file_manager = form.file_manager + # Setup FileManager for order upload + setup_valid = self.file_manager.setup() + if setup_valid: + # Set headers + self.headers = self.file_manager.HEADERS + # Set columns and rows + self.columns = self.file_manager.columns() + self.rows = self.file_manager.rows() return form.files - def extractDataFromFile(self, file_manager): - """ Read data from the file """ - - self.columns = file_manager.columns() - self.rows = file_manager.rows() + def post(self, request, *args, **kwargs): + """ Perform the various 'POST' requests required. + """ + print('Posting!') + return super().post(*args, **kwargs) def done(self, form_list, **kwargs): return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) From b4342d6203a8c9ee6e0393570c5d2eef3111cd8b Mon Sep 17 00:00:00 2001 From: eeintech Date: Tue, 4 May 2021 17:35:27 -0400 Subject: [PATCH 05/48] Fixed templates, not sure why can't keep FileManager across forms... and also need to save form data --- InvenTree/common/views.py | 4 +- InvenTree/order/forms.py | 6 +- .../order/order_wizard/match_fields.html | 125 +++++------ .../order/order_wizard/match_parts.html | 21 +- .../order/order_wizard/po_upload.html | 29 +-- InvenTree/order/views.py | 210 ++++++++++++++++-- 6 files changed, 275 insertions(+), 120 deletions(-) diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index af3f7fea94..2abe53d380 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -143,7 +143,7 @@ class MultiStepFormView(SessionWizardView): try: # Get template - template = self.form_steps_template[int(self.steps.current)] + template = self.form_steps_template[self.steps.index] except IndexError: return self.template_name @@ -157,7 +157,7 @@ class MultiStepFormView(SessionWizardView): # Get form description try: - description = self.form_steps_description[int(self.steps.current)] + description = self.form_steps_description[self.steps.index] except IndexError: description = '' # Add description to form steps diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index b1e5745937..858679ffdf 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -295,16 +295,14 @@ class UploadFile(forms.Form): help_text=_('Select order file to upload'), ) - file_manager = None - def clean_file(self): file = self.cleaned_data['file'] # Create a FileManager object - will perform initial data validation # (and raise a ValidationError if there is something wrong with the file) - self.file_manager = FileManager(file=file, name='order') + file_manager = FileManager(file=file, name='order') - return file + return file_manager class MatchField(forms.Form): diff --git a/InvenTree/order/templates/order/order_wizard/match_fields.html b/InvenTree/order/templates/order/order_wizard/match_fields.html index 818f986850..1ac1f30997 100644 --- a/InvenTree/order/templates/order/order_wizard/match_fields.html +++ b/InvenTree/order/templates/order/order_wizard/match_fields.html @@ -3,8 +3,7 @@ {% load i18n %} {% load static %} -{% block form %} - +{% block form_alert %} {% if missing_columns and missing_columns|length > 0 %} {% endif %} +{% endblock form_alert %} -
    - {% csrf_token %} - {% load crispy_forms_tags %} - {{ wizard.management_form }} - +{% block form_buttons_top %} {% if wizard.steps.prev %} {% endif %} +{% endblock form_buttons_top %} - - - - - - {% for col in columns %} - - {% endfor %} - - - - - - - {% for col in columns %} - - {% endfor %} - - {% for row in rows %} - {% with forloop.counter as row_index %} - - + + + + {% for col in columns %} + - {% for item in row.data %} - - {% endfor %} - - {% endwith %} + + {% endfor %} - -
    {% trans "File Fields" %} -
    - - {{ col.name }} - -
    -
    {% trans "Match Fields" %} - - {% if col.duplicate %} -

    {% trans "Duplicate column selection" %}

    - {% endif %} -
    -
    {% trans "File Fields" %} +
    + + {{ col.name }} + - -
    {{ row_index }} - - {{ item }} -
    + + + + + + {% trans "Match Fields" %} + {% for col in columns %} + + + {% if col.duplicate %} +

    {% trans "Duplicate column selection" %}

    + {% endif %} + + {% 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 %} -
    - -{% endblock %} \ No newline at end of file +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html index a1c5103e9e..735d847b87 100644 --- a/InvenTree/order/templates/order/order_wizard/match_parts.html +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -3,26 +3,22 @@ {% load i18n %} {% load static %} -{% block form %} - +{% block form_alert %} {% if form_errors %} {% endif %} +{% endblock form_alert %} -
    - {% csrf_token %} - {% load crispy_forms_tags %} - {{ wizard.management_form }} - +{% block form_buttons_top %} {% if wizard.steps.prev %} {% endif %} - {% csrf_token %} +{% endblock form_buttons_top %} - +{% block form_content %} @@ -95,11 +91,10 @@ {% endfor %} -
    +{% endblock form_content %} -
    - -{% endblock %} +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} {% block js_ready %} {{ block.super }} diff --git a/InvenTree/order/templates/order/order_wizard/po_upload.html b/InvenTree/order/templates/order/order_wizard/po_upload.html index 2c7c49b662..129570417b 100644 --- a/InvenTree/order/templates/order/order_wizard/po_upload.html +++ b/InvenTree/order/templates/order/order_wizard/po_upload.html @@ -17,30 +17,31 @@

    {% trans "Step" %} {{ wizard.steps.step1 }} {% trans "of" %} {{ wizard.steps.count }} {% if description %}- {{ description }}{% endif %}

    -{% block form %} +{% block form_alert %} +{% endblock form_alert %} -
    + {% csrf_token %} - {% load crispy_forms_tags %} -{{ wizard.management_form }} -{% if wizard.form.forms %} - {{ wizard.form.management_form }} - {% for form in wizard.form.forms %} - {% crispy form %} - {% endfor %} -{% else %} - {% crispy wizard.form %} -{% endif %} +{% 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 form %} {% endblock details %} {% block js_ready %} diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index f9eb94eaa2..ee11609b35 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -570,9 +570,9 @@ class PurchaseOrderUpload(MultiStepFormView): ''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) ''' form_list = [ - order_forms.UploadFile, - order_forms.MatchField, - order_forms.MatchPart, + ('upload', order_forms.UploadFile), + ('fields', order_forms.MatchField), + ('parts', order_forms.MatchPart), ] form_steps_template = [ 'order/order_wizard/po_upload.html', @@ -608,26 +608,152 @@ class PurchaseOrderUpload(MultiStepFormView): context.update({'rows': self.rows}) # print(f'{self.rows}') + print(f'{context=}') return context - def process_step(self, form): - print(f'{self.steps.current=} | {form.data}') - return self.get_form_step_data(form) + def getTableDataFromForm(self, form_data): + """ Extract table cell data from form data. + These data are used to maintain state between sessions. - # def get_all_cleaned_data(self): - # cleaned_data = super().get_all_cleaned_data() - # print(f'{self.steps.current=} | {cleaned_data}') - # return cleaned_data + Table data keys are as follows: - # def get_form_step_data(self, form): - # print(f'{self.steps.current=} | {form.data}') - # return form.data + col_name_ - Column name at idx as provided in the uploaded file + col_guess_ - Column guess at idx as selected + row__col - Cell data as provided in the uploaded file + + """ + + if not self.file_manager: + print('Lost file manager...') + return + + # Map the columns + self.column_names = {} + self.column_selections = {} + + self.row_data = {} + + for item in form_data: + value = form_data[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.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.rows.append({ + 'index': row_idx, + 'data': items, + 'errors': {}, + }) + + # Construct the column data + self.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.columns.append(header) + + # Are there any missing columns? + self.missing_columns = [] + + # Check that all required fields are present + for col in self.file_manager.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 self.file_manager.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 self.file_manager.PART_MATCH_HEADERS: + self.missing_columns.append(col) + + def handleFileUpload(self, form): + """ Process file upload and setup fields form """ - def get_form_step_files(self, form): # Check if user completed file upload - if self.steps.current == '0': - # Retrieve FileManager instance from form - self.file_manager = form.file_manager + if self.steps.current == 'upload': + # Copy FileManager instance from form + self.file_manager = form.cleaned_data['file'] + print(f'{self.file_manager=}') # Setup FileManager for order upload setup_valid = self.file_manager.setup() if setup_valid: @@ -637,13 +763,51 @@ class PurchaseOrderUpload(MultiStepFormView): self.columns = self.file_manager.columns() self.rows = self.file_manager.rows() - return form.files + # Save FileManager + # self.storage.set_step_data('file', self.file_manager) - def post(self, request, *args, **kwargs): - """ Perform the various 'POST' requests required. - """ - print('Posting!') - return super().post(*args, **kwargs) + def handleFieldSelection(self, form_data): + """ Process field matching """ + + # Extract form data + self.getTableDataFromForm(form_data) + + valid = len(self.missing_columns) == 0 and not self.duplicates + + if not valid: + raise ValidationError('Invalid data') + + def handlePartSelection(self, form_data): + pass + # print(f'{form_data=}') + + # def process_step(self, form): + # print(f'{self.steps.current=} | {form.data}') + # return self.get_form_step_data(form) + + def get_form_step_data(self, form): + print(f'{self.steps.current=}\n{form.data=}') + print(f'{self.file_manager=}') + # Process steps + if self.steps.current == 'upload': + self.handleFileUpload(form) + if self.steps.current == 'fields': + self.handleFieldSelection(form.data) + elif self.steps.current == 'parts': + self.handlePartSelection(form.data) + + return form.data + + # def get_all_cleaned_data(self): + # cleaned_data = super().get_all_cleaned_data() + # print(f'{self.steps.current=} | {cleaned_data}') + # return cleaned_data + + # def post(self, request, *args, **kwargs): + # """ Perform the various 'POST' requests required. + # """ + # print('Posting!') + # return super().post(*args, **kwargs) def done(self, form_list, **kwargs): return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) From 9773fee50bd4143c461e48a2e6208239e79b05ce Mon Sep 17 00:00:00 2001 From: eeintech Date: Wed, 5 May 2021 12:40:48 -0400 Subject: [PATCH 06/48] Validation of matching fields is working --- InvenTree/common/files.py | 31 ++-- InvenTree/order/forms.py | 6 +- .../order/order_wizard/match_fields.html | 1 - InvenTree/order/views.py | 145 ++++++++++++------ 4 files changed, 121 insertions(+), 62 deletions(-) diff --git a/InvenTree/common/files.py b/InvenTree/common/files.py index ab73ec4f1e..d1c8a924b1 100644 --- a/InvenTree/common/files.py +++ b/InvenTree/common/files.py @@ -43,27 +43,42 @@ class FileManager: # Update headers self.update_headers() - def process(self, file): - """ Process file """ + @classmethod + def validate(cls, file): + """ Validate file extension and data """ - self.data = None + cleaned_data = None ext = os.path.splitext(file.name)[-1].lower() if ext in ['.csv', '.tsv', ]: # These file formats need string decoding raw_data = file.read().decode('utf-8') + # Reset stream position to beginning of file + file.seek(0) elif ext in ['.xls', '.xlsx']: raw_data = file.read() + # Reset stream position to beginning of file + file.seek(0) else: raise ValidationError(_(f'Unsupported file format: {ext}')) try: - self.data = tablib.Dataset().load(raw_data) + cleaned_data = tablib.Dataset().load(raw_data) except tablib.UnsupportedFormat: - raise ValidationError(_(f'Error reading {self.name} file (invalid format)')) + raise ValidationError(_('Error reading file (invalid format)')) except tablib.core.InvalidDimensions: - raise ValidationError(_(f'Error reading {self.name} file (incorrect dimension)')) + raise ValidationError(_('Error reading file (incorrect dimension)')) + except KeyError: + # TODO: Find fix for XLSX format as it keeps on returning a KeyError + raise ValidationError(_('Error reading file (data could be corrupted)')) + + return cleaned_data + + def process(self, file): + """ Process file """ + + self.data = self.__class__.validate(file) def update_headers(self): """ Update headers """ @@ -74,7 +89,7 @@ class FileManager: """ Setup headers depending on the file name """ if not self.name: - return False + return if self.name == 'order': self.REQUIRED_HEADERS = [ @@ -94,8 +109,6 @@ class FileManager: # Update headers self.update_headers() - return True - def guess_header(self, header, threshold=80): """ Try to match a header (from the file) to a list of known headers diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 858679ffdf..b99c09caba 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -298,11 +298,11 @@ class UploadFile(forms.Form): def clean_file(self): file = self.cleaned_data['file'] - # Create a FileManager object - will perform initial data validation + # Validate file using FileManager class - will perform initial data validation # (and raise a ValidationError if there is something wrong with the file) - file_manager = FileManager(file=file, name='order') + FileManager.validate(file) - return file_manager + return file class MatchField(forms.Form): diff --git a/InvenTree/order/templates/order/order_wizard/match_fields.html b/InvenTree/order/templates/order/order_wizard/match_fields.html index 1ac1f30997..d1385d031b 100644 --- a/InvenTree/order/templates/order/order_wizard/match_fields.html +++ b/InvenTree/order/templates/order/order_wizard/match_fields.html @@ -25,7 +25,6 @@ {% endblock form_buttons_top %} {% block form_content %} - {{ wizard.form }} diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index ee11609b35..d671fd694d 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -29,6 +29,7 @@ from part.models import Part from common.models import InvenTreeSetting from common.views import MultiStepFormView +from common.files import FileManager from . import forms as order_forms @@ -590,6 +591,7 @@ class PurchaseOrderUpload(MultiStepFormView): headers = None rows = None columns = None + missing_columns = None def get_context_data(self, form, **kwargs): context = super().get_context_data(form=form, **kwargs) @@ -603,12 +605,13 @@ class PurchaseOrderUpload(MultiStepFormView): # print(f'{self.headers}') if self.columns: context.update({'columns': self.columns}) - # print(f'{self.columns}') + print(f'{self.columns}') if self.rows: context.update({'rows': self.rows}) # print(f'{self.rows}') + if self.missing_columns: + context.update({'missing_columns': self.missing_columns}) - print(f'{context=}') return context def getTableDataFromForm(self, form_data): @@ -623,10 +626,6 @@ class PurchaseOrderUpload(MultiStepFormView): """ - if not self.file_manager: - print('Lost file manager...') - return - # Map the columns self.column_names = {} self.column_selections = {} @@ -746,68 +745,116 @@ class PurchaseOrderUpload(MultiStepFormView): for col in self.file_manager.PART_MATCH_HEADERS: self.missing_columns.append(col) - def handleFileUpload(self, form): - """ Process file upload and setup fields form """ + def getFileManager(self, form=None): + """ Create FileManager instance from upload file """ + + if self.file_manager: + return - # Check if user completed file upload if self.steps.current == 'upload': - # Copy FileManager instance from form - self.file_manager = form.cleaned_data['file'] - print(f'{self.file_manager=}') - # Setup FileManager for order upload - setup_valid = self.file_manager.setup() - if setup_valid: - # Set headers - self.headers = self.file_manager.HEADERS - # Set columns and rows - self.columns = self.file_manager.columns() - self.rows = self.file_manager.rows() + # Get file from form data + order_file = form.cleaned_data['file'] + self.file_manager = FileManager(file=order_file, name='order') + else: + # Retrieve stored files from upload step + upload_files = self.storage.get_step_files('upload') + # Get file + order_file = upload_files.get('upload-file', None) + if order_file: + self.file_manager = FileManager(file=order_file, name='order') - # Save FileManager - # self.storage.set_step_data('file', self.file_manager) + def setupFieldSelection(self, form): + """ Setup fields form """ + + # Get FileManager + self.getFileManager(form) + # Setup headers + self.file_manager.setup() + # Set headers + self.headers = self.file_manager.HEADERS + # Set columns and rows + self.columns = self.file_manager.columns() + self.rows = self.file_manager.rows() - def handleFieldSelection(self, form_data): + def handleFieldSelection(self, form): """ Process field matching """ + # Update headers + if self.file_manager: + self.file_manager.setup() + else: + return False + # Extract form data - self.getTableDataFromForm(form_data) + self.getTableDataFromForm(form.data) valid = len(self.missing_columns) == 0 and not self.duplicates - if not valid: - raise ValidationError('Invalid data') + return valid - def handlePartSelection(self, form_data): + def handlePartSelection(self, form): pass - # print(f'{form_data=}') - - # def process_step(self, form): - # print(f'{self.steps.current=} | {form.data}') - # return self.get_form_step_data(form) def get_form_step_data(self, form): - print(f'{self.steps.current=}\n{form.data=}') - print(f'{self.file_manager=}') + """ Process form data after it has been posted """ + + # print(f'{self.steps.current=}\n{form.data=}') + + # Retrieve FileManager instance from uploaded file + self.getFileManager(form) + # print(f'{self.file_manager=}') + # Process steps if self.steps.current == 'upload': - self.handleFileUpload(form) - if self.steps.current == 'fields': - self.handleFieldSelection(form.data) - elif self.steps.current == 'parts': - self.handlePartSelection(form.data) + self.setupFieldSelection(form) + # elif self.steps.current == 'fields': + # self.handleFieldSelection(form) + # elif self.steps.current == 'parts': + # self.handlePartSelection(form) return form.data - - # def get_all_cleaned_data(self): - # cleaned_data = super().get_all_cleaned_data() - # print(f'{self.steps.current=} | {cleaned_data}') - # return cleaned_data - # def post(self, request, *args, **kwargs): - # """ Perform the various 'POST' requests required. - # """ - # print('Posting!') - # return super().post(*args, **kwargs) + def validate(self, step, form): + """ Validate forms """ + + valid = False + + # Process steps + if step == 'upload': + # Validation is done during POST + valid = True + elif step == 'fields': + # Retrieve FileManager instance from uploaded file + self.getFileManager(form) + # Validate user form data + valid = self.handleFieldSelection(form) + + if not valid: + form.add_error(None, 'Fields matching failed') + # Set headers + self.headers = self.file_manager.HEADERS + + elif step == 'parts': + valid = self.handlePartSelection(form) + + return valid + + def post(self, request, *args, **kwargs): + """ Perform validations before posting data """ + + wizard_goto_step = self.request.POST.get('wizard_goto_step', None) + + form = self.get_form(data=self.request.POST, files=self.request.FILES) + + print(f'\nCurrent step = {self.steps.current}') + form_valid = self.validate(self.steps.current, form) + + if not form_valid and not wizard_goto_step: + # Re-render same step + return self.render(form) + + print('\nPosting... ') + return super().post(*args, **kwargs) def done(self, form_list, **kwargs): return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) From 2cd1df691b2165509e4df5c0377b9b810e3f832e Mon Sep 17 00:00:00 2001 From: eeintech Date: Wed, 5 May 2021 13:16:40 -0400 Subject: [PATCH 07/48] Working towards part matching --- .../order/order_wizard/match_fields.html | 4 +- .../order/order_wizard/match_parts.html | 8 +- InvenTree/order/views.py | 94 ++++++++++++++++++- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/InvenTree/order/templates/order/order_wizard/match_fields.html b/InvenTree/order/templates/order/order_wizard/match_fields.html index d1385d031b..e837279ccb 100644 --- a/InvenTree/order/templates/order/order_wizard/match_fields.html +++ b/InvenTree/order/templates/order/order_wizard/match_fields.html @@ -18,9 +18,9 @@ {% endblock form_alert %} {% block form_buttons_top %} - {% if wizard.steps.prev %} + {% comment %} {% if wizard.steps.prev %} - {% endif %} + {% endif %} {% endcomment %} {% endblock form_buttons_top %} diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html index 735d847b87..2cf4aa1ea2 100644 --- a/InvenTree/order/templates/order/order_wizard/match_parts.html +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -12,9 +12,9 @@ {% endblock form_alert %} {% block form_buttons_top %} - {% if wizard.steps.prev %} + {% comment %} {% if wizard.steps.prev %} - {% endif %} + {% endif %} {% endcomment %} {% endblock form_buttons_top %} @@ -25,7 +25,7 @@ {% trans "Row" %} {% trans "Select Part" %} - {% for col in bom_columns %} + {% for col in columns %} @@ -39,7 +39,7 @@ - {% for row in bom_rows %} + {% for row in rows %} - {% endif %} {% endcomment %} + {% endif %} {% endblock form_buttons_top %} @@ -29,7 +34,7 @@ {% trans "File Fields" %} - {% for col in columns %} + {% for col in form %}
    @@ -46,17 +51,22 @@ {% trans "Match Fields" %} - {% for col in columns %} + {% for col in form %} - {% for req in headers %} {% endfor %} - - {% if col.duplicate %} -

    {% trans "Duplicate column selection" %}

    - {% endif %} + {% endcomment %} + {% for duplicate in duplicates %} + {% if duplicate == col.name %} + + {% endif %} + {% endfor %} {% endfor %} diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html index 6714c0c11a..63cac689b4 100644 --- a/InvenTree/order/templates/order/order_wizard/match_parts.html +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -12,9 +12,9 @@ {% endblock form_alert %} {% block form_buttons_top %} - {% comment %} {% if wizard.steps.prev %} + {% if wizard.steps.prev %} - {% endif %} {% endcomment %} + {% endif %} {% endblock form_buttons_top %} diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 6a6a8ce6e2..b3f6c1326a 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -28,8 +28,7 @@ from stock.models import StockItem, StockLocation from part.models import Part from common.models import InvenTreeSetting -from common.views import MultiStepFormView -from common.files import FileManager +from common.views import FileManagementFormView from . import forms as order_forms @@ -567,14 +566,10 @@ class SalesOrderShip(AjaxUpdateView): return self.renderJsonResponse(request, form, data, context) -class PurchaseOrderUpload(MultiStepFormView): +class PurchaseOrderUpload(FileManagementFormView): ''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) ''' - form_list = [ - ('upload', order_forms.UploadFile), - ('fields', order_forms.MatchField), - ('parts', order_forms.MatchPart), - ] + name = 'order' form_steps_template = [ 'order/order_wizard/po_upload.html', 'order/order_wizard/match_fields.html', @@ -583,16 +578,8 @@ class PurchaseOrderUpload(MultiStepFormView): form_steps_description = [ _("Upload File"), _("Match Fields"), - _("Match Parts"), + _("Match Supplier Parts"), ] - media_folder = 'order_uploads/' - - # Used for data table - headers = None - rows = None - columns = None - missing_columns = None - allowed_parts = None def get_context_data(self, form, **kwargs): context = super().get_context_data(form=form, **kwargs) @@ -601,499 +588,8 @@ class PurchaseOrderUpload(MultiStepFormView): context.update({'order': order}) - if self.headers: - context.update({'headers': self.headers}) - # print(f'{self.headers}') - if self.columns: - context.update({'columns': self.columns}) - # print(f'{self.columns}') - if self.rows: - for row in self.rows: - row_data = row['data'] - - data = [] - - for idx, item in enumerate(row_data): - data.append({ - 'cell': item, - 'idx': idx, - 'column': self.columns[idx] - }) - - row['data'] = data - - context.update({'rows': self.rows}) - # print(f'{self.rows}') - if self.missing_columns: - context.update({'missing_columns': self.missing_columns}) - return context - def getTableDataFromForm(self, form_data): - """ Extract table cell data from form data. - 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 - 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 form_data: - value = form_data[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.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.rows.append({ - 'index': row_idx, - 'data': items, - 'errors': {}, - }) - - # Construct the column data - self.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.columns.append(header) - - # Are there any missing columns? - self.missing_columns = [] - - # Check that all required fields are present - for col in self.file_manager.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 self.file_manager.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 self.file_manager.PART_MATCH_HEADERS: - self.missing_columns.append(col) - - 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): - """ 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. - """ - - # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database - q_idx = self.getColumnIndex('Quantity') - s_idx = self.getColumnIndex('Supplier_SKU') - # m_idx = self.getColumnIndex('Manufacturer_MPN') - # p_idx = self.getColumnIndex('Unit_Price') - # e_idx = self.getColumnIndex('Extended_Price') - - for row in self.rows: - - # 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_parts - - # Check if there is a column corresponding to "quantity" - if q_idx >= 0: - q_val = row['data'][q_idx] - - if q_val: - # Delete commas - q_val = q_val.replace(',','') - - try: - # Attempt to extract a valid quantity from the field - quantity = Decimal(q_val) - except (ValueError, InvalidOperation): - pass - - # Store the 'quantity' value - row['quantity'] = quantity - - # Check if there is a column corresponding to "Supplier SKU" - if s_idx >= 0: - sku = row['data'][s_idx] - - try: - # Attempt SupplierPart lookup based on SKU value - exact_match_part = SupplierPart.objects.get(SKU__contains=sku) - except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned): - exact_match_part = None - - # Check if there is a column corresponding to "Manufacturer MPN" - # if m_idx >= 0: - # row['part_mpn'] = row['data'][m_idx] - - # try: - # # Attempt ManufacturerPart lookup based on MPN value - # exact_match_part = ManufacturerPart.objects.get(MPN=row['part_mpn']) - # except (ValueError, ManufacturerPart.DoesNotExist): - # exact_match_part = None - - # 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 SKU or MPN, use that - row['part_match'] = exact_match_part - - def updatePartSelectionColumns(self, form): - # for idx, row in enumerate(self.rows): - # print(f'{idx} | {row}\n\n') - pass - - def getFileManager(self, form=None): - """ Create FileManager instance from upload file """ - - if self.file_manager: - return - - if self.steps.current == 'upload': - # Get file from form data - order_file = form.cleaned_data['file'] - self.file_manager = FileManager(file=order_file, name='order') - else: - # Retrieve stored files from upload step - upload_files = self.storage.get_step_files('upload') - # Get file - order_file = upload_files.get('upload-file', None) - if order_file: - self.file_manager = FileManager(file=order_file, name='order') - - def setupFieldSelection(self, form): - """ Setup fields form """ - - # Get FileManager - self.getFileManager(form) - # Setup headers - self.file_manager.setup() - # Set headers - self.headers = self.file_manager.HEADERS - # Set columns and rows - self.columns = self.file_manager.columns() - self.rows = self.file_manager.rows() - - def handleFieldSelection(self, form): - """ Process field matching """ - - # Retrieve FileManager instance from uploaded file - self.getFileManager(form) - - # Update headers - if self.file_manager: - self.file_manager.setup() - else: - return False - - # Extract form data - self.getTableDataFromForm(form.data) - - valid = len(self.missing_columns) == 0 and not self.duplicates - - return valid - - def getRowByIndex(self, idx): - - for row in self.rows: - if row['index'] == idx: - return row - - return None - - def handlePartSelection(self, form): - - # Retrieve FileManager instance from uploaded file - self.getFileManager(form) - - # Extract form data - self.getTableDataFromForm(form.data) - - # Keep track of the parts that have been selected - parts = {} - - # Extract other data (part selections, etc) - for key, value in form.data.items(): - - # 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.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') - - # 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 - - return valid - - def get_form_step_data(self, form): - """ Process form data after it has been posted """ - - # print(f'{self.steps.current=}\n{form.data=}') - - # Retrieve FileManager instance from uploaded file - self.getFileManager(form) - # print(f'{self.file_manager=}') - - # Process steps - if self.steps.current == 'upload': - self.setupFieldSelection(form) - elif self.steps.current == 'fields': - self.allowed_parts = SupplierPart.objects.all() - self.rows = self.file_manager.rows() - self.preFillSelections() - # self.updatePartSelectionColumns(form) - # elif self.steps.current == 'parts': - # self.handlePartSelection(form) - - return form.data - - def validate(self, step, form): - """ Validate forms """ - - valid = False - - # Process steps - if step == 'upload': - # Validation is done during POST - valid = True - elif step == 'fields': - # Validate user form data - valid = self.handleFieldSelection(form) - - if not valid: - form.add_error(None, 'Fields matching failed') - # Reload headers - self.headers = self.file_manager.HEADERS - - elif step == 'parts': - valid = self.handlePartSelection(form) - - # if not valid: - # pass - - return valid - - def post(self, request, *args, **kwargs): - """ Perform validations before posting data """ - - wizard_goto_step = self.request.POST.get('wizard_goto_step', None) - - form = self.get_form(data=self.request.POST, files=self.request.FILES) - - print(f'\nCurrent step = {self.steps.current}') - form_valid = self.validate(self.steps.current, form) - - if not form_valid and not wizard_goto_step: - # Re-render same step - return self.render(form) - - print('\nPosting... ') - return super().post(*args, **kwargs) - def done(self, form_list, **kwargs): return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) From 4f942fd9f7506fde7fcaeff5aa307e9192f57a69 Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 6 May 2021 17:07:22 -0400 Subject: [PATCH 11/48] Working towards item match form --- InvenTree/common/forms.py | 39 +++++- InvenTree/common/views.py | 115 ++++++++++++++++-- .../order/order_wizard/match_parts.html | 14 ++- 3 files changed, 152 insertions(+), 16 deletions(-) diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index ba349808e1..6238320c75 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -77,10 +77,11 @@ class MatchField(forms.Form): super().__init__(*args, **kwargs) - # Setup headers + # Setup FileManager file_manager.setup() + # Get columns columns = file_manager.columns() - # Find headers choices + # Get headers choices headers_choices = [(header, header) for header in file_manager.HEADERS] # Create column fields @@ -98,4 +99,38 @@ class MatchItem(forms.Form): """ Step 3 of FileManagementFormView """ def __init__(self, *args, **kwargs): + + # Get FileManager + file_manager = None + if 'file_manager' in kwargs: + file_manager = kwargs.pop('file_manager') + + if 'row_data' in kwargs: + row_data = kwargs.pop('row_data') + super().__init__(*args, **kwargs) + + # Setup FileManager + file_manager.setup() + # Get columns + columns = file_manager.columns() + + # Create fields + # Item selection + for row in row_data: + for col in row['data']: + print(f'{row["index"]=} | {col["column"]["guess"]=}') + if col['column']['guess']: + if col['column']['guess'] in file_manager.PART_MATCH_HEADERS: + # Get item options + item_options = row['item_options'] + # Get item match + item_match = row['item_match'] + + field_name = col['column']['guess'].lower() + '_' + str(row['index']) + self.fields[field_name] = forms.ChoiceField( + choices=item_options, + required=True, + ) + if item_match: + self.fields[field_name].initial = item_match diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index f8993584ab..98fad524c4 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -6,6 +6,8 @@ Django views for interacting with common models from __future__ import unicode_literals import os +import ast +from decimal import Decimal, InvalidOperation from django.utils.translation import ugettext_lazy as _ from django.forms import CheckboxInput, Select @@ -21,6 +23,8 @@ from . import models from . import forms from .files import FileManager +from part.models import SupplierPart + class SettingEdit(AjaxUpdateView): """ @@ -243,19 +247,25 @@ class FileManagementFormView(MultiStepFormView): self.getFileManager(step) if step == 'upload': + # Dynamically build upload form if self.name: - # Dynamically build upload form kwargs = { 'name': self.name } return kwargs elif step == 'fields': - if self.file_manager: - # Dynamically build match field form - kwargs = { - 'file_manager': self.file_manager - } - return kwargs + # Dynamically build match field form + kwargs = { + 'file_manager': self.file_manager + } + return kwargs + elif step == 'items': + # Dynamically build match item form + kwargs = {} + kwargs['file_manager'] = self.file_manager + self.getFieldSelections() + kwargs['row_data'] = self.rows + return kwargs return super().get_form_kwargs() @@ -281,7 +291,7 @@ class FileManagementFormView(MultiStepFormView): self.row_data = {} for item in form_data: - # print(f'{item} | {form_data[item]}') + # print(f'{item} | {form_data[item]} | {type(form_data[item])}') value = form_data[item] # Column names as passed as col_name_ where idx is an integer @@ -323,7 +333,9 @@ class FileManagementFormView(MultiStepFormView): if row_id not in self.row_data: self.row_data[row_id] = {} - self.row_data[row_id][col_id] = value + # TODO: this is a hack + value = value.replace("'", '"') + self.row_data[row_id][col_id] = ast.literal_eval(value) # self.col_ids = sorted(self.column_names.keys()) @@ -395,6 +407,91 @@ class FileManagementFormView(MultiStepFormView): self.extra_context_data['missing_columns'] = missing_columns self.extra_context_data['duplicates'] = duplicates + 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 getFieldSelections(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. + """ + + # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database + q_idx = self.getColumnIndex('Quantity') + s_idx = self.getColumnIndex('Supplier_SKU') + # m_idx = self.getColumnIndex('Manufacturer_MPN') + # p_idx = self.getColumnIndex('Unit_Price') + # e_idx = self.getColumnIndex('Extended_Price') + + self.allowed_items = SupplierPart.objects.all() + + for row in self.rows: + + # 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 + item_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) + except (ValueError, InvalidOperation): + pass + + # Store the 'quantity' value + row['quantity'] = quantity + + # Check if there is a column corresponding to "Supplier SKU" + if s_idx >= 0: + sku = row['data'][s_idx] + + try: + # Attempt SupplierPart lookup based on SKU value + exact_match_part = SupplierPart.objects.get(SKU__contains=sku) + except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned): + exact_match_part = None + + # Check if there is a column corresponding to "Manufacturer MPN" + # if m_idx >= 0: + # row['part_mpn'] = row['data'][m_idx] + + # try: + # # Attempt ManufacturerPart lookup based on MPN value + # exact_match_part = ManufacturerPart.objects.get(MPN=row['part_mpn']) + # except (ValueError, ManufacturerPart.DoesNotExist): + # exact_match_part = None + + # Supply list of part options for each row, sorted by how closely they match the part name + row['item_options'] = item_options + + # Unless found, the 'part_match' is blank + row['item_match'] = None + + if exact_match_part: + # If there is an exact match based on SKU or MPN, use that + row['item_match'] = exact_match_part + def checkFieldSelection(self, form): """ Check field matching """ diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html index 63cac689b4..abfe66577b 100644 --- a/InvenTree/order/templates/order/order_wizard/match_parts.html +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -39,7 +39,7 @@ - {% for row in rows %} + {% for row in form %} - {% add row.index 1 %} - {% if row.errors.part %}

    {{ row.errors.part }}

    - {% endif %} + {% endif %} {% endcomment %} {% for item in row.data %} + {% comment %} {% if item.column.guess == 'Quantity' %} {% if row.errors.quantity %} @@ -74,7 +78,7 @@ {% else %} {{ item.cell }} {% endif %} - + {% endcomment %} {% endfor %} From 6e269ae41a66c03a41beb04606d179988ec02943 Mon Sep 17 00:00:00 2001 From: eeintech Date: Fri, 7 May 2021 13:23:10 -0400 Subject: [PATCH 12/48] Now displaying item match table --- InvenTree/common/files.py | 8 +- InvenTree/common/forms.py | 48 +++++--- InvenTree/common/views.py | 103 +++++++++++++----- .../order/order_wizard/match_parts.html | 23 ++-- 4 files changed, 133 insertions(+), 49 deletions(-) diff --git a/InvenTree/common/files.py b/InvenTree/common/files.py index 6de08731a6..91fdc61a10 100644 --- a/InvenTree/common/files.py +++ b/InvenTree/common/files.py @@ -20,8 +20,8 @@ class FileManager: # Fields which are absolutely necessary for valid upload REQUIRED_HEADERS = [] - # Fields which are used for part matching (only one of them is needed) - PART_MATCH_HEADERS = [] + # Fields which are used for item matching (only one of them is needed) + ITEM_MATCH_HEADERS = [] # Fields which would be helpful but are not required OPTIONAL_HEADERS = [] @@ -83,7 +83,7 @@ class FileManager: def update_headers(self): """ Update headers """ - self.HEADERS = self.REQUIRED_HEADERS + self.PART_MATCH_HEADERS + self.OPTIONAL_HEADERS + self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_HEADERS def setup(self): """ Setup headers depending on the file name """ @@ -96,7 +96,7 @@ class FileManager: 'Quantity', ] - self.PART_MATCH_HEADERS = [ + self.ITEM_MATCH_HEADERS = [ 'Manufacturer_MPN', 'Supplier_SKU', ] diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index 6238320c75..8b09c28929 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -119,18 +119,40 @@ class MatchItem(forms.Form): # Item selection for row in row_data: for col in row['data']: - print(f'{row["index"]=} | {col["column"]["guess"]=}') - if col['column']['guess']: - if col['column']['guess'] in file_manager.PART_MATCH_HEADERS: - # Get item options - item_options = row['item_options'] - # Get item match - item_match = row['item_match'] - - field_name = col['column']['guess'].lower() + '_' + str(row['index']) - self.fields[field_name] = forms.ChoiceField( - choices=item_options, + if col['column']['guess'] in file_manager.REQUIRED_HEADERS: + field_name = col['column']['guess'].lower() + '-' + str(row['index'] - 1) + if 'quantity' in col['column']['guess'].lower(): + self.fields[field_name] = forms.CharField( required=True, + widget=forms.NumberInput(attrs={ + 'name': 'quantity' + str(row['index']), + 'class': 'numberinput', + 'type': 'number', + 'min': '1', + 'step': 'any', + 'value': row['quantity'], + }) ) - if item_match: - self.fields[field_name].initial = item_match + else: + self.fields[field_name] = forms.Input( + required=True, + widget=forms.Select(attrs={ + }) + ) + elif col['column']['guess'] in file_manager.ITEM_MATCH_HEADERS: + print(f'{row["index"]=} | {col["column"]["guess"]=} | {row.get("item_match", "No Match")}') + + # Get item options + item_options = [(option.id, option) for option in row['item_options']] + # Get item match + item_match = row['item_match'] + + field_name = col['column']['guess'].lower() + '-' + str(row['index'] - 1) + self.fields[field_name] = forms.ChoiceField( + choices=[('', '-' * 10)] + item_options, + required=True, + widget=forms.Select(attrs={'class': 'bomselect'}) + ) + if item_match: + print(f'{item_match=}') + self.fields[field_name].initial = item_match.id diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 98fad524c4..fd2b452527 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -23,7 +23,7 @@ from . import models from . import forms from .files import FileManager -from part.models import SupplierPart +from company.models import ManufacturerPart, SupplierPart class SettingEdit(AjaxUpdateView): @@ -195,12 +195,41 @@ class FileManagementFormView(MultiStepFormView): def get_context_data(self, form, **kwargs): context = super().get_context_data(form=form, **kwargs) - if self.steps.current == 'fields': + if self.steps.current == 'fields' or self.steps.current == 'items': # Get columns and row data columns = self.file_manager.columns() rows = self.file_manager.rows() + + + key_item_select = '' + key_quantity_select = '' + if self.steps.current == 'items': + # Get file manager + self.getFileManager() + # Find column key for item selection + for item in self.file_manager.ITEM_MATCH_HEADERS: + item = item.lower() + for key in form.fields.keys(): + print(f'{item=} is in {key=} ?') + if item in key: + key_item_select = item + break + break + + # Find column key for quantity selection + key_quantity_select = 'quantity' + # Optimize for template for row in rows: + + # Add item select field + if key_item_select: + row['item_select'] = key_item_select + '-' + str(row['index']) + print(f'{row["item_select"]}') + # Add quantity select field + if key_quantity_select: + row['quantity_select'] = key_quantity_select + '-' + str(row['index']) + row_data = row['data'] data = [] @@ -209,12 +238,16 @@ class FileManagementFormView(MultiStepFormView): data.append({ 'cell': item, 'idx': idx, - 'column': columns[idx] + 'column': columns[idx], }) row['data'] = data + print(f'\n{row=}') + context.update({'rows': rows}) + if self.steps.current == 'items': + context.update({'columns': columns}) # Load extra context data print(f'{self.extra_context_data=}') @@ -393,14 +426,14 @@ class FileManagementFormView(MultiStepFormView): # Check that at least one of the part match field is present part_match_found = False - for col in self.file_manager.PART_MATCH_HEADERS: + for col in self.file_manager.ITEM_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 self.file_manager.PART_MATCH_HEADERS: + for col in self.file_manager.ITEM_MATCH_HEADERS: missing_columns.append(col) # Store extra context data @@ -425,15 +458,16 @@ class FileManagementFormView(MultiStepFormView): The pre-fill data are then passed through to the part selection form. """ + match_supplier = False + match_manufacturer = False + # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database q_idx = self.getColumnIndex('Quantity') s_idx = self.getColumnIndex('Supplier_SKU') - # m_idx = self.getColumnIndex('Manufacturer_MPN') + m_idx = self.getColumnIndex('Manufacturer_MPN') # p_idx = self.getColumnIndex('Unit_Price') # e_idx = self.getColumnIndex('Extended_Price') - self.allowed_items = SupplierPart.objects.all() - for row in self.rows: # Initially use a quantity of zero @@ -442,9 +476,6 @@ class FileManagementFormView(MultiStepFormView): # Initially we do not have a part to reference exact_match_part = None - # A list of potential Part matches - item_options = self.allowed_items - # Check if there is a column corresponding to "quantity" if q_idx >= 0: q_val = row['data'][q_idx]['cell'] @@ -464,7 +495,10 @@ class FileManagementFormView(MultiStepFormView): # Check if there is a column corresponding to "Supplier SKU" if s_idx >= 0: - sku = row['data'][s_idx] + sku = row['data'][s_idx]['cell'] + + # Match for supplier + match_supplier = True try: # Attempt SupplierPart lookup based on SKU value @@ -473,17 +507,27 @@ class FileManagementFormView(MultiStepFormView): exact_match_part = None # Check if there is a column corresponding to "Manufacturer MPN" - # if m_idx >= 0: - # row['part_mpn'] = row['data'][m_idx] + if m_idx >= 0: + mpn = row['data'][m_idx]['cell'] + + # Match for manufacturer + if not match_supplier: + match_manufacturer = True + + try: + # Attempt ManufacturerPart lookup based on MPN value + exact_match_part = ManufacturerPart.objects.get(MPN__contains=mpn) + except (ValueError, ManufacturerPart.DoesNotExist, ManufacturerPart.MultipleObjectsReturned): + exact_match_part = None + + # Check if matching for supplier or manufacturer parts + if match_supplier: + self.allowed_items = SupplierPart.objects.all() + elif match_manufacturer: + self.allowed_items = ManufacturerPart.objects.all() - # try: - # # Attempt ManufacturerPart lookup based on MPN value - # exact_match_part = ManufacturerPart.objects.get(MPN=row['part_mpn']) - # except (ValueError, ManufacturerPart.DoesNotExist): - # exact_match_part = None - # Supply list of part options for each row, sorted by how closely they match the part name - row['item_options'] = item_options + row['item_options'] = self.allowed_items # Unless found, the 'part_match' is blank row['item_match'] = None @@ -502,6 +546,16 @@ class FileManagementFormView(MultiStepFormView): return valid + def checkPartSelection(self, form): + """ Check part matching """ + + # Extract form data + self.getFormTableData(form.data) + + valid = len(self.extra_context_data.get('missing_columns', [])) == 0 and not self.extra_context_data.get('duplicates', []) + + return valid + def validate(self, step, form): """ Validate forms """ @@ -519,11 +573,10 @@ class FileManagementFormView(MultiStepFormView): form.add_error(None, 'Fields matching failed') elif step == 'items': - # valid = self.checkPartSelection(form) + valid = self.checkPartSelection(form) - # if not valid: - # form.add_error(None, 'Items matching failed') - pass + if not valid: + form.add_error(None, 'Items matching failed') return valid diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html index abfe66577b..9994c9d41e 100644 --- a/InvenTree/order/templates/order/order_wizard/match_parts.html +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -39,7 +39,7 @@ - {% for row in form %} + {% for row in rows %} + {% for field in form.visible_fields %} + {% if field.name == row.item_select %} + {{ field }} + {% endif %} + {% endfor %} + {% comment %} + {% for field in form.visible_fields %} + {% if field.name == row.quantity_select %} + {{ field }} + {% endif %} + {% endfor %} + {% comment %} {% endcomment %} {% if row.errors.quantity %}

    {{ row.errors.quantity }}

    {% endif %} {% else %} {{ item.cell }} {% endif %} - {% endcomment %} + {% endfor %} From fbf24621f351b3f1e6276053b3048f8a1dcffcd2 Mon Sep 17 00:00:00 2001 From: eeintech Date: Fri, 7 May 2021 16:46:10 -0400 Subject: [PATCH 13/48] Getting there... --- InvenTree/common/forms.py | 78 ++--- InvenTree/common/views.py | 294 +++++++++--------- .../order/order_wizard/match_fields.html | 6 - .../order/order_wizard/match_parts.html | 17 +- 4 files changed, 188 insertions(+), 207 deletions(-) diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index 8b09c28929..3c1220dd0f 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -107,6 +107,8 @@ class MatchItem(forms.Form): if 'row_data' in kwargs: row_data = kwargs.pop('row_data') + else: + row_data = None super().__init__(*args, **kwargs) @@ -115,44 +117,46 @@ class MatchItem(forms.Form): # Get columns columns = file_manager.columns() - # Create fields - # Item selection - for row in row_data: - for col in row['data']: - if col['column']['guess'] in file_manager.REQUIRED_HEADERS: - field_name = col['column']['guess'].lower() + '-' + str(row['index'] - 1) - if 'quantity' in col['column']['guess'].lower(): - self.fields[field_name] = forms.CharField( - required=True, - widget=forms.NumberInput(attrs={ - 'name': 'quantity' + str(row['index']), - 'class': 'numberinput', - 'type': 'number', - 'min': '1', - 'step': 'any', - 'value': row['quantity'], - }) - ) - else: - self.fields[field_name] = forms.Input( + if row_data: + # Create fields + for row in row_data: + for col in row['data']: + # print(f"{col=}") + if col['column']['guess'] in file_manager.REQUIRED_HEADERS: + field_name = col['column']['guess'].lower() + '-' + str(row['index']) + if 'quantity' in col['column']['guess'].lower(): + self.fields[field_name] = forms.CharField( + required=True, + widget=forms.NumberInput(attrs={ + 'name': 'quantity' + str(row['index']), + 'class': 'numberinput', + 'type': 'number', + 'min': '0', + 'step': 'any', + 'value': row['quantity'], + }) + ) + else: + self.fields[field_name] = forms.Input( + required=True, + widget=forms.Select(attrs={ + }) + ) + elif col['column']['guess'] in file_manager.ITEM_MATCH_HEADERS: + # print(f'{row["index"]=} | {col["column"]["guess"]=} | {row.get("item_match", "No Match")}') + + # Get item options + item_options = [(option.id, option) for option in row['item_options']] + # Get item match + item_match = row['item_match'] + + field_name = col['column']['guess'].lower() + '-' + str(row['index']) + self.fields[field_name] = forms.ChoiceField( + choices=[('', '-' * 10)] + item_options, required=True, widget=forms.Select(attrs={ + 'class': 'select bomselect', }) ) - elif col['column']['guess'] in file_manager.ITEM_MATCH_HEADERS: - print(f'{row["index"]=} | {col["column"]["guess"]=} | {row.get("item_match", "No Match")}') - - # Get item options - item_options = [(option.id, option) for option in row['item_options']] - # Get item match - item_match = row['item_match'] - - field_name = col['column']['guess'].lower() + '-' + str(row['index'] - 1) - self.fields[field_name] = forms.ChoiceField( - choices=[('', '-' * 10)] + item_options, - required=True, - widget=forms.Select(attrs={'class': 'bomselect'}) - ) - if item_match: - print(f'{item_match=}') - self.fields[field_name].initial = item_match.id + if item_match: + self.fields[field_name].initial = item_match.id diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index fd2b452527..a5ad21b667 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -197,66 +197,24 @@ class FileManagementFormView(MultiStepFormView): if self.steps.current == 'fields' or self.steps.current == 'items': # Get columns and row data - columns = self.file_manager.columns() - rows = self.file_manager.rows() - + self.columns = self.file_manager.columns() + self.rows = self.file_manager.rows() + # Set form table data + self.set_form_table_data(form=form) - key_item_select = '' - key_quantity_select = '' - if self.steps.current == 'items': - # Get file manager - self.getFileManager() - # Find column key for item selection - for item in self.file_manager.ITEM_MATCH_HEADERS: - item = item.lower() - for key in form.fields.keys(): - print(f'{item=} is in {key=} ?') - if item in key: - key_item_select = item - break - break - - # Find column key for quantity selection - key_quantity_select = 'quantity' - - # Optimize for template - for row in rows: - - # Add item select field - if key_item_select: - row['item_select'] = key_item_select + '-' + str(row['index']) - print(f'{row["item_select"]}') - # Add quantity select field - if key_quantity_select: - row['quantity_select'] = key_quantity_select + '-' + str(row['index']) - - row_data = row['data'] - - data = [] - - for idx, item in enumerate(row_data): - data.append({ - 'cell': item, - 'idx': idx, - 'column': columns[idx], - }) - - row['data'] = data - - print(f'\n{row=}') - - context.update({'rows': rows}) - if self.steps.current == 'items': - context.update({'columns': columns}) + # if self.steps.current == 'items': + # for row in self.rows: + # print(f'{row=}') + context.update({'rows': self.rows}) + context.update({'columns': self.columns}) # Load extra context data - print(f'{self.extra_context_data=}') for key, items in self.extra_context_data.items(): context.update({key: items}) return context - def getFileManager(self, step=None, form=None): + def get_file_manager(self, step=None, form=None): """ Get FileManager instance from uploaded file """ if self.file_manager: @@ -274,10 +232,8 @@ class FileManagementFormView(MultiStepFormView): def get_form_kwargs(self, step=None): """ Update kwargs to dynamically build forms """ - print(f'[STEP] {step}') - # Always retrieve FileManager instance from uploaded file - self.getFileManager(step) + self.get_file_manager(step) if step == 'upload': # Dynamically build upload form @@ -296,14 +252,25 @@ class FileManagementFormView(MultiStepFormView): # Dynamically build match item form kwargs = {} kwargs['file_manager'] = self.file_manager - self.getFieldSelections() + + # Get data from fields step + data = self.storage.get_step_data('fields') + + # Process to update columns and rows + self.rows = self.file_manager.rows() + self.columns = self.file_manager.columns() + self.get_form_table_data(data) + self.set_form_table_data() + self.get_field_selection() + kwargs['row_data'] = self.rows + return kwargs return super().get_form_kwargs() - def getFormTableData(self, form_data): - """ Extract table cell data from form data. + def get_form_table_data(self, form_data): + """ Extract table cell data from form data and fields. These data are used to maintain state between sessions. Table data keys are as follows: @@ -314,18 +281,15 @@ class FileManagementFormView(MultiStepFormView): """ - # Store extra context data - self.extra_context_data = {} - # Map the columns self.column_names = {} self.column_selections = {} self.row_data = {} - for item in form_data: + for item, value in form_data.items(): # print(f'{item} | {form_data[item]} | {type(form_data[item])}') - value = form_data[item] + # value = form.data[item] # Column names as passed as col_name_ where idx is an integer @@ -368,79 +332,85 @@ class FileManagementFormView(MultiStepFormView): # TODO: this is a hack value = value.replace("'", '"') - self.row_data[row_id][col_id] = ast.literal_eval(value) + # print(f'{type(value)=} | {value=}') + try: + self.row_data[row_id][col_id] = ast.literal_eval(value) + except (ValueError, SyntaxError): + pass - # self.col_ids = sorted(self.column_names.keys()) + def set_form_table_data(self, form=None): + if self.row_data: + # Re-construct the row data + self.rows = [] - # Re-construct the data table - self.rows = [] + for row_idx in sorted(self.row_data.keys()): + row = self.row_data[row_idx] + items = [] - for row_idx in sorted(self.row_data.keys()): - row = self.row_data[row_idx] - items = [] + for col_idx in sorted(row.keys()): - for col_idx in sorted(row.keys()): + value = row[col_idx] + items.append(value) - value = row[col_idx] - items.append(value) + self.rows.append({ + 'index': row_idx, + 'data': items, + 'errors': {}, + }) + else: + # Update the row data + for row in self.rows: + row_data = row['data'] - self.rows.append({ - 'index': row_idx, - 'data': items, - 'errors': {}, - }) + data = [] - # Construct the column data - self.columns = [] + for idx, item in enumerate(row_data): + data.append({ + 'cell': item, + 'idx': idx, + 'column': self.columns[idx], + }) + + row['data'] = data - # Track any duplicate column selections - duplicates = [] + # In the item selection step: update row data to contain fields + if form and self.steps.current == 'items': + key_item_select = '' + key_quantity_select = '' - for col in self.column_names: + # Find column key for item selection + for item in self.file_manager.ITEM_MATCH_HEADERS: + item = item.lower() + for key in form.fields.keys(): + if item in key: + key_item_select = item + break + break - if col in self.column_selections: - guess = self.column_selections[col] - else: - guess = None + # Find column key for quantity selection + key_quantity_select = 'quantity' - header = ({ - 'name': self.column_names[col], - 'guess': guess - }) + # Update row data + for row in self.rows: + # Add item select field + if key_item_select: + row['item_select'] = key_item_select + '-' + str(row['index']) + # Add quantity select field + if key_quantity_select: + row['quantity_select'] = key_quantity_select + '-' + str(row['index']) - if guess: - n = list(self.column_selections.values()).count(self.column_selections[col]) - if n > 1: - header['duplicate'] = True - duplicates.append(col) + if self.column_names: + # Re-construct the column data + self.columns = [] - self.columns.append(header) + for key in self.column_names: + header = ({ + 'name': key, + 'guess': self.column_selections[key], + }) + self.columns.append(header) - # Are there any missing columns? - missing_columns = [] - - # Check that all required fields are present - for col in self.file_manager.REQUIRED_HEADERS: - if col not in self.column_selections.values(): - missing_columns.append(col) - - # Check that at least one of the part match field is present - part_match_found = False - for col in self.file_manager.ITEM_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 self.file_manager.ITEM_MATCH_HEADERS: - missing_columns.append(col) - - # Store extra context data - self.extra_context_data['missing_columns'] = missing_columns - self.extra_context_data['duplicates'] = duplicates - - def getColumnIndex(self, name): + def get_column_index(self, name): """ Return the index of the column with the given name. It named column is not found, return -1 """ @@ -452,7 +422,7 @@ class FileManagementFormView(MultiStepFormView): return idx - def getFieldSelections(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. @@ -460,13 +430,14 @@ class FileManagementFormView(MultiStepFormView): match_supplier = False match_manufacturer = False + self.allowed_items = None # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database - q_idx = self.getColumnIndex('Quantity') - s_idx = self.getColumnIndex('Supplier_SKU') - m_idx = self.getColumnIndex('Manufacturer_MPN') - # p_idx = self.getColumnIndex('Unit_Price') - # e_idx = self.getColumnIndex('Extended_Price') + q_idx = self.get_column_index('Quantity') + s_idx = self.get_column_index('Supplier_SKU') + m_idx = self.get_column_index('Manufacturer_MPN') + # p_idx = self.get_column_index('Unit_Price') + # e_idx = self.get_column_index('Extended_Price') for row in self.rows: @@ -495,6 +466,7 @@ class FileManagementFormView(MultiStepFormView): # Check if there is a column corresponding to "Supplier SKU" if s_idx >= 0: + print(f'{row["data"][s_idx]=}') sku = row['data'][s_idx]['cell'] # Match for supplier @@ -536,22 +508,51 @@ class FileManagementFormView(MultiStepFormView): # If there is an exact match based on SKU or MPN, use that row['item_match'] = exact_match_part - def checkFieldSelection(self, form): + def check_field_selection(self, form): """ Check field matching """ - # Extract form data - self.getFormTableData(form.data) + # Are there any missing columns? + missing_columns = [] - valid = len(self.extra_context_data.get('missing_columns', [])) == 0 and not self.extra_context_data.get('duplicates', []) + # Check that all required fields are present + for col in self.file_manager.REQUIRED_HEADERS: + if col not in self.column_selections.values(): + missing_columns.append(col) - return valid + # Check that at least one of the part match field is present + part_match_found = False + for col in self.file_manager.ITEM_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 self.file_manager.ITEM_MATCH_HEADERS: + missing_columns.append(col) - def checkPartSelection(self, form): - """ Check part matching """ + # Track any duplicate column selections + duplicates = [] - # Extract form data - self.getFormTableData(form.data) + for col in self.column_names: + if col in self.column_selections: + guess = self.column_selections[col] + else: + guess = None + + if guess: + n = list(self.column_selections.values()).count(self.column_selections[col]) + if n > 1: + duplicates.append(col) + + # Store extra context data + self.extra_context_data = { + 'missing_columns': missing_columns, + 'duplicates': duplicates, + } + + # Data validation valid = len(self.extra_context_data.get('missing_columns', [])) == 0 and not self.extra_context_data.get('duplicates', []) return valid @@ -559,24 +560,20 @@ class FileManagementFormView(MultiStepFormView): def validate(self, step, form): """ Validate forms """ - valid = False + valid = True - # Process steps - if step == 'upload': - # Validation is done during POST - valid = True - elif step == 'fields': + # Get form table data + self.get_form_table_data(form.data) + + if step == 'fields': # Validate user form data - valid = self.checkFieldSelection(form) + valid = self.check_field_selection(form) if not valid: form.add_error(None, 'Fields matching failed') elif step == 'items': - valid = self.checkPartSelection(form) - - if not valid: - form.add_error(None, 'Items matching failed') + pass return valid @@ -593,5 +590,4 @@ class FileManagementFormView(MultiStepFormView): # Re-render same step return self.render(form) - print('\nPosting... ') return super().post(*args, **kwargs) diff --git a/InvenTree/order/templates/order/order_wizard/match_fields.html b/InvenTree/order/templates/order/order_wizard/match_fields.html index 2d5dd3292b..bb96c2223f 100644 --- a/InvenTree/order/templates/order/order_wizard/match_fields.html +++ b/InvenTree/order/templates/order/order_wizard/match_fields.html @@ -54,12 +54,6 @@ {% for col in form %} {{ col }} - {% comment %} {% endcomment %} {% for duplicate in duplicates %} {% if duplicate == col.name %}