diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index c1559d5bf8..dd7a782485 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -373,7 +373,9 @@ class AjaxCreateView(AjaxMixin, CreateView): # Extra JSON data sent alongside form data = { - 'form_valid': valid + 'form_valid': valid, + 'form_errors': self.form.errors.as_json(), + 'non_field_errors': self.form.non_field_errors().as_json(), } # Add in any extra class data @@ -453,7 +455,9 @@ class AjaxUpdateView(AjaxMixin, UpdateView): valid = form.is_valid() data = { - 'form_valid': valid + 'form_valid': valid, + 'form_errors': form.errors.as_json(), + 'non_field_errors': form.non_field_errors().as_json(), } # Add in any extra class data diff --git a/InvenTree/build/fixtures/build.yaml b/InvenTree/build/fixtures/build.yaml index 0ac5d00a20..cc645f9696 100644 --- a/InvenTree/build/fixtures/build.yaml +++ b/InvenTree/build/fixtures/build.yaml @@ -31,4 +31,52 @@ level: 0 lft: 0 rght: 0 - tree_id: 1 \ No newline at end of file + tree_id: 1 + +- model: build.build + pk: 3 + fields: + part: 50 + reference: "0003" + title: 'Making things' + batch: 'B2' + status: 40 # COMPLETE + quantity: 21 + notes: 'Some even more simple notes' + creation_date: '2019-03-16' + level: 0 + lft: 0 + rght: 0 + tree_id: 1 + +- model: build.build + pk: 4 + fields: + part: 50 + reference: "0004" + title: 'Making things' + batch: 'B4' + status: 40 # COMPLETE + quantity: 21 + notes: 'Some even even more simple notes' + creation_date: '2019-03-16' + level: 0 + lft: 0 + rght: 0 + tree_id: 1 + +- model: build.build + pk: 5 + fields: + part: 25 + reference: "0005" + title: "Building some Widgets" + batch: "B10" + status: 40 # Complete + quantity: 10 + creation_date: '2019-03-16' + notes: "A thing" + level: 0 + lft: 0 + rght: 0 + tree_id: 1 diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index f03686cb41..1e4239e974 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -213,7 +213,7 @@ class Build(MPTTModel): in_stock = (True / False) - If supplied, filter by 'in-stock' status """ - outputs = self.build_outputs + outputs = self.build_outputs.all() # Filter by 'in stock' status in_stock = kwargs.get('in_stock', None) @@ -836,6 +836,13 @@ class BuildItem(models.Model): ('build', 'stock_item', 'install_into'), ] + def save(self, *args, **kwargs): + + self.validate_unique() + self.clean() + + super().save() + def validate_unique(self, exclude=None): """ Test that this BuildItem object is "unique". @@ -872,6 +879,9 @@ class BuildItem(models.Model): errors = {} + if not self.install_into: + raise ValidationError(_('Build item must specify a build output')) + try: # Allocated part must be in the BOM for the master part if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False): diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 4e65a3a416..b560a4f9c9 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -10,8 +10,6 @@ from stock.models import StockItem from part.models import Part, BomItem from InvenTree import status_codes as status -from InvenTree.helpers import extract_serial_numbers - class BuildTest(TestCase): """ @@ -63,6 +61,21 @@ class BuildTest(TestCase): quantity=10 ) + # Create some build output (StockItem) objects + self.output_1 = StockItem.objects.create( + part=self.assembly, + quantity=5, + is_building=True, + build=self.build + ) + + self.output_2 = StockItem.objects.create( + part=self.assembly, + quantity=5, + is_building=True, + build=self.build, + ) + # Create some stock items to assign to the build self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=1000) self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100) @@ -72,21 +85,28 @@ class BuildTest(TestCase): def test_init(self): # Perform some basic tests before we start the ball rolling - self.assertEqual(StockItem.objects.count(), 3) + self.assertEqual(StockItem.objects.count(), 5) + + # Build is PENDING self.assertEqual(self.build.status, status.BuildStatus.PENDING) - self.assertFalse(self.build.isFullyAllocated()) - self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1)) - self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2)) + # Build has two build outputs + self.assertEqual(self.build.output_count, 2) - self.assertEqual(self.build.getRequiredQuantity(self.sub_part_1), 100) - self.assertEqual(self.build.getRequiredQuantity(self.sub_part_2), 250) + # None of the build outputs have been completed + for output in self.build.get_build_outputs().all(): + self.assertFalse(self.build.isFullyAllocated(output)) + + self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1)) + self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2)) + + self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 50) + self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 50) + self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 125) + self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 125) self.assertFalse(self.build.is_complete) - # Delete some stock and see if the build can still be completed - self.stock_2_1.delete() - def test_build_item_clean(self): # Ensure that dodgy BuildItem objects cannot be created @@ -96,7 +116,7 @@ class BuildTest(TestCase): b = BuildItem(stock_item=stock, build=self.build, quantity=10) with self.assertRaises(ValidationError): - b.clean() + b.save() # Create a BuildItem which has too much stock assigned b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999) @@ -110,6 +130,10 @@ class BuildTest(TestCase): with self.assertRaises(ValidationError): b.clean() + # Ok, what about we make one that does *not* fail? + b = BuildItem(stock_item=self.stock_1_1, build=self.build, install_into=self.output_1, quantity=10) + b.save() + def test_duplicate_bom_line(self): # Try to add a duplicate BOM item - it should fail! @@ -120,107 +144,145 @@ class BuildTest(TestCase): quantity=99 ) - def allocate_stock(self, q11, q12, q21): + def allocate_stock(self, q11, q12, q21, output): # Assign stock to this build - BuildItem.objects.create( - build=self.build, - stock_item=self.stock_1_1, - quantity=q11 - ) + if q11 > 0: + BuildItem.objects.create( + build=self.build, + stock_item=self.stock_1_1, + quantity=q11, + install_into=output + ) - BuildItem.objects.create( - build=self.build, - stock_item=self.stock_1_2, - quantity=q12 - ) + if q12 > 0: + BuildItem.objects.create( + build=self.build, + stock_item=self.stock_1_2, + quantity=q12, + install_into=output + ) - BuildItem.objects.create( - build=self.build, - stock_item=self.stock_2_1, - quantity=q21 - ) + if q21 > 0: + BuildItem.objects.create( + build=self.build, + stock_item=self.stock_2_1, + quantity=q21, + install_into=output, + ) - # Attempt to create another identical BuildItem - b = BuildItem( - build=self.build, - stock_item=self.stock_2_1, - quantity=q21 - ) + # Attempt to create another identical BuildItem + b = BuildItem( + build=self.build, + stock_item=self.stock_2_1, + quantity=q21 + ) - with self.assertRaises(ValidationError): - b.clean() - - self.assertEqual(BuildItem.objects.count(), 3) + with self.assertRaises(ValidationError): + b.clean() def test_partial_allocation(self): + """ + Partially allocate against output 1 + """ - self.allocate_stock(50, 50, 200) + self.allocate_stock(50, 50, 200, self.output_1) - self.assertFalse(self.build.isFullyAllocated()) - self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1)) - self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2)) + self.assertTrue(self.build.isFullyAllocated(self.output_1)) + self.assertFalse(self.build.isFullyAllocated(self.output_2)) + self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1)) + self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1)) + + self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_2)) + self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2)) - self.build.unallocateStock() + # Check that the part has been allocated + self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 100) + + self.build.unallocateStock(output=self.output_1) self.assertEqual(BuildItem.objects.count(), 0) - def test_auto_allocate(self): + # Check that the part has been unallocated + self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 0) - allocations = self.build.getAutoAllocations() + def test_auto_allocate(self): + """ + Test auto-allocation functionality against the build outputs + """ + + allocations = self.build.getAutoAllocations(self.output_1) self.assertEqual(len(allocations), 1) - self.build.auto_allocate() + self.build.autoAllocate(self.output_1) self.assertEqual(BuildItem.objects.count(), 1) - self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2)) + + # Check that one part has been fully allocated to the build output + self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1)) + + # But, the *other* build output has not been allocated against + self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2)) def test_cancel(self): + """ + Test cancellation of the build + """ - self.allocate_stock(50, 50, 200) + # TODO + + """ + self.allocate_stock(50, 50, 200, self.output_1) self.build.cancelBuild(None) self.assertEqual(BuildItem.objects.count(), 0) + """ + pass def test_complete(self): + """ + Test completion of a build output + """ - self.allocate_stock(50, 50, 250) + self.allocate_stock(50, 50, 250, self.output_1) + self.allocate_stock(50, 50, 250, self.output_2) - self.assertTrue(self.build.isFullyAllocated()) + self.assertTrue(self.build.isFullyAllocated(self.output_1)) + self.assertTrue(self.build.isFullyAllocated(self.output_2)) - # Generate some serial numbers! - serials = extract_serial_numbers("1-10", 10) + self.build.completeBuildOutput(self.output_1, None) - self.build.completeBuild(None, serials, None) + self.assertFalse(self.build.can_complete) + self.build.completeBuildOutput(self.output_2, None) + + self.assertTrue(self.build.can_complete) + + self.build.complete_build(None) + self.assertEqual(self.build.status, status.BuildStatus.COMPLETE) # the original BuildItem objects should have been deleted! self.assertEqual(BuildItem.objects.count(), 0) # New stock items should have been created! - # - Ten for the build output (as the part was serialized) - # - Three for the split items assigned to the build - self.assertEqual(StockItem.objects.count(), 16) + self.assertEqual(StockItem.objects.count(), 4) A = StockItem.objects.get(pk=self.stock_1_1.pk) - B = StockItem.objects.get(pk=self.stock_1_2.pk) + + # This stock item has been depleted! + with self.assertRaises(StockItem.DoesNotExist): + StockItem.objects.get(pk=self.stock_1_2.pk) + C = StockItem.objects.get(pk=self.stock_2_1.pk) # Stock should have been subtracted from the original items - self.assertEqual(A.quantity, 950) - self.assertEqual(B.quantity, 50) - self.assertEqual(C.quantity, 4750) - - # New stock items should have also been allocated to the build - allocated = StockItem.objects.filter(build_order=self.build) - - self.assertEqual(allocated.count(), 3) - - q = sum([item.quantity for item in allocated.all()]) - - self.assertEqual(q, 350) + self.assertEqual(A.quantity, 900) + self.assertEqual(C.quantity, 4500) # And 10 new stock items created for the build output outputs = StockItem.objects.filter(build=self.build) - self.assertEqual(outputs.count(), 10) + self.assertEqual(outputs.count(), 2) + + for output in outputs: + self.assertFalse(output.is_building) diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index ded98a441c..0c2e270c6c 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -12,6 +12,7 @@ from rest_framework import status import json from .models import Build +from stock.models import StockItem from InvenTree.status_codes import BuildStatus @@ -49,7 +50,7 @@ class BuildTestSimple(TestCase): def test_build_objects(self): # Ensure the Build objects were correctly created - self.assertEqual(Build.objects.count(), 2) + self.assertEqual(Build.objects.count(), 5) b = Build.objects.get(pk=2) self.assertEqual(b.batch, 'B2') self.assertEqual(b.quantity, 21) @@ -127,11 +128,37 @@ class TestBuildAPI(APITestCase): self.client.login(username='testuser', password='password') def test_get_build_list(self): - """ Test that we can retrieve list of build objects """ + """ + Test that we can retrieve list of build objects + """ + url = reverse('api-build-list') response = self.client.get(url, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 5) + + # Filter query by build status + response = self.client.get(url, {'status': 40}, format='json') + + self.assertEqual(len(response.data), 4) + + # Filter by "active" status + response = self.client.get(url, {'active': True}, format='json') + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['pk'], 1) + + response = self.client.get(url, {'active': False}, format='json') + self.assertEqual(len(response.data), 4) + + # Filter by 'part' status + response = self.client.get(url, {'part': 25}, format='json') + self.assertEqual(len(response.data), 2) + + # Filter by an invalid part + response = self.client.get(url, {'part': 99999}, format='json') + self.assertEqual(len(response.data), 0) + def test_get_build_item_list(self): """ Test that we can retrieve list of BuildItem objects """ url = reverse('api-build-item-list') @@ -176,6 +203,16 @@ class TestBuildViews(TestCase): self.client.login(username='username', password='password') + # Create a build output for build # 1 + self.build = Build.objects.get(pk=1) + + self.output = StockItem.objects.create( + part=self.build.part, + quantity=self.build.quantity, + build=self.build, + is_building=True, + ) + def test_build_index(self): """ test build index view """ @@ -254,10 +291,15 @@ class TestBuildViews(TestCase): # url = reverse('build-item-edit') pass - def test_build_complete(self): - """ Test the build completion form """ + def test_build_output_complete(self): + """ + Test the build output completion form + """ - url = reverse('build-complete', args=(1,)) + # Firstly, check that the build cannot be completed! + self.assertFalse(self.build.can_complete) + + url = reverse('build-output-complete', args=(self.output.pk,)) # Test without confirmation response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') @@ -267,12 +309,26 @@ class TestBuildViews(TestCase): self.assertFalse(data['form_valid']) # Test with confirmation, valid location - response = self.client.post(url, {'confirm': 1, 'location': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + response = self.client.post( + url, + { + 'confirm': 1, + 'confirm_incomplete': 1, + 'location': 1, + 'output': self.output.pk, + }, + HTTP_X_REQUESTED_WITH='XMLHttpRequest' + ) + self.assertEqual(response.status_code, 200) - + data = json.loads(response.content) self.assertTrue(data['form_valid']) + # Now the build should be able to be completed + self.build.refresh_from_db() + self.assertTrue(self.build.can_complete) + # Test with confirmation, invalid location response = self.client.post(url, {'confirm': 1, 'location': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertEqual(response.status_code, 200) diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 30a2b1d694..f40a5a3480 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -67,13 +67,11 @@ class BuildCancel(AjaxUpdateView): if not confirm: form.add_error('confirm_cancel', _('Confirm build cancellation')) - def save(self, form, **kwargs): + def save(self, build, form, **kwargs): """ Cancel the build. """ - build = self.get_object() - build.cancelBuild(self.request.user) def get_data(self): diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index c77fd5dc57..9883edfcd3 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -67,6 +67,7 @@ name: 'Widget' description: 'A watchamacallit' category: 7 + assembly: true trackable: true tree_id: 0 level: 0 diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index b18daacd5e..2ec42dd2f3 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1060,7 +1060,7 @@ class StockItem(MPTTModel): if self.updateQuantity(count): - self.addTransactionNote('Stocktake - counted {n} items'.format(n=count), + self.addTransactionNote('Stocktake - counted {n} items'.format(n=helpers.normalize(count)), user, notes=notes, system=True) @@ -1089,7 +1089,7 @@ class StockItem(MPTTModel): if self.updateQuantity(self.quantity + quantity): - self.addTransactionNote('Added {n} items to stock'.format(n=quantity), + self.addTransactionNote('Added {n} items to stock'.format(n=helpers.normalize(quantity)), user, notes=notes, system=True) @@ -1115,7 +1115,7 @@ class StockItem(MPTTModel): if self.updateQuantity(self.quantity - quantity): - self.addTransactionNote('Removed {n} items from stock'.format(n=quantity), + self.addTransactionNote('Removed {n} items from stock'.format(n=helpers.normalize(quantity)), user, notes=notes, system=True) diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index d3c713d07d..7c0768428b 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -71,6 +71,7 @@ class RuleSet(models.Model): 'part_bomitem', 'build_build', 'build_builditem', + 'build_buildorderattachment', 'stock_stockitem', 'stock_stocklocation', ],