Merge pull request #411 from SchrodingersGat/bom-upload

BOM upload
This commit is contained in:
Oliver 2019-07-10 15:17:58 +10:00 committed by GitHub
commit 1f9e6f4a68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1272 additions and 144 deletions

3
.gitignore vendored
View File

@ -37,8 +37,9 @@ InvenTree/media
# Key file
secret_key.txt
# Ignore python IDE project configuration
# IDE / development files
.idea/
*.code-workspace
# Coverage reports
.coverage

210
InvenTree/part/bom.py Normal file
View File

@ -0,0 +1,210 @@
"""
Functionality for Bill of Material (BOM) management.
Primarily BOM upload tools.
"""
from fuzzywuzzy import fuzz
import tablib
import os
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from InvenTree.helpers import DownloadFile
def IsValidBOMFormat(fmt):
""" Test if a file format specifier is in the valid list of BOM file formats """
return fmt.strip().lower() in ['csv', 'xls', 'xlsx', 'tsv']
def MakeBomTemplate(fmt):
""" Generate a Bill of Materials upload template file (for user download) """
fmt = fmt.strip().lower()
if not IsValidBOMFormat(fmt):
fmt = 'csv'
fields = [
'Part',
'Quantity',
'Overage',
'Reference',
'Notes'
]
data = tablib.Dataset(headers=fields).export(fmt)
filename = 'InvenTree_BOM_Template.' + fmt
return DownloadFile(data, filename)
class BomUploadManager:
""" Class for managing an uploaded BOM file """
# Fields which are absolutely necessary for valid upload
REQUIRED_HEADERS = [
'Part',
'Quantity'
]
# Fields which would be helpful but are not required
OPTIONAL_HEADERS = [
'Reference',
'Notes',
'Overage',
'Description',
'Category',
'Supplier',
'Manufacturer',
'MPN',
'IPN',
]
EDITABLE_HEADERS = [
'Reference',
'Notes'
]
HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
def __init__(self, bom_file):
""" Initialize the BomUpload class with a user-uploaded file object """
self.process(bom_file)
def process(self, bom_file):
""" Process a BOM file """
self.data = None
ext = os.path.splitext(bom_file.name)[-1].lower()
if ext in ['.csv', '.tsv', ]:
# These file formats need string decoding
raw_data = bom_file.read().decode('utf-8')
elif ext in ['.xls', '.xlsx']:
raw_data = bom_file.read()
else:
raise ValidationError({'bom_file': _('Unsupported file format: {f}'.format(f=ext))})
try:
self.data = tablib.Dataset().load(raw_data)
except tablib.UnsupportedFormat:
raise ValidationError({'bom_file': _('Error reading BOM file (invalid data)')})
except tablib.core.InvalidDimensions:
raise ValidationError({'bom_file': _('Error reading BOM file (incorrect row size)')})
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
# Finally, look for a close match using fuzzy matching
matches = []
for h in self.HEADERS:
ratio = fuzz.partial_ratio(header, h)
if ratio > threshold:
matches.append({'header': h, 'match': ratio})
if len(matches) > 0:
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
return matches[0]['header']
return None
def columns(self):
""" Return a list of headers for the thingy """
headers = []
for header in self.data.headers:
headers.append({
'name': header,
'guess': self.guess_header(header)
})
return headers
def col_count(self):
if self.data is None:
return 0
return len(self.data.headers)
def row_count(self):
""" Return the number of rows in the file.
Ignored the top rows as indicated by 'starting row'
"""
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)):
print("converting", item, "to", int(item))
data[idx] = int(item)
except ValueError:
pass
if empty:
print("Empty - continuing")
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

@ -8,6 +8,7 @@ from __future__ import unicode_literals
from InvenTree.forms import HelperForm
from django import forms
from django.core.validators import MinValueValidator
from .models import Part, PartCategory, PartAttachment
from .models import BomItem
@ -38,24 +39,27 @@ class BomValidateForm(HelperForm):
]
class BomExportForm(HelperForm):
class BomUploadSelectFile(HelperForm):
""" Form for importing a BOM. Provides a file input box for upload """
# TODO - Define these choices somewhere else, and import them here
format_choices = (
('csv', 'CSV'),
('pdf', 'PDF'),
('xml', 'XML'),
('xlsx', 'XLSX'),
('html', 'HTML')
)
# Select export type
format = forms.CharField(label='Format', widget=forms.Select(choices=format_choices), required='true', help_text='Select export format')
bom_file = forms.FileField(label='BOM file', required=True, help_text="Select BOM file to upload")
class Meta:
model = Part
fields = [
'format',
'bom_file',
]
class BomUploadSelectFields(HelperForm):
""" Form for selecting BOM fields """
starting_row = forms.IntegerField(required=True, initial=2, help_text='Index of starting row', validators=[MinValueValidator(1)])
class Meta:
model = Part
fields = [
'starting_row',
]
@ -130,6 +134,7 @@ class EditBomItemForm(HelperForm):
'part',
'sub_part',
'quantity',
'reference',
'overage',
'note'
]

View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.2 on 2019-06-27 11:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0011_part_revision'),
]
operations = [
migrations.AddField(
model_name='bomitem',
name='reference',
field=models.CharField(blank=True, help_text='BOM item reference', max_length=500),
),
migrations.AlterField(
model_name='bomitem',
name='note',
field=models.CharField(blank=True, help_text='BOM item notes', max_length=500),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2.2 on 2019-06-27 23:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0012_auto_20190627_2144'),
]
operations = [
migrations.AlterField(
model_name='bomitem',
name='part',
field=models.ForeignKey(help_text='Select parent part', limit_choices_to={'assembly': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bom_items', to='part.Part'),
),
migrations.AlterField(
model_name='bomitem',
name='sub_part',
field=models.ForeignKey(help_text='Select part to be used in BOM', limit_choices_to={'component': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'),
),
]

View File

@ -671,6 +671,13 @@ class Part(models.Model):
self.save()
@transaction.atomic
def clear_bom(self):
""" Clear the BOM items for the part (delete all BOM lines).
"""
self.bom_items.all().delete()
def required_parts(self):
""" Return a list of parts required to make this part (list of BOM items) """
parts = []
@ -678,6 +685,18 @@ class Part(models.Model):
parts.append(bom.sub_part)
return parts
def get_allowed_bom_items(self):
""" Return a list of parts which can be added to a BOM for this part.
- Exclude parts which are not 'component' parts
- Exclude parts which this part is in the BOM for
"""
parts = Part.objects.filter(component=True).exclude(id=self.id)
parts = parts.exclude(id__in=[part.id for part in self.used_in.all()])
return parts
@property
def supplier_count(self):
""" Return the number of supplier parts available for this part """
@ -843,15 +862,19 @@ class Part(models.Model):
'Part',
'Description',
'Quantity',
'Overage',
'Reference',
'Note',
])
for it in self.bom_items.all():
for it in self.bom_items.all().order_by('id'):
line = []
line.append(it.sub_part.full_name)
line.append(it.sub_part.description)
line.append(it.quantity)
line.append(it.overage)
line.append(it.reference)
line.append(it.note)
data.append(line)
@ -969,6 +992,7 @@ class BomItem(models.Model):
part: Link to the parent part (the part that will be produced)
sub_part: Link to the child part (the part that will be consumed)
quantity: Number of 'sub_parts' consumed to produce one 'part'
reference: BOM reference field (e.g. part designators)
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
note: Note field for this BOM item
"""
@ -982,7 +1006,6 @@ class BomItem(models.Model):
help_text='Select parent part',
limit_choices_to={
'assembly': True,
'active': True,
})
# A link to the child item (sub-part)
@ -991,7 +1014,6 @@ class BomItem(models.Model):
help_text='Select part to be used in BOM',
limit_choices_to={
'component': True,
'active': True
})
# Quantity required
@ -1001,8 +1023,10 @@ class BomItem(models.Model):
help_text='Estimated build wastage quantity (absolute or percentage)'
)
reference = models.CharField(max_length=500, blank=True, help_text='BOM item reference')
# Note attached to this BOM line item
note = models.CharField(max_length=100, blank=True, help_text='BOM item notes')
note = models.CharField(max_length=500, blank=True, help_text='BOM item notes')
def clean(self):
""" Check validity of the BomItem model.

View File

@ -53,6 +53,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
'total_stock',
'available_stock',
'image_url',
'active',
]
@ -166,6 +167,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
'sub_part',
'sub_part_detail',
'quantity',
'reference',
'price_range',
'overage',
'note',

View File

@ -31,25 +31,28 @@
</div>
{% endif %}
<div id='button-toolbar'>
<div id='button-toolbar' class="btn-group" role="group" aria-label="...">
{% if editing_enabled %}
<div class='btn-group' style='float: right;'>
<button class='btn btn-info' type='button' id='bom-item-new'>New BOM Item</button>
<button class='btn btn-success' type='button' id='editing-finished'>Finish Editing</button>
</div>
{% else %}
<div class='dropdown' style="float: right;">
<button class='btn btn-primary dropdown-toggle' type='button' data-toggle='dropdown'>
Options
<span class='caret'></span>
</button>
<ul class='dropdown-menu'>
{% if part.is_bom_valid == False %}
<li><a href='#' id='validate-bom' title='Validate BOM'>Validate BOM</a></li>
{% endif %}
<li><a href='#' id='edit-bom' title='Edit BOM'>Edit BOM</a></li>
<li><a href='#' id='export-bom' title='Export BOM'>Export BOM</a></li>
</ul>
<button class='btn btn-default' type='button' title='Remove selected BOM items' id='bom-item-delete'><span class='glyphicon glyphicon-trash'></span></button>
<a href="{% url 'upload-bom' part.id %}">
<button class='btn btn-default' type='button' title='Import BOM data' id='bom-upload'><span class='glyphicon glyphicon-open-file'></span></button>
</a>
<button class='btn btn-default' type='button' title='New BOM Item' id='bom-item-new'><span class='glyphicon glyphicon-plus'></span></button>
<button class='btn btn-default' type='button' title='Finish Editing' id='editing-finished'><span class='glyphicon glyphicon-ok'></span></button>
{% elif part.active %}
<button class='btn btn-default' type='button' title='Edit BOM' id='edit-bom'><span class='glyphicon glyphicon-edit'></span></button>
{% if part.is_bom_valid == False %}
<button class='btn btn-default' id='validate-bom' type='button'><span class='glyphicon glyphicon-check'></span></button>
{% endif %}
<div class='btn-group' role='group'>
<div class='dropdown'>
<button title='Export BOM' class='btn btn-default dropdown-toggle' data-toggle='dropdown' type='button'><span class='glyphicon glyphicon-download-alt'></span></button>
<ul class='dropdown-menu'>
<li><a href='#' class='download-bom' format='csv'>CSV</a></li>
<li><a href='#' class='download-bom' format='xlsx'>XLSX</a></li>
<li><a href='#' class='download-bom' format='json'>JSON</a></li>
</ul>
</div>
</div>
{% endif %}
</div>
@ -59,11 +62,6 @@
{% endblock %}
{% block js_load %}
{{ block.super }}
<script type='text/javascript' src="{% static 'script/inventree/bom.js' %}"></script>
{% endblock %}
{% block js_ready %}
{{ block.super }}
@ -76,6 +74,12 @@
sub_part_detail: true,
});
linkButtonsToSelection($("#bom-table"),
[
"#bom-item-delete",
]
);
{% if editing_enabled %}
$("#editing-finished").click(function() {
location.href = "{% url 'part-bom' part.id %}";
@ -112,15 +116,11 @@
});
$("#edit-bom").click(function () {
location.href = "{% url 'part-bom' part.id %}?edit=True";
location.href = "{% url 'part-bom' part.id %}?edit=1";
});
$("#export-bom").click(function () {
downloadBom({
modal: '#modal-form',
url: "{% url 'bom-export' part.id %}"
});
$(".download-bom").click(function () {
location.href = "{% url 'bom-export' part.id %}?format=" + $(this).attr('format');
});
{% endif %}

View File

@ -0,0 +1,90 @@
{% extends "part/part_base.html" %}
{% load static %}
{% load inventree_extras %}
{% block details %}
{% include "part/tabs.html" with tab='bom' %}
<h4>Upload Bill of Materials</h4>
<p>Step 2 - Select Fields</p>
<hr>
{% if missing_columns and missing_columns|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'>
Missing selections for the following required columns:
<br>
<ul>
{% for col in missing_columns %}
<li>{{ col }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
<button type="submit" class="save btn btn-default">Submit Selections</button>
{% csrf_token %}
{% load crispy_forms_tags %}
{% crispy form %}
<input type='hidden' name='form_step' value='select_fields'/>
<table class='table table-striped'>
<thead>
<tr>
<th></th>
<th>Row</th>
{% for col in bom_columns %}
<th>
<div>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
{{ col.name }}
<button class='btn btn-default btn-remove' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='Remove column'>
<span col_id='{{ forloop.counter0 }}' onClick='removeColFromBomWizard()' class='glyphicon glyphicon-small glyphicon-remove'></span>
</button>
</div>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
{% for col in bom_columns %}
<td>
<select class='select' id='id_col_{{ forloop.counter0 }}' name='col_guess_{{ forloop.counter0 }}'>
<option value=''>---------</option>
{% for req in bom_headers %}
<option value='{{ req }}'{% if req == col.guess %}selected='selected'{% endif %}>{{ req }}</option>
{% endfor %}
</select>
{% if col.duplicate %}
<p class='help-inline'>Duplicate column selection</p>
{% endif %}
</td>
{% endfor %}
</tr>
{% for row in bom_rows %}
<tr>
<td>
<button class='btn btn-default btn-remove' id='del_row_{{ forloop.counter }}' style='display: inline; float: right;' title='Remove row'>
<span row_id='{{ forloop.counter }}' onClick='removeRowFromBomWizard()' class='glyphicon glyphicon-small glyphicon-remove'></span>
</button>
</td>
<td>{{ forloop.counter }}</td>
{% for item in row.data %}
<td>
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
{{ item.cell }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% endblock %}

View File

@ -0,0 +1,108 @@
{% extends "part/part_base.html" %}
{% load static %}
{% load inventree_extras %}
{% block details %}
{% include "part/tabs.html" with tab="bom" %}
<h4>Upload Bill of Materials</h4>
<p>Step 3 - Select Parts</p>
<hr>
{% if form_errors %}
<div class='alert alert-danger alert-block' role='alert'>
Errors exist in the submitted data.
</div>
{% endif %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
<button type="submit" class="save btn btn-default">Submit BOM</button>
{% csrf_token %}
{% load crispy_forms_tags %}
<input type='hidden' name='form_step' value='select_parts'/>
<table class='table table-striped'>
<thead>
<tr>
<th></th>
<th></th>
<th>Row</th>
{% for col in bom_columns %}
<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>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in bom_rows %}
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-name='{{ row.part_name }}' part-description='{{ row.description }}' part-select='#select_part_{{ row.index }}'>
<td>
<button class='btn btn-default btn-remove' id='del_row_{{ forloop.counter }}' style='display: inline; float: right;' title='Remove row'>
<span row_id='{{ forloop.counter }}' onClick='removeRowFromBomWizard()' class='glyphicon glyphicon-small glyphicon-remove'></span>
</button>
</td>
<td>
</td>
<td>{% add row.index 1 %}</td>
{% for item in row.data %}
<td>
{% if item.column.guess == 'Part' %}
<button class='btn btn-default btn-create' id='new_part_row_{{ row.index }}' title='Create new part' type='button'>
<span row_id='{{ row.index }}' class='glyphicon glyphicon-small glyphicon-plus' onClick='newPartFromBomWizard()'/>
</button>
<select class='select bomselect' id='select_part_{{ row.index }}' name='part_{{ row.index }}'>
<option value=''>---------</option>
{% for part in row.part_options %}
<option value='{{ part.id }}'{% if part.id == row.part.id %} selected='selected'{% endif %}>{{ part.full_name }} - {{ part.description }}</option>
{% endfor %}
</select>
<i>{{ item.cell }}</i>
{% if row.errors.part %}
<p class='help-inline'>{{ row.errors.part }}</p>
{% endif %}
{% elif item.column.guess == 'Quantity' %}
<input name='quantity_{{ row.index }}' class='numberinput' type='number' min='1' value='{{ row.quantity }}'/>
{% if row.errors.quantity %}
<p class='help-inline'>{{ row.errors.quantity }}</p>
{% endif %}
{% elif item.column.guess == 'Reference' %}
<input name='reference_{{ row.index }}' value='{{ row.reference }}'/>
{% elif item.column.guess == 'Notes' %}
<input name='notes_{{ row.index }}' value='{{ row.notes }}'/>
{% elif item.column.guess == 'Overage' %}
<input name='overage_{{ row.index }}' value='{{ row.overage }}'/>
{% else %}
{{ item.cell }}
{% endif %}
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$('.bomselect').select2({
dropdownAutoWidth: true,
matcher: partialMatcher,
});
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "part/part_base.html" %}
{% load static %}
{% load inventree_extras %}
{% block details %}
{% include "part/tabs.html" with tab='bom' %}
<h4>Upload Bill of Materials</h4>
<hr>
<p>Step 1 - Select BOM File</p>
<div class='alert alert-info alert-block'>
<p>The BOM file must contain the required named columns as provided in the <a href="/part/bom_template/">BOM Upload Template</a></a></p>
</div>
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
<button type="submit" class="save btn btn-default">Upload File</button>
{% csrf_token %}
{% load crispy_forms_tags %}
<input type='hidden' name='form_step' value='select_file'/>
{% crispy form %}
</form>
{% endblock %}

View File

@ -99,14 +99,16 @@
<div class="col-sm-6">
<table class="table table-striped">
<tr>
<td colspan='2'>
<h4>Stock Status</h4>
<td>
<h4>Available Stock</h4>
</td>
<td><h4>{{ part.net_stock }} {{ part.units }}</h4></td>
</tr>
<tr>
<td>In Stock</td>
<td>{{ part.total_stock }}</td>
</tr>
{% if not part.is_template %}
{% if part.allocation_count > 0 %}
<tr>
<td>Allocated</td>
@ -119,14 +121,12 @@
<td>{{ part.on_order }}</td>
</tr>
{% endif %}
<tr>
<td><b>Total Available</b></td>
<td><b>{{ part.net_stock }}</b></td>
</tr>
{% if part.assembly %}
{% endif %}
{% if not part.is_template %}
{% if part.assembly %}
<tr>
<td colspan='2'>
<h4>Build Status</h4>
<b>Build Status</b>
</td>
</tr>
<tr>
@ -139,6 +139,7 @@
<td>{{ part.quantity_being_built }}</td>
</tr>
{% endif %}
{% endif %}
{% endif %}
</table>
</div>

View File

@ -35,15 +35,24 @@
{
field: 'part_detail',
title: 'Part',
sortable: true,
formatter: function(value, row, index, field) {
return imageHoverIcon(row.part_detail.image_url) + renderLink(value.full_name, value.url + 'bom/');
var html = imageHoverIcon(row.part_detail.image_url) + renderLink(value.full_name, value.url + 'bom/');
if (!row.part_detail.active) {
html += "<span class='label label-warning' style='float: right;'>INACTIVE</span>";
}
return html;
}
},
{
field: 'part_detail.description',
title: 'Description',
sortable: true,
},
{
sortable: true,
field: 'quantity',
title: 'Uses',
}

View File

@ -24,9 +24,9 @@
<table class='table table-striped table-condensed' id='variant-table' data-toolbar='#button-toolbar'>
<thead>
<tr>
<th>Variant</th>
<th>Description</th>
<th>Stock</th>
<th data-sortable='true'>Variant</th>
<th data-sortable='true'>Description</th>
<th data-sortable='true'>Stock</th>
</tr>
</thead>
<tbody>
@ -35,6 +35,9 @@
<td>
{% include "hover_image.html" with image=variant.image hover=True %}
<a href="{% url 'part-detail' variant.id %}">{{ variant.full_name }}</a>
{% if not variant.active %}
<span class='label label-warning' style='float: right;'>INACTIVE</span>
{% endif %}
</td>
<td>{{ variant.description }}</td>
<td>{{ variant.total_stock }}</td>

View File

@ -8,12 +8,24 @@ from InvenTree import version
register = template.Library()
@register.simple_tag()
def inrange(n, *args, **kwargs):
""" Return range(n) for iterating through a numeric quantity """
return range(n)
@register.simple_tag()
def multiply(x, y, *args, **kwargs):
""" Multiply two numbers together """
return x * y
@register.simple_tag()
def add(x, y, *args, **kwargs):
""" Add two numbers together """
return x + y
@register.simple_tag()
def part_allocation_count(build, part, *args, **kwargs):
""" Return the total number of <part> allocated to <build> """

View File

@ -27,6 +27,8 @@ part_detail_urls = [
url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'),
url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'),
url(r'^allocation/?', views.PartDetail.as_view(template_name='part/allocation.html'), name='part-allocation'),
@ -73,6 +75,9 @@ part_urls = [
# Create a new BOM item
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
# Download a BOM upload template
url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'),
# Individual part
url(r'^(?P<pk>\d+)/', include(part_detail_urls)),

View File

@ -5,13 +5,17 @@ Django views for interacting with Part app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.core.exceptions import ValidationError
from django.shortcuts import get_object_or_404
from django.shortcuts import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _
from django.urls import reverse_lazy
from django.views.generic import DetailView, ListView
from django.urls import reverse, reverse_lazy
from django.views.generic import DetailView, ListView, FormView
from django.forms.models import model_to_dict
from django.forms import HiddenInput, CheckboxInput
from fuzzywuzzy import fuzz
from .models import PartCategory, Part, PartAttachment
from .models import BomItem
from .models import match_part_names
@ -19,6 +23,7 @@ from .models import match_part_names
from company.models import SupplierPart
from . import forms as part_forms
from .bom import MakeBomTemplate, BomUploadManager
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.views import QRCodeView
@ -489,6 +494,11 @@ class PartCreate(AjaxCreateView):
initials['keywords'] = category.default_keywords
except PartCategory.DoesNotExist:
pass
# Allow initial data to be passed through as arguments
for label in ['name', 'IPN', 'description', 'revision', 'keywords']:
if label in self.request.GET:
initials[label] = self.request.GET.get(label)
return initials
@ -508,14 +518,15 @@ class PartDetail(DetailView):
- If '?editing=True', set 'editing_enabled' context variable
"""
context = super(PartDetail, self).get_context_data(**kwargs)
part = self.get_object()
if str2bool(self.request.GET.get('edit', '')):
context['editing_enabled'] = 1
# Allow BOM editing if the part is active
context['editing_enabled'] = 1 if part.active else 0
else:
context['editing_enabled'] = 0
part = self.get_object()
context['starred'] = part.isStarredBy(self.request.user)
context['disabled'] = not part.active
@ -616,36 +627,549 @@ class BomValidate(AjaxUpdateView):
return self.renderJsonResponse(request, form, data, context=self.get_context())
class BomExport(AjaxView):
class BomUpload(FormView):
""" View for uploading a BOM file, and handling BOM data importing.
model = Part
ajax_form_title = 'Export BOM'
ajax_template_name = 'part/bom_export.html'
form_class = part_forms.BomExportForm
The BOM upload process is as follows:
def get_object(self):
return get_object_or_404(Part, pk=self.kwargs['pk'])
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.
"""
template_name = 'part/bom_upload/upload_file.html'
# Context data passed to the forms (initially empty, extracted from uploaded file)
bom_headers = []
bom_columns = []
bom_rows = []
missing_columns = []
allowed_parts = []
def get_success_url(self):
part = self.get_object()
return reverse('upload-bom', kwargs={'pk': part.id})
def get_form_class(self):
form_step = self.request.POST.get('form_step', None)
if form_step == 'select_fields':
return part_forms.BomUploadSelectFields
else:
# Default form is the starting point
return part_forms.BomUploadSelectFile
def get_context_data(self, *args, **kwargs):
ctx = super().get_context_data(*args, **kwargs)
# Give each row item access to the column it is in
# This provides for much simpler template rendering
rows = []
for row in self.bom_rows:
row_data = row['data']
data = []
for idx, item in enumerate(row_data):
data.append({
'cell': item,
'idx': idx,
'column': self.bom_columns[idx]
})
rows.append({
'index': row.get('index', -1),
'data': data,
'part_options': row.get('part_options', self.allowed_parts),
# User-input (passed between client and server)
'quantity': row.get('quantity', None),
'description': row.get('description', ''),
'part_name': row.get('part_name', ''),
'part': row.get('part', None),
'reference': row.get('reference', ''),
'notes': row.get('notes', ''),
'errors': row.get('errors', ''),
})
ctx['part'] = self.part
ctx['bom_headers'] = BomUploadManager.HEADERS
ctx['bom_columns'] = self.bom_columns
ctx['bom_rows'] = rows
ctx['missing_columns'] = self.missing_columns
ctx['allowed_parts_list'] = self.allowed_parts
return ctx
def getAllowedParts(self):
""" Return a queryset of parts which are allowed to be added to this BOM.
"""
return self.part.get_allowed_bom_items()
def get(self, request, *args, **kwargs):
form = self.form_class()
""" Perform the initial 'GET' request.
return self.renderJsonResponse(request, form)
Initially returns a form for file upload """
self.request = request
# A valid Part object must be supplied. This is the 'parent' part for the BOM
self.part = get_object_or_404(Part, pk=self.kwargs['pk'])
self.form = self.get_form()
form_class = self.get_form_class()
form = self.get_form(form_class)
return self.render_to_response(self.get_context_data(form=form))
def handleBomFileUpload(self):
""" Process a BOM file upload form.
This function validates that the uploaded file was valid,
and contains tabulated data that can be extracted.
If the file does not satisfy these requirements,
the "upload file" form is again shown to the user.
"""
bom_file = self.request.FILES.get('bom_file', None)
manager = None
bom_file_valid = False
if bom_file is None:
self.form.errors['bom_file'] = [_('No BOM file provided')]
else:
# Create a BomUploadManager object - will perform initial data validation
# (and raise a ValidationError if there is something wrong with the file)
try:
manager = BomUploadManager(bom_file)
bom_file_valid = True
except ValidationError as e:
errors = e.error_dict
for k, v in errors.items():
self.form.errors[k] = v
if bom_file_valid:
# BOM file is valid? Proceed to the next step!
form = part_forms.BomUploadSelectFields
self.template_name = 'part/bom_upload/select_fields.html'
self.extractDataFromFile(manager)
else:
form = self.form
return self.render_to_response(self.get_context_data(form=form))
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.
"""
q_idx = self.getColumnIndex('Quantity')
p_idx = self.getColumnIndex('Part')
d_idx = self.getColumnIndex('Description')
r_idx = self.getColumnIndex('Reference')
n_idx = self.getColumnIndex('Notes')
for row in self.bom_rows:
quantity = 0
part = None
if q_idx >= 0:
q_val = row['data'][q_idx]
try:
quantity = int(q_val)
except ValueError:
pass
if p_idx >= 0:
part_name = row['data'][p_idx]
row['part_name'] = part_name
# Fuzzy match the values and see what happends
matches = []
for part in self.allowed_parts:
ratio = fuzz.partial_ratio(part.name + part.description, part_name)
matches.append({'part': part, 'match': ratio})
if len(matches) > 0:
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
if d_idx >= 0:
row['description'] = row['data'][d_idx]
if r_idx >= 0:
row['reference'] = row['data'][r_idx]
if n_idx >= 0:
row['notes'] = row['data'][n_idx]
row['quantity'] = quantity
row['part_options'] = [m['part'] for m in matches]
def extractDataFromFile(self, bom):
""" Read data from the BOM file """
self.bom_columns = bom.columns()
self.bom_rows = bom.rows()
def getTableDataFromPost(self):
""" Extract table cell data from POST request.
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 in the BOM
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 self.request.POST:
value = self.request.POST[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.bom_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.bom_rows.append({
'index': row_idx,
'data': items,
'errors': {},
})
# Construct the column data
self.bom_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.bom_columns.append(header)
# Are there any missing columns?
self.missing_columns = []
for col in BomUploadManager.REQUIRED_HEADERS:
if col not in self.column_selections.values():
self.missing_columns.append(col)
def handleFieldSelection(self):
""" Handle the output of the field selection form.
Here the user is presented with the raw data and must select the
column names and which rows to process.
"""
# Extract POST data
self.getTableDataFromPost()
valid = len(self.missing_columns) == 0 and not self.duplicates
form = part_forms.BomUploadSelectFields
if valid:
# Try to extract meaningful data
self.preFillSelections()
form = None
self.template_name = 'part/bom_upload/select_parts.html'
else:
self.template_name = 'part/bom_upload/select_fields.html'
return self.render_to_response(self.get_context_data(form=form))
def handlePartSelection(self):
# Extract basic table data from POST request
self.getTableDataFromPost()
# Keep track of the parts that have been selected
parts = {}
# Extract other data (part selections, etc)
for key in self.request.POST:
value = self.request.POST[key]
# 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 = 1
try:
q = int(value)
if q <= 0:
row['errors']['quantity'] = _('Quantity must be greater than zero')
except ValueError:
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
# 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.bom_rows:
# Has a part been selected for the given row?
if row.get('part', None) 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
self.template_name = 'part/bom_upload/select_parts.html'
ctx = self.get_context_data(form=None)
if valid:
self.part.clear_bom()
# Generate new BOM items
for row in self.bom_rows:
part = row.get('part')
quantity = row.get('quantity')
reference = row.get('reference', '')
notes = row.get('notes', '')
# Create a new BOM item!
item = BomItem(
part=self.part,
sub_part=part,
quantity=quantity,
reference=reference,
note=notes
)
item.save()
# Redirect to the BOM view
return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.part.id}))
else:
ctx['form_errors'] = True
return self.render_to_response(ctx)
def getRowByIndex(self, idx):
for row in self.bom_rows:
if row['index'] == idx:
return row
return None
def post(self, request, *args, **kwargs):
"""
User has now submitted the BOM export data
""" Perform the various 'POST' requests required.
"""
# part = self.get_object()
self.request = request
return super(AjaxView, self).post(request, *args, **kwargs)
self.part = get_object_or_404(Part, pk=self.kwargs['pk'])
self.allowed_parts = self.getAllowedParts()
self.form = self.get_form(self.get_form_class())
def get_data(self):
return {
# 'form_valid': True,
# 'redirect': '/'
# 'redirect': reverse('bom-download', kwargs={'pk': self.request.GET.get('pk')})
}
# Did the user POST a file named bom_file?
form_step = request.POST.get('form_step', None)
if form_step == 'select_file':
return self.handleBomFileUpload()
elif form_step == 'select_fields':
return self.handleFieldSelection()
elif form_step == 'select_parts':
return self.handlePartSelection()
return self.render_to_response(self.get_context_data(form=self.form))
class BomUploadTemplate(AjaxView):
"""
Provide a BOM upload template file for download.
- Generates a template file in the provided format e.g. ?format=csv
"""
def get(self, request, *args, **kwargs):
export_format = request.GET.get('format', 'csv')
return MakeBomTemplate(export_format)
class BomDownload(AjaxView):
@ -654,8 +1178,6 @@ class BomDownload(AjaxView):
- File format should be passed as a query param e.g. ?format=csv
"""
# TODO - This should no longer extend an AjaxView!
model = Part
def get(self, request, *args, **kwargs):
@ -908,16 +1430,24 @@ class BomItemCreate(AjaxCreateView):
try:
part = Part.objects.get(id=part_id)
# Only allow active parts to be selected
query = form.fields['part'].queryset.filter(active=True)
form.fields['part'].queryset = query
# Don't allow selection of sub_part objects which are already added to the Bom!
query = form.fields['sub_part'].queryset
# Don't allow a part to be added to its own BOM
query = query.exclude(id=part.id)
query = query.filter(active=True)
# Eliminate any options that are already in the BOM!
query = query.exclude(id__in=[item.id for item in part.required_parts()])
form.fields['sub_part'].queryset = query
form.fields['part'].widget = HiddenInput()
except Part.DoesNotExist:
pass

View File

@ -77,6 +77,10 @@
width: 100%;
}
.bomselect {
max-width: 250px;
}
/* Part image icons with full-display on mouse hover */
.hover-img-thumb {

View File

@ -12,47 +12,79 @@ function reloadBomTable(table, options) {
}
function downloadBom(options = {}) {
function removeRowFromBomWizard(e) {
/* Remove a row from BOM upload wizard
*/
var modal = options.modal || "#modal-form";
var content = `
<b>Select file format</b><br>
<div class='controls'>
<select id='bom-format' class='select'>
<option value='csv'>CSV</option>
<option value='tsv'>TSV</option>
<option value='xls'>XLS</option>
<option value='xlsx'>XLSX</option>
<option value='ods'>ODS</option>
<option value='yaml'>YAML</option>
<option value='json'>JSON</option>
<option value='xml'>XML</option>
<option value='html'>HTML</option>
</select>
</div>
`;
e = e || window.event;
openModal({
modal: modal,
title: "Export Bill of Materials",
submit_text: "Download",
close_text: "Cancel",
var src = e.target || e.srcElement;
var table = $(src).closest('table');
// Which column was clicked?
var row = $(src).closest('tr');
row.remove();
var rowNum = 1;
var colNum = 0;
table.find('tr').each(function() {
colNum++;
if (colNum >= 3) {
var cell = $(this).find('td:eq(1)');
cell.text(rowNum++);
console.log("Row: " + rowNum);
}
});
}
modalSetContent(modal, content);
modalEnable(modal, true);
function removeColFromBomWizard(e) {
/* Remove a column from BOM upload wizard
*/
$(modal).on('click', '#modal-form-submit', function() {
$(modal).modal('hide');
e = e || window.event;
var format = $(modal).find('#bom-format :selected').val();
var src = e.target || e.srcElement;
if (options.url) {
var url = options.url + "?format=" + format;
// Which column was clicked?
var col = $(src).closest('th').index();
location.href = url;
var table = $(src).closest('table');
table.find('tr').each(function() {
this.removeChild(this.cells[col]);
});
}
function newPartFromBomWizard(e) {
/* Create a new part directly from the BOM wizard.
*/
e = e || window.event;
var src = e.target || e.srcElement;
var row = $(src).closest('tr');
launchModalForm('/part/new/', {
data: {
'description': row.attr('part-description'),
'name': row.attr('part-name'),
},
success: function(response) {
/* A new part has been created! Push it as an option.
*/
var select = row.attr('part-select');
var option = new Option(response.text, response.pk, true, true);
$(select).append(option).trigger('change');
}
});
}
@ -78,13 +110,16 @@ function loadBomTable(table, options) {
title: 'ID',
visible: false,
},
{
];
if (options.editable) {
cols.push({
checkbox: true,
title: 'Select',
searchable: false,
sortable: false,
},
];
});
}
// Part column
cols.push(
@ -106,33 +141,39 @@ function loadBomTable(table, options) {
}
);
// Part reference
cols.push({
field: 'reference',
title: 'Reference',
searchable: true,
sortable: true,
});
// Part quantity
cols.push(
{
field: 'quantity',
title: 'Required',
searchable: false,
sortable: true,
formatter: function(value, row, index, field) {
var text = value;
cols.push({
field: 'quantity',
title: 'Quantity',
searchable: false,
sortable: true,
formatter: function(value, row, index, field) {
var text = value;
if (row.overage) {
text += "<small> (+" + row.overage + ") </small>";
}
if (row.overage) {
text += "<small> (+" + row.overage + ") </small>";
}
return text;
},
footerFormatter: function(data) {
var quantity = 0;
return text;
},
footerFormatter: function(data) {
var quantity = 0;
data.forEach(function(item) {
quantity += item.quantity;
});
data.forEach(function(item) {
quantity += item.quantity;
});
return quantity;
},
}
);
return quantity;
},
});
if (!options.editable) {
cols.push(
@ -192,7 +233,7 @@ function loadBomTable(table, options) {
var bEdit = "<button title='Edit BOM Item' class='bom-edit-button btn btn-default btn-glyph' type='button' url='/part/bom/" + row.pk + "/edit'><span class='glyphicon glyphicon-edit'/></button>";
var bDelt = "<button title='Delete BOM Item' class='bom-delete-button btn btn-default btn-glyph' type='button' url='/part/bom/" + row.pk + "/delete'><span class='glyphicon glyphicon-trash'/></button>";
return "<div class='btn-group'>" + bEdit + bDelt + "</div>";
return "<div class='btn-group' role='group'>" + bEdit + bDelt + "</div>";
}
});
}

View File

@ -129,6 +129,11 @@ function loadStockTable(table, options) {
}
name += row.part__name;
if (row.part__revision) {
name += " | ";
name += row.part__revision;
}
return imageHoverIcon(row.part__image) + renderLink(name, '/part/' + row.part + '/stock/');
}

View File

@ -295,6 +295,7 @@ class StockList(generics.ListCreateAPIView):
'part',
'part__IPN',
'part__name',
'part__revision',
'part__description',
'part__image',
'part__category',

View File

@ -99,6 +99,7 @@ InvenTree
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/bom.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/tables.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/modals.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/order.js' %}"></script>