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" %}',