mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
1e94a597c9
@ -7,18 +7,90 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
|
from crispy_forms.layout import Layout, Field
|
||||||
|
from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
class HelperForm(forms.ModelForm):
|
class HelperForm(forms.ModelForm):
|
||||||
""" Provides simple integration of crispy_forms extension. """
|
""" Provides simple integration of crispy_forms extension. """
|
||||||
|
|
||||||
|
# Custom field decorations can be specified here, per form class
|
||||||
|
field_prefix = {}
|
||||||
|
field_suffix = {}
|
||||||
|
field_placeholder = {}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(forms.ModelForm, self).__init__(*args, **kwargs)
|
super(forms.ModelForm, self).__init__(*args, **kwargs)
|
||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
|
|
||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
|
|
||||||
|
"""
|
||||||
|
Create a default 'layout' for this form.
|
||||||
|
Ref: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html
|
||||||
|
This is required to do fancy things later (like adding PrependedText, etc).
|
||||||
|
|
||||||
|
Simply create a 'blank' layout for each available field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.rebuild_layout()
|
||||||
|
|
||||||
|
def rebuild_layout(self):
|
||||||
|
|
||||||
|
layouts = []
|
||||||
|
|
||||||
|
for field in self.fields:
|
||||||
|
prefix = self.field_prefix.get(field, None)
|
||||||
|
suffix = self.field_suffix.get(field, None)
|
||||||
|
placeholder = self.field_placeholder.get(field, '')
|
||||||
|
|
||||||
|
# Look for font-awesome icons
|
||||||
|
if prefix and prefix.startswith('fa-'):
|
||||||
|
prefix = r"<i class='fas {fa}'/>".format(fa=prefix)
|
||||||
|
|
||||||
|
if suffix and suffix.startswith('fa-'):
|
||||||
|
suffix = r"<i class='fas {fa}'/>".format(fa=suffix)
|
||||||
|
|
||||||
|
if prefix and suffix:
|
||||||
|
layouts.append(
|
||||||
|
Field(
|
||||||
|
PrependedAppendedText(
|
||||||
|
field,
|
||||||
|
prepended_text=prefix,
|
||||||
|
appended_text=suffix,
|
||||||
|
placeholder=placeholder
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif prefix:
|
||||||
|
layouts.append(
|
||||||
|
Field(
|
||||||
|
PrependedText(
|
||||||
|
field,
|
||||||
|
prefix,
|
||||||
|
placeholder=placeholder
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif suffix:
|
||||||
|
layouts.append(
|
||||||
|
Field(
|
||||||
|
AppendedText(
|
||||||
|
field,
|
||||||
|
suffix,
|
||||||
|
placeholder=placeholder
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
layouts.append(Field(field, placeholder=placeholder))
|
||||||
|
|
||||||
|
self.helper.layout = Layout(*layouts)
|
||||||
|
|
||||||
|
|
||||||
class DeleteForm(forms.Form):
|
class DeleteForm(forms.Form):
|
||||||
""" Generic deletion form which provides simple user confirmation
|
""" Generic deletion form which provides simple user confirmation
|
||||||
|
@ -120,6 +120,59 @@ def normalize(d):
|
|||||||
return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize()
|
return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize()
|
||||||
|
|
||||||
|
|
||||||
|
def increment(n):
|
||||||
|
"""
|
||||||
|
Attempt to increment an integer (or a string that looks like an integer!)
|
||||||
|
|
||||||
|
e.g.
|
||||||
|
|
||||||
|
001 -> 002
|
||||||
|
2 -> 3
|
||||||
|
AB01 -> AB02
|
||||||
|
QQQ -> QQQ
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
value = str(n).strip()
|
||||||
|
|
||||||
|
# Ignore empty strings
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
pattern = r"(.*?)(\d+)?$"
|
||||||
|
|
||||||
|
result = re.search(pattern, value)
|
||||||
|
|
||||||
|
# No match!
|
||||||
|
if result is None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
groups = result.groups()
|
||||||
|
|
||||||
|
# If we cannot match the regex, then simply return the provided value
|
||||||
|
if not len(groups) == 2:
|
||||||
|
return value
|
||||||
|
|
||||||
|
prefix, number = groups
|
||||||
|
|
||||||
|
# No number extracted? Simply return the prefix (without incrementing!)
|
||||||
|
if not number:
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
# Record the width of the number
|
||||||
|
width = len(number)
|
||||||
|
|
||||||
|
try:
|
||||||
|
number = int(number) + 1
|
||||||
|
number = str(number)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
number = number.zfill(width)
|
||||||
|
|
||||||
|
return prefix + number
|
||||||
|
|
||||||
|
|
||||||
def decimal2string(d):
|
def decimal2string(d):
|
||||||
"""
|
"""
|
||||||
Format a Decimal number as a string,
|
Format a Decimal number as a string,
|
||||||
|
@ -56,7 +56,7 @@ class InvenTreeAttachment(models.Model):
|
|||||||
attachment = models.FileField(upload_to=rename_attachment,
|
attachment = models.FileField(upload_to=rename_attachment,
|
||||||
help_text=_('Select file to attach'))
|
help_text=_('Select file to attach'))
|
||||||
|
|
||||||
comment = models.CharField(max_length=100, help_text=_('File comment'))
|
comment = models.CharField(blank=True, max_length=100, help_text=_('File comment'))
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
|
@ -341,7 +341,7 @@ if DEBUG:
|
|||||||
print("STATIC_ROOT:", STATIC_ROOT)
|
print("STATIC_ROOT:", STATIC_ROOT)
|
||||||
|
|
||||||
# crispy forms use the bootstrap templates
|
# crispy forms use the bootstrap templates
|
||||||
CRISPY_TEMPLATE_PACK = 'bootstrap'
|
CRISPY_TEMPLATE_PACK = 'bootstrap3'
|
||||||
|
|
||||||
# Use database transactions when importing / exporting data
|
# Use database transactions when importing / exporting data
|
||||||
IMPORT_EXPORT_USE_TRANSACTIONS = True
|
IMPORT_EXPORT_USE_TRANSACTIONS = True
|
||||||
|
@ -346,9 +346,11 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
.dropzone * {
|
.dropzone * {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
.dragover {
|
.dragover {
|
||||||
background-color: #55A;
|
background-color: #55A;
|
||||||
|
@ -140,10 +140,13 @@ function enableDragAndDrop(element, url, options) {
|
|||||||
url - URL to POST the file to
|
url - URL to POST the file to
|
||||||
options - object with following possible values:
|
options - object with following possible values:
|
||||||
label - Label of the file to upload (default='file')
|
label - Label of the file to upload (default='file')
|
||||||
|
data - Other form data to upload
|
||||||
success - Callback function in case of success
|
success - Callback function in case of success
|
||||||
error - Callback function in case of error
|
error - Callback function in case of error
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
data = options.data || {};
|
||||||
|
|
||||||
$(element).on('drop', function(event) {
|
$(element).on('drop', function(event) {
|
||||||
|
|
||||||
var transfer = event.originalEvent.dataTransfer;
|
var transfer = event.originalEvent.dataTransfer;
|
||||||
@ -152,6 +155,11 @@ function enableDragAndDrop(element, url, options) {
|
|||||||
|
|
||||||
var formData = new FormData();
|
var formData = new FormData();
|
||||||
|
|
||||||
|
// Add the extra data
|
||||||
|
for (var key in data) {
|
||||||
|
formData.append(key, data[key]);
|
||||||
|
}
|
||||||
|
|
||||||
if (isFileTransfer(transfer)) {
|
if (isFileTransfer(transfer)) {
|
||||||
formData.append(label, transfer.files[0]);
|
formData.append(label, transfer.files[0]);
|
||||||
|
|
||||||
|
@ -108,6 +108,29 @@ class TestQuoteWrap(TestCase):
|
|||||||
self.assertEqual(helpers.WrapWithQuotes('hello"'), '"hello"')
|
self.assertEqual(helpers.WrapWithQuotes('hello"'), '"hello"')
|
||||||
|
|
||||||
|
|
||||||
|
class TestIncrement(TestCase):
|
||||||
|
|
||||||
|
def tests(self):
|
||||||
|
""" Test 'intelligent' incrementing function """
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
("", ""),
|
||||||
|
(1, "2"),
|
||||||
|
("001", "002"),
|
||||||
|
("1001", "1002"),
|
||||||
|
("ABC123", "ABC124"),
|
||||||
|
("XYZ0", "XYZ1"),
|
||||||
|
("123Q", "123Q"),
|
||||||
|
("QQQ", "QQQ"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for test in tests:
|
||||||
|
a, b = test
|
||||||
|
|
||||||
|
result = helpers.increment(a)
|
||||||
|
self.assertEqual(result, b)
|
||||||
|
|
||||||
|
|
||||||
class TestMakeBarcode(TestCase):
|
class TestMakeBarcode(TestCase):
|
||||||
""" Tests for barcode string creation """
|
""" Tests for barcode string creation """
|
||||||
|
|
||||||
|
@ -81,6 +81,7 @@ dynamic_javascript_urls = [
|
|||||||
url(r'^order.js', DynamicJsView.as_view(template_name='js/order.html'), name='order.js'),
|
url(r'^order.js', DynamicJsView.as_view(template_name='js/order.html'), name='order.js'),
|
||||||
url(r'^company.js', DynamicJsView.as_view(template_name='js/company.html'), name='company.js'),
|
url(r'^company.js', DynamicJsView.as_view(template_name='js/company.html'), name='company.js'),
|
||||||
url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.html'), name='bom.js'),
|
url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.html'), name='bom.js'),
|
||||||
|
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.html'), name='table_filters.js'),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
@ -46,12 +46,23 @@ class ConfirmBuildForm(HelperForm):
|
|||||||
class CompleteBuildForm(HelperForm):
|
class CompleteBuildForm(HelperForm):
|
||||||
""" Form for marking a Build as complete """
|
""" Form for marking a Build as complete """
|
||||||
|
|
||||||
|
field_prefix = {
|
||||||
|
'serial_numbers': 'fa-hashtag',
|
||||||
|
}
|
||||||
|
|
||||||
|
field_placeholder = {
|
||||||
|
}
|
||||||
|
|
||||||
location = forms.ModelChoiceField(
|
location = forms.ModelChoiceField(
|
||||||
queryset=StockLocation.objects.all(),
|
queryset=StockLocation.objects.all(),
|
||||||
help_text='Location of completed parts',
|
help_text=_('Location of completed parts'),
|
||||||
)
|
)
|
||||||
|
|
||||||
serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
|
serial_numbers = forms.CharField(
|
||||||
|
label=_('Serial numbers'),
|
||||||
|
required=False,
|
||||||
|
help_text=_('Enter unique serial numbers (or leave blank)')
|
||||||
|
)
|
||||||
|
|
||||||
confirm = forms.BooleanField(required=False, help_text=_('Confirm build completion'))
|
confirm = forms.BooleanField(required=False, help_text=_('Confirm build completion'))
|
||||||
|
|
||||||
|
@ -53,6 +53,22 @@ class Build(MPTTModel):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('build-detail', kwargs={'pk': self.id})
|
return reverse('build-detail', kwargs={'pk': self.id})
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""
|
||||||
|
Validation for Build object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.part.trackable:
|
||||||
|
if not self.quantity == int(self.quantity):
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': _("Build quantity must be integer value for trackable parts")
|
||||||
|
})
|
||||||
|
except PartModels.Part.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
title = models.CharField(
|
title = models.CharField(
|
||||||
verbose_name=_('Build Title'),
|
verbose_name=_('Build Title'),
|
||||||
blank=False,
|
blank=False,
|
||||||
|
@ -198,8 +198,8 @@ InvenTree | Allocate Parts
|
|||||||
var html = `<div class='btn-group float-right' role='group'>`;
|
var html = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
{% if build.status == BuildStatus.PENDING %}
|
{% if build.status == BuildStatus.PENDING %}
|
||||||
html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
|
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
|
||||||
html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
|
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
@ -389,7 +389,7 @@ InvenTree | Allocate Parts
|
|||||||
html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}');
|
html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}');
|
||||||
}
|
}
|
||||||
|
|
||||||
html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate stock" %}');
|
html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
@ -196,6 +196,15 @@ class BuildComplete(AjaxUpdateView):
|
|||||||
|
|
||||||
if not build.part.trackable:
|
if not build.part.trackable:
|
||||||
form.fields.pop('serial_numbers')
|
form.fields.pop('serial_numbers')
|
||||||
|
else:
|
||||||
|
if build.quantity == 1:
|
||||||
|
text = _('Next available serial number is')
|
||||||
|
else:
|
||||||
|
text = _('Next available serial numbers are')
|
||||||
|
|
||||||
|
form.field_placeholder['serial_numbers'] = text + " " + build.part.getSerialNumberString(build.quantity)
|
||||||
|
|
||||||
|
form.rebuild_layout()
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
|
||||||
@ -208,6 +217,7 @@ class BuildComplete(AjaxUpdateView):
|
|||||||
initials = super(BuildComplete, self).get_initial().copy()
|
initials = super(BuildComplete, self).get_initial().copy()
|
||||||
|
|
||||||
build = self.get_object()
|
build = self.get_object()
|
||||||
|
|
||||||
if build.part.default_location is not None:
|
if build.part.default_location is not None:
|
||||||
try:
|
try:
|
||||||
location = StockLocation.objects.get(pk=build.part.default_location.id)
|
location = StockLocation.objects.get(pk=build.part.default_location.id)
|
||||||
@ -282,7 +292,7 @@ class BuildComplete(AjaxUpdateView):
|
|||||||
existing = []
|
existing = []
|
||||||
|
|
||||||
for serial in serials:
|
for serial in serials:
|
||||||
if not StockItem.check_serial_number(build.part, serial):
|
if build.part.checkIfSerialNumberExists(serial):
|
||||||
existing.append(serial)
|
existing.append(serial)
|
||||||
|
|
||||||
if len(existing) > 0:
|
if len(existing) > 0:
|
||||||
|
@ -16,6 +16,14 @@ from .models import SupplierPriceBreak
|
|||||||
class EditCompanyForm(HelperForm):
|
class EditCompanyForm(HelperForm):
|
||||||
""" Form for editing a Company object """
|
""" Form for editing a Company object """
|
||||||
|
|
||||||
|
field_prefix = {
|
||||||
|
'website': 'fa-globe-asia',
|
||||||
|
'email': 'fa-at',
|
||||||
|
'address': 'fa-envelope',
|
||||||
|
'contact': 'fa-user-tie',
|
||||||
|
'phone': 'fa-phone',
|
||||||
|
}
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Company
|
model = Company
|
||||||
fields = [
|
fields = [
|
||||||
@ -45,6 +53,13 @@ class CompanyImageForm(HelperForm):
|
|||||||
class EditSupplierPartForm(HelperForm):
|
class EditSupplierPartForm(HelperForm):
|
||||||
""" Form for editing a SupplierPart object """
|
""" Form for editing a SupplierPart object """
|
||||||
|
|
||||||
|
field_prefix = {
|
||||||
|
'link': 'fa-link',
|
||||||
|
'MPN': 'fa-hashtag',
|
||||||
|
'SKU': 'fa-hashtag',
|
||||||
|
'note': 'fa-pencil-alt',
|
||||||
|
}
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SupplierPart
|
model = SupplierPart
|
||||||
fields = [
|
fields = [
|
||||||
|
@ -85,10 +85,10 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
|
|||||||
|
|
||||||
$('#company-edit').click(function() {
|
$('#company-edit').click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'company-edit' company.id %}",
|
"{% url 'company-edit' company.id %}",
|
||||||
{
|
{
|
||||||
reload: true
|
reload: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#company-order-2").click(function() {
|
$("#company-order-2").click(function() {
|
||||||
@ -104,10 +104,10 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
|
|||||||
|
|
||||||
$('#company-delete').click(function() {
|
$('#company-delete').click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'company-delete' company.id %}",
|
"{% url 'company-delete' company.id %}",
|
||||||
{
|
{
|
||||||
redirect: "{% url 'company-index' %}"
|
redirect: "{% url 'company-index' %}"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
enableDragAndDrop(
|
enableDragAndDrop(
|
||||||
@ -123,11 +123,11 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
|
|||||||
|
|
||||||
$("#company-thumb").click(function() {
|
$("#company-thumb").click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'company-image' company.id %}",
|
"{% url 'company-image' company.id %}",
|
||||||
{
|
{
|
||||||
reload: true
|
reload: true
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -33,9 +33,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
$("#new-sales-order").click(function() {
|
$("#new-sales-order").click(function() {
|
||||||
// TODO - Create a new sales order
|
launchModalForm(
|
||||||
|
"{% url 'so-create' %}",
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
customer: {{ company.id }},
|
||||||
|
},
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -4,7 +4,7 @@
|
|||||||
- model: order.purchaseorder
|
- model: order.purchaseorder
|
||||||
pk: 1
|
pk: 1
|
||||||
fields:
|
fields:
|
||||||
reference: 0001
|
reference: '0001'
|
||||||
description: "Ordering some screws"
|
description: "Ordering some screws"
|
||||||
supplier: 1
|
supplier: 1
|
||||||
|
|
||||||
@ -12,7 +12,7 @@
|
|||||||
- model: order.purchaseorder
|
- model: order.purchaseorder
|
||||||
pk: 2
|
pk: 2
|
||||||
fields:
|
fields:
|
||||||
reference: 0002
|
reference: '0002'
|
||||||
description: "Ordering some more screws"
|
description: "Ordering some more screws"
|
||||||
supplier: 3
|
supplier: 3
|
||||||
|
|
||||||
|
@ -88,6 +88,19 @@ class ReceivePurchaseOrderForm(HelperForm):
|
|||||||
class EditPurchaseOrderForm(HelperForm):
|
class EditPurchaseOrderForm(HelperForm):
|
||||||
""" Form for editing a PurchaseOrder object """
|
""" Form for editing a PurchaseOrder object """
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
|
self.field_prefix = {
|
||||||
|
'reference': 'PO',
|
||||||
|
'link': 'fa-link',
|
||||||
|
}
|
||||||
|
|
||||||
|
self.field_placeholder = {
|
||||||
|
'reference': _('Enter purchase order number'),
|
||||||
|
}
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PurchaseOrder
|
model = PurchaseOrder
|
||||||
fields = [
|
fields = [
|
||||||
@ -102,6 +115,19 @@ class EditPurchaseOrderForm(HelperForm):
|
|||||||
class EditSalesOrderForm(HelperForm):
|
class EditSalesOrderForm(HelperForm):
|
||||||
""" Form for editing a SalesOrder object """
|
""" Form for editing a SalesOrder object """
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
|
self.field_prefix = {
|
||||||
|
'reference': 'SO',
|
||||||
|
'link': 'fa-link',
|
||||||
|
}
|
||||||
|
|
||||||
|
self.field_placeholder = {
|
||||||
|
'reference': _('Enter sales order number'),
|
||||||
|
}
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SalesOrder
|
model = SalesOrder
|
||||||
fields = [
|
fields = [
|
||||||
|
23
InvenTree/order/migrations/0035_auto_20200513_0016.py
Normal file
23
InvenTree/order/migrations/0035_auto_20200513_0016.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.0.5 on 2020-05-13 00:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('order', '0034_auto_20200512_1054'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='purchaseorderattachment',
|
||||||
|
name='comment',
|
||||||
|
field=models.CharField(blank=True, help_text='File comment', max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='salesorderattachment',
|
||||||
|
name='comment',
|
||||||
|
field=models.CharField(blank=True, help_text='File comment', max_length=100),
|
||||||
|
),
|
||||||
|
]
|
@ -24,7 +24,7 @@ from stock import models as stock_models
|
|||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
|
|
||||||
from InvenTree.fields import RoundingDecimalField
|
from InvenTree.fields import RoundingDecimalField
|
||||||
from InvenTree.helpers import decimal2string
|
from InvenTree.helpers import decimal2string, increment
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
|
||||||
from InvenTree.models import InvenTreeAttachment
|
from InvenTree.models import InvenTreeAttachment
|
||||||
|
|
||||||
@ -49,6 +49,43 @@ class Order(models.Model):
|
|||||||
|
|
||||||
ORDER_PREFIX = ""
|
ORDER_PREFIX = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getNextOrderNumber(cls):
|
||||||
|
"""
|
||||||
|
Try to predict the next order-number
|
||||||
|
"""
|
||||||
|
|
||||||
|
if cls.objects.count() == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# We will assume that the latest pk has the highest PO number
|
||||||
|
order = cls.objects.last()
|
||||||
|
ref = order.reference
|
||||||
|
|
||||||
|
if not ref:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tries = set()
|
||||||
|
|
||||||
|
tries.add(ref)
|
||||||
|
|
||||||
|
while 1:
|
||||||
|
new_ref = increment(ref)
|
||||||
|
|
||||||
|
if new_ref in tries:
|
||||||
|
# We are in a looping situation - simply return the original one
|
||||||
|
return ref
|
||||||
|
|
||||||
|
# Check that the new ref does not exist in the database
|
||||||
|
if cls.objects.filter(reference=new_ref).exists():
|
||||||
|
tries.add(new_ref)
|
||||||
|
new_ref = increment(new_ref)
|
||||||
|
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return new_ref
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
el = []
|
el = []
|
||||||
|
|
||||||
|
@ -20,6 +20,20 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
enableDragAndDrop(
|
||||||
|
'#attachment-dropzone',
|
||||||
|
"{% url 'po-attachment-create' %}",
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
order: {{ order.id }},
|
||||||
|
},
|
||||||
|
label: 'attachment',
|
||||||
|
success: function(data, status, xhr) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
$("#new-attachment").click(function() {
|
$("#new-attachment").click(function() {
|
||||||
launchModalForm("{% url 'po-attachment-create' %}?order={{ order.id }}",
|
launchModalForm("{% url 'po-attachment-create' %}?order={{ order.id }}",
|
||||||
{
|
{
|
||||||
|
@ -208,7 +208,7 @@ $("#po-table").inventreeTable({
|
|||||||
var pk = row.pk;
|
var pk = row.pk;
|
||||||
|
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||||
html += makeIconButton('fa-edit', 'button-line-edit', pk, '{% trans "Edit line item" %}');
|
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
|
||||||
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
|
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -89,8 +89,8 @@ function showAllocationSubTable(index, row, element) {
|
|||||||
var pk = row.pk;
|
var pk = row.pk;
|
||||||
|
|
||||||
{% if order.status == SalesOrderStatus.PENDING %}
|
{% if order.status == SalesOrderStatus.PENDING %}
|
||||||
html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
|
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
|
||||||
html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
|
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
html += "</div>";
|
html += "</div>";
|
||||||
@ -274,11 +274,11 @@ $("#so-lines-table").inventreeTable({
|
|||||||
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}');
|
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}');
|
||||||
}
|
}
|
||||||
|
|
||||||
html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate parts" %}');
|
html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate parts" %}');
|
||||||
}
|
}
|
||||||
|
|
||||||
html += makeIconButton('fa-edit', 'button-edit', pk, '{% trans "Edit line item" %}');
|
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
|
||||||
html += makeIconButton('fa-trash-alt', 'button-delete', pk, '{% trans "Delete line item " %}');
|
html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}');
|
||||||
|
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
|
||||||
|
@ -19,6 +19,20 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
enableDragAndDrop(
|
||||||
|
'#attachment-dropzone',
|
||||||
|
"{% url 'so-attachment-create' %}",
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
order: {{ order.id }},
|
||||||
|
},
|
||||||
|
label: 'attachment',
|
||||||
|
success: function(data, status, xhr) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
$("#new-attachment").click(function() {
|
$("#new-attachment").click(function() {
|
||||||
launchModalForm("{% url 'so-attachment-create' %}?order={{ order.id }}",
|
launchModalForm("{% url 'so-attachment-create' %}?order={{ order.id }}",
|
||||||
{
|
{
|
||||||
|
@ -31,11 +31,17 @@ class OrderTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
|
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
|
||||||
|
|
||||||
self.assertEqual(str(order), 'PO 1 - ACME')
|
self.assertEqual(str(order), 'PO 0001 - ACME')
|
||||||
|
|
||||||
line = PurchaseOrderLineItem.objects.get(pk=1)
|
line = PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
|
|
||||||
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1 - ACME)")
|
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 0001 - ACME)")
|
||||||
|
|
||||||
|
def test_increment(self):
|
||||||
|
|
||||||
|
next_ref = PurchaseOrder.getNextOrderNumber()
|
||||||
|
|
||||||
|
self.assertEqual(next_ref, '0003')
|
||||||
|
|
||||||
def test_on_order(self):
|
def test_on_order(self):
|
||||||
""" There should be 3 separate items on order for the M2x4 LPHS part """
|
""" There should be 3 separate items on order for the M2x4 LPHS part """
|
||||||
|
@ -29,7 +29,7 @@ from . import forms as order_forms
|
|||||||
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||||
from InvenTree.helpers import DownloadFile, str2bool
|
from InvenTree.helpers import DownloadFile, str2bool
|
||||||
|
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus, StockStatus
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -112,7 +112,10 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
|
|||||||
|
|
||||||
initials = super(AjaxCreateView, self).get_initial()
|
initials = super(AjaxCreateView, self).get_initial()
|
||||||
|
|
||||||
initials["order"] = PurchaseOrder.objects.get(id=self.request.GET.get('order', -1))
|
try:
|
||||||
|
initials["order"] = PurchaseOrder.objects.get(id=self.request.GET.get('order', -1))
|
||||||
|
except (ValueError, PurchaseOrder.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
return initials
|
return initials
|
||||||
|
|
||||||
@ -149,7 +152,10 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
|
|||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
initials = super().get_initial().copy()
|
initials = super().get_initial().copy()
|
||||||
|
|
||||||
initials['order'] = SalesOrder.objects.get(id=self.request.GET.get('order', None))
|
try:
|
||||||
|
initials['order'] = SalesOrder.objects.get(id=self.request.GET.get('order', None))
|
||||||
|
except (ValueError, SalesOrder.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
return initials
|
return initials
|
||||||
|
|
||||||
@ -284,6 +290,7 @@ class PurchaseOrderCreate(AjaxCreateView):
|
|||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
initials = super().get_initial().copy()
|
initials = super().get_initial().copy()
|
||||||
|
|
||||||
|
initials['reference'] = PurchaseOrder.getNextOrderNumber()
|
||||||
initials['status'] = PurchaseOrderStatus.PENDING
|
initials['status'] = PurchaseOrderStatus.PENDING
|
||||||
|
|
||||||
supplier_id = self.request.GET.get('supplier', None)
|
supplier_id = self.request.GET.get('supplier', None)
|
||||||
@ -314,7 +321,8 @@ class SalesOrderCreate(AjaxCreateView):
|
|||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
initials = super().get_initial().copy()
|
initials = super().get_initial().copy()
|
||||||
|
|
||||||
initials['status'] = PurchaseOrderStatus.PENDING
|
initials['reference'] = SalesOrder.getNextOrderNumber()
|
||||||
|
initials['status'] = SalesOrderStatus.PENDING
|
||||||
|
|
||||||
customer_id = self.request.GET.get('customer', None)
|
customer_id = self.request.GET.get('customer', None)
|
||||||
|
|
||||||
|
@ -7,6 +7,10 @@
|
|||||||
description: 'M2x4 low profile head screw'
|
description: 'M2x4 low profile head screw'
|
||||||
category: 8
|
category: 8
|
||||||
link: www.acme.com/parts/m2x4lphs
|
link: www.acme.com/parts/m2x4lphs
|
||||||
|
tree_id: 0
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
- model: part.part
|
- model: part.part
|
||||||
pk: 2
|
pk: 2
|
||||||
@ -14,6 +18,10 @@
|
|||||||
name: 'M3x12 SHCS'
|
name: 'M3x12 SHCS'
|
||||||
description: 'M3x12 socket head cap screw'
|
description: 'M3x12 socket head cap screw'
|
||||||
category: 8
|
category: 8
|
||||||
|
tree_id: 0
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
# Create some resistors
|
# Create some resistors
|
||||||
|
|
||||||
@ -23,6 +31,11 @@
|
|||||||
name: 'R_2K2_0805'
|
name: 'R_2K2_0805'
|
||||||
description: '2.2kOhm resistor in 0805 package'
|
description: '2.2kOhm resistor in 0805 package'
|
||||||
category: 2
|
category: 2
|
||||||
|
tree_id: 0
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
|
||||||
- model: part.part
|
- model: part.part
|
||||||
fields:
|
fields:
|
||||||
@ -30,6 +43,10 @@
|
|||||||
description: '4.7kOhm resistor in 0603 package'
|
description: '4.7kOhm resistor in 0603 package'
|
||||||
category: 2
|
category: 2
|
||||||
default_location: 2 # Home/Bathroom
|
default_location: 2 # Home/Bathroom
|
||||||
|
tree_id: 0
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
# Create some capacitors
|
# Create some capacitors
|
||||||
- model: part.part
|
- model: part.part
|
||||||
@ -37,6 +54,10 @@
|
|||||||
name: 'C_22N_0805'
|
name: 'C_22N_0805'
|
||||||
description: '22nF capacitor in 0805 package'
|
description: '22nF capacitor in 0805 package'
|
||||||
category: 3
|
category: 3
|
||||||
|
tree_id: 0
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
- model: part.part
|
- model: part.part
|
||||||
pk: 25
|
pk: 25
|
||||||
@ -45,6 +66,10 @@
|
|||||||
description: 'A watchamacallit'
|
description: 'A watchamacallit'
|
||||||
category: 7
|
category: 7
|
||||||
trackable: true
|
trackable: true
|
||||||
|
tree_id: 0
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
- model: part.part
|
- model: part.part
|
||||||
pk: 50
|
pk: 50
|
||||||
@ -52,6 +77,10 @@
|
|||||||
name: 'Orphan'
|
name: 'Orphan'
|
||||||
description: 'A part without a category'
|
description: 'A part without a category'
|
||||||
category: null
|
category: null
|
||||||
|
tree_id: 0
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
# A part that can be made from other parts
|
# A part that can be made from other parts
|
||||||
- model: part.part
|
- model: part.part
|
||||||
@ -65,3 +94,64 @@
|
|||||||
active: False
|
active: False
|
||||||
IPN: BOB
|
IPN: BOB
|
||||||
revision: A2
|
revision: A2
|
||||||
|
tree_id: 0
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
# A 'template' part
|
||||||
|
- model: part.part
|
||||||
|
pk: 10000
|
||||||
|
fields:
|
||||||
|
name: 'Chair Template'
|
||||||
|
description: 'A chair'
|
||||||
|
is_template: True
|
||||||
|
category: 7
|
||||||
|
tree_id: 1
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: part.part
|
||||||
|
pk: 10001
|
||||||
|
fields:
|
||||||
|
name: 'Blue Chair'
|
||||||
|
variant_of: 10000
|
||||||
|
category: 7
|
||||||
|
tree_id: 1
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: part.part
|
||||||
|
pk: 10002
|
||||||
|
fields:
|
||||||
|
name: 'Red chair'
|
||||||
|
variant_of: 10000
|
||||||
|
category: 7
|
||||||
|
tree_id: 1
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: part.part
|
||||||
|
pk: 10003
|
||||||
|
fields:
|
||||||
|
name: 'Green chair'
|
||||||
|
variant_of: 10000
|
||||||
|
category: 7
|
||||||
|
tree_id: 1
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: part.part
|
||||||
|
pk: 10004
|
||||||
|
fields:
|
||||||
|
name: 'Green chair variant'
|
||||||
|
variant_of: 10003
|
||||||
|
category:
|
||||||
|
tree_id: 1
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
@ -97,6 +97,12 @@ class SetPartCategoryForm(forms.Form):
|
|||||||
class EditPartForm(HelperForm):
|
class EditPartForm(HelperForm):
|
||||||
""" Form for editing a Part object """
|
""" Form for editing a Part object """
|
||||||
|
|
||||||
|
field_prefix = {
|
||||||
|
'keywords': 'fa-key',
|
||||||
|
'link': 'fa-link',
|
||||||
|
'IPN': 'fa-hashtag',
|
||||||
|
}
|
||||||
|
|
||||||
deep_copy = forms.BooleanField(required=False,
|
deep_copy = forms.BooleanField(required=False,
|
||||||
initial=True,
|
initial=True,
|
||||||
help_text=_("Perform 'deep copy' which will duplicate all BOM data for this part"),
|
help_text=_("Perform 'deep copy' which will duplicate all BOM data for this part"),
|
||||||
@ -155,6 +161,10 @@ class EditPartParameterForm(HelperForm):
|
|||||||
class EditCategoryForm(HelperForm):
|
class EditCategoryForm(HelperForm):
|
||||||
""" Form for editing a PartCategory object """
|
""" Form for editing a PartCategory object """
|
||||||
|
|
||||||
|
field_prefix = {
|
||||||
|
'default_keywords': 'fa-key',
|
||||||
|
}
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PartCategory
|
model = PartCategory
|
||||||
fields = [
|
fields = [
|
||||||
|
18
InvenTree/part/migrations/0038_auto_20200513_0016.py
Normal file
18
InvenTree/part/migrations/0038_auto_20200513_0016.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.5 on 2020-05-13 00:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0037_partattachment_upload_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='partattachment',
|
||||||
|
name='comment',
|
||||||
|
field=models.CharField(blank=True, help_text='File comment', max_length=100),
|
||||||
|
),
|
||||||
|
]
|
50
InvenTree/part/migrations/0039_auto_20200515_1127.py
Normal file
50
InvenTree/part/migrations/0039_auto_20200515_1127.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 3.0.5 on 2020-05-15 11:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from part.models import Part
|
||||||
|
|
||||||
|
|
||||||
|
def update_tree(apps, schema_editor):
|
||||||
|
# Update the MPTT for Part model
|
||||||
|
Part.objects.rebuild()
|
||||||
|
|
||||||
|
|
||||||
|
def nupdate_tree(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0038_auto_20200513_0016'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='part',
|
||||||
|
name='level',
|
||||||
|
field=models.PositiveIntegerField(default=0, editable=False),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='part',
|
||||||
|
name='lft',
|
||||||
|
field=models.PositiveIntegerField(default=0, editable=False),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='part',
|
||||||
|
name='rght',
|
||||||
|
field=models.PositiveIntegerField(default=0, editable=False),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='part',
|
||||||
|
name='tree_id',
|
||||||
|
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
|
||||||
|
migrations.RunPython(update_tree, reverse_code=nupdate_tree)
|
||||||
|
]
|
@ -24,7 +24,7 @@ from markdownx.models import MarkdownxField
|
|||||||
|
|
||||||
from django_cleanup import cleanup
|
from django_cleanup import cleanup
|
||||||
|
|
||||||
from mptt.models import TreeForeignKey
|
from mptt.models import TreeForeignKey, MPTTModel
|
||||||
|
|
||||||
from stdimage.models import StdImageField
|
from stdimage.models import StdImageField
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
|||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
from InvenTree.helpers import decimal2string, normalize
|
from InvenTree.helpers import decimal2string, normalize
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||||
|
|
||||||
from build import models as BuildModels
|
from build import models as BuildModels
|
||||||
from order import models as OrderModels
|
from order import models as OrderModels
|
||||||
@ -200,7 +200,7 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False):
|
|||||||
|
|
||||||
|
|
||||||
@cleanup.ignore
|
@cleanup.ignore
|
||||||
class Part(models.Model):
|
class Part(MPTTModel):
|
||||||
""" The Part object represents an abstract part, the 'concept' of an actual entity.
|
""" The Part object represents an abstract part, the 'concept' of an actual entity.
|
||||||
|
|
||||||
An actual physical instance of a Part is a StockItem which is treated separately.
|
An actual physical instance of a Part is a StockItem which is treated separately.
|
||||||
@ -236,8 +236,12 @@ class Part(models.Model):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Part"
|
verbose_name = _("Part")
|
||||||
verbose_name_plural = "Parts"
|
verbose_name_plural = _("Parts")
|
||||||
|
|
||||||
|
class MPTTMeta:
|
||||||
|
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
|
||||||
|
parent_attr = 'variant_of'
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -262,6 +266,66 @@ class Part(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{n} - {d}".format(n=self.full_name, d=self.description)
|
return "{n} - {d}".format(n=self.full_name, d=self.description)
|
||||||
|
|
||||||
|
def checkIfSerialNumberExists(self, sn):
|
||||||
|
"""
|
||||||
|
Check if a serial number exists for this Part.
|
||||||
|
|
||||||
|
Note: Serial numbers must be unique across an entire Part "tree",
|
||||||
|
so here we filter by the entire tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||||
|
stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn)
|
||||||
|
|
||||||
|
return stock.exists()
|
||||||
|
|
||||||
|
def getHighestSerialNumber(self):
|
||||||
|
"""
|
||||||
|
Return the highest serial number for this Part.
|
||||||
|
|
||||||
|
Note: Serial numbers must be unique across an entire Part "tree",
|
||||||
|
so we filter by the entire tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||||
|
stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None).order_by('-serial')
|
||||||
|
|
||||||
|
if stock.count() > 0:
|
||||||
|
return stock.first().serial
|
||||||
|
|
||||||
|
# No serial numbers found
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getNextSerialNumber(self):
|
||||||
|
"""
|
||||||
|
Return the next-available serial number for this Part.
|
||||||
|
"""
|
||||||
|
|
||||||
|
n = self.getHighestSerialNumber()
|
||||||
|
|
||||||
|
if n is None:
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
return n + 1
|
||||||
|
|
||||||
|
def getSerialNumberString(self, quantity):
|
||||||
|
"""
|
||||||
|
Return a formatted string representing the next available serial numbers,
|
||||||
|
given a certain quantity of items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sn = self.getNextSerialNumber()
|
||||||
|
|
||||||
|
if quantity >= 2:
|
||||||
|
sn = "{n}-{m}".format(
|
||||||
|
n=sn,
|
||||||
|
m=int(sn + quantity - 1)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sn = str(sn)
|
||||||
|
|
||||||
|
return sn
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def full_name(self):
|
def full_name(self):
|
||||||
""" Format a 'full name' for this Part.
|
""" Format a 'full name' for this Part.
|
||||||
@ -638,32 +702,40 @@ class Part(models.Model):
|
|||||||
self.sales_order_allocation_count(),
|
self.sales_order_allocation_count(),
|
||||||
])
|
])
|
||||||
|
|
||||||
@property
|
def stock_entries(self, include_variants=True, in_stock=None):
|
||||||
def stock_entries(self):
|
""" Return all stock entries for this Part.
|
||||||
""" Return all 'in stock' items. To be in stock:
|
|
||||||
|
|
||||||
- build_order is None
|
- If this is a template part, include variants underneath this.
|
||||||
- sales_order is None
|
|
||||||
- belongs_to is None
|
Note: To return all stock-entries for all part variants under this one,
|
||||||
|
we need to be creative with the filtering.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
if include_variants:
|
||||||
|
query = StockModels.StockItem.objects.filter(part__in=self.get_descendants(include_self=True))
|
||||||
|
else:
|
||||||
|
query = self.stock_items
|
||||||
|
|
||||||
|
if in_stock is True:
|
||||||
|
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||||
|
elif in_stock is False:
|
||||||
|
query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER)
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_stock(self):
|
def total_stock(self):
|
||||||
""" Return the total stock quantity for this part.
|
""" Return the total stock quantity for this part.
|
||||||
Part may be stored in multiple locations
|
|
||||||
|
- Part may be stored in multiple locations
|
||||||
|
- If this part is a "template" (variants exist) then these are counted too
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.is_template:
|
entries = self.stock_entries(in_stock=True)
|
||||||
total = sum([variant.total_stock for variant in self.variants.all()])
|
|
||||||
else:
|
|
||||||
total = self.stock_entries.filter(status__in=StockStatus.AVAILABLE_CODES).aggregate(total=Sum('quantity'))['total']
|
|
||||||
|
|
||||||
if total:
|
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
|
||||||
return total
|
|
||||||
else:
|
return query['t']
|
||||||
return Decimal(0)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_bom(self):
|
def has_bom(self):
|
||||||
|
@ -16,6 +16,20 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
enableDragAndDrop(
|
||||||
|
'#attachment-dropzone',
|
||||||
|
"{% url 'part-attachment-create' %}",
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
part: {{ part.id }},
|
||||||
|
},
|
||||||
|
label: 'attachment',
|
||||||
|
success: function(data, status, xhr) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
$("#new-attachment").click(function() {
|
$("#new-attachment").click(function() {
|
||||||
launchModalForm("{% url 'part-attachment-create' %}?part={{ part.id }}",
|
launchModalForm("{% url 'part-attachment-create' %}?part={{ part.id }}",
|
||||||
{
|
{
|
||||||
|
@ -85,7 +85,7 @@ class PartAPITest(APITestCase):
|
|||||||
data = {'cascade': True}
|
data = {'cascade': True}
|
||||||
response = self.client.get(url, data, format='json')
|
response = self.client.get(url, data, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), 8)
|
self.assertEqual(len(response.data), 13)
|
||||||
|
|
||||||
def test_get_parts_by_cat(self):
|
def test_get_parts_by_cat(self):
|
||||||
url = reverse('api-part-list')
|
url = reverse('api-part-list')
|
||||||
|
@ -88,9 +88,9 @@ class CategoryTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(self.electronics.partcount(), 3)
|
self.assertEqual(self.electronics.partcount(), 3)
|
||||||
|
|
||||||
self.assertEqual(self.mechanical.partcount(), 4)
|
self.assertEqual(self.mechanical.partcount(), 8)
|
||||||
self.assertEqual(self.mechanical.partcount(active=True), 3)
|
self.assertEqual(self.mechanical.partcount(active=True), 7)
|
||||||
self.assertEqual(self.mechanical.partcount(False), 2)
|
self.assertEqual(self.mechanical.partcount(False), 6)
|
||||||
|
|
||||||
self.assertEqual(self.electronics.item_count, self.electronics.partcount())
|
self.assertEqual(self.electronics.item_count, self.electronics.partcount())
|
||||||
|
|
||||||
|
@ -52,6 +52,20 @@ class PartTest(TestCase):
|
|||||||
|
|
||||||
self.C1 = Part.objects.get(name='C_22N_0805')
|
self.C1 = Part.objects.get(name='C_22N_0805')
|
||||||
|
|
||||||
|
Part.objects.rebuild()
|
||||||
|
|
||||||
|
def test_tree(self):
|
||||||
|
# Test that the part variant tree is working properly
|
||||||
|
chair = Part.objects.get(pk=10000)
|
||||||
|
self.assertEqual(chair.get_children().count(), 3)
|
||||||
|
self.assertEqual(chair.get_descendant_count(), 4)
|
||||||
|
|
||||||
|
green = Part.objects.get(pk=10004)
|
||||||
|
self.assertEqual(green.get_ancestors().count(), 2)
|
||||||
|
self.assertEqual(green.get_root(), chair)
|
||||||
|
self.assertEqual(green.get_family().count(), 3)
|
||||||
|
self.assertEqual(Part.objects.filter(tree_id=chair.tree_id).count(), 5)
|
||||||
|
|
||||||
def test_str(self):
|
def test_str(self):
|
||||||
p = Part.objects.get(pk=100)
|
p = Part.objects.get(pk=100)
|
||||||
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
||||||
|
@ -10,6 +10,7 @@ import import_export.widgets as widgets
|
|||||||
|
|
||||||
from .models import StockLocation, StockItem, StockItemAttachment
|
from .models import StockLocation, StockItem, StockItemAttachment
|
||||||
from .models import StockItemTracking
|
from .models import StockItemTracking
|
||||||
|
from .models import StockItemTestResult
|
||||||
|
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
@ -117,7 +118,13 @@ class StockTrackingAdmin(ImportExportModelAdmin):
|
|||||||
list_display = ('item', 'date', 'title')
|
list_display = ('item', 'date', 'title')
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemTestResultAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
list_display = ('stock_item', 'test', 'result', 'value')
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(StockLocation, LocationAdmin)
|
admin.site.register(StockLocation, LocationAdmin)
|
||||||
admin.site.register(StockItem, StockItemAdmin)
|
admin.site.register(StockItem, StockItemAdmin)
|
||||||
admin.site.register(StockItemTracking, StockTrackingAdmin)
|
admin.site.register(StockItemTracking, StockTrackingAdmin)
|
||||||
admin.site.register(StockItemAttachment, StockAttachmentAdmin)
|
admin.site.register(StockItemAttachment, StockAttachmentAdmin)
|
||||||
|
admin.site.register(StockItemTestResult, StockItemTestResultAdmin)
|
||||||
|
@ -15,6 +15,7 @@ from django.db.models import Q
|
|||||||
from .models import StockLocation, StockItem
|
from .models import StockLocation, StockItem
|
||||||
from .models import StockItemTracking
|
from .models import StockItemTracking
|
||||||
from .models import StockItemAttachment
|
from .models import StockItemAttachment
|
||||||
|
from .models import StockItemTestResult
|
||||||
|
|
||||||
from part.models import Part, PartCategory
|
from part.models import Part, PartCategory
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
@ -26,6 +27,7 @@ from .serializers import StockItemSerializer
|
|||||||
from .serializers import LocationSerializer, LocationBriefSerializer
|
from .serializers import LocationSerializer, LocationBriefSerializer
|
||||||
from .serializers import StockTrackingSerializer
|
from .serializers import StockTrackingSerializer
|
||||||
from .serializers import StockItemAttachmentSerializer
|
from .serializers import StockItemAttachmentSerializer
|
||||||
|
from .serializers import StockItemTestResultSerializer
|
||||||
|
|
||||||
from InvenTree.views import TreeSerializer
|
from InvenTree.views import TreeSerializer
|
||||||
from InvenTree.helpers import str2bool, isNull
|
from InvenTree.helpers import str2bool, isNull
|
||||||
@ -536,11 +538,10 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
try:
|
try:
|
||||||
part = Part.objects.get(pk=part_id)
|
part = Part.objects.get(pk=part_id)
|
||||||
|
|
||||||
# If the part is a Template part, select stock items for any "variant" parts under that template
|
# Filter by any parts "under" the given part
|
||||||
if part.is_template:
|
parts = part.get_descendants(include_self=True)
|
||||||
queryset = queryset.filter(part__in=[part.id for part in Part.objects.filter(variant_of=part_id)])
|
|
||||||
else:
|
queryset = queryset.filter(part__in=parts)
|
||||||
queryset = queryset.filter(part=part_id)
|
|
||||||
|
|
||||||
except (ValueError, Part.DoesNotExist):
|
except (ValueError, Part.DoesNotExist):
|
||||||
raise ValidationError({"part": "Invalid Part ID specified"})
|
raise ValidationError({"part": "Invalid Part ID specified"})
|
||||||
@ -654,11 +655,89 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
queryset = StockItemAttachment.objects.all()
|
queryset = StockItemAttachment.objects.all()
|
||||||
serializer_class = StockItemAttachmentSerializer
|
serializer_class = StockItemAttachmentSerializer
|
||||||
|
|
||||||
|
filter_backends = [
|
||||||
|
DjangoFilterBackend,
|
||||||
|
filters.OrderingFilter,
|
||||||
|
filters.SearchFilter,
|
||||||
|
]
|
||||||
|
|
||||||
filter_fields = [
|
filter_fields = [
|
||||||
'stock_item',
|
'stock_item',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemTestResultList(generics.ListCreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for listing (and creating) a StockItemTestResult object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = StockItemTestResult.objects.all()
|
||||||
|
serializer_class = StockItemTestResultSerializer
|
||||||
|
|
||||||
|
permission_classes = [
|
||||||
|
permissions.IsAuthenticated,
|
||||||
|
]
|
||||||
|
|
||||||
|
filter_backends = [
|
||||||
|
DjangoFilterBackend,
|
||||||
|
filters.SearchFilter,
|
||||||
|
filters.OrderingFilter,
|
||||||
|
]
|
||||||
|
|
||||||
|
filter_fields = [
|
||||||
|
'stock_item',
|
||||||
|
'test',
|
||||||
|
'user',
|
||||||
|
'result',
|
||||||
|
'value',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
kwargs['attachment_detail'] = str2bool(self.request.query_params.get('attachment_detail', False))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""
|
||||||
|
Create a new test result object.
|
||||||
|
|
||||||
|
Also, check if an attachment was uploaded alongside the test result,
|
||||||
|
and save it to the database if it were.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Capture the user information
|
||||||
|
test_result = serializer.save()
|
||||||
|
test_result.user = self.request.user
|
||||||
|
|
||||||
|
# Check if a file has been attached to the request
|
||||||
|
attachment_file = self.request.FILES.get('attachment', None)
|
||||||
|
|
||||||
|
if attachment_file:
|
||||||
|
# Create a new attachment associated with the stock item
|
||||||
|
attachment = StockItemAttachment(
|
||||||
|
attachment=attachment_file,
|
||||||
|
stock_item=test_result.stock_item,
|
||||||
|
user=test_result.user
|
||||||
|
)
|
||||||
|
|
||||||
|
attachment.save()
|
||||||
|
|
||||||
|
# Link the attachment back to the test result
|
||||||
|
test_result.attachment = attachment
|
||||||
|
|
||||||
|
test_result.save()
|
||||||
|
|
||||||
|
|
||||||
class StockTrackingList(generics.ListCreateAPIView):
|
class StockTrackingList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for list view of StockItemTracking objects.
|
""" API endpoint for list view of StockItemTracking objects.
|
||||||
|
|
||||||
@ -769,6 +848,11 @@ stock_api_urls = [
|
|||||||
url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'),
|
url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
# Base URL for StockItemTestResult API endpoints
|
||||||
|
url(r'^test/', include([
|
||||||
|
url(r'^$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'),
|
||||||
|
])),
|
||||||
|
|
||||||
url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'),
|
url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'),
|
||||||
|
|
||||||
url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'),
|
url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'),
|
||||||
|
@ -69,3 +69,171 @@
|
|||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
rght: 0
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 105
|
||||||
|
fields:
|
||||||
|
part: 25
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 1000
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
# Stock items for template / variant parts
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 500
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 5
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 501
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 1
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 501
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 1
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 502
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 2
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 503
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 3
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 504
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 4
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 505
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 5
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 510
|
||||||
|
fields:
|
||||||
|
part: 10002
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 10
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 511
|
||||||
|
fields:
|
||||||
|
part: 10002
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 11
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 512
|
||||||
|
fields:
|
||||||
|
part: 10002
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 12
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 520
|
||||||
|
fields:
|
||||||
|
part: 10004
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 20
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 521
|
||||||
|
fields:
|
||||||
|
part: 10004
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 21
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 522
|
||||||
|
fields:
|
||||||
|
part: 10004
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 22
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
31
InvenTree/stock/fixtures/stock_tests.yaml
Normal file
31
InvenTree/stock/fixtures/stock_tests.yaml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
- model: stock.stockitemtestresult
|
||||||
|
fields:
|
||||||
|
stock_item: 105
|
||||||
|
test: "Firmware Version"
|
||||||
|
value: "0xA1B2C3D4"
|
||||||
|
result: True
|
||||||
|
date: 2020-02-02
|
||||||
|
|
||||||
|
- model: stock.stockitemtestresult
|
||||||
|
fields:
|
||||||
|
stock_item: 105
|
||||||
|
test: "Settings Checksum"
|
||||||
|
value: "0xAABBCCDD"
|
||||||
|
result: True
|
||||||
|
date: 2020-02-02
|
||||||
|
|
||||||
|
- model: stock.stockitemtestresult
|
||||||
|
fields:
|
||||||
|
stock_item: 105
|
||||||
|
test: "Temperature Test"
|
||||||
|
result: False
|
||||||
|
date: 2020-05-16
|
||||||
|
notes: 'Got too hot or something'
|
||||||
|
|
||||||
|
- model: stock.stockitemtestresult
|
||||||
|
fields:
|
||||||
|
stock_item: 105
|
||||||
|
test: "Temperature Test"
|
||||||
|
result: True
|
||||||
|
date: 2020-05-17
|
||||||
|
notes: 'Passed temperature test by making it cooler'
|
@ -13,7 +13,11 @@ from mptt.fields import TreeNodeChoiceField
|
|||||||
|
|
||||||
from InvenTree.helpers import GetExportFormats
|
from InvenTree.helpers import GetExportFormats
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from .models import StockLocation, StockItem, StockItemTracking, StockItemAttachment
|
from InvenTree.fields import RoundingDecimalFormField
|
||||||
|
|
||||||
|
from .models import StockLocation, StockItem, StockItemTracking
|
||||||
|
from .models import StockItemAttachment
|
||||||
|
from .models import StockItemTestResult
|
||||||
|
|
||||||
|
|
||||||
class EditStockItemAttachmentForm(HelperForm):
|
class EditStockItemAttachmentForm(HelperForm):
|
||||||
@ -30,6 +34,22 @@ class EditStockItemAttachmentForm(HelperForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EditStockItemTestResultForm(HelperForm):
|
||||||
|
"""
|
||||||
|
Form for creating / editing a StockItemTestResult object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = StockItemTestResult
|
||||||
|
fields = [
|
||||||
|
'stock_item',
|
||||||
|
'test',
|
||||||
|
'result',
|
||||||
|
'value',
|
||||||
|
'notes',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class EditStockLocationForm(HelperForm):
|
class EditStockLocationForm(HelperForm):
|
||||||
""" Form for editing a StockLocation """
|
""" Form for editing a StockLocation """
|
||||||
|
|
||||||
@ -47,6 +67,15 @@ class CreateStockItemForm(HelperForm):
|
|||||||
|
|
||||||
serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
|
serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
|
self.field_prefix = {
|
||||||
|
'serial_numbers': 'fa-hashtag',
|
||||||
|
'link': 'fa-link',
|
||||||
|
}
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StockItem
|
model = StockItem
|
||||||
fields = [
|
fields = [
|
||||||
@ -69,6 +98,7 @@ class CreateStockItemForm(HelperForm):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.cleaned_data = {}
|
self.cleaned_data = {}
|
||||||
|
|
||||||
# If the form is permitted to be empty, and none of the form data has
|
# If the form is permitted to be empty, and none of the form data has
|
||||||
# changed from the initial data, short circuit any validation.
|
# changed from the initial data, short circuit any validation.
|
||||||
if self.empty_permitted and not self.has_changed():
|
if self.empty_permitted and not self.has_changed():
|
||||||
@ -79,7 +109,7 @@ class CreateStockItemForm(HelperForm):
|
|||||||
self._clean_form()
|
self._clean_form()
|
||||||
|
|
||||||
|
|
||||||
class SerializeStockForm(forms.ModelForm):
|
class SerializeStockForm(HelperForm):
|
||||||
""" Form for serializing a StockItem. """
|
""" Form for serializing a StockItem. """
|
||||||
|
|
||||||
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label='Destination', required=True, help_text='Destination for serialized stock (by default, will remain in current location)')
|
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label='Destination', required=True, help_text='Destination for serialized stock (by default, will remain in current location)')
|
||||||
@ -88,6 +118,18 @@ class SerializeStockForm(forms.ModelForm):
|
|||||||
|
|
||||||
note = forms.CharField(label='Notes', required=False, help_text='Add transaction note (optional)')
|
note = forms.CharField(label='Notes', required=False, help_text='Add transaction note (optional)')
|
||||||
|
|
||||||
|
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
|
# Extract the stock item
|
||||||
|
item = kwargs.pop('item', None)
|
||||||
|
|
||||||
|
if item:
|
||||||
|
self.field_placeholder['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StockItem
|
model = StockItem
|
||||||
|
|
||||||
|
18
InvenTree/stock/migrations/0039_auto_20200513_0016.py
Normal file
18
InvenTree/stock/migrations/0039_auto_20200513_0016.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.5 on 2020-05-13 00:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stock', '0038_stockitemattachment_upload_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='stockitemattachment',
|
||||||
|
name='comment',
|
||||||
|
field=models.CharField(blank=True, help_text='File comment', max_length=100),
|
||||||
|
),
|
||||||
|
]
|
29
InvenTree/stock/migrations/0040_stockitemtestresult.py
Normal file
29
InvenTree/stock/migrations/0040_stockitemtestresult.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 3.0.5 on 2020-05-16 09:55
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('stock', '0039_auto_20200513_0016'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StockItemTestResult',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('test', models.CharField(help_text='Test name', max_length=100, verbose_name='Test')),
|
||||||
|
('result', models.BooleanField(default=False, help_text='Test result', verbose_name='Result')),
|
||||||
|
('value', models.CharField(blank=True, help_text='Test output value', max_length=500, verbose_name='Value')),
|
||||||
|
('date', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('attachment', models.ForeignKey(blank=True, help_text='Test result attachment', null=True, on_delete=django.db.models.deletion.SET_NULL, to='stock.StockItemAttachment', verbose_name='Attachment')),
|
||||||
|
('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_results', to='stock.StockItem')),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
18
InvenTree/stock/migrations/0041_stockitemtestresult_notes.py
Normal file
18
InvenTree/stock/migrations/0041_stockitemtestresult_notes.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.5 on 2020-05-16 10:14
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stock', '0040_stockitemtestresult'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='stockitemtestresult',
|
||||||
|
name='notes',
|
||||||
|
field=models.CharField(blank=True, help_text='Test notes', max_length=500, verbose_name='Notes'),
|
||||||
|
),
|
||||||
|
]
|
@ -142,9 +142,21 @@ class StockItem(MPTTModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Save this StockItem to the database. Performs a number of checks:
|
||||||
|
|
||||||
|
- Unique serial number requirement
|
||||||
|
- Adds a transaction note when the item is first created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.validate_unique()
|
||||||
|
self.clean()
|
||||||
|
|
||||||
if not self.pk:
|
if not self.pk:
|
||||||
|
# StockItem has not yet been saved
|
||||||
add_note = True
|
add_note = True
|
||||||
else:
|
else:
|
||||||
|
# StockItem has already been saved
|
||||||
add_note = False
|
add_note = False
|
||||||
|
|
||||||
user = kwargs.pop('user', None)
|
user = kwargs.pop('user', None)
|
||||||
@ -172,59 +184,26 @@ class StockItem(MPTTModel):
|
|||||||
""" Return True if this StockItem is serialized """
|
""" Return True if this StockItem is serialized """
|
||||||
return self.serial is not None and self.quantity == 1
|
return self.serial is not None and self.quantity == 1
|
||||||
|
|
||||||
@classmethod
|
def validate_unique(self, exclude=None):
|
||||||
def check_serial_number(cls, part, serial_number):
|
"""
|
||||||
""" Check if a new stock item can be created with the provided part_id
|
Test that this StockItem is "unique".
|
||||||
|
If the StockItem is serialized, the same serial number.
|
||||||
Args:
|
cannot exist for the same part (or part tree).
|
||||||
part: The part to be checked
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not part.trackable:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Return False if an invalid serial number is supplied
|
|
||||||
try:
|
|
||||||
serial_number = int(serial_number)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
items = StockItem.objects.filter(serial=serial_number)
|
|
||||||
|
|
||||||
# Is this part a variant? If so, check S/N across all sibling variants
|
|
||||||
if part.variant_of is not None:
|
|
||||||
items = items.filter(part__variant_of=part.variant_of)
|
|
||||||
else:
|
|
||||||
items = items.filter(part=part)
|
|
||||||
|
|
||||||
# An existing serial number exists
|
|
||||||
if items.exists():
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def validate_unique(self, exclude=None):
|
|
||||||
super(StockItem, self).validate_unique(exclude)
|
super(StockItem, self).validate_unique(exclude)
|
||||||
|
|
||||||
# If the Part object is a variant (of a template part),
|
if self.serial is not None:
|
||||||
# ensure that the serial number is unique
|
# Query to look for duplicate serial numbers
|
||||||
# across all variants of the same template part
|
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
|
||||||
|
stock = StockItem.objects.filter(part__in=parts, serial=self.serial)
|
||||||
|
|
||||||
try:
|
# Exclude myself from the search
|
||||||
if self.serial is not None:
|
if self.pk is not None:
|
||||||
# This is a variant part (check S/N across all sibling variants)
|
stock = stock.exclude(pk=self.pk)
|
||||||
if self.part.variant_of is not None:
|
|
||||||
if StockItem.objects.filter(part__variant_of=self.part.variant_of, serial=self.serial).exclude(id=self.id).exists():
|
if stock.exists():
|
||||||
raise ValidationError({
|
raise ValidationError({"serial": _("StockItem with this serial number already exists")})
|
||||||
'serial': _('A stock item with this serial number already exists for template part {part}'.format(part=self.part.variant_of))
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
if StockItem.objects.filter(part=self.part, serial=self.serial).exclude(id=self.id).exists():
|
|
||||||
raise ValidationError({
|
|
||||||
'serial': _('A stock item with this serial number already exists')
|
|
||||||
})
|
|
||||||
except PartModels.Part.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
""" Validate the StockItem object (separate to field validation)
|
""" Validate the StockItem object (separate to field validation)
|
||||||
@ -236,6 +215,8 @@ class StockItem(MPTTModel):
|
|||||||
- Quantity must be 1 if the StockItem has a serial number
|
- Quantity must be 1 if the StockItem has a serial number
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
super().clean()
|
||||||
|
|
||||||
if self.status == StockStatus.SHIPPED and self.sales_order is None:
|
if self.status == StockStatus.SHIPPED and self.sales_order is None:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'sales_order': "SalesOrder must be specified as status is marked as SHIPPED",
|
'sales_order': "SalesOrder must be specified as status is marked as SHIPPED",
|
||||||
@ -248,6 +229,24 @@ class StockItem(MPTTModel):
|
|||||||
'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set',
|
'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.part.trackable:
|
||||||
|
# Trackable parts must have integer values for quantity field!
|
||||||
|
if not self.quantity == int(self.quantity):
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': _('Quantity must be integer value for trackable parts')
|
||||||
|
})
|
||||||
|
except PartModels.Part.DoesNotExist:
|
||||||
|
# For some reason the 'clean' process sometimes throws errors because self.part does not exist
|
||||||
|
# It *seems* that this only occurs in unit testing, though.
|
||||||
|
# Probably should investigate this at some point.
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.quantity < 0:
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': _('Quantity must be greater than zero')
|
||||||
|
})
|
||||||
|
|
||||||
# The 'supplier_part' field must point to the same part!
|
# The 'supplier_part' field must point to the same part!
|
||||||
try:
|
try:
|
||||||
if self.supplier_part is not None:
|
if self.supplier_part is not None:
|
||||||
@ -599,6 +598,9 @@ class StockItem(MPTTModel):
|
|||||||
if self.serialized:
|
if self.serialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not self.part.trackable:
|
||||||
|
raise ValidationError({"part": _("Part is not set as trackable")})
|
||||||
|
|
||||||
# Quantity must be a valid integer value
|
# Quantity must be a valid integer value
|
||||||
try:
|
try:
|
||||||
quantity = int(quantity)
|
quantity = int(quantity)
|
||||||
@ -624,7 +626,7 @@ class StockItem(MPTTModel):
|
|||||||
existing = []
|
existing = []
|
||||||
|
|
||||||
for serial in serials:
|
for serial in serials:
|
||||||
if not StockItem.check_serial_number(self.part, serial):
|
if self.part.checkIfSerialNumberExists(serial):
|
||||||
existing.append(serial)
|
existing.append(serial)
|
||||||
|
|
||||||
if len(existing) > 0:
|
if len(existing) > 0:
|
||||||
@ -919,6 +921,51 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
def getTestResults(self, test=None, result=None, user=None):
|
||||||
|
"""
|
||||||
|
Return all test results associated with this StockItem.
|
||||||
|
|
||||||
|
Optionally can filter results by:
|
||||||
|
- Test name
|
||||||
|
- Test result
|
||||||
|
- User
|
||||||
|
"""
|
||||||
|
|
||||||
|
results = self.test_results
|
||||||
|
|
||||||
|
if test:
|
||||||
|
# Filter by test name
|
||||||
|
results = results.filter(test=test)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
# Filter by test status
|
||||||
|
results = results.filter(result=result)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# Filter by user
|
||||||
|
results = results.filter(user=user)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def testResultMap(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Return a map of test-results using the test name as the key.
|
||||||
|
Where multiple test results exist for a given name,
|
||||||
|
the *most recent* test is used.
|
||||||
|
|
||||||
|
This map is useful for rendering to a template (e.g. a test report),
|
||||||
|
as all named tests are accessible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
results = self.getTestResults(**kwargs).order_by('-date')
|
||||||
|
|
||||||
|
result_map = {}
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
result_map[result.test] = result
|
||||||
|
|
||||||
|
return result_map
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
|
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
|
||||||
def before_delete_stock_item(sender, instance, using, **kwargs):
|
def before_delete_stock_item(sender, instance, using, **kwargs):
|
||||||
@ -991,3 +1038,86 @@ class StockItemTracking(models.Model):
|
|||||||
|
|
||||||
# TODO
|
# TODO
|
||||||
# file = models.FileField()
|
# file = models.FileField()
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemTestResult(models.Model):
|
||||||
|
"""
|
||||||
|
A StockItemTestResult records results of custom tests against individual StockItem objects.
|
||||||
|
This is useful for tracking unit acceptance tests, and particularly useful when integrated
|
||||||
|
with automated testing setups.
|
||||||
|
|
||||||
|
Multiple results can be recorded against any given test, allowing tests to be run many times.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
stock_item: Link to StockItem
|
||||||
|
test: Test name (simple string matching)
|
||||||
|
result: Test result value (pass / fail / etc)
|
||||||
|
value: Recorded test output value (optional)
|
||||||
|
attachment: Link to StockItem attachment (optional)
|
||||||
|
notes: Extra user notes related to the test (optional)
|
||||||
|
user: User who uploaded the test result
|
||||||
|
date: Date the test result was recorded
|
||||||
|
"""
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# If an attachment is linked to this result, the attachment must also point to the item
|
||||||
|
try:
|
||||||
|
if self.attachment:
|
||||||
|
if not self.attachment.stock_item == self.stock_item:
|
||||||
|
raise ValidationError({
|
||||||
|
'attachment': _("Test result attachment must be linked to the same StockItem"),
|
||||||
|
})
|
||||||
|
except (StockItem.DoesNotExist, StockItemAttachment.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
|
stock_item = models.ForeignKey(
|
||||||
|
StockItem,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='test_results'
|
||||||
|
)
|
||||||
|
|
||||||
|
test = models.CharField(
|
||||||
|
blank=False, max_length=100,
|
||||||
|
verbose_name=_('Test'),
|
||||||
|
help_text=_('Test name')
|
||||||
|
)
|
||||||
|
|
||||||
|
result = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('Result'),
|
||||||
|
help_text=_('Test result')
|
||||||
|
)
|
||||||
|
|
||||||
|
value = models.CharField(
|
||||||
|
blank=True, max_length=500,
|
||||||
|
verbose_name=_('Value'),
|
||||||
|
help_text=_('Test output value')
|
||||||
|
)
|
||||||
|
|
||||||
|
attachment = models.ForeignKey(
|
||||||
|
StockItemAttachment,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
blank=True, null=True,
|
||||||
|
verbose_name=_('Attachment'),
|
||||||
|
help_text=_('Test result attachment'),
|
||||||
|
)
|
||||||
|
|
||||||
|
notes = models.CharField(
|
||||||
|
blank=True, max_length=500,
|
||||||
|
verbose_name=_('Notes'),
|
||||||
|
help_text=_("Test notes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
blank=True, null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
date = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
editable=False
|
||||||
|
)
|
||||||
|
@ -7,6 +7,7 @@ from rest_framework import serializers
|
|||||||
from .models import StockItem, StockLocation
|
from .models import StockItem, StockLocation
|
||||||
from .models import StockItemTracking
|
from .models import StockItemTracking
|
||||||
from .models import StockItemAttachment
|
from .models import StockItemAttachment
|
||||||
|
from .models import StockItemTestResult
|
||||||
|
|
||||||
from django.db.models import Sum, Count
|
from django.db.models import Sum, Count
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
@ -193,7 +194,7 @@ class LocationSerializer(InvenTreeModelSerializer):
|
|||||||
class StockItemAttachmentSerializer(InvenTreeModelSerializer):
|
class StockItemAttachmentSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for StockItemAttachment model """
|
""" Serializer for StockItemAttachment model """
|
||||||
|
|
||||||
def __init_(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
user_detail = kwargs.pop('user_detail', False)
|
user_detail = kwargs.pop('user_detail', False)
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@ -211,11 +212,55 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
'stock_item',
|
'stock_item',
|
||||||
'attachment',
|
'attachment',
|
||||||
'comment',
|
'comment',
|
||||||
|
'upload_date',
|
||||||
'user',
|
'user',
|
||||||
'user_detail',
|
'user_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemTestResultSerializer(InvenTreeModelSerializer):
|
||||||
|
""" Serializer for the StockItemTestResult model """
|
||||||
|
|
||||||
|
user_detail = UserSerializerBrief(source='user', read_only=True)
|
||||||
|
attachment_detail = StockItemAttachmentSerializer(source='attachment', read_only=True)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user_detail = kwargs.pop('user_detail', False)
|
||||||
|
attachment_detail = kwargs.pop('attachment_detail', False)
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if user_detail is not True:
|
||||||
|
self.fields.pop('user_detail')
|
||||||
|
|
||||||
|
if attachment_detail is not True:
|
||||||
|
self.fields.pop('attachment_detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = StockItemTestResult
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'stock_item',
|
||||||
|
'test',
|
||||||
|
'result',
|
||||||
|
'value',
|
||||||
|
'attachment',
|
||||||
|
'attachment_detail',
|
||||||
|
'notes',
|
||||||
|
'user',
|
||||||
|
'user_detail',
|
||||||
|
'date'
|
||||||
|
]
|
||||||
|
|
||||||
|
read_only_fields = [
|
||||||
|
'pk',
|
||||||
|
'attachment',
|
||||||
|
'user',
|
||||||
|
'date',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockTrackingSerializer(InvenTreeModelSerializer):
|
class StockTrackingSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for StockItemTracking model """
|
""" Serializer for StockItemTracking model """
|
||||||
|
|
||||||
|
@ -8,8 +8,9 @@
|
|||||||
|
|
||||||
{% include "stock/tabs.html" with tab="tracking" %}
|
{% include "stock/tabs.html" with tab="tracking" %}
|
||||||
|
|
||||||
<hr>
|
|
||||||
<h4>{% trans "Stock Tracking Information" %}</h4>
|
<h4>{% trans "Stock Tracking Information" %}</h4>
|
||||||
|
<hr>
|
||||||
|
|
||||||
<div id='table-toolbar'>
|
<div id='table-toolbar'>
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>New Entry</button>
|
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>New Entry</button>
|
||||||
|
@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
{% include "stock/tabs.html" with tab='attachments' %}
|
{% include "stock/tabs.html" with tab='attachments' %}
|
||||||
|
|
||||||
<hr>
|
|
||||||
<h4>{% trans "Stock Item Attachments" %}</h4>
|
<h4>{% trans "Stock Item Attachments" %}</h4>
|
||||||
|
<hr>
|
||||||
|
|
||||||
{% include "attachment_table.html" with attachments=item.attachments.all %}
|
{% include "attachment_table.html" with attachments=item.attachments.all %}
|
||||||
|
|
||||||
@ -17,6 +17,20 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
enableDragAndDrop(
|
||||||
|
'#attachment-dropzone',
|
||||||
|
"{% url 'stock-item-attachment-create' %}",
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
stock_item: {{ item.id }},
|
||||||
|
},
|
||||||
|
label: 'attachment',
|
||||||
|
success: function(data, status, xhr) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
$("#new-attachment").click(function() {
|
$("#new-attachment").click(function() {
|
||||||
launchModalForm("{% url 'stock-item-attachment-create' %}?item={{ item.id }}",
|
launchModalForm("{% url 'stock-item-attachment-create' %}?item={{ item.id }}",
|
||||||
{
|
{
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
{% include "stock/tabs.html" with tab="notes" %}
|
{% include "stock/tabs.html" with tab="notes" %}
|
||||||
|
|
||||||
{% if editing %}
|
{% if editing %}
|
||||||
|
|
||||||
<h4>{% trans "Stock Item Notes" %}</h4>
|
<h4>{% trans "Stock Item Notes" %}</h4>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
76
InvenTree/stock/templates/stock/item_tests.html
Normal file
76
InvenTree/stock/templates/stock/item_tests.html
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{% extends "stock/item_base.html" %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block details %}
|
||||||
|
|
||||||
|
{% include "stock/tabs.html" with tab='tests' %}
|
||||||
|
|
||||||
|
<h4>{% trans "Test Results" %}</h4>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div id='button-toolbar'>
|
||||||
|
<div class='button-toolbar container-fluid' style="float: right;">
|
||||||
|
<div class='btn-group' role='group'>
|
||||||
|
<button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Result" %}</button>
|
||||||
|
</div>
|
||||||
|
<div class='filter-list' id='filter-list-stocktests'>
|
||||||
|
<!-- Empty div -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='test-result-table'></table>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
loadStockTestResultsTable(
|
||||||
|
$("#test-result-table"), {
|
||||||
|
params: {
|
||||||
|
stock_item: {{ item.id }},
|
||||||
|
user_detail: true,
|
||||||
|
attachment_detail: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function reloadTable() {
|
||||||
|
$("#test-result-table").bootstrapTable("refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#add-test-result").click(function() {
|
||||||
|
launchModalForm(
|
||||||
|
"{% url 'stock-item-test-create' %}", {
|
||||||
|
data: {
|
||||||
|
stock_item: {{ item.id }},
|
||||||
|
},
|
||||||
|
success: reloadTable,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#test-result-table").on('click', '.button-test-edit', function() {
|
||||||
|
var button = $(this);
|
||||||
|
|
||||||
|
var url = `/stock/item/test/${button.attr('pk')}/edit/`;
|
||||||
|
|
||||||
|
launchModalForm(url, {
|
||||||
|
success: reloadTable,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#test-result-table").on('click', '.button-test-delete', function() {
|
||||||
|
var button = $(this);
|
||||||
|
|
||||||
|
var url = `/stock/item/test/${button.attr('pk')}/delete/`;
|
||||||
|
|
||||||
|
launchModalForm(url, {
|
||||||
|
success: reloadTable,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -1,15 +1,20 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
<ul class='nav nav-tabs'>
|
<ul class='nav nav-tabs'>
|
||||||
<li{% ifequal tab 'children' %} class='active'{% endifequal %}>
|
|
||||||
<a href="{% url 'stock-item-children' item.id %}">{% trans "Children" %}{% if item.child_count > 0 %}<span class='badge'>{{ item.child_count }}</span>{% endif %}</a>
|
|
||||||
</li>
|
|
||||||
<li{% ifequal tab 'tracking' %} class='active'{% endifequal %}>
|
<li{% ifequal tab 'tracking' %} class='active'{% endifequal %}>
|
||||||
<a href="{% url 'stock-item-detail' item.id %}">
|
<a href="{% url 'stock-item-detail' item.id %}">
|
||||||
{% trans "Tracking" %}
|
{% trans "Tracking" %}
|
||||||
{% if item.tracking_info.count > 0 %}<span class='badge'>{{ item.tracking_info.count }}</span>{% endif %}
|
{% if item.tracking_info.count > 0 %}<span class='badge'>{{ item.tracking_info.count }}</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if item.part.trackable %}
|
||||||
|
<li{% if tab == 'tests' %} class='active'{% endif %}>
|
||||||
|
<a href="{% url 'stock-item-test-results' item.id %}">
|
||||||
|
{% trans "Test Results" %}
|
||||||
|
{% if item.test_results.count > 0 %}<span class='badge'>{{ item.test_results.count }}</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% if 0 %}
|
{% if 0 %}
|
||||||
<!-- These tabs are to be implemented in the future -->
|
<!-- These tabs are to be implemented in the future -->
|
||||||
<li{% ifequal tab 'builds' %} class='active'{% endifequal %}>
|
<li{% ifequal tab 'builds' %} class='active'{% endifequal %}>
|
||||||
@ -28,4 +33,9 @@
|
|||||||
{% if item.attachments.count > 0 %}<span class='badge'>{{ item.attachments.count }}</span>{% endif %}
|
{% if item.attachments.count > 0 %}<span class='badge'>{{ item.attachments.count }}</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if item.child_count > 0 %}
|
||||||
|
<li{% ifequal tab 'children' %} class='active'{% endifequal %}>
|
||||||
|
<a href="{% url 'stock-item-children' item.id %}">{% trans "Children" %}{% if item.child_count > 0 %}<span class='badge'>{{ item.child_count }}</span>{% endif %}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
@ -6,11 +6,17 @@ from django.contrib.auth import get_user_model
|
|||||||
from .models import StockLocation
|
from .models import StockLocation
|
||||||
|
|
||||||
|
|
||||||
class StockLocationTest(APITestCase):
|
class StockAPITestCase(APITestCase):
|
||||||
"""
|
|
||||||
Series of API tests for the StockLocation API
|
fixtures = [
|
||||||
"""
|
'category',
|
||||||
list_url = reverse('api-location-list')
|
'part',
|
||||||
|
'company',
|
||||||
|
'location',
|
||||||
|
'supplier_part',
|
||||||
|
'stock',
|
||||||
|
'stock_tests',
|
||||||
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Create a user for auth
|
# Create a user for auth
|
||||||
@ -18,6 +24,21 @@ class StockLocationTest(APITestCase):
|
|||||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
self.client.login(username='testuser', password='password')
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
|
def doPost(self, url, data={}):
|
||||||
|
response = self.client.post(url, data=data, format='json')
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class StockLocationTest(StockAPITestCase):
|
||||||
|
"""
|
||||||
|
Series of API tests for the StockLocation API
|
||||||
|
"""
|
||||||
|
list_url = reverse('api-location-list')
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
# Add some stock locations
|
# Add some stock locations
|
||||||
StockLocation.objects.create(name='top', description='top category')
|
StockLocation.objects.create(name='top', description='top category')
|
||||||
|
|
||||||
@ -38,7 +59,7 @@ class StockLocationTest(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
class StockItemTest(APITestCase):
|
class StockItemTest(StockAPITestCase):
|
||||||
"""
|
"""
|
||||||
Series of API tests for the StockItem API
|
Series of API tests for the StockItem API
|
||||||
"""
|
"""
|
||||||
@ -49,11 +70,7 @@ class StockItemTest(APITestCase):
|
|||||||
return reverse('api-stock-detail', kwargs={'pk': pk})
|
return reverse('api-stock-detail', kwargs={'pk': pk})
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Create a user for auth
|
super().setUp()
|
||||||
User = get_user_model()
|
|
||||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
|
||||||
self.client.login(username='testuser', password='password')
|
|
||||||
|
|
||||||
# Create some stock locations
|
# Create some stock locations
|
||||||
top = StockLocation.objects.create(name='A', description='top')
|
top = StockLocation.objects.create(name='A', description='top')
|
||||||
|
|
||||||
@ -65,30 +82,11 @@ class StockItemTest(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class StocktakeTest(APITestCase):
|
class StocktakeTest(StockAPITestCase):
|
||||||
"""
|
"""
|
||||||
Series of tests for the Stocktake API
|
Series of tests for the Stocktake API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
fixtures = [
|
|
||||||
'category',
|
|
||||||
'part',
|
|
||||||
'company',
|
|
||||||
'location',
|
|
||||||
'supplier_part',
|
|
||||||
'stock',
|
|
||||||
]
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
User = get_user_model()
|
|
||||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
|
||||||
self.client.login(username='testuser', password='password')
|
|
||||||
|
|
||||||
def doPost(self, url, data={}):
|
|
||||||
response = self.client.post(url, data=data, format='json')
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def test_action(self):
|
def test_action(self):
|
||||||
"""
|
"""
|
||||||
Test each stocktake action endpoint,
|
Test each stocktake action endpoint,
|
||||||
@ -179,3 +177,82 @@ class StocktakeTest(APITestCase):
|
|||||||
|
|
||||||
response = self.doPost(url, data)
|
response = self.doPost(url, data)
|
||||||
self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST)
|
self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class StockTestResultTest(StockAPITestCase):
|
||||||
|
|
||||||
|
def get_url(self):
|
||||||
|
return reverse('api-stock-test-result-list')
|
||||||
|
|
||||||
|
def test_list(self):
|
||||||
|
|
||||||
|
url = self.get_url()
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertGreaterEqual(len(response.data), 4)
|
||||||
|
|
||||||
|
response = self.client.get(url, data={'stock_item': 105})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertGreaterEqual(len(response.data), 4)
|
||||||
|
|
||||||
|
def test_post_fail(self):
|
||||||
|
# Attempt to post a new test result without specifying required data
|
||||||
|
|
||||||
|
url = self.get_url()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
'test': 'A test',
|
||||||
|
'result': True,
|
||||||
|
},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# This one should pass!
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
'test': 'A test',
|
||||||
|
'stock_item': 105,
|
||||||
|
'result': True,
|
||||||
|
},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def test_post(self):
|
||||||
|
# Test creation of a new test result
|
||||||
|
|
||||||
|
url = self.get_url()
|
||||||
|
|
||||||
|
response = self.client.get(url)
|
||||||
|
n = len(response.data)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'stock_item': 105,
|
||||||
|
'test': 'Checked Steam Valve',
|
||||||
|
'result': False,
|
||||||
|
'value': '150kPa',
|
||||||
|
'notes': 'I guess there was just too much pressure?',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(url, data, format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(len(response.data), n + 1)
|
||||||
|
|
||||||
|
# And read out again
|
||||||
|
response = self.client.get(url, data={'test': 'Checked Steam Valve'})
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
|
||||||
|
test = response.data[0]
|
||||||
|
self.assertEqual(test['value'], '150kPa')
|
||||||
|
self.assertEqual(test['user'], 1)
|
||||||
|
@ -17,6 +17,7 @@ class StockTest(TestCase):
|
|||||||
'part',
|
'part',
|
||||||
'location',
|
'location',
|
||||||
'stock',
|
'stock',
|
||||||
|
'stock_tests',
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@ -38,6 +39,10 @@ class StockTest(TestCase):
|
|||||||
|
|
||||||
self.user = User.objects.get(username='username')
|
self.user = User.objects.get(username='username')
|
||||||
|
|
||||||
|
# Ensure the MPTT objects are correctly rebuild
|
||||||
|
Part.objects.rebuild()
|
||||||
|
StockItem.objects.rebuild()
|
||||||
|
|
||||||
def test_loc_count(self):
|
def test_loc_count(self):
|
||||||
self.assertEqual(StockLocation.objects.count(), 7)
|
self.assertEqual(StockLocation.objects.count(), 7)
|
||||||
|
|
||||||
@ -91,17 +96,20 @@ class StockTest(TestCase):
|
|||||||
self.assertFalse(self.drawer2.has_items())
|
self.assertFalse(self.drawer2.has_items())
|
||||||
|
|
||||||
# Drawer 3 should have three stock items
|
# Drawer 3 should have three stock items
|
||||||
self.assertEqual(self.drawer3.stock_items.count(), 3)
|
self.assertEqual(self.drawer3.stock_items.count(), 16)
|
||||||
self.assertEqual(self.drawer3.item_count, 3)
|
self.assertEqual(self.drawer3.item_count, 16)
|
||||||
|
|
||||||
def test_stock_count(self):
|
def test_stock_count(self):
|
||||||
part = Part.objects.get(pk=1)
|
part = Part.objects.get(pk=1)
|
||||||
|
entries = part.stock_entries()
|
||||||
|
|
||||||
# There should be 5000 screws in stock
|
self.assertEqual(entries.count(), 2)
|
||||||
|
|
||||||
|
# There should be 9000 screws in stock
|
||||||
self.assertEqual(part.total_stock, 9000)
|
self.assertEqual(part.total_stock, 9000)
|
||||||
|
|
||||||
# There should be 18 widgets in stock
|
# There should be 18 widgets in stock
|
||||||
self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 18)
|
self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 19)
|
||||||
|
|
||||||
def test_delete_location(self):
|
def test_delete_location(self):
|
||||||
|
|
||||||
@ -161,12 +169,12 @@ class StockTest(TestCase):
|
|||||||
self.assertEqual(w1.quantity, 4)
|
self.assertEqual(w1.quantity, 4)
|
||||||
|
|
||||||
# There should also be a new object still in drawer3
|
# There should also be a new object still in drawer3
|
||||||
self.assertEqual(StockItem.objects.filter(part=25).count(), 4)
|
self.assertEqual(StockItem.objects.filter(part=25).count(), 5)
|
||||||
widget = StockItem.objects.get(location=self.drawer3.id, part=25, quantity=4)
|
widget = StockItem.objects.get(location=self.drawer3.id, part=25, quantity=4)
|
||||||
|
|
||||||
# Try to move negative units
|
# Try to move negative units
|
||||||
self.assertFalse(widget.move(self.bathroom, 'Test', None, quantity=-100))
|
self.assertFalse(widget.move(self.bathroom, 'Test', None, quantity=-100))
|
||||||
self.assertEqual(StockItem.objects.filter(part=25).count(), 4)
|
self.assertEqual(StockItem.objects.filter(part=25).count(), 5)
|
||||||
|
|
||||||
# Try to move to a blank location
|
# Try to move to a blank location
|
||||||
self.assertFalse(widget.move(None, 'null', None))
|
self.assertFalse(widget.move(None, 'null', None))
|
||||||
@ -327,3 +335,99 @@ class StockTest(TestCase):
|
|||||||
|
|
||||||
# Serialize the remainder of the stock
|
# Serialize the remainder of the stock
|
||||||
item.serializeStock(2, [99, 100], self.user)
|
item.serializeStock(2, [99, 100], self.user)
|
||||||
|
|
||||||
|
|
||||||
|
class VariantTest(StockTest):
|
||||||
|
"""
|
||||||
|
Tests for calculation stock counts against templates / variants
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_variant_stock(self):
|
||||||
|
# Check the 'Chair' variant
|
||||||
|
chair = Part.objects.get(pk=10000)
|
||||||
|
|
||||||
|
# No stock items for the variant part itself
|
||||||
|
self.assertEqual(chair.stock_entries(include_variants=False).count(), 0)
|
||||||
|
|
||||||
|
self.assertEqual(chair.stock_entries().count(), 12)
|
||||||
|
|
||||||
|
green = Part.objects.get(pk=10003)
|
||||||
|
self.assertEqual(green.stock_entries(include_variants=False).count(), 0)
|
||||||
|
self.assertEqual(green.stock_entries().count(), 3)
|
||||||
|
|
||||||
|
def test_serial_numbers(self):
|
||||||
|
# Test serial number functionality for variant / template parts
|
||||||
|
|
||||||
|
chair = Part.objects.get(pk=10000)
|
||||||
|
|
||||||
|
# Operations on the top-level object
|
||||||
|
self.assertTrue(chair.checkIfSerialNumberExists(1))
|
||||||
|
self.assertTrue(chair.checkIfSerialNumberExists(2))
|
||||||
|
self.assertTrue(chair.checkIfSerialNumberExists(3))
|
||||||
|
self.assertTrue(chair.checkIfSerialNumberExists(4))
|
||||||
|
self.assertTrue(chair.checkIfSerialNumberExists(5))
|
||||||
|
|
||||||
|
self.assertTrue(chair.checkIfSerialNumberExists(20))
|
||||||
|
self.assertTrue(chair.checkIfSerialNumberExists(21))
|
||||||
|
self.assertTrue(chair.checkIfSerialNumberExists(22))
|
||||||
|
|
||||||
|
self.assertFalse(chair.checkIfSerialNumberExists(30))
|
||||||
|
|
||||||
|
self.assertEqual(chair.getNextSerialNumber(), 23)
|
||||||
|
|
||||||
|
# Same operations on a sub-item
|
||||||
|
variant = Part.objects.get(pk=10003)
|
||||||
|
self.assertEqual(variant.getNextSerialNumber(), 23)
|
||||||
|
|
||||||
|
# Create a new serial number
|
||||||
|
n = variant.getHighestSerialNumber()
|
||||||
|
|
||||||
|
item = StockItem(
|
||||||
|
part=variant,
|
||||||
|
quantity=1,
|
||||||
|
serial=n
|
||||||
|
)
|
||||||
|
|
||||||
|
# This should fail
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
# This should pass
|
||||||
|
item.serial = n + 1
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
# Attempt to create the same serial number but for a variant (should fail!)
|
||||||
|
item.pk = None
|
||||||
|
item.part = Part.objects.get(pk=10004)
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
item.serial += 1
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
|
||||||
|
class TestResultTest(StockTest):
|
||||||
|
"""
|
||||||
|
Tests for the StockItemTestResult model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_test_count(self):
|
||||||
|
item = StockItem.objects.get(pk=105)
|
||||||
|
tests = item.test_results
|
||||||
|
self.assertEqual(tests.count(), 4)
|
||||||
|
|
||||||
|
results = item.getTestResults(test="Temperature Test")
|
||||||
|
self.assertEqual(results.count(), 2)
|
||||||
|
|
||||||
|
# Passing tests
|
||||||
|
self.assertEqual(item.getTestResults(result=True).count(), 3)
|
||||||
|
self.assertEqual(item.getTestResults(result=False).count(), 1)
|
||||||
|
|
||||||
|
# Result map
|
||||||
|
result_map = item.testResultMap()
|
||||||
|
|
||||||
|
self.assertEqual(len(result_map), 3)
|
||||||
|
|
||||||
|
for test in ['Firmware Version', 'Settings Checksum', 'Temperature Test']:
|
||||||
|
self.assertIn(test, result_map.keys())
|
||||||
|
@ -24,6 +24,7 @@ stock_item_detail_urls = [
|
|||||||
|
|
||||||
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
|
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
|
||||||
|
|
||||||
|
url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'),
|
||||||
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
|
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
|
||||||
url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
|
url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
|
||||||
url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'),
|
url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'),
|
||||||
@ -51,12 +52,20 @@ stock_urls = [
|
|||||||
|
|
||||||
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
|
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
|
||||||
|
|
||||||
|
# URLs for StockItem attachments
|
||||||
url(r'^item/attachment/', include([
|
url(r'^item/attachment/', include([
|
||||||
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),
|
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),
|
||||||
url(r'^(?P<pk>\d+)/edit/', views.StockItemAttachmentEdit.as_view(), name='stock-item-attachment-edit'),
|
url(r'^(?P<pk>\d+)/edit/', views.StockItemAttachmentEdit.as_view(), name='stock-item-attachment-edit'),
|
||||||
url(r'^(?P<pk>\d+)/delete/', views.StockItemAttachmentDelete.as_view(), name='stock-item-attachment-delete'),
|
url(r'^(?P<pk>\d+)/delete/', views.StockItemAttachmentDelete.as_view(), name='stock-item-attachment-delete'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
# URLs for StockItem tests
|
||||||
|
url(r'^item/test/', include([
|
||||||
|
url(r'^new/', views.StockItemTestResultCreate.as_view(), name='stock-item-test-create'),
|
||||||
|
url(r'^(?P<pk>\d+)/edit/', views.StockItemTestResultEdit.as_view(), name='stock-item-test-edit'),
|
||||||
|
url(r'^(?P<pk>\d+)/delete/', views.StockItemTestResultDelete.as_view(), name='stock-item-test-delete'),
|
||||||
|
])),
|
||||||
|
|
||||||
url(r'^track/', include(stock_tracking_urls)),
|
url(r'^track/', include(stock_tracking_urls)),
|
||||||
|
|
||||||
url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),
|
url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),
|
||||||
|
@ -26,7 +26,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment
|
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
|
||||||
|
|
||||||
from .admin import StockItemResource
|
from .admin import StockItemResource
|
||||||
|
|
||||||
@ -38,6 +38,7 @@ from .forms import TrackingEntryForm
|
|||||||
from .forms import SerializeStockForm
|
from .forms import SerializeStockForm
|
||||||
from .forms import ExportOptionsForm
|
from .forms import ExportOptionsForm
|
||||||
from .forms import EditStockItemAttachmentForm
|
from .forms import EditStockItemAttachmentForm
|
||||||
|
from .forms import EditStockItemTestResultForm
|
||||||
|
|
||||||
|
|
||||||
class StockIndex(ListView):
|
class StockIndex(ListView):
|
||||||
@ -228,6 +229,69 @@ class StockItemAttachmentDelete(AjaxDeleteView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemTestResultCreate(AjaxCreateView):
|
||||||
|
"""
|
||||||
|
View for adding a new StockItemTestResult
|
||||||
|
"""
|
||||||
|
|
||||||
|
model = StockItemTestResult
|
||||||
|
form_class = EditStockItemTestResultForm
|
||||||
|
ajax_form_title = _("Add Test Result")
|
||||||
|
|
||||||
|
def post_save(self, **kwargs):
|
||||||
|
""" Record the user that uploaded the test result """
|
||||||
|
|
||||||
|
self.object.user = self.request.user
|
||||||
|
self.object.save()
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
|
||||||
|
initials = super().get_initial()
|
||||||
|
|
||||||
|
try:
|
||||||
|
stock_id = self.request.GET.get('stock_item', None)
|
||||||
|
initials['stock_item'] = StockItem.objects.get(pk=stock_id)
|
||||||
|
except (ValueError, StockItem.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return initials
|
||||||
|
|
||||||
|
def get_form(self):
|
||||||
|
|
||||||
|
form = super().get_form()
|
||||||
|
form.fields['stock_item'].widget = HiddenInput()
|
||||||
|
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemTestResultEdit(AjaxUpdateView):
|
||||||
|
"""
|
||||||
|
View for editing a StockItemTestResult
|
||||||
|
"""
|
||||||
|
|
||||||
|
model = StockItemTestResult
|
||||||
|
form_class = EditStockItemTestResultForm
|
||||||
|
ajax_form_title = _("Edit Test Result")
|
||||||
|
|
||||||
|
def get_form(self):
|
||||||
|
|
||||||
|
form = super().get_form()
|
||||||
|
|
||||||
|
form.fields['stock_item'].widget = HiddenInput()
|
||||||
|
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemTestResultDelete(AjaxDeleteView):
|
||||||
|
"""
|
||||||
|
View for deleting a StockItemTestResult
|
||||||
|
"""
|
||||||
|
|
||||||
|
model = StockItemTestResult
|
||||||
|
ajax_form_title = _("Delete Test Result")
|
||||||
|
context_object_name = "result"
|
||||||
|
|
||||||
|
|
||||||
class StockExportOptions(AjaxView):
|
class StockExportOptions(AjaxView):
|
||||||
""" Form for selecting StockExport options """
|
""" Form for selecting StockExport options """
|
||||||
|
|
||||||
@ -717,7 +781,7 @@ class StockItemEdit(AjaxUpdateView):
|
|||||||
query = query.filter(part=item.part.id)
|
query = query.filter(part=item.part.id)
|
||||||
form.fields['supplier_part'].queryset = query
|
form.fields['supplier_part'].queryset = query
|
||||||
|
|
||||||
if not item.part.trackable:
|
if not item.part.trackable or not item.serialized:
|
||||||
form.fields.pop('serial')
|
form.fields.pop('serial')
|
||||||
|
|
||||||
return form
|
return form
|
||||||
@ -757,6 +821,17 @@ class StockItemSerialize(AjaxUpdateView):
|
|||||||
ajax_form_title = _('Serialize Stock')
|
ajax_form_title = _('Serialize Stock')
|
||||||
form_class = SerializeStockForm
|
form_class = SerializeStockForm
|
||||||
|
|
||||||
|
def get_form(self):
|
||||||
|
|
||||||
|
context = self.get_form_kwargs()
|
||||||
|
|
||||||
|
# Pass the StockItem object through to the form
|
||||||
|
context['item'] = self.get_object()
|
||||||
|
|
||||||
|
form = SerializeStockForm(**context)
|
||||||
|
|
||||||
|
return form
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
|
|
||||||
initials = super().get_initial().copy()
|
initials = super().get_initial().copy()
|
||||||
@ -764,6 +839,7 @@ class StockItemSerialize(AjaxUpdateView):
|
|||||||
item = self.get_object()
|
item = self.get_object()
|
||||||
|
|
||||||
initials['quantity'] = item.quantity
|
initials['quantity'] = item.quantity
|
||||||
|
initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
|
||||||
initials['destination'] = item.location.pk
|
initials['destination'] = item.location.pk
|
||||||
|
|
||||||
return initials
|
return initials
|
||||||
@ -844,6 +920,8 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
|
|
||||||
form = super().get_form()
|
form = super().get_form()
|
||||||
|
|
||||||
|
part = None
|
||||||
|
|
||||||
# If the user has selected a Part, limit choices for SupplierPart
|
# If the user has selected a Part, limit choices for SupplierPart
|
||||||
if form['part'].value():
|
if form['part'].value():
|
||||||
part_id = form['part'].value()
|
part_id = form['part'].value()
|
||||||
@ -851,6 +929,11 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
try:
|
try:
|
||||||
part = Part.objects.get(id=part_id)
|
part = Part.objects.get(id=part_id)
|
||||||
|
|
||||||
|
sn = part.getNextSerialNumber()
|
||||||
|
form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn)
|
||||||
|
|
||||||
|
form.rebuild_layout()
|
||||||
|
|
||||||
# Hide the 'part' field (as a valid part is selected)
|
# Hide the 'part' field (as a valid part is selected)
|
||||||
form.fields['part'].widget = HiddenInput()
|
form.fields['part'].widget = HiddenInput()
|
||||||
|
|
||||||
@ -873,6 +956,7 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
|
|
||||||
# If there is one (and only one) supplier part available, pre-select it
|
# If there is one (and only one) supplier part available, pre-select it
|
||||||
all_parts = parts.all()
|
all_parts = parts.all()
|
||||||
|
|
||||||
if len(all_parts) == 1:
|
if len(all_parts) == 1:
|
||||||
|
|
||||||
# TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
|
# TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
|
||||||
@ -917,6 +1001,7 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
if part_id:
|
if part_id:
|
||||||
try:
|
try:
|
||||||
part = Part.objects.get(pk=part_id)
|
part = Part.objects.get(pk=part_id)
|
||||||
|
|
||||||
# Check that the supplied part is 'valid'
|
# Check that the supplied part is 'valid'
|
||||||
if not part.is_template and part.active and not part.virtual:
|
if not part.is_template and part.active and not part.virtual:
|
||||||
initials['part'] = part
|
initials['part'] = part
|
||||||
@ -954,6 +1039,8 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
- Manage serial-number valdiation for tracked parts
|
- Manage serial-number valdiation for tracked parts
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
part = None
|
||||||
|
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
|
|
||||||
data = {}
|
data = {}
|
||||||
@ -965,12 +1052,22 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
try:
|
try:
|
||||||
part = Part.objects.get(id=part_id)
|
part = Part.objects.get(id=part_id)
|
||||||
quantity = Decimal(form['quantity'].value())
|
quantity = Decimal(form['quantity'].value())
|
||||||
|
|
||||||
|
sn = part.getNextSerialNumber()
|
||||||
|
form.field_placeholder['serial_numbers'] = _("Next available serial number is") + " " + str(sn)
|
||||||
|
|
||||||
|
form.rebuild_layout()
|
||||||
|
|
||||||
except (Part.DoesNotExist, ValueError, InvalidOperation):
|
except (Part.DoesNotExist, ValueError, InvalidOperation):
|
||||||
part = None
|
part = None
|
||||||
quantity = 1
|
quantity = 1
|
||||||
valid = False
|
valid = False
|
||||||
form.errors['quantity'] = [_('Invalid quantity')]
|
form.errors['quantity'] = [_('Invalid quantity')]
|
||||||
|
|
||||||
|
if quantity <= 0:
|
||||||
|
form.errors['quantity'] = [_('Quantity must be greater than zero')]
|
||||||
|
valid = False
|
||||||
|
|
||||||
if part is None:
|
if part is None:
|
||||||
form.errors['part'] = [_('Invalid part selection')]
|
form.errors['part'] = [_('Invalid part selection')]
|
||||||
else:
|
else:
|
||||||
@ -988,7 +1085,7 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
existing = []
|
existing = []
|
||||||
|
|
||||||
for serial in serials:
|
for serial in serials:
|
||||||
if not StockItem.check_serial_number(part, serial):
|
if part.checkIfSerialNumberExists(serial):
|
||||||
existing.append(serial)
|
existing.append(serial)
|
||||||
|
|
||||||
if len(existing) > 0:
|
if len(existing) > 0:
|
||||||
@ -1003,24 +1100,26 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
|
|
||||||
form_data = form.cleaned_data
|
form_data = form.cleaned_data
|
||||||
|
|
||||||
for serial in serials:
|
if form.is_valid():
|
||||||
# Create a new stock item for each serial number
|
|
||||||
item = StockItem(
|
|
||||||
part=part,
|
|
||||||
quantity=1,
|
|
||||||
serial=serial,
|
|
||||||
supplier_part=form_data.get('supplier_part'),
|
|
||||||
location=form_data.get('location'),
|
|
||||||
batch=form_data.get('batch'),
|
|
||||||
delete_on_deplete=False,
|
|
||||||
status=form_data.get('status'),
|
|
||||||
link=form_data.get('link'),
|
|
||||||
)
|
|
||||||
|
|
||||||
item.save(user=request.user)
|
for serial in serials:
|
||||||
|
# Create a new stock item for each serial number
|
||||||
|
item = StockItem(
|
||||||
|
part=part,
|
||||||
|
quantity=1,
|
||||||
|
serial=serial,
|
||||||
|
supplier_part=form_data.get('supplier_part'),
|
||||||
|
location=form_data.get('location'),
|
||||||
|
batch=form_data.get('batch'),
|
||||||
|
delete_on_deplete=False,
|
||||||
|
status=form_data.get('status'),
|
||||||
|
link=form_data.get('link'),
|
||||||
|
)
|
||||||
|
|
||||||
data['success'] = _('Created {n} new stock items'.format(n=len(serials)))
|
item.save(user=request.user)
|
||||||
valid = True
|
|
||||||
|
data['success'] = _('Created {n} new stock items'.format(n=len(serials)))
|
||||||
|
valid = True
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
form.errors['serial_numbers'] = e.messages
|
form.errors['serial_numbers'] = e.messages
|
||||||
@ -1031,12 +1130,16 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
form.clean()
|
form.clean()
|
||||||
form._post_clean()
|
form._post_clean()
|
||||||
|
|
||||||
item = form.save(commit=False)
|
if form.is_valid():
|
||||||
item.save(user=request.user)
|
|
||||||
|
|
||||||
data['pk'] = item.pk
|
item = form.save(commit=False)
|
||||||
data['url'] = item.get_absolute_url()
|
item.save(user=request.user)
|
||||||
data['success'] = _("Created new stock item")
|
|
||||||
|
data['pk'] = item.pk
|
||||||
|
data['url'] = item.get_absolute_url()
|
||||||
|
data['success'] = _("Created new stock item")
|
||||||
|
|
||||||
|
valid = True
|
||||||
|
|
||||||
else: # Referenced Part object is not marked as "trackable"
|
else: # Referenced Part object is not marked as "trackable"
|
||||||
# For non-serialized items, simply save the form.
|
# For non-serialized items, simply save the form.
|
||||||
@ -1044,14 +1147,17 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
form.clean()
|
form.clean()
|
||||||
form._post_clean()
|
form._post_clean()
|
||||||
|
|
||||||
item = form.save(commit=False)
|
if form.is_valid:
|
||||||
item.save(user=request.user)
|
item = form.save(commit=False)
|
||||||
|
item.save(user=request.user)
|
||||||
|
|
||||||
data['pk'] = item.pk
|
data['pk'] = item.pk
|
||||||
data['url'] = item.get_absolute_url()
|
data['url'] = item.get_absolute_url()
|
||||||
data['success'] = _("Created new stock item")
|
data['success'] = _("Created new stock item")
|
||||||
|
|
||||||
data['form_valid'] = valid
|
valid = True
|
||||||
|
|
||||||
|
data['form_valid'] = valid and form.is_valid()
|
||||||
|
|
||||||
return self.renderJsonResponse(request, form, data=data)
|
return self.renderJsonResponse(request, form, data=data)
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class='dropzone' id='attachment-dropzone'>
|
||||||
<table class='table table-striped table-condensed' data-toolbar='#attachment-buttons' id='attachment-table'>
|
<table class='table table-striped table-condensed' data-toolbar='#attachment-buttons' id='attachment-table'>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -27,7 +28,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class='btn-group' style='float: right;'>
|
<div class='btn-group' style='float: right;'>
|
||||||
<button type='button' class='btn btn-default btn-glyph attachment-edit-button' pk="{{ attachment.id }}" data-toggle='tooltip' title='{% trans "Edit attachment" %}'>
|
<button type='button' class='btn btn-default btn-glyph attachment-edit-button' pk="{{ attachment.id }}" data-toggle='tooltip' title='{% trans "Edit attachment" %}'>
|
||||||
<span class='fas fa-edit'/>
|
<span class='fas fa-edit icon-blue'/>
|
||||||
</button>
|
</button>
|
||||||
<button type='button' class='btn btn-default btn-glyph attachment-delete-button' pk="{{ attachment.id }}" data-toggle='tooltip' title='{% trans "Delete attachment" %}'>
|
<button type='button' class='btn btn-default btn-glyph attachment-delete-button' pk="{{ attachment.id }}" data-toggle='tooltip' title='{% trans "Delete attachment" %}'>
|
||||||
<span class='fas fa-trash-alt icon-red'/>
|
<span class='fas fa-trash-alt icon-red'/>
|
||||||
@ -38,3 +39,4 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
@ -114,6 +114,7 @@ InvenTree
|
|||||||
<script type='text/javascript' src="{% url 'stock.js' %}"></script>
|
<script type='text/javascript' src="{% url 'stock.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% url 'build.js' %}"></script>
|
<script type='text/javascript' src="{% url 'build.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% url 'order.js' %}"></script>
|
<script type='text/javascript' src="{% url 'order.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% url 'table_filters.js' %}"></script>
|
||||||
|
|
||||||
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
|
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script>
|
<script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script>
|
||||||
@ -122,8 +123,6 @@ InvenTree
|
|||||||
{% block js_load %}
|
{% block js_load %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% include "table_filters.html" %}
|
|
||||||
|
|
||||||
<script type='text/javascript'>
|
<script type='text/javascript'>
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
@ -18,6 +18,123 @@ function removeStockRow(e) {
|
|||||||
$('#' + row).remove();
|
$('#' + row).remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function passFailBadge(result) {
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return `<span class='label label-green'>{% trans "PASS" %}</span>`;
|
||||||
|
} else {
|
||||||
|
return `<span class='label label-red'>{% trans "FAIL" %}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStockTestResultsTable(table, options) {
|
||||||
|
/*
|
||||||
|
* Load StockItemTestResult table
|
||||||
|
*/
|
||||||
|
|
||||||
|
var params = options.params || {};
|
||||||
|
|
||||||
|
// HTML element to setup the filtering
|
||||||
|
var filterListElement = options.filterList || '#filter-list-stocktests';
|
||||||
|
|
||||||
|
var filters = {};
|
||||||
|
|
||||||
|
filters = loadTableFilters("stocktests");
|
||||||
|
|
||||||
|
var original = {};
|
||||||
|
|
||||||
|
for (var key in params) {
|
||||||
|
original[key] = params[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFilterList("stocktests", table, filterListElement);
|
||||||
|
|
||||||
|
// Override the default values, or add new ones
|
||||||
|
for (var key in params) {
|
||||||
|
filters[key] = params[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
table.inventreeTable({
|
||||||
|
method: 'get',
|
||||||
|
formatNoMatches: function() {
|
||||||
|
return '{% trans "No test results matching query" %}';
|
||||||
|
},
|
||||||
|
url: "{% url 'api-stock-test-result-list' %}",
|
||||||
|
queryParams: filters,
|
||||||
|
original: original,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
field: 'pk',
|
||||||
|
title: 'ID',
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'test',
|
||||||
|
title: '{% trans "Test" %}',
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value, row) {
|
||||||
|
var html = value;
|
||||||
|
|
||||||
|
if (row.attachment_detail) {
|
||||||
|
html += `<a href='${row.attachment_detail.attachment}'><span class='fas fa-file-alt label-right'></span></a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'result',
|
||||||
|
title: "{% trans "Result" %}",
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value) {
|
||||||
|
return passFailBadge(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'value',
|
||||||
|
title: "{% trans "Value" %}",
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'notes',
|
||||||
|
title: '{% trans "Notes" %}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'date',
|
||||||
|
title: '{% trans "Uploaded" %}',
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value, row) {
|
||||||
|
var html = value;
|
||||||
|
|
||||||
|
if (row.user_detail) {
|
||||||
|
html += `<span class='badge'>${row.user_detail.username}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'buttons',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
|
||||||
|
var pk = row.pk;
|
||||||
|
|
||||||
|
var html = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}');
|
||||||
|
html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}');
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function loadStockTable(table, options) {
|
function loadStockTable(table, options) {
|
||||||
/* Load data into a stock table with adjustable options.
|
/* Load data into a stock table with adjustable options.
|
||||||
* Fetches data (via AJAX) and loads into a bootstrap table.
|
* Fetches data (via AJAX) and loads into a bootstrap table.
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load status_codes %}
|
{% load status_codes %}
|
||||||
|
|
||||||
<script type='text/javascript'>
|
|
||||||
|
|
||||||
{% include "status_codes.html" with label='stock' options=StockStatus.list %}
|
{% include "status_codes.html" with label='stock' options=StockStatus.list %}
|
||||||
{% include "status_codes.html" with label='build' options=BuildStatus.list %}
|
{% include "status_codes.html" with label='build' options=BuildStatus.list %}
|
||||||
{% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %}
|
{% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %}
|
||||||
@ -39,6 +37,16 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filters for the 'stock test' table
|
||||||
|
if (tableKey == 'stocktests') {
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
type: 'bool',
|
||||||
|
title: "{% trans 'Test result' %}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filters for the "Build" table
|
// Filters for the "Build" table
|
||||||
if (tableKey == 'build') {
|
if (tableKey == 'build') {
|
||||||
return {
|
return {
|
||||||
@ -110,6 +118,10 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "Salable" %}',
|
title: '{% trans "Salable" %}',
|
||||||
},
|
},
|
||||||
|
trackable: {
|
||||||
|
type: 'bool',
|
||||||
|
title: '{% trans "Trackable" %}',
|
||||||
|
},
|
||||||
purchaseable: {
|
purchaseable: {
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "Purchasable" %}',
|
title: '{% trans "Purchasable" %}',
|
||||||
@ -120,4 +132,3 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
// Finally, no matching key
|
// Finally, no matching key
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
</script>
|
|
Loading…
Reference in New Issue
Block a user