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 */
}
.notes {
border-radius: 5px;
background-color: #fafafa;
padding: 5px;
}
.alert {
display: none;
border-radius: 5px;
@ -853,6 +859,11 @@ input[type="submit"] {
margin-right: 2px;
}
.btn-small {
padding: 3px;
padding-left: 5px;
}
.btn-remove {
padding: 3px;
padding-left: 5px;

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,6 @@ from django.utils.translation import ugettext_lazy as _
import django.forms
from .models import Company
from .models import ManufacturerPart
from .models import SupplierPart
from .models import SupplierPriceBreak
@ -35,25 +34,6 @@ class CompanyImageDownloadForm(HelperForm):
]
class EditManufacturerPartForm(HelperForm):
""" Form for editing a ManufacturerPart object """
field_prefix = {
'link': 'fa-link',
'MPN': 'fa-hashtag',
}
class Meta:
model = ManufacturerPart
fields = [
'part',
'manufacturer',
'MPN',
'description',
'link',
]
class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """

View File

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

View File

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

View File

@ -62,7 +62,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{% trans "Internal Part" %}</td>
<td>
{% if part.part %}
<a href="{% url 'part-manufacturers' part.part.id %}">{{ part.part.full_name }}</a>{% include "clip.html"%}
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>{% include "clip.html"%}
{% endif %}
</td>
</tr>
@ -118,9 +118,13 @@ $('#edit-part').click(function () {
fields: {
part: {},
manufacturer: {},
MPN: {},
MPN: {
icon: 'fa-hashtag',
},
description: {},
link: {},
link: {
icon: 'fa-link',
},
},
title: '{% trans "Edit Manufacturer Part" %}',
reload: true,

View File

@ -18,7 +18,7 @@
<td>{% trans "Internal Part" %}</td>
<td>
{% if part.part %}
<a href="{% url 'part-manufacturers' part.part.id %}">{{ part.part.full_name }}</a>
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>
{% endif %}
</td>
</tr>

View File

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

View File

@ -194,45 +194,6 @@ class ManufacturerPartViewTests(CompanyViewTestBase):
Tests for the ManufacturerPart views.
"""
def test_manufacturer_part_create(self):
"""
Test the ManufacturerPartCreate view.
"""
url = reverse('manufacturer-part-create')
# First check that we can GET the form
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# How many manufaturer parts are already in the database?
n = ManufacturerPart.objects.all().count()
data = {
'part': 1,
'manufacturer': 6,
}
# MPN is required! (form should fail)
(response, errors) = self.post(url, data, valid=False)
self.assertIsNotNone(errors.get('MPN', None))
data['MPN'] = 'TEST-ME-123'
(response, errors) = self.post(url, data, valid=True)
# Check that the ManufacturerPart was created!
self.assertEqual(n + 1, ManufacturerPart.objects.all().count())
# Try to create duplicate ManufacturerPart
(response, errors) = self.post(url, data, valid=False)
self.assertIsNotNone(errors.get('__all__', None))
# Check that the ManufacturerPart count stayed the same
self.assertEqual(n + 1, ManufacturerPart.objects.all().count())
def test_supplier_part_create(self):
"""
Test that the SupplierPartCreate view creates Manufacturer Part.

View File

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

View File

@ -29,7 +29,6 @@ from .models import SupplierPart
from part.models import Part
from .forms import EditManufacturerPartForm
from .forms import EditSupplierPartForm
from .forms import CompanyImageDownloadForm
@ -242,74 +241,6 @@ class ManufacturerPartDetail(DetailView):
return ctx
class ManufacturerPartCreate(AjaxCreateView):
""" Create view for making new ManufacturerPart """
model = ManufacturerPart
form_class = EditManufacturerPartForm
ajax_template_name = 'company/manufacturer_part_create.html'
ajax_form_title = _('Create New Manufacturer Part')
context_object_name = 'part'
def get_context_data(self):
"""
Supply context data to the form
"""
ctx = super().get_context_data()
# Add 'part' object
form = self.get_form()
part = form['part'].value()
try:
part = Part.objects.get(pk=part)
except (ValueError, Part.DoesNotExist):
part = None
ctx['part'] = part
return ctx
def get_form(self):
""" Create Form instance to create a new ManufacturerPart object.
Hide some fields if they are not appropriate in context
"""
form = super(AjaxCreateView, self).get_form()
if form.initial.get('part', None):
# Hide the part field
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
""" Provide initial data for new ManufacturerPart:
- If 'manufacturer_id' provided, pre-fill manufacturer field
- If 'part_id' provided, pre-fill part field
"""
initials = super(ManufacturerPartCreate, self).get_initial().copy()
manufacturer_id = self.get_param('manufacturer')
part_id = self.get_param('part')
if manufacturer_id:
try:
initials['manufacturer'] = Company.objects.get(pk=manufacturer_id)
except (ValueError, Company.DoesNotExist):
pass
if part_id:
try:
initials['part'] = Part.objects.get(pk=part_id)
except (ValueError, Part.DoesNotExist):
pass
return initials
class SupplierPartDetail(DetailView):
""" Detail view for SupplierPart """
model = SupplierPart

View File

@ -3,14 +3,9 @@ Functionality for Bill of Material (BOM) management.
Primarily BOM upload tools.
"""
from rapidfuzz import fuzz
import tablib
import os
from collections import OrderedDict
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from InvenTree.helpers import DownloadFile, GetExportFormats
@ -145,11 +140,16 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
stock_data = []
# Get part default location
try:
stock_data.append(bom_item.sub_part.get_default_location().name)
loc = bom_item.sub_part.get_default_location()
if loc is not None:
stock_data.append(str(loc.name))
else:
stock_data.append('')
except AttributeError:
stock_data.append('')
# Get part current stock
stock_data.append(bom_item.sub_part.available_stock)
stock_data.append(str(bom_item.sub_part.available_stock))
for s_idx, header in enumerate(stock_headers):
try:
@ -323,177 +323,6 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
data = dataset.export(fmt)
filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt)
filename = f"{part.full_name}_BOM.{fmt}"
return DownloadFile(data, filename)
class BomUploadManager:
""" Class for managing an uploaded BOM file """
# Fields which are absolutely necessary for valid upload
REQUIRED_HEADERS = [
'Quantity'
]
# Fields which are used for part matching (only one of them is needed)
PART_MATCH_HEADERS = [
'Part_Name',
'Part_IPN',
'Part_ID',
]
# Fields which would be helpful but are not required
OPTIONAL_HEADERS = [
'Reference',
'Note',
'Overage',
]
EDITABLE_HEADERS = [
'Reference',
'Note',
'Overage'
]
HEADERS = REQUIRED_HEADERS + PART_MATCH_HEADERS + OPTIONAL_HEADERS
def __init__(self, bom_file):
""" Initialize the BomUpload class with a user-uploaded file object """
self.process(bom_file)
def process(self, bom_file):
""" Process a BOM file """
self.data = None
ext = os.path.splitext(bom_file.name)[-1].lower()
if ext in ['.csv', '.tsv', ]:
# These file formats need string decoding
raw_data = bom_file.read().decode('utf-8')
elif ext in ['.xls', '.xlsx']:
raw_data = bom_file.read()
else:
raise ValidationError({'bom_file': _('Unsupported file format: {f}').format(f=ext)})
try:
self.data = tablib.Dataset().load(raw_data)
except tablib.UnsupportedFormat:
raise ValidationError({'bom_file': _('Error reading BOM file (invalid data)')})
except tablib.core.InvalidDimensions:
raise ValidationError({'bom_file': _('Error reading BOM file (incorrect row size)')})
def guess_header(self, header, threshold=80):
""" Try to match a header (from the file) to a list of known headers
Args:
header - Header name to look for
threshold - Match threshold for fuzzy search
"""
# Try for an exact match
for h in self.HEADERS:
if h == header:
return h
# Try for a case-insensitive match
for h in self.HEADERS:
if h.lower() == header.lower():
return h
# Try for a case-insensitive match with space replacement
for h in self.HEADERS:
if h.lower() == header.lower().replace(' ', '_'):
return h
# Finally, look for a close match using fuzzy matching
matches = []
for h in self.HEADERS:
ratio = fuzz.partial_ratio(header, h)
if ratio > threshold:
matches.append({'header': h, 'match': ratio})
if len(matches) > 0:
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
return matches[0]['header']
return None
def columns(self):
""" Return a list of headers for the thingy """
headers = []
for header in self.data.headers:
headers.append({
'name': header,
'guess': self.guess_header(header)
})
return headers
def col_count(self):
if self.data is None:
return 0
return len(self.data.headers)
def row_count(self):
""" Return the number of rows in the file. """
if self.data is None:
return 0
return len(self.data)
def rows(self):
""" Return a list of all rows """
rows = []
for i in range(self.row_count()):
data = [item for item in self.get_row_data(i)]
# Is the row completely empty? Skip!
empty = True
for idx, item in enumerate(data):
if len(str(item).strip()) > 0:
empty = False
try:
# Excel import casts number-looking-items into floats, which is annoying
if item == int(item) and not str(item) == str(int(item)):
data[idx] = int(item)
except ValueError:
pass
# Skip empty rows
if empty:
continue
row = {
'data': data,
'index': i
}
rows.append(row)
return rows
def get_row_data(self, index):
""" Retrieve row data at a particular index """
if self.data is None or index >= len(self.data):
return None
return self.data[index]
def get_row_dict(self, index):
""" Retrieve a dict object representing the data row at a particular offset """
if self.data is None or index >= len(self.data):
return None
return self.data.dict[index]

View File

@ -11,10 +11,11 @@ from django.utils.translation import ugettext_lazy as _
from mptt.fields import TreeNodeChoiceField
from InvenTree.forms import HelperForm
from InvenTree.helpers import GetExportFormats
from InvenTree.helpers import GetExportFormats, clean_decimal
from InvenTree.fields import RoundingDecimalFormField
import common.models
from common.forms import MatchItemForm
from .models import Part, PartCategory, PartRelated
from .models import BomItem
@ -55,16 +56,6 @@ class PartImageDownloadForm(HelperForm):
]
class PartImageForm(HelperForm):
""" Form for uploading a Part image """
class Meta:
model = Part
fields = [
'image',
]
class BomExportForm(forms.Form):
""" Simple form to let user set BOM export options,
before exporting a BOM (bill of materials) file.
@ -143,16 +134,28 @@ class BomValidateForm(HelperForm):
]
class BomUploadSelectFile(HelperForm):
""" Form for importing a BOM. Provides a file input box for upload """
class BomMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """
bom_file = forms.FileField(label=_('BOM file'), required=True, help_text=_("Select BOM file to upload"))
def get_special_field(self, col_guess, row, file_manager):
""" Set special fields """
class Meta:
model = Part
fields = [
'bom_file',
]
# set quantity field
if 'quantity' in col_guess.lower():
return forms.CharField(
required=False,
widget=forms.NumberInput(attrs={
'name': 'quantity' + str(row['index']),
'class': 'numberinput',
'type': 'number',
'min': '0',
'step': 'any',
'value': clean_decimal(row.get('quantity', '')),
})
)
# return default
return super().get_special_field(col_guess, row, file_manager)
class CreatePartRelatedForm(HelperForm):

View File

@ -30,7 +30,7 @@ from mptt.models import TreeForeignKey, MPTTModel
from stdimage.models import StdImageField
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from datetime import datetime
from rapidfuzz import fuzz
import hashlib
@ -2418,6 +2418,15 @@ class BomItem(models.Model):
- If the "sub_part" is trackable, then the "part" must be trackable too!
"""
super().clean()
try:
self.quantity = Decimal(self.quantity)
except InvalidOperation:
raise ValidationError({
'quantity': _('Must be a valid number')
})
try:
# Check for circular BOM references
if self.sub_part:

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 %}
{% block details %}
{% if roles.part.change != True and editing_enabled %}
<div class='alert alert-danger alert-block'>
{% trans "You do not have permission to edit the BOM." %}
</div>
{% else %}
{% if part.bom_checked_date %}
{% if part.is_bom_valid %}
<div class='alert alert-block alert-info'>
@ -72,6 +78,7 @@
<table class='table table-bom table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
</table>
{% endif %}
{% endblock %}

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 %}
{% block heading %}
{% trans "Upload Bill of Materials" %}
{% trans "Upload BOM File" %}
{% endblock %}
{% block details %}
<p>{% trans "Step 1 - Select BOM File" %}</p>
{% block form_alert %}
<div class='alert alert-info alert-block'>
<b>{% trans "Requirements for BOM upload" %}:</b>
<ul>
@ -22,16 +21,31 @@
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div>
{% endblock %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
<button type="submit" class="save btn btn-default">{% trans 'Upload File' %}</button>
{% csrf_token %}
{% load crispy_forms_tags %}
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
<input type='hidden' name='form_step' value='select_file'/>
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
{% crispy form %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
{% endblock %}

View File

@ -1,6 +1,7 @@
{% extends "part/part_base.html" %}
{% load static %}
{% load i18n %}
{% load markdownify %}
{% block menubar %}
@ -135,11 +136,38 @@
{% endif %}
{% if part.responsible %}
<tr>
<td><span class='fas fa-user'>d</span></td>
<td><span class='fas fa-user'></span></td>
<td><strong>{% trans "Responsible User" %}</strong></td>
<td>{{ part.responsible }}</td>
</tr>
{% endif %}
<tr><td colspan="3"></td></tr>
<tr>
<td><span class='fas fa-sticky-note'></span></td>
<td>
<strong>{% trans "Notes" %}</strong>
</td>
<td>
<div class='btn-group float-right'>
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-small btn-default'>
<span class='fas fa-edit'>
</span>
</button>
</div>
</td>
</tr>
<tr>
<td colspan='3'>
{% if part.notes %}
<div class='notes'>
{{ part.notes | markdownify }}
</div>
{% endif %}
</td>
</tr>
</table>
</div>
<div class='col-sm-6'>
@ -238,6 +266,42 @@
{% endblock %}
{% block post_content_panel %}
<div class='row'>
<div class='col-sm-6'>
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>{% trans "Parameters" %}</h4>
</div>
<div class='panel-content'>
<div id='param-button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
{% if roles.part.add %}
<button title='{% trans "Add new parameter" %}' class='btn btn-success' id='param-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button>
{% endif %}
</div>
</div>
<table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#param-button-toolbar"></table>
</div>
</div>
</div>
<div class='col-sm-6'>
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>{% trans "Attachments" %}</h4>
</div>
<div class='panel-content'>
{% include "attachment_table.html" %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block js_load %}
{{ block.super }}
{% endblock %}
@ -245,6 +309,18 @@
{% block js_ready %}
{{ block.super }}
$('#edit-notes').click(function() {
constructForm('{% url "api-part-detail" part.pk %}', {
fields: {
notes: {
multiline: true,
}
},
title: '{% trans "Edit Part Notes" %}',
reload: true,
});
});
$(".slidey").change(function() {
var field = $(this).attr('fieldname');
@ -263,4 +339,118 @@
);
});
loadPartParameterTable(
'#parameter-table',
'{% url "api-part-parameter-list" %}',
{
params: {
part: {{ part.pk }},
}
}
);
$('#param-table').inventreeTable({
});
{% if roles.part.add %}
$('#param-create').click(function() {
constructForm('{% url "api-part-parameter-list" %}', {
method: 'POST',
fields: {
part: {
value: {{ part.pk }},
hidden: true,
},
template: {},
data: {},
},
title: '{% trans "Add Parameter" %}',
onSuccess: function() {
$('#parameter-table').bootstrapTable('refresh');
}
});
});
{% endif %}
$('.param-edit').click(function() {
var button = $(this);
launchModalForm(button.attr('url'), {
reload: true,
});
});
$('.param-delete').click(function() {
var button = $(this);
launchModalForm(button.attr('url'), {
reload: true,
});
});
loadAttachmentTable(
'{% url "api-part-attachment-list" %}',
{
filters: {
part: {{ part.pk }},
},
onEdit: function(pk) {
var url = `/api/part/attachment/${pk}/`;
constructForm(url, {
fields: {
comment: {},
},
title: '{% trans "Edit Attachment" %}',
onSuccess: reloadAttachmentTable,
});
},
onDelete: function(pk) {
var url = `/api/part/attachment/${pk}/`;
constructForm(url, {
method: 'DELETE',
confirmMessage: '{% trans "Confirm Delete Operation" %}',
title: '{% trans "Delete Attachment" %}',
onSuccess: reloadAttachmentTable,
});
}
}
);
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-part-attachment-list" %}',
{
data: {
part: {{ part.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
$("#new-attachment").click(function() {
constructForm(
'{% url "api-part-attachment-list" %}',
{
method: 'POST',
fields: {
attachment: {},
comment: {},
part: {
value: {{ part.pk }},
hidden: true,
}
},
onSuccess: reloadAttachmentTable,
title: '{% trans "Add Attachment" %}',
}
)
});
{% endblock %}

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

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'/>
</button>
{% if roles.stock.change %}
<button type='button' class='btn btn-default' id='part-count' title='{% trans "Count part stock" %}'>
<span class='fas fa-clipboard-list'/>
</button>
<div class='btn-group'>
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-boxes'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li>
<a href='#' id='part-count'>
<span class='fas fa-clipboard-list'></span>
{% trans "Count part stock" %}
</a>
</li>
<li>
<a href='#' id='part-move'>
<span class='fas fa-exchange-alt'></span>
{% trans "Transfer part stock" %}
</a>
</li>
</ul>
</div>
{% endif %}
{% if part.purchaseable %}
{% if roles.purchase_order.add %}
@ -272,14 +288,34 @@
printPartLabels([{{ part.pk }}]);
});
$("#part-count").click(function() {
launchModalForm("/stock/adjust/", {
data: {
action: "count",
function adjustPartStock(action) {
inventreeGet(
'{% url "api-stock-list" %}',
{
part: {{ part.id }},
in_stock: true,
allow_variants: true,
part_detail: true,
location_detail: true,
},
reload: true,
});
{
success: function(items) {
adjustStock(action, items, {
onSuccess: function() {
location.reload();
}
});
},
}
);
}
$("#part-move").click(function() {
adjustPartStock('move');
});
$("#part-count").click(function() {
adjustPartStock('count');
});
$("#price-button").click(function() {

View File

@ -6,12 +6,13 @@
{% include 'part/navbar.html' with tab='suppliers' %}
{% endblock %}
{% block heading %}
{% trans "Part Suppliers" %}
{% endblock %}
{% block details %}
<div id='button-toolbar'>
<div id='supplier-button-toolbar'>
<div class='btn-group'>
<button class="btn btn-success" id='supplier-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
@ -25,11 +26,40 @@
</div>
</div>
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#button-toolbar'>
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#supplier-button-toolbar'>
</table>
{% endblock %}
{% block post_content_panel %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>
{% trans "Part Manufacturers" %}
</h4>
</div>
<div class='panel-content'>
<div id='manufacturer-button-toolbar'>
<div class='btn-group'>
<button class="btn btn-success" id='manufacturer-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
</ul>
</div>
</div>
</div>
<table class='table table-condensed table-striped' id='manufacturer-table' data-toolbar='#manufacturer-button-toolbar'></table>
</div>
</div>
{% endblock %}
{% block js_load %}
{{ block.super }}
{% endblock %}
@ -90,6 +120,52 @@
}
);
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options'])
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']);
loadManufacturerPartTable(
'#manufacturer-table',
"{% url 'api-manufacturer-part-list' %}",
{
params: {
part: {{ part.id }},
part_detail: true,
manufacturer_detail: true,
},
}
);
linkButtonsToSelection($("#manufacturer-table"), ['#manufacturer-part-options']);
$("#manufacturer-part-delete").click(function() {
var selections = $("#manufacturer-table").bootstrapTable("getSelections");
deleteManufacturerParts(selections, {
onSuccess: function() {
$("#manufacturer-table").bootstrapTable("refresh");
}
});
});
$('#manufacturer-create').click(function () {
constructForm('{% url "api-manufacturer-part-list" %}', {
fields: {
part: {
value: {{ part.pk }},
hidden: true,
},
manufacturer: {},
MPN: {},
description: {},
link: {},
},
method: 'POST',
title: '{% trans "Add Manufacturer Part" %}',
onSuccess: function() {
$("#manufacturer-table").bootstrapTable("refresh");
}
});
});
{% endblock %}

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-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'),
url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'),
url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'),
url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'),
url(r'^allocation/?', views.PartDetail.as_view(template_name='part/allocation.html'), name='part-allocation'),
@ -56,15 +55,12 @@ part_detail_urls = [
url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'),
url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'),
url(r'^prices/', views.PartPricingView.as_view(template_name='part/prices.html'), name='part-prices'),
url(r'^manufacturers/?', views.PartDetail.as_view(template_name='part/manufacturer.html'), name='part-manufacturers'),
url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'),
url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'),
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'),
url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),

View File

@ -13,7 +13,7 @@ from django.shortcuts import get_object_or_404
from django.shortcuts import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _
from django.urls import reverse, reverse_lazy
from django.views.generic import DetailView, ListView, FormView, UpdateView
from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict
from django.forms import HiddenInput, CheckboxInput
from django.conf import settings
@ -42,13 +42,14 @@ from common.models import InvenTreeSetting
from company.models import SupplierPart
from common.files import FileManager
from common.views import FileManagementFormView, FileManagementAjaxView
from common.forms import UploadFileForm, MatchFieldForm
from stock.models import StockLocation
import common.settings as inventree_settings
from . import forms as part_forms
from .bom import MakeBomTemplate, BomUploadManager, ExportBom, IsValidBOMFormat
from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat
from order.models import PurchaseOrderLineItem
from .admin import PartResource
@ -746,40 +747,6 @@ class PartImportAjax(FileManagementAjaxView, PartImport):
return PartImport.validate(self, self.steps.current, form, **kwargs)
class PartNotes(UpdateView):
""" View for editing the 'notes' field of a Part object.
Presents a live markdown editor.
"""
context_object_name = 'part'
# form_class = part_forms.EditNotesForm
template_name = 'part/notes.html'
model = Part
role_required = 'part.change'
fields = ['notes']
def get_success_url(self):
""" Return the success URL for this form """
return reverse('part-notes', kwargs={'pk': self.get_object().id})
def get_context_data(self, **kwargs):
part = self.get_object()
context = super().get_context_data(**kwargs)
context['editing'] = str2bool(self.request.GET.get('edit', ''))
ctx = part.get_context_data(self.request)
context.update(ctx)
return context
class PartDetail(InvenTreeRoleMixin, DetailView):
""" Detail view for Part object
"""
@ -1245,7 +1212,7 @@ class BomValidate(AjaxUpdateView):
}
class BomUpload(InvenTreeRoleMixin, FormView):
class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
""" View for uploading a BOM file, and handling BOM data importing.
The BOM upload process is as follows:
@ -1272,184 +1239,116 @@ class BomUpload(InvenTreeRoleMixin, FormView):
During these steps, data are passed between the server/client as JSON objects.
"""
template_name = 'part/bom_upload/upload_file.html'
# Context data passed to the forms (initially empty, extracted from uploaded file)
bom_headers = []
bom_columns = []
bom_rows = []
missing_columns = []
allowed_parts = []
role_required = ('part.change', 'part.add')
def get_success_url(self):
part = self.get_object()
return reverse('upload-bom', kwargs={'pk': part.id})
class BomFileManager(FileManager):
# Fields which are absolutely necessary for valid upload
REQUIRED_HEADERS = [
'Quantity'
]
def get_form_class(self):
# Fields which are used for part matching (only one of them is needed)
ITEM_MATCH_HEADERS = [
'Part_Name',
'Part_IPN',
'Part_ID',
]
# Default form is the starting point
return part_forms.BomUploadSelectFile
# Fields which would be helpful but are not required
OPTIONAL_HEADERS = [
'Reference',
'Note',
'Overage',
]
def get_context_data(self, *args, **kwargs):
EDITABLE_HEADERS = [
'Reference',
'Note',
'Overage'
]
ctx = super().get_context_data(*args, **kwargs)
name = 'order'
form_list = [
('upload', UploadFileForm),
('fields', MatchFieldForm),
('items', part_forms.BomMatchItemForm),
]
form_steps_template = [
'part/bom_upload/upload_file.html',
'part/bom_upload/match_fields.html',
'part/bom_upload/match_parts.html',
]
form_steps_description = [
_("Upload File"),
_("Match Fields"),
_("Match Parts"),
]
form_field_map = {
'item_select': 'part',
'quantity': 'quantity',
'overage': 'overage',
'reference': 'reference',
'note': 'note',
}
file_manager_class = BomFileManager
# Give each row item access to the column it is in
# This provides for much simpler template rendering
def get_part(self):
""" Get part or return 404 """
rows = []
for row in self.bom_rows:
row_data = row['data']
return get_object_or_404(Part, pk=self.kwargs['pk'])
data = []
def get_context_data(self, form, **kwargs):
""" Handle context data for order """
for idx, item in enumerate(row_data):
context = super().get_context_data(form=form, **kwargs)
data.append({
'cell': item,
'idx': idx,
'column': self.bom_columns[idx]
})
part = self.get_part()
rows.append({
'index': row.get('index', -1),
'data': data,
'part_match': row.get('part_match', None),
'part_options': row.get('part_options', self.allowed_parts),
context.update({'part': part})
# User-input (passed between client and server)
'quantity': row.get('quantity', None),
'description': row.get('description', ''),
'part_name': row.get('part_name', ''),
'part': row.get('part', None),
'reference': row.get('reference', ''),
'notes': row.get('notes', ''),
'errors': row.get('errors', ''),
})
return context
ctx['part'] = self.part
ctx['bom_headers'] = BomUploadManager.HEADERS
ctx['bom_columns'] = self.bom_columns
ctx['bom_rows'] = rows
ctx['missing_columns'] = self.missing_columns
ctx['allowed_parts_list'] = self.allowed_parts
return ctx
def getAllowedParts(self):
def get_allowed_parts(self):
""" Return a queryset of parts which are allowed to be added to this BOM.
"""
return self.part.get_allowed_bom_items()
return self.get_part().get_allowed_bom_items()
def get(self, request, *args, **kwargs):
""" Perform the initial 'GET' request.
Initially returns a form for file upload """
self.request = request
# A valid Part object must be supplied. This is the 'parent' part for the BOM
self.part = get_object_or_404(Part, pk=self.kwargs['pk'])
self.form = self.get_form()
form_class = self.get_form_class()
form = self.get_form(form_class)
return self.render_to_response(self.get_context_data(form=form))
def handleBomFileUpload(self):
""" Process a BOM file upload form.
This function validates that the uploaded file was valid,
and contains tabulated data that can be extracted.
If the file does not satisfy these requirements,
the "upload file" form is again shown to the user.
"""
bom_file = self.request.FILES.get('bom_file', None)
manager = None
bom_file_valid = False
if bom_file is None:
self.form.add_error('bom_file', _('No BOM file provided'))
else:
# Create a BomUploadManager object - will perform initial data validation
# (and raise a ValidationError if there is something wrong with the file)
try:
manager = BomUploadManager(bom_file)
bom_file_valid = True
except ValidationError as e:
errors = e.error_dict
for k, v in errors.items():
self.form.add_error(k, v)
if bom_file_valid:
# BOM file is valid? Proceed to the next step!
form = None
self.template_name = 'part/bom_upload/select_fields.html'
self.extractDataFromFile(manager)
else:
form = self.form
return self.render_to_response(self.get_context_data(form=form))
def getColumnIndex(self, name):
""" Return the index of the column with the given name.
It named column is not found, return -1
"""
try:
idx = list(self.column_selections.values()).index(name)
except ValueError:
idx = -1
return idx
def preFillSelections(self):
def get_field_selection(self):
""" Once data columns have been selected, attempt to pre-select the proper data from the database.
This function is called once the field selection has been validated.
The pre-fill data are then passed through to the part selection form.
"""
self.allowed_items = self.get_allowed_parts()
# Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database
k_idx = self.getColumnIndex('Part_ID')
p_idx = self.getColumnIndex('Part_Name')
i_idx = self.getColumnIndex('Part_IPN')
k_idx = self.get_column_index('Part_ID')
p_idx = self.get_column_index('Part_Name')
i_idx = self.get_column_index('Part_IPN')
q_idx = self.getColumnIndex('Quantity')
r_idx = self.getColumnIndex('Reference')
o_idx = self.getColumnIndex('Overage')
n_idx = self.getColumnIndex('Note')
q_idx = self.get_column_index('Quantity')
r_idx = self.get_column_index('Reference')
o_idx = self.get_column_index('Overage')
n_idx = self.get_column_index('Note')
for row in self.bom_rows:
for row in self.rows:
"""
Iterate through each row in the uploaded data,
and see if we can match the row to a "Part" object in the database.
There are three potential ways to match, based on the uploaded data:
a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field
b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field
c) Use the name of the part, uploaded in the "Part_Name" field
Notes:
- If using the Part_ID field, we can do an exact match against the PK field
- If using the Part_IPN field, we can do an exact match against the IPN field
- If using the Part_Name field, we can use fuzzy string matching to match "close" values
We also extract other information from the row, for the other non-matched fields:
- Quantity
- Reference
- Overage
- Note
"""
# Initially use a quantity of zero
@ -1459,42 +1358,55 @@ class BomUpload(InvenTreeRoleMixin, FormView):
exact_match_part = None
# A list of potential Part matches
part_options = self.allowed_parts
part_options = self.allowed_items
# Check if there is a column corresponding to "quantity"
if q_idx >= 0:
q_val = row['data'][q_idx]
q_val = row['data'][q_idx]['cell']
if q_val:
# Delete commas
q_val = q_val.replace(',', '')
try:
# Attempt to extract a valid quantity from the field
quantity = Decimal(q_val)
# Store the 'quantity' value
row['quantity'] = quantity
except (ValueError, InvalidOperation):
pass
# Store the 'quantity' value
row['quantity'] = quantity
# Check if there is a column corresponding to "PK"
if k_idx >= 0:
pk = row['data'][k_idx]
pk = row['data'][k_idx]['cell']
if pk:
try:
# Attempt Part lookup based on PK value
exact_match_part = Part.objects.get(pk=pk)
exact_match_part = self.allowed_items.get(pk=pk)
except (ValueError, Part.DoesNotExist):
exact_match_part = None
# Check if there is a column corresponding to "Part Name"
if p_idx >= 0:
part_name = row['data'][p_idx]
# Check if there is a column corresponding to "Part IPN" and no exact match found yet
if i_idx >= 0 and not exact_match_part:
part_ipn = row['data'][i_idx]['cell']
if part_ipn:
part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())]
# Check for single match
if len(part_matches) == 1:
exact_match_part = part_matches[0]
# Check if there is a column corresponding to "Part Name" and no exact match found yet
if p_idx >= 0 and not exact_match_part:
part_name = row['data'][p_idx]['cell']
row['part_name'] = part_name
matches = []
for part in self.allowed_parts:
for part in self.allowed_items:
ratio = fuzz.partial_ratio(part.name + part.description, part_name)
matches.append({'part': part, 'match': ratio})
@ -1503,390 +1415,67 @@ class BomUpload(InvenTreeRoleMixin, FormView):
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
part_options = [m['part'] for m in matches]
# Supply list of part options for each row, sorted by how closely they match the part name
row['item_options'] = part_options
# Check if there is a column corresponding to "Part IPN"
if i_idx >= 0:
row['part_ipn'] = row['data'][i_idx]
# Unless found, the 'item_match' is blank
row['item_match'] = None
if exact_match_part:
# If there is an exact match based on PK or IPN, use that
row['item_match'] = exact_match_part
# Check if there is a column corresponding to "Overage" field
if o_idx >= 0:
row['overage'] = row['data'][o_idx]
row['overage'] = row['data'][o_idx]['cell']
# Check if there is a column corresponding to "Reference" field
if r_idx >= 0:
row['reference'] = row['data'][r_idx]
row['reference'] = row['data'][r_idx]['cell']
# Check if there is a column corresponding to "Note" field
if n_idx >= 0:
row['note'] = row['data'][n_idx]
# Supply list of part options for each row, sorted by how closely they match the part name
row['part_options'] = part_options
# Unless found, the 'part_match' is blank
row['part_match'] = None
if exact_match_part:
# If there is an exact match based on PK, use that
row['part_match'] = exact_match_part
else:
# Otherwise, check to see if there is a matching IPN
try:
if row['part_ipn']:
part_matches = [part for part in self.allowed_parts if part.IPN and row['part_ipn'].lower() == str(part.IPN.lower())]
# Check for single match
if len(part_matches) == 1:
row['part_match'] = part_matches[0]
continue
except KeyError:
pass
def extractDataFromFile(self, bom):
""" Read data from the BOM file """
self.bom_columns = bom.columns()
self.bom_rows = bom.rows()
def getTableDataFromPost(self):
""" Extract table cell data from POST request.
These data are used to maintain state between sessions.
Table data keys are as follows:
col_name_<idx> - Column name at idx as provided in the uploaded file
col_guess_<idx> - Column guess at idx as selected in the BOM
row_<x>_col<y> - Cell data as provided in the uploaded file
"""
# Map the columns
self.column_names = {}
self.column_selections = {}
self.row_data = {}
for item in self.request.POST:
value = self.request.POST[item]
# Column names as passed as col_name_<idx> where idx is an integer
# Extract the column names
if item.startswith('col_name_'):
try:
col_id = int(item.replace('col_name_', ''))
except ValueError:
continue
col_name = value
self.column_names[col_id] = col_name
# Extract the column selections (in the 'select fields' view)
if item.startswith('col_guess_'):
try:
col_id = int(item.replace('col_guess_', ''))
except ValueError:
continue
col_name = value
self.column_selections[col_id] = value
# Extract the row data
if item.startswith('row_'):
# Item should be of the format row_<r>_col_<c>
s = item.split('_')
if len(s) < 4:
continue
# Ignore row/col IDs which are not correct numeric values
try:
row_id = int(s[1])
col_id = int(s[3])
except ValueError:
continue
if row_id not in self.row_data:
self.row_data[row_id] = {}
self.row_data[row_id][col_id] = value
self.col_ids = sorted(self.column_names.keys())
# Re-construct the data table
self.bom_rows = []
for row_idx in sorted(self.row_data.keys()):
row = self.row_data[row_idx]
items = []
for col_idx in sorted(row.keys()):
value = row[col_idx]
items.append(value)
self.bom_rows.append({
'index': row_idx,
'data': items,
'errors': {},
})
# Construct the column data
self.bom_columns = []
# Track any duplicate column selections
self.duplicates = False
for col in self.col_ids:
if col in self.column_selections:
guess = self.column_selections[col]
else:
guess = None
header = ({
'name': self.column_names[col],
'guess': guess
})
if guess:
n = list(self.column_selections.values()).count(self.column_selections[col])
if n > 1:
header['duplicate'] = True
self.duplicates = True
self.bom_columns.append(header)
# Are there any missing columns?
self.missing_columns = []
# Check that all required fields are present
for col in BomUploadManager.REQUIRED_HEADERS:
if col not in self.column_selections.values():
self.missing_columns.append(col)
# Check that at least one of the part match field is present
part_match_found = False
for col in BomUploadManager.PART_MATCH_HEADERS:
if col in self.column_selections.values():
part_match_found = True
break
# If not, notify user
if not part_match_found:
for col in BomUploadManager.PART_MATCH_HEADERS:
self.missing_columns.append(col)
def handleFieldSelection(self):
""" Handle the output of the field selection form.
Here the user is presented with the raw data and must select the
column names and which rows to process.
"""
# Extract POST data
self.getTableDataFromPost()
valid = len(self.missing_columns) == 0 and not self.duplicates
if valid:
# Try to extract meaningful data
self.preFillSelections()
self.template_name = 'part/bom_upload/select_parts.html'
else:
self.template_name = 'part/bom_upload/select_fields.html'
return self.render_to_response(self.get_context_data(form=None))
def handlePartSelection(self):
# Extract basic table data from POST request
self.getTableDataFromPost()
# Keep track of the parts that have been selected
parts = {}
# Extract other data (part selections, etc)
for key in self.request.POST:
value = self.request.POST[key]
# Extract quantity from each row
if key.startswith('quantity_'):
try:
row_id = int(key.replace('quantity_', ''))
row = self.getRowByIndex(row_id)
if row is None:
continue
q = Decimal(1)
try:
q = Decimal(value)
if q < 0:
row['errors']['quantity'] = _('Quantity must be greater than zero')
if 'part' in row.keys():
if row['part'].trackable:
# Trackable parts must use integer quantities
if not q == int(q):
row['errors']['quantity'] = _('Quantity must be integer value for trackable parts')
except (ValueError, InvalidOperation):
row['errors']['quantity'] = _('Enter a valid quantity')
row['quantity'] = q
except ValueError:
continue
# Extract part from each row
if key.startswith('part_'):
try:
row_id = int(key.replace('part_', ''))
row = self.getRowByIndex(row_id)
if row is None:
continue
except ValueError:
# Row ID non integer value
continue
try:
part_id = int(value)
part = Part.objects.get(id=part_id)
except ValueError:
row['errors']['part'] = _('Select valid part')
continue
except Part.DoesNotExist:
row['errors']['part'] = _('Select valid part')
continue
# Keep track of how many of each part we have seen
if part_id in parts:
parts[part_id]['quantity'] += 1
row['errors']['part'] = _('Duplicate part selected')
else:
parts[part_id] = {
'part': part,
'quantity': 1,
}
row['part'] = part
if part.trackable:
# For trackable parts, ensure the quantity is an integer value!
if 'quantity' in row.keys():
q = row['quantity']
if not q == int(q):
row['errors']['quantity'] = _('Quantity must be integer value for trackable parts')
# Extract other fields which do not require further validation
for field in ['reference', 'notes']:
if key.startswith(field + '_'):
try:
row_id = int(key.replace(field + '_', ''))
row = self.getRowByIndex(row_id)
if row:
row[field] = value
except:
continue
# Are there any errors after form handling?
valid = True
for row in self.bom_rows:
# Has a part been selected for the given row?
part = row.get('part', None)
if part is None:
row['errors']['part'] = _('Select a part')
else:
# Will the selected part result in a recursive BOM?
try:
part.checkAddToBOM(self.part)
except ValidationError:
row['errors']['part'] = _('Selected part creates a circular BOM')
# Has a quantity been specified?
if row.get('quantity', None) is None:
row['errors']['quantity'] = _('Specify quantity')
errors = row.get('errors', [])
if len(errors) > 0:
valid = False
self.template_name = 'part/bom_upload/select_parts.html'
ctx = self.get_context_data(form=None)
if valid:
self.part.clear_bom()
# Generate new BOM items
for row in self.bom_rows:
part = row.get('part')
quantity = row.get('quantity')
reference = row.get('reference', '')
notes = row.get('notes', '')
# Create a new BOM item!
item = BomItem(
part=self.part,
sub_part=part,
quantity=quantity,
reference=reference,
note=notes
)
row['note'] = row['data'][n_idx]['cell']
def done(self, form_list, **kwargs):
""" Once all the data is in, process it to add BomItem instances to the part """
self.part = self.get_part()
items = self.get_clean_items()
# Clear BOM
self.part.clear_bom()
# Generate new BOM items
for bom_item in items.values():
try:
part = Part.objects.get(pk=int(bom_item.get('part')))
except (ValueError, Part.DoesNotExist):
continue
quantity = bom_item.get('quantity')
overage = bom_item.get('overage', '')
reference = bom_item.get('reference', '')
note = bom_item.get('note', '')
# Create a new BOM item
item = BomItem(
part=self.part,
sub_part=part,
quantity=quantity,
overage=overage,
reference=reference,
note=note,
)
try:
item.save()
except IntegrityError:
# BomItem already exists
pass
# Redirect to the BOM view
return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.part.id}))
else:
ctx['form_errors'] = True
return self.render_to_response(ctx)
def getRowByIndex(self, idx):
for row in self.bom_rows:
if row['index'] == idx:
return row
return None
def post(self, request, *args, **kwargs):
""" Perform the various 'POST' requests required.
"""
self.request = request
self.part = get_object_or_404(Part, pk=self.kwargs['pk'])
self.allowed_parts = self.getAllowedParts()
self.form = self.get_form(self.get_form_class())
# Did the user POST a file named bom_file?
form_step = request.POST.get('form_step', None)
if form_step == 'select_file':
return self.handleBomFileUpload()
elif form_step == 'select_fields':
return self.handleFieldSelection()
elif form_step == 'select_parts':
return self.handlePartSelection()
return self.render_to_response(self.get_context_data(form=self.form))
return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.kwargs['pk']}))
class PartExport(AjaxView):

View File

@ -120,9 +120,6 @@ class StockAdjust(APIView):
- StockAdd: add stock items
- StockRemove: remove stock items
- StockTransfer: transfer stock items
# TODO - This needs serious refactoring!!!
"""
queryset = StockItem.objects.none()
@ -143,7 +140,10 @@ class StockAdjust(APIView):
elif 'items' in request.data:
_items = request.data['items']
else:
raise ValidationError({'items': 'Request must contain list of stock items'})
_items = []
if len(_items) == 0:
raise ValidationError(_('Request must contain list of stock items'))
# List of validated items
self.items = []
@ -151,13 +151,22 @@ class StockAdjust(APIView):
for entry in _items:
if not type(entry) == dict:
raise ValidationError({'error': 'Improperly formatted data'})
raise ValidationError(_('Improperly formatted data'))
# Look for a 'pk' value (use 'id' as a backup)
pk = entry.get('pk', entry.get('id', None))
try:
pk = int(pk)
except (ValueError, TypeError):
raise ValidationError(_('Each entry must contain a valid integer primary-key'))
try:
pk = entry.get('pk', None)
item = StockItem.objects.get(pk=pk)
except (ValueError, StockItem.DoesNotExist):
raise ValidationError({'pk': 'Each entry must contain a valid pk field'})
except (StockItem.DoesNotExist):
raise ValidationError({
pk: [_('Primary key does not match valid stock item')]
})
if self.allow_missing_quantity and 'quantity' not in entry:
entry['quantity'] = item.quantity
@ -165,16 +174,21 @@ class StockAdjust(APIView):
try:
quantity = Decimal(str(entry.get('quantity', None)))
except (ValueError, TypeError, InvalidOperation):
raise ValidationError({'quantity': "Each entry must contain a valid quantity value"})
raise ValidationError({
pk: [_('Invalid quantity value')]
})
if quantity < 0:
raise ValidationError({'quantity': 'Quantity field must not be less than zero'})
raise ValidationError({
pk: [_('Quantity must not be less than zero')]
})
self.items.append({
'item': item,
'quantity': quantity
})
# Extract 'notes' field
self.notes = str(request.data.get('notes', ''))
@ -228,6 +242,11 @@ class StockRemove(StockAdjust):
for item in self.items:
if item['quantity'] > item['item'].quantity:
raise ValidationError({
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
})
if item['item'].take_stock(item['quantity'], request.user, notes=self.notes):
n += 1
@ -243,19 +262,24 @@ class StockTransfer(StockAdjust):
def post(self, request, *args, **kwargs):
self.get_items(request)
data = request.data
try:
location = StockLocation.objects.get(pk=data.get('location', None))
except (ValueError, StockLocation.DoesNotExist):
raise ValidationError({'location': 'Valid location must be specified'})
raise ValidationError({'location': [_('Valid location must be specified')]})
n = 0
self.get_items(request)
for item in self.items:
if item['quantity'] > item['item'].quantity:
raise ValidationError({
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
})
# If quantity is not specified, move the entire stock
if item['quantity'] in [0, None]:
item['quantity'] = item['item'].quantity
@ -454,13 +478,6 @@ class StockList(generics.ListCreateAPIView):
- GET: Return a list of all StockItem objects (with optional query filters)
- POST: Create a new StockItem
Additional query parameters are available:
- location: Filter stock by location
- category: Filter by parts belonging to a certain category
- supplier: Filter by supplier
- ancestor: Filter by an 'ancestor' StockItem
- status: Filter by the StockItem status
"""
serializer_class = StockItemSerializer
@ -482,7 +499,6 @@ class StockList(generics.ListCreateAPIView):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# TODO - Save the user who created this item
item = serializer.save()
# A location was *not* specified - try to infer it
@ -1092,47 +1108,41 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = LocationSerializer
stock_endpoints = [
url(r'^$', StockDetail.as_view(), name='api-stock-detail'),
]
location_endpoints = [
url(r'^(?P<pk>\d+)/', LocationDetail.as_view(), name='api-location-detail'),
url(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
]
stock_api_urls = [
url(r'location/', include(location_endpoints)),
url(r'^location/', include([
url(r'^(?P<pk>\d+)/', LocationDetail.as_view(), name='api-location-detail'),
url(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
])),
# These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019
# TODO: Remove server-side forms for stock adjustment!!!
url(r'count/?', StockCount.as_view(), name='api-stock-count'),
url(r'add/?', StockAdd.as_view(), name='api-stock-add'),
url(r'remove/?', StockRemove.as_view(), name='api-stock-remove'),
url(r'transfer/?', StockTransfer.as_view(), name='api-stock-transfer'),
# Endpoints for bulk stock adjustment actions
url(r'^count/', StockCount.as_view(), name='api-stock-count'),
url(r'^add/', StockAdd.as_view(), name='api-stock-add'),
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
# Base URL for StockItemAttachment API endpoints
# StockItemAttachment API endpoints
url(r'^attachment/', include([
url(r'^(?P<pk>\d+)/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'),
url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'),
])),
# Base URL for StockItemTestResult API endpoints
# StockItemTestResult API endpoints
url(r'^test/', include([
url(r'^(?P<pk>\d+)/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'),
url(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'),
])),
# StockItemTracking API endpoints
url(r'^track/', include([
url(r'^(?P<pk>\d+)/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'),
url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
])),
url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'),
url(r'^tree/', StockCategoryTree.as_view(), name='api-stock-tree'),
# Detail for a single stock item
url(r'^(?P<pk>\d+)/', include(stock_endpoints)),
url(r'^(?P<pk>\d+)/', StockDetail.as_view(), name='api-stock-detail'),
# Anything else
url(r'^.*$', StockList.as_view(), name='api-stock-list'),
]

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):
""" Form for editing a StockItem object.
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) {
launchModalForm("/stock/adjust/",
inventreeGet(
'{% url "api-stock-detail" item.pk %}',
{
data: {
action: action,
item: {{ item.id }},
},
reload: true,
follow: true,
part_detail: true,
location_detail: true,
},
{
success: function(item) {
adjustStock(action, [item], {
onSuccess: function() {
location.reload();
}
});
}
}
);
}

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 roles.stock.change %}
<div class='btn-group'>
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-boxes'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='location-count'><span class='fas fa-clipboard-list'></span>
{% trans "Count stock" %}</a></li>
</ul>
<li>
<a href='#' id='location-count'>
<span class='fas fa-clipboard-list'></span>
{% trans "Count stock" %}
</a>
</li>
<li>
<a href='#' id='location-move'>
<span class='fas fa-exchange-alt'></span>
{% trans "Transfer stock" %}
</a>
</li>
</ul>
</div>
{% endif %}
{% if roles.stock_location.change %}
@ -215,14 +227,34 @@
});
{% if location %}
$("#location-count").click(function() {
launchModalForm("/stock/adjust/", {
data: {
action: "count",
function adjustLocationStock(action) {
inventreeGet(
'{% url "api-stock-list" %}',
{
location: {{ location.id }},
reload: true,
in_stock: true,
part_detail: true,
location_detail: true,
},
{
success: function(items) {
adjustStock(action, items, {
onSuccess: function() {
location.reload();
}
});
}
}
});
);
}
$("#location-count").click(function() {
adjustLocationStock('count');
});
$("#location-move").click(function() {
adjustLocationStock('move');
});
$('#print-label').click(function() {

View File

@ -7,8 +7,8 @@ from __future__ import unicode_literals
from datetime import datetime, timedelta
from rest_framework import status
from django.urls import reverse
from rest_framework import status
from InvenTree.status_codes import StockStatus
from InvenTree.api_tester import InvenTreeAPITestCase
@ -456,30 +456,32 @@ class StocktakeTest(StockAPITestCase):
# POST without a PK
response = self.post(url, data)
self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST)
# POST with a PK but no quantity
# POST with an invalid PK
data['items'] = [{
'pk': 10
}]
response = self.post(url, data)
self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST)
# POST with missing quantity value
data['items'] = [{
'pk': 1234
}]
response = self.post(url, data)
self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
# POST with an invalid quantity value
data['items'] = [{
'pk': 1234,
'quantity': '10x0d'
}]
response = self.post(url, data)
self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
data['items'] = [{
'pk': 1234,

View File

@ -105,31 +105,6 @@ class StockItemTest(StockViewTestCase):
response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_adjust_items(self):
url = reverse('stock-adjust')
# Move items
response = self.client.get(url, {'stock[]': [1, 2, 3, 4, 5], 'action': 'move'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Count part
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Remove items
response = self.client.get(url, {'location': 1, 'action': 'take'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Add items
response = self.client.get(url, {'item': 1, 'action': 'add'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Blank response
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# TODO - Tests for POST data
def test_edit_item(self):
# Test edit view for StockItem
response = self.client.get(reverse('stock-item-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')

View File

@ -64,8 +64,6 @@ stock_urls = [
url(r'^track/', include(stock_tracking_urls)),
url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),
url(r'^export-options/?', views.StockExportOptions.as_view(), name='stock-export-options'),
url(r'^export/?', views.StockExport.as_view(), name='stock-export'),

View File

@ -91,20 +91,20 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
data = super().get_context_data(**kwargs)
if self.object.serialized:
serial_elem = {a.serial: a for a in self.object.part.stock_items.all() if a.serialized}
serials = [int(a) for a in serial_elem.keys()]
serial_elem = {int(a.serial): a for a in self.object.part.stock_items.all() if a.serialized}
serials = serial_elem.keys()
current = int(self.object.serial)
# previous
for nbr in range(current - 1, -1, -1):
if nbr in serials:
data['previous'] = serial_elem.get(str(nbr), None)
data['previous'] = serial_elem.get(nbr, None)
break
# next
for nbr in range(current + 1, max(serials) + 1):
if nbr in serials:
data['next'] = serial_elem.get(str(nbr), None)
data['next'] = serial_elem.get(nbr, None)
break
return data
@ -749,343 +749,6 @@ class StockItemUninstall(AjaxView, FormMixin):
return context
class StockAdjust(AjaxView, FormMixin):
""" View for enacting simple stock adjustments:
- Take items from stock
- Add items to stock
- Count items
- Move stock
- Delete stock items
"""
ajax_template_name = 'stock/stock_adjust.html'
ajax_form_title = _('Adjust Stock')
form_class = StockForms.AdjustStockForm
stock_items = []
role_required = 'stock.change'
def get_GET_items(self):
""" Return list of stock items initally requested using GET.
Items can be retrieved by:
a) List of stock ID - stock[]=1,2,3,4,5
b) Parent part - part=3
c) Parent location - location=78
d) Single item - item=2
"""
# Start with all 'in stock' items
items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
# Client provides a list of individual stock items
if 'stock[]' in self.request.GET:
items = items.filter(id__in=self.request.GET.getlist('stock[]'))
# Client provides a PART reference
elif 'part' in self.request.GET:
items = items.filter(part=self.request.GET.get('part'))
# Client provides a LOCATION reference
elif 'location' in self.request.GET:
items = items.filter(location=self.request.GET.get('location'))
# Client provides a single StockItem lookup
elif 'item' in self.request.GET:
items = [StockItem.objects.get(id=self.request.GET.get('item'))]
# Unsupported query (no items)
else:
items = []
for item in items:
# Initialize quantity to zero for addition/removal
if self.stock_action in ['take', 'add']:
item.new_quantity = 0
# Initialize quantity at full amount for counting or moving
else:
item.new_quantity = item.quantity
return items
def get_POST_items(self):
""" Return list of stock items sent back by client on a POST request """
items = []
for item in self.request.POST:
if item.startswith('stock-id-'):
pk = item.replace('stock-id-', '')
q = self.request.POST[item]
try:
stock_item = StockItem.objects.get(pk=pk)
except StockItem.DoesNotExist:
continue
stock_item.new_quantity = q
items.append(stock_item)
return items
def get_stock_action_titles(self):
# Choose form title and action column based on the action
titles = {
'move': [_('Move Stock Items'), _('Move')],
'count': [_('Count Stock Items'), _('Count')],
'take': [_('Remove From Stock'), _('Take')],
'add': [_('Add Stock Items'), _('Add')],
'delete': [_('Delete Stock Items'), _('Delete')],
}
self.ajax_form_title = titles[self.stock_action][0]
self.stock_action_title = titles[self.stock_action][1]
def get_context_data(self):
context = super().get_context_data()
context['stock_items'] = self.stock_items
context['stock_action'] = self.stock_action.strip().lower()
self.get_stock_action_titles()
context['stock_action_title'] = self.stock_action_title
# Quantity column will be read-only in some circumstances
context['edit_quantity'] = not self.stock_action == 'delete'
return context
def get_form(self):
form = super().get_form()
if not self.stock_action == 'move':
form.fields.pop('destination')
form.fields.pop('set_loc')
return form
def get(self, request, *args, **kwargs):
self.request = request
# Action
self.stock_action = request.GET.get('action', '').lower()
# Pick a default action...
if self.stock_action not in ['move', 'count', 'take', 'add', 'delete']:
self.stock_action = 'count'
# Save list of items!
self.stock_items = self.get_GET_items()
return self.renderJsonResponse(request, self.get_form())
def post(self, request, *args, **kwargs):
self.request = request
self.stock_action = request.POST.get('stock_action', 'invalid').strip().lower()
# Update list of stock items
self.stock_items = self.get_POST_items()
form = self.get_form()
valid = form.is_valid()
for item in self.stock_items:
try:
item.new_quantity = Decimal(item.new_quantity)
except ValueError:
item.error = _('Must enter integer value')
valid = False
continue
if item.new_quantity < 0:
item.error = _('Quantity must be positive')
valid = False
continue
if self.stock_action in ['move', 'take']:
if item.new_quantity > item.quantity:
item.error = _('Quantity must not exceed {x}').format(x=item.quantity)
valid = False
continue
confirmed = str2bool(request.POST.get('confirm'))
if not confirmed:
valid = False
form.add_error('confirm', _('Confirm stock adjustment'))
data = {
'form_valid': valid,
}
if valid:
result = self.do_action(note=form.cleaned_data['note'])
data['success'] = result
# Special case - Single Stock Item
# If we deplete the stock item, we MUST redirect to a new view
single_item = len(self.stock_items) == 1
if result and single_item:
# Was the entire stock taken?
item = self.stock_items[0]
if item.quantity == 0:
# Instruct the form to redirect
data['url'] = reverse('stock-index')
return self.renderJsonResponse(request, form, data=data, context=self.get_context_data())
def do_action(self, note=None):
""" Perform stock adjustment action """
if self.stock_action == 'move':
destination = None
set_default_loc = str2bool(self.request.POST.get('set_loc', False))
try:
destination = StockLocation.objects.get(id=self.request.POST.get('destination'))
except StockLocation.DoesNotExist:
pass
except ValueError:
pass
return self.do_move(destination, set_default_loc, note=note)
elif self.stock_action == 'add':
return self.do_add(note=note)
elif self.stock_action == 'take':
return self.do_take(note=note)
elif self.stock_action == 'count':
return self.do_count(note=note)
elif self.stock_action == 'delete':
return self.do_delete(note=note)
else:
return _('No action performed')
def do_add(self, note=None):
count = 0
for item in self.stock_items:
if item.new_quantity <= 0:
continue
item.add_stock(item.new_quantity, self.request.user, notes=note)
count += 1
return _('Added stock to {n} items').format(n=count)
def do_take(self, note=None):
count = 0
for item in self.stock_items:
if item.new_quantity <= 0:
continue
item.take_stock(item.new_quantity, self.request.user, notes=note)
count += 1
return _('Removed stock from {n} items').format(n=count)
def do_count(self, note=None):
count = 0
for item in self.stock_items:
item.stocktake(item.new_quantity, self.request.user, notes=note)
count += 1
return _("Counted stock for {n} items".format(n=count))
def do_move(self, destination, set_loc=None, note=None):
""" Perform actual stock movement """
count = 0
for item in self.stock_items:
# Avoid moving zero quantity
if item.new_quantity <= 0:
continue
# If we wish to set the destination location to the default one
if set_loc:
item.part.default_location = destination
item.part.save()
# Do not move to the same location (unless the quantity is different)
if destination == item.location and item.new_quantity == item.quantity:
continue
item.move(destination, note, self.request.user, quantity=item.new_quantity)
count += 1
# Is ownership control enabled?
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
if stock_ownership_control:
# Fetch destination owner
destination_owner = destination.owner
if destination_owner:
# Update owner
item.owner = destination_owner
item.save()
if count == 0:
return _('No items were moved')
else:
return _('Moved {n} items to {dest}').format(
n=count,
dest=destination.pathstring)
def do_delete(self):
""" Delete multiple stock items """
count = 0
# note = self.request.POST['note']
for item in self.stock_items:
# TODO - In the future, StockItems should not be 'deleted'
# TODO - Instead, they should be marked as "inactive"
item.delete()
count += 1
return _("Deleted {n} stock items").format(n=count)
class StockItemEdit(AjaxUpdateView):
"""
View for editing details of a single StockItem

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

View File

@ -1,3 +1,6 @@
{% load i18n %}
{% load inventree_extras %}
var jQuery = window.$;
// using jQuery
@ -138,4 +141,49 @@ function inventreeDelete(url, options={}) {
inventreePut(url, {}, options);
}
function showApiError(xhr) {
var title = null;
var message = null;
switch (xhr.status) {
case 0: // No response
title = '{% trans "No Response" %}';
message = '{% trans "No response from the InvenTree server" %}';
break;
case 400: // Bad request
// Note: Normally error code 400 is handled separately,
// and should now be shown here!
title = '{% trans "Error 400: Bad request" %}';
message = '{% trans "API request returned error code 400" %}';
break;
case 401: // Not authenticated
title = '{% trans "Error 401: Not Authenticated" %}';
message = '{% trans "Authentication credentials not supplied" %}';
break;
case 403: // Permission denied
title = '{% trans "Error 403: Permission Denied" %}';
message = '{% trans "You do not have the required permissions to access this function" %}';
break;
case 404: // Resource not found
title = '{% trans "Error 404: Resource Not Found" %}';
message = '{% trans "The requested resource could not be located on the server" %}';
break;
case 408: // Timeout
title = '{% trans "Error 408: Timeout" %}';
message = '{% trans "Connection timeout while requesting data from server" %}';
break;
default:
title = '{% trans "Unhandled Error Code" %}';
message = `{% trans "Error code" %}: ${xhr.status}`;
break;
}
message += "<hr>";
message += renderErrorMessage(xhr);
showAlertDialog(title, message);
}

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!)
fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters);
// TODO: Refactor the following code with Object.assign (see above)
// Secondary modal options
fields[field].secondary = field_options.secondary;
// Edit callback
fields[field].onEdit = field_options.onEdit;
fields[field].multiline = field_options.multiline;
// Custom help_text
if (field_options.help_text) {
fields[field].help_text = field_options.help_text;
@ -395,11 +399,11 @@ function constructFormBody(fields, options) {
for (var name in displayed_fields) {
// Only push names which are actually in the set of fields
if (name in fields) {
field_names.push(name);
} else {
console.log(`WARNING: '${name}' does not match a valid field name.`);
field_names.push(name);
// Field not specified in the API, but the client wishes to add it!
if (!(name in fields)) {
fields[name] = displayed_fields[name];
}
}
@ -422,10 +426,8 @@ function constructFormBody(fields, options) {
default:
break;
}
var f = constructField(name, field, options);
html += f;
html += constructField(name, field, options);
}
// TODO: Dynamically create the modals,
@ -441,7 +443,15 @@ function constructFormBody(fields, options) {
modalEnable(modal, true);
// Insert generated form content
$(modal).find('.modal-form-content').html(html);
$(modal).find('#form-content').html(html);
if (options.preFormContent) {
$(modal).find('#pre-form-content').html(options.preFormContent);
}
if (options.postFormContent) {
$(modal).find('#post-form-content').html(options.postFormContent);
}
// Clear any existing buttons from the modal
$(modal).find('#modal-footer-buttons').html('');
@ -474,7 +484,21 @@ function constructFormBody(fields, options) {
$(modal).on('click', '#modal-form-submit', function() {
submitFormData(fields, options);
// Immediately disable the "submit" button,
// to prevent the form being submitted multiple times!
$(options.modal).find('#modal-form-submit').prop('disabled', true);
// Run custom code before normal form submission
if (options.beforeSubmit) {
options.beforeSubmit(fields, options);
}
// Run custom code instead of normal form submission
if (options.onSubmit) {
options.onSubmit(fields, options);
} else {
submitFormData(fields, options);
}
});
}
@ -511,10 +535,6 @@ function insertConfirmButton(options) {
*/
function submitFormData(fields, options) {
// Immediately disable the "submit" button,
// to prevent the form being submitted multiple times!
$(options.modal).find('#modal-form-submit').prop('disabled', true);
// Form data to be uploaded to the server
// Only used if file / image upload is required
var form_data = new FormData();
@ -581,47 +601,9 @@ function submitFormData(fields, options) {
case 400: // Bad request
handleFormErrors(xhr.responseJSON, fields, options);
break;
case 0: // No response
$(options.modal).modal('hide');
showAlertDialog(
'{% trans "No Response" %}',
'{% trans "No response from the InvenTree server" %}',
);
break;
case 401: // Not authenticated
$(options.modal).modal('hide');
showAlertDialog(
'{% trans "Error 401: Not Authenticated" %}',
'{% trans "Authentication credentials not supplied" %}',
);
break;
case 403: // Permission denied
$(options.modal).modal('hide');
showAlertDialog(
'{% trans "Error 403: Permission Denied" %}',
'{% trans "You do not have the required permissions to access this function" %}',
);
break;
case 404: // Resource not found
$(options.modal).modal('hide');
showAlertDialog(
'{% trans "Error 404: Resource Not Found" %}',
'{% trans "The requested resource could not be located on the server" %}',
);
break;
case 408: // Timeout
$(options.modal).modal('hide');
showAlertDialog(
'{% trans "Error 408: Timeout" %}',
'{% trans "Connection timeout while requesting data from server" %}',
);
break;
default:
$(options.modal).modal('hide');
showAlertDialog('{% trans "Error requesting form data" %}', renderErrorMessage(xhr));
console.log(`WARNING: Unhandled response code - ${xhr.status}`);
showApiError(xhr);
break;
}
}
@ -697,6 +679,10 @@ function getFormFieldValue(name, field, options) {
// Find the HTML element
var el = $(options.modal).find(`#id_${name}`);
if (!el) {
return null;
}
var value = null;
switch (field.type) {
@ -834,33 +820,27 @@ function handleFormErrors(errors, fields, options) {
}
for (field_name in errors) {
if (field_name in fields) {
// Add the 'has-error' class
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
// Add the 'has-error' class
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
var field_errors = errors[field_name];
var field_errors = errors[field_name];
// Add an entry for each returned error message
for (var idx = field_errors.length-1; idx >= 0; idx--) {
// Add an entry for each returned error message
for (var idx = field_errors.length-1; idx >= 0; idx--) {
var error_text = field_errors[idx];
var error_text = field_errors[idx];
var html = `
<span id='error_${idx+1}_id_${field_name}' class='help-block form-error-message'>
<strong>${error_text}</strong>
</span>`;
var html = `
<span id='error_${idx+1}_id_${field_name}' class='help-block form-error-message'>
<strong>${error_text}</strong>
</span>`;
field_dom.append(html);
}
} else {
console.log(`WARNING: handleFormErrors found no match for field '${field_name}'`);
field_dom.append(html);
}
}
}
@ -1464,21 +1444,21 @@ function constructInputOptions(name, classes, type, parameters) {
opts.push(`readonly=''`);
}
if (parameters.value) {
if (parameters.value != null) {
// Existing value?
opts.push(`value='${parameters.value}'`);
} else if (parameters.default) {
} else if (parameters.default != null) {
// Otherwise, a defualt value?
opts.push(`value='${parameters.default}'`);
}
// Maximum input length
if (parameters.max_length) {
if (parameters.max_length != null) {
opts.push(`maxlength='${parameters.max_length}'`);
}
// Minimum input length
if (parameters.min_length) {
if (parameters.min_length != null) {
opts.push(`minlength='${parameters.min_length}'`);
}
@ -1497,12 +1477,21 @@ function constructInputOptions(name, classes, type, parameters) {
opts.push(`required=''`);
}
// Custom mouseover title?
if (parameters.title != null) {
opts.push(`title='${parameters.title}'`);
}
// Placeholder?
if (parameters.placeholder) {
if (parameters.placeholder != null) {
opts.push(`placeholder='${parameters.placeholder}'`);
}
return `<input ${opts.join(' ')}>`;
if (parameters.multiline) {
return `<textarea ${opts.join(' ')}></textarea>`;
} else {
return `<input ${opts.join(' ')}>`;
}
}

View File

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

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) {
// Remove a selected row from a stock modal form
@ -228,6 +537,58 @@ function loadStockTestResultsTable(table, options) {
}
function locationDetail(row, showLink=true) {
/*
* Function to display a "location" of a StockItem.
*
* Complicating factors: A StockItem may not actually *be* in a location!
* - Could be at a customer
* - Could be installed in another stock item
* - Could be assigned to a sales order
* - Could be currently in production!
*
* So, instead of being naive, we'll check!
*/
// Display text
var text = '';
// URL (optional)
var url = '';
if (row.is_building && row.build) {
// StockItem is currently being built!
text = '{% trans "In production" %}';
url = `/build/${row.build}/`;
} else if (row.belongs_to) {
// StockItem is installed inside a different StockItem
text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
url = `/stock/item/${row.belongs_to}/installed/`;
} else if (row.customer) {
// StockItem has been assigned to a customer
text = '{% trans "Shipped to customer" %}';
url = `/company/${row.customer}/assigned-stock/`;
} else if (row.sales_order) {
// StockItem has been assigned to a sales order
text = '{% trans "Assigned to Sales Order" %}';
url = `/order/sales-order/${row.sales_order}/`;
} else if (row.location) {
text = row.location_detail.pathstring;
url = `/stock/location/${row.location}/`;
} else {
text = '<i>{% trans "No stock location set" %}</i>';
url = '';
}
if (showLink && url) {
return renderLink(text, url);
} else {
return text;
}
}
function loadStockTable(table, options) {
/* Load data into a stock table with adjustable options.
* Fetches data (via AJAX) and loads into a bootstrap table.
@ -271,56 +632,6 @@ function loadStockTable(table, options) {
filters[key] = params[key];
}
function locationDetail(row) {
/*
* Function to display a "location" of a StockItem.
*
* Complicating factors: A StockItem may not actually *be* in a location!
* - Could be at a customer
* - Could be installed in another stock item
* - Could be assigned to a sales order
* - Could be currently in production!
*
* So, instead of being naive, we'll check!
*/
// Display text
var text = '';
// URL (optional)
var url = '';
if (row.is_building && row.build) {
// StockItem is currently being built!
text = '{% trans "In production" %}';
url = `/build/${row.build}/`;
} else if (row.belongs_to) {
// StockItem is installed inside a different StockItem
text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
url = `/stock/item/${row.belongs_to}/installed/`;
} else if (row.customer) {
// StockItem has been assigned to a customer
text = '{% trans "Shipped to customer" %}';
url = `/company/${row.customer}/assigned-stock/`;
} else if (row.sales_order) {
// StockItem has been assigned to a sales order
text = '{% trans "Assigned to Sales Order" %}';
url = `/order/sales-order/${row.sales_order}/`;
} else if (row.location) {
text = row.location_detail.pathstring;
url = `/stock/location/${row.location}/`;
} else {
text = '<i>{% trans "No stock location set" %}</i>';
url = '';
}
if (url) {
return renderLink(text, url);
} else {
return text;
}
}
var grouping = true;
if ('grouping' in options) {
@ -741,39 +1052,15 @@ function loadStockTable(table, options) {
]
);
function stockAdjustment(action) {
var items = $("#stock-table").bootstrapTable("getSelections");
var stock = [];
items.forEach(function(item) {
stock.push(item.pk);
});
// Buttons for launching secondary modals
var secondary = [];
if (action == 'move') {
secondary.push({
field: 'destination',
label: '{% trans "New Location" %}',
title: '{% trans "Create new location" %}',
url: "/stock/location/new/",
});
}
launchModalForm("/stock/adjust/",
{
data: {
action: action,
stock: stock,
},
success: function() {
$("#stock-table").bootstrapTable('refresh');
},
secondary: secondary,
adjustStock(action, items, {
onSuccess: function() {
$('#stock-table').bootstrapTable('refresh');
}
);
});
}
// Automatically link button callbacks

View File

@ -56,46 +56,4 @@
</div>
</div>
</div>
</div>
<div class='modal fade modal-fixed-footer' role='dialog' id='modal-question-dialog'>
<div class='modal-dialog'>
<div class='modal-content'>
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&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>
</div>