Merge pull request #1561 from eeintech/multi_part_forms

Multi-step form framework + Purchase order upload file view
This commit is contained in:
Oliver 2021-05-12 22:18:35 +10:00 committed by GitHub
commit 9d98ecca92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1308 additions and 2 deletions

View File

@ -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', [

240
InvenTree/common/files.py Normal file
View File

@ -0,0 +1,240 @@
"""
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 = []
# 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 = []
EDITABLE_HEADERS = []
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)
# Update headers
self.update_headers()
@classmethod
def validate(cls, file):
""" Validate file extension and data """
cleaned_data = None
ext = os.path.splitext(file.name)[-1].lower().replace('.', '')
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', 'json', 'yaml', ]:
raw_data = file.read()
# Reset stream position to beginning of file
file.seek(0)
else:
raise ValidationError(_(f'Unsupported file format: {ext.upper()}'))
try:
cleaned_data = tablib.Dataset().load(raw_data, format=ext)
except tablib.UnsupportedFormat:
raise ValidationError(_('Error reading file (invalid format)'))
except tablib.core.InvalidDimensions:
raise ValidationError(_('Error reading file (incorrect dimension)'))
except 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 """
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_HEADERS
def setup(self):
""" Setup headers depending on the file name """
if not self.name:
return
if self.name == 'order':
self.REQUIRED_HEADERS = [
'Quantity',
]
self.ITEM_MATCH_HEADERS = [
'Manufacturer_MPN',
'Supplier_SKU',
]
self.OPTIONAL_HEADERS = [
'Purchase_Price',
'Reference',
'Notes',
]
# Update headers
self.update_headers()
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:
# Guess header
guess = self.guess_header(header, threshold=95)
# Check if already present
guess_exists = False
for idx, data in enumerate(headers):
if guess == data['guess']:
guess_exists = True
break
if not guess_exists:
headers.append({
'name': header,
'guess': guess
})
else:
headers.append({
'name': header,
'guess': None
})
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
except TypeError:
data[idx] = ''
# 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]

View File

@ -5,8 +5,16 @@ Django forms for interacting with common objects
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from decimal import Decimal, InvalidOperation
from django import forms
from django.utils.translation import gettext as _
from djmoney.forms.fields import MoneyField
from InvenTree.forms import HelperForm
from .files import FileManager
from .models import InvenTreeSetting
@ -21,3 +29,183 @@ class SettingEditForm(HelperForm):
fields = [
'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 FileManager
file_manager.setup()
# Get columns
columns = file_manager.columns()
# Get 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,
widget=forms.Select(attrs={
'class': 'select fieldselect',
})
)
if col['guess']:
self.fields[field_name].initial = col['guess']
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')
else:
row_data = None
super().__init__(*args, **kwargs)
def clean(number):
""" Clean-up decimal value """
# Check if empty
if not number:
return number
# Check if decimal type
try:
clean_number = Decimal(number)
except InvalidOperation:
clean_number = number
return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize()
# Setup FileManager
file_manager.setup()
# Create fields
if row_data:
# Navigate row data
for row in row_data:
# Navigate column data
for col in row['data']:
# Get column matching
col_guess = col['column'].get('guess', None)
# Create input for required headers
if col_guess in file_manager.REQUIRED_HEADERS:
# Set field name
field_name = col_guess.lower() + '-' + str(row['index'])
# Set field input box
if 'quantity' in col_guess.lower():
self.fields[field_name] = forms.CharField(
required=False,
widget=forms.NumberInput(attrs={
'name': 'quantity' + str(row['index']),
'class': 'numberinput', # form-control',
'type': 'number',
'min': '0',
'step': 'any',
'value': clean(row.get('quantity', '')),
})
)
# Create item selection box
elif col_guess in file_manager.ITEM_MATCH_HEADERS:
# Get item options
item_options = [(option.id, option) for option in row['item_options']]
# Get item match
item_match = row['item_match']
# Set field name
field_name = 'item_select-' + str(row['index'])
# Set field select box
self.fields[field_name] = forms.ChoiceField(
choices=[('', '-' * 10)] + item_options,
required=False,
widget=forms.Select(attrs={
'class': 'select bomselect',
})
)
# Update select box when match was found
if item_match:
# Make it a required field: does not validate if
# removed using JS function
# self.fields[field_name].required = True
# Update initial value
self.fields[field_name].initial = item_match.id
# Optional entries
elif col_guess in file_manager.OPTIONAL_HEADERS:
# Set field name
field_name = col_guess.lower() + '-' + str(row['index'])
# Get value
value = row.get(col_guess.lower(), '')
# Set field input box
if 'price' in col_guess.lower():
self.fields[field_name] = MoneyField(
label=_(col_guess),
default_currency=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'),
decimal_places=5,
max_digits=19,
required=False,
default_amount=clean(value),
)
else:
self.fields[field_name] = forms.CharField(
required=False,
initial=value,
)

View File

@ -5,14 +5,21 @@ 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
from InvenTree.views import AjaxUpdateView
from InvenTree.helpers import str2bool
from . import models
from . import forms
from .files import FileManager
class SettingEdit(AjaxUpdateView):
@ -101,3 +108,392 @@ 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 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_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[self.steps.index]
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 form description
try:
description = self.form_steps_description[self.steps.index]
except IndexError:
description = ''
# Add description to form steps
context.update({'description': description})
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 in ('fields', 'items'):
# Get columns and row data
self.columns = self.file_manager.columns()
self.rows = self.file_manager.rows()
# Check for stored data
stored_data = self.storage.get_step_data(self.steps.current)
if stored_data:
self.get_form_table_data(stored_data)
elif self.steps.current == 'items':
# Set form table data
self.set_form_table_data(form=form)
# Update context
context.update({'rows': self.rows})
context.update({'columns': self.columns})
# Load extra context data
for key, items in self.extra_context_data.items():
context.update({key: items})
return context
def get_file_manager(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 """
# Always retrieve FileManager instance from uploaded file
self.get_file_manager(step)
if step == 'upload':
# Dynamically build upload form
if self.name:
kwargs = {
'name': self.name
}
return kwargs
elif step == 'fields':
# 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
# 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 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:
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, value in form_data.items():
# 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[col_id] = value
# Extract the column selections (in the 'select fields' view)
if item.startswith('fields-'):
try:
col_name = item.replace('fields-', '')
except ValueError:
continue
for idx, name in self.column_names.items():
if name == col_name:
self.column_selections[idx] = value
break
# 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
def set_form_table_data(self, form=None):
""" Set the form table data """
if self.column_names:
# Re-construct the column data
self.columns = []
for idx, value in self.column_names.items():
header = ({
'name': value,
'guess': self.column_selections.get(idx, ''),
})
self.columns.append(header)
if self.row_data:
# Re-construct the row data
self.rows = []
# Update the row data
for row_idx, row_key in enumerate(sorted(self.row_data.keys())):
row_data = self.row_data[row_key]
data = []
for idx, item in row_data.items():
column_data = {
'name': self.column_names[idx],
'guess': self.column_selections[idx],
}
cell_data = {
'cell': item,
'idx': idx,
'column': column_data,
}
data.append(cell_data)
row = {
'index': row_idx,
'data': data,
'errors': {},
}
self.rows.append(row)
# In the item selection step: update row data with mapping to form fields
if form and self.steps.current == 'items':
# Find field keys
field_keys = []
for field in form.fields:
field_key = field.split('-')[0]
if field_key not in field_keys:
field_keys.append(field_key)
# Populate rows
for row in self.rows:
for field_key in field_keys:
# Map row data to field
row[field_key] = field_key + '-' + str(row['index'])
def get_column_index(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 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.
This method is very specific to the type of data found in the file,
therefore overwrite it in the subclass.
"""
pass
def check_field_selection(self, form):
""" Check field matching """
# 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)
# 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
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 = not missing_columns and not duplicates
return valid
def validate(self, step, form):
""" Validate forms """
valid = True
# Get form table data
self.get_form_table_data(form.data)
if step == 'fields':
# Validate user form data
valid = self.check_field_selection(form)
if not valid:
form.add_error(None, _('Fields matching failed'))
elif step == 'items':
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)
return super().post(*args, **kwargs)

View File

@ -0,0 +1,99 @@
{% extends "order/order_wizard/po_upload.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block form_alert %}
{% if missing_columns and missing_columns|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Missing selections for the following required columns" %}:
<br>
<ul>
{% for col in missing_columns %}
<li>{{ col }}</li>
{% endfor %}
</ul>
</div>
{% 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 %}
{% block form_buttons_top %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
{% endblock form_buttons_top %}
{% block form_content %}
<thead>
<tr>
<th>{% trans "File Fields" %}</th>
<th></th>
{% for col in form %}
<th>
<div>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
{{ col.name }}
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
</button>
</div>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td>{% trans "Match Fields" %}</td>
<td></td>
{% for col in form %}
<td>
{{ col }}
{% for duplicate in duplicates %}
{% 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 %}
{% endfor %}
</td>
{% endfor %}
</tr>
{% for row in rows %}
{% with forloop.counter as row_index %}
<tr>
<td style='width: 32px;'>
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
</button>
</td>
<td style='text-align: left;'>{{ row_index }}</td>
{% for item in row.data %}
<td>
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
{{ item }}
</td>
{% endfor %}
</tr>
{% endwith %}
{% endfor %}
</tbody>
{% endblock form_content %}
{% block form_buttons_bottom %}
{% endblock form_buttons_bottom %}
{% block js_ready %}
{{ block.super }}
$('.fieldselect').select2({
width: '100%',
matcher: partialMatcher,
});
{% endblock %}

View File

@ -0,0 +1,125 @@
{% extends "order/order_wizard/po_upload.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block form_alert %}
{% if form.errors %}
{% endif %}
{% if form_errors %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Errors exist in the submitted data" %}
</div>
{% endif %}
{% endblock form_alert %}
{% block form_buttons_top %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
{% endblock form_buttons_top %}
{% block form_content %}
<thead>
<tr>
<th></th>
<th>{% trans "Row" %}</th>
<th>{% trans "Select Supplier Part" %}</th>
<th>{% trans "Quantity" %}</th>
{% for col in columns %}
{% if col.guess != 'Quantity' %}
<th>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
{% if col.guess %}
{{ col.guess }}
{% else %}
{{ col.name }}
{% endif %}
</th>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody>
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
{% for row in rows %}
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
<td>
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
</button>
</td>
<td>
{% add row.index 1 %}
</td>
<td>
{% for field in form.visible_fields %}
{% if field.name == row.item_select %}
{{ field }}
{% endif %}
{% endfor %}
{% if row.errors.part %}
<p class='help-inline'>{{ row.errors.part }}</p>
{% endif %}
</td>
<td>
{% for field in form.visible_fields %}
{% if field.name == row.quantity %}
{{ field }}
{% endif %}
{% endfor %}
{% if row.errors.quantity %}
<p class='help-inline'>{{ row.errors.quantity }}</p>
{% endif %}
</td>
{% for item in row.data %}
{% if item.column.guess != 'Quantity' %}
<td>
{% if item.column.guess == 'Purchase_Price' %}
{% for field in form.visible_fields %}
{% if field.name == row.purchase_price %}
{{ field }}
{% endif %}
{% endfor %}
{% elif item.column.guess == 'Reference' %}
{% for field in form.visible_fields %}
{% if field.name == row.reference %}
{{ field }}
{% endif %}
{% endfor %}
{% elif item.column.guess == 'Notes' %}
{% for field in form.visible_fields %}
{% if field.name == row.notes %}
{{ field }}
{% endif %}
{% endfor %}
{% else %}
{{ item.cell }}
{% endif %}
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
{% endblock form_content %}
{% block form_buttons_bottom %}
{% endblock form_buttons_bottom %}
{% block js_ready %}
{{ block.super }}
$('.bomselect').select2({
dropdownAutoWidth: true,
matcher: partialMatcher,
});
$('.currencyselect').select2({
dropdownAutoWidth: true,
});
{% endblock %}

View File

@ -0,0 +1,56 @@
{% 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 %}
{% trans "Upload File for Purchase Order" %}
{{ wizard.form.media }}
{% endblock %}
{% block details %}
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
{% block form_alert %}
{% endblock form_alert %}
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
{% else %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Order is already processed. Files cannot be uploaded." %}
</div>
{% endif %}
{% endblock details %}
{% block js_ready %}
{{ block.super }}
{% endblock %}

View File

@ -1,6 +1,7 @@
{% load i18n %}
{% load static %}
{% load inventree_extras %}
{% load status_codes %}
<ul class='list-group'>
<li class='list-group-item'>
@ -14,6 +15,14 @@
{% trans "Details" %}
</a>
</li>
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
<li class='list-group-item {% if tab == "upload" %}active{% endif %}' title='{% trans "Upload File" %}'>
<a href='{% url "po-upload" order.id %}'>
<span class='fas fa-file-upload'></span>
{% trans "Upload File" %}
</a>
</li>
{% endif %}
<li class='list-group-item {% if tab == "received" %}active{% endif %}' title='{% trans "Received Stock Items" %}'>
<a href='{% url "po-received" order.id %}'>
<span class='fas fa-sign-in-alt'></span>

View File

@ -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'),

View File

@ -6,10 +6,12 @@ Django views for interacting with Order app
from __future__ import unicode_literals
from django.db import transaction
from django.db.utils import IntegrityError
from django.http.response import JsonResponse
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
@ -23,11 +25,12 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
from .models import SalesOrderAllocation
from .admin import POLineItemResource
from build.models import Build
from company.models import Company, SupplierPart
from company.models import Company, SupplierPart # ManufacturerPart
from stock.models import StockItem, StockLocation
from part.models import Part
from common.models import InvenTreeSetting
from common.views import FileManagementFormView
from . import forms as order_forms
from part.views import PartPricing
@ -566,6 +569,192 @@ class SalesOrderShip(AjaxUpdateView):
return self.renderJsonResponse(request, form, data, context)
class PurchaseOrderUpload(FileManagementFormView):
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
name = 'order'
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"),
_("Match Fields"),
_("Match Supplier Parts"),
]
# Form field name: PurchaseOrderLineItem field
form_field_map = {
'item_select': 'part',
'quantity': 'quantity',
'purchase_price': 'purchase_price',
'reference': 'reference',
'notes': 'notes',
}
def get_order(self):
""" Get order or return 404 """
return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
def get_context_data(self, form, **kwargs):
context = super().get_context_data(form=form, **kwargs)
order = self.get_order()
context.update({'order': order})
return context
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 SupplierPart selection form.
"""
order = self.get_order()
self.allowed_items = SupplierPart.objects.filter(supplier=order.supplier).prefetch_related('manufacturer_part')
# Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database
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('Purchase_Price')
r_idx = self.get_column_index('Reference')
n_idx = self.get_column_index('Notes')
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
# Check if there is a column corresponding to "quantity"
if q_idx >= 0:
q_val = row['data'][q_idx]['cell']
if q_val:
# Delete commas
q_val = q_val.replace(',', '')
try:
# Attempt to extract a valid quantity from the field
quantity = Decimal(q_val)
# Store the 'quantity' value
row['quantity'] = quantity
except (ValueError, InvalidOperation):
pass
# Check if there is a column corresponding to "Supplier SKU"
if s_idx >= 0:
sku = row['data'][s_idx]['cell']
try:
# Attempt SupplierPart lookup based on SKU value
exact_match_part = self.allowed_items.get(SKU__contains=sku)
except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned):
exact_match_part = None
# Check if there is a column corresponding to "Manufacturer MPN" and no exact match found yet
if m_idx >= 0 and not exact_match_part:
mpn = row['data'][m_idx]['cell']
try:
# Attempt SupplierPart lookup based on MPN value
exact_match_part = self.allowed_items.get(manufacturer_part__MPN__contains=mpn)
except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned):
exact_match_part = None
# Supply list of part options for each row, sorted by how closely they match the part name
row['item_options'] = self.allowed_items
# 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
# Check if there is a column corresponding to "purchase_price"
if p_idx >= 0:
p_val = row['data'][p_idx]['cell']
if p_val:
# Delete commas
p_val = p_val.replace(',', '')
try:
# Attempt to extract a valid decimal value from the field
purchase_price = Decimal(p_val)
# Store the 'purchase_price' value
row['purchase_price'] = purchase_price
except (ValueError, InvalidOperation):
pass
# Check if there is a column corresponding to "reference"
if r_idx >= 0:
reference = row['data'][r_idx]['cell']
row['reference'] = reference
# Check if there is a column corresponding to "notes"
if n_idx >= 0:
notes = row['data'][n_idx]['cell']
row['notes'] = notes
def done(self, form_list, **kwargs):
""" Once all the data is in, process it to add PurchaseOrderLineItem instances to the order """
order = self.get_order()
items = {}
for form_key, form_value in self.get_all_cleaned_data().items():
# Split key from row value
try:
(field, idx) = form_key.split('-')
except ValueError:
continue
if idx not in items:
# Insert into items
items.update({
idx: {
self.form_field_map[field]: form_value,
}
})
else:
# Update items
items[idx][self.form_field_map[field]] = form_value
# Create PurchaseOrderLineItem instances
for purchase_order_item in items.values():
try:
supplier_part = SupplierPart.objects.get(pk=int(purchase_order_item['part']))
except (ValueError, SupplierPart.DoesNotExist):
continue
quantity = purchase_order_item.get('quantity', 0)
if quantity:
purchase_order_line_item = PurchaseOrderLineItem(
order=order,
part=supplier_part,
quantity=quantity,
purchase_price=purchase_order_item.get('purchase_price', None),
reference=purchase_order_item.get('reference', ''),
notes=purchase_order_item.get('notes', ''),
)
try:
purchase_order_line_item.save()
except IntegrityError:
# PurchaseOrderLineItem already exists
pass
return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']}))
class PurchaseOrderExport(AjaxView):
""" File download for a purchase order

View File

@ -11,9 +11,10 @@ django-markdownx==3.0.1 # Markdown form fields
django-markdownify==0.8.0 # Markdown rendering
coreapi==2.3.0 # API documentation
pygments==2.7.4 # Syntax highlighting
tablib==0.13.0 # Import / export data files
# tablib==0.13.0 # Import / export data files (installed as dependency of django-import-export package)
django-crispy-forms==1.11.2 # Form helpers
django-import-export==2.0.0 # Data import / export for admin interface
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
flake8==3.8.3 # PEP checking
pep8-naming==0.11.1 # PEP naming convention extension
@ -32,5 +33,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