Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-05-17 00:42:03 +10:00
commit 1e94a597c9
57 changed files with 1916 additions and 203 deletions

View File

@ -7,18 +7,90 @@ from __future__ import unicode_literals
from django import forms
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
class HelperForm(forms.ModelForm):
""" 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):
super(forms.ModelForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
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):
""" Generic deletion form which provides simple user confirmation

View File

@ -120,6 +120,59 @@ def normalize(d):
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):
"""
Format a Decimal number as a string,

View File

@ -56,7 +56,7 @@ class InvenTreeAttachment(models.Model):
attachment = models.FileField(upload_to=rename_attachment,
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,

View File

@ -341,7 +341,7 @@ if DEBUG:
print("STATIC_ROOT:", STATIC_ROOT)
# crispy forms use the bootstrap templates
CRISPY_TEMPLATE_PACK = 'bootstrap'
CRISPY_TEMPLATE_PACK = 'bootstrap3'
# Use database transactions when importing / exporting data
IMPORT_EXPORT_USE_TRANSACTIONS = True

View File

@ -346,9 +346,11 @@
z-index: 2;
}
/*
.dropzone * {
pointer-events: none;
}
*/
.dragover {
background-color: #55A;

View File

@ -140,10 +140,13 @@ function enableDragAndDrop(element, url, options) {
url - URL to POST the file to
options - object with following possible values:
label - Label of the file to upload (default='file')
data - Other form data to upload
success - Callback function in case of success
error - Callback function in case of error
*/
data = options.data || {};
$(element).on('drop', function(event) {
var transfer = event.originalEvent.dataTransfer;
@ -152,6 +155,11 @@ function enableDragAndDrop(element, url, options) {
var formData = new FormData();
// Add the extra data
for (var key in data) {
formData.append(key, data[key]);
}
if (isFileTransfer(transfer)) {
formData.append(label, transfer.files[0]);

View File

@ -108,6 +108,29 @@ class TestQuoteWrap(TestCase):
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):
""" Tests for barcode string creation """

View File

@ -81,6 +81,7 @@ dynamic_javascript_urls = [
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'^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 = [

View File

@ -46,12 +46,23 @@ class ConfirmBuildForm(HelperForm):
class CompleteBuildForm(HelperForm):
""" Form for marking a Build as complete """
field_prefix = {
'serial_numbers': 'fa-hashtag',
}
field_placeholder = {
}
location = forms.ModelChoiceField(
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'))

View File

@ -53,6 +53,22 @@ class Build(MPTTModel):
def get_absolute_url(self):
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(
verbose_name=_('Build Title'),
blank=False,

View File

@ -198,8 +198,8 @@ InvenTree | Allocate Parts
var html = `<div class='btn-group float-right' role='group'>`;
{% if build.status == BuildStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
{% endif %}
html += `</div>`;
@ -389,7 +389,7 @@ InvenTree | Allocate 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 %}
html += '</div>';

View File

@ -196,6 +196,15 @@ class BuildComplete(AjaxUpdateView):
if not build.part.trackable:
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
@ -208,6 +217,7 @@ class BuildComplete(AjaxUpdateView):
initials = super(BuildComplete, self).get_initial().copy()
build = self.get_object()
if build.part.default_location is not None:
try:
location = StockLocation.objects.get(pk=build.part.default_location.id)
@ -282,7 +292,7 @@ class BuildComplete(AjaxUpdateView):
existing = []
for serial in serials:
if not StockItem.check_serial_number(build.part, serial):
if build.part.checkIfSerialNumberExists(serial):
existing.append(serial)
if len(existing) > 0:

View File

@ -16,6 +16,14 @@ from .models import SupplierPriceBreak
class EditCompanyForm(HelperForm):
""" 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:
model = Company
fields = [
@ -45,6 +53,13 @@ class CompanyImageForm(HelperForm):
class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """
field_prefix = {
'link': 'fa-link',
'MPN': 'fa-hashtag',
'SKU': 'fa-hashtag',
'note': 'fa-pencil-alt',
}
class Meta:
model = SupplierPart
fields = [

View File

@ -85,10 +85,10 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
$('#company-edit').click(function() {
launchModalForm(
"{% url 'company-edit' company.id %}",
{
reload: true
});
"{% url 'company-edit' company.id %}",
{
reload: true
});
});
$("#company-order-2").click(function() {
@ -104,10 +104,10 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
$('#company-delete').click(function() {
launchModalForm(
"{% url 'company-delete' company.id %}",
{
redirect: "{% url 'company-index' %}"
});
"{% url 'company-delete' company.id %}",
{
redirect: "{% url 'company-index' %}"
});
});
enableDragAndDrop(
@ -123,11 +123,11 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
$("#company-thumb").click(function() {
launchModalForm(
"{% url 'company-image' company.id %}",
{
reload: true
}
);
"{% url 'company-image' company.id %}",
{
reload: true
}
);
});
{% endblock %}

View File

@ -33,9 +33,16 @@
}
});
$("#new-sales-order").click(function() {
// TODO - Create a new sales order
launchModalForm(
"{% url 'so-create' %}",
{
data: {
customer: {{ company.id }},
},
follow: true,
},
);
});
{% endblock %}

View File

@ -4,7 +4,7 @@
- model: order.purchaseorder
pk: 1
fields:
reference: 0001
reference: '0001'
description: "Ordering some screws"
supplier: 1
@ -12,7 +12,7 @@
- model: order.purchaseorder
pk: 2
fields:
reference: 0002
reference: '0002'
description: "Ordering some more screws"
supplier: 3

View File

@ -88,6 +88,19 @@ class ReceivePurchaseOrderForm(HelperForm):
class EditPurchaseOrderForm(HelperForm):
""" 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:
model = PurchaseOrder
fields = [
@ -102,6 +115,19 @@ class EditPurchaseOrderForm(HelperForm):
class EditSalesOrderForm(HelperForm):
""" 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:
model = SalesOrder
fields = [

View 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),
),
]

View File

@ -24,7 +24,7 @@ from stock import models as stock_models
from company.models import Company, SupplierPart
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.models import InvenTreeAttachment
@ -49,6 +49,43 @@ class Order(models.Model):
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):
el = []

View File

@ -20,6 +20,20 @@
{% block js_ready %}
{{ 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() {
launchModalForm("{% url 'po-attachment-create' %}?order={{ order.id }}",
{

View File

@ -208,7 +208,7 @@ $("#po-table").inventreeTable({
var pk = row.pk;
{% 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" %}');
{% endif %}

View File

@ -89,8 +89,8 @@ function showAllocationSubTable(index, row, element) {
var pk = row.pk;
{% if order.status == SalesOrderStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
{% endif %}
html += "</div>";
@ -274,11 +274,11 @@ $("#so-lines-table").inventreeTable({
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-trash-alt', 'button-delete', pk, '{% trans "Delete line item " %}');
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}');
html += `</div>`;

View File

@ -19,6 +19,20 @@
{% block js_ready %}
{{ 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() {
launchModalForm("{% url 'so-attachment-create' %}?order={{ order.id }}",
{

View File

@ -31,11 +31,17 @@ class OrderTest(TestCase):
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)
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):
""" There should be 3 separate items on order for the M2x4 LPHS part """

View File

@ -29,7 +29,7 @@ from . import forms as order_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
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__)
@ -112,7 +112,10 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
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
@ -149,7 +152,10 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
def get_initial(self):
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
@ -284,6 +290,7 @@ class PurchaseOrderCreate(AjaxCreateView):
def get_initial(self):
initials = super().get_initial().copy()
initials['reference'] = PurchaseOrder.getNextOrderNumber()
initials['status'] = PurchaseOrderStatus.PENDING
supplier_id = self.request.GET.get('supplier', None)
@ -314,7 +321,8 @@ class SalesOrderCreate(AjaxCreateView):
def get_initial(self):
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)

View File

@ -7,6 +7,10 @@
description: 'M2x4 low profile head screw'
category: 8
link: www.acme.com/parts/m2x4lphs
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part
pk: 2
@ -14,6 +18,10 @@
name: 'M3x12 SHCS'
description: 'M3x12 socket head cap screw'
category: 8
tree_id: 0
level: 0
lft: 0
rght: 0
# Create some resistors
@ -23,6 +31,11 @@
name: 'R_2K2_0805'
description: '2.2kOhm resistor in 0805 package'
category: 2
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part
fields:
@ -30,6 +43,10 @@
description: '4.7kOhm resistor in 0603 package'
category: 2
default_location: 2 # Home/Bathroom
tree_id: 0
level: 0
lft: 0
rght: 0
# Create some capacitors
- model: part.part
@ -37,6 +54,10 @@
name: 'C_22N_0805'
description: '22nF capacitor in 0805 package'
category: 3
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part
pk: 25
@ -45,6 +66,10 @@
description: 'A watchamacallit'
category: 7
trackable: true
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part
pk: 50
@ -52,6 +77,10 @@
name: 'Orphan'
description: 'A part without a category'
category: null
tree_id: 0
level: 0
lft: 0
rght: 0
# A part that can be made from other parts
- model: part.part
@ -65,3 +94,64 @@
active: False
IPN: BOB
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

View File

@ -97,6 +97,12 @@ class SetPartCategoryForm(forms.Form):
class EditPartForm(HelperForm):
""" Form for editing a Part object """
field_prefix = {
'keywords': 'fa-key',
'link': 'fa-link',
'IPN': 'fa-hashtag',
}
deep_copy = forms.BooleanField(required=False,
initial=True,
help_text=_("Perform 'deep copy' which will duplicate all BOM data for this part"),
@ -155,6 +161,10 @@ class EditPartParameterForm(HelperForm):
class EditCategoryForm(HelperForm):
""" Form for editing a PartCategory object """
field_prefix = {
'default_keywords': 'fa-key',
}
class Meta:
model = PartCategory
fields = [

View 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),
),
]

View 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)
]

View File

@ -24,7 +24,7 @@ from markdownx.models import MarkdownxField
from django_cleanup import cleanup
from mptt.models import TreeForeignKey
from mptt.models import TreeForeignKey, MPTTModel
from stdimage.models import StdImageField
@ -39,7 +39,7 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
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 order import models as OrderModels
@ -200,7 +200,7 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False):
@cleanup.ignore
class Part(models.Model):
class Part(MPTTModel):
""" 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.
@ -236,8 +236,12 @@ class Part(models.Model):
"""
class Meta:
verbose_name = "Part"
verbose_name_plural = "Parts"
verbose_name = _("Part")
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):
"""
@ -262,6 +266,66 @@ class Part(models.Model):
def __str__(self):
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
def full_name(self):
""" Format a 'full name' for this Part.
@ -638,32 +702,40 @@ class Part(models.Model):
self.sales_order_allocation_count(),
])
@property
def stock_entries(self):
""" Return all 'in stock' items. To be in stock:
def stock_entries(self, include_variants=True, in_stock=None):
""" Return all stock entries for this Part.
- build_order is None
- sales_order is None
- belongs_to is None
- If this is a template part, include variants underneath this.
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
def total_stock(self):
""" 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:
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']
entries = self.stock_entries(in_stock=True)
if total:
return total
else:
return Decimal(0)
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
return query['t']
@property
def has_bom(self):

View File

@ -16,6 +16,20 @@
{% block js_ready %}
{{ 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() {
launchModalForm("{% url 'part-attachment-create' %}?part={{ part.id }}",
{

View File

@ -85,7 +85,7 @@ class PartAPITest(APITestCase):
data = {'cascade': True}
response = self.client.get(url, data, format='json')
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):
url = reverse('api-part-list')

View File

@ -88,9 +88,9 @@ class CategoryTest(TestCase):
self.assertEqual(self.electronics.partcount(), 3)
self.assertEqual(self.mechanical.partcount(), 4)
self.assertEqual(self.mechanical.partcount(active=True), 3)
self.assertEqual(self.mechanical.partcount(False), 2)
self.assertEqual(self.mechanical.partcount(), 8)
self.assertEqual(self.mechanical.partcount(active=True), 7)
self.assertEqual(self.mechanical.partcount(False), 6)
self.assertEqual(self.electronics.item_count, self.electronics.partcount())

View File

@ -52,6 +52,20 @@ class PartTest(TestCase):
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):
p = Part.objects.get(pk=100)
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")

View File

@ -10,6 +10,7 @@ import import_export.widgets as widgets
from .models import StockLocation, StockItem, StockItemAttachment
from .models import StockItemTracking
from .models import StockItemTestResult
from build.models import Build
from company.models import SupplierPart
@ -117,7 +118,13 @@ class StockTrackingAdmin(ImportExportModelAdmin):
list_display = ('item', 'date', 'title')
class StockItemTestResultAdmin(admin.ModelAdmin):
list_display = ('stock_item', 'test', 'result', 'value')
admin.site.register(StockLocation, LocationAdmin)
admin.site.register(StockItem, StockItemAdmin)
admin.site.register(StockItemTracking, StockTrackingAdmin)
admin.site.register(StockItemAttachment, StockAttachmentAdmin)
admin.site.register(StockItemTestResult, StockItemTestResultAdmin)

View File

@ -15,6 +15,7 @@ from django.db.models import Q
from .models import StockLocation, StockItem
from .models import StockItemTracking
from .models import StockItemAttachment
from .models import StockItemTestResult
from part.models import Part, PartCategory
from part.serializers import PartBriefSerializer
@ -26,6 +27,7 @@ from .serializers import StockItemSerializer
from .serializers import LocationSerializer, LocationBriefSerializer
from .serializers import StockTrackingSerializer
from .serializers import StockItemAttachmentSerializer
from .serializers import StockItemTestResultSerializer
from InvenTree.views import TreeSerializer
from InvenTree.helpers import str2bool, isNull
@ -536,11 +538,10 @@ class StockList(generics.ListCreateAPIView):
try:
part = Part.objects.get(pk=part_id)
# If the part is a Template part, select stock items for any "variant" parts under that template
if part.is_template:
queryset = queryset.filter(part__in=[part.id for part in Part.objects.filter(variant_of=part_id)])
else:
queryset = queryset.filter(part=part_id)
# Filter by any parts "under" the given part
parts = part.get_descendants(include_self=True)
queryset = queryset.filter(part__in=parts)
except (ValueError, Part.DoesNotExist):
raise ValidationError({"part": "Invalid Part ID specified"})
@ -654,11 +655,89 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
queryset = StockItemAttachment.objects.all()
serializer_class = StockItemAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
filters.SearchFilter,
]
filter_fields = [
'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):
""" 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'),
])),
# 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'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'),

View File

@ -69,3 +69,171 @@
tree_id: 0
lft: 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

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

View File

@ -13,7 +13,11 @@ from mptt.fields import TreeNodeChoiceField
from InvenTree.helpers import GetExportFormats
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):
@ -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):
""" 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)'))
def __init__(self, *args, **kwargs):
self.field_prefix = {
'serial_numbers': 'fa-hashtag',
'link': 'fa-link',
}
super().__init__(*args, **kwargs)
class Meta:
model = StockItem
fields = [
@ -69,6 +98,7 @@ class CreateStockItemForm(HelperForm):
return
self.cleaned_data = {}
# If the form is permitted to be empty, and none of the form data has
# changed from the initial data, short circuit any validation.
if self.empty_permitted and not self.has_changed():
@ -79,7 +109,7 @@ class CreateStockItemForm(HelperForm):
self._clean_form()
class SerializeStockForm(forms.ModelForm):
class SerializeStockForm(HelperForm):
""" 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)')
@ -88,6 +118,18 @@ class SerializeStockForm(forms.ModelForm):
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:
model = StockItem

View 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),
),
]

View 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)),
],
),
]

View 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'),
),
]

View File

@ -142,9 +142,21 @@ class StockItem(MPTTModel):
)
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:
# StockItem has not yet been saved
add_note = True
else:
# StockItem has already been saved
add_note = False
user = kwargs.pop('user', None)
@ -172,59 +184,26 @@ class StockItem(MPTTModel):
""" Return True if this StockItem is serialized """
return self.serial is not None and self.quantity == 1
@classmethod
def check_serial_number(cls, part, serial_number):
""" Check if a new stock item can be created with the provided part_id
Args:
part: The part to be checked
def validate_unique(self, exclude=None):
"""
Test that this StockItem is "unique".
If the StockItem is serialized, the same serial number.
cannot exist for the same part (or part tree).
"""
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)
# If the Part object is a variant (of a template part),
# ensure that the serial number is unique
# across all variants of the same template part
if self.serial is not None:
# Query to look for duplicate serial numbers
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
stock = StockItem.objects.filter(part__in=parts, serial=self.serial)
try:
if self.serial is not None:
# This is a variant part (check S/N across all sibling variants)
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():
raise ValidationError({
'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
# Exclude myself from the search
if self.pk is not None:
stock = stock.exclude(pk=self.pk)
if stock.exists():
raise ValidationError({"serial": _("StockItem with this serial number already exists")})
def clean(self):
""" 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
"""
super().clean()
if self.status == StockStatus.SHIPPED and self.sales_order is None:
raise ValidationError({
'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',
})
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!
try:
if self.supplier_part is not None:
@ -599,6 +598,9 @@ class StockItem(MPTTModel):
if self.serialized:
return
if not self.part.trackable:
raise ValidationError({"part": _("Part is not set as trackable")})
# Quantity must be a valid integer value
try:
quantity = int(quantity)
@ -624,7 +626,7 @@ class StockItem(MPTTModel):
existing = []
for serial in serials:
if not StockItem.check_serial_number(self.part, serial):
if self.part.checkIfSerialNumberExists(serial):
existing.append(serial)
if len(existing) > 0:
@ -919,6 +921,51 @@ class StockItem(MPTTModel):
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')
def before_delete_stock_item(sender, instance, using, **kwargs):
@ -991,3 +1038,86 @@ class StockItemTracking(models.Model):
# TODO
# 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
)

View File

@ -7,6 +7,7 @@ from rest_framework import serializers
from .models import StockItem, StockLocation
from .models import StockItemTracking
from .models import StockItemAttachment
from .models import StockItemTestResult
from django.db.models import Sum, Count
from django.db.models.functions import Coalesce
@ -193,7 +194,7 @@ class LocationSerializer(InvenTreeModelSerializer):
class StockItemAttachmentSerializer(InvenTreeModelSerializer):
""" Serializer for StockItemAttachment model """
def __init_(self, *args, **kwargs):
def __init__(self, *args, **kwargs):
user_detail = kwargs.pop('user_detail', False)
super().__init__(*args, **kwargs)
@ -211,11 +212,55 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer):
'stock_item',
'attachment',
'comment',
'upload_date',
'user',
'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):
""" Serializer for StockItemTracking model """

View File

@ -8,8 +8,9 @@
{% include "stock/tabs.html" with tab="tracking" %}
<hr>
<h4>{% trans "Stock Tracking Information" %}</h4>
<hr>
<div id='table-toolbar'>
<div class='btn-group'>
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>New Entry</button>

View File

@ -7,8 +7,8 @@
{% include "stock/tabs.html" with tab='attachments' %}
<hr>
<h4>{% trans "Stock Item Attachments" %}</h4>
<hr>
{% include "attachment_table.html" with attachments=item.attachments.all %}
@ -17,6 +17,20 @@
{% block js_ready %}
{{ 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() {
launchModalForm("{% url 'stock-item-attachment-create' %}?item={{ item.id }}",
{

View File

@ -10,6 +10,7 @@
{% include "stock/tabs.html" with tab="notes" %}
{% if editing %}
<h4>{% trans "Stock Item Notes" %}</h4>
<hr>

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

View File

@ -1,15 +1,20 @@
{% load i18n %}
<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 %}>
<a href="{% url 'stock-item-detail' item.id %}">
{% trans "Tracking" %}
{% if item.tracking_info.count > 0 %}<span class='badge'>{{ item.tracking_info.count }}</span>{% endif %}
</a>
</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 %}
<!-- These tabs are to be implemented in the future -->
<li{% ifequal tab 'builds' %} class='active'{% endifequal %}>
@ -28,4 +33,9 @@
{% if item.attachments.count > 0 %}<span class='badge'>{{ item.attachments.count }}</span>{% endif %}
</a>
</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>

View File

@ -6,11 +6,17 @@ from django.contrib.auth import get_user_model
from .models import StockLocation
class StockLocationTest(APITestCase):
"""
Series of API tests for the StockLocation API
"""
list_url = reverse('api-location-list')
class StockAPITestCase(APITestCase):
fixtures = [
'category',
'part',
'company',
'location',
'supplier_part',
'stock',
'stock_tests',
]
def setUp(self):
# Create a user for auth
@ -18,6 +24,21 @@ class StockLocationTest(APITestCase):
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
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
StockLocation.objects.create(name='top', description='top category')
@ -38,7 +59,7 @@ class StockLocationTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
class StockItemTest(APITestCase):
class StockItemTest(StockAPITestCase):
"""
Series of API tests for the StockItem API
"""
@ -49,11 +70,7 @@ class StockItemTest(APITestCase):
return reverse('api-stock-detail', kwargs={'pk': pk})
def setUp(self):
# Create a user for auth
User = get_user_model()
User.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password')
super().setUp()
# Create some stock locations
top = StockLocation.objects.create(name='A', description='top')
@ -65,30 +82,11 @@ class StockItemTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
class StocktakeTest(APITestCase):
class StocktakeTest(StockAPITestCase):
"""
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):
"""
Test each stocktake action endpoint,
@ -179,3 +177,82 @@ class StocktakeTest(APITestCase):
response = self.doPost(url, data)
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)

View File

@ -17,6 +17,7 @@ class StockTest(TestCase):
'part',
'location',
'stock',
'stock_tests',
]
def setUp(self):
@ -38,6 +39,10 @@ class StockTest(TestCase):
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):
self.assertEqual(StockLocation.objects.count(), 7)
@ -91,17 +96,20 @@ class StockTest(TestCase):
self.assertFalse(self.drawer2.has_items())
# Drawer 3 should have three stock items
self.assertEqual(self.drawer3.stock_items.count(), 3)
self.assertEqual(self.drawer3.item_count, 3)
self.assertEqual(self.drawer3.stock_items.count(), 16)
self.assertEqual(self.drawer3.item_count, 16)
def test_stock_count(self):
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)
# 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):
@ -161,12 +169,12 @@ class StockTest(TestCase):
self.assertEqual(w1.quantity, 4)
# 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)
# Try to move negative units
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
self.assertFalse(widget.move(None, 'null', None))
@ -327,3 +335,99 @@ class StockTest(TestCase):
# Serialize the remainder of the stock
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())

View File

@ -24,6 +24,7 @@ stock_item_detail_urls = [
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'^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'),
@ -51,12 +52,20 @@ stock_urls = [
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
# URLs for StockItem attachments
url(r'^item/attachment/', include([
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+)/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'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),

View File

@ -26,7 +26,7 @@ from datetime import datetime
from company.models import Company, SupplierPart
from part.models import Part
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
from .admin import StockItemResource
@ -38,6 +38,7 @@ from .forms import TrackingEntryForm
from .forms import SerializeStockForm
from .forms import ExportOptionsForm
from .forms import EditStockItemAttachmentForm
from .forms import EditStockItemTestResultForm
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):
""" Form for selecting StockExport options """
@ -717,7 +781,7 @@ class StockItemEdit(AjaxUpdateView):
query = query.filter(part=item.part.id)
form.fields['supplier_part'].queryset = query
if not item.part.trackable:
if not item.part.trackable or not item.serialized:
form.fields.pop('serial')
return form
@ -757,6 +821,17 @@ class StockItemSerialize(AjaxUpdateView):
ajax_form_title = _('Serialize Stock')
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):
initials = super().get_initial().copy()
@ -764,6 +839,7 @@ class StockItemSerialize(AjaxUpdateView):
item = self.get_object()
initials['quantity'] = item.quantity
initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
initials['destination'] = item.location.pk
return initials
@ -844,6 +920,8 @@ class StockItemCreate(AjaxCreateView):
form = super().get_form()
part = None
# If the user has selected a Part, limit choices for SupplierPart
if form['part'].value():
part_id = form['part'].value()
@ -851,6 +929,11 @@ class StockItemCreate(AjaxCreateView):
try:
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)
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
all_parts = parts.all()
if len(all_parts) == 1:
# TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
@ -917,6 +1001,7 @@ class StockItemCreate(AjaxCreateView):
if part_id:
try:
part = Part.objects.get(pk=part_id)
# Check that the supplied part is 'valid'
if not part.is_template and part.active and not part.virtual:
initials['part'] = part
@ -954,6 +1039,8 @@ class StockItemCreate(AjaxCreateView):
- Manage serial-number valdiation for tracked parts
"""
part = None
form = self.get_form()
data = {}
@ -965,12 +1052,22 @@ class StockItemCreate(AjaxCreateView):
try:
part = Part.objects.get(id=part_id)
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):
part = None
quantity = 1
valid = False
form.errors['quantity'] = [_('Invalid quantity')]
if quantity <= 0:
form.errors['quantity'] = [_('Quantity must be greater than zero')]
valid = False
if part is None:
form.errors['part'] = [_('Invalid part selection')]
else:
@ -988,7 +1085,7 @@ class StockItemCreate(AjaxCreateView):
existing = []
for serial in serials:
if not StockItem.check_serial_number(part, serial):
if part.checkIfSerialNumberExists(serial):
existing.append(serial)
if len(existing) > 0:
@ -1003,24 +1100,26 @@ class StockItemCreate(AjaxCreateView):
form_data = form.cleaned_data
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'),
)
if form.is_valid():
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)))
valid = True
item.save(user=request.user)
data['success'] = _('Created {n} new stock items'.format(n=len(serials)))
valid = True
except ValidationError as e:
form.errors['serial_numbers'] = e.messages
@ -1031,12 +1130,16 @@ class StockItemCreate(AjaxCreateView):
form.clean()
form._post_clean()
item = form.save(commit=False)
item.save(user=request.user)
if form.is_valid():
data['pk'] = item.pk
data['url'] = item.get_absolute_url()
data['success'] = _("Created new stock item")
item = form.save(commit=False)
item.save(user=request.user)
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"
# For non-serialized items, simply save the form.
@ -1044,14 +1147,17 @@ class StockItemCreate(AjaxCreateView):
form.clean()
form._post_clean()
item = form.save(commit=False)
item.save(user=request.user)
if form.is_valid:
item = form.save(commit=False)
item.save(user=request.user)
data['pk'] = item.pk
data['url'] = item.get_absolute_url()
data['success'] = _("Created new stock item")
data['pk'] = item.pk
data['url'] = item.get_absolute_url()
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)

View File

@ -6,6 +6,7 @@
</div>
</div>
<div class='dropzone' id='attachment-dropzone'>
<table class='table table-striped table-condensed' data-toolbar='#attachment-buttons' id='attachment-table'>
<thead>
<tr>
@ -27,7 +28,7 @@
<td>
<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" %}'>
<span class='fas fa-edit'/>
<span class='fas fa-edit icon-blue'/>
</button>
<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'/>
@ -38,3 +39,4 @@
{% endfor %}
</tbody>
</table>
</div>

View File

@ -114,6 +114,7 @@ InvenTree
<script type='text/javascript' src="{% url 'stock.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 'table_filters.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script>
@ -122,8 +123,6 @@ InvenTree
{% block js_load %}
{% endblock %}
{% include "table_filters.html" %}
<script type='text/javascript'>
$(document).ready(function () {

View File

@ -18,6 +18,123 @@ function removeStockRow(e) {
$('#' + 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) {
/* Load data into a stock table with adjustable options.
* Fetches data (via AJAX) and loads into a bootstrap table.

View File

@ -1,8 +1,6 @@
{% load i18n %}
{% load status_codes %}
<script type='text/javascript'>
{% 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='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
if (tableKey == 'build') {
return {
@ -110,6 +118,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Salable" %}',
},
trackable: {
type: 'bool',
title: '{% trans "Trackable" %}',
},
purchaseable: {
type: 'bool',
title: '{% trans "Purchasable" %}',
@ -120,4 +132,3 @@ function getAvailableTableFilters(tableKey) {
// Finally, no matching key
return {};
}
</script>