diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 0fe3136871..99232519dc 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -133,7 +133,7 @@ class ReferenceIndexingMixin(models.Model): reference_int = models.BigIntegerField(default=0) -def extract_int(reference): +def extract_int(reference, clip=0x7fffffff): # Default value if we cannot convert to an integer ref_int = 0 @@ -146,6 +146,15 @@ def extract_int(reference): ref_int = int(ref) except: ref_int = 0 + + # Ensure that the returned values are within the range that can be stored in an IntegerField + # Note: This will result in large values being "clipped" + if clip is not None: + if ref_int > clip: + ref_int = clip + elif ref_int < -clip: + ref_int = -clip + return ref_int diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 50c07a610e..59a628d256 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -9,7 +9,7 @@ import re import common.models # InvenTree software version -INVENTREE_SW_VERSION = "0.6.1" +INVENTREE_SW_VERSION = "0.6.2" # InvenTree API version INVENTREE_API_VERSION = 26 diff --git a/InvenTree/barcodes/api.py b/InvenTree/barcodes/api.py index 0d3656ce4c..047db357a4 100644 --- a/InvenTree/barcodes/api.py +++ b/InvenTree/barcodes/api.py @@ -12,6 +12,7 @@ from rest_framework.views import APIView from stock.models import StockItem from stock.serializers import StockItemSerializer +from barcodes.plugins.inventree_barcode import InvenTreeBarcodePlugin from barcodes.barcode import hash_barcode from plugin import registry @@ -57,6 +58,9 @@ class BarcodeScan(APIView): barcode_data = data.get('barcode') + # Ensure that the default barcode handler is installed + plugins.append(InvenTreeBarcodePlugin()) + # Look for a barcode plugin which knows how to deal with this barcode plugin = None diff --git a/InvenTree/barcodes/plugins/inventree_barcode.py b/InvenTree/barcodes/plugins/inventree_barcode.py index 842f9029aa..1b451f0286 100644 --- a/InvenTree/barcodes/plugins/inventree_barcode.py +++ b/InvenTree/barcodes/plugins/inventree_barcode.py @@ -52,7 +52,7 @@ class InvenTreeBarcodePlugin(BarcodePlugin): # If any of the following keys are in the JSON data, # let's go ahead and assume that the code is a valid InvenTree one... - for key in ['tool', 'version', 'InvenTree', 'stockitem', 'location', 'part']: + for key in ['tool', 'version', 'InvenTree', 'stockitem', 'stocklocation', 'part']: if key in self.data.keys(): return True diff --git a/InvenTree/barcodes/tests.py b/InvenTree/barcodes/tests.py index a9795c3928..c9a063e8f0 100644 --- a/InvenTree/barcodes/tests.py +++ b/InvenTree/barcodes/tests.py @@ -56,6 +56,66 @@ class BarcodeAPITest(APITestCase): self.assertIn('plugin', data) self.assertIsNone(data['plugin']) + def test_find_part(self): + """ + Test that we can lookup a part based on ID + """ + + response = self.client.post( + self.scan_url, + { + 'barcode': { + 'part': 1, + }, + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('part', response.data) + self.assertIn('barcode_data', response.data) + self.assertEqual(response.data['part']['pk'], 1) + + def test_find_stock_item(self): + """ + Test that we can lookup a stock item based on ID + """ + + response = self.client.post( + self.scan_url, + { + 'barcode': { + 'stockitem': 1, + } + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('stockitem', response.data) + self.assertIn('barcode_data', response.data) + self.assertEqual(response.data['stockitem']['pk'], 1) + + def test_find_location(self): + """ + Test that we can lookup a stock location based on ID + """ + + response = self.client.post( + self.scan_url, + { + 'barcode': { + 'stocklocation': 1, + }, + }, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('stocklocation', response.data) + self.assertIn('barcode_data', response.data) + self.assertEqual(response.data['stocklocation']['pk'], 1) + def test_integer_barcode(self): response = self.postBarcode(self.scan_url, '123456789') diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 2ea2086431..69834b9444 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -112,17 +112,16 @@ class PurchaseOrderTest(OrderTest): self.assignRole('purchase_order.add') url = reverse('api-po-list') - huge_numer = 9223372036854775808 + huge_number = 9223372036854775808 - # too big self.post( url, { 'supplier': 1, - 'reference': huge_numer, + 'reference': huge_number, 'description': 'PO not created via the API', }, - expected_code=400 + expected_code=201, ) def test_po_attachments(self): diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 09e1f77542..4653e41a61 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -20,7 +20,7 @@ from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator from django.contrib.auth.models import User -from django.db.models.signals import pre_delete, post_save +from django.db.models.signals import post_save from django.dispatch import receiver from jinja2 import Template @@ -76,6 +76,35 @@ class PartCategory(InvenTreeTree): default_keywords: Default keywords for parts created in this category """ + def delete(self, *args, **kwargs): + """ + Custom model deletion routine, which updates any child categories or parts. + This must be handled within a transaction.atomic(), otherwise the tree structure is damaged + """ + + with transaction.atomic(): + + parent = self.parent + tree_id = self.tree_id + + # Update each part in this category to point to the parent category + for part in self.parts.all(): + part.category = self.parent + part.save() + + # Update each child category + for child in self.children.all(): + child.parent = self.parent + child.save() + + super().delete(*args, **kwargs) + + if parent is not None: + # Partially rebuild the tree (cheaper than a complete rebuild) + PartCategory.objects.partial_rebuild(tree_id) + else: + PartCategory.objects.rebuild() + default_location = TreeForeignKey( 'stock.StockLocation', related_name="default_categories", null=True, blank=True, @@ -260,27 +289,6 @@ class PartCategory(InvenTreeTree): ).delete() -@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log') -def before_delete_part_category(sender, instance, using, **kwargs): - """ Receives before_delete signal for PartCategory object - - Before deleting, update child Part and PartCategory objects: - - - For each child category, set the parent to the parent of *this* category - - For each part, set the 'category' to the parent of *this* category - """ - - # Update each part in this category to point to the parent category - for part in instance.parts.all(): - part.category = instance.parent - part.save() - - # Update each child category - for child in instance.children.all(): - child.parent = instance.parent - child.save() - - def rename_part_image(instance, filename): """ Function for renaming a part image file diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index 53030d402a..6eb76fa845 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -172,3 +172,122 @@ class CategoryTest(TestCase): # And one part should have no default location at all w = Part.objects.get(name='Widget') self.assertIsNone(w.get_default_location()) + + def test_category_tree(self): + """ + Unit tests for the part category tree structure (MPTT) + Ensure that the MPTT structure is rebuilt correctly, + and the correct ancestor tree is observed. + """ + + # Clear out any existing parts + Part.objects.all().delete() + + # First, create a structured tree of part categories + A = PartCategory.objects.create( + name='A', + description='Top level category', + ) + + B1 = PartCategory.objects.create(name='B1', parent=A) + B2 = PartCategory.objects.create(name='B2', parent=A) + B3 = PartCategory.objects.create(name='B3', parent=A) + + C11 = PartCategory.objects.create(name='C11', parent=B1) + C12 = PartCategory.objects.create(name='C12', parent=B1) + C13 = PartCategory.objects.create(name='C13', parent=B1) + + C21 = PartCategory.objects.create(name='C21', parent=B2) + C22 = PartCategory.objects.create(name='C22', parent=B2) + C23 = PartCategory.objects.create(name='C23', parent=B2) + + C31 = PartCategory.objects.create(name='C31', parent=B3) + C32 = PartCategory.objects.create(name='C32', parent=B3) + C33 = PartCategory.objects.create(name='C33', parent=B3) + + # Check that the tree_id value is correct + for cat in [B1, B2, B3, C11, C22, C33]: + self.assertEqual(cat.tree_id, A.tree_id) + self.assertEqual(cat.level, cat.parent.level + 1) + self.assertEqual(cat.get_ancestors().count(), cat.level) + + # Spot check for C31 + ancestors = C31.get_ancestors(include_self=True) + + self.assertEqual(ancestors.count(), 3) + self.assertEqual(ancestors[0], A) + self.assertEqual(ancestors[1], B3) + self.assertEqual(ancestors[2], C31) + + # At this point, we are confident that the tree is correctly structured + + # Add some parts to category B3 + + for i in range(10): + Part.objects.create( + name=f'Part {i}', + description='A test part', + category=B3, + ) + + self.assertEqual(Part.objects.filter(category=B3).count(), 10) + self.assertEqual(Part.objects.filter(category=A).count(), 0) + + # Delete category B3 + B3.delete() + + # Child parts have been moved to category A + self.assertEqual(Part.objects.filter(category=A).count(), 10) + + for cat in [C31, C32, C33]: + # These categories should now be directly under A + cat.refresh_from_db() + + self.assertEqual(cat.parent, A) + self.assertEqual(cat.level, 1) + self.assertEqual(cat.get_ancestors().count(), 1) + self.assertEqual(cat.get_ancestors()[0], A) + + # Now, delete category A + A.delete() + + # Parts have now been moved to the top-level category + self.assertEqual(Part.objects.filter(category=None).count(), 10) + + for loc in [B1, B2, C31, C32, C33]: + # These should now all be "top level" categories + loc.refresh_from_db() + + self.assertEqual(loc.level, 0) + self.assertEqual(loc.parent, None) + + # Check descendants for B1 + descendants = B1.get_descendants() + self.assertEqual(descendants.count(), 3) + + for loc in [C11, C12, C13]: + self.assertTrue(loc in descendants) + + # Check category C1x, should be B1 -> C1x + for loc in [C11, C12, C13]: + loc.refresh_from_db() + + self.assertEqual(loc.level, 1) + self.assertEqual(loc.parent, B1) + ancestors = loc.get_ancestors(include_self=True) + + self.assertEqual(ancestors.count(), 2) + self.assertEqual(ancestors[0], B1) + self.assertEqual(ancestors[1], loc) + + # Check category C2x, should be B2 -> C2x + for loc in [C21, C22, C23]: + loc.refresh_from_db() + + self.assertEqual(loc.level, 1) + self.assertEqual(loc.parent, B2) + ancestors = loc.get_ancestors(include_self=True) + + self.assertEqual(ancestors.count(), 2) + self.assertEqual(ancestors[0], B2) + self.assertEqual(ancestors[1], loc) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 14132d297b..171ee7e0a3 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -54,6 +54,35 @@ class StockLocation(InvenTreeTree): Stock locations can be heirarchical as required """ + def delete(self, *args, **kwargs): + """ + Custom model deletion routine, which updates any child locations or items. + This must be handled within a transaction.atomic(), otherwise the tree structure is damaged + """ + + with transaction.atomic(): + + parent = self.parent + tree_id = self.tree_id + + # Update each stock item in the stock location + for item in self.stock_items.all(): + item.location = self.parent + item.save() + + # Update each child category + for child in self.children.all(): + child.parent = self.parent + child.save() + + super().delete(*args, **kwargs) + + if parent is not None: + # Partially rebuild the tree (cheaper than a complete rebuild) + StockLocation.objects.partial_rebuild(tree_id) + else: + StockLocation.objects.rebuild() + @staticmethod def get_api_url(): return reverse('api-location-list') @@ -159,20 +188,6 @@ class StockLocation(InvenTreeTree): return self.stock_item_count() -@receiver(pre_delete, sender=StockLocation, dispatch_uid='stocklocation_delete_log') -def before_delete_stock_location(sender, instance, using, **kwargs): - - # Update each part in the stock location - for item in instance.stock_items.all(): - item.location = instance.parent - item.save() - - # Update each child category - for child in instance.children.all(): - child.parent = instance.parent - child.save() - - class StockItemManager(TreeManager): """ Custom database manager for the StockItem class. @@ -269,10 +284,62 @@ class StockItem(MPTTModel): serial_int = 0 if serial is not None: - serial_int = extract_int(str(serial)) + + serial = str(serial).strip() + + serial_int = extract_int(serial) self.serial_int = serial_int + def get_next_serialized_item(self, include_variants=True, reverse=False): + """ + Get the "next" serial number for the part this stock item references. + + e.g. if this stock item has a serial number 100, we may return the stock item with serial number 101 + + Note that this only works for "serialized" stock items with integer values + + Args: + include_variants: True if we wish to include stock for variant parts + reverse: True if we want to return the "previous" (lower) serial number + + Returns: + A StockItem object matching the requirements, or None + + """ + + if not self.serialized: + return None + + # Find only serialized stock items + items = StockItem.objects.exclude(serial=None).exclude(serial='') + + if include_variants: + # Match against any part within the variant tree + items = items.filter(part__tree_id=self.part.tree_id) + else: + # Match only against the specific part + items = items.filter(part=self.part) + + serial = self.serial_int + + if reverse: + # Select only stock items with lower serial numbers, in decreasing order + items = items.filter(serial_int__lt=serial) + items = items.order_by('-serial_int') + else: + # Select only stock items with higher serial numbers, in increasing order + items = items.filter(serial_int__gt=serial) + items = items.order_by('serial_int') + + if items.count() > 0: + item = items.first() + + if item.serialized: + return item + + return None + def save(self, *args, **kwargs): """ Save this StockItem to the database. Performs a number of checks: @@ -350,7 +417,7 @@ class StockItem(MPTTModel): @property def serialized(self): """ Return True if this StockItem is serialized """ - return self.serial is not None and self.quantity == 1 + return self.serial is not None and len(str(self.serial).strip()) > 0 and self.quantity == 1 def validate_unique(self, exclude=None): """ diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 40e6561926..50f77a593b 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -346,6 +346,118 @@ class StockTest(TestCase): with self.assertRaises(StockItem.DoesNotExist): w2 = StockItem.objects.get(pk=101) + def test_serials(self): + """ + Tests for stock serialization + """ + + p = Part.objects.create( + name='trackable part', + description='trackable part', + trackable=True, + ) + + item = StockItem.objects.create( + part=p, + quantity=1, + ) + + self.assertFalse(item.serialized) + + item.serial = None + item.save() + self.assertFalse(item.serialized) + + item.serial = ' ' + item.save() + self.assertFalse(item.serialized) + + item.serial = '' + item.save() + self.assertFalse(item.serialized) + + item.serial = '1' + item.save() + self.assertTrue(item.serialized) + + def test_big_serials(self): + """ + Unit tests for "large" serial numbers which exceed integer encoding + """ + + p = Part.objects.create( + name='trackable part', + description='trackable part', + trackable=True, + ) + + item = StockItem.objects.create( + part=p, + quantity=1, + ) + + for sn in [12345, '12345', ' 12345 ']: + item.serial = sn + item.save() + + self.assertEqual(item.serial_int, 12345) + + item.serial = "-123" + item.save() + + # Negative number should map to zero + self.assertEqual(item.serial_int, 0) + + # Test a very very large value + item.serial = '99999999999999999999999999999999999999999999999999999' + item.save() + + self.assertEqual(item.serial_int, 0x7fffffff) + + # Non-numeric values should encode to zero + for sn in ['apple', 'banana', 'carrot']: + item.serial = sn + item.save() + + self.assertEqual(item.serial_int, 0) + + # Next, test for incremenet / decrement functionality + item.serial = 100 + item.save() + + item_next = StockItem.objects.create( + part=p, + serial=150, + quantity=1 + ) + + self.assertEqual(item.get_next_serialized_item(), item_next) + + item_prev = StockItem.objects.create( + part=p, + serial=' 57', + quantity=1, + ) + + self.assertEqual(item.get_next_serialized_item(reverse=True), item_prev) + + # Create a number of serialized stock items around the current item + for i in range(75, 125): + try: + StockItem.objects.create( + part=p, + serial=i, + quantity=1, + ) + except: + pass + + item_next = item.get_next_serialized_item() + item_prev = item.get_next_serialized_item(reverse=True) + + self.assertEqual(item_next.serial_int, 101) + self.assertEqual(item_prev.serial_int, 99) + def test_serialize_stock_invalid(self): """ Test manual serialization of parts. @@ -412,6 +524,174 @@ class StockTest(TestCase): # Serialize the remainder of the stock item.serializeStock(2, [99, 100], self.user) + def test_location_tree(self): + """ + Unit tests for stock location tree structure (MPTT). + Ensure that the MPTT structure is rebuilt correctly, + and the corrent ancestor tree is observed. + + Ref: https://github.com/inventree/InvenTree/issues/2636 + Ref: https://github.com/inventree/InvenTree/issues/2733 + """ + + # First, we will create a stock location structure + + A = StockLocation.objects.create( + name='A', + description='Top level location' + ) + + B1 = StockLocation.objects.create( + name='B1', + parent=A + ) + + B2 = StockLocation.objects.create( + name='B2', + parent=A + ) + + B3 = StockLocation.objects.create( + name='B3', + parent=A + ) + + C11 = StockLocation.objects.create( + name='C11', + parent=B1, + ) + + C12 = StockLocation.objects.create( + name='C12', + parent=B1, + ) + + C21 = StockLocation.objects.create( + name='C21', + parent=B2, + ) + + C22 = StockLocation.objects.create( + name='C22', + parent=B2, + ) + + C31 = StockLocation.objects.create( + name='C31', + parent=B3, + ) + + C32 = StockLocation.objects.create( + name='C32', + parent=B3 + ) + + # Check that the tree_id is correct for each sublocation + for loc in [B1, B2, B3, C11, C12, C21, C22, C31, C32]: + self.assertEqual(loc.tree_id, A.tree_id) + + # Check that the tree levels are correct for each node in the tree + + self.assertEqual(A.level, 0) + self.assertEqual(A.get_ancestors().count(), 0) + + for loc in [B1, B2, B3]: + self.assertEqual(loc.parent, A) + self.assertEqual(loc.level, 1) + self.assertEqual(loc.get_ancestors().count(), 1) + + for loc in [C11, C12]: + self.assertEqual(loc.parent, B1) + self.assertEqual(loc.level, 2) + self.assertEqual(loc.get_ancestors().count(), 2) + + for loc in [C21, C22]: + self.assertEqual(loc.parent, B2) + self.assertEqual(loc.level, 2) + self.assertEqual(loc.get_ancestors().count(), 2) + + for loc in [C31, C32]: + self.assertEqual(loc.parent, B3) + self.assertEqual(loc.level, 2) + self.assertEqual(loc.get_ancestors().count(), 2) + + # Spot-check for C32 + ancestors = C32.get_ancestors(include_self=True) + + self.assertEqual(ancestors[0], A) + self.assertEqual(ancestors[1], B3) + self.assertEqual(ancestors[2], C32) + + # At this point, we are confident that the tree is correctly structured. + + # Let's delete node B3 from the tree. We expect that: + # - C31 should move directly under A + # - C32 should move directly under A + + # Add some stock items to B3 + for i in range(10): + StockItem.objects.create( + part=Part.objects.get(pk=1), + quantity=10, + location=B3 + ) + + self.assertEqual(StockItem.objects.filter(location=B3).count(), 10) + self.assertEqual(StockItem.objects.filter(location=A).count(), 0) + + B3.delete() + + A.refresh_from_db() + C31.refresh_from_db() + C32.refresh_from_db() + + # Stock items have been moved to A + self.assertEqual(StockItem.objects.filter(location=A).count(), 10) + + # Parent should be A + self.assertEqual(C31.parent, A) + self.assertEqual(C32.parent, A) + + self.assertEqual(C31.tree_id, A.tree_id) + self.assertEqual(C31.level, 1) + + self.assertEqual(C32.tree_id, A.tree_id) + self.assertEqual(C32.level, 1) + + # Ancestor tree should be just A + ancestors = C31.get_ancestors() + self.assertEqual(ancestors.count(), 1) + self.assertEqual(ancestors[0], A) + + ancestors = C32.get_ancestors() + self.assertEqual(ancestors.count(), 1) + self.assertEqual(ancestors[0], A) + + # Delete A + A.delete() + + # Stock items have been moved to top-level location + self.assertEqual(StockItem.objects.filter(location=None).count(), 10) + + for loc in [B1, B2, C11, C12, C21, C22]: + loc.refresh_from_db() + + self.assertEqual(B1.parent, None) + self.assertEqual(B2.parent, None) + + self.assertEqual(C11.parent, B1) + self.assertEqual(C12.parent, B1) + self.assertEqual(C11.get_ancestors().count(), 1) + self.assertEqual(C12.get_ancestors().count(), 1) + + self.assertEqual(C21.parent, B2) + self.assertEqual(C22.parent, B2) + + ancestors = C21.get_ancestors() + + self.assertEqual(C21.get_ancestors().count(), 1) + self.assertEqual(C22.get_ancestors().count(), 1) + class VariantTest(StockTest): """ diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index d1fde25b0a..8218b00a6a 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -103,43 +103,16 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView): model = StockItem def get_context_data(self, **kwargs): - """ add previous and next item """ + """ + Add information on the "next" and "previous" StockItem objects, + based on the serial numbers. + """ + data = super().get_context_data(**kwargs) if self.object.serialized: - - serial_elem = {} - - try: - current = int(self.object.serial) - - for item in self.object.part.stock_items.all(): - - if item.serialized: - try: - sn = int(item.serial) - serial_elem[sn] = item - except ValueError: - # We only support integer serial number progression - pass - - serials = serial_elem.keys() - - # previous - for nbr in range(current - 1, min(serials), -1): - if nbr in serials: - data['previous'] = serial_elem.get(nbr, None) - break - - # next - for nbr in range(current + 1, max(serials) + 1): - if nbr in serials: - data['next'] = serial_elem.get(nbr, None) - break - - except ValueError: - # We only support integer serial number progression - pass + data['previous'] = self.object.get_next_serialized_item(reverse=True) + data['next'] = self.object.get_next_serialized_item() data['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') data['item_owner'] = self.object.get_item_owner() diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 008091bf15..1dc31cc94f 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -824,7 +824,7 @@ function loadPurchaseOrderTable(table, options) { sortable: true, sortName: 'supplier__name', formatter: function(value, row) { - return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/purchase-orders/`); + return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/?display=purchase-orders`); } }, {