diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 27d66756ff..92048866a0 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -1,12 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 168 +INVENTREE_API_VERSION = 169 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ -v168 -> 2024-02-07 : https://github.com/inventree/InvenTree/pull/4824 +v169 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/6430 + - Adds 'key' field to PartTestTemplate API endpoint + - Adds annotated 'results' field to PartTestTemplate API endpoint + - Adds 'template' field to StockItemTestResult API endpoint + +v168 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/4824 - Adds machine CRUD API endpoints - Adds machine settings API endpoints - Adds machine restart API endpoint diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 1d2884c829..d36f93704e 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -76,16 +76,23 @@ def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False): return ref_int -def generateTestKey(test_name): +def generateTestKey(test_name: str) -> str: """Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template. Tests must be named such that they will have unique keys. """ + if test_name is None: + test_name = '' + key = test_name.strip().lower() key = key.replace(' ', '') # Remove any characters that cannot be used to represent a variable - key = re.sub(r'[^a-zA-Z0-9]', '', key) + key = re.sub(r'[^a-zA-Z0-9_]', '', key) + + # If the key starts with a digit, prefix with an underscore + if key[0].isdigit(): + key = '_' + key return key diff --git a/InvenTree/InvenTree/management/commands/check_migrations.py b/InvenTree/InvenTree/management/commands/check_migrations.py new file mode 100644 index 0000000000..b06971724c --- /dev/null +++ b/InvenTree/InvenTree/management/commands/check_migrations.py @@ -0,0 +1,19 @@ +"""Check if there are any pending database migrations, and run them.""" + +import logging + +from django.core.management.base import BaseCommand + +from InvenTree.tasks import check_for_migrations + +logger = logging.getLogger('inventree') + + +class Command(BaseCommand): + """Check if there are any pending database migrations, and run them.""" + + def handle(self, *args, **kwargs): + """Check for any pending database migrations.""" + logger.info('Checking for pending database migrations') + check_for_migrations(force=True, reload_registry=False) + logger.info('Database migrations complete') diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 7ef19f0133..f623bdf4c3 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -509,6 +509,20 @@ class TestHelpers(TestCase): self.assertNotIn(PartCategory, models) self.assertNotIn(InvenTreeSetting, models) + def test_test_key(self): + """Test for the generateTestKey function.""" + tests = { + ' Hello World ': 'helloworld', + ' MY NEW TEST KEY ': 'mynewtestkey', + ' 1234 5678': '_12345678', + ' 100 percenT': '_100percent', + ' MY_NEW_TEST': 'my_new_test', + ' 100_new_tests': '_100_new_tests', + } + + for name, key in tests.items(): + self.assertEqual(helpers.generateTestKey(name), key) + class TestQuoteWrap(TestCase): """Tests for string wrapping.""" diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 6af1c0a065..fd4fe9975f 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -655,9 +655,10 @@ class BuildTest(BuildTestBase): # 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, + template=self.test_template_required, 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 diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index a692ff9ac8..52726423ed 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -363,6 +363,7 @@ class PartTestTemplateAdmin(admin.ModelAdmin): """Admin class for the PartTestTemplate model.""" list_display = ('part', 'test_name', 'required') + readonly_fields = ['key'] autocomplete_fields = ('part',) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index b2b57fbaa8..3b019296bc 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -389,29 +389,53 @@ class PartTestTemplateFilter(rest_filters.FilterSet): Note that for the 'part' field, we also include any parts "above" the specified part. """ - variants = part.get_ancestors(include_self=True) - return queryset.filter(part__in=variants) + include_inherited = str2bool( + self.request.query_params.get('include_inherited', True) + ) + + if include_inherited: + return queryset.filter(part__in=part.get_ancestors(include_self=True)) + else: + return queryset.filter(part=part) -class PartTestTemplateDetail(RetrieveUpdateDestroyAPI): +class PartTestTemplateMixin: + """Mixin class for the PartTestTemplate API endpoints.""" + + queryset = PartTestTemplate.objects.all() + serializer_class = part_serializers.PartTestTemplateSerializer + + def get_queryset(self, *args, **kwargs): + """Return an annotated queryset for the PartTestTemplateDetail endpoints.""" + queryset = super().get_queryset(*args, **kwargs) + queryset = part_serializers.PartTestTemplateSerializer.annotate_queryset( + queryset + ) + return queryset + + +class PartTestTemplateDetail(PartTestTemplateMixin, RetrieveUpdateDestroyAPI): """Detail endpoint for PartTestTemplate model.""" - queryset = PartTestTemplate.objects.all() - serializer_class = part_serializers.PartTestTemplateSerializer + pass -class PartTestTemplateList(ListCreateAPI): +class PartTestTemplateList(PartTestTemplateMixin, ListCreateAPI): """API endpoint for listing (and creating) a PartTestTemplate.""" - queryset = PartTestTemplate.objects.all() - serializer_class = part_serializers.PartTestTemplateSerializer filterset_class = PartTestTemplateFilter filter_backends = SEARCH_ORDER_FILTER search_fields = ['test_name', 'description'] - ordering_fields = ['test_name', 'required', 'requires_value', 'requires_attachment'] + ordering_fields = [ + 'test_name', + 'required', + 'requires_value', + 'requires_attachment', + 'results', + ] ordering = 'test_name' diff --git a/InvenTree/part/fixtures/test_templates.yaml b/InvenTree/part/fixtures/test_templates.yaml index aaf11b7974..5427ec4314 100644 --- a/InvenTree/part/fixtures/test_templates.yaml +++ b/InvenTree/part/fixtures/test_templates.yaml @@ -4,30 +4,35 @@ fields: part: 10000 test_name: Test strength of chair + key: 'teststrengthofchair' - model: part.parttesttemplate pk: 2 fields: part: 10000 test_name: Apply paint + key: 'applypaint' - model: part.parttesttemplate pk: 3 fields: part: 10000 test_name: Sew cushion + key: 'sewcushion' - model: part.parttesttemplate pk: 4 fields: part: 10000 test_name: Attach legs + key: 'attachlegs' - model: part.parttesttemplate pk: 5 fields: part: 10000 test_name: Record weight + key: 'recordweight' required: false # Add some tests for one of the variants @@ -35,12 +40,30 @@ pk: 6 fields: part: 10003 - test_name: Check that chair is green + test_name: Check chair is green + key: 'checkchairisgreen' required: true - model: part.parttesttemplate - pk: 7 + pk: 8 fields: - part: 10004 - test_name: Check that chair is especially green + part: 25 + test_name: 'Temperature Test' + key: 'temperaturetest' + required: False + +- model: part.parttesttemplate + pk: 9 + fields: + part: 25 + test_name: 'Settings Checksum' + key: 'settingschecksum' + required: False + +- model: part.parttesttemplate + pk: 10 + fields: + part: 25 + test_name: 'Firmware Version' + key: 'firmwareversion' required: False diff --git a/InvenTree/part/migrations/0120_parttesttemplate_key.py b/InvenTree/part/migrations/0120_parttesttemplate_key.py new file mode 100644 index 0000000000..82954ac785 --- /dev/null +++ b/InvenTree/part/migrations/0120_parttesttemplate_key.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.9 on 2024-02-07 03:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0119_auto_20231120_0457'), + ] + + operations = [ + migrations.AddField( + model_name='parttesttemplate', + name='key', + field=models.CharField(blank=True, help_text='Simplified key for the test', max_length=100, verbose_name='Test Key'), + ), + ] diff --git a/InvenTree/part/migrations/0121_auto_20240207_0344.py b/InvenTree/part/migrations/0121_auto_20240207_0344.py new file mode 100644 index 0000000000..6e028a0645 --- /dev/null +++ b/InvenTree/part/migrations/0121_auto_20240207_0344.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.9 on 2024-02-07 03:44 + +from django.db import migrations + + +def set_key(apps, schema_editor): + """Create a 'key' value for existing PartTestTemplate objects.""" + + import InvenTree.helpers + + PartTestTemplate = apps.get_model('part', 'PartTestTemplate') + + for template in PartTestTemplate.objects.all(): + template.key = InvenTree.helpers.generateTestKey(str(template.test_name).strip()) + template.save() + + if PartTestTemplate.objects.count() > 0: + print(f"\nUpdated 'key' value for {PartTestTemplate.objects.count()} PartTestTemplate objects") + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0120_parttesttemplate_key'), + ] + + operations = [ + migrations.RunPython(set_key, reverse_code=migrations.RunPython.noop) + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index a36f17fd55..eaa2613863 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -3393,6 +3393,10 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel): run on the model (refer to the validate_unique function). """ + def __str__(self): + """Format a string representation of this PartTestTemplate.""" + return ' | '.join([self.part.name, self.test_name]) + @staticmethod def get_api_url(): """Return the list API endpoint URL associated with the PartTestTemplate model.""" @@ -3408,6 +3412,8 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel): """Clean fields for the PartTestTemplate model.""" self.test_name = self.test_name.strip() + self.key = helpers.generateTestKey(self.test_name) + self.validate_unique() super().clean() @@ -3418,30 +3424,18 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel): 'part': _('Test templates can only be created for trackable parts') }) - # Get a list of all tests "above" this one + # Check that this test is unique within the part tree tests = PartTestTemplate.objects.filter( - part__in=self.part.get_ancestors(include_self=True) - ) + key=self.key, part__tree_id=self.part.tree_id + ).exclude(pk=self.pk) - # If this item is already in the database, exclude it from comparison! - if self.pk is not None: - tests = tests.exclude(pk=self.pk) - - key = self.key - - for test in tests: - if test.key == key: - raise ValidationError({ - 'test_name': _('Test with this name already exists for this part') - }) + if tests.exists(): + raise ValidationError({ + 'test_name': _('Test with this name already exists for this part') + }) super().validate_unique(exclude) - @property - def key(self): - """Generate a key for this test.""" - return helpers.generateTestKey(self.test_name) - part = models.ForeignKey( Part, on_delete=models.CASCADE, @@ -3457,6 +3451,13 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel): help_text=_('Enter a name for the test'), ) + key = models.CharField( + blank=True, + max_length=100, + verbose_name=_('Test Key'), + help_text=_('Simplified key for the test'), + ) + description = models.CharField( blank=False, null=True, diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 3c9eb554c0..b636bca568 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -156,9 +156,20 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer) 'required', 'requires_value', 'requires_attachment', + 'results', ] key = serializers.CharField(read_only=True) + results = serializers.IntegerField( + label=_('Results'), + help_text=_('Number of results recorded against this template'), + read_only=True, + ) + + @staticmethod + def annotate_queryset(queryset): + """Custom query annotations for the PartTestTemplate serializer.""" + return queryset.annotate(results=SubqueryCount('test_results')) class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer): diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 878b27519f..5cc16e74c9 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -806,14 +806,14 @@ class PartAPITest(PartAPITestBase): response = self.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 7) + self.assertEqual(len(response.data), 9) # Request for a particular part response = self.get(url, data={'part': 10000}) self.assertEqual(len(response.data), 5) response = self.get(url, data={'part': 10004}) - self.assertEqual(len(response.data), 7) + self.assertEqual(len(response.data), 6) # Try to post a new object (missing description) response = self.post( diff --git a/InvenTree/part/test_migrations.py b/InvenTree/part/test_migrations.py index 313e262773..1a15e57ebf 100644 --- a/InvenTree/part/test_migrations.py +++ b/InvenTree/part/test_migrations.py @@ -229,3 +229,47 @@ class TestPartParameterTemplateMigration(MigratorTestCase): self.assertEqual(template.choices, '') self.assertEqual(template.checkbox, False) + + +class TestPartTestParameterMigration(MigratorTestCase): + """Unit tests for the PartTestTemplate model migrations.""" + + migrate_from = ('part', '0119_auto_20231120_0457') + migrate_to = ('part', '0121_auto_20240207_0344') + + test_keys = { + 'atest': 'A test', + 'someresult': 'Some result', + 'anotherresult': 'Another result', + } + + def prepare(self): + """Setup initial database state.""" + Part = self.old_state.apps.get_model('part', 'part') + PartTestTemplate = self.old_state.apps.get_model('part', 'parttesttemplate') + + # Create a part + p = Part.objects.create( + name='Test Part', + description='A test part', + level=0, + lft=0, + rght=0, + tree_id=0, + ) + + # Create some test templates + for v in self.test_keys.values(): + PartTestTemplate.objects.create( + test_name=v, part=p, description='A test template' + ) + + self.assertEqual(PartTestTemplate.objects.count(), 3) + + def test_key_field(self): + """Self that the key field is created and correctly filled.""" + PartTestTemplate = self.new_state.apps.get_model('part', 'parttesttemplate') + + for key, value in self.test_keys.items(): + template = PartTestTemplate.objects.get(test_name=value) + self.assertEqual(template.key, key) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index c1865b8366..2247d034ec 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -378,8 +378,8 @@ class TestTemplateTest(TestCase): # Test the lowest-level part which has more associated tests variant = Part.objects.get(pk=10004) - self.assertEqual(variant.getTestTemplates().count(), 7) - self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 1) + self.assertEqual(variant.getTestTemplates().count(), 6) + self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 0) self.assertEqual(variant.getTestTemplates(required=True).count(), 5) def test_uniqueness(self): @@ -389,21 +389,29 @@ class TestTemplateTest(TestCase): with self.assertRaises(ValidationError): PartTestTemplate.objects.create(part=variant, test_name='Record weight') + # Test that error is raised if we try to create a duplicate test name with self.assertRaises(ValidationError): PartTestTemplate.objects.create( - part=variant, test_name='Check that chair is especially green' + part=variant, test_name='Check chair is green' ) # Also should fail if we attempt to create a test that would generate the same key with self.assertRaises(ValidationError): - PartTestTemplate.objects.create( + template = PartTestTemplate.objects.create( part=variant, test_name='ReCoRD weiGHT ' ) + template.clean() + # But we should be able to create a new one! n = variant.getTestTemplates().count() - PartTestTemplate.objects.create(part=variant, test_name='A Sample Test') + template = PartTestTemplate.objects.create( + part=variant, test_name='A Sample Test' + ) + + # Test key should have been saved + self.assertEqual(template.key, 'asampletest') self.assertEqual(variant.getTestTemplates().count(), n + 1) diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index 8f43aca98d..ab9b68f83c 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -194,6 +194,7 @@ class ReportTest(InvenTreeAPITestCase): 'part', 'company', 'location', + 'test_templates', 'supplier_part', 'stock', 'stock_tests', diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index 32d62d33e3..f97c281f52 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -317,6 +317,6 @@ class StockTrackingAdmin(ImportExportModelAdmin): class StockItemTestResultAdmin(admin.ModelAdmin): """Admin class for StockItemTestResult.""" - list_display = ('stock_item', 'test', 'result', 'value') + list_display = ('stock_item', 'test_name', 'result', 'value') autocomplete_fields = ['stock_item'] diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index c601f6478a..c5892acacd 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -38,6 +38,7 @@ from InvenTree.filters import ( from InvenTree.helpers import ( DownloadFile, extract_serial_numbers, + generateTestKey, is_ajax, isNull, str2bool, @@ -1188,22 +1189,87 @@ class StockAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): serializer_class = StockSerializers.StockItemAttachmentSerializer -class StockItemTestResultDetail(RetrieveUpdateDestroyAPI): +class StockItemTestResultMixin: + """Mixin class for the StockItemTestResult API endpoints.""" + + queryset = StockItemTestResult.objects.all() + serializer_class = StockSerializers.StockItemTestResultSerializer + + def get_serializer_context(self): + """Extend serializer context.""" + ctx = super().get_serializer_context() + ctx['request'] = self.request + return ctx + + def get_serializer(self, *args, **kwargs): + """Set context before returning serializer.""" + try: + kwargs['user_detail'] = str2bool( + self.request.query_params.get('user_detail', False) + ) + kwargs['template_detail'] = str2bool( + self.request.query_params.get('template_detail', False) + ) + except Exception: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + +class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI): """Detail endpoint for StockItemTestResult.""" - queryset = StockItemTestResult.objects.all() - serializer_class = StockSerializers.StockItemTestResultSerializer + pass -class StockItemTestResultList(ListCreateDestroyAPIView): +class StockItemTestResultFilter(rest_filters.FilterSet): + """API filter for the StockItemTestResult list.""" + + class Meta: + """Metaclass options.""" + + model = StockItemTestResult + + # Simple filter fields + fields = ['user', 'template', 'result', 'value'] + + build = rest_filters.ModelChoiceFilter( + label='Build', queryset=Build.objects.all(), field_name='stock_item__build' + ) + + part = rest_filters.ModelChoiceFilter( + label='Part', queryset=Part.objects.all(), field_name='stock_item__part' + ) + + required = rest_filters.BooleanFilter( + label='Required', field_name='template__required' + ) + + test = rest_filters.CharFilter( + label='Test name (case insensitive)', method='filter_test_name' + ) + + def filter_test_name(self, queryset, name, value): + """Filter by test name. + + This method is provided for legacy support, + where the StockItemTestResult model had a "test" field. + Now the "test" name is stored against the PartTestTemplate model + """ + key = generateTestKey(value) + return queryset.filter(template__key=key) + + +class StockItemTestResultList(StockItemTestResultMixin, ListCreateDestroyAPIView): """API endpoint for listing (and creating) a StockItemTestResult object.""" - queryset = StockItemTestResult.objects.all() - serializer_class = StockSerializers.StockItemTestResultSerializer - + filterset_class = StockItemTestResultFilter filter_backends = SEARCH_ORDER_FILTER - filterset_fields = ['test', 'user', 'result', 'value'] + filterset_fields = ['user', 'template', 'result', 'value'] + ordering_fields = ['date', 'result'] ordering = 'date' @@ -1213,18 +1279,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView): queryset = super().filter_queryset(queryset) - # Filter by 'build' - build = params.get('build', None) - - if build is not None: - try: - build = Build.objects.get(pk=build) - - queryset = queryset.filter(stock_item__build=build) - - except (ValueError, Build.DoesNotExist): - pass - # Filter by stock item item = params.get('stock_item', None) @@ -1251,19 +1305,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView): return queryset - def get_serializer(self, *args, **kwargs): - """Set context before returning serializer.""" - try: - kwargs['user_detail'] = str2bool( - self.request.query_params.get('user_detail', False) - ) - except Exception: - pass - - kwargs['context'] = self.get_serializer_context() - - return self.serializer_class(*args, **kwargs) - def perform_create(self, serializer): """Create a new test result object. diff --git a/InvenTree/stock/fixtures/stock_tests.yaml b/InvenTree/stock/fixtures/stock_tests.yaml index 4b413b1289..e3e8da21e0 100644 --- a/InvenTree/stock/fixtures/stock_tests.yaml +++ b/InvenTree/stock/fixtures/stock_tests.yaml @@ -2,7 +2,7 @@ pk: 1 fields: stock_item: 105 - test: "Firmware Version" + template: 10 value: "0xA1B2C3D4" result: True date: 2020-02-02 @@ -11,7 +11,7 @@ pk: 2 fields: stock_item: 105 - test: "Settings Checksum" + template: 9 value: "0xAABBCCDD" result: True date: 2020-02-02 @@ -20,7 +20,7 @@ pk: 3 fields: stock_item: 105 - test: "Temperature Test" + template: 8 result: False date: 2020-05-16 notes: 'Got too hot or something' @@ -29,7 +29,7 @@ pk: 4 fields: stock_item: 105 - test: "Temperature Test" + template: 8 result: True date: 2020-05-17 notes: 'Passed temperature test by making it cooler' @@ -38,7 +38,7 @@ pk: 5 fields: stock_item: 522 - test: 'applypaint' + template: 2 result: True date: 2020-05-17 @@ -46,7 +46,7 @@ pk: 6 fields: stock_item: 522 - test: 'applypaint' + template: 2 result: False date: 2020-05-18 @@ -54,7 +54,7 @@ pk: 7 fields: stock_item: 522 - test: 'Attach Legs' + template: 4 result: True date: 2020-05-17 @@ -62,15 +62,6 @@ pk: 8 fields: stock_item: 522 - test: 'Check that chair is GreEn' + template: 3 result: True - date: 2020-05-17 - -- model: stock.stockitemtestresult - pk: 12345 - fields: - stock_item: 522 - test: 'test strength of chair' - result: False - value: 100kg - date: 2020-05-17 + date: 2024-02-15 diff --git a/InvenTree/stock/migrations/0105_stockitemtestresult_template.py b/InvenTree/stock/migrations/0105_stockitemtestresult_template.py new file mode 100644 index 0000000000..79a2c11155 --- /dev/null +++ b/InvenTree/stock/migrations/0105_stockitemtestresult_template.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.9 on 2024-02-07 03:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0121_auto_20240207_0344'), + ('stock', '0104_alter_stockitem_purchase_price_currency'), + ] + + operations = [ + migrations.AddField( + model_name='stockitemtestresult', + name='template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='test_results', to='part.parttesttemplate'), + ), + ] diff --git a/InvenTree/stock/migrations/0106_auto_20240207_0353.py b/InvenTree/stock/migrations/0106_auto_20240207_0353.py new file mode 100644 index 0000000000..2337963424 --- /dev/null +++ b/InvenTree/stock/migrations/0106_auto_20240207_0353.py @@ -0,0 +1,129 @@ +# Generated by Django 4.2.9 on 2024-02-07 03:53 + +from django.db import migrations + + +def set_template(apps, schema_editor): + """Matching existing StockItemTestResult objects to their associated template. + + - Use the 'key' value from the associated test object. + - Look at the referenced part first + - If no matches, look at parent part template(s) + - If still no matches, create a new PartTestTemplate object + """ + import time + import InvenTree.helpers + + StockItemTestResult = apps.get_model('stock', 'stockitemtestresult') + PartTestTemplate = apps.get_model('part', 'parttesttemplate') + Part = apps.get_model('part', 'part') + + # Look at any test results which do not match a template + results = StockItemTestResult.objects.filter(template=None) + + parts = results.values_list('stock_item__part', flat=True).distinct() + + n_results = results.count() + + if n_results == 0: + return + + print(f"\n{n_results} StockItemTestResult objects do not have matching templates!") + print(f"Updating test results for {len(parts)} unique parts...") + + # Keep a map of test templates + part_tree_map = {} + + t1 = time.time() + + new_templates = 0 + + # For each part with missing templates, work out what templates are missing + for pk in parts: + part = Part.objects.get(pk=pk) + tree_id = part.tree_id + # Find all results matching this part + part_results = results.filter(stock_item__part=part) + test_names = part_results.values_list('test', flat=True).distinct() + + key_map = part_tree_map.get(tree_id, None) or {} + + for name in test_names: + template = None + + key = InvenTree.helpers.generateTestKey(name) + + if template := key_map.get(key, None): + # We have a template for this key + pass + + elif template := PartTestTemplate.objects.filter(part__tree_id=part.tree_id, key=key).first(): + # We have found an existing template for this test + pass + + elif template := PartTestTemplate.objects.filter(part__tree_id=part.tree_id, test_name__iexact=name).first(): + # We have found an existing template for this test + pass + + # Create a new template, based on the available test information + else: + + # Find the parent part template + top_level_part = part + + while top_level_part.variant_of: + top_level_part = top_level_part.variant_of + + template = PartTestTemplate.objects.create( + part=top_level_part, + test_name=name, + key=key, + ) + + new_templates += 1 + + # Finally, update all matching results + part_results.filter(test=name).update(template=template) + + # Update the key map for this part tree + key_map[key] = template + + # Update the part tree map + part_tree_map[tree_id] = key_map + + t2 = time.time() + dt = t2 - t1 + + print(f"Updated {n_results} StockItemTestResult objects in {dt:.3f} seconds.") + + if new_templates > 0: + print(f"Created {new_templates} new templates!") + + # Check that there are now zero reamining results without templates + results = StockItemTestResult.objects.filter(template=None) + assert(results.count() == 0) + + +def remove_template(apps, schema_editor): + """Remove template links from existing StockItemTestResult objects.""" + + StockItemTestResult = apps.get_model('stock', 'stockitemtestresult') + results = StockItemTestResult.objects.all() + results.update(template=None) + + if results.count() > 0: + print(f"\nRemoved template links from {results.count()} StockItemTestResult objects") + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ('stock', '0105_stockitemtestresult_template'), + ('part', '0121_auto_20240207_0344') + ] + + operations = [ + migrations.RunPython(set_template, reverse_code=remove_template), + ] diff --git a/InvenTree/stock/migrations/0107_remove_stockitemtestresult_test_and_more.py b/InvenTree/stock/migrations/0107_remove_stockitemtestresult_test_and_more.py new file mode 100644 index 0000000000..7003f08870 --- /dev/null +++ b/InvenTree/stock/migrations/0107_remove_stockitemtestresult_test_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.9 on 2024-02-07 09:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0121_auto_20240207_0344'), + ('stock', '0106_auto_20240207_0353'), + ] + + operations = [ + migrations.RemoveField( + model_name='stockitemtestresult', + name='test', + ), + migrations.AlterField( + model_name='stockitemtestresult', + name='template', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_results', to='part.parttesttemplate'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 88183da23c..a894f1a3fe 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1550,6 +1550,52 @@ class StockItem( result.stock_item = self result.save() + def add_test_result(self, create_template=True, **kwargs): + """Helper function to add a new StockItemTestResult. + + The main purpose of this function is to allow lookup of the template, + based on the provided test name. + + If no template is found, a new one is created (if create_template=True). + + Args: + create_template: If True, create a new template if it does not exist + + kwargs: + template: The ID of the associated PartTestTemplate + test_name: The name of the test (if the template is not provided) + result: The result of the test + value: The value of the test + user: The user who performed the test + notes: Any notes associated with the test + """ + template = kwargs.get('template', None) + test_name = kwargs.pop('test_name', None) + + test_key = InvenTree.helpers.generateTestKey(test_name) + + if template is None and test_name is not None: + # Attempt to find a matching template + + template = PartModels.PartTestTemplate.objects.filter( + part__tree_id=self.part.tree_id, key=test_key + ).first() + + if template is None: + if create_template: + template = PartModels.PartTestTemplate.objects.create( + part=self.part, test_name=test_name + ) + else: + raise ValidationError({ + 'template': _('Test template does not exist') + }) + + kwargs['template'] = template + kwargs['stock_item'] = self + + return StockItemTestResult.objects.create(**kwargs) + def can_merge(self, other=None, raise_error=False, **kwargs): """Check if this stock item can be merged into another stock item.""" allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False) @@ -1623,6 +1669,9 @@ class StockItem( if len(other_items) == 0: return + # Keep track of the tree IDs that are being merged + tree_ids = {self.tree_id} + user = kwargs.get('user', None) location = kwargs.get('location', None) notes = kwargs.get('notes', None) @@ -1634,6 +1683,8 @@ class StockItem( if not self.can_merge(other, raise_error=raise_error, **kwargs): return + tree_ids.add(other.tree_id) + for other in other_items: self.quantity += other.quantity @@ -1665,6 +1716,14 @@ class StockItem( self.location = location self.save() + # Rebuild stock trees as required + try: + for tree_id in tree_ids: + StockItem.objects.partial_rebuild(tree_id=tree_id) + except Exception: + logger.warning('Rebuilding entire StockItem tree') + StockItem.objects.rebuild() + @transaction.atomic def splitStock(self, quantity, location=None, user=None, **kwargs): """Split this stock item into two items, in the same location. @@ -1994,19 +2053,24 @@ class StockItem( results.delete() - def getTestResults(self, test=None, result=None, user=None): + def getTestResults(self, template=None, test=None, result=None, user=None): """Return all test results associated with this StockItem. Optionally can filter results by: + - Test template ID - Test name - Test result - User """ results = self.test_results + if template: + results = results.filter(template=template) + if test: # Filter by test name - results = results.filter(test=test) + test_key = InvenTree.helpers.generateTestKey(test) + results = results.filter(template__key=test_key) if result is not None: # Filter by test status @@ -2037,8 +2101,7 @@ class StockItem( result_map = {} for result in results: - key = InvenTree.helpers.generateTestKey(result.test) - result_map[key] = result + result_map[result.key] = result # Do we wish to "cascade" and include test results from installed stock items? cascade = kwargs.get('cascade', False) @@ -2098,7 +2161,7 @@ class StockItem( def hasRequiredTests(self): """Return True if there are any 'required tests' associated with this StockItem.""" - return self.part.getRequiredTests().count() > 0 + return self.required_test_count > 0 def passedAllRequiredTests(self): """Returns True if this StockItem has passed all required tests.""" @@ -2286,7 +2349,7 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel): Attributes: stock_item: Link to StockItem - test: Test name (simple string matching) + template: Link to TestTemplate result: Test result value (pass / fail / etc) value: Recorded test output value (optional) attachment: Link to StockItem attachment (optional) @@ -2295,6 +2358,10 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel): date: Date the test result was recorded """ + def __str__(self): + """Return string representation.""" + return f'{self.test_name} - {self.result}' + @staticmethod def get_api_url(): """Return API url.""" @@ -2334,14 +2401,22 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel): @property def key(self): """Return key for test.""" - return InvenTree.helpers.generateTestKey(self.test) + return InvenTree.helpers.generateTestKey(self.test_name) stock_item = models.ForeignKey( StockItem, on_delete=models.CASCADE, related_name='test_results' ) - test = models.CharField( - blank=False, max_length=100, verbose_name=_('Test'), help_text=_('Test name') + @property + def test_name(self): + """Return the test name of the associated test template.""" + return self.template.test_name + + template = models.ForeignKey( + 'part.parttesttemplate', + on_delete=models.CASCADE, + blank=False, + related_name='test_results', ) result = models.BooleanField( diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index c5755eeb20..8864526dce 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -1,5 +1,6 @@ """JSON serializers for Stock app.""" +import logging from datetime import datetime, timedelta from decimal import Decimal @@ -23,7 +24,7 @@ import part.models as part_models import stock.filters from company.serializers import SupplierPartSerializer from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField -from part.serializers import PartBriefSerializer +from part.serializers import PartBriefSerializer, PartTestTemplateSerializer from .models import ( StockItem, @@ -34,6 +35,8 @@ from .models import ( StockLocationType, ) +logger = logging.getLogger('inventree') + class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Provides a brief serializer for a StockLocation object.""" @@ -56,8 +59,6 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ fields = [ 'pk', 'stock_item', - 'key', - 'test', 'result', 'value', 'attachment', @@ -65,6 +66,8 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ 'user', 'user_detail', 'date', + 'template', + 'template_detail', ] read_only_fields = ['pk', 'user', 'date'] @@ -72,20 +75,67 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ def __init__(self, *args, **kwargs): """Add detail fields.""" user_detail = kwargs.pop('user_detail', False) + template_detail = kwargs.pop('template_detail', False) super().__init__(*args, **kwargs) if user_detail is not True: self.fields.pop('user_detail') + if template_detail is not True: + self.fields.pop('template_detail') + user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True) - key = serializers.CharField(read_only=True) + template = serializers.PrimaryKeyRelatedField( + queryset=part_models.PartTestTemplate.objects.all(), + many=False, + required=False, + allow_null=True, + help_text=_('Template'), + label=_('Test template for this result'), + ) + + template_detail = PartTestTemplateSerializer(source='template', read_only=True) attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField( required=False ) + def validate(self, data): + """Validate the test result data.""" + stock_item = data['stock_item'] + template = data.get('template', None) + + # To support legacy API, we can accept a test name instead of a template + # In such a case, we use the test name to lookup the appropriate template + test_name = self.context['request'].data.get('test', None) + + if not template and not test_name: + raise ValidationError(_('Template ID or test name must be provided')) + + if not template: + test_key = InvenTree.helpers.generateTestKey(test_name) + + # Find a template based on name + if template := part_models.PartTestTemplate.objects.filter( + part__tree_id=stock_item.part.tree_id, key=test_key + ).first(): + data['template'] = template + + else: + logger.info( + "No matching test template found for '%s' - creating a new template", + test_name, + ) + + # Create a new test template based on the provided dasta + data['template'] = part_models.PartTestTemplate.objects.create( + part=stock_item.part, test_name=test_name + ) + + return super().validate(data) + class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): """Brief serializers for a StockItem.""" diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 4512d13d5d..30272d53a9 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -292,6 +292,7 @@ constructForm('{% url "api-stock-test-result-list" %}', { method: 'POST', fields: stockItemTestResultFields({ + part: {{ item.part.pk }}, stock_item: {{ item.pk }}, }), title: '{% trans "Add Test Result" escape %}', diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index ebee467b2b..187a2f5219 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -19,7 +19,7 @@ import part.models from common.models import InvenTreeSetting from InvenTree.status_codes import StockHistoryCode, StockStatus from InvenTree.unit_test import InvenTreeAPITestCase -from part.models import Part +from part.models import Part, PartTestTemplate from stock.models import ( StockItem, StockItemTestResult, @@ -34,6 +34,7 @@ class StockAPITestCase(InvenTreeAPITestCase): fixtures = [ 'category', 'part', + 'test_templates', 'bom', 'company', 'location', @@ -1559,6 +1560,8 @@ class StockTestResultTest(StockAPITestCase): response = self.client.get(url) n = len(response.data) + # Test upload using test name (legacy method) + # Note that a new test template will be created data = { 'stock_item': 105, 'test': 'Checked Steam Valve', @@ -1569,6 +1572,9 @@ class StockTestResultTest(StockAPITestCase): response = self.post(url, data, expected_code=201) + # Check that a new test template has been created + test_template = PartTestTemplate.objects.get(key='checkedsteamvalve') + response = self.client.get(url) self.assertEqual(len(response.data), n + 1) @@ -1581,6 +1587,27 @@ class StockTestResultTest(StockAPITestCase): self.assertEqual(test['value'], '150kPa') self.assertEqual(test['user'], self.user.pk) + # Test upload using template reference (new method) + data = { + 'stock_item': 105, + 'template': test_template.pk, + 'result': True, + 'value': '75kPa', + } + + response = self.post(url, data, expected_code=201) + + # Check that a new test template has been created + self.assertEqual(test_template.test_results.all().count(), 2) + + # List test results against the template + response = self.client.get(url, data={'template': test_template.pk}) + + self.assertEqual(len(response.data), 2) + + for item in response.data: + self.assertEqual(item['template'], test_template.pk) + def test_post_bitmap(self): """2021-08-25. @@ -1598,14 +1625,15 @@ class StockTestResultTest(StockAPITestCase): with open(image_file, 'rb') as bitmap: data = { 'stock_item': 105, - 'test': 'Checked Steam Valve', + 'test': 'Temperature Test', 'result': False, - 'value': '150kPa', - 'notes': 'I guess there was just too much pressure?', + 'value': '550C', + 'notes': 'I guess there was just too much heat?', 'attachment': bitmap, } response = self.client.post(self.get_url(), data) + self.assertEqual(response.status_code, 201) # Check that an attachment has been uploaded @@ -1619,23 +1647,34 @@ class StockTestResultTest(StockAPITestCase): url = reverse('api-stock-test-result-list') + stock_item = StockItem.objects.get(pk=1) + + # Ensure the part is marked as "trackable" + p = stock_item.part + p.trackable = True + p.save() + # Create some objects (via the API) for _ii in range(50): response = self.post( url, { - 'stock_item': 1, + 'stock_item': stock_item.pk, 'test': f'Some test {_ii}', 'result': True, 'value': 'Test result value', }, - expected_code=201, + # expected_code=201, ) tests.append(response.data['pk']) self.assertEqual(StockItemTestResult.objects.count(), n + 50) + # Filter test results by part + response = self.get(url, {'part': p.pk}, expected_code=200) + self.assertEqual(len(response.data), 50) + # Attempt a delete without providing items self.delete(url, {}, expected_code=400) @@ -1838,6 +1877,7 @@ class StockMetadataAPITest(InvenTreeAPITestCase): fixtures = [ 'category', 'part', + 'test_templates', 'bom', 'company', 'location', diff --git a/InvenTree/stock/test_migrations.py b/InvenTree/stock/test_migrations.py index 661c266706..1d2eb0f0b2 100644 --- a/InvenTree/stock/test_migrations.py +++ b/InvenTree/stock/test_migrations.py @@ -133,3 +133,100 @@ class TestScheduledForDeletionMigration(MigratorTestCase): # All the "scheduled for deletion" items have been removed self.assertEqual(StockItem.objects.count(), 3) + + +class TestTestResultMigration(MigratorTestCase): + """Unit tests for StockItemTestResult data migrations.""" + + migrate_from = ('stock', '0103_stock_location_types') + migrate_to = ('stock', '0107_remove_stockitemtestresult_test_and_more') + + test_keys = { + 'appliedpaint': 'Applied Paint', + 'programmed': 'Programmed', + 'checkedresultcode': 'Checked Result CODE', + } + + def prepare(self): + """Create initial data.""" + Part = self.old_state.apps.get_model('part', 'part') + PartTestTemplate = self.old_state.apps.get_model('part', 'parttesttemplate') + StockItem = self.old_state.apps.get_model('stock', 'stockitem') + StockItemTestResult = self.old_state.apps.get_model( + 'stock', 'stockitemtestresult' + ) + + # Create a test part + parent_part = Part.objects.create( + name='Parent Part', + description='A parent part', + is_template=True, + active=True, + trackable=True, + level=0, + tree_id=1, + lft=0, + rght=0, + ) + + # Create some child parts + children = [ + Part.objects.create( + name=f'Child part {idx}', + description='A child part', + variant_of=parent_part, + active=True, + trackable=True, + level=0, + tree_id=1, + lft=0, + rght=0, + ) + for idx in range(3) + ] + + # Create some stock items + for ii, child in enumerate(children): + for jj in range(4): + si = StockItem.objects.create( + part=child, + serial=str(1 + ii * jj), + quantity=1, + tree_id=0, + level=0, + lft=0, + rght=0, + ) + + # Create some test results + for _k, v in self.test_keys.items(): + StockItemTestResult.objects.create( + stock_item=si, test=v, result=True, value=f'Result: {ii} : {jj}' + ) + + # Check initial record counts + self.assertEqual(PartTestTemplate.objects.count(), 0) + self.assertEqual(StockItemTestResult.objects.count(), 36) + + def test_migration(self): + """Test that the migrations were applied as expected.""" + Part = self.new_state.apps.get_model('part', 'part') + PartTestTemplate = self.new_state.apps.get_model('part', 'parttesttemplate') + StockItem = self.new_state.apps.get_model('stock', 'stockitem') + StockItemTestResult = self.new_state.apps.get_model( + 'stock', 'stockitemtestresult' + ) + + # Test that original record counts are correct + self.assertEqual(Part.objects.count(), 4) + self.assertEqual(StockItem.objects.count(), 12) + self.assertEqual(StockItemTestResult.objects.count(), 36) + + # Two more test templates should have been created + self.assertEqual(PartTestTemplate.objects.count(), 3) + + for k in self.test_keys.keys(): + self.assertTrue(PartTestTemplate.objects.filter(key=k).exists()) + + for result in StockItemTestResult.objects.all(): + self.assertIsNotNone(result.template) diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 5b14bd7867..a9a6e1cfda 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -12,7 +12,7 @@ from company.models import Company from InvenTree.status_codes import StockHistoryCode from InvenTree.unit_test import InvenTreeTestCase from order.models import SalesOrder -from part.models import Part +from part.models import Part, PartTestTemplate from .models import StockItem, StockItemTestResult, StockItemTracking, StockLocation @@ -1086,31 +1086,51 @@ class TestResultTest(StockTestBase): self.assertEqual(status['total'], 5) self.assertEqual(status['passed'], 2) - self.assertEqual(status['failed'], 2) + self.assertEqual(status['failed'], 1) self.assertFalse(item.passedAllRequiredTests()) # Add some new test results to make it pass! - test = StockItemTestResult.objects.get(pk=12345) - test.result = True + test = StockItemTestResult.objects.get(pk=8) + test.result = False test.save() + status = item.requiredTestStatus() + self.assertEqual(status['total'], 5) + self.assertEqual(status['passed'], 1) + self.assertEqual(status['failed'], 2) + + template = PartTestTemplate.objects.get(pk=3) + StockItemTestResult.objects.create( - stock_item=item, test='sew cushion', result=True + stock_item=item, template=template, result=True ) # Still should be failing at this point, # as the most recent "apply paint" test was False self.assertFalse(item.passedAllRequiredTests()) + template = PartTestTemplate.objects.get(pk=2) + # Add a new test result against this required test StockItemTestResult.objects.create( stock_item=item, - test='apply paint', + template=template, date=datetime.datetime(2022, 12, 12), result=True, ) + self.assertFalse(item.passedAllRequiredTests()) + + # Generate a passing result for all required tests + for template in item.part.getRequiredTests(): + StockItemTestResult.objects.create( + stock_item=item, + template=template, + result=True, + date=datetime.datetime(2025, 12, 12), + ) + self.assertTrue(item.passedAllRequiredTests()) def test_duplicate_item_tests(self): @@ -1140,17 +1160,9 @@ class TestResultTest(StockTestBase): 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 - ) + item.add_test_result(test_name='Firmware', result=True) + item.add_test_result(test_name='Paint Color', result=True, value='Red') + item.add_test_result(test_name='Applied Sticker', result=False) self.assertEqual(item.test_results.count(), 3) self.assertEqual(item.quantity, 50) @@ -1163,7 +1175,7 @@ class TestResultTest(StockTestBase): self.assertEqual(item.test_results.count(), 3) self.assertEqual(item2.test_results.count(), 3) - StockItemTestResult.objects.create(stock_item=item2, test='A new test') + item2.add_test_result(test_name='A new test') self.assertEqual(item.test_results.count(), 3) self.assertEqual(item2.test_results.count(), 4) @@ -1172,7 +1184,7 @@ class TestResultTest(StockTestBase): item2.serializeStock(1, [100], self.user) # Add a test result to the parent *after* serialization - StockItemTestResult.objects.create(stock_item=item2, test='abcde') + item2.add_test_result(test_name='abcde') self.assertEqual(item2.test_results.count(), 5) @@ -1201,11 +1213,20 @@ class TestResultTest(StockTestBase): ) # Now, create some test results against the sub item + # Ensure there is a matching PartTestTemplate + if template := PartTestTemplate.objects.filter( + part=item.part, key='firmwareversion' + ).first(): + pass + else: + template = PartTestTemplate.objects.create( + part=item.part, test_name='Firmware Version', required=True + ) # First test is overshadowed by the same test for the parent part StockItemTestResult.objects.create( stock_item=sub_item, - test='firmware version', + template=template, date=datetime.datetime.now().date(), result=True, ) @@ -1214,10 +1235,19 @@ class TestResultTest(StockTestBase): tests = item.testResultMap(include_installed=True) self.assertEqual(len(tests), 3) + if template := PartTestTemplate.objects.filter( + part=item.part, key='somenewtest' + ).first(): + pass + else: + template = PartTestTemplate.objects.create( + part=item.part, test_name='Some New Test', required=True + ) + # Now, add a *unique* test result for the sub item StockItemTestResult.objects.create( stock_item=sub_item, - test='some new test', + template=template, date=datetime.datetime.now().date(), result=False, value='abcde', diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index b65083867e..cae1c5e4fc 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -68,6 +68,8 @@ function getModelRenderer(model) { return renderPartCategory; case 'partparametertemplate': return renderPartParameterTemplate; + case 'parttesttemplate': + return renderPartTestTemplate; case 'purchaseorder': return renderPurchaseOrder; case 'salesorder': @@ -483,6 +485,18 @@ function renderPartParameterTemplate(data, parameters={}) { } +function renderPartTestTemplate(data, parameters={}) { + + return renderModel( + { + text: data.test_name, + textSecondary: data.description, + }, + parameters + ); +} + + // Renderer for "ManufacturerPart" model function renderManufacturerPart(data, parameters={}) { diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 4babf49dd2..b24411f2e8 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -2867,6 +2867,18 @@ function loadPartTestTemplateTable(table, options) { field: 'test_name', title: '{% trans "Test Name" %}', sortable: true, + formatter: function(value, row) { + let html = value; + + if (row.results && row.results > 0) { + html += ` + + ${row.results} + `; + } + + return html; + } }, { field: 'description', @@ -2909,7 +2921,7 @@ function loadPartTestTemplateTable(table, options) { } else { var text = '{% trans "This test is defined for a parent part" %}'; - return renderLink(text, `/part/${row.part}/tests/`); + return renderLink(text, `/part/${row.part}/?display=test-templates`); } } } diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 6790ee20a8..4594bda33b 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1381,7 +1381,11 @@ function formatDate(row) { /* Construct set of default fields for a StockItemTestResult */ function stockItemTestResultFields(options={}) { let fields = { - test: {}, + template: { + filters: { + include_inherited: true, + } + }, result: {}, value: {}, attachment: {}, @@ -1393,6 +1397,10 @@ function stockItemTestResultFields(options={}) { }, }; + if (options.part) { + fields.template.filters.part = options.part; + } + if (options.stock_item) { fields.stock_item.value = options.stock_item; } @@ -1412,6 +1420,7 @@ function loadStockTestResultsTable(table, options) { let params = { part: options.part, + include_inherited: true, }; var filters = loadTableFilters(filterKey, params); @@ -1424,17 +1433,16 @@ function loadStockTestResultsTable(table, options) { let html = ''; - if (row.requires_attachment == false && row.requires_value == false && !row.result) { + if (row.parent != parent_node && row.requires_attachment == false && row.requires_value == false && !row.result) { // Enable a "quick tick" option for this test result html += makeIconButton('fa-check-circle icon-green', 'button-test-tick', row.test_name, '{% trans "Pass test" %}'); } - html += makeIconButton('fa-plus icon-green', 'button-test-add', row.test_name, '{% trans "Add test result" %}'); + html += makeIconButton('fa-plus icon-green', 'button-test-add', row.templateId, '{% trans "Add test result" %}'); if (!grouped && row.result != null) { - var pk = row.pk; - html += makeEditButton('button-test-edit', pk, '{% trans "Edit test result" %}'); - html += makeDeleteButton('button-test-delete', pk, '{% trans "Delete test result" %}'); + html += makeEditButton('button-test-edit', row.testId, '{% trans "Edit test result" %}'); + html += makeDeleteButton('button-test-delete', row.testId, '{% trans "Delete test result" %}'); } return wrapButtons(html); @@ -1532,9 +1540,14 @@ function loadStockTestResultsTable(table, options) { ], onLoadSuccess: function(tableData) { - // Set "parent" for each existing row - tableData.forEach(function(item, idx) { - tableData[idx].parent = parent_node; + // Construct an initial dataset based on the returned templates + let results = tableData.map((template) => { + return { + ...template, + templateId: template.pk, + parent: parent_node, + results: [] + }; }); // Once the test template data are loaded, query for test results @@ -1545,6 +1558,7 @@ function loadStockTestResultsTable(table, options) { stock_item: options.stock_item, user_detail: true, attachment_detail: true, + template_detail: false, ordering: '-date', }; @@ -1561,54 +1575,40 @@ function loadStockTestResultsTable(table, options) { query_params, { success: function(data) { - // Iterate through the returned test data - data.forEach(function(item) { - var match = false; - var override = false; + data.sort((a, b) => { + return a.pk < b.pk; + }).forEach((row) => { + let idx = results.findIndex((template) => { + return template.templateId == row.template; + }); - // Extract the simplified test key - var key = item.key; + if (idx > -1) { - // Attempt to associate this result with an existing test - for (var idx = 0; idx < tableData.length; idx++) { + results[idx].results.push(row); - var row = tableData[idx]; - - if (key == row.key) { - - item.test_name = row.test_name; - item.test_description = row.description; - item.required = row.required; - - if (row.result == null) { - item.parent = parent_node; - tableData[idx] = item; - override = true; - } else { - item.parent = row.pk; - } - - match = true; - - break; + // Check if a test result is already recorded + if (results[idx].testId) { + // Push this result into the results array + results.push({ + ...results[idx], + ...row, + parent: results[idx].templateId, + testId: row.pk, + }); + } else { + // First result - update the parent row + results[idx] = { + ...row, + ...results[idx], + testId: row.pk, + }; } } - - // No match could be found - if (!match) { - item.test_name = item.test; - item.parent = parent_node; - } - - if (!override) { - tableData.push(item); - } - }); // Push data back into the table - table.bootstrapTable('load', tableData); + table.bootstrapTable('load', results); } } ); @@ -1645,25 +1645,17 @@ function loadStockTestResultsTable(table, options) { $(table).on('click', '.button-test-add', function() { var button = $(this); - var test_name = button.attr('pk'); + var templateId = button.attr('pk'); + + let fields = stockItemTestResultFields(); + + fields['stock_item']['value'] = options.stock_item; + fields['template']['value'] = templateId; + fields['template']['filters']['part'] = options.part; constructForm('{% url "api-stock-test-result-list" %}', { method: 'POST', - fields: { - test: { - value: test_name, - }, - result: {}, - value: {}, - attachment: {}, - notes: { - icon: 'fa-sticky-note', - }, - stock_item: { - value: options.stock_item, - hidden: true, - } - }, + fields: fields, title: '{% trans "Add Test Result" %}', onSuccess: reloadTestTable, }); @@ -1692,11 +1684,9 @@ function loadStockTestResultsTable(table, options) { var url = `/api/stock/test/${pk}/`; - var row = $(table).bootstrapTable('getRowByUniqueId', pk); - var html = `