Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Oliver Walters 2021-07-13 22:51:30 +10:00
commit 3e1d240a2a
47 changed files with 1607 additions and 6281 deletions

View File

@ -837,6 +837,12 @@ input[type="submit"] {
pointer-events: none; /* Prevent this div from blocking links underneath */ pointer-events: none; /* Prevent this div from blocking links underneath */
} }
.notes {
border-radius: 5px;
background-color: #fafafa;
padding: 5px;
}
.alert { .alert {
display: none; display: none;
border-radius: 5px; border-radius: 5px;
@ -853,6 +859,11 @@ input[type="submit"] {
margin-right: 2px; margin-right: 2px;
} }
.btn-small {
padding: 3px;
padding-left: 5px;
}
.btn-remove { .btn-remove {
padding: 3px; padding: 3px;
padding-left: 5px; padding-left: 5px;

View File

@ -35,8 +35,8 @@ function loadTree(url, tree, options={}) {
showTags: true, showTags: true,
}); });
if (sessionStorage.getItem(key)) { if (localStorage.getItem(key)) {
var saved_exp = sessionStorage.getItem(key).split(","); var saved_exp = localStorage.getItem(key).split(",");
// Automatically expand the desired notes // Automatically expand the desired notes
for (var q = 0; q < saved_exp.length; q++) { for (var q = 0; q < saved_exp.length; q++) {
@ -57,7 +57,7 @@ function loadTree(url, tree, options={}) {
} }
// Save the expanded nodes // Save the expanded nodes
sessionStorage.setItem(key, exp); localStorage.setItem(key, exp);
}); });
} }
}, },
@ -106,17 +106,17 @@ function initNavTree(options) {
width: '0px' width: '0px'
}, 50); }, 50);
sessionStorage.setItem(stateLabel, 'closed'); localStorage.setItem(stateLabel, 'closed');
} else { } else {
sessionStorage.setItem(stateLabel, 'open'); localStorage.setItem(stateLabel, 'open');
sessionStorage.setItem(widthLabel, `${width}px`); localStorage.setItem(widthLabel, `${width}px`);
} }
} }
}); });
} }
var state = sessionStorage.getItem(stateLabel); var state = localStorage.getItem(stateLabel);
var width = sessionStorage.getItem(widthLabel) || '300px'; var width = localStorage.getItem(widthLabel) || '300px';
if (state && state == 'open') { if (state && state == 'open') {
@ -131,21 +131,21 @@ function initNavTree(options) {
$(toggleId).click(function() { $(toggleId).click(function() {
var state = sessionStorage.getItem(stateLabel) || 'closed'; var state = localStorage.getItem(stateLabel) || 'closed';
var width = sessionStorage.getItem(widthLabel) || '300px'; var width = localStorage.getItem(widthLabel) || '300px';
if (state == 'open') { if (state == 'open') {
$(treeId).animate({ $(treeId).animate({
width: '0px' width: '0px'
}, 50); }, 50);
sessionStorage.setItem(stateLabel, 'closed'); localStorage.setItem(stateLabel, 'closed');
} else { } else {
$(treeId).animate({ $(treeId).animate({
width: width, width: width,
}, 50); }, 50);
sessionStorage.setItem(stateLabel, 'open'); localStorage.setItem(stateLabel, 'open');
} }
}); });
} }
@ -198,17 +198,18 @@ function enableNavbar(options) {
width: '45px' width: '45px'
}, 50); }, 50);
sessionStorage.setItem(stateLabel, 'closed'); localStorage.setItem(stateLabel, 'closed');
} else { } else {
sessionStorage.setItem(widthLabel, `${width}px`); localStorage.setItem(widthLabel, `${width}px`);
sessionStorage.setItem(stateLabel, 'open'); localStorage.setItem(stateLabel, 'open');
} }
} }
}); });
} }
var state = sessionStorage.getItem(stateLabel); var state = localStorage.getItem(stateLabel);
var width = sessionStorage.getItem(widthLabel) || '250px';
var width = localStorage.getItem(widthLabel) || '250px';
if (state && state == 'open') { if (state && state == 'open') {
@ -224,8 +225,8 @@ function enableNavbar(options) {
$(toggleId).click(function() { $(toggleId).click(function() {
var state = sessionStorage.getItem(stateLabel) || 'closed'; var state = localStorage.getItem(stateLabel) || 'closed';
var width = sessionStorage.getItem(widthLabel) || '250px'; var width = localStorage.getItem(widthLabel) || '250px';
if (state == 'open') { if (state == 'open') {
$(navId).animate({ $(navId).animate({
@ -233,7 +234,7 @@ function enableNavbar(options) {
minWidth: '45px', minWidth: '45px',
}, 50); }, 50);
sessionStorage.setItem(stateLabel, 'closed'); localStorage.setItem(stateLabel, 'closed');
} else { } else {
@ -241,7 +242,7 @@ function enableNavbar(options) {
'width': width 'width': width
}, 50); }, 50);
sessionStorage.setItem(stateLabel, 'open'); localStorage.setItem(stateLabel, 'open');
} }
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,6 @@ from django.utils.translation import ugettext_lazy as _
import django.forms import django.forms
from .models import Company from .models import Company
from .models import ManufacturerPart
from .models import SupplierPart from .models import SupplierPart
from .models import SupplierPriceBreak 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): class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """ """ Form for editing a SupplierPart object """

View File

@ -206,24 +206,27 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
MPN = serializers.StringRelatedField(source='manufacturer_part.MPN') 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: class Meta:
model = SupplierPart model = SupplierPart
fields = [ fields = [
'description',
'link',
'manufacturer',
'manufacturer_detail',
'manufacturer_part',
'manufacturer_part_detail',
'MPN',
'note',
'pk', 'pk',
'packaging',
'part', 'part',
'part_detail', 'part_detail',
'pretty_name', 'pretty_name',
'SKU',
'supplier', 'supplier',
'supplier_detail', 'supplier_detail',
'SKU',
'manufacturer',
'MPN',
'manufacturer_detail',
'manufacturer_part',
'description',
'link',
] ]
def create(self, validated_data): def create(self, validated_data):

View File

@ -53,29 +53,27 @@
{{ block.super }} {{ block.super }}
$("#manufacturer-part-create").click(function () { $("#manufacturer-part-create").click(function () {
launchModalForm(
"{% url 'manufacturer-part-create' %}", constructForm('{% url "api-manufacturer-part-list" %}', {
{ fields: {
data: { part: {},
manufacturer: {{ company.id }}, manufacturer: {
value: {{ company.pk }},
}, },
success: function() { MPN: {
$("#part-table").bootstrapTable("refresh"); icon: 'fa-hashtag',
}, },
secondary: [ description: {},
{ link: {
field: 'part', icon: 'fa-link',
label: '{% trans "New Part" %}', },
title: '{% trans "Create new Part" %}', },
url: "{% url 'part-create' %}" method: 'POST',
}, title: '{% trans "Add Manufacturer Part" %}',
{ onSuccess: function() {
field: 'manufacturer', $("#part-table").bootstrapTable("refresh");
label: '{% trans "New Manufacturer" %}', }
title: '{% trans "Create new Manufacturer" %}', });
},
]
});
}); });
loadManufacturerPartTable( loadManufacturerPartTable(

View File

@ -62,7 +62,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{% trans "Internal Part" %}</td> <td>{% trans "Internal Part" %}</td>
<td> <td>
{% if part.part %} {% 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 %} {% endif %}
</td> </td>
</tr> </tr>
@ -118,9 +118,13 @@ $('#edit-part').click(function () {
fields: { fields: {
part: {}, part: {},
manufacturer: {}, manufacturer: {},
MPN: {}, MPN: {
icon: 'fa-hashtag',
},
description: {}, description: {},
link: {}, link: {
icon: 'fa-link',
},
}, },
title: '{% trans "Edit Manufacturer Part" %}', title: '{% trans "Edit Manufacturer Part" %}',
reload: true, reload: true,

View File

@ -18,7 +18,7 @@
<td>{% trans "Internal Part" %}</td> <td>{% trans "Internal Part" %}</td>
<td> <td>
{% if part.part %} {% 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 %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -230,7 +230,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Check manufacturer part # 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}) url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
response = self.get(url) response = self.get(url)
self.assertEqual(response.data['MPN'], 'PART_NUMBER') self.assertEqual(response.data['MPN'], 'PART_NUMBER')

View File

@ -194,45 +194,6 @@ class ManufacturerPartViewTests(CompanyViewTestBase):
Tests for the ManufacturerPart views. 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): def test_supplier_part_create(self):
""" """
Test that the SupplierPartCreate view creates Manufacturer Part. Test that the SupplierPartCreate view creates Manufacturer Part.

View File

@ -38,8 +38,7 @@ company_urls = [
] ]
manufacturer_part_urls = [ manufacturer_part_urls = [
url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'),
url(r'^(?P<pk>\d+)/', include([ 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(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'), url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'),

View File

@ -29,7 +29,6 @@ from .models import SupplierPart
from part.models import Part from part.models import Part
from .forms import EditManufacturerPartForm
from .forms import EditSupplierPartForm from .forms import EditSupplierPartForm
from .forms import CompanyImageDownloadForm from .forms import CompanyImageDownloadForm
@ -242,74 +241,6 @@ class ManufacturerPartDetail(DetailView):
return ctx 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): class SupplierPartDetail(DetailView):
""" Detail view for SupplierPart """ """ Detail view for SupplierPart """
model = SupplierPart model = SupplierPart

View File

@ -3,14 +3,9 @@ Functionality for Bill of Material (BOM) management.
Primarily BOM upload tools. Primarily BOM upload tools.
""" """
from rapidfuzz import fuzz
import tablib
import os
from collections import OrderedDict from collections import OrderedDict
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext as _
from django.core.exceptions import ValidationError
from InvenTree.helpers import DownloadFile, GetExportFormats 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 = [] stock_data = []
# Get part default location # Get part default location
try: 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: except AttributeError:
stock_data.append('') stock_data.append('')
# Get part current stock # 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): for s_idx, header in enumerate(stock_headers):
try: try:
@ -323,177 +323,6 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
data = dataset.export(fmt) 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) 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]

View File

@ -11,10 +11,11 @@ from django.utils.translation import ugettext_lazy as _
from mptt.fields import TreeNodeChoiceField from mptt.fields import TreeNodeChoiceField
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.helpers import GetExportFormats from InvenTree.helpers import GetExportFormats, clean_decimal
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField
import common.models import common.models
from common.forms import MatchItemForm
from .models import Part, PartCategory, PartRelated from .models import Part, PartCategory, PartRelated
from .models import BomItem 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): class BomExportForm(forms.Form):
""" Simple form to let user set BOM export options, """ Simple form to let user set BOM export options,
before exporting a BOM (bill of materials) file. before exporting a BOM (bill of materials) file.
@ -143,16 +134,28 @@ class BomValidateForm(HelperForm):
] ]
class BomUploadSelectFile(HelperForm): class BomMatchItemForm(MatchItemForm):
""" Form for importing a BOM. Provides a file input box for upload """ """ 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: # set quantity field
model = Part if 'quantity' in col_guess.lower():
fields = [ return forms.CharField(
'bom_file', 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): class CreatePartRelatedForm(HelperForm):

View File

@ -30,7 +30,7 @@ from mptt.models import TreeForeignKey, MPTTModel
from stdimage.models import StdImageField from stdimage.models import StdImageField
from decimal import Decimal from decimal import Decimal, InvalidOperation
from datetime import datetime from datetime import datetime
from rapidfuzz import fuzz from rapidfuzz import fuzz
import hashlib import hashlib
@ -2418,6 +2418,15 @@ class BomItem(models.Model):
- If the "sub_part" is trackable, then the "part" must be trackable too! - 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: try:
# Check for circular BOM references # Check for circular BOM references
if self.sub_part: if self.sub_part:

View File

@ -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 %}

View File

@ -11,6 +11,12 @@
{% endblock %} {% endblock %}
{% block details %} {% 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.bom_checked_date %}
{% if part.is_bom_valid %} {% if part.is_bom_valid %}
<div class='alert alert-block alert-info'> <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 class='table table-bom table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
</table> </table>
{% endif %}
{% endblock %} {% endblock %}

View 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 %}

View 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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -8,13 +8,12 @@
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}
{% trans "Upload Bill of Materials" %} {% trans "Upload BOM File" %}
{% endblock %} {% endblock %}
{% block details %} {% block details %}
<p>{% trans "Step 1 - Select BOM File" %}</p> {% block form_alert %}
<div class='alert alert-info alert-block'> <div class='alert alert-info alert-block'>
<b>{% trans "Requirements for BOM upload" %}:</b> <b>{% trans "Requirements for BOM upload" %}:</b>
<ul> <ul>
@ -22,16 +21,31 @@
<li>{% trans "Each part must already exist in the database" %}</li> <li>{% trans "Each part must already exist in the database" %}</li>
</ul> </ul>
</div> </div>
{% endblock %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data"> <p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
<button type="submit" class="save btn btn-default">{% trans 'Upload File' %}</button> {% if description %}- {{ description }}{% endif %}</p>
{% csrf_token %}
{% load crispy_forms_tags %}
<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> </form>
{% endblock form_buttons_bottom %}
{% endblock %} {% endblock %}

View File

@ -1,6 +1,7 @@
{% extends "part/part_base.html" %} {% extends "part/part_base.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load markdownify %}
{% block menubar %} {% block menubar %}
@ -135,11 +136,38 @@
{% endif %} {% endif %}
{% if part.responsible %} {% if part.responsible %}
<tr> <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><strong>{% trans "Responsible User" %}</strong></td>
<td>{{ part.responsible }}</td> <td>{{ part.responsible }}</td>
</tr> </tr>
{% endif %} {% 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> </table>
</div> </div>
<div class='col-sm-6'> <div class='col-sm-6'>
@ -238,6 +266,42 @@
{% endblock %} {% 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 js_load %}
{{ block.super }} {{ block.super }}
{% endblock %} {% endblock %}
@ -245,6 +309,18 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ 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() { $(".slidey").change(function() {
var field = $(this).attr('fieldname'); 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 %} {% endblock %}

View File

@ -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 %}

View File

@ -19,12 +19,6 @@
</span> </span>
</a> </a>
</li> </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 %} {% if part.is_template %}
<li class='list-group-item {% if tab == "variants" %}active{% endif %}' title='{% trans "Part Variants" %}'> <li class='list-group-item {% if tab == "variants" %}active{% endif %}' title='{% trans "Part Variants" %}'>
<a href='{% url "part-variants" part.id %}'> <a href='{% url "part-variants" part.id %}'>
@ -78,12 +72,6 @@
</a> </a>
</li> </li>
{% if part.purchaseable and roles.purchase_order.view %} {% 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" %}'> <li class='list-group-item {% if tab == "suppliers" %}active{% endif %}' title='{% trans "Suppliers" %}'>
<a href='{% url "part-suppliers" part.id %}'> <a href='{% url "part-suppliers" part.id %}'>
<span class='menu-tab-icon fas fa-building sidebar-icon'></span> <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" %}'> <li class='list-group-item {% if tab == "tests" %}active{% endif %}' title='{% trans "Part Test Templates" %}'>
<a href='{% url "part-test-templates" part.id %}'> <a href='{% url "part-test-templates" part.id %}'>
<span class='menu-tab-icon fas fa-vial sidebar-icon'></span> <span class='menu-tab-icon fas fa-vial sidebar-icon'></span>
{% trans "Tests" %} {% trans "Test Templates" %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@ -121,16 +109,4 @@
</a> </a>
</li> </li>
{% endif %} {% 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> </ul>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -49,9 +49,25 @@
<span id='part-price-icon' class='fas fa-dollar-sign'/> <span id='part-price-icon' class='fas fa-dollar-sign'/>
</button> </button>
{% if roles.stock.change %} {% if roles.stock.change %}
<button type='button' class='btn btn-default' id='part-count' title='{% trans "Count part stock" %}'> <div class='btn-group'>
<span class='fas fa-clipboard-list'/> <button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
</button> <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 %} {% endif %}
{% if part.purchaseable %} {% if part.purchaseable %}
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
@ -272,14 +288,34 @@
printPartLabels([{{ part.pk }}]); printPartLabels([{{ part.pk }}]);
}); });
$("#part-count").click(function() { function adjustPartStock(action) {
launchModalForm("/stock/adjust/", { inventreeGet(
data: { '{% url "api-stock-list" %}',
action: "count", {
part: {{ part.id }}, 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() { $("#price-button").click(function() {

View File

@ -6,12 +6,13 @@
{% include 'part/navbar.html' with tab='suppliers' %} {% include 'part/navbar.html' with tab='suppliers' %}
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}
{% trans "Part Suppliers" %} {% trans "Part Suppliers" %}
{% endblock %} {% endblock %}
{% block details %} {% block details %}
<div id='button-toolbar'> <div id='supplier-button-toolbar'>
<div class='btn-group'> <div class='btn-group'>
<button class="btn btn-success" id='supplier-create'> <button class="btn btn-success" id='supplier-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %} <span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
@ -25,11 +26,40 @@
</div> </div>
</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> </table>
{% endblock %} {% 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 js_load %}
{{ block.super }} {{ block.super }}
{% endblock %} {% 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 %} {% endblock %}

View 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"')

View File

@ -48,7 +48,6 @@ part_detail_urls = [
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-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'^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'^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'), 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'^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'^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'^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'^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'^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'^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'^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'^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'^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'), url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),

View File

@ -13,7 +13,7 @@ from django.shortcuts import get_object_or_404
from django.shortcuts import HttpResponseRedirect from django.shortcuts import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.urls import reverse, reverse_lazy 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.models import model_to_dict
from django.forms import HiddenInput, CheckboxInput from django.forms import HiddenInput, CheckboxInput
from django.conf import settings from django.conf import settings
@ -42,13 +42,14 @@ from common.models import InvenTreeSetting
from company.models import SupplierPart from company.models import SupplierPart
from common.files import FileManager from common.files import FileManager
from common.views import FileManagementFormView, FileManagementAjaxView from common.views import FileManagementFormView, FileManagementAjaxView
from common.forms import UploadFileForm, MatchFieldForm
from stock.models import StockLocation from stock.models import StockLocation
import common.settings as inventree_settings import common.settings as inventree_settings
from . import forms as part_forms 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 order.models import PurchaseOrderLineItem
from .admin import PartResource from .admin import PartResource
@ -746,40 +747,6 @@ class PartImportAjax(FileManagementAjaxView, PartImport):
return PartImport.validate(self, self.steps.current, form, **kwargs) 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): class PartDetail(InvenTreeRoleMixin, DetailView):
""" Detail view for Part object """ 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. """ View for uploading a BOM file, and handling BOM data importing.
The BOM upload process is as follows: 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. 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') role_required = ('part.change', 'part.add')
def get_success_url(self): class BomFileManager(FileManager):
part = self.get_object() # Fields which are absolutely necessary for valid upload
return reverse('upload-bom', kwargs={'pk': part.id}) 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 # Fields which would be helpful but are not required
return part_forms.BomUploadSelectFile 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 def get_part(self):
# This provides for much simpler template rendering """ Get part or return 404 """
rows = [] return get_object_or_404(Part, pk=self.kwargs['pk'])
for row in self.bom_rows:
row_data = row['data']
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({ part = self.get_part()
'cell': item,
'idx': idx,
'column': self.bom_columns[idx]
})
rows.append({ context.update({'part': part})
'index': row.get('index', -1),
'data': data,
'part_match': row.get('part_match', None),
'part_options': row.get('part_options', self.allowed_parts),
# User-input (passed between client and server) return context
'quantity': row.get('quantity', None),
'description': row.get('description', ''),
'part_name': row.get('part_name', ''),
'part': row.get('part', None),
'reference': row.get('reference', ''),
'notes': row.get('notes', ''),
'errors': row.get('errors', ''),
})
ctx['part'] = self.part def get_allowed_parts(self):
ctx['bom_headers'] = BomUploadManager.HEADERS
ctx['bom_columns'] = self.bom_columns
ctx['bom_rows'] = rows
ctx['missing_columns'] = self.missing_columns
ctx['allowed_parts_list'] = self.allowed_parts
return ctx
def getAllowedParts(self):
""" Return a queryset of parts which are allowed to be added to this BOM. """ Return 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): def get_field_selection(self):
""" 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):
""" Once data columns have been selected, attempt to pre-select the proper data from the database. """ 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. This function is called once the field selection has been validated.
The pre-fill data are then passed through to the part selection form. 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 # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database
k_idx = self.getColumnIndex('Part_ID') k_idx = self.get_column_index('Part_ID')
p_idx = self.getColumnIndex('Part_Name') p_idx = self.get_column_index('Part_Name')
i_idx = self.getColumnIndex('Part_IPN') i_idx = self.get_column_index('Part_IPN')
q_idx = self.getColumnIndex('Quantity') q_idx = self.get_column_index('Quantity')
r_idx = self.getColumnIndex('Reference') r_idx = self.get_column_index('Reference')
o_idx = self.getColumnIndex('Overage') o_idx = self.get_column_index('Overage')
n_idx = self.getColumnIndex('Note') 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, Iterate through each row in the uploaded data,
and see if we can match the row to a "Part" object in the database. 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: 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 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 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 c) Use the name of the part, uploaded in the "Part_Name" field
Notes: Notes:
- If using the Part_ID field, we can do an exact match against the PK field - 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_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 - 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: We also extract other information from the row, for the other non-matched fields:
- Quantity - Quantity
- Reference - Reference
- Overage - Overage
- Note - Note
""" """
# Initially use a quantity of zero # Initially use a quantity of zero
@ -1459,42 +1358,55 @@ class BomUpload(InvenTreeRoleMixin, FormView):
exact_match_part = None exact_match_part = None
# A list of potential Part matches # 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" # Check if there is a column corresponding to "quantity"
if q_idx >= 0: if q_idx >= 0:
q_val = row['data'][q_idx] q_val = row['data'][q_idx]['cell']
if q_val: if q_val:
# Delete commas
q_val = q_val.replace(',', '')
try: try:
# Attempt to extract a valid quantity from the field # Attempt to extract a valid quantity from the field
quantity = Decimal(q_val) quantity = Decimal(q_val)
# Store the 'quantity' value
row['quantity'] = quantity
except (ValueError, InvalidOperation): except (ValueError, InvalidOperation):
pass pass
# Store the 'quantity' value
row['quantity'] = quantity
# Check if there is a column corresponding to "PK" # Check if there is a column corresponding to "PK"
if k_idx >= 0: if k_idx >= 0:
pk = row['data'][k_idx] pk = row['data'][k_idx]['cell']
if pk: if pk:
try: try:
# Attempt Part lookup based on PK value # 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): except (ValueError, Part.DoesNotExist):
exact_match_part = None exact_match_part = None
# Check if there is a column corresponding to "Part Name" # Check if there is a column corresponding to "Part IPN" and no exact match found yet
if p_idx >= 0: if i_idx >= 0 and not exact_match_part:
part_name = row['data'][p_idx] 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 row['part_name'] = part_name
matches = [] matches = []
for part in self.allowed_parts: for part in self.allowed_items:
ratio = fuzz.partial_ratio(part.name + part.description, part_name) ratio = fuzz.partial_ratio(part.name + part.description, part_name)
matches.append({'part': part, 'match': ratio}) matches.append({'part': part, 'match': ratio})
@ -1503,390 +1415,67 @@ class BomUpload(InvenTreeRoleMixin, FormView):
matches = sorted(matches, key=lambda item: item['match'], reverse=True) matches = sorted(matches, key=lambda item: item['match'], reverse=True)
part_options = [m['part'] for m in matches] 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" # Unless found, the 'item_match' is blank
if i_idx >= 0: row['item_match'] = None
row['part_ipn'] = row['data'][i_idx]
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 # Check if there is a column corresponding to "Overage" field
if o_idx >= 0: 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 # Check if there is a column corresponding to "Reference" field
if r_idx >= 0: 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 # Check if there is a column corresponding to "Note" field
if n_idx >= 0: if n_idx >= 0:
row['note'] = row['data'][n_idx] row['note'] = row['data'][n_idx]['cell']
# Supply list of part options for each row, sorted by how closely they match the part name def done(self, form_list, **kwargs):
row['part_options'] = part_options """ Once all the data is in, process it to add BomItem instances to the part """
# Unless found, the 'part_match' is blank self.part = self.get_part()
row['part_match'] = None items = self.get_clean_items()
if exact_match_part: # Clear BOM
# If there is an exact match based on PK, use that self.part.clear_bom()
row['part_match'] = exact_match_part
else: # Generate new BOM items
# Otherwise, check to see if there is a matching IPN for bom_item in items.values():
try: try:
if row['part_ipn']: part = Part.objects.get(pk=int(bom_item.get('part')))
part_matches = [part for part in self.allowed_parts if part.IPN and row['part_ipn'].lower() == str(part.IPN.lower())] except (ValueError, Part.DoesNotExist):
continue
# Check for single match
if len(part_matches) == 1: quantity = bom_item.get('quantity')
row['part_match'] = part_matches[0] overage = bom_item.get('overage', '')
reference = bom_item.get('reference', '')
continue note = bom_item.get('note', '')
except KeyError:
pass # Create a new BOM item
item = BomItem(
def extractDataFromFile(self, bom): part=self.part,
""" Read data from the BOM file """ sub_part=part,
quantity=quantity,
self.bom_columns = bom.columns() overage=overage,
self.bom_rows = bom.rows() reference=reference,
note=note,
def getTableDataFromPost(self): )
""" Extract table cell data from POST request.
These data are used to maintain state between sessions. try:
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
)
item.save() item.save()
except IntegrityError:
# BomItem already exists
pass
# Redirect to the BOM view return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.kwargs['pk']}))
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))
class PartExport(AjaxView): class PartExport(AjaxView):

View File

@ -120,9 +120,6 @@ class StockAdjust(APIView):
- StockAdd: add stock items - StockAdd: add stock items
- StockRemove: remove stock items - StockRemove: remove stock items
- StockTransfer: transfer stock items - StockTransfer: transfer stock items
# TODO - This needs serious refactoring!!!
""" """
queryset = StockItem.objects.none() queryset = StockItem.objects.none()
@ -143,7 +140,10 @@ class StockAdjust(APIView):
elif 'items' in request.data: elif 'items' in request.data:
_items = request.data['items'] _items = request.data['items']
else: 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 # List of validated items
self.items = [] self.items = []
@ -151,13 +151,22 @@ class StockAdjust(APIView):
for entry in _items: for entry in _items:
if not type(entry) == dict: 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: try:
pk = entry.get('pk', None)
item = StockItem.objects.get(pk=pk) item = StockItem.objects.get(pk=pk)
except (ValueError, StockItem.DoesNotExist): except (StockItem.DoesNotExist):
raise ValidationError({'pk': 'Each entry must contain a valid pk field'}) raise ValidationError({
pk: [_('Primary key does not match valid stock item')]
})
if self.allow_missing_quantity and 'quantity' not in entry: if self.allow_missing_quantity and 'quantity' not in entry:
entry['quantity'] = item.quantity entry['quantity'] = item.quantity
@ -165,16 +174,21 @@ class StockAdjust(APIView):
try: try:
quantity = Decimal(str(entry.get('quantity', None))) quantity = Decimal(str(entry.get('quantity', None)))
except (ValueError, TypeError, InvalidOperation): except (ValueError, TypeError, InvalidOperation):
raise ValidationError({'quantity': "Each entry must contain a valid quantity value"}) raise ValidationError({
pk: [_('Invalid quantity value')]
})
if quantity < 0: 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({ self.items.append({
'item': item, 'item': item,
'quantity': quantity 'quantity': quantity
}) })
# Extract 'notes' field
self.notes = str(request.data.get('notes', '')) self.notes = str(request.data.get('notes', ''))
@ -228,6 +242,11 @@ class StockRemove(StockAdjust):
for item in self.items: 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): if item['item'].take_stock(item['quantity'], request.user, notes=self.notes):
n += 1 n += 1
@ -243,19 +262,24 @@ class StockTransfer(StockAdjust):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.get_items(request)
data = request.data data = request.data
try: try:
location = StockLocation.objects.get(pk=data.get('location', None)) location = StockLocation.objects.get(pk=data.get('location', None))
except (ValueError, StockLocation.DoesNotExist): except (ValueError, StockLocation.DoesNotExist):
raise ValidationError({'location': 'Valid location must be specified'}) raise ValidationError({'location': [_('Valid location must be specified')]})
n = 0 n = 0
self.get_items(request)
for item in self.items: 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 quantity is not specified, move the entire stock
if item['quantity'] in [0, None]: if item['quantity'] in [0, None]:
item['quantity'] = item['item'].quantity 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) - GET: Return a list of all StockItem objects (with optional query filters)
- POST: Create a new StockItem - 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 serializer_class = StockItemSerializer
@ -482,7 +499,6 @@ class StockList(generics.ListCreateAPIView):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
# TODO - Save the user who created this item
item = serializer.save() item = serializer.save()
# A location was *not* specified - try to infer it # A location was *not* specified - try to infer it
@ -1092,47 +1108,41 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = LocationSerializer 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 = [ 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 # Endpoints for bulk stock adjustment actions
# TODO: Remove server-side forms for stock adjustment!!! url(r'^count/', StockCount.as_view(), name='api-stock-count'),
url(r'count/?', StockCount.as_view(), name='api-stock-count'), url(r'^add/', StockAdd.as_view(), name='api-stock-add'),
url(r'add/?', StockAdd.as_view(), name='api-stock-add'), url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
url(r'remove/?', StockRemove.as_view(), name='api-stock-remove'), url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
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'^attachment/', include([
url(r'^(?P<pk>\d+)/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'), url(r'^(?P<pk>\d+)/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'),
url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'), 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'^test/', include([
url(r'^(?P<pk>\d+)/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'), 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'), url(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'),
])), ])),
# StockItemTracking API endpoints
url(r'^track/', include([ url(r'^track/', include([
url(r'^(?P<pk>\d+)/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'), 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'^.*$', 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 # 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'), url(r'^.*$', StockList.as_view(), name='api-stock-list'),
] ]

View File

@ -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): class EditStockItemForm(HelperForm):
""" Form for editing a StockItem object. """ Form for editing a StockItem object.
Note that not all fields can be edited here (even if they can be specified during creation. Note that not all fields can be edited here (even if they can be specified during creation.

View File

@ -534,14 +534,21 @@ $("#barcode-scan-into-location").click(function() {
}); });
function itemAdjust(action) { function itemAdjust(action) {
launchModalForm("/stock/adjust/",
inventreeGet(
'{% url "api-stock-detail" item.pk %}',
{ {
data: { part_detail: true,
action: action, location_detail: true,
item: {{ item.id }}, },
}, {
reload: true, success: function(item) {
follow: true, adjustStock(action, [item], {
onSuccess: function() {
location.reload();
}
});
}
} }
); );
} }

View File

@ -59,11 +59,23 @@
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
{% if roles.stock.change %} {% if roles.stock.change %}
<div class='btn-group'> <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'> <ul class='dropdown-menu' role='menu'>
<li><a href='#' id='location-count'><span class='fas fa-clipboard-list'></span> <li>
{% trans "Count stock" %}</a></li> <a href='#' id='location-count'>
</ul> <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> </div>
{% endif %} {% endif %}
{% if roles.stock_location.change %} {% if roles.stock_location.change %}
@ -215,14 +227,34 @@
}); });
{% if location %} {% if location %}
$("#location-count").click(function() {
launchModalForm("/stock/adjust/", { function adjustLocationStock(action) {
data: { inventreeGet(
action: "count", '{% url "api-stock-list" %}',
{
location: {{ location.id }}, 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() { $('#print-label').click(function() {

View File

@ -7,8 +7,8 @@ from __future__ import unicode_literals
from datetime import datetime, timedelta from datetime import datetime, timedelta
from rest_framework import status
from django.urls import reverse from django.urls import reverse
from rest_framework import status
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
@ -456,30 +456,32 @@ class StocktakeTest(StockAPITestCase):
# POST without a PK # POST without a PK
response = self.post(url, data) 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'] = [{ data['items'] = [{
'pk': 10 'pk': 10
}] }]
response = self.post(url, data) 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'] = [{ data['items'] = [{
'pk': 1234 'pk': 1234
}] }]
response = self.post(url, data) 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'] = [{ data['items'] = [{
'pk': 1234, 'pk': 1234,
'quantity': '10x0d' 'quantity': '10x0d'
}] }]
response = self.post(url, data) 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'] = [{ data['items'] = [{
'pk': 1234, 'pk': 1234,

View File

@ -105,31 +105,6 @@ class StockItemTest(StockViewTestCase):
response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200) 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): def test_edit_item(self):
# Test edit view for StockItem # Test edit view for StockItem
response = self.client.get(reverse('stock-item-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.get(reverse('stock-item-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')

View File

@ -64,8 +64,6 @@ stock_urls = [
url(r'^track/', include(stock_tracking_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-options/?', views.StockExportOptions.as_view(), name='stock-export-options'),
url(r'^export/?', views.StockExport.as_view(), name='stock-export'), url(r'^export/?', views.StockExport.as_view(), name='stock-export'),

View File

@ -91,20 +91,20 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
if self.object.serialized: if self.object.serialized:
serial_elem = {a.serial: a for a in self.object.part.stock_items.all() if a.serialized} serial_elem = {int(a.serial): a for a in self.object.part.stock_items.all() if a.serialized}
serials = [int(a) for a in serial_elem.keys()] serials = serial_elem.keys()
current = int(self.object.serial) current = int(self.object.serial)
# previous # previous
for nbr in range(current - 1, -1, -1): for nbr in range(current - 1, -1, -1):
if nbr in serials: if nbr in serials:
data['previous'] = serial_elem.get(str(nbr), None) data['previous'] = serial_elem.get(nbr, None)
break break
# next # next
for nbr in range(current + 1, max(serials) + 1): for nbr in range(current + 1, max(serials) + 1):
if nbr in serials: if nbr in serials:
data['next'] = serial_elem.get(str(nbr), None) data['next'] = serial_elem.get(nbr, None)
break break
return data return data
@ -749,343 +749,6 @@ class StockItemUninstall(AjaxView, FormMixin):
return context 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): class StockItemEdit(AjaxUpdateView):
""" """
View for editing details of a single StockItem View for editing details of a single StockItem

View File

@ -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/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 '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.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 'css/bootstrap-toggle.css' %}">
<link rel="stylesheet" href="{% static 'fullcalendar/main.css' %}"> <link rel="stylesheet" href="{% static 'fullcalendar/main.css' %}">
<link rel="stylesheet" href="{% static 'script/jquery-ui/jquery-ui.min.css' %}"> <link rel="stylesheet" href="{% static 'script/jquery-ui/jquery-ui.min.css' %}">

View File

@ -1,3 +1,6 @@
{% load i18n %}
{% load inventree_extras %}
var jQuery = window.$; var jQuery = window.$;
// using jQuery // using jQuery
@ -138,4 +141,49 @@ function inventreeDelete(url, options={}) {
inventreePut(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);
} }

View File

@ -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,
}
], ],
}); });
} }

View File

@ -353,12 +353,16 @@ function constructFormBody(fields, options) {
// Override existing query filters (if provided!) // Override existing query filters (if provided!)
fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters); fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters);
// TODO: Refactor the following code with Object.assign (see above)
// Secondary modal options // Secondary modal options
fields[field].secondary = field_options.secondary; fields[field].secondary = field_options.secondary;
// Edit callback // Edit callback
fields[field].onEdit = field_options.onEdit; fields[field].onEdit = field_options.onEdit;
fields[field].multiline = field_options.multiline;
// Custom help_text // Custom help_text
if (field_options.help_text) { if (field_options.help_text) {
fields[field].help_text = 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) { for (var name in displayed_fields) {
// Only push names which are actually in the set of fields field_names.push(name);
if (name in fields) {
field_names.push(name); // Field not specified in the API, but the client wishes to add it!
} else { if (!(name in fields)) {
console.log(`WARNING: '${name}' does not match a valid field name.`); fields[name] = displayed_fields[name];
} }
} }
@ -422,10 +426,8 @@ function constructFormBody(fields, options) {
default: default:
break; break;
} }
var f = constructField(name, field, options);
html += f; html += constructField(name, field, options);
} }
// TODO: Dynamically create the modals, // TODO: Dynamically create the modals,
@ -441,7 +443,15 @@ function constructFormBody(fields, options) {
modalEnable(modal, true); modalEnable(modal, true);
// Insert generated form content // 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 // Clear any existing buttons from the modal
$(modal).find('#modal-footer-buttons').html(''); $(modal).find('#modal-footer-buttons').html('');
@ -474,7 +484,21 @@ function constructFormBody(fields, options) {
$(modal).on('click', '#modal-form-submit', function() { $(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) { 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 // Form data to be uploaded to the server
// Only used if file / image upload is required // Only used if file / image upload is required
var form_data = new FormData(); var form_data = new FormData();
@ -581,47 +601,9 @@ function submitFormData(fields, options) {
case 400: // Bad request case 400: // Bad request
handleFormErrors(xhr.responseJSON, fields, options); handleFormErrors(xhr.responseJSON, fields, options);
break; 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: default:
$(options.modal).modal('hide'); $(options.modal).modal('hide');
showApiError(xhr);
showAlertDialog('{% trans "Error requesting form data" %}', renderErrorMessage(xhr));
console.log(`WARNING: Unhandled response code - ${xhr.status}`);
break; break;
} }
} }
@ -697,6 +679,10 @@ function getFormFieldValue(name, field, options) {
// Find the HTML element // Find the HTML element
var el = $(options.modal).find(`#id_${name}`); var el = $(options.modal).find(`#id_${name}`);
if (!el) {
return null;
}
var value = null; var value = null;
switch (field.type) { switch (field.type) {
@ -834,33 +820,27 @@ function handleFormErrors(errors, fields, options) {
} }
for (field_name in errors) { for (field_name in errors) {
if (field_name in fields) {
// Add the 'has-error' class // Add the 'has-error' class
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error'); $(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 // Add an entry for each returned error message
for (var idx = field_errors.length-1; idx >= 0; idx--) { for (var idx = field_errors.length-1; idx >= 0; idx--) {
var error_text = field_errors[idx]; var error_text = field_errors[idx];
var html = ` var html = `
<span id='error_${idx+1}_id_${field_name}' class='help-block form-error-message'> <span id='error_${idx+1}_id_${field_name}' class='help-block form-error-message'>
<strong>${error_text}</strong> <strong>${error_text}</strong>
</span>`; </span>`;
field_dom.append(html); field_dom.append(html);
}
} else {
console.log(`WARNING: handleFormErrors found no match for field '${field_name}'`);
} }
} }
} }
@ -1464,21 +1444,21 @@ function constructInputOptions(name, classes, type, parameters) {
opts.push(`readonly=''`); opts.push(`readonly=''`);
} }
if (parameters.value) { if (parameters.value != null) {
// Existing value? // Existing value?
opts.push(`value='${parameters.value}'`); opts.push(`value='${parameters.value}'`);
} else if (parameters.default) { } else if (parameters.default != null) {
// Otherwise, a defualt value? // Otherwise, a defualt value?
opts.push(`value='${parameters.default}'`); opts.push(`value='${parameters.default}'`);
} }
// Maximum input length // Maximum input length
if (parameters.max_length) { if (parameters.max_length != null) {
opts.push(`maxlength='${parameters.max_length}'`); opts.push(`maxlength='${parameters.max_length}'`);
} }
// Minimum input length // Minimum input length
if (parameters.min_length) { if (parameters.min_length != null) {
opts.push(`minlength='${parameters.min_length}'`); opts.push(`minlength='${parameters.min_length}'`);
} }
@ -1497,12 +1477,21 @@ function constructInputOptions(name, classes, type, parameters) {
opts.push(`required=''`); opts.push(`required=''`);
} }
// Custom mouseover title?
if (parameters.title != null) {
opts.push(`title='${parameters.title}'`);
}
// Placeholder? // Placeholder?
if (parameters.placeholder) { if (parameters.placeholder != null) {
opts.push(`placeholder='${parameters.placeholder}'`); opts.push(`placeholder='${parameters.placeholder}'`);
} }
return `<input ${opts.join(' ')}>`; if (parameters.multiline) {
return `<textarea ${opts.join(' ')}></textarea>`;
} else {
return `<input ${opts.join(' ')}>`;
}
} }

View File

@ -12,7 +12,6 @@
*/ */
function createNewModal(options={}) { function createNewModal(options={}) {
var id = 1; var id = 1;
// Check out what modal forms are already being displayed // Check out what modal forms are already being displayed
@ -39,12 +38,13 @@ function createNewModal(options={}) {
</h3> </h3>
</div> </div>
<div class='modal-form-content-wrapper'> <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'> <div id='non-field-errors'>
<!-- Form error messages go here --> <!-- Form error messages go here -->
</div> </div>
<div id='pre-form-content'>
<!-- Content can be inserted here *before* the form fields -->
</div>
<div id='form-content' class='modal-form-content'> <div id='form-content' class='modal-form-content'>
<!-- Form content will be injected here--> <!-- Form content will be injected here-->
</div> </div>
@ -102,6 +102,14 @@ function createNewModal(options={}) {
modalSetSubmitText(modal_name, options.submitText || '{% trans "Submit" %}'); modalSetSubmitText(modal_name, options.submitText || '{% trans "Submit" %}');
modalSetCloseText(modal_name, options.cancelText || '{% trans "Cancel" %}'); 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 the "name" of the modal
return modal_name; return modal_name;
} }
@ -551,25 +559,18 @@ function showAlertDialog(title, content, options={}) {
* *
* title - Title text * title - Title text
* content - HTML content of the dialog window * 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() { var modal = createNewModal({
$(modal + ' .modal-form-content').scrollTop(0); title: title,
cancelText: '{% trans "Close" %}',
hideSubmitButton: true,
}); });
modalSetTitle(modal, title); modalSetContent(modal, content);
modalSetContent(modal, content);
$(modal).modal({ $(modal).modal('show');
backdrop: 'static',
keyboard: false,
});
$(modal).modal('show');
} }
@ -586,22 +587,15 @@ function showQuestionDialog(title, content, options={}) {
* cancel - Functino to run if the user presses 'Cancel' * cancel - Functino to run if the user presses 'Cancel'
*/ */
var modal = options.modal || '#modal-question-dialog'; var modal = createNewModal({
title: title,
$(modal).on('shown.bs.modal', function() { submitText: options.accept_text || '{% trans "Accept" %}',
$(modal + ' .modal-form-content').scrollTop(0); cancelText: options.cancel_text || '{% trans "Cancel" %}',
}); });
modalSetTitle(modal, title);
modalSetContent(modal, content); modalSetContent(modal, content);
var accept_text = options.accept_text || '{% trans "Accept" %}'; $(modal).on('click', "#modal-form-submit", function() {
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).modal('hide'); $(modal).modal('hide');
if (options.accept) { 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'); $(modal).modal('show');
} }

View File

@ -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) { function removeStockRow(e) {
// Remove a selected row from a stock modal form // 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) { function loadStockTable(table, options) {
/* Load data into a stock table with adjustable options. /* Load data into a stock table with adjustable options.
* Fetches data (via AJAX) and loads into a bootstrap table. * Fetches data (via AJAX) and loads into a bootstrap table.
@ -271,56 +632,6 @@ function loadStockTable(table, options) {
filters[key] = params[key]; 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; var grouping = true;
if ('grouping' in options) { if ('grouping' in options) {
@ -741,39 +1052,15 @@ function loadStockTable(table, options) {
] ]
); );
function stockAdjustment(action) { function stockAdjustment(action) {
var items = $("#stock-table").bootstrapTable("getSelections"); var items = $("#stock-table").bootstrapTable("getSelections");
var stock = []; adjustStock(action, items, {
onSuccess: function() {
items.forEach(function(item) { $('#stock-table').bootstrapTable('refresh');
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,
} }
); });
} }
// Automatically link button callbacks // Automatically link button callbacks

View File

@ -56,46 +56,4 @@
</div> </div>
</div> </div>
</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">&times;</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">&times;</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>