mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Match field step is now managed through form
This commit is contained in:
parent
f79382d96f
commit
e31452a6ad
@ -56,7 +56,7 @@ class FileManager:
|
|||||||
raw_data = file.read().decode('utf-8')
|
raw_data = file.read().decode('utf-8')
|
||||||
# Reset stream position to beginning of file
|
# Reset stream position to beginning of file
|
||||||
file.seek(0)
|
file.seek(0)
|
||||||
elif ext in ['.xls', '.xlsx']:
|
elif ext in ['.xls', '.xlsx', '.json', '.yaml', ]:
|
||||||
raw_data = file.read()
|
raw_data = file.read()
|
||||||
# Reset stream position to beginning of file
|
# Reset stream position to beginning of file
|
||||||
file.seek(0)
|
file.seek(0)
|
||||||
|
@ -5,8 +5,12 @@ Django forms for interacting with common objects
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
|
|
||||||
|
from .files import FileManager
|
||||||
from .models import InvenTreeSetting
|
from .models import InvenTreeSetting
|
||||||
|
|
||||||
|
|
||||||
@ -21,3 +25,77 @@ class SettingEditForm(HelperForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'value'
|
'value'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UploadFile(forms.Form):
|
||||||
|
""" Step 1 of FileManagementFormView """
|
||||||
|
|
||||||
|
file = forms.FileField(
|
||||||
|
label=_('File'),
|
||||||
|
help_text=_('Select file to upload'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
""" Update label and help_text """
|
||||||
|
|
||||||
|
# Get file name
|
||||||
|
name = None
|
||||||
|
if 'name' in kwargs:
|
||||||
|
name = kwargs.pop('name')
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if name:
|
||||||
|
# Update label and help_text with file name
|
||||||
|
self.fields['file'].label = _(f'{name.title()} File')
|
||||||
|
self.fields['file'].help_text = _(f'Select {name} file to upload')
|
||||||
|
|
||||||
|
def clean_file(self):
|
||||||
|
"""
|
||||||
|
Run tabular file validation.
|
||||||
|
If anything is wrong with the file, it will raise ValidationError
|
||||||
|
"""
|
||||||
|
|
||||||
|
file = self.cleaned_data['file']
|
||||||
|
|
||||||
|
# Validate file using FileManager class - will perform initial data validation
|
||||||
|
# (and raise a ValidationError if there is something wrong with the file)
|
||||||
|
FileManager.validate(file)
|
||||||
|
|
||||||
|
return file
|
||||||
|
|
||||||
|
|
||||||
|
class MatchField(forms.Form):
|
||||||
|
""" Step 2 of FileManagementFormView """
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
|
# Get FileManager
|
||||||
|
file_manager = None
|
||||||
|
if 'file_manager' in kwargs:
|
||||||
|
file_manager = kwargs.pop('file_manager')
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Setup headers
|
||||||
|
file_manager.setup()
|
||||||
|
columns = file_manager.columns()
|
||||||
|
# Find headers choices
|
||||||
|
headers_choices = [(header, header) for header in file_manager.HEADERS]
|
||||||
|
|
||||||
|
# Create column fields
|
||||||
|
for col in columns:
|
||||||
|
field_name = col['name']
|
||||||
|
self.fields[field_name] = forms.ChoiceField(
|
||||||
|
choices=[('', '-' * 10)] + headers_choices,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
if col['guess']:
|
||||||
|
self.fields[field_name].initial = col['guess']
|
||||||
|
|
||||||
|
|
||||||
|
class MatchItem(forms.Form):
|
||||||
|
""" Step 3 of FileManagementFormView """
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -19,6 +19,7 @@ from InvenTree.helpers import str2bool
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import forms
|
from . import forms
|
||||||
|
from .files import FileManager
|
||||||
|
|
||||||
|
|
||||||
class SettingEdit(AjaxUpdateView):
|
class SettingEdit(AjaxUpdateView):
|
||||||
@ -164,3 +165,283 @@ class MultiStepFormView(SessionWizardView):
|
|||||||
context.update({'description': description})
|
context.update({'description': description})
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class FileManagementFormView(MultiStepFormView):
|
||||||
|
""" Setup form wizard to perform the following steps:
|
||||||
|
1. Upload tabular data file
|
||||||
|
2. Match headers to InvenTree fields
|
||||||
|
3. Edit row data and match InvenTree items
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = None
|
||||||
|
form_list = [
|
||||||
|
('upload', forms.UploadFile),
|
||||||
|
('fields', forms.MatchField),
|
||||||
|
('items', forms.MatchItem),
|
||||||
|
]
|
||||||
|
form_steps_description = [
|
||||||
|
_("Upload File"),
|
||||||
|
_("Match Fields"),
|
||||||
|
_("Match Items"),
|
||||||
|
]
|
||||||
|
media_folder = 'file_upload/'
|
||||||
|
extra_context_data = {}
|
||||||
|
|
||||||
|
def get_context_data(self, form, **kwargs):
|
||||||
|
context = super().get_context_data(form=form, **kwargs)
|
||||||
|
|
||||||
|
if self.steps.current == 'fields':
|
||||||
|
# Get columns and row data
|
||||||
|
columns = self.file_manager.columns()
|
||||||
|
rows = self.file_manager.rows()
|
||||||
|
# Optimize for template
|
||||||
|
for row in rows:
|
||||||
|
row_data = row['data']
|
||||||
|
|
||||||
|
data = []
|
||||||
|
|
||||||
|
for idx, item in enumerate(row_data):
|
||||||
|
data.append({
|
||||||
|
'cell': item,
|
||||||
|
'idx': idx,
|
||||||
|
'column': columns[idx]
|
||||||
|
})
|
||||||
|
|
||||||
|
row['data'] = data
|
||||||
|
|
||||||
|
context.update({'rows': rows})
|
||||||
|
|
||||||
|
# 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):
|
||||||
|
""" Get FileManager instance from uploaded file """
|
||||||
|
|
||||||
|
if self.file_manager:
|
||||||
|
return
|
||||||
|
|
||||||
|
if step is not None:
|
||||||
|
# Retrieve stored files from upload step
|
||||||
|
upload_files = self.storage.get_step_files('upload')
|
||||||
|
if upload_files:
|
||||||
|
# Get file
|
||||||
|
file = upload_files.get('upload-file', None)
|
||||||
|
if file:
|
||||||
|
self.file_manager = FileManager(file=file, name=self.name)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if step == 'upload':
|
||||||
|
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
|
||||||
|
|
||||||
|
return super().get_form_kwargs()
|
||||||
|
|
||||||
|
def getFormTableData(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_<idx> - Column name at idx as provided in the uploaded file
|
||||||
|
col_guess_<idx> - Column guess at idx as selected
|
||||||
|
row_<x>_col<y> - Cell data as provided in the uploaded file
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
# print(f'{item} | {form_data[item]}')
|
||||||
|
value = form_data[item]
|
||||||
|
|
||||||
|
# Column names as passed as col_name_<idx> 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
|
||||||
|
|
||||||
|
self.column_names[value] = col_id
|
||||||
|
|
||||||
|
# Extract the column selections (in the 'select fields' view)
|
||||||
|
if item.startswith('fields-'):
|
||||||
|
|
||||||
|
try:
|
||||||
|
col_name = item.replace('fields-', '')
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.column_selections[col_name] = value
|
||||||
|
|
||||||
|
# Extract the row data
|
||||||
|
if item.startswith('row_'):
|
||||||
|
# Item should be of the format row_<r>_col_<c>
|
||||||
|
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
|
||||||
|
duplicates = []
|
||||||
|
|
||||||
|
for col in self.column_names:
|
||||||
|
|
||||||
|
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
|
||||||
|
duplicates.append(col)
|
||||||
|
|
||||||
|
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.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:
|
||||||
|
missing_columns.append(col)
|
||||||
|
|
||||||
|
# Store extra context data
|
||||||
|
self.extra_context_data['missing_columns'] = missing_columns
|
||||||
|
self.extra_context_data['duplicates'] = duplicates
|
||||||
|
|
||||||
|
def checkFieldSelection(self, form):
|
||||||
|
""" Check field 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 """
|
||||||
|
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
# Process steps
|
||||||
|
if step == 'upload':
|
||||||
|
# Validation is done during POST
|
||||||
|
valid = True
|
||||||
|
elif step == 'fields':
|
||||||
|
# Validate user form data
|
||||||
|
valid = self.checkFieldSelection(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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
@ -14,8 +14,6 @@ from InvenTree.forms import HelperForm
|
|||||||
from InvenTree.fields import RoundingDecimalFormField
|
from InvenTree.fields import RoundingDecimalFormField
|
||||||
from InvenTree.fields import DatePickerFormField
|
from InvenTree.fields import DatePickerFormField
|
||||||
|
|
||||||
from common.files import FileManager
|
|
||||||
|
|
||||||
import part.models
|
import part.models
|
||||||
|
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
@ -286,30 +284,3 @@ class EditSalesOrderAllocationForm(HelperForm):
|
|||||||
'line',
|
'line',
|
||||||
'item',
|
'item',
|
||||||
'quantity']
|
'quantity']
|
||||||
|
|
||||||
|
|
||||||
class UploadFile(forms.Form):
|
|
||||||
""" Step 1 """
|
|
||||||
file = forms.FileField(
|
|
||||||
label=_('Order File'),
|
|
||||||
help_text=_('Select order file to upload'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean_file(self):
|
|
||||||
file = self.cleaned_data['file']
|
|
||||||
|
|
||||||
# Validate file using FileManager class - will perform initial data validation
|
|
||||||
# (and raise a ValidationError if there is something wrong with the file)
|
|
||||||
FileManager.validate(file)
|
|
||||||
|
|
||||||
return file
|
|
||||||
|
|
||||||
|
|
||||||
class MatchField(forms.Form):
|
|
||||||
""" Step 2 """
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class MatchPart(forms.Form):
|
|
||||||
""" Step 3 """
|
|
||||||
pass
|
|
||||||
|
@ -15,12 +15,17 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if duplicates and duplicates|length > 0 %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock form_alert %}
|
{% endblock form_alert %}
|
||||||
|
|
||||||
{% block form_buttons_top %}
|
{% block form_buttons_top %}
|
||||||
{% comment %} {% if wizard.steps.prev %}
|
{% if wizard.steps.prev %}
|
||||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||||
{% endif %} {% endcomment %}
|
{% endif %}
|
||||||
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||||
{% endblock form_buttons_top %}
|
{% endblock form_buttons_top %}
|
||||||
|
|
||||||
@ -29,7 +34,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>{% trans "File Fields" %}</th>
|
<th>{% trans "File Fields" %}</th>
|
||||||
{% for col in columns %}
|
{% for col in form %}
|
||||||
<th>
|
<th>
|
||||||
<div>
|
<div>
|
||||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||||
@ -46,17 +51,22 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td>{% trans "Match Fields" %}</td>
|
<td>{% trans "Match Fields" %}</td>
|
||||||
{% for col in columns %}
|
{% for col in form %}
|
||||||
<td>
|
<td>
|
||||||
<select class='select' id='id_col_{{ forloop.counter0 }}' name='col_guess_{{ forloop.counter0 }}'>
|
{{ col }}
|
||||||
|
{% comment %} <select class='select' id='id_col_{{ forloop.counter0 }}' name='col_guess_{{ forloop.counter0 }}'>
|
||||||
<option value=''>---------</option>
|
<option value=''>---------</option>
|
||||||
{% for req in headers %}
|
{% for req in headers %}
|
||||||
<option value='{{ req }}'{% if req == col.guess %}selected='selected'{% endif %}>{{ req }}</option>
|
<option value='{{ req }}'{% if req == col.guess %}selected='selected'{% endif %}>{{ req }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select> {% endcomment %}
|
||||||
{% if col.duplicate %}
|
{% for duplicate in duplicates %}
|
||||||
<p class='help-inline'>{% trans "Duplicate column selection" %}</p>
|
{% if duplicate == col.name %}
|
||||||
|
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||||
|
<b>{% trans "Duplicate selection" %}</b>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -12,9 +12,9 @@
|
|||||||
{% endblock form_alert %}
|
{% endblock form_alert %}
|
||||||
|
|
||||||
{% block form_buttons_top %}
|
{% block form_buttons_top %}
|
||||||
{% comment %} {% if wizard.steps.prev %}
|
{% if wizard.steps.prev %}
|
||||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||||
{% endif %} {% endcomment %}
|
{% endif %}
|
||||||
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||||
{% endblock form_buttons_top %}
|
{% endblock form_buttons_top %}
|
||||||
|
|
||||||
|
@ -28,8 +28,7 @@ from stock.models import StockItem, StockLocation
|
|||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from common.views import MultiStepFormView
|
from common.views import FileManagementFormView
|
||||||
from common.files import FileManager
|
|
||||||
|
|
||||||
from . import forms as order_forms
|
from . import forms as order_forms
|
||||||
|
|
||||||
@ -567,14 +566,10 @@ class SalesOrderShip(AjaxUpdateView):
|
|||||||
return self.renderJsonResponse(request, form, data, context)
|
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) '''
|
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
|
||||||
|
|
||||||
form_list = [
|
name = 'order'
|
||||||
('upload', order_forms.UploadFile),
|
|
||||||
('fields', order_forms.MatchField),
|
|
||||||
('parts', order_forms.MatchPart),
|
|
||||||
]
|
|
||||||
form_steps_template = [
|
form_steps_template = [
|
||||||
'order/order_wizard/po_upload.html',
|
'order/order_wizard/po_upload.html',
|
||||||
'order/order_wizard/match_fields.html',
|
'order/order_wizard/match_fields.html',
|
||||||
@ -583,16 +578,8 @@ class PurchaseOrderUpload(MultiStepFormView):
|
|||||||
form_steps_description = [
|
form_steps_description = [
|
||||||
_("Upload File"),
|
_("Upload File"),
|
||||||
_("Match Fields"),
|
_("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):
|
def get_context_data(self, form, **kwargs):
|
||||||
context = super().get_context_data(form=form, **kwargs)
|
context = super().get_context_data(form=form, **kwargs)
|
||||||
@ -601,499 +588,8 @@ class PurchaseOrderUpload(MultiStepFormView):
|
|||||||
|
|
||||||
context.update({'order': order})
|
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
|
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_<idx> - Column name at idx as provided in the uploaded file
|
|
||||||
col_guess_<idx> - Column guess at idx as selected
|
|
||||||
row_<x>_col<y> - 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_<idx> 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_<r>_col_<c>
|
|
||||||
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):
|
def done(self, form_list, **kwargs):
|
||||||
return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']}))
|
return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']}))
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user