From 72cfaccac574814e6e201706d117bb2295f9f29d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 May 2020 21:16:00 +1000 Subject: [PATCH 01/18] Pass StockItem object through to the SerializeStock form --- InvenTree/stock/forms.py | 15 ++++++++++++++- InvenTree/stock/views.py | 13 ++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index a4578440cb..0286054766 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 @@ -79,7 +81,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 +90,17 @@ 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 + stock_item = kwargs.pop('item') + + super().__init__(*args, **kwargs) + + # TODO - Pre-fill the serial numbers! + class Meta: model = StockItem diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index e616be1f35..b94036f66e 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -755,7 +755,18 @@ class StockItemSerialize(AjaxUpdateView): model = StockItem ajax_template_name = 'stock/item_serialize.html' ajax_form_title = _('Serialize Stock') - form_class = SerializeStockForm + #form_class = SerializeStockForm + + def get_form(self): + + context = self.get_form_kwargs() + + # Pass the StockItem object through to the form + context['item'] = self.get_object() + + form = SerializeStockForm(**context) + + return form def get_initial(self): From 0a78432a0fbd4a527a2f1cb095601ca56640be49 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 May 2020 21:35:53 +1000 Subject: [PATCH 02/18] Convert 'part" to MPTT model - based on the 'variant_of' field - Now recursive variants can be implemented properly --- .../migrations/0039_auto_20200515_1127.py | 50 +++++++++++++++++++ InvenTree/part/models.py | 12 +++-- 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 InvenTree/part/migrations/0039_auto_20200515_1127.py 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..c2cfec8d9b 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 @@ -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): """ From 0652579312618d8b3461990abf94c34ff9e81a9c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 May 2020 21:44:25 +1000 Subject: [PATCH 03/18] Update fixture for part model to match MPTT requirements --- InvenTree/part/fixtures/part.yaml | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 117763be84..e018c52c3b 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,8 @@ 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 \ No newline at end of file From 2d6c531fda2fcc44b9ca5febe9d7c6e634a8195e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 May 2020 22:01:21 +1000 Subject: [PATCH 04/18] Unit testing for part variant MPTT --- InvenTree/part/fixtures/part.yaml | 59 ++++++++++++++++++++++++++++++- InvenTree/part/test_part.py | 12 +++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index e018c52c3b..035049fe81 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -97,4 +97,61 @@ tree_id: 0 level: 0 lft: 0 - rght: 0 \ No newline at end of file + 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/test_part.py b/InvenTree/part/test_part.py index c7c3c014a1..0ecb9b5997 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -52,6 +52,18 @@ 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) + def test_str(self): p = Part.objects.get(pk=100) self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?") From ea88a03b5ac455e9022ece2024c85fa254c954aa Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 08:43:57 +1000 Subject: [PATCH 05/18] More serial number validation and unit testing - --- InvenTree/part/models.py | 82 ++++++++++++--- InvenTree/part/test_api.py | 2 +- InvenTree/part/test_category.py | 6 +- InvenTree/part/test_part.py | 2 + InvenTree/stock/fixtures/stock.yaml | 156 ++++++++++++++++++++++++++++ InvenTree/stock/models.py | 79 +++++++------- InvenTree/stock/tests.py | 84 ++++++++++++++- 7 files changed, 346 insertions(+), 65 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index c2cfec8d9b..528d932cf0 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -266,6 +266,48 @@ class Part(MPTTModel): def __str__(self): return "{n} - {d}".format(n=self.full_name, d=self.description) + def check_if_serial_number_exists(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 get_highest_serial_number(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 get_next_serial_number(self): + """ + Return the next-available serial number for this Part. + """ + + n = self.get_highest_serial_number() + + if n is None: + return 1 + else: + return n + 1 + @property def full_name(self): """ Format a 'full name' for this Part. @@ -642,32 +684,40 @@ class Part(MPTTModel): 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 0ecb9b5997..622f0af547 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -63,6 +63,8 @@ class PartTest(TestCase): 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) 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/models.py b/InvenTree/stock/models.py index ecfbf751c9..e7e4223f24 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -142,11 +142,31 @@ 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. + """ + + # 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) + if not self.pk: + # StockItem has not yet been saved add_note = True else: + # StockItem has already been saved add_note = False + stock = stock.exclude(pk=self.pk) + + if self.serial is not None: + # Check for presence of stock with same serial number + if stock.exists(): + raise ValidationError({"serial": _("StockItem with this serial number already exists")}) + user = kwargs.pop('user', None) add_note = add_note and kwargs.pop('note', True) @@ -172,37 +192,6 @@ 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 - """ - - 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) @@ -210,18 +199,21 @@ class StockItem(MPTTModel): # ensure that the serial number is unique # across all variants of the same template part + print("validating...") + print(self.pk, 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') + + parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) + stock = StockItem.objects.filter( + part__in=parts, + serial=self.serial, + ).exclude(pk=self.pk) + + if stock.exists(): + raise ValidationError({ + 'serial': _('A stock item with this serial number already exists for this part'), }) except PartModels.Part.DoesNotExist: pass @@ -599,6 +591,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 +619,7 @@ class StockItem(MPTTModel): existing = [] for serial in serials: - if not StockItem.check_serial_number(self.part, serial): + if self.part.check_if_serial_number_exists(serial): existing.append(serial) if len(existing) > 0: diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index a866bdb880..307879629a 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 @@ -301,6 +308,7 @@ class StockTest(TestCase): item.delete_on_deplete = True item.save() + n = StockItem.objects.filter(part=25).count() self.assertEqual(item.quantity, 10) @@ -327,3 +335,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.check_if_serial_number_exists(1)) + self.assertTrue(chair.check_if_serial_number_exists(2)) + self.assertTrue(chair.check_if_serial_number_exists(3)) + self.assertTrue(chair.check_if_serial_number_exists(4)) + self.assertTrue(chair.check_if_serial_number_exists(5)) + + self.assertTrue(chair.check_if_serial_number_exists(20)) + self.assertTrue(chair.check_if_serial_number_exists(21)) + self.assertTrue(chair.check_if_serial_number_exists(22)) + + self.assertFalse(chair.check_if_serial_number_exists(30)) + + self.assertEqual(chair.get_next_serial_number(), 23) + + # Same operations on a sub-item + variant = Part.objects.get(pk=10003) + self.assertEqual(variant.get_next_serial_number(), 23) + + # Create a new serial number + n = variant.get_highest_serial_number() + + 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() From 10762fc1cf085ec969f0f4702fc7e814ee0facef Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 08:55:19 +1000 Subject: [PATCH 06/18] Refactor tractor --- InvenTree/part/models.py | 4 ++-- InvenTree/stock/models.py | 47 ++++++++++++++------------------------- 2 files changed, 19 insertions(+), 32 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 528d932cf0..0fdf764961 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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 @@ -241,7 +241,7 @@ class Part(MPTTModel): class MPTTMeta: # For legacy reasons the 'variant_of' field is used to indicate the MPTT parent - parent_attr='variant_of' + parent_attr = 'variant_of' def save(self, *args, **kwargs): """ diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index e7e4223f24..bf9c763ae7 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -149,9 +149,8 @@ class StockItem(MPTTModel): - Adds a transaction note when the item is first created. """ - # 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) + self.validate_unique() + self.clean() if not self.pk: # StockItem has not yet been saved @@ -160,13 +159,6 @@ class StockItem(MPTTModel): # StockItem has already been saved add_note = False - stock = stock.exclude(pk=self.pk) - - if self.serial is not None: - # Check for presence of stock with same serial number - if stock.exists(): - raise ValidationError({"serial": _("StockItem with this serial number already exists")}) - user = kwargs.pop('user', None) add_note = add_note and kwargs.pop('note', True) @@ -193,30 +185,25 @@ class StockItem(MPTTModel): return self.serial is not None and self.quantity == 1 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). + """ + 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) - print("validating...") - print(self.pk, self.serial) + # Exclude myself from the search + if self.pk is not None: + stock = stock.exclude(pk=self.pk) - try: - if self.serial is not None: - - parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) - stock = StockItem.objects.filter( - part__in=parts, - serial=self.serial, - ).exclude(pk=self.pk) - - if stock.exists(): - raise ValidationError({ - 'serial': _('A stock item with this serial number already exists for this part'), - }) - except PartModels.Part.DoesNotExist: - pass + if stock.exists(): + raise ValidationError({"serial": _("StockItem with this serial number already exists")}) def clean(self): """ Validate the StockItem object (separate to field validation) From 0ccac09962b5adad621aaba21b9812ac9afd5201 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 09:06:39 +1000 Subject: [PATCH 07/18] Auto-fill serial numbers for the SerializeStock form --- InvenTree/stock/forms.py | 25 +++++++++++++++++++++++-- InvenTree/stock/views.py | 9 +++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 0286054766..3eb3c1018b 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -9,6 +9,9 @@ from django import forms from django.forms.utils import ErrorDict 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.helpers import GetExportFormats @@ -95,11 +98,29 @@ class SerializeStockForm(HelperForm): def __init__(self, *args, **kwargs): # Extract the stock item - stock_item = kwargs.pop('item') + item = kwargs.pop('item') super().__init__(*args, **kwargs) - # TODO - Pre-fill the serial numbers! + # Pre-calculate what the serial numbers should be! + sn = item.part.get_next_serial_number() + + if item.quantity >= 2: + sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) + else: + sn = str(sn) + + # TODO - Refactor this? Should not have to specify Field('field') for each field... + self.helper.layout = Layout( + Field('quantity'), + Field(PrependedText( + 'serial_numbers', + '#', + placeholder=sn + )), + Field('destination'), + Field('note'), + ) class Meta: model = StockItem diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index b94036f66e..13ec701c10 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -774,7 +774,16 @@ class StockItemSerialize(AjaxUpdateView): item = self.get_object() + # Pre-calculate what the serial numbers should be! + sn = item.part.get_next_serial_number() + + if item.quantity >= 2: + sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) + else: + sn = str(sn) + initials['quantity'] = item.quantity + initials['serial_numbers'] = sn initials['destination'] = item.location.pk return initials From 8fae32e3c712aca1082b784e8b36bcc16aa37264 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 09:33:34 +1000 Subject: [PATCH 08/18] Refactor HelperForm to easily allow setting prepended text / placeholder / etc --- InvenTree/InvenTree/forms.py | 39 ++++++++++++++++++++++++++++++++++-- InvenTree/stock/forms.py | 22 +++++++++----------- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 3d8871f824..fb47f2a32b 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 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 + prefix = {} + suffix = {} + placeholder = {} + def __init__(self, *args, **kwargs): super(forms.ModelForm, self).__init__(*args, **kwargs) self.helper = FormHelper() @@ -28,7 +34,36 @@ class HelperForm(forms.ModelForm): Simply create a 'blank' layout for each available field. """ - self.helper.layout = Layout(*self.fields.keys()) + layouts = [] + + for field in self.fields: + prefix = self.prefix.get(field, None) + suffix = self.suffix.get(field, None) + placeholder = self.placeholder.get(field, None) + + # Look for font-awesome icons + if prefix and prefix.startswith('fa-'): + prefix = "".format(fa=prefix) + + if suffix and suffix.startswith('fa-'): + suffix = "".format(fa=suffix) + + if prefix or suffix or placeholder: + layouts.append( + Field( + PrependedAppendedText( + field, + prepended_text=prefix, + appended_text=suffix, + placeholder=placeholder + ) + ) + ) + + else: + layouts.append(Field(field)) + + self.helper.layout = Layout(*layouts) class DeleteForm(forms.Form): diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 3eb3c1018b..cf04396f54 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -100,8 +100,6 @@ class SerializeStockForm(HelperForm): # Extract the stock item item = kwargs.pop('item') - super().__init__(*args, **kwargs) - # Pre-calculate what the serial numbers should be! sn = item.part.get_next_serial_number() @@ -110,17 +108,15 @@ class SerializeStockForm(HelperForm): else: sn = str(sn) - # TODO - Refactor this? Should not have to specify Field('field') for each field... - self.helper.layout = Layout( - Field('quantity'), - Field(PrependedText( - 'serial_numbers', - '#', - placeholder=sn - )), - Field('destination'), - Field('note'), - ) + self.prefix = { + 'serial_numbers': 'fa-hashtag', + } + + self.placeholder = { + 'serial_numbers': sn + } + + super().__init__(*args, **kwargs) class Meta: model = StockItem From 08d177e55fc60d7b5287be7c04729cf2b013750d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 09:36:43 +1000 Subject: [PATCH 09/18] Update refactor for editing PO and SO forms --- InvenTree/InvenTree/forms.py | 2 +- InvenTree/order/forms.py | 44 +++++++++++++++--------------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index fb47f2a32b..0fb625fb29 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -39,7 +39,7 @@ class HelperForm(forms.ModelForm): for field in self.fields: prefix = self.prefix.get(field, None) suffix = self.suffix.get(field, None) - placeholder = self.placeholder.get(field, None) + placeholder = self.placeholder.get(field, '') # Look for font-awesome icons if prefix and prefix.startswith('fa-'): diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index e7e26064f8..8496fee501 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -93,20 +93,16 @@ class EditPurchaseOrderForm(HelperForm): def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + self.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.placeholder = { + 'reference': _('Enter purchase order number'), + } + + super().__init__(*args, **kwargs) class Meta: model = PurchaseOrder @@ -124,20 +120,16 @@ class EditSalesOrderForm(HelperForm): def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + self.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.placeholder = { + 'reference': _('Enter sales order number'), + } + + super().__init__(*args, **kwargs) class Meta: model = SalesOrder From 498ad4162c08c42810933ba633f626c5f6165161 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 11:05:45 +1000 Subject: [PATCH 10/18] Bugfix: Turns out 'prefix' and 'suffix' were protected fields! --- InvenTree/InvenTree/forms.py | 44 +++++++++++++++++++++++++++--------- InvenTree/order/forms.py | 8 +++---- InvenTree/stock/forms.py | 4 ++-- InvenTree/stock/views.py | 2 +- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 0fb625fb29..b4509446f6 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -8,7 +8,7 @@ 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 PrependedAppendedText +from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText from django.contrib.auth.models import User @@ -16,9 +16,9 @@ class HelperForm(forms.ModelForm): """ Provides simple integration of crispy_forms extension. """ # Custom field decorations can be specified here, per form class - prefix = {} - suffix = {} - placeholder = {} + field_prefix = {} + field_suffix = {} + field_placeholder = {} def __init__(self, *args, **kwargs): super(forms.ModelForm, self).__init__(*args, **kwargs) @@ -37,18 +37,18 @@ class HelperForm(forms.ModelForm): layouts = [] for field in self.fields: - prefix = self.prefix.get(field, None) - suffix = self.suffix.get(field, None) - placeholder = self.placeholder.get(field, '') + 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 = "".format(fa=prefix) + prefix = r"".format(fa=prefix) if suffix and suffix.startswith('fa-'): - suffix = "".format(fa=suffix) + suffix = r"".format(fa=suffix) - if prefix or suffix or placeholder: + if prefix and suffix: layouts.append( Field( PrependedAppendedText( @@ -60,8 +60,30 @@ class HelperForm(forms.ModelForm): ) ) + 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)) + layouts.append(Field(field, placeholder=placeholder)) self.helper.layout = Layout(*layouts) diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 8496fee501..2887ef8892 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -93,12 +93,12 @@ class EditPurchaseOrderForm(HelperForm): def __init__(self, *args, **kwargs): - self.prefix = { + self.field_prefix = { 'reference': 'PO', 'link': 'fa-link', } - self.placeholder = { + self.field_placeholder = { 'reference': _('Enter purchase order number'), } @@ -120,12 +120,12 @@ class EditSalesOrderForm(HelperForm): def __init__(self, *args, **kwargs): - self.prefix = { + self.field_prefix = { 'reference': 'SO', 'link': 'fa-link', } - self.placeholder = { + self.field_placeholder = { 'reference': _('Enter sales order number'), } diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index cf04396f54..22c302c859 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -108,11 +108,11 @@ class SerializeStockForm(HelperForm): else: sn = str(sn) - self.prefix = { + self.field_prefix = { 'serial_numbers': 'fa-hashtag', } - self.placeholder = { + self.field_placeholder = { 'serial_numbers': sn } diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 13ec701c10..c33af4e15a 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 From 4cb97b1340e4361744432a706c0259c598b8fbd2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 11:55:10 +1000 Subject: [PATCH 11/18] Add some more form candy --- InvenTree/company/forms.py | 15 +++++++++++ InvenTree/part/forms.py | 10 ++++++++ InvenTree/stock/forms.py | 35 ++++++++++++++++++-------- InvenTree/stock/views.py | 1 + InvenTree/templates/table_filters.html | 4 +++ 5 files changed, 54 insertions(+), 11 deletions(-) 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/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/stock/forms.py b/InvenTree/stock/forms.py index 22c302c859..4eaff44b74 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -52,6 +52,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 = [ @@ -74,6 +83,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(): @@ -98,24 +108,27 @@ class SerializeStockForm(HelperForm): def __init__(self, *args, **kwargs): # Extract the stock item - item = kwargs.pop('item') + item = kwargs.pop('item', None) - # Pre-calculate what the serial numbers should be! - sn = item.part.get_next_serial_number() + if item: - if item.quantity >= 2: - sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) - else: - sn = str(sn) + # Pre-calculate what the serial numbers should be! + sn = item.part.get_next_serial_number() + + if item.quantity >= 2: + sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) + else: + sn = str(sn) + + + self.field_placeholder = { + 'serial_numbers': sn + } self.field_prefix = { 'serial_numbers': 'fa-hashtag', } - self.field_placeholder = { - 'serial_numbers': sn - } - super().__init__(*args, **kwargs) class Meta: diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index c33af4e15a..a7195a5300 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -937,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 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" %}', From 3d0bea15ae12a3c0a40091429c9924b8d49f0497 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 12:03:18 +1000 Subject: [PATCH 12/18] Refactor function naming --- InvenTree/build/views.py | 2 +- InvenTree/part/models.py | 8 ++++---- InvenTree/stock/forms.py | 2 +- InvenTree/stock/models.py | 2 +- InvenTree/stock/tests.py | 24 ++++++++++++------------ InvenTree/stock/views.py | 2 +- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 6f651a7628..96eb602f2b 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -282,7 +282,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/part/models.py b/InvenTree/part/models.py index 0fdf764961..31771dc291 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -266,7 +266,7 @@ class Part(MPTTModel): def __str__(self): return "{n} - {d}".format(n=self.full_name, d=self.description) - def check_if_serial_number_exists(self, sn): + def checkIfSerialNumberExists(self, sn): """ Check if a serial number exists for this Part. @@ -279,7 +279,7 @@ class Part(MPTTModel): return stock.exists() - def get_highest_serial_number(self): + def getHighestSerialNumber(self): """ Return the highest serial number for this Part. @@ -296,12 +296,12 @@ class Part(MPTTModel): # No serial numbers found return None - def get_next_serial_number(self): + def getNextSerialNumber(self): """ Return the next-available serial number for this Part. """ - n = self.get_highest_serial_number() + n = self.getHighestSerialNumber() if n is None: return 1 diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 4eaff44b74..520ac72835 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -113,7 +113,7 @@ class SerializeStockForm(HelperForm): if item: # Pre-calculate what the serial numbers should be! - sn = item.part.get_next_serial_number() + sn = item.part.getNextSerialNumber() if item.quantity >= 2: sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index bf9c763ae7..e0f38ba871 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -606,7 +606,7 @@ class StockItem(MPTTModel): existing = [] for serial in serials: - if self.part.check_if_serial_number_exists(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 307879629a..ab038c3c62 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -361,26 +361,26 @@ class VariantTest(StockTest): chair = Part.objects.get(pk=10000) # Operations on the top-level object - self.assertTrue(chair.check_if_serial_number_exists(1)) - self.assertTrue(chair.check_if_serial_number_exists(2)) - self.assertTrue(chair.check_if_serial_number_exists(3)) - self.assertTrue(chair.check_if_serial_number_exists(4)) - self.assertTrue(chair.check_if_serial_number_exists(5)) + 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.check_if_serial_number_exists(20)) - self.assertTrue(chair.check_if_serial_number_exists(21)) - self.assertTrue(chair.check_if_serial_number_exists(22)) + self.assertTrue(chair.checkIfSerialNumberExists(20)) + self.assertTrue(chair.checkIfSerialNumberExists(21)) + self.assertTrue(chair.checkIfSerialNumberExists(22)) - self.assertFalse(chair.check_if_serial_number_exists(30)) + self.assertFalse(chair.checkIfSerialNumberExists(30)) - self.assertEqual(chair.get_next_serial_number(), 23) + self.assertEqual(chair.getNextSerialNumber(), 23) # Same operations on a sub-item variant = Part.objects.get(pk=10003) - self.assertEqual(variant.get_next_serial_number(), 23) + self.assertEqual(variant.getNextSerialNumber(), 23) # Create a new serial number - n = variant.get_highest_serial_number() + n = variant.getHighestSerialNumber() item = StockItem( part=variant, diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index a7195a5300..ee5367e346 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -775,7 +775,7 @@ class StockItemSerialize(AjaxUpdateView): item = self.get_object() # Pre-calculate what the serial numbers should be! - sn = item.part.get_next_serial_number() + sn = item.part.getNextSerialNumber() if item.quantity >= 2: sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) From 3df8f330808db5d21bd039d36dbcc629a5082784 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 12:04:53 +1000 Subject: [PATCH 13/18] Logic fixes for CreateStockItem form - Improved data validation - Fix bug where form was not checked for validity --- InvenTree/stock/models.py | 12 ++++++ InvenTree/stock/views.py | 81 +++++++++++++++++++++++---------------- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index e0f38ba871..7ba7aa1e26 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -227,6 +227,18 @@ class StockItem(MPTTModel): 'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set', }) + 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') + }) + + 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: diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index ee5367e346..7312ee67e6 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -984,19 +984,23 @@ class StockItemCreate(AjaxCreateView): if valid: part_id = form['part'].value() try: - part = Part.objects.get(id=part_id) + self.part = Part.objects.get(id=part_id) quantity = Decimal(form['quantity'].value()) except (Part.DoesNotExist, ValueError, InvalidOperation): - part = None + self.part = None quantity = 1 valid = False form.errors['quantity'] = [_('Invalid quantity')] - if part is None: + if quantity <= 0: + form.errors['quantity'] = [_('Quantity must be greater than zero')] + valid = False + + if self.part is None: form.errors['part'] = [_('Invalid part selection')] else: # A trackable part must provide serial numbesr - if part.trackable: + if self.part.trackable: sn = request.POST.get('serial_numbers', '') sn = str(sn).strip() @@ -1009,7 +1013,7 @@ class StockItemCreate(AjaxCreateView): existing = [] for serial in serials: - if not StockItem.check_serial_number(part, serial): + if self.part.checkIfSerialNumberExists(serial): existing.append(serial) if len(existing) > 0: @@ -1024,24 +1028,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=self.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 @@ -1052,6 +1058,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) @@ -1059,20 +1083,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) From 6552d011a4fa2861b89069dc60a22fda475d0025 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 16:42:34 +1000 Subject: [PATCH 14/18] Better calculatation of placeholder text for serial number --- InvenTree/stock/views.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 7312ee67e6..6493a9d381 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -864,6 +864,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() @@ -871,6 +873,9 @@ 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) + # Hide the 'part' field (as a valid part is selected) form.fields['part'].widget = HiddenInput() @@ -893,6 +898,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 @@ -904,7 +910,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): @@ -975,6 +981,8 @@ class StockItemCreate(AjaxCreateView): - Manage serial-number valdiation for tracked parts """ + part = None + form = self.get_form() data = {} @@ -984,10 +992,14 @@ class StockItemCreate(AjaxCreateView): if valid: part_id = form['part'].value() try: - self.part = Part.objects.get(id=part_id) + 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) + except (Part.DoesNotExist, ValueError, InvalidOperation): - self.part = None + part = None quantity = 1 valid = False form.errors['quantity'] = [_('Invalid quantity')] @@ -996,11 +1008,11 @@ class StockItemCreate(AjaxCreateView): form.errors['quantity'] = [_('Quantity must be greater than zero')] valid = False - if self.part is None: + if part is None: form.errors['part'] = [_('Invalid part selection')] else: # A trackable part must provide serial numbesr - if self.part.trackable: + if part.trackable: sn = request.POST.get('serial_numbers', '') sn = str(sn).strip() @@ -1013,7 +1025,7 @@ class StockItemCreate(AjaxCreateView): existing = [] for serial in serials: - if self.part.checkIfSerialNumberExists(serial): + if part.checkIfSerialNumberExists(serial): existing.append(serial) if len(existing) > 0: @@ -1033,7 +1045,7 @@ class StockItemCreate(AjaxCreateView): for serial in serials: # Create a new stock item for each serial number item = StockItem( - part=self.part, + part=part, quantity=1, serial=serial, supplier_part=form_data.get('supplier_part'), From 7190a8ef6972feddfc82702d76a77b8d66cf7168 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 17:29:41 +1000 Subject: [PATCH 15/18] Serial number placeholder text for BuildComplete form --- InvenTree/InvenTree/forms.py | 4 ++++ InvenTree/build/forms.py | 7 +++++++ InvenTree/build/models.py | 13 +++++++++++++ InvenTree/build/views.py | 15 +++++++++++++++ InvenTree/stock/views.py | 4 ++++ 5 files changed, 43 insertions(+) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index b4509446f6..ad4b810e32 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -34,6 +34,10 @@ class HelperForm(forms.ModelForm): Simply create a 'blank' layout for each available field. """ + self.rebuild_layout() + + def rebuild_layout(self): + layouts = [] for field in self.fields: diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index d9f497da4a..f7a91464d5 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -46,6 +46,13 @@ 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', diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 54094632c1..e75fc88946 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -53,6 +53,19 @@ 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() + + if self.part.trackable: + if not self.quantity == int(self.quantity): + raise ValidationError({ + 'quantity': _("Build quantity must be integer value for trackable parts") + }) + title = models.CharField( verbose_name=_('Build Title'), blank=False, diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 96eb602f2b..6ef455aa70 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -196,6 +196,20 @@ class BuildComplete(AjaxUpdateView): if not build.part.trackable: form.fields.pop('serial_numbers') + else: + sn = build.part.getNextSerialNumber() + + if build.quantity > 1: + sn = "{n}-{m}".format( + n=str(sn), + m=str(sn+build.quantity-1) + ) + else: + sn = str(sn) + + form.field_placeholder['serial_numbers'] = sn + + form.rebuild_layout() return form @@ -208,6 +222,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) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 6493a9d381..b096f2a24f 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -876,6 +876,8 @@ class StockItemCreate(AjaxCreateView): 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() @@ -998,6 +1000,8 @@ class StockItemCreate(AjaxCreateView): 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 From 40735d66a1532615161559cb8bd5d0d7912fa009 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 17:32:20 +1000 Subject: [PATCH 16/18] Translation tweaks --- InvenTree/build/forms.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index f7a91464d5..f60c257cef 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -55,10 +55,14 @@ class CompleteBuildForm(HelperForm): 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')) From a6ad263ee75447155d247c6f09f354bab48abfa4 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 17:43:32 +1000 Subject: [PATCH 17/18] Fix clean functions so unit tests pass --- InvenTree/build/models.py | 13 ++++++++----- InvenTree/stock/models.py | 22 +++++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index e75fc88946..23043d077f 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -60,11 +60,14 @@ class Build(MPTTModel): super().clean() - if self.part.trackable: - if not self.quantity == int(self.quantity): - raise ValidationError({ - 'quantity': _("Build quantity must be integer value for trackable parts") - }) + 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'), diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 7ba7aa1e26..bfdb8461e2 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -215,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", @@ -227,14 +229,20 @@ class StockItem(MPTTModel): 'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set', }) - 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') - }) + 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: + if self.quantity < 0: raise ValidationError({ 'quantity': _('Quantity must be greater than zero') }) From 8a99062704c307b3a527ce82f50e54224c2ea78d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 17:52:25 +1000 Subject: [PATCH 18/18] PEP fixes --- InvenTree/build/views.py | 15 +++++---------- InvenTree/order/forms.py | 3 --- InvenTree/part/models.py | 18 ++++++++++++++++++ InvenTree/stock/forms.py | 21 +-------------------- InvenTree/stock/tests.py | 1 - InvenTree/stock/views.py | 12 ++---------- 6 files changed, 26 insertions(+), 44 deletions(-) diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 6ef455aa70..2124207c7d 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -197,17 +197,12 @@ class BuildComplete(AjaxUpdateView): if not build.part.trackable: form.fields.pop('serial_numbers') else: - sn = build.part.getNextSerialNumber() - - if build.quantity > 1: - sn = "{n}-{m}".format( - n=str(sn), - m=str(sn+build.quantity-1) - ) + if build.quantity == 1: + text = _('Next available serial number is') else: - sn = str(sn) - - form.field_placeholder['serial_numbers'] = sn + text = _('Next available serial numbers are') + + form.field_placeholder['serial_numbers'] = text + " " + build.part.getSerialNumberString(build.quantity) form.rebuild_layout() diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 2887ef8892..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 diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 31771dc291..7639b9c25b 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -308,6 +308,24 @@ class Part(MPTTModel): 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. diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 520ac72835..98a4de56d6 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -9,9 +9,6 @@ from django import forms from django.forms.utils import ErrorDict 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.helpers import GetExportFormats @@ -111,23 +108,7 @@ class SerializeStockForm(HelperForm): item = kwargs.pop('item', None) if item: - - # Pre-calculate what the serial numbers should be! - sn = item.part.getNextSerialNumber() - - if item.quantity >= 2: - sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) - else: - sn = str(sn) - - - self.field_placeholder = { - 'serial_numbers': sn - } - - self.field_prefix = { - 'serial_numbers': 'fa-hashtag', - } + self.field_placeholder['serial_numbers'] = item.part.getSerialNumberString(item.quantity) super().__init__(*args, **kwargs) diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index ab038c3c62..edb9660000 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -308,7 +308,6 @@ class StockTest(TestCase): item.delete_on_deplete = True item.save() - n = StockItem.objects.filter(part=25).count() self.assertEqual(item.quantity, 10) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index b096f2a24f..dc70cc6bfb 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -755,7 +755,7 @@ class StockItemSerialize(AjaxUpdateView): model = StockItem ajax_template_name = 'stock/item_serialize.html' ajax_form_title = _('Serialize Stock') - #form_class = SerializeStockForm + form_class = SerializeStockForm def get_form(self): @@ -774,16 +774,8 @@ class StockItemSerialize(AjaxUpdateView): item = self.get_object() - # Pre-calculate what the serial numbers should be! - sn = item.part.getNextSerialNumber() - - if item.quantity >= 2: - sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) - else: - sn = str(sn) - initials['quantity'] = item.quantity - initials['serial_numbers'] = sn + initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity) initials['destination'] = item.location.pk return initials