Merge pull request #2603 from SchrodingersGat/bom-upload-improvements

Bom upload improvements
This commit is contained in:
Oliver 2022-02-07 14:46:25 +11:00 committed by GitHub
commit c94c0902b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 742 additions and 659 deletions

View File

@ -2,6 +2,8 @@
Custom field validators for InvenTree
"""
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@ -115,26 +117,28 @@ def validate_tree_name(value):
def validate_overage(value):
""" Validate that a BOM overage string is properly formatted.
"""
Validate that a BOM overage string is properly formatted.
An overage string can look like:
- An integer number ('1' / 3 / 4)
- A decimal number ('0.123')
- A percentage ('5%' / '10 %')
"""
value = str(value).lower().strip()
# First look for a simple integer value
# First look for a simple numerical value
try:
i = int(value)
i = Decimal(value)
if i < 0:
raise ValidationError(_("Overage value must not be negative"))
# Looks like an integer!
# Looks like a number
return True
except ValueError:
except (ValueError, InvalidOperation):
pass
# Now look for a percentage value
@ -155,7 +159,7 @@ def validate_overage(value):
pass
raise ValidationError(
_("Overage must be an integer value or a percentage")
_("Invalid value for overage")
)

View File

@ -1533,6 +1533,40 @@ class BomList(generics.ListCreateAPIView):
]
class BomExtract(generics.CreateAPIView):
"""
API endpoint for extracting BOM data from a BOM file.
"""
queryset = Part.objects.none()
serializer_class = part_serializers.BomExtractSerializer
def create(self, request, *args, **kwargs):
"""
Custom create function to return the extracted data
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
data = serializer.extract_data()
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
class BomUpload(generics.CreateAPIView):
"""
API endpoint for uploading a complete Bill of Materials.
It is assumed that the BOM has been extracted from a file using the BomExtract endpoint.
"""
queryset = Part.objects.all()
serializer_class = part_serializers.BomUploadSerializer
class BomDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a single BomItem object """
@ -1685,6 +1719,10 @@ bom_api_urls = [
url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
])),
url(r'^extract/', BomExtract.as_view(), name='api-bom-extract'),
url(r'^upload/', BomUpload.as_view(), name='api-bom-upload'),
# Catch-all
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
]

View File

@ -4,9 +4,11 @@ JSON serializers for Part app
import imghdr
from decimal import Decimal
import os
import tablib
from django.urls import reverse_lazy
from django.db import models
from django.db import models, transaction
from django.db.models import Q
from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
@ -462,7 +464,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
price_range = serializers.CharField(read_only=True)
quantity = InvenTreeDecimalField()
quantity = InvenTreeDecimalField(required=True)
def validate_quantity(self, quantity):
if quantity <= 0:
raise serializers.ValidationError(_("Quantity must be greater than zero"))
return quantity
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
@ -699,3 +707,289 @@ class PartCopyBOMSerializer(serializers.Serializer):
skip_invalid=data.get('skip_invalid', False),
include_inherited=data.get('include_inherited', False),
)
class BomExtractSerializer(serializers.Serializer):
"""
Serializer for uploading a file and extracting data from it.
Note: 2022-02-04 - This needs a *serious* refactor in future, probably
When parsing the file, the following things happen:
a) Check file format and validity
b) Look for "required" fields
c) Look for "part" fields - used to "infer" part
Once the file itself has been validated, we iterate through each data row:
- If the "level" column is provided, ignore anything below level 1
- Try to "guess" the part based on part_id / part_name / part_ipn
- Extract other fields as required
"""
class Meta:
fields = [
'bom_file',
'part',
'clear_existing',
]
# These columns must be present
REQUIRED_COLUMNS = [
'quantity',
]
# We need at least one column to specify a "part"
PART_COLUMNS = [
'part',
'part_id',
'part_name',
'part_ipn',
]
# These columns are "optional"
OPTIONAL_COLUMNS = [
'allow_variants',
'inherited',
'optional',
'overage',
'note',
'reference',
]
def find_matching_column(self, col_name, columns):
# Direct match
if col_name in columns:
return col_name
col_name = col_name.lower().strip()
for col in columns:
if col.lower().strip() == col_name:
return col
# No match
return None
def find_matching_data(self, row, col_name, columns):
"""
Extract data from the row, based on the "expected" column name
"""
col_name = self.find_matching_column(col_name, columns)
return row.get(col_name, None)
bom_file = serializers.FileField(
label=_("BOM File"),
help_text=_("Select Bill of Materials file"),
required=True,
allow_empty_file=False,
)
def validate_bom_file(self, bom_file):
"""
Perform validation checks on the uploaded BOM file
"""
self.filename = bom_file.name
name, ext = os.path.splitext(bom_file.name)
# Remove the leading . from the extension
ext = ext[1:]
accepted_file_types = [
'xls', 'xlsx',
'csv', 'tsv',
'xml',
]
if ext not in accepted_file_types:
raise serializers.ValidationError(_("Unsupported file type"))
# Impose a 50MB limit on uploaded BOM files
max_upload_file_size = 50 * 1024 * 1024
if bom_file.size > max_upload_file_size:
raise serializers.ValidationError(_("File is too large"))
# Read file data into memory (bytes object)
data = bom_file.read()
if ext in ['csv', 'tsv', 'xml']:
data = data.decode()
# Convert to a tablib dataset (we expect headers)
self.dataset = tablib.Dataset().load(data, ext, headers=True)
for header in self.REQUIRED_COLUMNS:
match = self.find_matching_column(header, self.dataset.headers)
if match is None:
raise serializers.ValidationError(_("Missing required column") + f": '{header}'")
part_column_matches = {}
part_match = False
for col in self.PART_COLUMNS:
col_match = self.find_matching_column(col, self.dataset.headers)
part_column_matches[col] = col_match
if col_match is not None:
part_match = True
if not part_match:
raise serializers.ValidationError(_("No part column found"))
return bom_file
def extract_data(self):
"""
Read individual rows out of the BOM file
"""
rows = []
headers = self.dataset.headers
level_column = self.find_matching_column('level', headers)
for row in self.dataset.dict:
"""
If the "level" column is specified, and this is not a top-level BOM item, ignore the row!
"""
if level_column is not None:
level = row.get('level', None)
if level is not None:
try:
level = int(level)
if level != 1:
continue
except:
pass
"""
Next, we try to "guess" the part, based on the provided data.
A) If the part_id is supplied, use that!
B) If the part name and/or part_ipn are supplied, maybe we can use those?
"""
part_id = self.find_matching_data(row, 'part_id', headers)
part_name = self.find_matching_data(row, 'part_name', headers)
part_ipn = self.find_matching_data(row, 'part_ipn', headers)
part = None
if part_id is not None:
try:
part = Part.objects.get(pk=part_id)
except (ValueError, Part.DoesNotExist):
pass
# Optionally, specify using field "part"
if part is None:
pk = self.find_matching_data(row, 'part', headers)
if pk is not None:
try:
part = Part.objects.get(pk=pk)
except (ValueError, Part.DoesNotExist):
pass
if part is None:
if part_name is not None or part_ipn is not None:
queryset = Part.objects.all()
if part_name is not None:
queryset = queryset.filter(name=part_name)
if part_ipn is not None:
queryset = queryset.filter(IPN=part_ipn)
# Only if we have a single direct match
if queryset.exists() and queryset.count() == 1:
part = queryset.first()
row['part'] = part.pk if part is not None else None
rows.append(row)
return {
'rows': rows,
'headers': headers,
'filename': self.filename,
}
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True), required=True)
clear_existing = serializers.BooleanField(
label=_("Clear Existing BOM"),
help_text=_("Delete existing BOM data first"),
)
def save(self):
data = self.validated_data
master_part = data['part']
clear_existing = data['clear_existing']
if clear_existing:
# Remove all existing BOM items
master_part.bom_items.all().delete()
class BomUploadSerializer(serializers.Serializer):
"""
Serializer for uploading a BOM against a specified part.
A "BOM" is a set of BomItem objects which are to be validated together as a set
"""
items = BomItemSerializer(many=True, required=True)
def validate(self, data):
items = data['items']
if len(items) == 0:
raise serializers.ValidationError(_("At least one BOM item is required"))
data = super().validate(data)
return data
def save(self):
data = self.validated_data
items = data['items']
try:
with transaction.atomic():
for item in items:
part = item['part']
sub_part = item['sub_part']
# Ignore duplicate BOM items
if BomItem.objects.filter(part=part, sub_part=sub_part).exists():
continue
# Create a new BomItem object
BomItem.objects.create(**item)
except Exception as e:
raise serializers.ValidationError(detail=serializers.as_serializer_error(e))

View File

@ -1,99 +0,0 @@
{% extends "part/bom_upload/upload_file.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block form_alert %}
{% if missing_columns and missing_columns|length > 0 %}
<div class='alert alert-danger alert-block' style='margin-top:12px;' 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-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% 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-outline-secondary 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.value %}
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
<strong>{% trans "Duplicate selection" %}</strong>
</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-outline-secondary 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

@ -1,127 +0,0 @@
{% extends "part/bom_upload/upload_file.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% load crispy_forms_tags %}
{% block form_alert %}
{% if form.errors %}
{% endif %}
{% if form_errors %}
<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-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Submit Selections" %}</button>
{% endblock form_buttons_top %}
{% block form_content %}
<thead>
<tr>
<th></th>
<th>{% trans "Row" %}</th>
<th>{% trans "Select Part" %}</th>
<th>{% trans "Reference" %}</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-outline-secondary 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.reference %}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% if row.errors.reference %}
<p class='help-inline'>{{ row.errors.reference }}</p>
{% endif %}
</td>
<td>
{% for field in form.visible_fields %}
{% if field.name == row.quantity %}
{{ field|as_crispy_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 == 'Overage' %}
{% for field in form.visible_fields %}
{% if field.name == row.overage %}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% elif item.column.guess == 'Note' %}
{% for field in form.visible_fields %}
{% if field.name == row.note %}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% else %}
{{ item.cell }}
{% endif %}
<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,
});
{% endblock %}

View File

@ -1,67 +0,0 @@
{% extends "part/part_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block sidebar %}
{% url "part-detail" part.id as url %}
{% trans "Return to BOM" as text %}
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
{% endblock %}
{% block heading %}
{% trans "Upload Bill of Materials" %}
{% endblock %}
{% block actions %}
{% endblock %}
{% block page_info %}
<div class='panel-content'>
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
<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 %}
{% block form_alert %}
<div class='alert alert-info alert-block'>
<strong>{% trans "Requirements for BOM upload" %}:</strong>
<ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li>
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div>
{% endblock %}
<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-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
</div>
{% endblock page_info %}
{% block js_ready %}
{{ block.super }}
enableSidebar('bom-upload');
$('#bom-template-download').click(function() {
downloadBomTemplate();
});
{% endblock js_ready %}

View File

@ -0,0 +1,105 @@
{% extends "part/part_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block sidebar %}
{% url "part-detail" part.id as url %}
{% trans "Return to BOM" as text %}
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
{% endblock %}
{% block heading %}
{% trans "Upload Bill of Materials" %}
{% endblock %}
{% block actions %}
<!--
<button type='button' class='btn btn-outline-secondary' id='bom-info'>
<span class='fas fa-info-circle' title='{% trans "BOM upload requirements" %}'></span>
</button>
-->
<button type='button' class='btn btn-primary' id='bom-upload'>
<span class='fas fa-file-upload'></span> {% trans "Upload BOM File" %}
</button>
<button type='button' class='btn btn-success' id='bom-submit' style='display: none;'>
<span class='fas fa-sign-in-alt'></span> {% trans "Submit BOM Data" %}
</button>
{% endblock %}
{% block page_info %}
<div class='panel-content'>
<div class='alert alert-info alert-block'>
<strong>{% trans "Requirements for BOM upload" %}:</strong>
<ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li>
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div>
<div id='non-field-errors'>
<!-- Upload error messages go here -->
</div>
<!-- This table is filled out after BOM file is uploaded and processed -->
<table class='table table-striped table-condensed' id='bom-import-table'>
<thead>
<tr>
<th style='max-width: 500px;'>{% trans "Part" %}</th>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Reference" %}</th>
<th>{% trans "Overage" %}</th>
<th>{% trans "Allow Variants" %}</th>
<th>{% trans "Inherited" %}</th>
<th>{% trans "Optional" %}</th>
<th>{% trans "Note" %}</th>
<th><!-- Buttons Column --></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
{% endblock page_info %}
{% block js_ready %}
{{ block.super }}
enableSidebar('bom-upload');
$('#bom-template-download').click(function() {
downloadBomTemplate();
});
$('#bom-upload').click(function() {
constructForm('{% url "api-bom-extract" %}', {
method: 'POST',
fields: {
bom_file: {},
part: {
value: {{ part.pk }},
hidden: true,
},
clear_existing: {},
},
title: '{% trans "Upload BOM File" %}',
onSuccess: function(response) {
$('#bom-upload').hide();
$('#bom-submit').show();
constructBomUploadTable(response);
$('#bom-submit').click(function() {
submitBomTable({{ part.pk }}, {
bom_data: response,
});
});
}
});
});
{% endblock js_ready %}

View File

@ -107,7 +107,7 @@ class BomExportTest(TestCase):
"""
params = {
'file_format': 'csv',
'format': 'csv',
'cascade': True,
'parameter_data': True,
'stock_data': True,
@ -171,7 +171,7 @@ class BomExportTest(TestCase):
"""
params = {
'file_format': 'xls',
'format': 'xls',
'cascade': True,
'parameter_data': True,
'stock_data': True,
@ -192,7 +192,7 @@ class BomExportTest(TestCase):
"""
params = {
'file_format': 'xlsx',
'format': 'xlsx',
'cascade': True,
'parameter_data': True,
'stock_data': True,
@ -210,7 +210,7 @@ class BomExportTest(TestCase):
"""
params = {
'file_format': 'json',
'format': 'json',
'cascade': True,
'parameter_data': True,
'stock_data': True,

View File

@ -33,7 +33,6 @@ part_parameter_urls = [
part_detail_urls = [
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),

View File

@ -28,20 +28,17 @@ import requests
import os
import io
from rapidfuzz import fuzz
from decimal import Decimal, InvalidOperation
from decimal import Decimal
from .models import PartCategory, Part
from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate
from .models import BomItem
from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting
from company.models import SupplierPart
from common.files import FileManager
from common.views import FileManagementFormView, FileManagementAjaxView
from common.forms import UploadFileForm, MatchFieldForm
from stock.models import StockItem, StockLocation
@ -704,270 +701,12 @@ class PartImageSelect(AjaxUpdateView):
return self.renderJsonResponse(request, form, data)
class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
""" View for uploading a BOM file, and handling BOM data importing.
class BomUpload(InvenTreeRoleMixin, DetailView):
""" View for uploading a BOM file, and handling BOM data importing. """
The BOM upload process is as follows:
1. (Client) Select and upload BOM file
2. (Server) Verify that supplied file is a file compatible with tablib library
3. (Server) Introspect data file, try to find sensible columns / values / etc
4. (Server) Send suggestions back to the client
5. (Client) Makes choices based on suggestions:
- Accept automatic matching to parts found in database
- Accept suggestions for 'partial' or 'fuzzy' matches
- Create new parts in case of parts not being available
6. (Client) Sends updated dataset back to server
7. (Server) Check POST data for validity, sanity checking, etc.
8. (Server) Respond to POST request
- If data are valid, proceed to 9.
- If data not valid, return to 4.
9. (Server) Send confirmation form to user
- Display the actions which will occur
- Provide final "CONFIRM" button
10. (Client) Confirm final changes
11. (Server) Apply changes to database, update BOM items.
During these steps, data are passed between the server/client as JSON objects.
"""
role_required = ('part.change', 'part.add')
class BomFileManager(FileManager):
# Fields which are absolutely necessary for valid upload
REQUIRED_HEADERS = [
'Quantity'
]
# Fields which are used for part matching (only one of them is needed)
ITEM_MATCH_HEADERS = [
'Part_Name',
'Part_IPN',
'Part_ID',
]
# Fields which would be helpful but are not required
OPTIONAL_HEADERS = [
'Reference',
'Note',
'Overage',
]
EDITABLE_HEADERS = [
'Reference',
'Note',
'Overage'
]
name = 'order'
form_list = [
('upload', UploadFileForm),
('fields', MatchFieldForm),
('items', part_forms.BomMatchItemForm),
]
form_steps_template = [
'part/bom_upload/upload_file.html',
'part/bom_upload/match_fields.html',
'part/bom_upload/match_parts.html',
]
form_steps_description = [
_("Upload File"),
_("Match Fields"),
_("Match Parts"),
]
form_field_map = {
'item_select': 'part',
'quantity': 'quantity',
'overage': 'overage',
'reference': 'reference',
'note': 'note',
}
file_manager_class = BomFileManager
def get_part(self):
""" Get part or return 404 """
return get_object_or_404(Part, pk=self.kwargs['pk'])
def get_context_data(self, form, **kwargs):
""" Handle context data for order """
context = super().get_context_data(form=form, **kwargs)
part = self.get_part()
context.update({'part': part})
return context
def get_allowed_parts(self):
""" Return a queryset of parts which are allowed to be added to this BOM.
"""
return self.get_part().get_allowed_bom_items()
def get_field_selection(self):
""" Once data columns have been selected, attempt to pre-select the proper data from the database.
This function is called once the field selection has been validated.
The pre-fill data are then passed through to the part selection form.
"""
self.allowed_items = self.get_allowed_parts()
# Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database
k_idx = self.get_column_index('Part_ID')
p_idx = self.get_column_index('Part_Name')
i_idx = self.get_column_index('Part_IPN')
q_idx = self.get_column_index('Quantity')
r_idx = self.get_column_index('Reference')
o_idx = self.get_column_index('Overage')
n_idx = self.get_column_index('Note')
for row in self.rows:
"""
Iterate through each row in the uploaded data,
and see if we can match the row to a "Part" object in the database.
There are three potential ways to match, based on the uploaded data:
a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field
b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field
c) Use the name of the part, uploaded in the "Part_Name" field
Notes:
- If using the Part_ID field, we can do an exact match against the PK field
- If using the Part_IPN field, we can do an exact match against the IPN field
- If using the Part_Name field, we can use fuzzy string matching to match "close" values
We also extract other information from the row, for the other non-matched fields:
- Quantity
- Reference
- Overage
- Note
"""
# Initially use a quantity of zero
quantity = Decimal(0)
# Initially we do not have a part to reference
exact_match_part = None
# A list of potential Part matches
part_options = self.allowed_items
# Check if there is a column corresponding to "quantity"
if q_idx >= 0:
q_val = row['data'][q_idx]['cell']
if q_val:
# Delete commas
q_val = q_val.replace(',', '')
try:
# Attempt to extract a valid quantity from the field
quantity = Decimal(q_val)
# Store the 'quantity' value
row['quantity'] = quantity
except (ValueError, InvalidOperation):
pass
# Check if there is a column corresponding to "PK"
if k_idx >= 0:
pk = row['data'][k_idx]['cell']
if pk:
try:
# Attempt Part lookup based on PK value
exact_match_part = self.allowed_items.get(pk=pk)
except (ValueError, Part.DoesNotExist):
exact_match_part = None
# Check if there is a column corresponding to "Part IPN" and no exact match found yet
if i_idx >= 0 and not exact_match_part:
part_ipn = row['data'][i_idx]['cell']
if part_ipn:
part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())]
# Check for single match
if len(part_matches) == 1:
exact_match_part = part_matches[0]
# Check if there is a column corresponding to "Part Name" and no exact match found yet
if p_idx >= 0 and not exact_match_part:
part_name = row['data'][p_idx]['cell']
row['part_name'] = part_name
matches = []
for part in self.allowed_items:
ratio = fuzz.partial_ratio(part.name + part.description, part_name)
matches.append({'part': part, 'match': ratio})
# Sort matches by the 'strength' of the match ratio
if len(matches) > 0:
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
part_options = [m['part'] for m in matches]
# Supply list of part options for each row, sorted by how closely they match the part name
row['item_options'] = part_options
# Unless found, the 'item_match' is blank
row['item_match'] = None
if exact_match_part:
# If there is an exact match based on PK or IPN, use that
row['item_match'] = exact_match_part
# Check if there is a column corresponding to "Overage" field
if o_idx >= 0:
row['overage'] = row['data'][o_idx]['cell']
# Check if there is a column corresponding to "Reference" field
if r_idx >= 0:
row['reference'] = row['data'][r_idx]['cell']
# Check if there is a column corresponding to "Note" field
if n_idx >= 0:
row['note'] = row['data'][n_idx]['cell']
def done(self, form_list, **kwargs):
""" Once all the data is in, process it to add BomItem instances to the part """
self.part = self.get_part()
items = self.get_clean_items()
# Clear BOM
self.part.clear_bom()
# Generate new BOM items
for bom_item in items.values():
try:
part = Part.objects.get(pk=int(bom_item.get('part')))
except (ValueError, Part.DoesNotExist):
continue
quantity = bom_item.get('quantity')
overage = bom_item.get('overage', '')
reference = bom_item.get('reference', '')
note = bom_item.get('note', '')
# Create a new BOM item
item = BomItem(
part=self.part,
sub_part=part,
quantity=quantity,
overage=overage,
reference=reference,
note=note,
)
try:
item.save()
except IntegrityError:
# BomItem already exists
pass
return HttpResponseRedirect(reverse('part-detail', kwargs={'pk': self.kwargs['pk']}))
context_object_name = 'part'
queryset = Part.objects.all()
template_name = 'part/upload_bom.html'
class PartExport(AjaxView):
@ -1060,7 +799,7 @@ class BomDownload(AjaxView):
part = get_object_or_404(Part, pk=self.kwargs['pk'])
export_format = request.GET.get('file_format', 'csv')
export_format = request.GET.get('format', 'csv')
cascade = str2bool(request.GET.get('cascade', False))
@ -1103,55 +842,6 @@ class BomDownload(AjaxView):
}
class BomExport(AjaxView):
""" Provide a simple form to allow the user to select BOM download options.
"""
model = Part
ajax_form_title = _("Export Bill of Materials")
role_required = 'part.view'
def post(self, request, *args, **kwargs):
# Extract POSTed form data
fmt = request.POST.get('file_format', 'csv').lower()
cascade = str2bool(request.POST.get('cascading', False))
levels = request.POST.get('levels', None)
parameter_data = str2bool(request.POST.get('parameter_data', False))
stock_data = str2bool(request.POST.get('stock_data', False))
supplier_data = str2bool(request.POST.get('supplier_data', False))
manufacturer_data = str2bool(request.POST.get('manufacturer_data', False))
try:
part = Part.objects.get(pk=self.kwargs['pk'])
except:
part = None
# Format a URL to redirect to
if part:
url = reverse('bom-download', kwargs={'pk': part.pk})
else:
url = ''
url += '?file_format=' + fmt
url += '&cascade=' + str(cascade)
url += '&parameter_data=' + str(parameter_data)
url += '&stock_data=' + str(stock_data)
url += '&supplier_data=' + str(supplier_data)
url += '&manufacturer_data=' + str(manufacturer_data)
if levels:
url += '&levels=' + str(levels)
data = {
'form_valid': part is not None,
'url': url,
}
return self.renderJsonResponse(request, self.form_class(), data=data)
class PartDelete(AjaxDeleteView):
""" View to delete a Part object """

View File

@ -15,6 +15,7 @@
*/
/* exported
constructBomUploadTable,
downloadBomTemplate,
exportBom,
newPartFromBomWizard,
@ -22,8 +23,175 @@
loadUsedInTable,
removeRowFromBomWizard,
removeColFromBomWizard,
submitBomTable
*/
/* Construct a table of data extracted from a BOM file.
* This data is used to import a BOM interactively.
*/
function constructBomUploadTable(data, options={}) {
if (!data.rows) {
// TODO: Error message!
return;
}
function constructRow(row, idx, fields) {
// Construct an individual row from the provided data
var field_options = {
hideLabels: true,
hideClearButton: true,
form_classes: 'bom-form-group',
};
function constructRowField(field_name) {
var field = fields[field_name] || null;
if (!field) {
return `Cannot render field '${field_name}`;
}
field.value = row[field_name];
return constructField(`items_${field_name}_${idx}`, field, field_options);
}
// Construct form inputs
var sub_part = constructRowField('sub_part');
var quantity = constructRowField('quantity');
var reference = constructRowField('reference');
var overage = constructRowField('overage');
var variants = constructRowField('allow_variants');
var inherited = constructRowField('inherited');
var optional = constructRowField('optional');
var note = constructRowField('note');
var buttons = `<div class='btn-group float-right' role='group'>`;
// buttons += makeIconButton('fa-file-alt', 'button-row-data', idx, '{% trans "Display row data" %}');
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', idx, '{% trans "Remove row" %}');
buttons += `</div>`;
var html = `
<tr id='items_${idx}' class='bom-import-row' idx='${idx}'>
<td id='col_sub_part_${idx}'>${sub_part}</td>
<td id='col_quantity_${idx}'>${quantity}</td>
<td id='col_reference_${idx}'>${reference}</td>
<td id='col_overage_${idx}'>${overage}</td>
<td id='col_variants_${idx}'>${variants}</td>
<td id='col_inherited_${idx}'>${inherited}</td>
<td id='col_optional_${idx}'>${optional}</td>
<td id='col_note_${idx}'>${note}</td>
<td id='col_buttons_${idx}'>${buttons}</td>
</tr>`;
$('#bom-import-table tbody').append(html);
// Initialize the "part" selector for this row
initializeRelatedField(
{
name: `items_sub_part_${idx}`,
value: row.part,
api_url: '{% url "api-part-list" %}',
filters: {
component: true,
},
model: 'part',
required: true,
auto_fill: false,
onSelect: function(data, field, opts) {
// TODO?
},
}
);
// Add callback for "remove row" button
$(`#button-row-remove-${idx}`).click(function() {
$(`#items_${idx}`).remove();
});
}
// Request API endpoint options
getApiEndpointOptions('{% url "api-bom-list" %}', function(response) {
var fields = response.actions.POST;
data.rows.forEach(function(row, idx) {
constructRow(row, idx, fields);
});
});
}
/* Extract rows from the BOM upload table,
* and submit data to the server
*/
function submitBomTable(part_id, options={}) {
// Extract rows from the form
var rows = [];
var idx_values = [];
var url = '{% url "api-bom-upload" %}';
$('.bom-import-row').each(function() {
var idx = $(this).attr('idx');
idx_values.push(idx);
// Extract each field from the row
rows.push({
part: part_id,
sub_part: getFormFieldValue(`items_sub_part_${idx}`, {}),
quantity: getFormFieldValue(`items_quantity_${idx}`, {}),
reference: getFormFieldValue(`items_reference_${idx}`, {}),
overage: getFormFieldValue(`items_overage_${idx}`, {}),
allow_variants: getFormFieldValue(`items_allow_variants_${idx}`, {type: 'boolean'}),
inherited: getFormFieldValue(`items_inherited_${idx}`, {type: 'boolean'}),
optional: getFormFieldValue(`items_optional_${idx}`, {type: 'boolean'}),
note: getFormFieldValue(`items_note_${idx}`, {}),
});
});
var data = {
items: rows,
};
var options = {
nested: {
items: idx_values,
}
};
getApiEndpointOptions(url, function(response) {
var fields = response.actions.POST;
inventreePut(url, data, {
method: 'POST',
success: function(response) {
window.location.href = `/part/${part_id}/?display=bom`;
},
error: function(xhr) {
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, options);
break;
default:
showApiError(xhr, url);
break;
}
}
});
});
}
function downloadBomTemplate(options={}) {
var format = options.format;

View File

@ -837,7 +837,15 @@ function getFormFieldElement(name, options) {
var field_name = getFieldName(name, options);
var el = $(options.modal).find(`#id_${field_name}`);
var el = null;
if (options && options.modal) {
// Field element is associated with a model?
el = $(options.modal).find(`#id_${field_name}`);
} else {
// Field element is top-level
el = $(`#id_${field_name}`);
}
if (!el.exists) {
console.log(`ERROR: Could not find form element for field '${name}'`);
@ -882,12 +890,13 @@ function validateFormField(name, options) {
* - field: The field specification provided from the OPTIONS request
* - options: The original options object provided by the client
*/
function getFormFieldValue(name, field, options) {
function getFormFieldValue(name, field={}, options={}) {
// Find the HTML element
var el = getFormFieldElement(name, options);
if (!el) {
console.log(`ERROR: getFormFieldValue could not locate field '{name}'`);
return null;
}
@ -973,8 +982,9 @@ function handleFormSuccess(response, options) {
/*
* Remove all error text items from the form
*/
function clearFormErrors(options) {
function clearFormErrors(options={}) {
if (options && options.modal) {
// Remove the individual error messages
$(options.modal).find('.form-error-message').remove();
@ -983,6 +993,11 @@ function clearFormErrors(options) {
// Hide the 'non field errors'
$(options.modal).find('#non-field-errors').html('');
} else {
$('.form-error-message').remove();
$('.form-field-errors').removeClass('form-field-error');
$('#non-field-errors').html('');
}
}
/*
@ -1010,7 +1025,7 @@ function clearFormErrors(options) {
*
*/
function handleNestedErrors(errors, field_name, options) {
function handleNestedErrors(errors, field_name, options={}) {
var error_list = errors[field_name];
@ -1041,8 +1056,31 @@ function handleNestedErrors(errors, field_name, options) {
// Here, error_item is a map of field names to error messages
for (sub_field_name in error_item) {
var errors = error_item[sub_field_name];
if (sub_field_name == 'non_field_errors') {
var row = null;
if (options.modal) {
row = $(options.modal).find(`#items_${nest_id}`);
} else {
row = $(`#items_${nest_id}`);
}
for (var ii = errors.length - 1; ii >= 0; ii--) {
var html = `
<div id='error_${ii}_non_field_error' class='help-block form-field-error form-error-message'>
<strong>${errors[ii]}</strong>
</div>`;
row.after(html);
}
}
// Find the target (nested) field
var target = `${field_name}_${sub_field_name}_${nest_id}`;
@ -1066,15 +1104,23 @@ function handleNestedErrors(errors, field_name, options) {
* - fields: The form data object
* - options: Form options provided by the client
*/
function handleFormErrors(errors, fields, options) {
function handleFormErrors(errors, fields={}, options={}) {
// Reset the status of the "submit" button
if (options.modal) {
$(options.modal).find('#modal-form-submit').prop('disabled', false);
}
// Remove any existing error messages from the form
clearFormErrors(options);
var non_field_errors = $(options.modal).find('#non-field-errors');
var non_field_errors = null;
if (options.modal) {
non_field_errors = $(options.modal).find('#non-field-errors');
} else {
non_field_errors = $('#non-field-errors');
}
// TODO: Display the JSON error text when hovering over the "info" icon
non_field_errors.append(
@ -1150,14 +1196,19 @@ function handleFormErrors(errors, fields, options) {
/*
* Add a rendered error message to the provided field
*/
function addFieldErrorMessage(name, error_text, error_idx, options) {
function addFieldErrorMessage(name, error_text, error_idx, options={}) {
field_name = getFieldName(name, options);
// Add the 'form-field-error' class
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
var field_dom = null;
var field_dom = $(options.modal).find(`#errors-${field_name}`);
if (options.modal) {
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
field_dom = $(options.modal).find(`#errors-${field_name}`);
} else {
$(`#div_id_${field_name}`).addClass('form-field-error');
field_dom = $(`#errors-${field_name}`);
}
if (field_dom) {
@ -1228,11 +1279,17 @@ function addClearCallbacks(fields, options) {
}
function addClearCallback(name, field, options) {
function addClearCallback(name, field, options={}) {
var field_name = getFieldName(name, options);
var el = $(options.modal).find(`#clear_${field_name}`);
var el = null;
if (options && options.modal) {
el = $(options.modal).find(`#clear_${field_name}`);
} else {
el = $(`#clear_${field_name}`);
}
if (!el) {
console.log(`WARNING: addClearCallback could not find field '${name}'`);
@ -1330,11 +1387,13 @@ function hideFormGroup(group, options) {
$(options.modal).find(`#form-panel-${group}`).hide();
}
// Show a form group
function showFormGroup(group, options) {
$(options.modal).find(`#form-panel-${group}`).show();
}
function setFormGroupVisibility(group, vis, options) {
if (vis) {
showFormGroup(group, options);
@ -1344,7 +1403,7 @@ function setFormGroupVisibility(group, vis, options) {
}
function initializeRelatedFields(fields, options) {
function initializeRelatedFields(fields, options={}) {
var field_names = options.field_names;
@ -1452,12 +1511,11 @@ function addSecondaryModal(field, fields, options) {
* - field: Field definition from the OPTIONS request
* - options: Original options object provided by the client
*/
function initializeRelatedField(field, fields, options) {
function initializeRelatedField(field, fields, options={}) {
var name = field.name;
if (!field.api_url) {
// TODO: Provide manual api_url option?
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
return;
}
@ -1475,10 +1533,22 @@ function initializeRelatedField(field, fields, options) {
// limit size for AJAX requests
var pageSize = options.pageSize || 25;
var parent = null;
var auto_width = false;
var width = '100%';
// Special considerations if the select2 input is a child of a modal
if (options && options.modal) {
parent = $(options.modal);
auto_width = true;
width = null;
}
select.select2({
placeholder: '',
dropdownParent: $(options.modal),
dropdownAutoWidth: false,
dropdownParent: parent,
dropdownAutoWidth: auto_width,
width: width,
language: {
noResults: function(query) {
if (field.noResults) {
@ -1654,7 +1724,7 @@ function initializeRelatedField(field, fields, options) {
* - data: JSON data representing the model instance
* - options: The modal form specifications
*/
function setRelatedFieldData(name, data, options) {
function setRelatedFieldData(name, data, options={}) {
var select = getFormFieldElement(name, options);
@ -1779,10 +1849,10 @@ function renderModelData(name, model, data, parameters, options) {
/*
* Construct a field name for the given field
*/
function getFieldName(name, options) {
function getFieldName(name, options={}) {
var field_name = name;
if (options.depth) {
if (options && options.depth) {
field_name += `_${options.depth}`;
}
@ -1872,7 +1942,7 @@ function constructField(name, parameters, options) {
options.current_group = group;
}
var form_classes = 'form-group';
var form_classes = options.form_classes || 'form-group';
if (parameters.errors) {
form_classes += ' form-field-error';
@ -1925,7 +1995,7 @@ function constructField(name, parameters, options) {
if (extra) {
if (!parameters.required) {
if (!parameters.required && !options.hideClearButton) {
html += `
<span class='input-group-text form-clear' id='clear_${field_name}' title='{% trans "Clear input" %}'>
<span class='icon-red fas fa-backspace'></span>
@ -2053,7 +2123,7 @@ function constructInput(name, parameters, options) {
// Construct a set of default input options which apply to all input types
function constructInputOptions(name, classes, type, parameters) {
function constructInputOptions(name, classes, type, parameters, options={}) {
var opts = [];
@ -2135,11 +2205,18 @@ function constructInputOptions(name, classes, type, parameters) {
if (parameters.multiline) {
return `<textarea ${opts.join(' ')}></textarea>`;
} else if (parameters.type == 'boolean') {
var help_text = '';
if (!options.hideLabels && parameters.help_text) {
help_text = `<em><small>${parameters.help_text}</small></em>`;
}
return `
<div class='form-check form-switch'>
<input ${opts.join(' ')}>
<label class='form-check-label' for=''>
<em><small>${parameters.help_text}</small></em>
${help_text}
</label>
</div>
`;
@ -2162,13 +2239,14 @@ function constructHiddenInput(name, parameters) {
// Construct a "checkbox" input
function constructCheckboxInput(name, parameters) {
function constructCheckboxInput(name, parameters, options={}) {
return constructInputOptions(
name,
'form-check-input',
'checkbox',
parameters
parameters,
options
);
}

View File

@ -161,7 +161,7 @@ function renderPart(name, data, parameters, options) {
html += ` <span>${data.full_name || data.name}</span>`;
if (data.description) {
html += ` - <i>${data.description}</i>`;
html += ` - <i><small>${data.description}</small></i>`;
}
var extra = '';