diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 27d66756ff8f..92048866a0cd 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 1d2884c829bf..d36f93704e92 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 000000000000..b06971724c37 --- /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 7ef19f0133d7..f623bdf4c316 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -509,6 +509,20 @@ def test_model_mixin(self): 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 6af1c0a06573..fd4fe9975f00 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -655,9 +655,10 @@ def test_complete_with_required_tests(self): # 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 a692ff9ac8eb..52726423edb6 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 b2b57fbaa821..3b019296bc65 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -389,29 +389,53 @@ def filter_part(self, queryset, name, part): 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): - """Detail endpoint for PartTestTemplate model.""" + +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 PartTestTemplateList(ListCreateAPI): +class PartTestTemplateDetail(PartTestTemplateMixin, RetrieveUpdateDestroyAPI): + """Detail endpoint for PartTestTemplate model.""" + + pass + + +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 aaf11b79746c..5427ec4314b9 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 000000000000..82954ac785b7 --- /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 000000000000..6e028a064536 --- /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 a36f17fd55c2..eaa261386337 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 @@ def clean(self): """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 @@ def validate_unique(self, exclude=None): '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 @@ def key(self): 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 3c9eb554c088..b636bca5687d 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -156,9 +156,20 @@ class Meta: '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 878b27519fd4..5cc16e74c9cf 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -806,14 +806,14 @@ def test_test_templates(self): 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 313e2627733b..1a15e57ebfc4 100644 --- a/InvenTree/part/test_migrations.py +++ b/InvenTree/part/test_migrations.py @@ -229,3 +229,47 @@ def test_units_migration(self): 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 c1865b83661f..2247d034ec27 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -378,8 +378,8 @@ def test_template_count(self): # 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 @@ def test_uniqueness(self): 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 8f43aca98dba..ab9b68f83ca2 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 32d62d33e3d8..f97c281f52c6 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 c601f6478a99..c5892acacd54 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -38,6 +38,7 @@ 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): - """Detail endpoint for StockItemTestResult.""" +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 -class StockItemTestResultList(ListCreateDestroyAPIView): - """API endpoint for listing (and creating) a StockItemTestResult object.""" + 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 - queryset = StockItemTestResult.objects.all() - serializer_class = StockSerializers.StockItemTestResultSerializer + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + +class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI): + """Detail endpoint for StockItemTestResult.""" + + pass + + +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.""" + + 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 @@ def filter_queryset(self, queryset): 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 @@ def filter_queryset(self, queryset): 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 4b413b128955..e3e8da21e0f8 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 000000000000..79a2c11155a2 --- /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 000000000000..23379634249d --- /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 000000000000..7003f08870f5 --- /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 88183da23c14..a894f1a3fefb 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1550,6 +1550,52 @@ def copyTestResultsFrom(self, other, filters=None): 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 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): 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 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): 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 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): 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 @@ def clear_test_results(self, **kwargs): 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 @@ def testResultMap(self, **kwargs): 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 @@ def required_test_count(self): 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 @@ def clean(self): @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 c5755eeb20ee..8864526dcec7 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 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 @@ StockLocationType, ) +logger = logging.getLogger('inventree') + class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Provides a brief serializer for a StockLocation object.""" @@ -56,8 +59,6 @@ class Meta: fields = [ 'pk', 'stock_item', - 'key', - 'test', 'result', 'value', 'attachment', @@ -65,6 +66,8 @@ class Meta: 'user', 'user_detail', 'date', + 'template', + 'template_detail', ] read_only_fields = ['pk', 'user', 'date'] @@ -72,20 +75,67 @@ class Meta: 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 4512d13d5d6f..30272d53a905 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -292,6 +292,7 @@

{% trans "Installed Stock Items" %}

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 ebee467b2bdf..187a2f5219e5 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -19,7 +19,7 @@ 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 @@ def test_post(self): 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 @@ def test_post(self): 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 @@ def test_post(self): 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 @@ def test_post_bitmap(self): 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 @@ def test_bulk_delete(self): 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 661c266706fb..1d2eb0f0b2ac 100644 --- a/InvenTree/stock/test_migrations.py +++ b/InvenTree/stock/test_migrations.py @@ -133,3 +133,100 @@ def test_migration(self): # 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 5b14bd7867a9..a9a6e1cfda77 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -12,7 +12,7 @@ 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 @@ def test_test_results(self): 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 @@ def test_duplicate_item_tests(self): 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 @@ def test_duplicate_item_tests(self): 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 @@ def test_duplicate_item_tests(self): 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 @@ def test_installed_tests(self): ) # 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 @@ def test_installed_tests(self): 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 b65083867ecb..cae1c5e4fc14 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 4babf49dd2bd..b24411f2e871 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 6790ee20a8c4..4594bda33bbf 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; - - // Extract the simplified test key - var key = item.key; - // Attempt to associate this result with an existing test - for (var idx = 0; idx < tableData.length; idx++) { - - 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; + data.sort((a, b) => { + return a.pk < b.pk; + }).forEach((row) => { + let idx = results.findIndex((template) => { + return template.templateId == row.template; + }); + + if (idx > -1) { + + results[idx].results.push(row); + + // 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 = `
- {% trans "Delete test result" %}: ${row.test_name || row.test || row.key} + {% trans "Delete test result" %}
`; constructForm(url, { diff --git a/docs/docs/part/test.md b/docs/docs/part/test.md index 7a385f0dc7ce..286e1bd42cc0 100644 --- a/docs/docs/part/test.md +++ b/docs/docs/part/test.md @@ -20,7 +20,22 @@ Test templates "cascade" down to variant parts: this means that if a master part The name of the test is a simple string value which defines the name of the test. This test must be unique for a given part (or across a set of part variants). -The test name is used to generate a test "key" which is then used to match against test results associated with individual stock items. +#### Test Key + +The test name is used to generate a test "key" which is then used to match against test results associated with individual stock items. The *key* is a simplified string representation of the test name, which consists only of lowercase alphanumeric characters. This key value is automatically generated (based on the test name) whenever the test template is saved. + +The generated test key is intended to be a valid python variable name, and can be used to reference the test in the report generation system. + +##### Examples + +Some examples of generated test key values are provided below: + +| Test Name | Test Key | +| --- | --- | +| "Firmware Version" | "firmwareversion" | +| " My NEW T E sT " | "mynewtest" | +| "100 Percent Test"| "_100percenttest" *(note that the leading underscore is added to ensure the key is a valid python variable name)* | +| "Test 123" | "test123" | #### Test Description diff --git a/docs/docs/stock/test.md b/docs/docs/stock/test.md index 7ebe2063a819..c284f50f3f22 100644 --- a/docs/docs/stock/test.md +++ b/docs/docs/stock/test.md @@ -14,9 +14,9 @@ The master "Part" record for the stock item can define multiple [test templates] ### Test Result Fields -#### Test Name +#### Test Template -The name of the test data is used to associate the test with a test template object. +The *template* field links to a [Part Test Template](../part/test.md#part-test-templates) object. Each test result instance must link to a test template. #### Result diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 64ff7b1d9342..2ce7225d00d0 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -21,7 +21,7 @@ export function setApiDefaults() { const token = useSessionState.getState().token; api.defaults.baseURL = host; - api.defaults.timeout = 1000; + api.defaults.timeout = 2500; if (!!token) { api.defaults.headers.common['Authorization'] = `Token ${token}`; diff --git a/src/frontend/src/components/items/YesNoButton.tsx b/src/frontend/src/components/items/YesNoButton.tsx index 11db58974324..ec12af7f90ee 100644 --- a/src/frontend/src/components/items/YesNoButton.tsx +++ b/src/frontend/src/components/items/YesNoButton.tsx @@ -3,8 +3,18 @@ import { Badge } from '@mantine/core'; import { isTrue } from '../../functions/conversion'; -export function YesNoButton({ value }: { value: any }) { +export function PassFailButton({ + value, + passText, + failText +}: { + value: any; + passText?: string; + failText?: string; +}) { const v = isTrue(value); + const pass = passText || t`Pass`; + const fail = failText || t`Fail`; return ( - {v ? t`Yes` : t`No`} + {v ? pass : fail} ); } + +export function YesNoButton({ value }: { value: any }) { + return ; +} diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index 490874b4af68..2b11565463f6 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -23,7 +23,8 @@ import { import { RenderPart, RenderPartCategory, - RenderPartParameterTemplate + RenderPartParameterTemplate, + RenderPartTestTemplate } from './Part'; import { RenderStockItem, RenderStockLocation } from './Stock'; import { RenderOwner, RenderUser } from './User'; @@ -48,6 +49,7 @@ const RendererLookup: EnumDictionary< [ModelType.part]: RenderPart, [ModelType.partcategory]: RenderPartCategory, [ModelType.partparametertemplate]: RenderPartParameterTemplate, + [ModelType.parttesttemplate]: RenderPartTestTemplate, [ModelType.projectcode]: RenderProjectCode, [ModelType.purchaseorder]: RenderPurchaseOrder, [ModelType.purchaseorderline]: RenderPurchaseOrder, diff --git a/src/frontend/src/components/render/ModelType.tsx b/src/frontend/src/components/render/ModelType.tsx index d5718aa67417..405083493371 100644 --- a/src/frontend/src/components/render/ModelType.tsx +++ b/src/frontend/src/components/render/ModelType.tsx @@ -32,6 +32,13 @@ export const ModelInformationDict: ModelDict = { url_detail: '/partparametertemplate/:pk/', api_endpoint: ApiEndpoints.part_parameter_template_list }, + parttesttemplate: { + label: t`Part Test Template`, + label_multiple: t`Part Test Templates`, + url_overview: '/parttesttemplate', + url_detail: '/parttesttemplate/:pk/', + api_endpoint: ApiEndpoints.part_test_template_list + }, supplierpart: { label: t`Supplier Part`, label_multiple: t`Supplier Parts`, diff --git a/src/frontend/src/components/render/Part.tsx b/src/frontend/src/components/render/Part.tsx index fe9d9562eee7..7a3b8a071699 100644 --- a/src/frontend/src/components/render/Part.tsx +++ b/src/frontend/src/components/render/Part.tsx @@ -51,3 +51,16 @@ export function RenderPartParameterTemplate({ /> ); } + +export function RenderPartTestTemplate({ + instance +}: { + instance: any; +}): ReactNode { + return ( + + ); +} diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 978604655ce7..0d9c3d0c5a83 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -80,6 +80,7 @@ export enum ApiEndpoints { stock_location_list = 'stock/location/', stock_location_tree = 'stock/location/tree/', stock_attachment_list = 'stock/attachment/', + stock_test_result_list = 'stock/test/', // Order API endpoints purchase_order_list = 'order/po/', diff --git a/src/frontend/src/enums/ModelType.tsx b/src/frontend/src/enums/ModelType.tsx index 49c59ce23546..da0f3413dbad 100644 --- a/src/frontend/src/enums/ModelType.tsx +++ b/src/frontend/src/enums/ModelType.tsx @@ -7,6 +7,7 @@ export enum ModelType { manufacturerpart = 'manufacturerpart', partcategory = 'partcategory', partparametertemplate = 'partparametertemplate', + parttesttemplate = 'parttesttemplate', projectcode = 'projectcode', stockitem = 'stockitem', stocklocation = 'stocklocation', diff --git a/src/frontend/src/hooks/UseInstance.tsx b/src/frontend/src/hooks/UseInstance.tsx index 71f61e27d94f..d160269d1472 100644 --- a/src/frontend/src/hooks/UseInstance.tsx +++ b/src/frontend/src/hooks/UseInstance.tsx @@ -51,6 +51,7 @@ export function useInstance({ return api .get(url, { + timeout: 10000, params: params }) .then((response) => { diff --git a/src/frontend/src/hooks/UseTable.tsx b/src/frontend/src/hooks/UseTable.tsx index 7a0e66a0ced4..41303b314783 100644 --- a/src/frontend/src/hooks/UseTable.tsx +++ b/src/frontend/src/hooks/UseTable.tsx @@ -19,6 +19,8 @@ export type TableState = { activeFilters: TableFilter[]; setActiveFilters: (filters: TableFilter[]) => void; clearActiveFilters: () => void; + expandedRecords: any[]; + setExpandedRecords: (records: any[]) => void; selectedRecords: any[]; setSelectedRecords: (records: any[]) => void; clearSelectedRecords: () => void; @@ -59,6 +61,9 @@ export function useTable(tableName: string): TableState { setActiveFilters([]); }, []); + // Array of expanded records + const [expandedRecords, setExpandedRecords] = useState([]); + // Array of selected records const [selectedRecords, setSelectedRecords] = useState([]); @@ -81,6 +86,8 @@ export function useTable(tableName: string): TableState { activeFilters, setActiveFilters, clearActiveFilters, + expandedRecords, + setExpandedRecords, selectedRecords, setSelectedRecords, clearSelectedRecords, diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index e8af423437f2..468aad4ea018 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -41,6 +41,7 @@ import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; import { AttachmentTable } from '../../tables/general/AttachmentTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; +import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable'; export default function StockDetail() { const { id } = useParams(); @@ -89,7 +90,15 @@ export default function StockDetail() { name: 'testdata', label: t`Test Data`, icon: , - hidden: !stockitem?.part_detail?.trackable + hidden: !stockitem?.part_detail?.trackable, + content: stockitem?.pk ? ( + + ) : ( + + ) }, { name: 'installed_items', diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx index a70f8098946d..7f7369a321b3 100644 --- a/src/frontend/src/tables/ColumnRenderers.tsx +++ b/src/frontend/src/tables/ColumnRenderers.tsx @@ -74,7 +74,9 @@ export function ReferenceColumn(): TableColumn { export function NoteColumn(): TableColumn { return { accessor: 'note', - sortable: false + sortable: false, + title: t`Note`, + render: (record: any) => record.note ?? record.notes }; } @@ -119,6 +121,15 @@ export function ResponsibleColumn(): TableColumn { }; } +export function DateColumn(): TableColumn { + return { + accessor: 'date', + sortable: true, + title: t`Date`, + render: (record: any) => renderDate(record.date) + }; +} + export function TargetDateColumn(): TableColumn { return { accessor: 'target_date', diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 66870443cc8d..1e2c3c13c7dd 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -74,8 +74,9 @@ export type InvenTreeTableProps = { tableFilters?: TableFilter[]; tableActions?: React.ReactNode[]; printingActions?: any[]; + rowExpansion?: any; idAccessor?: string; - dataFormatter?: (data: T) => any; + dataFormatter?: (data: any) => any; rowActions?: (record: T) => RowAction[]; onRowClick?: (record: T, index: number, event: any) => void; }; @@ -223,14 +224,12 @@ export function InvenTreeTable({ hidden: false, switchable: false, width: 50, - render: function (record: any) { - return ( - 0} - /> - ); - } + render: (record: any) => ( + 0} + /> + ) }); } @@ -373,14 +372,11 @@ export function InvenTreeTable({ tableProps.noRecordsText ?? t`No records found` ); - let results = []; + let results = response.data?.results ?? response.data ?? []; if (props.dataFormatter) { // Custom data formatter provided - results = props.dataFormatter(response.data); - } else { - // Extract returned data (accounting for pagination) and ensure it is a list - results = response.data?.results ?? response.data ?? []; + results = props.dataFormatter(results); } if (!Array.isArray(results)) { @@ -611,6 +607,7 @@ export function InvenTreeTable({ onSelectedRecordsChange={ tableProps.enableSelection ? onSelectedRecordsChange : undefined } + rowExpansion={tableProps.rowExpansion} fetching={isFetching} noRecordsText={missingRecordsText} records={data} diff --git a/src/frontend/src/tables/part/PartTestTemplateTable.tsx b/src/frontend/src/tables/part/PartTestTemplateTable.tsx index 513affad89a1..3b9a970f48ab 100644 --- a/src/frontend/src/tables/part/PartTestTemplateTable.tsx +++ b/src/frontend/src/tables/part/PartTestTemplateTable.tsx @@ -1,10 +1,14 @@ -import { t } from '@lingui/macro'; +import { Trans, t } from '@lingui/macro'; +import { Alert, Badge, Text } from '@mantine/core'; import { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { AddItemButton } from '../../components/buttons/AddItemButton'; import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; +import { getDetailUrl } from '../../functions/urls'; import { useCreateApiFormModal, useDeleteApiFormModal, @@ -22,6 +26,7 @@ import { RowDeleteAction, RowEditAction } from '../RowActions'; export default function PartTestTemplateTable({ partId }: { partId: number }) { const table = useTable('part-test-template'); const user = useUserState(); + const navigate = useNavigate(); const tableColumns: TableColumn[] = useMemo(() => { return [ @@ -30,6 +35,15 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) { switchable: false, sortable: true }, + { + accessor: 'results', + switchable: true, + sortable: true, + title: t`Results`, + render: (record: any) => { + return record.results || {t`No Results`}; + } + }, DescriptionColumn({ switchable: false }), @@ -43,7 +57,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) { accessor: 'requires_attachment' }) ]; - }, []); + }, [partId]); const tableFilters: TableFilter[] = useMemo(() => { return [ @@ -58,6 +72,11 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) { { name: 'requires_attachment', description: t`Show tests that require an attachment` + }, + { + name: 'include_inherited', + label: t`Include Inherited`, + description: t`Show tests from inherited templates` } ]; }, []); @@ -99,13 +118,27 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) { url: ApiEndpoints.part_test_template_list, pk: selectedTest, title: t`Delete Test Template`, + preFormContent: ( + + + + Any tests results associated with this template will be deleted + + + + ), onFormSuccess: table.refreshTable }); const rowActions = useCallback( (record: any) => { - let can_edit = user.hasChangeRole(UserRoles.part); - let can_delete = user.hasDeleteRole(UserRoles.part); + const can_edit = user.hasChangeRole(UserRoles.part); + const can_delete = user.hasDeleteRole(UserRoles.part); + + if (record.part != partId) { + // No actions, as this test is defined for a parent part + return []; + } return [ RowEditAction({ @@ -124,7 +157,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) { }) ]; }, - [user] + [user, partId] ); const tableActions = useMemo(() => { @@ -150,11 +183,18 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) { columns={tableColumns} props={{ params: { - part: partId + part: partId, + part_detail: true }, tableFilters: tableFilters, tableActions: tableActions, - rowActions: rowActions + rowActions: rowActions, + onRowClick: (row) => { + if (row.part && row.part != partId) { + // This test is defined for a different part + navigate(getDetailUrl(ModelType.part, row.part)); + } + } }} /> diff --git a/src/frontend/src/tables/settings/CurrencyTable.tsx b/src/frontend/src/tables/settings/CurrencyTable.tsx index 19f110e7c3fa..59dd8d4cddb2 100644 --- a/src/frontend/src/tables/settings/CurrencyTable.tsx +++ b/src/frontend/src/tables/settings/CurrencyTable.tsx @@ -67,8 +67,8 @@ export default function CurrencyTable() { columns={columns} props={{ tableActions: tableActions, - dataFormatter: (data) => { - let rates = data?.exchange_rates ?? {}; + dataFormatter: (data: any) => { + let rates = data.exchange_rates ?? {}; return Object.entries(rates).map(([currency, rate]) => { return { diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 567031268db6..b99416ff590b 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -338,7 +338,7 @@ export function StockItemTable({ params = {} }: { params?: any }) { columns={tableColumns} props={{ enableDownload: true, - enableSelection: true, + enableSelection: false, tableFilters: tableFilters, onRowClick: (record) => navigate(getDetailUrl(ModelType.stockitem, record.pk)), diff --git a/src/frontend/src/tables/stock/StockItemTestResultTable.tsx b/src/frontend/src/tables/stock/StockItemTestResultTable.tsx new file mode 100644 index 000000000000..7cd71763856c --- /dev/null +++ b/src/frontend/src/tables/stock/StockItemTestResultTable.tsx @@ -0,0 +1,428 @@ +import { t } from '@lingui/macro'; +import { Badge, Group, Text, Tooltip } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { + IconCircleCheck, + IconCirclePlus, + IconInfoCircle +} from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { DataTable } from 'mantine-datatable'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { api } from '../../App'; +import { AddItemButton } from '../../components/buttons/AddItemButton'; +import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; +import { AttachmentLink } from '../../components/items/AttachmentLink'; +import { PassFailButton } from '../../components/items/YesNoButton'; +import { RenderUser } from '../../components/render/User'; +import { renderDate } from '../../defaults/formatters'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { UserRoles } from '../../enums/Roles'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; +import { TableColumn } from '../Column'; +import { DescriptionColumn, NoteColumn } from '../ColumnRenderers'; +import { TableFilter } from '../Filter'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { RowActions, RowDeleteAction, RowEditAction } from '../RowActions'; + +export default function StockItemTestResultTable({ + partId, + itemId +}: { + partId: number; + itemId: number; +}) { + const user = useUserState(); + const table = useTable('stocktests'); + + // Fetch the test templates required for this stock item + const { data: testTemplates } = useQuery({ + queryKey: ['stocktesttemplates', partId, itemId], + queryFn: async () => { + if (!partId) { + return []; + } + + return api + .get(apiUrl(ApiEndpoints.part_test_template_list), { + params: { + part: partId, + include_inherited: true + } + }) + .then((response) => response.data) + .catch((_error) => []); + } + }); + + useEffect(() => { + table.refreshTable(); + }, [testTemplates]); + + // Format the test results based on the returned data + const formatRecords = useCallback( + (records: any[]): any[] => { + // Construct a list of test templates + let results = testTemplates.map((template: any) => { + return { + ...template, + templateId: template.pk, + results: [] + }; + }); + + // If any of the tests results point to templates which we do not have, add them in + records.forEach((record) => { + if (!results.find((r: any) => r.templateId == record.template)) { + results.push({ + ...record.template_detail, + templateId: record.template, + results: [] + }); + } + }); + + // Iterate through the returned records + // Note that the results are sorted by oldest first, + // to ensure that the most recent result is displayed "on top" + records + .sort((a: any, b: any) => { + return a.pk > b.pk ? 1 : -1; + }) + .forEach((record) => { + // Find matching template + let idx = results.findIndex( + (r: any) => r.templateId == record.template + ); + if (idx >= 0) { + results[idx] = { + ...results[idx], + ...record + }; + + results[idx].results.push(record); + } + }); + + return results; + }, + [partId, itemId, testTemplates] + ); + + const tableColumns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'test', + title: t`Test`, + switchable: false, + sortable: true, + render: (record: any) => { + let required = record.required ?? record.template_detail?.required; + let installed = + record.stock_item != undefined && record.stock_item != itemId; + + return ( + + + {!record.templateId && '- '} + {record.test_name ?? record.template_detail?.test_name} + + + {record.results && record.results.length > 1 && ( + + + {record.results.length} + + + )} + {installed && ( + + + + )} + + + ); + } + }, + { + accessor: 'result', + title: t`Result`, + switchable: false, + sortable: true, + render: (record: any) => { + if (record.result === undefined) { + return ( + {t`No Result`} + ); + } else { + return ; + } + } + }, + DescriptionColumn({ + accessor: 'description' + }), + { + accessor: 'value', + title: t`Value` + }, + { + accessor: 'attachment', + title: t`Attachment`, + render: (record: any) => + record.attachment && + }, + NoteColumn(), + { + accessor: 'date', + sortable: true, + title: t`Date`, + render: (record: any) => { + return ( + + {renderDate(record.date)} + {record.user_detail && ( + + )} + + ); + } + } + ]; + }, [itemId]); + + const resultFields: ApiFormFieldSet = useMemo(() => { + return { + template: { + filters: { + include_inherited: true, + part: partId + } + }, + result: {}, + value: {}, + attachment: {}, + notes: {}, + stock_item: { + value: itemId, + hidden: true + } + }; + }, [partId, itemId]); + + const [selectedTemplate, setSelectedTemplate] = useState( + undefined + ); + + const newTestModal = useCreateApiFormModal({ + url: ApiEndpoints.stock_test_result_list, + fields: resultFields, + initialData: { + template: selectedTemplate, + result: true + }, + title: t`Add Test Result`, + onFormSuccess: () => table.refreshTable(), + successMessage: t`Test result added` + }); + + const [selectedTest, setSelectedTest] = useState( + undefined + ); + + const editTestModal = useEditApiFormModal({ + url: ApiEndpoints.stock_test_result_list, + pk: selectedTest, + fields: resultFields, + title: t`Edit Test Result`, + onFormSuccess: () => table.refreshTable(), + successMessage: t`Test result updated` + }); + + const deleteTestModal = useDeleteApiFormModal({ + url: ApiEndpoints.stock_test_result_list, + pk: selectedTest, + title: t`Delete Test Result`, + onFormSuccess: () => table.refreshTable(), + successMessage: t`Test result deleted` + }); + + const passTest = useCallback( + (templateId: number) => { + api + .post(apiUrl(ApiEndpoints.stock_test_result_list), { + template: templateId, + stock_item: itemId, + result: true + }) + .then(() => { + table.refreshTable(); + showNotification({ + title: t`Test Passed`, + message: t`Test result has been recorded`, + color: 'green' + }); + }); + }, + [itemId] + ); + + const rowActions = useCallback( + (record: any) => { + if (record.stock_item != undefined && record.stock_item != itemId) { + // Test results for other stock items cannot be edited + return []; + } + + return [ + { + title: t`Pass Test`, + color: 'green', + icon: , + hidden: + !record.templateId || + record?.requires_attachment || + record?.requires_value || + record.result, + onClick: () => passTest(record.templateId) + }, + { + title: t`Add`, + tooltip: t`Add Test Result`, + color: 'green', + icon: , + hidden: !user.hasAddRole(UserRoles.stock) || !record.templateId, + onClick: () => { + setSelectedTemplate(record.templateId); + newTestModal.open(); + } + }, + RowEditAction({ + tooltip: t`Edit Test Result`, + hidden: + !user.hasChangeRole(UserRoles.stock) || !record.template_detail, + onClick: () => { + setSelectedTest(record.pk); + editTestModal.open(); + } + }), + RowDeleteAction({ + tooltip: t`Delete Test Result`, + hidden: + !user.hasDeleteRole(UserRoles.stock) || !record.template_detail, + onClick: () => { + setSelectedTest(record.pk); + deleteTestModal.open(); + } + }) + ]; + }, + [user, itemId] + ); + + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'required', + label: t`Required`, + description: t`Show results for required tests` + }, + { + name: 'include_installed', + label: t`Include Installed`, + description: t`Show results for installed stock items` + }, + { + name: 'result', + label: t`Passed`, + description: t`Show only passed tests` + } + ]; + }, []); + + const tableActions = useMemo(() => { + return [ + { + setSelectedTemplate(undefined); + newTestModal.open(); + }} + hidden={!user.hasAddRole(UserRoles.stock)} + /> + ]; + }, [user]); + + // Row expansion controller + const rowExpansion: any = useMemo(() => { + const cols: any = [ + ...tableColumns, + { + accessor: 'actions', + title: ' ', + hidden: false, + switchable: false, + width: 50, + render: (record: any) => ( + + ) + } + ]; + + return { + allowMultiple: true, + content: ({ record }: { record: any }) => { + if (!record || !record.results || record.results.length < 2) { + return null; + } + + const results = record?.results ?? []; + + return ( + + ); + } + }; + }, []); + + return ( + <> + {newTestModal.modal} + {editTestModal.modal} + {deleteTestModal.modal} + + + ); +} diff --git a/tasks.py b/tasks.py index 1aece92963a8..53a71b334452 100644 --- a/tasks.py +++ b/tasks.py @@ -105,12 +105,7 @@ def content_excludes( excludes.append('socialaccount.socialapp') excludes.append('socialaccount.socialtoken') - output = '' - - for e in excludes: - output += f'--exclude {e} ' - - return output + return ' '.join([f'--exclude {e}' for e in excludes]) def localDir() -> Path: @@ -370,6 +365,7 @@ def migrate(c): print('========================================') # Run custom management command which wraps migrations in "maintenance mode" + manage(c, 'makemigrations') manage(c, 'runmigrations', pty=True) manage(c, 'migrate --run-syncdb')