diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index cfc242b3a13a..72f87e87fcf5 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -915,6 +915,11 @@ def complete_build_output(self, output, user, **kwargs): # List the allocated BuildItem objects for the given output allocated_items = output.items_to_install.all() + if (common.settings.prevent_build_output_complete_on_incompleted_tests() and output.hasRequiredTests() and not output.passedAllRequiredTests()): + serial = output.serial + raise ValidationError( + _(f"Build output {serial} has not passed all required tests")) + for build_item in allocated_items: # Complete the allocation of stock for that item build_item.complete_allocation(user) diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index e0a342b59d29..e02e5b5e3250 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -27,6 +27,7 @@ from stock.models import generate_batch_code, StockItem, StockLocation from stock.serializers import StockItemSerializerBrief, LocationSerializer +import common.models from common.serializers import ProjectCodeSerializer import part.filters from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer @@ -523,6 +524,17 @@ def validate(self, data): outputs = data.get('outputs', []) + if common.settings.prevent_build_output_complete_on_incompleted_tests(): + errors = [] + for output in outputs: + stock_item = output['output'] + if stock_item.hasRequiredTests() and not stock_item.passedAllRequiredTests(): + serial = stock_item.serial + errors.append(_(f"Build output {serial} has not passed all required tests")) + + if errors: + raise ValidationError(errors) + if len(outputs) == 0: raise ValidationError(_("A list of build outputs must be provided")) diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 0961e0329d8b..6af1c0a06573 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -1,5 +1,5 @@ """Unit tests for the 'build' models""" - +import uuid from datetime import datetime, timedelta from django.test import TestCase @@ -14,8 +14,8 @@ import common.models import build.tasks from build.models import Build, BuildItem, BuildLine, generate_next_build_reference -from part.models import Part, BomItem, BomItemSubstitute -from stock.models import StockItem +from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate +from stock.models import StockItem, StockItemTestResult from users.models import Owner import logging @@ -55,6 +55,76 @@ def setUpTestData(cls): trackable=True, ) + # create one build with one required test template + cls.tested_part_with_required_test = Part.objects.create( + name="Part having required tests", + description="Why does it matter what my description is?", + assembly=True, + trackable=True, + ) + + cls.test_template_required = PartTestTemplate.objects.create( + part=cls.tested_part_with_required_test, + test_name="Required test", + description="Required test template description", + required=True, + requires_value=False, + requires_attachment=False + ) + + ref = generate_next_build_reference() + + cls.build_w_tests_trackable = Build.objects.create( + reference=ref, + title="This is a build", + part=cls.tested_part_with_required_test, + quantity=1, + issued_by=get_user_model().objects.get(pk=1), + ) + + cls.stockitem_with_required_test = StockItem.objects.create( + part=cls.tested_part_with_required_test, + quantity=1, + is_building=True, + serial=uuid.uuid4(), + build=cls.build_w_tests_trackable + ) + + # now create a part with a non-required test template + cls.tested_part_wo_required_test = Part.objects.create( + name="Part with one non.required test", + description="Why does it matter what my description is?", + assembly=True, + trackable=True, + ) + + cls.test_template_non_required = PartTestTemplate.objects.create( + part=cls.tested_part_wo_required_test, + test_name="Required test template", + description="Required test template description", + required=False, + requires_value=False, + requires_attachment=False + ) + + ref = generate_next_build_reference() + + cls.build_wo_tests_trackable = Build.objects.create( + reference=ref, + title="This is a build", + part=cls.tested_part_wo_required_test, + quantity=1, + issued_by=get_user_model().objects.get(pk=1), + ) + + cls.stockitem_wo_required_test = StockItem.objects.create( + part=cls.tested_part_wo_required_test, + quantity=1, + is_building=True, + serial=uuid.uuid4(), + build=cls.build_wo_tests_trackable + ) + cls.sub_part_1 = Part.objects.create( name="Widget A", description="A widget", @@ -245,7 +315,7 @@ def test_next_ref(self): def test_init(self): """Perform some basic tests before we start the ball rolling""" - self.assertEqual(StockItem.objects.count(), 10) + self.assertEqual(StockItem.objects.count(), 12) # Build is PENDING self.assertEqual(self.build.status, status.BuildStatus.PENDING) @@ -558,7 +628,7 @@ def test_complete(self): self.assertEqual(BuildItem.objects.count(), 0) # New stock items should have been created! - self.assertEqual(StockItem.objects.count(), 13) + self.assertEqual(StockItem.objects.count(), 15) # This stock item has been marked as "consumed" item = StockItem.objects.get(pk=self.stock_1_1.pk) @@ -573,6 +643,26 @@ def test_complete(self): for output in outputs: self.assertFalse(output.is_building) + def test_complete_with_required_tests(self): + """Test the prevention completion when a required test is missing feature""" + + # with required tests incompleted the save should fail + common.models.InvenTreeSetting.set_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None) + + with self.assertRaises(ValidationError): + self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None) + + # let's complete the required test and see if it could be saved + StockItemTestResult.objects.create( + stock_item=self.stockitem_with_required_test, + test=self.test_template_required.test_name, + result=True + ) + self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None) + + # let's see if a non required test could be saved + self.build_wo_tests_trackable.complete_build_output(self.stockitem_wo_required_test, None) + def test_overdue_notification(self): """Test sending of notifications when a build order is overdue.""" self.build.target_date = datetime.now().date() - timedelta(days=1) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 51092d4306e1..82463e216a48 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1981,6 +1981,14 @@ def save(self, *args, **kwargs): 'default': False, 'validator': bool, }, + 'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': { + 'name': _('Block Until Tests Pass'), + 'description': _( + 'Prevent build outputs from being completed until all required tests pass' + ), + 'default': False, + 'validator': bool, + }, } typ = 'inventree' diff --git a/InvenTree/common/settings.py b/InvenTree/common/settings.py index 68dc4f7c99f9..03601ad1f897 100644 --- a/InvenTree/common/settings.py +++ b/InvenTree/common/settings.py @@ -56,3 +56,12 @@ def stock_expiry_enabled(): from common.models import InvenTreeSetting return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY', False, create=False) + + +def prevent_build_output_complete_on_incompleted_tests(): + """Returns True if the completion of the build outputs is disabled until the required tests are passed.""" + from common.models import InvenTreeSetting + + return InvenTreeSetting.get_setting( + 'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', False, create=False + ) diff --git a/InvenTree/templates/InvenTree/settings/build.html b/InvenTree/templates/InvenTree/settings/build.html index cee6d1c349cd..764e3336217c 100644 --- a/InvenTree/templates/InvenTree/settings/build.html +++ b/InvenTree/templates/InvenTree/settings/build.html @@ -13,6 +13,7 @@