from django.test import TestCase from django.db.models import Sum from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError import datetime from InvenTree.status_codes import StockHistoryCode from .models import StockLocation, StockItem, StockItemTracking from .models import StockItemTestResult from part.models import Part from build.models import Build class StockTest(TestCase): """ Tests to ensure that the stock location tree functions correcly """ fixtures = [ 'category', 'part', 'test_templates', 'location', 'stock', 'stock_tests', ] def setUp(self): # Extract some shortcuts from the fixtures self.home = StockLocation.objects.get(name='Home') self.bathroom = StockLocation.objects.get(name='Bathroom') self.diningroom = StockLocation.objects.get(name='Dining Room') self.office = StockLocation.objects.get(name='Office') self.drawer1 = StockLocation.objects.get(name='Drawer_1') self.drawer2 = StockLocation.objects.get(name='Drawer_2') self.drawer3 = StockLocation.objects.get(name='Drawer_3') # Create a user user = get_user_model() user.objects.create_user('username', 'user@email.com', 'password') self.client.login(username='username', password='password') self.user = user.objects.get(username='username') # Ensure the MPTT objects are correctly rebuild Part.objects.rebuild() StockItem.objects.rebuild() def test_expiry(self): """ Test expiry date functionality for StockItem model. """ today = datetime.datetime.now().date() item = StockItem.objects.create( location=self.office, part=Part.objects.get(pk=1), quantity=10, ) # Without an expiry_date set, item should not be "expired" self.assertFalse(item.is_expired()) # Set the expiry date to today item.expiry_date = today item.save() self.assertFalse(item.is_expired()) # Set the expiry date in the future item.expiry_date = today + datetime.timedelta(days=5) item.save() self.assertFalse(item.is_expired()) # Set the expiry date in the past item.expiry_date = today - datetime.timedelta(days=5) item.save() self.assertTrue(item.is_expired()) def test_is_building(self): """ Test that the is_building flag does not count towards stock. """ part = Part.objects.get(pk=1) # Record the total stock count n = part.total_stock StockItem.objects.create(part=part, quantity=5) # And there should be *no* items being build self.assertEqual(part.quantity_being_built, 0) build = Build.objects.create(part=part, title='A test build', quantity=1) # Add some stock items which are "building" for i in range(10): StockItem.objects.create( part=part, build=build, quantity=10, is_building=True ) # The "is_building" quantity should not be counted here self.assertEqual(part.total_stock, n + 5) self.assertEqual(part.quantity_being_built, 1) def test_loc_count(self): self.assertEqual(StockLocation.objects.count(), 7) def test_url(self): it = StockItem.objects.get(pk=2) self.assertEqual(it.get_absolute_url(), '/stock/item/2/') self.assertEqual(self.home.get_absolute_url(), '/stock/location/1/') def test_barcode(self): barcode = self.office.format_barcode(brief=False) self.assertIn('"name": "Office"', barcode) def test_strings(self): it = StockItem.objects.get(pk=1) self.assertEqual(str(it), '4000 x M2x4 LPHS @ Dining Room') def test_parent_locations(self): self.assertEqual(self.office.parent, None) self.assertEqual(self.drawer1.parent, self.office) self.assertEqual(self.drawer2.parent, self.office) self.assertEqual(self.drawer3.parent, self.office) self.assertEqual(self.drawer3.pathstring, 'Office/Drawer_3') # Move one of the drawers self.drawer3.parent = self.home self.drawer3.save() self.assertNotEqual(self.drawer3.parent, self.office) self.assertEqual(self.drawer3.pathstring, 'Home/Drawer_3') def test_children(self): self.assertTrue(self.office.has_children) self.assertFalse(self.drawer2.has_children) childs = [item.pk for item in self.office.getUniqueChildren()] self.assertIn(self.drawer1.id, childs) self.assertIn(self.drawer2.id, childs) self.assertNotIn(self.bathroom.id, childs) def test_items(self): self.assertTrue(self.drawer1.has_items()) self.assertTrue(self.drawer3.has_items()) self.assertFalse(self.drawer2.has_items()) # Drawer 3 should have three stock items self.assertEqual(self.drawer3.stock_items.count(), 16) self.assertEqual(self.drawer3.item_count, 16) def test_stock_count(self): part = Part.objects.get(pk=1) entries = part.stock_entries() self.assertEqual(entries.count(), 2) # There should be 9000 screws in stock self.assertEqual(part.total_stock, 9000) # There should be 16 widgets "in stock" self.assertEqual( StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 16 ) def test_delete_location(self): # How many stock items are there? n_stock = StockItem.objects.count() # What parts are in drawer 3? stock_ids = [part.id for part in StockItem.objects.filter(location=self.drawer3.id)] # Delete location - parts should move to parent location self.drawer3.delete() # There should still be the same number of parts self.assertEqual(StockItem.objects.count(), n_stock) # stock should have moved for s_id in stock_ids: s_item = StockItem.objects.get(id=s_id) self.assertEqual(s_item.location, self.office) def test_move(self): """ Test stock movement functions """ # Move 4,000 screws to the bathroom it = StockItem.objects.get(pk=1) self.assertNotEqual(it.location, self.bathroom) self.assertTrue(it.move(self.bathroom, 'Moved to the bathroom', None)) self.assertEqual(it.location, self.bathroom) # There now should be 2 lots of screws in the bathroom self.assertEqual(StockItem.objects.filter(part=1, location=self.bathroom).count(), 2) # Check that a tracking item was added track = StockItemTracking.objects.filter(item=it).latest('id') self.assertEqual(track.item, it) self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_MOVE) self.assertEqual(track.notes, 'Moved to the bathroom') def test_self_move(self): # Try to move an item to its current location (should fail) it = StockItem.objects.get(pk=1) n = it.tracking_info.count() self.assertFalse(it.move(it.location, 'Moved to same place', None)) # Ensure tracking info was not added self.assertEqual(it.tracking_info.count(), n) def test_partial_move(self): w1 = StockItem.objects.get(pk=100) # A batch code is required to split partial stock! w1.batch = 'BW1' w1.save() # Move 6 of the units self.assertTrue(w1.move(self.diningroom, 'Moved', None, quantity=6)) # There should be 4 remaining self.assertEqual(w1.quantity, 4) # There should also be a new object still in drawer3 self.assertEqual(StockItem.objects.filter(part=25).count(), 5) widget = StockItem.objects.get(location=self.drawer3.id, part=25, quantity=4) # Try to move negative units self.assertFalse(widget.move(self.bathroom, 'Test', None, quantity=-100)) self.assertEqual(StockItem.objects.filter(part=25).count(), 5) # Try to move to a blank location self.assertFalse(widget.move(None, 'null', None)) def test_split_stock(self): # Split the 1234 x 2K2 resistors in Drawer_1 n = StockItem.objects.filter(part=3).count() stock = StockItem.objects.get(id=1234) stock.splitStock(1000, None, self.user) self.assertEqual(stock.quantity, 234) # There should be a new stock item too! self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1) # Try to split a negative quantity stock.splitStock(-10, None, self.user) self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1) stock.splitStock(stock.quantity, None, self.user) self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1) def test_stocktake(self): # Perform stocktake it = StockItem.objects.get(pk=2) self.assertEqual(it.quantity, 5000) it.stocktake(255, None, notes='Counted items!') self.assertEqual(it.quantity, 255) # Check that a tracking item was added track = StockItemTracking.objects.filter(item=it).latest('id') self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_COUNT) self.assertIn('Counted items', track.notes) n = it.tracking_info.count() self.assertFalse(it.stocktake(-1, None, 'test negative stocktake')) # Ensure tracking info was not added self.assertEqual(it.tracking_info.count(), n) def test_add_stock(self): it = StockItem.objects.get(pk=2) n = it.quantity it.add_stock(45, None, notes='Added some items') self.assertEqual(it.quantity, n + 45) # Check that a tracking item was added track = StockItemTracking.objects.filter(item=it).latest('id') self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_ADD) self.assertIn('Added some items', track.notes) self.assertFalse(it.add_stock(-10, None)) def test_take_stock(self): it = StockItem.objects.get(pk=2) n = it.quantity it.take_stock(15, None, notes='Removed some items') self.assertEqual(it.quantity, n - 15) # Check that a tracking item was added track = StockItemTracking.objects.filter(item=it).latest('id') self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_REMOVE) self.assertIn('Removed some items', track.notes) self.assertTrue(it.has_tracking_info) # Test that negative quantity does nothing self.assertFalse(it.take_stock(-10, None)) def test_deplete_stock(self): w1 = StockItem.objects.get(pk=100) w2 = StockItem.objects.get(pk=101) # Take 25 units from w1 (there are only 10 in stock) w1.take_stock(30, None, notes='Took 30') # Get from database again w1 = StockItem.objects.get(pk=100) self.assertEqual(w1.quantity, 0) # Take 25 units from w2 (will be deleted) w2.take_stock(30, None, notes='Took 30') with self.assertRaises(StockItem.DoesNotExist): w2 = StockItem.objects.get(pk=101) def test_serialize_stock_invalid(self): """ Test manual serialization of parts. Each of these tests should fail """ # Test serialization of non-serializable part item = StockItem.objects.get(pk=1234) with self.assertRaises(ValidationError): item.serializeStock(5, [1, 2, 3, 4, 5], self.user) with self.assertRaises(ValidationError): item.serializeStock(5, [1, 2, 3], self.user) # Pick a StockItem which can actually be serialized item = StockItem.objects.get(pk=100) # Try an invalid quantity with self.assertRaises(ValidationError): item.serializeStock("k", [], self.user) with self.assertRaises(ValidationError): item.serializeStock(-1, [], self.user) # Not enough serial numbers for all stock items. with self.assertRaises(ValidationError): item.serializeStock(3, "hello", self.user) def test_serialize_stock_valid(self): """ Perform valid stock serializations """ # There are 10 of these in stock # Item will deplete when deleted item = StockItem.objects.get(pk=100) item.delete_on_deplete = True item.save() n = StockItem.objects.filter(part=25).count() self.assertEqual(item.quantity, 10) item.serializeStock(3, [1, 2, 3], self.user) self.assertEqual(item.quantity, 7) # Try to serialize again (with same serial numbers) with self.assertRaises(ValidationError): item.serializeStock(3, [1, 2, 3], self.user) # Try to serialize too many items with self.assertRaises(ValidationError): item.serializeStock(13, [1, 2, 3], self.user) # Serialize some more stock item.serializeStock(5, [6, 7, 8, 9, 10], self.user) self.assertEqual(item.quantity, 2) # There should be 8 more items now self.assertEqual(StockItem.objects.filter(part=25).count(), n + 8) # 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.getLatestSerialNumber(), '22') # Check for conflicting serial numbers to_check = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] conflicts = chair.find_conflicting_serial_numbers(to_check) self.assertEqual(len(conflicts), 6) # Same operations on a sub-item variant = Part.objects.get(pk=10003) self.assertEqual(variant.getLatestSerialNumber(), '22') # Create a new serial number n = variant.getLatestSerialNumber() item = StockItem( part=variant, quantity=1, serial=n ) # This should fail with self.assertRaises(ValidationError): item.save() # Verify items with a non-numeric serial don't offer a next serial. item.serial = "string" item.save() self.assertEqual(variant.getLatestSerialNumber(), "string") # This should pass, although not strictly an int field now. item.serial = int(n) + 1 item.save() # Attempt to create the same serial number but for a variant (should fail!) item.pk = None item.part = Part.objects.get(pk=10004) with self.assertRaises(ValidationError): item.save() item.serial += 1 item.save() class TestResultTest(StockTest): """ Tests for the StockItemTestResult model. """ def test_test_count(self): item = StockItem.objects.get(pk=105) tests = item.test_results self.assertEqual(tests.count(), 4) results = item.getTestResults(test="Temperature Test") self.assertEqual(results.count(), 2) # Passing tests self.assertEqual(item.getTestResults(result=True).count(), 3) self.assertEqual(item.getTestResults(result=False).count(), 1) # Result map result_map = item.testResultMap() self.assertEqual(len(result_map), 3) # Keys are all lower-case and do not contain spaces for test in ['firmwareversion', 'settingschecksum', 'temperaturetest']: self.assertIn(test, result_map.keys()) def test_test_results(self): item = StockItem.objects.get(pk=522) status = item.requiredTestStatus() self.assertEqual(status['total'], 5) self.assertEqual(status['passed'], 2) self.assertEqual(status['failed'], 2) self.assertFalse(item.passedAllRequiredTests()) # Add some new test results to make it pass! test = StockItemTestResult.objects.get(pk=12345) test.result = True test.save() StockItemTestResult.objects.create( stock_item=item, test='sew cushion', result=True ) # Still should be failing at this point, # as the most recent "apply paint" test was False self.assertFalse(item.passedAllRequiredTests()) # Add a new test result against this required test StockItemTestResult.objects.create( stock_item=item, test='apply paint', date=datetime.datetime(2022, 12, 12), result=True ) self.assertTrue(item.passedAllRequiredTests()) def test_duplicate_item_tests(self): # Create an example stock item by copying one from the database (because we are lazy) item = StockItem.objects.get(pk=522) item.pk = None item.serial = None item.quantity = 50 item.batch = "B344" item.save() # Do some tests! StockItemTestResult.objects.create( stock_item=item, test="Firmware", result=True ) StockItemTestResult.objects.create( stock_item=item, test="Paint Color", result=True, value="Red" ) StockItemTestResult.objects.create( stock_item=item, test="Applied Sticker", result=False ) self.assertEqual(item.test_results.count(), 3) self.assertEqual(item.quantity, 50) # Split some items out item2 = item.splitStock(20, None, None) self.assertEqual(item.quantity, 30) self.assertEqual(item.test_results.count(), 3) self.assertEqual(item2.test_results.count(), 3) StockItemTestResult.objects.create( stock_item=item2, test='A new test' ) self.assertEqual(item.test_results.count(), 3) self.assertEqual(item2.test_results.count(), 4) # Test StockItem serialization item2.serializeStock(1, [100], self.user) # Add a test result to the parent *after* serialization StockItemTestResult.objects.create( stock_item=item2, test='abcde' ) self.assertEqual(item2.test_results.count(), 5) item3 = StockItem.objects.get(serial=100, part=item2.part) self.assertEqual(item3.test_results.count(), 4) def test_installed_tests(self): """ Test test results for stock in stock. Or, test "test results" for "stock items" installed "inside" a "stock item" """ # Get a "master" stock item item = StockItem.objects.get(pk=105) tests = item.testResultMap(include_installed=False) self.assertEqual(len(tests), 3) # There are no "sub items" intalled at this stage tests = item.testResultMap(include_installed=False) self.assertEqual(len(tests), 3) # Create a stock item which is installed *inside* the master item sub_item = StockItem.objects.create( part=item.part, quantity=1, belongs_to=item, location=None ) # Now, create some test results against the sub item # First test is overshadowed by the same test for the parent part StockItemTestResult.objects.create( stock_item=sub_item, test='firmware version', date=datetime.datetime.now().date(), result=True ) # Should return the same number of tests as before tests = item.testResultMap(include_installed=True) self.assertEqual(len(tests), 3) # Now, add a *unique* test result for the sub item StockItemTestResult.objects.create( stock_item=sub_item, test='some new test', date=datetime.datetime.now().date(), result=False, value='abcde', ) tests = item.testResultMap(include_installed=True) self.assertEqual(len(tests), 4) self.assertIn('somenewtest', tests) self.assertEqual(sub_item.test_results.count(), 2) # Check that asking for test result map for *top item only* still works tests = item.testResultMap(include_installed=False) self.assertEqual(len(tests), 3) self.assertNotIn('somenewtest', tests)