diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py
index 3d8871f824..ad4b810e32 100644
--- a/InvenTree/InvenTree/forms.py
+++ b/InvenTree/InvenTree/forms.py
@@ -7,13 +7,19 @@ from __future__ import unicode_literals
from django import forms
from crispy_forms.helper import FormHelper
-from crispy_forms.layout import Layout
+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()
@@ -28,7 +34,62 @@ class HelperForm(forms.ModelForm):
Simply create a 'blank' layout for each available field.
"""
- self.helper.layout = Layout(*self.fields.keys())
+ 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"".format(fa=prefix)
+
+ if suffix and suffix.startswith('fa-'):
+ suffix = r"".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):
diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py
index d9f497da4a..f60c257cef 100644
--- a/InvenTree/build/forms.py
+++ b/InvenTree/build/forms.py
@@ -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'))
diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index 54094632c1..23043d077f 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -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,
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index 6f651a7628..2124207c7d 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -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:
diff --git a/InvenTree/company/forms.py b/InvenTree/company/forms.py
index 003cfaf495..ac3cc69c99 100644
--- a/InvenTree/company/forms.py
+++ b/InvenTree/company/forms.py
@@ -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 = [
diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py
index e7e26064f8..ca61bd77be 100644
--- a/InvenTree/order/forms.py
+++ b/InvenTree/order/forms.py
@@ -8,9 +8,6 @@ from __future__ import unicode_literals
from django import forms
from django.utils.translation import ugettext as _
-from crispy_forms.layout import Field, Layout
-from crispy_forms.bootstrap import PrependedText
-
from mptt.fields import TreeNodeChoiceField
from InvenTree.forms import HelperForm
@@ -93,20 +90,16 @@ class EditPurchaseOrderForm(HelperForm):
def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
+ self.field_prefix = {
+ 'reference': 'PO',
+ 'link': 'fa-link',
+ }
- # TODO - Refactor this?
- self.helper.layout = Layout(
- Field(PrependedText(
- 'reference',
- 'PO',
- placeholder=_("Purchase Order")
- )),
- Field('supplier'),
- Field('supplier_reference'),
- Field('description'),
- Field('link'),
- )
+ self.field_placeholder = {
+ 'reference': _('Enter purchase order number'),
+ }
+
+ super().__init__(*args, **kwargs)
class Meta:
model = PurchaseOrder
@@ -124,20 +117,16 @@ class EditSalesOrderForm(HelperForm):
def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
+ self.field_prefix = {
+ 'reference': 'SO',
+ 'link': 'fa-link',
+ }
- # TODO - Refactor?
- self.helper.layout = Layout(
- Field(PrependedText(
- 'reference',
- 'SO',
- placeholder=_("Sales Order")
- )),
- Field('customer'),
- Field('customer_reference'),
- Field('description'),
- Field('link'),
- )
+ self.field_placeholder = {
+ 'reference': _('Enter sales order number'),
+ }
+
+ super().__init__(*args, **kwargs)
class Meta:
model = SalesOrder
diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml
index 117763be84..035049fe81 100644
--- a/InvenTree/part/fixtures/part.yaml
+++ b/InvenTree/part/fixtures/part.yaml
@@ -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
@@ -64,4 +93,65 @@
category: 7
active: False
IPN: BOB
- revision: A2
\ No newline at end of file
+ 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
diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 332e9b6611..4d70ab927a 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -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 = [
diff --git a/InvenTree/part/migrations/0039_auto_20200515_1127.py b/InvenTree/part/migrations/0039_auto_20200515_1127.py
new file mode 100644
index 0000000000..bc25097888
--- /dev/null
+++ b/InvenTree/part/migrations/0039_auto_20200515_1127.py
@@ -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)
+ ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 90383ab26f..7639b9c25b 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -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):
diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index ca3c747b12..61e7f4ab32 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -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')
diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py
index abd5669016..99d4bce796 100644
--- a/InvenTree/part/test_category.py
+++ b/InvenTree/part/test_category.py
@@ -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())
diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index c7c3c014a1..622f0af547 100644
--- a/InvenTree/part/test_part.py
+++ b/InvenTree/part/test_part.py
@@ -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?")
diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml
index ebc207f29c..cac34fb01b 100644
--- a/InvenTree/stock/fixtures/stock.yaml
+++ b/InvenTree/stock/fixtures/stock.yaml
@@ -68,4 +68,160 @@
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
\ No newline at end of file
diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index a4578440cb..98a4de56d6 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -13,6 +13,8 @@ from mptt.fields import TreeNodeChoiceField
from InvenTree.helpers import GetExportFormats
from InvenTree.forms import HelperForm
+from InvenTree.fields import RoundingDecimalFormField
+
from .models import StockLocation, StockItem, StockItemTracking, StockItemAttachment
@@ -47,6 +49,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 +80,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 +91,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 +100,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
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index ecfbf751c9..bfdb8461e2 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -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:
diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py
index a866bdb880..edb9660000 100644
--- a/InvenTree/stock/tests.py
+++ b/InvenTree/stock/tests.py
@@ -38,6 +38,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,13 +95,16 @@ 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(), 15)
+ self.assertEqual(self.drawer3.item_count, 15)
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
@@ -327,3 +334,73 @@ 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()
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index e616be1f35..dc70cc6bfb 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -717,7 +717,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 +757,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 +775,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 +856,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 +865,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 +892,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
@@ -884,7 +904,7 @@ class StockItemCreate(AjaxCreateView):
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
elif form['supplier_part'].value() is not None:
pass
-
+
return form
def get_initial(self):
@@ -917,6 +937,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 +975,8 @@ class StockItemCreate(AjaxCreateView):
- Manage serial-number valdiation for tracked parts
"""
+ part = None
+
form = self.get_form()
data = {}
@@ -965,12 +988,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 +1021,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 +1036,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,6 +1066,24 @@ class StockItemCreate(AjaxCreateView):
form.clean()
form._post_clean()
+ 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")
+
+ valid = True
+
+ else: # Referenced Part object is not marked as "trackable"
+ # For non-serialized items, simply save the form.
+ # We need to call _post_clean() here because it is prevented in the form implementation
+ form.clean()
+ form._post_clean()
+
+ if form.is_valid:
item = form.save(commit=False)
item.save(user=request.user)
@@ -1038,20 +1091,9 @@ class StockItemCreate(AjaxCreateView):
data['url'] = item.get_absolute_url()
data['success'] = _("Created new stock item")
- else: # Referenced Part object is not marked as "trackable"
- # For non-serialized items, simply save the form.
- # We need to call _post_clean() here because it is prevented in the form implementation
- form.clean()
- form._post_clean()
-
- item = form.save(commit=False)
- item.save(user=request.user)
+ valid = True
- data['pk'] = item.pk
- data['url'] = item.get_absolute_url()
- data['success'] = _("Created new stock item")
-
- data['form_valid'] = valid
+ data['form_valid'] = valid and form.is_valid()
return self.renderJsonResponse(request, form, data=data)
diff --git a/InvenTree/templates/table_filters.html b/InvenTree/templates/table_filters.html
index b337b25ac8..de0b049d57 100644
--- a/InvenTree/templates/table_filters.html
+++ b/InvenTree/templates/table_filters.html
@@ -110,6 +110,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Salable" %}',
},
+ trackable: {
+ type: 'bool',
+ title: '{% trans "Trackable" %}',
+ },
purchaseable: {
type: 'bool',
title: '{% trans "Purchasable" %}',