mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
3e1d240a2a
@ -837,6 +837,12 @@ input[type="submit"] {
|
||||
pointer-events: none; /* Prevent this div from blocking links underneath */
|
||||
}
|
||||
|
||||
.notes {
|
||||
border-radius: 5px;
|
||||
background-color: #fafafa;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: none;
|
||||
border-radius: 5px;
|
||||
@ -853,6 +859,11 @@ input[type="submit"] {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 3px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
padding: 3px;
|
||||
padding-left: 5px;
|
||||
|
@ -35,8 +35,8 @@ function loadTree(url, tree, options={}) {
|
||||
showTags: true,
|
||||
});
|
||||
|
||||
if (sessionStorage.getItem(key)) {
|
||||
var saved_exp = sessionStorage.getItem(key).split(",");
|
||||
if (localStorage.getItem(key)) {
|
||||
var saved_exp = localStorage.getItem(key).split(",");
|
||||
|
||||
// Automatically expand the desired notes
|
||||
for (var q = 0; q < saved_exp.length; q++) {
|
||||
@ -57,7 +57,7 @@ function loadTree(url, tree, options={}) {
|
||||
}
|
||||
|
||||
// Save the expanded nodes
|
||||
sessionStorage.setItem(key, exp);
|
||||
localStorage.setItem(key, exp);
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -106,17 +106,17 @@ function initNavTree(options) {
|
||||
width: '0px'
|
||||
}, 50);
|
||||
|
||||
sessionStorage.setItem(stateLabel, 'closed');
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
} else {
|
||||
sessionStorage.setItem(stateLabel, 'open');
|
||||
sessionStorage.setItem(widthLabel, `${width}px`);
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
localStorage.setItem(widthLabel, `${width}px`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var state = sessionStorage.getItem(stateLabel);
|
||||
var width = sessionStorage.getItem(widthLabel) || '300px';
|
||||
var state = localStorage.getItem(stateLabel);
|
||||
var width = localStorage.getItem(widthLabel) || '300px';
|
||||
|
||||
if (state && state == 'open') {
|
||||
|
||||
@ -131,21 +131,21 @@ function initNavTree(options) {
|
||||
|
||||
$(toggleId).click(function() {
|
||||
|
||||
var state = sessionStorage.getItem(stateLabel) || 'closed';
|
||||
var width = sessionStorage.getItem(widthLabel) || '300px';
|
||||
var state = localStorage.getItem(stateLabel) || 'closed';
|
||||
var width = localStorage.getItem(widthLabel) || '300px';
|
||||
|
||||
if (state == 'open') {
|
||||
$(treeId).animate({
|
||||
width: '0px'
|
||||
}, 50);
|
||||
|
||||
sessionStorage.setItem(stateLabel, 'closed');
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
} else {
|
||||
$(treeId).animate({
|
||||
width: width,
|
||||
}, 50);
|
||||
|
||||
sessionStorage.setItem(stateLabel, 'open');
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -198,17 +198,18 @@ function enableNavbar(options) {
|
||||
width: '45px'
|
||||
}, 50);
|
||||
|
||||
sessionStorage.setItem(stateLabel, 'closed');
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
} else {
|
||||
sessionStorage.setItem(widthLabel, `${width}px`);
|
||||
sessionStorage.setItem(stateLabel, 'open');
|
||||
localStorage.setItem(widthLabel, `${width}px`);
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var state = sessionStorage.getItem(stateLabel);
|
||||
var width = sessionStorage.getItem(widthLabel) || '250px';
|
||||
var state = localStorage.getItem(stateLabel);
|
||||
|
||||
var width = localStorage.getItem(widthLabel) || '250px';
|
||||
|
||||
if (state && state == 'open') {
|
||||
|
||||
@ -224,8 +225,8 @@ function enableNavbar(options) {
|
||||
|
||||
$(toggleId).click(function() {
|
||||
|
||||
var state = sessionStorage.getItem(stateLabel) || 'closed';
|
||||
var width = sessionStorage.getItem(widthLabel) || '250px';
|
||||
var state = localStorage.getItem(stateLabel) || 'closed';
|
||||
var width = localStorage.getItem(widthLabel) || '250px';
|
||||
|
||||
if (state == 'open') {
|
||||
$(navId).animate({
|
||||
@ -233,7 +234,7 @@ function enableNavbar(options) {
|
||||
minWidth: '45px',
|
||||
}, 50);
|
||||
|
||||
sessionStorage.setItem(stateLabel, 'closed');
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
|
||||
} else {
|
||||
|
||||
@ -241,7 +242,7 @@ function enableNavbar(options) {
|
||||
'width': width
|
||||
}, 50);
|
||||
|
||||
sessionStorage.setItem(stateLabel, 'open');
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,6 @@ from django.utils.translation import ugettext_lazy as _
|
||||
import django.forms
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
|
||||
@ -35,25 +34,6 @@ class CompanyImageDownloadForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class EditManufacturerPartForm(HelperForm):
|
||||
""" Form for editing a ManufacturerPart object """
|
||||
|
||||
field_prefix = {
|
||||
'link': 'fa-link',
|
||||
'MPN': 'fa-hashtag',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = ManufacturerPart
|
||||
fields = [
|
||||
'part',
|
||||
'manufacturer',
|
||||
'MPN',
|
||||
'description',
|
||||
'link',
|
||||
]
|
||||
|
||||
|
||||
class EditSupplierPartForm(HelperForm):
|
||||
""" Form for editing a SupplierPart object """
|
||||
|
||||
|
@ -206,24 +206,27 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
|
||||
MPN = serializers.StringRelatedField(source='manufacturer_part.MPN')
|
||||
|
||||
manufacturer_part = ManufacturerPartSerializer(read_only=True)
|
||||
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SupplierPart
|
||||
fields = [
|
||||
'description',
|
||||
'link',
|
||||
'manufacturer',
|
||||
'manufacturer_detail',
|
||||
'manufacturer_part',
|
||||
'manufacturer_part_detail',
|
||||
'MPN',
|
||||
'note',
|
||||
'pk',
|
||||
'packaging',
|
||||
'part',
|
||||
'part_detail',
|
||||
'pretty_name',
|
||||
'SKU',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'SKU',
|
||||
'manufacturer',
|
||||
'MPN',
|
||||
'manufacturer_detail',
|
||||
'manufacturer_part',
|
||||
'description',
|
||||
'link',
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
|
@ -53,29 +53,27 @@
|
||||
{{ block.super }}
|
||||
|
||||
$("#manufacturer-part-create").click(function () {
|
||||
launchModalForm(
|
||||
"{% url 'manufacturer-part-create' %}",
|
||||
{
|
||||
data: {
|
||||
manufacturer: {{ company.id }},
|
||||
|
||||
constructForm('{% url "api-manufacturer-part-list" %}', {
|
||||
fields: {
|
||||
part: {},
|
||||
manufacturer: {
|
||||
value: {{ company.pk }},
|
||||
},
|
||||
success: function() {
|
||||
$("#part-table").bootstrapTable("refresh");
|
||||
MPN: {
|
||||
icon: 'fa-hashtag',
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'part',
|
||||
label: '{% trans "New Part" %}',
|
||||
title: '{% trans "Create new Part" %}',
|
||||
url: "{% url 'part-create' %}"
|
||||
},
|
||||
{
|
||||
field: 'manufacturer',
|
||||
label: '{% trans "New Manufacturer" %}',
|
||||
title: '{% trans "Create new Manufacturer" %}',
|
||||
},
|
||||
]
|
||||
});
|
||||
description: {},
|
||||
link: {
|
||||
icon: 'fa-link',
|
||||
},
|
||||
},
|
||||
method: 'POST',
|
||||
title: '{% trans "Add Manufacturer Part" %}',
|
||||
onSuccess: function() {
|
||||
$("#part-table").bootstrapTable("refresh");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadManufacturerPartTable(
|
||||
|
@ -62,7 +62,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{% trans "Internal Part" %}</td>
|
||||
<td>
|
||||
{% if part.part %}
|
||||
<a href="{% url 'part-manufacturers' part.part.id %}">{{ part.part.full_name }}</a>{% include "clip.html"%}
|
||||
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>{% include "clip.html"%}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@ -118,9 +118,13 @@ $('#edit-part').click(function () {
|
||||
fields: {
|
||||
part: {},
|
||||
manufacturer: {},
|
||||
MPN: {},
|
||||
MPN: {
|
||||
icon: 'fa-hashtag',
|
||||
},
|
||||
description: {},
|
||||
link: {},
|
||||
link: {
|
||||
icon: 'fa-link',
|
||||
},
|
||||
},
|
||||
title: '{% trans "Edit Manufacturer Part" %}',
|
||||
reload: true,
|
||||
|
@ -18,7 +18,7 @@
|
||||
<td>{% trans "Internal Part" %}</td>
|
||||
<td>
|
||||
{% if part.part %}
|
||||
<a href="{% url 'part-manufacturers' part.part.id %}">{{ part.part.full_name }}</a>
|
||||
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -230,7 +230,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
# Check manufacturer part
|
||||
manufacturer_part_id = int(response.data['manufacturer_part']['pk'])
|
||||
manufacturer_part_id = int(response.data['manufacturer_part_detail']['pk'])
|
||||
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
|
||||
response = self.get(url)
|
||||
self.assertEqual(response.data['MPN'], 'PART_NUMBER')
|
||||
|
@ -194,45 +194,6 @@ class ManufacturerPartViewTests(CompanyViewTestBase):
|
||||
Tests for the ManufacturerPart views.
|
||||
"""
|
||||
|
||||
def test_manufacturer_part_create(self):
|
||||
"""
|
||||
Test the ManufacturerPartCreate view.
|
||||
"""
|
||||
|
||||
url = reverse('manufacturer-part-create')
|
||||
|
||||
# First check that we can GET the form
|
||||
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# How many manufaturer parts are already in the database?
|
||||
n = ManufacturerPart.objects.all().count()
|
||||
|
||||
data = {
|
||||
'part': 1,
|
||||
'manufacturer': 6,
|
||||
}
|
||||
|
||||
# MPN is required! (form should fail)
|
||||
(response, errors) = self.post(url, data, valid=False)
|
||||
|
||||
self.assertIsNotNone(errors.get('MPN', None))
|
||||
|
||||
data['MPN'] = 'TEST-ME-123'
|
||||
|
||||
(response, errors) = self.post(url, data, valid=True)
|
||||
|
||||
# Check that the ManufacturerPart was created!
|
||||
self.assertEqual(n + 1, ManufacturerPart.objects.all().count())
|
||||
|
||||
# Try to create duplicate ManufacturerPart
|
||||
(response, errors) = self.post(url, data, valid=False)
|
||||
|
||||
self.assertIsNotNone(errors.get('__all__', None))
|
||||
|
||||
# Check that the ManufacturerPart count stayed the same
|
||||
self.assertEqual(n + 1, ManufacturerPart.objects.all().count())
|
||||
|
||||
def test_supplier_part_create(self):
|
||||
"""
|
||||
Test that the SupplierPartCreate view creates Manufacturer Part.
|
||||
|
@ -38,8 +38,7 @@ company_urls = [
|
||||
]
|
||||
|
||||
manufacturer_part_urls = [
|
||||
url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'),
|
||||
|
||||
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'),
|
||||
url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'),
|
||||
|
@ -29,7 +29,6 @@ from .models import SupplierPart
|
||||
|
||||
from part.models import Part
|
||||
|
||||
from .forms import EditManufacturerPartForm
|
||||
from .forms import EditSupplierPartForm
|
||||
from .forms import CompanyImageDownloadForm
|
||||
|
||||
@ -242,74 +241,6 @@ class ManufacturerPartDetail(DetailView):
|
||||
return ctx
|
||||
|
||||
|
||||
class ManufacturerPartCreate(AjaxCreateView):
|
||||
""" Create view for making new ManufacturerPart """
|
||||
|
||||
model = ManufacturerPart
|
||||
form_class = EditManufacturerPartForm
|
||||
ajax_template_name = 'company/manufacturer_part_create.html'
|
||||
ajax_form_title = _('Create New Manufacturer Part')
|
||||
context_object_name = 'part'
|
||||
|
||||
def get_context_data(self):
|
||||
"""
|
||||
Supply context data to the form
|
||||
"""
|
||||
|
||||
ctx = super().get_context_data()
|
||||
|
||||
# Add 'part' object
|
||||
form = self.get_form()
|
||||
|
||||
part = form['part'].value()
|
||||
|
||||
try:
|
||||
part = Part.objects.get(pk=part)
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
part = None
|
||||
|
||||
ctx['part'] = part
|
||||
|
||||
return ctx
|
||||
|
||||
def get_form(self):
|
||||
""" Create Form instance to create a new ManufacturerPart object.
|
||||
Hide some fields if they are not appropriate in context
|
||||
"""
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
if form.initial.get('part', None):
|
||||
# Hide the part field
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
""" Provide initial data for new ManufacturerPart:
|
||||
|
||||
- If 'manufacturer_id' provided, pre-fill manufacturer field
|
||||
- If 'part_id' provided, pre-fill part field
|
||||
"""
|
||||
initials = super(ManufacturerPartCreate, self).get_initial().copy()
|
||||
|
||||
manufacturer_id = self.get_param('manufacturer')
|
||||
part_id = self.get_param('part')
|
||||
|
||||
if manufacturer_id:
|
||||
try:
|
||||
initials['manufacturer'] = Company.objects.get(pk=manufacturer_id)
|
||||
except (ValueError, Company.DoesNotExist):
|
||||
pass
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
initials['part'] = Part.objects.get(pk=part_id)
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class SupplierPartDetail(DetailView):
|
||||
""" Detail view for SupplierPart """
|
||||
model = SupplierPart
|
||||
|
@ -3,14 +3,9 @@ Functionality for Bill of Material (BOM) management.
|
||||
Primarily BOM upload tools.
|
||||
"""
|
||||
|
||||
from rapidfuzz import fuzz
|
||||
import tablib
|
||||
import os
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from InvenTree.helpers import DownloadFile, GetExportFormats
|
||||
|
||||
@ -145,11 +140,16 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
stock_data = []
|
||||
# Get part default location
|
||||
try:
|
||||
stock_data.append(bom_item.sub_part.get_default_location().name)
|
||||
loc = bom_item.sub_part.get_default_location()
|
||||
|
||||
if loc is not None:
|
||||
stock_data.append(str(loc.name))
|
||||
else:
|
||||
stock_data.append('')
|
||||
except AttributeError:
|
||||
stock_data.append('')
|
||||
# Get part current stock
|
||||
stock_data.append(bom_item.sub_part.available_stock)
|
||||
stock_data.append(str(bom_item.sub_part.available_stock))
|
||||
|
||||
for s_idx, header in enumerate(stock_headers):
|
||||
try:
|
||||
@ -323,177 +323,6 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
|
||||
data = dataset.export(fmt)
|
||||
|
||||
filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt)
|
||||
filename = f"{part.full_name}_BOM.{fmt}"
|
||||
|
||||
return DownloadFile(data, filename)
|
||||
|
||||
|
||||
class BomUploadManager:
|
||||
""" Class for managing an uploaded BOM file """
|
||||
|
||||
# Fields which are absolutely necessary for valid upload
|
||||
REQUIRED_HEADERS = [
|
||||
'Quantity'
|
||||
]
|
||||
|
||||
# Fields which are used for part matching (only one of them is needed)
|
||||
PART_MATCH_HEADERS = [
|
||||
'Part_Name',
|
||||
'Part_IPN',
|
||||
'Part_ID',
|
||||
]
|
||||
|
||||
# Fields which would be helpful but are not required
|
||||
OPTIONAL_HEADERS = [
|
||||
'Reference',
|
||||
'Note',
|
||||
'Overage',
|
||||
]
|
||||
|
||||
EDITABLE_HEADERS = [
|
||||
'Reference',
|
||||
'Note',
|
||||
'Overage'
|
||||
]
|
||||
|
||||
HEADERS = REQUIRED_HEADERS + PART_MATCH_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
|
||||
|
||||
# Try for a case-insensitive match with space replacement
|
||||
for h in self.HEADERS:
|
||||
if h.lower() == header.lower().replace(' ', '_'):
|
||||
return h
|
||||
|
||||
# Finally, look for a close match using fuzzy matching
|
||||
matches = []
|
||||
|
||||
for h in self.HEADERS:
|
||||
ratio = fuzz.partial_ratio(header, h)
|
||||
if ratio > threshold:
|
||||
matches.append({'header': h, 'match': ratio})
|
||||
|
||||
if len(matches) > 0:
|
||||
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
|
||||
return matches[0]['header']
|
||||
|
||||
return None
|
||||
|
||||
def columns(self):
|
||||
""" Return a list of headers for the thingy """
|
||||
headers = []
|
||||
|
||||
for header in self.data.headers:
|
||||
headers.append({
|
||||
'name': header,
|
||||
'guess': self.guess_header(header)
|
||||
})
|
||||
|
||||
return headers
|
||||
|
||||
def col_count(self):
|
||||
if self.data is None:
|
||||
return 0
|
||||
|
||||
return len(self.data.headers)
|
||||
|
||||
def row_count(self):
|
||||
""" Return the number of rows in the file. """
|
||||
|
||||
if self.data is None:
|
||||
return 0
|
||||
|
||||
return len(self.data)
|
||||
|
||||
def rows(self):
|
||||
""" Return a list of all rows """
|
||||
rows = []
|
||||
|
||||
for i in range(self.row_count()):
|
||||
|
||||
data = [item for item in self.get_row_data(i)]
|
||||
|
||||
# Is the row completely empty? Skip!
|
||||
empty = True
|
||||
|
||||
for idx, item in enumerate(data):
|
||||
if len(str(item).strip()) > 0:
|
||||
empty = False
|
||||
|
||||
try:
|
||||
# Excel import casts number-looking-items into floats, which is annoying
|
||||
if item == int(item) and not str(item) == str(int(item)):
|
||||
data[idx] = int(item)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Skip empty rows
|
||||
if empty:
|
||||
continue
|
||||
|
||||
row = {
|
||||
'data': data,
|
||||
'index': i
|
||||
}
|
||||
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
|
||||
def get_row_data(self, index):
|
||||
""" Retrieve row data at a particular index """
|
||||
if self.data is None or index >= len(self.data):
|
||||
return None
|
||||
|
||||
return self.data[index]
|
||||
|
||||
def get_row_dict(self, index):
|
||||
""" Retrieve a dict object representing the data row at a particular offset """
|
||||
|
||||
if self.data is None or index >= len(self.data):
|
||||
return None
|
||||
|
||||
return self.data.dict[index]
|
||||
|
@ -11,10 +11,11 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.helpers import GetExportFormats
|
||||
from InvenTree.helpers import GetExportFormats, clean_decimal
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
|
||||
import common.models
|
||||
from common.forms import MatchItemForm
|
||||
|
||||
from .models import Part, PartCategory, PartRelated
|
||||
from .models import BomItem
|
||||
@ -55,16 +56,6 @@ class PartImageDownloadForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class PartImageForm(HelperForm):
|
||||
""" Form for uploading a Part image """
|
||||
|
||||
class Meta:
|
||||
model = Part
|
||||
fields = [
|
||||
'image',
|
||||
]
|
||||
|
||||
|
||||
class BomExportForm(forms.Form):
|
||||
""" Simple form to let user set BOM export options,
|
||||
before exporting a BOM (bill of materials) file.
|
||||
@ -143,16 +134,28 @@ class BomValidateForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class BomUploadSelectFile(HelperForm):
|
||||
""" Form for importing a BOM. Provides a file input box for upload """
|
||||
class BomMatchItemForm(MatchItemForm):
|
||||
""" Override MatchItemForm fields """
|
||||
|
||||
bom_file = forms.FileField(label=_('BOM file'), required=True, help_text=_("Select BOM file to upload"))
|
||||
def get_special_field(self, col_guess, row, file_manager):
|
||||
""" Set special fields """
|
||||
|
||||
class Meta:
|
||||
model = Part
|
||||
fields = [
|
||||
'bom_file',
|
||||
]
|
||||
# set quantity field
|
||||
if 'quantity' in col_guess.lower():
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={
|
||||
'name': 'quantity' + str(row['index']),
|
||||
'class': 'numberinput',
|
||||
'type': 'number',
|
||||
'min': '0',
|
||||
'step': 'any',
|
||||
'value': clean_decimal(row.get('quantity', '')),
|
||||
})
|
||||
)
|
||||
|
||||
# return default
|
||||
return super().get_special_field(col_guess, row, file_manager)
|
||||
|
||||
|
||||
class CreatePartRelatedForm(HelperForm):
|
||||
|
@ -30,7 +30,7 @@ from mptt.models import TreeForeignKey, MPTTModel
|
||||
|
||||
from stdimage.models import StdImageField
|
||||
|
||||
from decimal import Decimal
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import datetime
|
||||
from rapidfuzz import fuzz
|
||||
import hashlib
|
||||
@ -2418,6 +2418,15 @@ class BomItem(models.Model):
|
||||
- If the "sub_part" is trackable, then the "part" must be trackable too!
|
||||
"""
|
||||
|
||||
super().clean()
|
||||
|
||||
try:
|
||||
self.quantity = Decimal(self.quantity)
|
||||
except InvalidOperation:
|
||||
raise ValidationError({
|
||||
'quantity': _('Must be a valid number')
|
||||
})
|
||||
|
||||
try:
|
||||
# Check for circular BOM references
|
||||
if self.sub_part:
|
||||
|
@ -1,86 +0,0 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include 'part/navbar.html' with tab='attachments' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Part Attachments" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% include "attachment_table.html" with attachments=part.part_attachments %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
loadAttachmentTable(
|
||||
'{% url "api-part-attachment-list" %}',
|
||||
{
|
||||
filters: {
|
||||
part: {{ part.pk }},
|
||||
},
|
||||
onEdit: function(pk) {
|
||||
var url = `/api/part/attachment/${pk}/`;
|
||||
|
||||
constructForm(url, {
|
||||
fields: {
|
||||
comment: {},
|
||||
},
|
||||
title: '{% trans "Edit Attachment" %}',
|
||||
onSuccess: reloadAttachmentTable,
|
||||
});
|
||||
},
|
||||
onDelete: function(pk) {
|
||||
var url = `/api/part/attachment/${pk}/`;
|
||||
|
||||
constructForm(url, {
|
||||
method: 'DELETE',
|
||||
confirmMessage: '{% trans "Confirm Delete Operation" %}',
|
||||
title: '{% trans "Delete Attachment" %}',
|
||||
onSuccess: reloadAttachmentTable,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-part-attachment-list" %}',
|
||||
{
|
||||
data: {
|
||||
part: {{ part.id }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
reloadAttachmentTable();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$("#new-attachment").click(function() {
|
||||
|
||||
constructForm(
|
||||
'{% url "api-part-attachment-list" %}',
|
||||
{
|
||||
method: 'POST',
|
||||
fields: {
|
||||
attachment: {},
|
||||
comment: {},
|
||||
part: {
|
||||
value: {{ part.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
},
|
||||
onSuccess: reloadAttachmentTable,
|
||||
title: '{% trans "Add Attachment" %}',
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -11,6 +11,12 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% if roles.part.change != True and editing_enabled %}
|
||||
<div class='alert alert-danger alert-block'>
|
||||
{% trans "You do not have permission to edit the BOM." %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if part.bom_checked_date %}
|
||||
{% if part.is_bom_valid %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
@ -72,6 +78,7 @@
|
||||
|
||||
<table class='table table-bom table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
99
InvenTree/part/templates/part/bom_upload/match_fields.html
Normal file
99
InvenTree/part/templates/part/bom_upload/match_fields.html
Normal file
@ -0,0 +1,99 @@
|
||||
{% 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' 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.value %}
|
||||
<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 %}
|
127
InvenTree/part/templates/part/bom_upload/match_parts.html
Normal file
127
InvenTree/part/templates/part/bom_upload/match_parts.html
Normal file
@ -0,0 +1,127 @@
|
||||
{% 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-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 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-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.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 %}
|
@ -1,94 +0,0 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "part/navbar.html" with tab='bom' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Upload Bill of Materials" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
<p>{% trans "Step 2 - Select Fields" %}</p>
|
||||
<hr>
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
|
||||
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||
{% csrf_token %}
|
||||
|
||||
<input type='hidden' name='form_step' value='select_fields'/>
|
||||
|
||||
<table class='table table-striped'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans "File Fields" %}</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' 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></td>
|
||||
<td>{% trans "Match Fields" %}</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'>{% trans "Duplicate column selection" %}</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in bom_rows %}
|
||||
<tr>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ forloop.counter }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ forloop.counter }}' class='fas fa-trash-alt icon-red'></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 %}
|
@ -1,121 +0,0 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "part/navbar.html" with tab="bom" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Upload Bill of Materials" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
<p>{% trans "Step 3 - Select Parts" %}</p>
|
||||
<hr>
|
||||
|
||||
{% if form_errors %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "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">{% trans "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>{% trans "Row" %}</th>
|
||||
<th>{% trans "Select Part" %}</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' onClick='removeRowFromBomWizard()' id='del_row_{{ forloop.counter }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ forloop.counter }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>{% add row.index 1 %}</td>
|
||||
<td>
|
||||
<button class='btn btn-default btn-create' onClick='newPartFromBomWizard()' id='new_part_row_{{ row.index }}' title='{% trans "Create new part" %}' type='button'>
|
||||
<span row_id='{{ row.index }}' class='fas fa-plus icon-green'/>
|
||||
</button>
|
||||
<select class='select bomselect' id='select_part_{{ row.index }}' name='part_{{ row.index }}'>
|
||||
<option value=''>--- {% trans "Select Part" %} ---</option>
|
||||
{% for part in row.part_options %}
|
||||
<option value='{{ part.id }}' {% if part.id == row.part.id %} selected='selected' {% elif part.id == row.part_match.id %} selected='selected' {% endif %}>
|
||||
{{ part }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if row.errors.part %}
|
||||
<p class='help-inline'>{{ row.errors.part }}</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
{% if item.column.guess == 'Part' %}
|
||||
<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' step='any' value='{% decimal 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 == 'Note' %}
|
||||
<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 %}
|
@ -8,13 +8,12 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Upload Bill of Materials" %}
|
||||
{% trans "Upload BOM File" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
<p>{% trans "Step 1 - Select BOM File" %}</p>
|
||||
|
||||
{% block form_alert %}
|
||||
<div class='alert alert-info alert-block'>
|
||||
<b>{% trans "Requirements for BOM upload" %}:</b>
|
||||
<ul>
|
||||
@ -22,16 +21,31 @@
|
||||
<li>{% trans "Each part must already exist in the database" %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
|
||||
<button type="submit" class="save btn btn-default">{% trans 'Upload File' %}</button>
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
<input type='hidden' name='form_step' value='select_file'/>
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% crispy form %}
|
||||
{% 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 %}
|
||||
|
||||
{% endblock %}
|
@ -1,6 +1,7 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load markdownify %}
|
||||
|
||||
|
||||
{% block menubar %}
|
||||
@ -135,11 +136,38 @@
|
||||
{% endif %}
|
||||
{% if part.responsible %}
|
||||
<tr>
|
||||
<td><span class='fas fa-user'>d</span></td>
|
||||
<td><span class='fas fa-user'></span></td>
|
||||
<td><strong>{% trans "Responsible User" %}</strong></td>
|
||||
<td>{{ part.responsible }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
<tr><td colspan="3"></td></tr>
|
||||
|
||||
<tr>
|
||||
<td><span class='fas fa-sticky-note'></span></td>
|
||||
<td>
|
||||
<strong>{% trans "Notes" %}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div class='btn-group float-right'>
|
||||
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-small btn-default'>
|
||||
<span class='fas fa-edit'>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan='3'>
|
||||
{% if part.notes %}
|
||||
<div class='notes'>
|
||||
{{ part.notes | markdownify }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
@ -238,6 +266,42 @@
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block post_content_panel %}
|
||||
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Parameters" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='param-button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
{% if roles.part.add %}
|
||||
<button title='{% trans "Add new parameter" %}' class='btn btn-success' id='param-create'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#param-button-toolbar"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Attachments" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% include "attachment_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_load %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
@ -245,6 +309,18 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-part-detail" part.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Part Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
$(".slidey").change(function() {
|
||||
var field = $(this).attr('fieldname');
|
||||
|
||||
@ -263,4 +339,118 @@
|
||||
);
|
||||
});
|
||||
|
||||
loadPartParameterTable(
|
||||
'#parameter-table',
|
||||
'{% url "api-part-parameter-list" %}',
|
||||
{
|
||||
params: {
|
||||
part: {{ part.pk }},
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$('#param-table').inventreeTable({
|
||||
});
|
||||
|
||||
{% if roles.part.add %}
|
||||
$('#param-create').click(function() {
|
||||
|
||||
constructForm('{% url "api-part-parameter-list" %}', {
|
||||
method: 'POST',
|
||||
fields: {
|
||||
part: {
|
||||
value: {{ part.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
template: {},
|
||||
data: {},
|
||||
},
|
||||
title: '{% trans "Add Parameter" %}',
|
||||
onSuccess: function() {
|
||||
$('#parameter-table').bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$('.param-edit').click(function() {
|
||||
var button = $(this);
|
||||
|
||||
launchModalForm(button.attr('url'), {
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
$('.param-delete').click(function() {
|
||||
var button = $(this);
|
||||
|
||||
launchModalForm(button.attr('url'), {
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
loadAttachmentTable(
|
||||
'{% url "api-part-attachment-list" %}',
|
||||
{
|
||||
filters: {
|
||||
part: {{ part.pk }},
|
||||
},
|
||||
onEdit: function(pk) {
|
||||
var url = `/api/part/attachment/${pk}/`;
|
||||
|
||||
constructForm(url, {
|
||||
fields: {
|
||||
comment: {},
|
||||
},
|
||||
title: '{% trans "Edit Attachment" %}',
|
||||
onSuccess: reloadAttachmentTable,
|
||||
});
|
||||
},
|
||||
onDelete: function(pk) {
|
||||
var url = `/api/part/attachment/${pk}/`;
|
||||
|
||||
constructForm(url, {
|
||||
method: 'DELETE',
|
||||
confirmMessage: '{% trans "Confirm Delete Operation" %}',
|
||||
title: '{% trans "Delete Attachment" %}',
|
||||
onSuccess: reloadAttachmentTable,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-part-attachment-list" %}',
|
||||
{
|
||||
data: {
|
||||
part: {{ part.id }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
reloadAttachmentTable();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$("#new-attachment").click(function() {
|
||||
|
||||
constructForm(
|
||||
'{% url "api-part-attachment-list" %}',
|
||||
{
|
||||
method: 'POST',
|
||||
fields: {
|
||||
attachment: {},
|
||||
comment: {},
|
||||
part: {
|
||||
value: {{ part.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
},
|
||||
onSuccess: reloadAttachmentTable,
|
||||
title: '{% trans "Add Attachment" %}',
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
|
@ -1,84 +0,0 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include 'part/navbar.html' with tab='manufacturers' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Part Manufacturers" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div class='btn-group'>
|
||||
<button class="btn btn-success" id='manufacturer-create'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
||||
</button>
|
||||
<div id='opt-dropdown' class="btn-group">
|
||||
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-condensed" id='manufacturer-table' data-toolbar='#button-toolbar'>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_load %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('#manufacturer-create').click(function () {
|
||||
launchModalForm(
|
||||
"{% url 'manufacturer-part-create' %}",
|
||||
{
|
||||
reload: true,
|
||||
data: {
|
||||
part: {{ part.id }}
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'manufacturer',
|
||||
label: '{% trans "New Manufacturer" %}',
|
||||
title: '{% trans "Create new manufacturer" %}',
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
$("#manufacturer-part-delete").click(function() {
|
||||
|
||||
var selections = $("#manufacturer-table").bootstrapTable("getSelections");
|
||||
|
||||
deleteManufacturerParts(selections, {
|
||||
onSuccess: function() {
|
||||
$("#manufacturer-table").bootstrapTable("refresh");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadManufacturerPartTable(
|
||||
"#manufacturer-table",
|
||||
"{% url 'api-manufacturer-part-list' %}",
|
||||
{
|
||||
params: {
|
||||
part: {{ part.id }},
|
||||
part_detail: true,
|
||||
manufacturer_detail: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
linkButtonsToSelection($("#manufacturer-table"), ['#manufacturer-part-options'])
|
||||
|
||||
{% endblock %}
|
@ -19,12 +19,6 @@
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class='list-group-item {% if tab == "params" %}active{% endif %}' title='{% trans "Part Parameters" %}'>
|
||||
<a href='{% url "part-params" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-tasks sidebar-icon'></span>
|
||||
{% trans "Parameters" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if part.is_template %}
|
||||
<li class='list-group-item {% if tab == "variants" %}active{% endif %}' title='{% trans "Part Variants" %}'>
|
||||
<a href='{% url "part-variants" part.id %}'>
|
||||
@ -78,12 +72,6 @@
|
||||
</a>
|
||||
</li>
|
||||
{% if part.purchaseable and roles.purchase_order.view %}
|
||||
<li class='list-group-item {% if tab == "manufacturers" %}active{% endif %}' title='{% trans "Manufacturers" %}'>
|
||||
<a href='{% url "part-manufacturers" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-industry sidebar-icon'></span>
|
||||
{% trans "Manufacturers" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class='list-group-item {% if tab == "suppliers" %}active{% endif %}' title='{% trans "Suppliers" %}'>
|
||||
<a href='{% url "part-suppliers" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-building sidebar-icon'></span>
|
||||
@ -109,7 +97,7 @@
|
||||
<li class='list-group-item {% if tab == "tests" %}active{% endif %}' title='{% trans "Part Test Templates" %}'>
|
||||
<a href='{% url "part-test-templates" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-vial sidebar-icon'></span>
|
||||
{% trans "Tests" %}
|
||||
{% trans "Test Templates" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -121,16 +109,4 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class='list-group-item {% if tab == "attachments" %}active{% endif %}' title='{% trans "Attachments" %}'>
|
||||
<a href='{% url "part-attachments" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-paperclip sidebar-icon'></span>
|
||||
{% trans "Attachments" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class='list-group-item {% if tab == "notes" %}active{% endif %}' title='{% trans "Part Notes" %}'>
|
||||
<a href='{% url "part-notes" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-clipboard sidebar-icon'></span>
|
||||
{% trans "Notes" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -1,57 +0,0 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include 'part/navbar.html' with tab='notes' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Part Notes" %}
|
||||
{% if roles.part.change and not editing %}
|
||||
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% if editing %}
|
||||
<form method='POST'>
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form }}
|
||||
<hr>
|
||||
|
||||
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
|
||||
|
||||
</form>
|
||||
|
||||
{{ form.media }}
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class='panel panel-default'>
|
||||
{% if part.notes %}
|
||||
<div class='panel-content'>
|
||||
{{ part.notes | markdownify }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if editing %}
|
||||
{% else %}
|
||||
$("#edit-notes").click(function() {
|
||||
location.href = "{% url 'part-notes' part.id %}?edit=1";
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -1,81 +0,0 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "part/navbar.html" with tab='params' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Part Parameters" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
{% if roles.part.add %}
|
||||
<button title='{% trans "Add new parameter" %}' class='btn btn-success' id='param-create'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#button-toolbar"></table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
loadPartParameterTable(
|
||||
'#parameter-table',
|
||||
'{% url "api-part-parameter-list" %}',
|
||||
{
|
||||
params: {
|
||||
part: {{ part.pk }},
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$('#param-table').inventreeTable({
|
||||
});
|
||||
|
||||
{% if roles.part.add %}
|
||||
$('#param-create').click(function() {
|
||||
|
||||
constructForm('{% url "api-part-parameter-list" %}', {
|
||||
method: 'POST',
|
||||
fields: {
|
||||
part: {
|
||||
value: {{ part.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
template: {},
|
||||
data: {},
|
||||
},
|
||||
title: '{% trans "Add Parameter" %}',
|
||||
onSuccess: function() {
|
||||
$('#parameter-table').bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$('.param-edit').click(function() {
|
||||
var button = $(this);
|
||||
|
||||
launchModalForm(button.attr('url'), {
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
$('.param-delete').click(function() {
|
||||
var button = $(this);
|
||||
|
||||
launchModalForm(button.attr('url'), {
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -49,9 +49,25 @@
|
||||
<span id='part-price-icon' class='fas fa-dollar-sign'/>
|
||||
</button>
|
||||
{% if roles.stock.change %}
|
||||
<button type='button' class='btn btn-default' id='part-count' title='{% trans "Count part stock" %}'>
|
||||
<span class='fas fa-clipboard-list'/>
|
||||
</button>
|
||||
<div class='btn-group'>
|
||||
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-boxes'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li>
|
||||
<a href='#' id='part-count'>
|
||||
<span class='fas fa-clipboard-list'></span>
|
||||
{% trans "Count part stock" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='#' id='part-move'>
|
||||
<span class='fas fa-exchange-alt'></span>
|
||||
{% trans "Transfer part stock" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if part.purchaseable %}
|
||||
{% if roles.purchase_order.add %}
|
||||
@ -272,14 +288,34 @@
|
||||
printPartLabels([{{ part.pk }}]);
|
||||
});
|
||||
|
||||
$("#part-count").click(function() {
|
||||
launchModalForm("/stock/adjust/", {
|
||||
data: {
|
||||
action: "count",
|
||||
function adjustPartStock(action) {
|
||||
inventreeGet(
|
||||
'{% url "api-stock-list" %}',
|
||||
{
|
||||
part: {{ part.id }},
|
||||
in_stock: true,
|
||||
allow_variants: true,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
reload: true,
|
||||
});
|
||||
{
|
||||
success: function(items) {
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
$("#part-move").click(function() {
|
||||
adjustPartStock('move');
|
||||
});
|
||||
|
||||
$("#part-count").click(function() {
|
||||
adjustPartStock('count');
|
||||
});
|
||||
|
||||
$("#price-button").click(function() {
|
||||
|
@ -6,12 +6,13 @@
|
||||
{% include 'part/navbar.html' with tab='suppliers' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Part Suppliers" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
<div id='button-toolbar'>
|
||||
<div id='supplier-button-toolbar'>
|
||||
<div class='btn-group'>
|
||||
<button class="btn btn-success" id='supplier-create'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
|
||||
@ -25,11 +26,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#button-toolbar'>
|
||||
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#supplier-button-toolbar'>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block post_content_panel %}
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Part Manufacturers" %}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class='panel-content'>
|
||||
<div id='manufacturer-button-toolbar'>
|
||||
<div class='btn-group'>
|
||||
<button class="btn btn-success" id='manufacturer-create'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
|
||||
</button>
|
||||
<div id='opt-dropdown' class="btn-group">
|
||||
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-condensed table-striped' id='manufacturer-table' data-toolbar='#manufacturer-button-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_load %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
@ -90,6 +120,52 @@
|
||||
}
|
||||
);
|
||||
|
||||
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options'])
|
||||
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']);
|
||||
|
||||
loadManufacturerPartTable(
|
||||
'#manufacturer-table',
|
||||
"{% url 'api-manufacturer-part-list' %}",
|
||||
{
|
||||
params: {
|
||||
part: {{ part.id }},
|
||||
part_detail: true,
|
||||
manufacturer_detail: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
linkButtonsToSelection($("#manufacturer-table"), ['#manufacturer-part-options']);
|
||||
|
||||
$("#manufacturer-part-delete").click(function() {
|
||||
|
||||
var selections = $("#manufacturer-table").bootstrapTable("getSelections");
|
||||
|
||||
deleteManufacturerParts(selections, {
|
||||
onSuccess: function() {
|
||||
$("#manufacturer-table").bootstrapTable("refresh");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#manufacturer-create').click(function () {
|
||||
|
||||
constructForm('{% url "api-manufacturer-part-list" %}', {
|
||||
fields: {
|
||||
part: {
|
||||
value: {{ part.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
manufacturer: {},
|
||||
MPN: {},
|
||||
description: {},
|
||||
link: {},
|
||||
},
|
||||
method: 'POST',
|
||||
title: '{% trans "Add Manufacturer Part" %}',
|
||||
onSuccess: function() {
|
||||
$("#manufacturer-table").bootstrapTable("refresh");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
{% endblock %}
|
131
InvenTree/part/test_bom_export.py
Normal file
131
InvenTree/part/test_bom_export.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""
|
||||
Unit testing for BOM export functionality
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
|
||||
class BomExportTest(TestCase):
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create a user
|
||||
user = get_user_model()
|
||||
|
||||
self.user = user.objects.create_user(
|
||||
username='username',
|
||||
email='user@email.com',
|
||||
password='password'
|
||||
)
|
||||
|
||||
# Put the user into a group with the correct permissions
|
||||
group = Group.objects.create(name='mygroup')
|
||||
self.user.groups.add(group)
|
||||
|
||||
# Give the group *all* the permissions!
|
||||
for rule in group.rule_sets.all():
|
||||
rule.can_view = True
|
||||
rule.can_change = True
|
||||
rule.can_add = True
|
||||
rule.can_delete = True
|
||||
|
||||
rule.save()
|
||||
|
||||
self.client.login(username='username', password='password')
|
||||
|
||||
self.url = reverse('bom-download', kwargs={'pk': 100})
|
||||
|
||||
def test_export_csv(self):
|
||||
"""
|
||||
Test BOM download in CSV format
|
||||
"""
|
||||
|
||||
print("URL", self.url)
|
||||
|
||||
params = {
|
||||
'file_format': 'csv',
|
||||
'cascade': True,
|
||||
'parameter_data': True,
|
||||
'stock_data': True,
|
||||
'supplier_data': True,
|
||||
'manufacturer_data': True,
|
||||
}
|
||||
|
||||
response = self.client.get(self.url, data=params)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
content = response.headers['Content-Disposition']
|
||||
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.csv"')
|
||||
|
||||
def test_export_xls(self):
|
||||
"""
|
||||
Test BOM download in XLS format
|
||||
"""
|
||||
|
||||
params = {
|
||||
'file_format': 'xls',
|
||||
'cascade': True,
|
||||
'parameter_data': True,
|
||||
'stock_data': True,
|
||||
'supplier_data': True,
|
||||
'manufacturer_data': True,
|
||||
}
|
||||
|
||||
response = self.client.get(self.url, data=params)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
content = response.headers['Content-Disposition']
|
||||
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.xls"')
|
||||
|
||||
def test_export_xlsx(self):
|
||||
"""
|
||||
Test BOM download in XLSX format
|
||||
"""
|
||||
|
||||
params = {
|
||||
'file_format': 'xlsx',
|
||||
'cascade': True,
|
||||
'parameter_data': True,
|
||||
'stock_data': True,
|
||||
'supplier_data': True,
|
||||
'manufacturer_data': True,
|
||||
}
|
||||
|
||||
response = self.client.get(self.url, data=params)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_export_json(self):
|
||||
"""
|
||||
Test BOM download in JSON format
|
||||
"""
|
||||
|
||||
params = {
|
||||
'file_format': 'json',
|
||||
'cascade': True,
|
||||
'parameter_data': True,
|
||||
'stock_data': True,
|
||||
'supplier_data': True,
|
||||
'manufacturer_data': True,
|
||||
}
|
||||
|
||||
response = self.client.get(self.url, data=params)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
content = response.headers['Content-Disposition']
|
||||
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.json"')
|
@ -48,7 +48,6 @@ part_detail_urls = [
|
||||
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
|
||||
url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'),
|
||||
|
||||
url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'),
|
||||
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'),
|
||||
@ -56,15 +55,12 @@ part_detail_urls = [
|
||||
url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'),
|
||||
url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'),
|
||||
url(r'^prices/', views.PartPricingView.as_view(template_name='part/prices.html'), name='part-prices'),
|
||||
url(r'^manufacturers/?', views.PartDetail.as_view(template_name='part/manufacturer.html'), name='part-manufacturers'),
|
||||
url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'),
|
||||
url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
|
||||
url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'),
|
||||
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
|
||||
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
|
||||
url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
|
||||
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
|
||||
url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'),
|
||||
|
||||
url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),
|
||||
|
||||
|
@ -13,7 +13,7 @@ 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, reverse_lazy
|
||||
from django.views.generic import DetailView, ListView, FormView, UpdateView
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.forms.models import model_to_dict
|
||||
from django.forms import HiddenInput, CheckboxInput
|
||||
from django.conf import settings
|
||||
@ -42,13 +42,14 @@ 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 StockLocation
|
||||
|
||||
import common.settings as inventree_settings
|
||||
|
||||
from . import forms as part_forms
|
||||
from .bom import MakeBomTemplate, BomUploadManager, ExportBom, IsValidBOMFormat
|
||||
from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat
|
||||
from order.models import PurchaseOrderLineItem
|
||||
|
||||
from .admin import PartResource
|
||||
@ -746,40 +747,6 @@ class PartImportAjax(FileManagementAjaxView, PartImport):
|
||||
return PartImport.validate(self, self.steps.current, form, **kwargs)
|
||||
|
||||
|
||||
class PartNotes(UpdateView):
|
||||
""" View for editing the 'notes' field of a Part object.
|
||||
Presents a live markdown editor.
|
||||
"""
|
||||
|
||||
context_object_name = 'part'
|
||||
# form_class = part_forms.EditNotesForm
|
||||
template_name = 'part/notes.html'
|
||||
model = Part
|
||||
|
||||
role_required = 'part.change'
|
||||
|
||||
fields = ['notes']
|
||||
|
||||
def get_success_url(self):
|
||||
""" Return the success URL for this form """
|
||||
|
||||
return reverse('part-notes', kwargs={'pk': self.get_object().id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
part = self.get_object()
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context['editing'] = str2bool(self.request.GET.get('edit', ''))
|
||||
|
||||
ctx = part.get_context_data(self.request)
|
||||
|
||||
context.update(ctx)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class PartDetail(InvenTreeRoleMixin, DetailView):
|
||||
""" Detail view for Part object
|
||||
"""
|
||||
@ -1245,7 +1212,7 @@ class BomValidate(AjaxUpdateView):
|
||||
}
|
||||
|
||||
|
||||
class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
|
||||
""" View for uploading a BOM file, and handling BOM data importing.
|
||||
|
||||
The BOM upload process is as follows:
|
||||
@ -1272,184 +1239,116 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
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 = []
|
||||
|
||||
role_required = ('part.change', 'part.add')
|
||||
|
||||
def get_success_url(self):
|
||||
part = self.get_object()
|
||||
return reverse('upload-bom', kwargs={'pk': part.id})
|
||||
class BomFileManager(FileManager):
|
||||
# Fields which are absolutely necessary for valid upload
|
||||
REQUIRED_HEADERS = [
|
||||
'Quantity'
|
||||
]
|
||||
|
||||
def get_form_class(self):
|
||||
# Fields which are used for part matching (only one of them is needed)
|
||||
ITEM_MATCH_HEADERS = [
|
||||
'Part_Name',
|
||||
'Part_IPN',
|
||||
'Part_ID',
|
||||
]
|
||||
|
||||
# Default form is the starting point
|
||||
return part_forms.BomUploadSelectFile
|
||||
# Fields which would be helpful but are not required
|
||||
OPTIONAL_HEADERS = [
|
||||
'Reference',
|
||||
'Note',
|
||||
'Overage',
|
||||
]
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
EDITABLE_HEADERS = [
|
||||
'Reference',
|
||||
'Note',
|
||||
'Overage'
|
||||
]
|
||||
|
||||
ctx = super().get_context_data(*args, **kwargs)
|
||||
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
|
||||
|
||||
# Give each row item access to the column it is in
|
||||
# This provides for much simpler template rendering
|
||||
def get_part(self):
|
||||
""" Get part or return 404 """
|
||||
|
||||
rows = []
|
||||
for row in self.bom_rows:
|
||||
row_data = row['data']
|
||||
return get_object_or_404(Part, pk=self.kwargs['pk'])
|
||||
|
||||
data = []
|
||||
def get_context_data(self, form, **kwargs):
|
||||
""" Handle context data for order """
|
||||
|
||||
for idx, item in enumerate(row_data):
|
||||
context = super().get_context_data(form=form, **kwargs)
|
||||
|
||||
data.append({
|
||||
'cell': item,
|
||||
'idx': idx,
|
||||
'column': self.bom_columns[idx]
|
||||
})
|
||||
part = self.get_part()
|
||||
|
||||
rows.append({
|
||||
'index': row.get('index', -1),
|
||||
'data': data,
|
||||
'part_match': row.get('part_match', None),
|
||||
'part_options': row.get('part_options', self.allowed_parts),
|
||||
context.update({'part': part})
|
||||
|
||||
# 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', ''),
|
||||
})
|
||||
return context
|
||||
|
||||
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):
|
||||
def get_allowed_parts(self):
|
||||
""" Return a queryset of parts which are allowed to be added to this BOM.
|
||||
"""
|
||||
|
||||
return self.part.get_allowed_bom_items()
|
||||
return self.get_part().get_allowed_bom_items()
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
""" Perform the initial 'GET' request.
|
||||
|
||||
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.add_error('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.add_error(k, v)
|
||||
|
||||
if bom_file_valid:
|
||||
# BOM file is valid? Proceed to the next step!
|
||||
form = None
|
||||
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):
|
||||
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.getColumnIndex('Part_ID')
|
||||
p_idx = self.getColumnIndex('Part_Name')
|
||||
i_idx = self.getColumnIndex('Part_IPN')
|
||||
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.getColumnIndex('Quantity')
|
||||
r_idx = self.getColumnIndex('Reference')
|
||||
o_idx = self.getColumnIndex('Overage')
|
||||
n_idx = self.getColumnIndex('Note')
|
||||
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.bom_rows:
|
||||
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
|
||||
@ -1459,42 +1358,55 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
exact_match_part = None
|
||||
|
||||
# A list of potential Part matches
|
||||
part_options = self.allowed_parts
|
||||
part_options = self.allowed_items
|
||||
|
||||
# Check if there is a column corresponding to "quantity"
|
||||
if q_idx >= 0:
|
||||
q_val = row['data'][q_idx]
|
||||
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
|
||||
|
||||
# Store the 'quantity' value
|
||||
row['quantity'] = quantity
|
||||
|
||||
# Check if there is a column corresponding to "PK"
|
||||
if k_idx >= 0:
|
||||
pk = row['data'][k_idx]
|
||||
pk = row['data'][k_idx]['cell']
|
||||
|
||||
if pk:
|
||||
try:
|
||||
# Attempt Part lookup based on PK value
|
||||
exact_match_part = Part.objects.get(pk=pk)
|
||||
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 Name"
|
||||
if p_idx >= 0:
|
||||
part_name = row['data'][p_idx]
|
||||
# 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_parts:
|
||||
for part in self.allowed_items:
|
||||
ratio = fuzz.partial_ratio(part.name + part.description, part_name)
|
||||
matches.append({'part': part, 'match': ratio})
|
||||
|
||||
@ -1503,390 +1415,67 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
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
|
||||
|
||||
# Check if there is a column corresponding to "Part IPN"
|
||||
if i_idx >= 0:
|
||||
row['part_ipn'] = row['data'][i_idx]
|
||||
# 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]
|
||||
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]
|
||||
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]
|
||||
|
||||
# Supply list of part options for each row, sorted by how closely they match the part name
|
||||
row['part_options'] = part_options
|
||||
|
||||
# Unless found, the 'part_match' is blank
|
||||
row['part_match'] = None
|
||||
|
||||
if exact_match_part:
|
||||
# If there is an exact match based on PK, use that
|
||||
row['part_match'] = exact_match_part
|
||||
else:
|
||||
# Otherwise, check to see if there is a matching IPN
|
||||
try:
|
||||
if row['part_ipn']:
|
||||
part_matches = [part for part in self.allowed_parts if part.IPN and row['part_ipn'].lower() == str(part.IPN.lower())]
|
||||
|
||||
# Check for single match
|
||||
if len(part_matches) == 1:
|
||||
row['part_match'] = part_matches[0]
|
||||
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
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 = []
|
||||
|
||||
# Check that all required fields are present
|
||||
for col in BomUploadManager.REQUIRED_HEADERS:
|
||||
if col not in self.column_selections.values():
|
||||
self.missing_columns.append(col)
|
||||
|
||||
# Check that at least one of the part match field is present
|
||||
part_match_found = False
|
||||
for col in BomUploadManager.PART_MATCH_HEADERS:
|
||||
if col in self.column_selections.values():
|
||||
part_match_found = True
|
||||
break
|
||||
|
||||
# If not, notify user
|
||||
if not part_match_found:
|
||||
for col in BomUploadManager.PART_MATCH_HEADERS:
|
||||
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
|
||||
|
||||
if valid:
|
||||
# Try to extract meaningful data
|
||||
self.preFillSelections()
|
||||
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=None))
|
||||
|
||||
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 = Decimal(1)
|
||||
|
||||
try:
|
||||
q = Decimal(value)
|
||||
if q < 0:
|
||||
row['errors']['quantity'] = _('Quantity must be greater than zero')
|
||||
|
||||
if 'part' in row.keys():
|
||||
if row['part'].trackable:
|
||||
# Trackable parts must use integer quantities
|
||||
if not q == int(q):
|
||||
row['errors']['quantity'] = _('Quantity must be integer value for trackable parts')
|
||||
|
||||
except (ValueError, InvalidOperation):
|
||||
row['errors']['quantity'] = _('Enter a valid quantity')
|
||||
|
||||
row['quantity'] = q
|
||||
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Extract part from each row
|
||||
if key.startswith('part_'):
|
||||
|
||||
try:
|
||||
row_id = int(key.replace('part_', ''))
|
||||
|
||||
row = self.getRowByIndex(row_id)
|
||||
|
||||
if row is None:
|
||||
continue
|
||||
except ValueError:
|
||||
# Row ID non integer value
|
||||
continue
|
||||
|
||||
try:
|
||||
part_id = int(value)
|
||||
part = Part.objects.get(id=part_id)
|
||||
except ValueError:
|
||||
row['errors']['part'] = _('Select valid part')
|
||||
continue
|
||||
except Part.DoesNotExist:
|
||||
row['errors']['part'] = _('Select valid part')
|
||||
continue
|
||||
|
||||
# Keep track of how many of each part we have seen
|
||||
if part_id in parts:
|
||||
parts[part_id]['quantity'] += 1
|
||||
row['errors']['part'] = _('Duplicate part selected')
|
||||
else:
|
||||
parts[part_id] = {
|
||||
'part': part,
|
||||
'quantity': 1,
|
||||
}
|
||||
|
||||
row['part'] = part
|
||||
|
||||
if part.trackable:
|
||||
# For trackable parts, ensure the quantity is an integer value!
|
||||
if 'quantity' in row.keys():
|
||||
q = row['quantity']
|
||||
|
||||
if not q == int(q):
|
||||
row['errors']['quantity'] = _('Quantity must be integer value for trackable parts')
|
||||
|
||||
# Extract other fields which do not require further validation
|
||||
for field in ['reference', 'notes']:
|
||||
if key.startswith(field + '_'):
|
||||
try:
|
||||
row_id = int(key.replace(field + '_', ''))
|
||||
|
||||
row = self.getRowByIndex(row_id)
|
||||
|
||||
if row:
|
||||
row[field] = value
|
||||
except:
|
||||
continue
|
||||
|
||||
# Are there any errors after form handling?
|
||||
valid = True
|
||||
|
||||
for row in self.bom_rows:
|
||||
# Has a part been selected for the given row?
|
||||
part = row.get('part', None)
|
||||
|
||||
if part is None:
|
||||
row['errors']['part'] = _('Select a part')
|
||||
else:
|
||||
# Will the selected part result in a recursive BOM?
|
||||
try:
|
||||
part.checkAddToBOM(self.part)
|
||||
except ValidationError:
|
||||
row['errors']['part'] = _('Selected part creates a circular BOM')
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# 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):
|
||||
""" Perform the various 'POST' requests required.
|
||||
"""
|
||||
|
||||
self.request = request
|
||||
|
||||
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())
|
||||
|
||||
# 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))
|
||||
return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.kwargs['pk']}))
|
||||
|
||||
|
||||
class PartExport(AjaxView):
|
||||
|
@ -120,9 +120,6 @@ class StockAdjust(APIView):
|
||||
- StockAdd: add stock items
|
||||
- StockRemove: remove stock items
|
||||
- StockTransfer: transfer stock items
|
||||
|
||||
# TODO - This needs serious refactoring!!!
|
||||
|
||||
"""
|
||||
|
||||
queryset = StockItem.objects.none()
|
||||
@ -143,7 +140,10 @@ class StockAdjust(APIView):
|
||||
elif 'items' in request.data:
|
||||
_items = request.data['items']
|
||||
else:
|
||||
raise ValidationError({'items': 'Request must contain list of stock items'})
|
||||
_items = []
|
||||
|
||||
if len(_items) == 0:
|
||||
raise ValidationError(_('Request must contain list of stock items'))
|
||||
|
||||
# List of validated items
|
||||
self.items = []
|
||||
@ -151,13 +151,22 @@ class StockAdjust(APIView):
|
||||
for entry in _items:
|
||||
|
||||
if not type(entry) == dict:
|
||||
raise ValidationError({'error': 'Improperly formatted data'})
|
||||
raise ValidationError(_('Improperly formatted data'))
|
||||
|
||||
# Look for a 'pk' value (use 'id' as a backup)
|
||||
pk = entry.get('pk', entry.get('id', None))
|
||||
|
||||
try:
|
||||
pk = int(pk)
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError(_('Each entry must contain a valid integer primary-key'))
|
||||
|
||||
try:
|
||||
pk = entry.get('pk', None)
|
||||
item = StockItem.objects.get(pk=pk)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
raise ValidationError({'pk': 'Each entry must contain a valid pk field'})
|
||||
except (StockItem.DoesNotExist):
|
||||
raise ValidationError({
|
||||
pk: [_('Primary key does not match valid stock item')]
|
||||
})
|
||||
|
||||
if self.allow_missing_quantity and 'quantity' not in entry:
|
||||
entry['quantity'] = item.quantity
|
||||
@ -165,16 +174,21 @@ class StockAdjust(APIView):
|
||||
try:
|
||||
quantity = Decimal(str(entry.get('quantity', None)))
|
||||
except (ValueError, TypeError, InvalidOperation):
|
||||
raise ValidationError({'quantity': "Each entry must contain a valid quantity value"})
|
||||
raise ValidationError({
|
||||
pk: [_('Invalid quantity value')]
|
||||
})
|
||||
|
||||
if quantity < 0:
|
||||
raise ValidationError({'quantity': 'Quantity field must not be less than zero'})
|
||||
raise ValidationError({
|
||||
pk: [_('Quantity must not be less than zero')]
|
||||
})
|
||||
|
||||
self.items.append({
|
||||
'item': item,
|
||||
'quantity': quantity
|
||||
})
|
||||
|
||||
# Extract 'notes' field
|
||||
self.notes = str(request.data.get('notes', ''))
|
||||
|
||||
|
||||
@ -228,6 +242,11 @@ class StockRemove(StockAdjust):
|
||||
|
||||
for item in self.items:
|
||||
|
||||
if item['quantity'] > item['item'].quantity:
|
||||
raise ValidationError({
|
||||
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
|
||||
})
|
||||
|
||||
if item['item'].take_stock(item['quantity'], request.user, notes=self.notes):
|
||||
n += 1
|
||||
|
||||
@ -243,19 +262,24 @@ class StockTransfer(StockAdjust):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
data = request.data
|
||||
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=data.get('location', None))
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
raise ValidationError({'location': 'Valid location must be specified'})
|
||||
raise ValidationError({'location': [_('Valid location must be specified')]})
|
||||
|
||||
n = 0
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
for item in self.items:
|
||||
|
||||
if item['quantity'] > item['item'].quantity:
|
||||
raise ValidationError({
|
||||
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
|
||||
})
|
||||
|
||||
# If quantity is not specified, move the entire stock
|
||||
if item['quantity'] in [0, None]:
|
||||
item['quantity'] = item['item'].quantity
|
||||
@ -454,13 +478,6 @@ class StockList(generics.ListCreateAPIView):
|
||||
|
||||
- GET: Return a list of all StockItem objects (with optional query filters)
|
||||
- POST: Create a new StockItem
|
||||
|
||||
Additional query parameters are available:
|
||||
- location: Filter stock by location
|
||||
- category: Filter by parts belonging to a certain category
|
||||
- supplier: Filter by supplier
|
||||
- ancestor: Filter by an 'ancestor' StockItem
|
||||
- status: Filter by the StockItem status
|
||||
"""
|
||||
|
||||
serializer_class = StockItemSerializer
|
||||
@ -482,7 +499,6 @@ class StockList(generics.ListCreateAPIView):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# TODO - Save the user who created this item
|
||||
item = serializer.save()
|
||||
|
||||
# A location was *not* specified - try to infer it
|
||||
@ -1092,47 +1108,41 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = LocationSerializer
|
||||
|
||||
|
||||
stock_endpoints = [
|
||||
url(r'^$', StockDetail.as_view(), name='api-stock-detail'),
|
||||
]
|
||||
|
||||
location_endpoints = [
|
||||
url(r'^(?P<pk>\d+)/', LocationDetail.as_view(), name='api-location-detail'),
|
||||
|
||||
url(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
|
||||
]
|
||||
|
||||
stock_api_urls = [
|
||||
url(r'location/', include(location_endpoints)),
|
||||
url(r'^location/', include([
|
||||
url(r'^(?P<pk>\d+)/', LocationDetail.as_view(), name='api-location-detail'),
|
||||
url(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
|
||||
])),
|
||||
|
||||
# These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019
|
||||
# TODO: Remove server-side forms for stock adjustment!!!
|
||||
url(r'count/?', StockCount.as_view(), name='api-stock-count'),
|
||||
url(r'add/?', StockAdd.as_view(), name='api-stock-add'),
|
||||
url(r'remove/?', StockRemove.as_view(), name='api-stock-remove'),
|
||||
url(r'transfer/?', StockTransfer.as_view(), name='api-stock-transfer'),
|
||||
# Endpoints for bulk stock adjustment actions
|
||||
url(r'^count/', StockCount.as_view(), name='api-stock-count'),
|
||||
url(r'^add/', StockAdd.as_view(), name='api-stock-add'),
|
||||
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
|
||||
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
|
||||
|
||||
# Base URL for StockItemAttachment API endpoints
|
||||
# StockItemAttachment API endpoints
|
||||
url(r'^attachment/', include([
|
||||
url(r'^(?P<pk>\d+)/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'),
|
||||
url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'),
|
||||
])),
|
||||
|
||||
# Base URL for StockItemTestResult API endpoints
|
||||
# StockItemTestResult API endpoints
|
||||
url(r'^test/', include([
|
||||
url(r'^(?P<pk>\d+)/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'),
|
||||
url(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'),
|
||||
])),
|
||||
|
||||
# StockItemTracking API endpoints
|
||||
url(r'^track/', include([
|
||||
url(r'^(?P<pk>\d+)/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'),
|
||||
url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
|
||||
])),
|
||||
|
||||
url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'),
|
||||
url(r'^tree/', StockCategoryTree.as_view(), name='api-stock-tree'),
|
||||
|
||||
# Detail for a single stock item
|
||||
url(r'^(?P<pk>\d+)/', include(stock_endpoints)),
|
||||
url(r'^(?P<pk>\d+)/', StockDetail.as_view(), name='api-stock-detail'),
|
||||
|
||||
# Anything else
|
||||
url(r'^.*$', StockList.as_view(), name='api-stock-list'),
|
||||
]
|
||||
|
@ -328,50 +328,6 @@ class UninstallStockForm(forms.ModelForm):
|
||||
]
|
||||
|
||||
|
||||
class AdjustStockForm(forms.ModelForm):
|
||||
""" Form for performing simple stock adjustments.
|
||||
|
||||
- Add stock
|
||||
- Remove stock
|
||||
- Count stock
|
||||
- Move stock
|
||||
|
||||
This form is used for managing stock adjuments for single or multiple stock items.
|
||||
"""
|
||||
|
||||
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination stock location'))
|
||||
|
||||
note = forms.CharField(label=_('Notes'), required=True, help_text=_('Add note (required)'))
|
||||
|
||||
# transaction = forms.BooleanField(required=False, initial=False, label='Create Transaction', help_text='Create a stock transaction for these parts')
|
||||
|
||||
confirm = forms.BooleanField(required=False, initial=False, label=_('Confirm stock adjustment'), help_text=_('Confirm movement of stock items'))
|
||||
|
||||
set_loc = forms.BooleanField(required=False, initial=False, label=_('Set Default Location'), help_text=_('Set the destination as the default location for selected parts'))
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
|
||||
fields = [
|
||||
'destination',
|
||||
'note',
|
||||
# 'transaction',
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class EditStockItemStatusForm(HelperForm):
|
||||
"""
|
||||
Simple form for editing StockItem status field
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
fields = [
|
||||
'status',
|
||||
]
|
||||
|
||||
|
||||
class EditStockItemForm(HelperForm):
|
||||
""" Form for editing a StockItem object.
|
||||
Note that not all fields can be edited here (even if they can be specified during creation.
|
||||
|
@ -534,14 +534,21 @@ $("#barcode-scan-into-location").click(function() {
|
||||
});
|
||||
|
||||
function itemAdjust(action) {
|
||||
launchModalForm("/stock/adjust/",
|
||||
|
||||
inventreeGet(
|
||||
'{% url "api-stock-detail" item.pk %}',
|
||||
{
|
||||
data: {
|
||||
action: action,
|
||||
item: {{ item.id }},
|
||||
},
|
||||
reload: true,
|
||||
follow: true,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
{
|
||||
success: function(item) {
|
||||
adjustStock(action, [item], {
|
||||
onSuccess: function() {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -59,11 +59,23 @@
|
||||
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
|
||||
{% if roles.stock.change %}
|
||||
<div class='btn-group'>
|
||||
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
|
||||
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-boxes'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='location-count'><span class='fas fa-clipboard-list'></span>
|
||||
{% trans "Count stock" %}</a></li>
|
||||
</ul>
|
||||
<li>
|
||||
<a href='#' id='location-count'>
|
||||
<span class='fas fa-clipboard-list'></span>
|
||||
{% trans "Count stock" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='#' id='location-move'>
|
||||
<span class='fas fa-exchange-alt'></span>
|
||||
{% trans "Transfer stock" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if roles.stock_location.change %}
|
||||
@ -215,14 +227,34 @@
|
||||
});
|
||||
|
||||
{% if location %}
|
||||
$("#location-count").click(function() {
|
||||
launchModalForm("/stock/adjust/", {
|
||||
data: {
|
||||
action: "count",
|
||||
|
||||
function adjustLocationStock(action) {
|
||||
inventreeGet(
|
||||
'{% url "api-stock-list" %}',
|
||||
{
|
||||
location: {{ location.id }},
|
||||
reload: true,
|
||||
in_stock: true,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
{
|
||||
success: function(items) {
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
$("#location-count").click(function() {
|
||||
adjustLocationStock('count');
|
||||
});
|
||||
|
||||
$("#location-move").click(function() {
|
||||
adjustLocationStock('move');
|
||||
});
|
||||
|
||||
$('#print-label').click(function() {
|
||||
|
@ -7,8 +7,8 @@ from __future__ import unicode_literals
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from rest_framework import status
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from InvenTree.status_codes import StockStatus
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
@ -456,30 +456,32 @@ class StocktakeTest(StockAPITestCase):
|
||||
|
||||
# POST without a PK
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# POST with a PK but no quantity
|
||||
# POST with an invalid PK
|
||||
data['items'] = [{
|
||||
'pk': 10
|
||||
}]
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# POST with missing quantity value
|
||||
data['items'] = [{
|
||||
'pk': 1234
|
||||
}]
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# POST with an invalid quantity value
|
||||
data['items'] = [{
|
||||
'pk': 1234,
|
||||
'quantity': '10x0d'
|
||||
}]
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
data['items'] = [{
|
||||
'pk': 1234,
|
||||
|
@ -105,31 +105,6 @@ class StockItemTest(StockViewTestCase):
|
||||
response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_adjust_items(self):
|
||||
url = reverse('stock-adjust')
|
||||
|
||||
# Move items
|
||||
response = self.client.get(url, {'stock[]': [1, 2, 3, 4, 5], 'action': 'move'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Count part
|
||||
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Remove items
|
||||
response = self.client.get(url, {'location': 1, 'action': 'take'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Add items
|
||||
response = self.client.get(url, {'item': 1, 'action': 'add'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Blank response
|
||||
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# TODO - Tests for POST data
|
||||
|
||||
def test_edit_item(self):
|
||||
# Test edit view for StockItem
|
||||
response = self.client.get(reverse('stock-item-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
@ -64,8 +64,6 @@ stock_urls = [
|
||||
|
||||
url(r'^track/', include(stock_tracking_urls)),
|
||||
|
||||
url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),
|
||||
|
||||
url(r'^export-options/?', views.StockExportOptions.as_view(), name='stock-export-options'),
|
||||
url(r'^export/?', views.StockExport.as_view(), name='stock-export'),
|
||||
|
||||
|
@ -91,20 +91,20 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
|
||||
data = super().get_context_data(**kwargs)
|
||||
|
||||
if self.object.serialized:
|
||||
serial_elem = {a.serial: a for a in self.object.part.stock_items.all() if a.serialized}
|
||||
serials = [int(a) for a in serial_elem.keys()]
|
||||
serial_elem = {int(a.serial): a for a in self.object.part.stock_items.all() if a.serialized}
|
||||
serials = serial_elem.keys()
|
||||
current = int(self.object.serial)
|
||||
|
||||
# previous
|
||||
for nbr in range(current - 1, -1, -1):
|
||||
if nbr in serials:
|
||||
data['previous'] = serial_elem.get(str(nbr), None)
|
||||
data['previous'] = serial_elem.get(nbr, None)
|
||||
break
|
||||
|
||||
# next
|
||||
for nbr in range(current + 1, max(serials) + 1):
|
||||
if nbr in serials:
|
||||
data['next'] = serial_elem.get(str(nbr), None)
|
||||
data['next'] = serial_elem.get(nbr, None)
|
||||
break
|
||||
|
||||
return data
|
||||
@ -749,343 +749,6 @@ class StockItemUninstall(AjaxView, FormMixin):
|
||||
return context
|
||||
|
||||
|
||||
class StockAdjust(AjaxView, FormMixin):
|
||||
""" View for enacting simple stock adjustments:
|
||||
|
||||
- Take items from stock
|
||||
- Add items to stock
|
||||
- Count items
|
||||
- Move stock
|
||||
- Delete stock items
|
||||
|
||||
"""
|
||||
|
||||
ajax_template_name = 'stock/stock_adjust.html'
|
||||
ajax_form_title = _('Adjust Stock')
|
||||
form_class = StockForms.AdjustStockForm
|
||||
stock_items = []
|
||||
role_required = 'stock.change'
|
||||
|
||||
def get_GET_items(self):
|
||||
""" Return list of stock items initally requested using GET.
|
||||
|
||||
Items can be retrieved by:
|
||||
|
||||
a) List of stock ID - stock[]=1,2,3,4,5
|
||||
b) Parent part - part=3
|
||||
c) Parent location - location=78
|
||||
d) Single item - item=2
|
||||
"""
|
||||
|
||||
# Start with all 'in stock' items
|
||||
items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
|
||||
|
||||
# Client provides a list of individual stock items
|
||||
if 'stock[]' in self.request.GET:
|
||||
items = items.filter(id__in=self.request.GET.getlist('stock[]'))
|
||||
|
||||
# Client provides a PART reference
|
||||
elif 'part' in self.request.GET:
|
||||
items = items.filter(part=self.request.GET.get('part'))
|
||||
|
||||
# Client provides a LOCATION reference
|
||||
elif 'location' in self.request.GET:
|
||||
items = items.filter(location=self.request.GET.get('location'))
|
||||
|
||||
# Client provides a single StockItem lookup
|
||||
elif 'item' in self.request.GET:
|
||||
items = [StockItem.objects.get(id=self.request.GET.get('item'))]
|
||||
|
||||
# Unsupported query (no items)
|
||||
else:
|
||||
items = []
|
||||
|
||||
for item in items:
|
||||
|
||||
# Initialize quantity to zero for addition/removal
|
||||
if self.stock_action in ['take', 'add']:
|
||||
item.new_quantity = 0
|
||||
# Initialize quantity at full amount for counting or moving
|
||||
else:
|
||||
item.new_quantity = item.quantity
|
||||
|
||||
return items
|
||||
|
||||
def get_POST_items(self):
|
||||
""" Return list of stock items sent back by client on a POST request """
|
||||
|
||||
items = []
|
||||
|
||||
for item in self.request.POST:
|
||||
if item.startswith('stock-id-'):
|
||||
|
||||
pk = item.replace('stock-id-', '')
|
||||
q = self.request.POST[item]
|
||||
|
||||
try:
|
||||
stock_item = StockItem.objects.get(pk=pk)
|
||||
except StockItem.DoesNotExist:
|
||||
continue
|
||||
|
||||
stock_item.new_quantity = q
|
||||
|
||||
items.append(stock_item)
|
||||
|
||||
return items
|
||||
|
||||
def get_stock_action_titles(self):
|
||||
|
||||
# Choose form title and action column based on the action
|
||||
titles = {
|
||||
'move': [_('Move Stock Items'), _('Move')],
|
||||
'count': [_('Count Stock Items'), _('Count')],
|
||||
'take': [_('Remove From Stock'), _('Take')],
|
||||
'add': [_('Add Stock Items'), _('Add')],
|
||||
'delete': [_('Delete Stock Items'), _('Delete')],
|
||||
}
|
||||
|
||||
self.ajax_form_title = titles[self.stock_action][0]
|
||||
self.stock_action_title = titles[self.stock_action][1]
|
||||
|
||||
def get_context_data(self):
|
||||
|
||||
context = super().get_context_data()
|
||||
|
||||
context['stock_items'] = self.stock_items
|
||||
|
||||
context['stock_action'] = self.stock_action.strip().lower()
|
||||
|
||||
self.get_stock_action_titles()
|
||||
context['stock_action_title'] = self.stock_action_title
|
||||
|
||||
# Quantity column will be read-only in some circumstances
|
||||
context['edit_quantity'] = not self.stock_action == 'delete'
|
||||
|
||||
return context
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
if not self.stock_action == 'move':
|
||||
form.fields.pop('destination')
|
||||
form.fields.pop('set_loc')
|
||||
|
||||
return form
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
self.request = request
|
||||
|
||||
# Action
|
||||
self.stock_action = request.GET.get('action', '').lower()
|
||||
|
||||
# Pick a default action...
|
||||
if self.stock_action not in ['move', 'count', 'take', 'add', 'delete']:
|
||||
self.stock_action = 'count'
|
||||
|
||||
# Save list of items!
|
||||
self.stock_items = self.get_GET_items()
|
||||
|
||||
return self.renderJsonResponse(request, self.get_form())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.request = request
|
||||
|
||||
self.stock_action = request.POST.get('stock_action', 'invalid').strip().lower()
|
||||
|
||||
# Update list of stock items
|
||||
self.stock_items = self.get_POST_items()
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
valid = form.is_valid()
|
||||
|
||||
for item in self.stock_items:
|
||||
|
||||
try:
|
||||
item.new_quantity = Decimal(item.new_quantity)
|
||||
except ValueError:
|
||||
item.error = _('Must enter integer value')
|
||||
valid = False
|
||||
continue
|
||||
|
||||
if item.new_quantity < 0:
|
||||
item.error = _('Quantity must be positive')
|
||||
valid = False
|
||||
continue
|
||||
|
||||
if self.stock_action in ['move', 'take']:
|
||||
|
||||
if item.new_quantity > item.quantity:
|
||||
item.error = _('Quantity must not exceed {x}').format(x=item.quantity)
|
||||
valid = False
|
||||
continue
|
||||
|
||||
confirmed = str2bool(request.POST.get('confirm'))
|
||||
|
||||
if not confirmed:
|
||||
valid = False
|
||||
form.add_error('confirm', _('Confirm stock adjustment'))
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
}
|
||||
|
||||
if valid:
|
||||
result = self.do_action(note=form.cleaned_data['note'])
|
||||
|
||||
data['success'] = result
|
||||
|
||||
# Special case - Single Stock Item
|
||||
# If we deplete the stock item, we MUST redirect to a new view
|
||||
single_item = len(self.stock_items) == 1
|
||||
|
||||
if result and single_item:
|
||||
|
||||
# Was the entire stock taken?
|
||||
item = self.stock_items[0]
|
||||
|
||||
if item.quantity == 0:
|
||||
# Instruct the form to redirect
|
||||
data['url'] = reverse('stock-index')
|
||||
|
||||
return self.renderJsonResponse(request, form, data=data, context=self.get_context_data())
|
||||
|
||||
def do_action(self, note=None):
|
||||
""" Perform stock adjustment action """
|
||||
|
||||
if self.stock_action == 'move':
|
||||
destination = None
|
||||
|
||||
set_default_loc = str2bool(self.request.POST.get('set_loc', False))
|
||||
|
||||
try:
|
||||
destination = StockLocation.objects.get(id=self.request.POST.get('destination'))
|
||||
except StockLocation.DoesNotExist:
|
||||
pass
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return self.do_move(destination, set_default_loc, note=note)
|
||||
|
||||
elif self.stock_action == 'add':
|
||||
return self.do_add(note=note)
|
||||
|
||||
elif self.stock_action == 'take':
|
||||
return self.do_take(note=note)
|
||||
|
||||
elif self.stock_action == 'count':
|
||||
return self.do_count(note=note)
|
||||
|
||||
elif self.stock_action == 'delete':
|
||||
return self.do_delete(note=note)
|
||||
|
||||
else:
|
||||
return _('No action performed')
|
||||
|
||||
def do_add(self, note=None):
|
||||
|
||||
count = 0
|
||||
|
||||
for item in self.stock_items:
|
||||
if item.new_quantity <= 0:
|
||||
continue
|
||||
|
||||
item.add_stock(item.new_quantity, self.request.user, notes=note)
|
||||
|
||||
count += 1
|
||||
|
||||
return _('Added stock to {n} items').format(n=count)
|
||||
|
||||
def do_take(self, note=None):
|
||||
|
||||
count = 0
|
||||
|
||||
for item in self.stock_items:
|
||||
if item.new_quantity <= 0:
|
||||
continue
|
||||
|
||||
item.take_stock(item.new_quantity, self.request.user, notes=note)
|
||||
|
||||
count += 1
|
||||
|
||||
return _('Removed stock from {n} items').format(n=count)
|
||||
|
||||
def do_count(self, note=None):
|
||||
|
||||
count = 0
|
||||
|
||||
for item in self.stock_items:
|
||||
|
||||
item.stocktake(item.new_quantity, self.request.user, notes=note)
|
||||
|
||||
count += 1
|
||||
|
||||
return _("Counted stock for {n} items".format(n=count))
|
||||
|
||||
def do_move(self, destination, set_loc=None, note=None):
|
||||
""" Perform actual stock movement """
|
||||
|
||||
count = 0
|
||||
|
||||
for item in self.stock_items:
|
||||
# Avoid moving zero quantity
|
||||
if item.new_quantity <= 0:
|
||||
continue
|
||||
|
||||
# If we wish to set the destination location to the default one
|
||||
if set_loc:
|
||||
item.part.default_location = destination
|
||||
item.part.save()
|
||||
|
||||
# Do not move to the same location (unless the quantity is different)
|
||||
if destination == item.location and item.new_quantity == item.quantity:
|
||||
continue
|
||||
|
||||
item.move(destination, note, self.request.user, quantity=item.new_quantity)
|
||||
|
||||
count += 1
|
||||
|
||||
# Is ownership control enabled?
|
||||
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||
|
||||
if stock_ownership_control:
|
||||
# Fetch destination owner
|
||||
destination_owner = destination.owner
|
||||
|
||||
if destination_owner:
|
||||
# Update owner
|
||||
item.owner = destination_owner
|
||||
item.save()
|
||||
|
||||
if count == 0:
|
||||
return _('No items were moved')
|
||||
|
||||
else:
|
||||
return _('Moved {n} items to {dest}').format(
|
||||
n=count,
|
||||
dest=destination.pathstring)
|
||||
|
||||
def do_delete(self):
|
||||
""" Delete multiple stock items """
|
||||
|
||||
count = 0
|
||||
# note = self.request.POST['note']
|
||||
|
||||
for item in self.stock_items:
|
||||
|
||||
# TODO - In the future, StockItems should not be 'deleted'
|
||||
# TODO - Instead, they should be marked as "inactive"
|
||||
|
||||
item.delete()
|
||||
|
||||
count += 1
|
||||
|
||||
return _("Deleted {n} stock items").format(n=count)
|
||||
|
||||
|
||||
class StockItemEdit(AjaxUpdateView):
|
||||
"""
|
||||
View for editing details of a single StockItem
|
||||
|
@ -41,7 +41,6 @@
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-table/extensions/group-by-v2/bootstrap-table-group-by.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-table/extensions/filter-control/bootstrap-table-filter-control.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'select2/css/select2.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'select2/css/select2-bootstrap.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/bootstrap-toggle.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'fullcalendar/main.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'script/jquery-ui/jquery-ui.min.css' %}">
|
||||
|
@ -1,3 +1,6 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
var jQuery = window.$;
|
||||
|
||||
// using jQuery
|
||||
@ -138,4 +141,49 @@ function inventreeDelete(url, options={}) {
|
||||
|
||||
inventreePut(url, {}, options);
|
||||
|
||||
}
|
||||
|
||||
|
||||
function showApiError(xhr) {
|
||||
|
||||
var title = null;
|
||||
var message = null;
|
||||
|
||||
switch (xhr.status) {
|
||||
case 0: // No response
|
||||
title = '{% trans "No Response" %}';
|
||||
message = '{% trans "No response from the InvenTree server" %}';
|
||||
break;
|
||||
case 400: // Bad request
|
||||
// Note: Normally error code 400 is handled separately,
|
||||
// and should now be shown here!
|
||||
title = '{% trans "Error 400: Bad request" %}';
|
||||
message = '{% trans "API request returned error code 400" %}';
|
||||
break;
|
||||
case 401: // Not authenticated
|
||||
title = '{% trans "Error 401: Not Authenticated" %}';
|
||||
message = '{% trans "Authentication credentials not supplied" %}';
|
||||
break;
|
||||
case 403: // Permission denied
|
||||
title = '{% trans "Error 403: Permission Denied" %}';
|
||||
message = '{% trans "You do not have the required permissions to access this function" %}';
|
||||
break;
|
||||
case 404: // Resource not found
|
||||
title = '{% trans "Error 404: Resource Not Found" %}';
|
||||
message = '{% trans "The requested resource could not be located on the server" %}';
|
||||
break;
|
||||
case 408: // Timeout
|
||||
title = '{% trans "Error 408: Timeout" %}';
|
||||
message = '{% trans "Connection timeout while requesting data from server" %}';
|
||||
break;
|
||||
default:
|
||||
title = '{% trans "Unhandled Error Code" %}';
|
||||
message = `{% trans "Error code" %}: ${xhr.status}`;
|
||||
break;
|
||||
}
|
||||
|
||||
message += "<hr>";
|
||||
message += renderErrorMessage(xhr);
|
||||
|
||||
showAlertDialog(title, message);
|
||||
}
|
@ -318,6 +318,12 @@ function loadManufacturerPartTable(table, url, options) {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '{% trans "Description" %}',
|
||||
sortable: false,
|
||||
switchable: true,
|
||||
}
|
||||
],
|
||||
});
|
||||
}
|
||||
@ -550,6 +556,21 @@ function loadSupplierPartTable(table, url, options) {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '{% trans "Description" %}',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'note',
|
||||
title: '{% trans "Notes" %}',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'packaging',
|
||||
title: '{% trans "Packaging" %}',
|
||||
sortable: false,
|
||||
}
|
||||
],
|
||||
});
|
||||
}
|
@ -353,12 +353,16 @@ function constructFormBody(fields, options) {
|
||||
// Override existing query filters (if provided!)
|
||||
fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters);
|
||||
|
||||
// TODO: Refactor the following code with Object.assign (see above)
|
||||
|
||||
// Secondary modal options
|
||||
fields[field].secondary = field_options.secondary;
|
||||
|
||||
// Edit callback
|
||||
fields[field].onEdit = field_options.onEdit;
|
||||
|
||||
fields[field].multiline = field_options.multiline;
|
||||
|
||||
// Custom help_text
|
||||
if (field_options.help_text) {
|
||||
fields[field].help_text = field_options.help_text;
|
||||
@ -395,11 +399,11 @@ function constructFormBody(fields, options) {
|
||||
|
||||
for (var name in displayed_fields) {
|
||||
|
||||
// Only push names which are actually in the set of fields
|
||||
if (name in fields) {
|
||||
field_names.push(name);
|
||||
} else {
|
||||
console.log(`WARNING: '${name}' does not match a valid field name.`);
|
||||
field_names.push(name);
|
||||
|
||||
// Field not specified in the API, but the client wishes to add it!
|
||||
if (!(name in fields)) {
|
||||
fields[name] = displayed_fields[name];
|
||||
}
|
||||
}
|
||||
|
||||
@ -422,10 +426,8 @@ function constructFormBody(fields, options) {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
var f = constructField(name, field, options);
|
||||
|
||||
html += f;
|
||||
html += constructField(name, field, options);
|
||||
}
|
||||
|
||||
// TODO: Dynamically create the modals,
|
||||
@ -441,7 +443,15 @@ function constructFormBody(fields, options) {
|
||||
modalEnable(modal, true);
|
||||
|
||||
// Insert generated form content
|
||||
$(modal).find('.modal-form-content').html(html);
|
||||
$(modal).find('#form-content').html(html);
|
||||
|
||||
if (options.preFormContent) {
|
||||
$(modal).find('#pre-form-content').html(options.preFormContent);
|
||||
}
|
||||
|
||||
if (options.postFormContent) {
|
||||
$(modal).find('#post-form-content').html(options.postFormContent);
|
||||
}
|
||||
|
||||
// Clear any existing buttons from the modal
|
||||
$(modal).find('#modal-footer-buttons').html('');
|
||||
@ -474,7 +484,21 @@ function constructFormBody(fields, options) {
|
||||
|
||||
$(modal).on('click', '#modal-form-submit', function() {
|
||||
|
||||
submitFormData(fields, options);
|
||||
// Immediately disable the "submit" button,
|
||||
// to prevent the form being submitted multiple times!
|
||||
$(options.modal).find('#modal-form-submit').prop('disabled', true);
|
||||
|
||||
// Run custom code before normal form submission
|
||||
if (options.beforeSubmit) {
|
||||
options.beforeSubmit(fields, options);
|
||||
}
|
||||
|
||||
// Run custom code instead of normal form submission
|
||||
if (options.onSubmit) {
|
||||
options.onSubmit(fields, options);
|
||||
} else {
|
||||
submitFormData(fields, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -511,10 +535,6 @@ function insertConfirmButton(options) {
|
||||
*/
|
||||
function submitFormData(fields, options) {
|
||||
|
||||
// Immediately disable the "submit" button,
|
||||
// to prevent the form being submitted multiple times!
|
||||
$(options.modal).find('#modal-form-submit').prop('disabled', true);
|
||||
|
||||
// Form data to be uploaded to the server
|
||||
// Only used if file / image upload is required
|
||||
var form_data = new FormData();
|
||||
@ -581,47 +601,9 @@ function submitFormData(fields, options) {
|
||||
case 400: // Bad request
|
||||
handleFormErrors(xhr.responseJSON, fields, options);
|
||||
break;
|
||||
case 0: // No response
|
||||
$(options.modal).modal('hide');
|
||||
showAlertDialog(
|
||||
'{% trans "No Response" %}',
|
||||
'{% trans "No response from the InvenTree server" %}',
|
||||
);
|
||||
break;
|
||||
case 401: // Not authenticated
|
||||
$(options.modal).modal('hide');
|
||||
showAlertDialog(
|
||||
'{% trans "Error 401: Not Authenticated" %}',
|
||||
'{% trans "Authentication credentials not supplied" %}',
|
||||
);
|
||||
break;
|
||||
case 403: // Permission denied
|
||||
$(options.modal).modal('hide');
|
||||
showAlertDialog(
|
||||
'{% trans "Error 403: Permission Denied" %}',
|
||||
'{% trans "You do not have the required permissions to access this function" %}',
|
||||
);
|
||||
break;
|
||||
case 404: // Resource not found
|
||||
$(options.modal).modal('hide');
|
||||
showAlertDialog(
|
||||
'{% trans "Error 404: Resource Not Found" %}',
|
||||
'{% trans "The requested resource could not be located on the server" %}',
|
||||
);
|
||||
break;
|
||||
case 408: // Timeout
|
||||
$(options.modal).modal('hide');
|
||||
showAlertDialog(
|
||||
'{% trans "Error 408: Timeout" %}',
|
||||
'{% trans "Connection timeout while requesting data from server" %}',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
$(options.modal).modal('hide');
|
||||
|
||||
showAlertDialog('{% trans "Error requesting form data" %}', renderErrorMessage(xhr));
|
||||
|
||||
console.log(`WARNING: Unhandled response code - ${xhr.status}`);
|
||||
showApiError(xhr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -697,6 +679,10 @@ function getFormFieldValue(name, field, options) {
|
||||
// Find the HTML element
|
||||
var el = $(options.modal).find(`#id_${name}`);
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = null;
|
||||
|
||||
switch (field.type) {
|
||||
@ -834,33 +820,27 @@ function handleFormErrors(errors, fields, options) {
|
||||
}
|
||||
|
||||
for (field_name in errors) {
|
||||
if (field_name in fields) {
|
||||
|
||||
// Add the 'has-error' class
|
||||
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
|
||||
// Add the 'has-error' class
|
||||
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
|
||||
|
||||
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
|
||||
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
|
||||
|
||||
var field_errors = errors[field_name];
|
||||
var field_errors = errors[field_name];
|
||||
|
||||
// Add an entry for each returned error message
|
||||
for (var idx = field_errors.length-1; idx >= 0; idx--) {
|
||||
// Add an entry for each returned error message
|
||||
for (var idx = field_errors.length-1; idx >= 0; idx--) {
|
||||
|
||||
var error_text = field_errors[idx];
|
||||
var error_text = field_errors[idx];
|
||||
|
||||
var html = `
|
||||
<span id='error_${idx+1}_id_${field_name}' class='help-block form-error-message'>
|
||||
<strong>${error_text}</strong>
|
||||
</span>`;
|
||||
var html = `
|
||||
<span id='error_${idx+1}_id_${field_name}' class='help-block form-error-message'>
|
||||
<strong>${error_text}</strong>
|
||||
</span>`;
|
||||
|
||||
field_dom.append(html);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log(`WARNING: handleFormErrors found no match for field '${field_name}'`);
|
||||
field_dom.append(html);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -1464,21 +1444,21 @@ function constructInputOptions(name, classes, type, parameters) {
|
||||
opts.push(`readonly=''`);
|
||||
}
|
||||
|
||||
if (parameters.value) {
|
||||
if (parameters.value != null) {
|
||||
// Existing value?
|
||||
opts.push(`value='${parameters.value}'`);
|
||||
} else if (parameters.default) {
|
||||
} else if (parameters.default != null) {
|
||||
// Otherwise, a defualt value?
|
||||
opts.push(`value='${parameters.default}'`);
|
||||
}
|
||||
|
||||
// Maximum input length
|
||||
if (parameters.max_length) {
|
||||
if (parameters.max_length != null) {
|
||||
opts.push(`maxlength='${parameters.max_length}'`);
|
||||
}
|
||||
|
||||
// Minimum input length
|
||||
if (parameters.min_length) {
|
||||
if (parameters.min_length != null) {
|
||||
opts.push(`minlength='${parameters.min_length}'`);
|
||||
}
|
||||
|
||||
@ -1497,12 +1477,21 @@ function constructInputOptions(name, classes, type, parameters) {
|
||||
opts.push(`required=''`);
|
||||
}
|
||||
|
||||
// Custom mouseover title?
|
||||
if (parameters.title != null) {
|
||||
opts.push(`title='${parameters.title}'`);
|
||||
}
|
||||
|
||||
// Placeholder?
|
||||
if (parameters.placeholder) {
|
||||
if (parameters.placeholder != null) {
|
||||
opts.push(`placeholder='${parameters.placeholder}'`);
|
||||
}
|
||||
|
||||
return `<input ${opts.join(' ')}>`;
|
||||
if (parameters.multiline) {
|
||||
return `<textarea ${opts.join(' ')}></textarea>`;
|
||||
} else {
|
||||
return `<input ${opts.join(' ')}>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -12,7 +12,6 @@
|
||||
*/
|
||||
function createNewModal(options={}) {
|
||||
|
||||
|
||||
var id = 1;
|
||||
|
||||
// Check out what modal forms are already being displayed
|
||||
@ -39,12 +38,13 @@ function createNewModal(options={}) {
|
||||
</h3>
|
||||
</div>
|
||||
<div class='modal-form-content-wrapper'>
|
||||
<div id='pre-form-content'>
|
||||
<!-- Content can be inserted here *before* the form fields -->
|
||||
</div>
|
||||
<div id='non-field-errors'>
|
||||
<!-- Form error messages go here -->
|
||||
</div>
|
||||
<div id='pre-form-content'>
|
||||
<!-- Content can be inserted here *before* the form fields -->
|
||||
</div>
|
||||
|
||||
<div id='form-content' class='modal-form-content'>
|
||||
<!-- Form content will be injected here-->
|
||||
</div>
|
||||
@ -102,6 +102,14 @@ function createNewModal(options={}) {
|
||||
modalSetSubmitText(modal_name, options.submitText || '{% trans "Submit" %}');
|
||||
modalSetCloseText(modal_name, options.cancelText || '{% trans "Cancel" %}');
|
||||
|
||||
if (options.hideSubmitButton) {
|
||||
$(modal_name).find('#modal-form-submit').hide();
|
||||
}
|
||||
|
||||
if (options.hideCloseButton) {
|
||||
$(modal_name).find('#modal-form-cancel').hide();
|
||||
}
|
||||
|
||||
// Return the "name" of the modal
|
||||
return modal_name;
|
||||
}
|
||||
@ -551,25 +559,18 @@ function showAlertDialog(title, content, options={}) {
|
||||
*
|
||||
* title - Title text
|
||||
* content - HTML content of the dialog window
|
||||
* options:
|
||||
* modal - modal form to use (default = '#modal-alert-dialog')
|
||||
*/
|
||||
|
||||
var modal = options.modal || '#modal-alert-dialog';
|
||||
|
||||
$(modal).on('shown.bs.modal', function() {
|
||||
$(modal + ' .modal-form-content').scrollTop(0);
|
||||
var modal = createNewModal({
|
||||
title: title,
|
||||
cancelText: '{% trans "Close" %}',
|
||||
hideSubmitButton: true,
|
||||
});
|
||||
|
||||
modalSetTitle(modal, title);
|
||||
modalSetContent(modal, content);
|
||||
modalSetContent(modal, content);
|
||||
|
||||
$(modal).modal({
|
||||
backdrop: 'static',
|
||||
keyboard: false,
|
||||
});
|
||||
|
||||
$(modal).modal('show');
|
||||
$(modal).modal('show');
|
||||
}
|
||||
|
||||
|
||||
@ -586,22 +587,15 @@ function showQuestionDialog(title, content, options={}) {
|
||||
* cancel - Functino to run if the user presses 'Cancel'
|
||||
*/
|
||||
|
||||
var modal = options.modal || '#modal-question-dialog';
|
||||
|
||||
$(modal).on('shown.bs.modal', function() {
|
||||
$(modal + ' .modal-form-content').scrollTop(0);
|
||||
var modal = createNewModal({
|
||||
title: title,
|
||||
submitText: options.accept_text || '{% trans "Accept" %}',
|
||||
cancelText: options.cancel_text || '{% trans "Cancel" %}',
|
||||
});
|
||||
|
||||
modalSetTitle(modal, title);
|
||||
modalSetContent(modal, content);
|
||||
|
||||
var accept_text = options.accept_text || '{% trans "Accept" %}';
|
||||
var cancel_text = options.cancel_text || '{% trans "Cancel" %}';
|
||||
|
||||
$(modal).find('#modal-form-cancel').html(cancel_text);
|
||||
$(modal).find('#modal-form-accept').html(accept_text);
|
||||
|
||||
$(modal).on('click', '#modal-form-accept', function() {
|
||||
$(modal).on('click', "#modal-form-submit", function() {
|
||||
$(modal).modal('hide');
|
||||
|
||||
if (options.accept) {
|
||||
@ -609,14 +603,6 @@ function showQuestionDialog(title, content, options={}) {
|
||||
}
|
||||
});
|
||||
|
||||
$(modal).on('click', 'modal-form-cancel', function() {
|
||||
$(modal).modal('hide');
|
||||
|
||||
if (options.cancel) {
|
||||
options.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
$(modal).modal('show');
|
||||
}
|
||||
|
||||
|
@ -20,6 +20,315 @@ function stockStatusCodes() {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Perform stock adjustments
|
||||
*/
|
||||
function adjustStock(action, items, options={}) {
|
||||
|
||||
var formTitle = 'Form Title Here';
|
||||
var actionTitle = null;
|
||||
|
||||
// API url
|
||||
var url = null;
|
||||
|
||||
var specifyLocation = false;
|
||||
var allowSerializedStock = false;
|
||||
|
||||
switch (action) {
|
||||
case 'move':
|
||||
formTitle = '{% trans "Transfer Stock" %}';
|
||||
actionTitle = '{% trans "Move" %}';
|
||||
specifyLocation = true;
|
||||
allowSerializedStock = true;
|
||||
url = '{% url "api-stock-transfer" %}';
|
||||
break;
|
||||
case 'count':
|
||||
formTitle = '{% trans "Count Stock" %}';
|
||||
actionTitle = '{% trans "Count" %}';
|
||||
url = '{% url "api-stock-count" %}';
|
||||
break;
|
||||
case 'take':
|
||||
formTitle = '{% trans "Remove Stock" %}';
|
||||
actionTitle = '{% trans "Take" %}';
|
||||
url = '{% url "api-stock-remove" %}';
|
||||
break;
|
||||
case 'add':
|
||||
formTitle = '{% trans "Add Stock" %}';
|
||||
actionTitle = '{% trans "Add" %}';
|
||||
url = '{% url "api-stock-add" %}';
|
||||
break;
|
||||
case 'delete':
|
||||
formTitle = '{% trans "Delete Stock" %}';
|
||||
allowSerializedStock = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Generate modal HTML content
|
||||
var html = `
|
||||
<table class='table table-striped table-condensed' id='stock-adjust-table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Stock" %}</th>
|
||||
<th>{% trans "Location" %}</th>
|
||||
<th>${actionTitle || ''}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
var itemCount = 0;
|
||||
|
||||
for (var idx = 0; idx < items.length; idx++) {
|
||||
|
||||
var item = items[idx];
|
||||
|
||||
if ((item.serial != null) && !allowSerializedStock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var pk = item.pk;
|
||||
|
||||
var readonly = (item.serial != null);
|
||||
var minValue = null;
|
||||
var maxValue = null;
|
||||
var value = null;
|
||||
|
||||
switch (action) {
|
||||
case 'move':
|
||||
minValue = 0;
|
||||
maxValue = item.quantity;
|
||||
value = item.quantity;
|
||||
break;
|
||||
case 'add':
|
||||
minValue = 0;
|
||||
value = 0;
|
||||
break;
|
||||
case 'take':
|
||||
minValue = 0;
|
||||
value = 0;
|
||||
break;
|
||||
case 'count':
|
||||
minValue = 0;
|
||||
value = item.quantity;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
var image = item.part_detail.thumbnail || item.part_detail.image || blankImage();
|
||||
|
||||
var status = stockStatusDisplay(item.status, {
|
||||
classes: 'float-right'
|
||||
});
|
||||
|
||||
var quantity = item.quantity;
|
||||
|
||||
var location = locationDetail(item, false);
|
||||
|
||||
if (item.location_detail) {
|
||||
location = item.location_detail.pathstring;
|
||||
}
|
||||
|
||||
if (item.serial != null) {
|
||||
quantity = `#${item.serial}`;
|
||||
}
|
||||
|
||||
var actionInput = '';
|
||||
|
||||
if (actionTitle != null) {
|
||||
actionInput = constructNumberInput(
|
||||
item.pk,
|
||||
{
|
||||
value: value,
|
||||
min_value: minValue,
|
||||
max_value: maxValue,
|
||||
read_only: readonly,
|
||||
title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}',
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
var buttons = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
buttons += makeIconButton(
|
||||
'fa-times icon-red',
|
||||
'button-stock-item-remove',
|
||||
pk,
|
||||
'{% trans "Remove stock item" %}',
|
||||
);
|
||||
|
||||
buttons += `</div>`;
|
||||
|
||||
html += `
|
||||
<tr id='stock_item_${pk}' class='stock-item-row'>
|
||||
<td id='part_${pk}'><img src='${image}' class='hover-img-thumb'> ${item.part_detail.full_name}</td>
|
||||
<td id='stock_${pk}'>${quantity}${status}</td>
|
||||
<td id='location_${pk}'>${location}</td>
|
||||
<td id='action_${pk}'>
|
||||
<div id='div_id_${pk}'>
|
||||
${actionInput}
|
||||
<div id='errors-${pk}'></div>
|
||||
</div>
|
||||
</td>
|
||||
<td id='buttons_${pk}'>${buttons}</td>
|
||||
</tr>`;
|
||||
|
||||
itemCount += 1;
|
||||
}
|
||||
|
||||
if (itemCount == 0) {
|
||||
showAlertDialog(
|
||||
'{% trans "Select Stock Items" %}',
|
||||
'{% trans "You must select at least one available stock item" %}',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
html += `</tbody></table>`;
|
||||
|
||||
var modal = createNewModal({
|
||||
title: formTitle,
|
||||
});
|
||||
|
||||
// Extra fields
|
||||
var extraFields = {
|
||||
location: {
|
||||
label: '{% trans "Location" %}',
|
||||
help_text: '{% trans "Select destination stock location" %}',
|
||||
type: 'related field',
|
||||
required: true,
|
||||
api_url: `/api/stock/location/`,
|
||||
model: 'stocklocation',
|
||||
},
|
||||
notes: {
|
||||
label: '{% trans "Notes" %}',
|
||||
help_text: '{% trans "Stock transaction notes" %}',
|
||||
type: 'string',
|
||||
}
|
||||
};
|
||||
|
||||
if (!specifyLocation) {
|
||||
delete extraFields.location;
|
||||
}
|
||||
|
||||
constructFormBody({}, {
|
||||
preFormContent: html,
|
||||
fields: extraFields,
|
||||
confirm: true,
|
||||
confirmMessage: '{% trans "Confirm stock adjustment" %}',
|
||||
modal: modal,
|
||||
onSubmit: function(fields, opts) {
|
||||
|
||||
// "Delete" action gets handled differently
|
||||
if (action == 'delete') {
|
||||
|
||||
var requests = [];
|
||||
|
||||
items.forEach(function(item) {
|
||||
requests.push(
|
||||
inventreeDelete(
|
||||
`/api/stock/${item.pk}/`,
|
||||
)
|
||||
)
|
||||
});
|
||||
|
||||
// Wait for *all* the requests to complete
|
||||
$.when.apply($, requests).then(function() {
|
||||
// Destroy the modal window
|
||||
$(modal).modal('hide');
|
||||
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess();
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Data to transmit
|
||||
var data = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
// Add values for each selected stock item
|
||||
items.forEach(function(item) {
|
||||
|
||||
var q = getFormFieldValue(item.pk, {}, {modal: modal});
|
||||
|
||||
if (q != null) {
|
||||
data.items.push({pk: item.pk, quantity: q});
|
||||
}
|
||||
});
|
||||
|
||||
// Add in extra field data
|
||||
for (field_name in extraFields) {
|
||||
data[field_name] = getFormFieldValue(
|
||||
field_name,
|
||||
fields[field_name],
|
||||
{
|
||||
modal: modal,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
inventreePut(
|
||||
url,
|
||||
data,
|
||||
{
|
||||
method: 'POST',
|
||||
success: function(response, status) {
|
||||
|
||||
// Destroy the modal window
|
||||
$(modal).modal('hide');
|
||||
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess();
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
switch (xhr.status) {
|
||||
case 400:
|
||||
|
||||
// Handle errors for standard fields
|
||||
handleFormErrors(
|
||||
xhr.responseJSON,
|
||||
extraFields,
|
||||
{
|
||||
modal: modal,
|
||||
}
|
||||
)
|
||||
|
||||
break;
|
||||
default:
|
||||
$(modal).modal('hide');
|
||||
showApiError(xhr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach callbacks for the action buttons
|
||||
$(modal).find('.button-stock-item-remove').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
$(modal).find(`#stock_item_${pk}`).remove();
|
||||
});
|
||||
|
||||
attachToggle(modal);
|
||||
|
||||
$(modal + ' .select2-container').addClass('select-full-width');
|
||||
$(modal + ' .select2-container').css('width', '100%');
|
||||
}
|
||||
|
||||
|
||||
function removeStockRow(e) {
|
||||
// Remove a selected row from a stock modal form
|
||||
|
||||
@ -228,6 +537,58 @@ function loadStockTestResultsTable(table, options) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
function locationDetail(row, showLink=true) {
|
||||
/*
|
||||
* Function to display a "location" of a StockItem.
|
||||
*
|
||||
* Complicating factors: A StockItem may not actually *be* in a location!
|
||||
* - Could be at a customer
|
||||
* - Could be installed in another stock item
|
||||
* - Could be assigned to a sales order
|
||||
* - Could be currently in production!
|
||||
*
|
||||
* So, instead of being naive, we'll check!
|
||||
*/
|
||||
|
||||
// Display text
|
||||
var text = '';
|
||||
|
||||
// URL (optional)
|
||||
var url = '';
|
||||
|
||||
if (row.is_building && row.build) {
|
||||
// StockItem is currently being built!
|
||||
text = '{% trans "In production" %}';
|
||||
url = `/build/${row.build}/`;
|
||||
} else if (row.belongs_to) {
|
||||
// StockItem is installed inside a different StockItem
|
||||
text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
|
||||
url = `/stock/item/${row.belongs_to}/installed/`;
|
||||
} else if (row.customer) {
|
||||
// StockItem has been assigned to a customer
|
||||
text = '{% trans "Shipped to customer" %}';
|
||||
url = `/company/${row.customer}/assigned-stock/`;
|
||||
} else if (row.sales_order) {
|
||||
// StockItem has been assigned to a sales order
|
||||
text = '{% trans "Assigned to Sales Order" %}';
|
||||
url = `/order/sales-order/${row.sales_order}/`;
|
||||
} else if (row.location) {
|
||||
text = row.location_detail.pathstring;
|
||||
url = `/stock/location/${row.location}/`;
|
||||
} else {
|
||||
text = '<i>{% trans "No stock location set" %}</i>';
|
||||
url = '';
|
||||
}
|
||||
|
||||
if (showLink && url) {
|
||||
return renderLink(text, url);
|
||||
} else {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function loadStockTable(table, options) {
|
||||
/* Load data into a stock table with adjustable options.
|
||||
* Fetches data (via AJAX) and loads into a bootstrap table.
|
||||
@ -271,56 +632,6 @@ function loadStockTable(table, options) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
function locationDetail(row) {
|
||||
/*
|
||||
* Function to display a "location" of a StockItem.
|
||||
*
|
||||
* Complicating factors: A StockItem may not actually *be* in a location!
|
||||
* - Could be at a customer
|
||||
* - Could be installed in another stock item
|
||||
* - Could be assigned to a sales order
|
||||
* - Could be currently in production!
|
||||
*
|
||||
* So, instead of being naive, we'll check!
|
||||
*/
|
||||
|
||||
// Display text
|
||||
var text = '';
|
||||
|
||||
// URL (optional)
|
||||
var url = '';
|
||||
|
||||
if (row.is_building && row.build) {
|
||||
// StockItem is currently being built!
|
||||
text = '{% trans "In production" %}';
|
||||
url = `/build/${row.build}/`;
|
||||
} else if (row.belongs_to) {
|
||||
// StockItem is installed inside a different StockItem
|
||||
text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
|
||||
url = `/stock/item/${row.belongs_to}/installed/`;
|
||||
} else if (row.customer) {
|
||||
// StockItem has been assigned to a customer
|
||||
text = '{% trans "Shipped to customer" %}';
|
||||
url = `/company/${row.customer}/assigned-stock/`;
|
||||
} else if (row.sales_order) {
|
||||
// StockItem has been assigned to a sales order
|
||||
text = '{% trans "Assigned to Sales Order" %}';
|
||||
url = `/order/sales-order/${row.sales_order}/`;
|
||||
} else if (row.location) {
|
||||
text = row.location_detail.pathstring;
|
||||
url = `/stock/location/${row.location}/`;
|
||||
} else {
|
||||
text = '<i>{% trans "No stock location set" %}</i>';
|
||||
url = '';
|
||||
}
|
||||
|
||||
if (url) {
|
||||
return renderLink(text, url);
|
||||
} else {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
var grouping = true;
|
||||
|
||||
if ('grouping' in options) {
|
||||
@ -741,39 +1052,15 @@ function loadStockTable(table, options) {
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
function stockAdjustment(action) {
|
||||
var items = $("#stock-table").bootstrapTable("getSelections");
|
||||
|
||||
var stock = [];
|
||||
|
||||
items.forEach(function(item) {
|
||||
stock.push(item.pk);
|
||||
});
|
||||
|
||||
// Buttons for launching secondary modals
|
||||
var secondary = [];
|
||||
|
||||
if (action == 'move') {
|
||||
secondary.push({
|
||||
field: 'destination',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new location" %}',
|
||||
url: "/stock/location/new/",
|
||||
});
|
||||
}
|
||||
|
||||
launchModalForm("/stock/adjust/",
|
||||
{
|
||||
data: {
|
||||
action: action,
|
||||
stock: stock,
|
||||
},
|
||||
success: function() {
|
||||
$("#stock-table").bootstrapTable('refresh');
|
||||
},
|
||||
secondary: secondary,
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
$('#stock-table').bootstrapTable('refresh');
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Automatically link button callbacks
|
||||
|
@ -56,46 +56,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='modal fade modal-fixed-footer' role='dialog' id='modal-question-dialog'>
|
||||
<div class='modal-dialog'>
|
||||
<div class='modal-content'>
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h3 id='modal-title'>Question Here</h3>
|
||||
</div>
|
||||
<div class='modal-form-content'>
|
||||
</div>
|
||||
<div class='modal-footer'>
|
||||
<div id='modal-footer-buttons'></div>
|
||||
<button type='button' class='btn btn-default' id='modal-form-cancel' data-dismiss='modal'>{% trans "Cancel" %}</button>
|
||||
<button type='button' class='btn btn-primary' id='modal-form-accept'>{% trans "Accept" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='modal fade modal-fixed-footer' role='dialog' id='modal-alert-dialog'>
|
||||
<div class='modal-dialog'>
|
||||
<div class='modal-content'>
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h3 id='modal-title'>Alert Information</h3>
|
||||
</div>
|
||||
<div class='modal-form-content-wrapper'>
|
||||
<div class='modal-form-content'>
|
||||
</div>
|
||||
</div>
|
||||
<div class='modal-footer'>
|
||||
<div id='modal-footer-buttons'></div>
|
||||
<button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
Loading…
Reference in New Issue
Block a user