From e30c4e7cddd1facc50c7bcadd72b785e11cad70c Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 Jun 2025 12:56:16 +1000 Subject: [PATCH] Part copy test (#9764) * Add functionality to copy part test templates when duplicating a part instance * Bug fixes * Tweak part duplication fields * Add simple playwright test * Updated unit test for part duplication * Bump API version --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/part/fixtures/part.yaml | 2 + src/backend/InvenTree/part/models.py | 47 ++++++++++++-- src/backend/InvenTree/part/serializers.py | 27 ++++++-- src/backend/InvenTree/part/test_api.py | 61 ++++++++++++------- src/frontend/src/pages/part/PartDetail.tsx | 7 ++- src/frontend/tests/pages/pui_part.spec.ts | 16 +++++ 7 files changed, 129 insertions(+), 36 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 9aceca3b8c..5edc5e71bb 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 346 +INVENTREE_API_VERSION = 347 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v347 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9764 + - Adds "copy_tests" field to the DuplicatePart API endpoint + v346 -> 2025-06-07 : https://github.com/inventree/InvenTree/pull/9718 - Adds "read_only" field to the GlobalSettings API endpoint(s) diff --git a/src/backend/InvenTree/part/fixtures/part.yaml b/src/backend/InvenTree/part/fixtures/part.yaml index 232faeb015..e242aee615 100644 --- a/src/backend/InvenTree/part/fixtures/part.yaml +++ b/src/backend/InvenTree/part/fixtures/part.yaml @@ -105,12 +105,14 @@ fields: name: 'Bob' description: 'Can we build it? Yes we can!' + notes: 'Some notes associated with this part' assembly: true salable: true purchaseable: false creation_date: '2025-08-08' category: 7 active: True + testable: True IPN: BOB revision: A2 tree_id: 0 diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index a74f541313..24b9b50f11 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -2320,13 +2320,15 @@ class Part( sub.save() @transaction.atomic - def copy_parameters_from(self, other, **kwargs): + def copy_parameters_from(self, other: Part, **kwargs) -> None: """Copy all parameter values from another Part instance.""" clear = kwargs.get('clear', True) if clear: self.get_parameters().delete() + parameters = [] + for parameter in other.get_parameters(): # If this part already has a parameter pointing to the same template, # delete that parameter from this part first! @@ -2342,7 +2344,37 @@ class Part( parameter.part = self parameter.pk = None - parameter.save() + parameters.append(parameter) + + if len(parameters) > 0: + PartParameter.objects.bulk_create(parameters) + + @transaction.atomic + def copy_tests_from(self, other: Part, **kwargs) -> None: + """Copy all test templates from another Part instance. + + Note: We only copy the direct test templates, not ones inherited from parent parts. + """ + templates = [] + parts = self.get_ancestors(include_self=True) + + # Prevent tests from being created for non-testable parts + if not self.testable: + return + + for template in other.test_templates.all(): + # Skip if a test template already exists for this part / key combination + if PartTestTemplate.objects.filter( + key=template.key, part__in=parts + ).exists(): + continue + + template.pk = None + template.part = self + templates.append(template) + + if len(templates) > 0: + PartTestTemplate.objects.bulk_create(templates) def getTestTemplates( self, required=None, include_parent: bool = True, enabled=None @@ -3644,10 +3676,13 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel): 'part': _('Test templates can only be created for testable parts') }) - # Check that this test is unique within the part tree - tests = PartTestTemplate.objects.filter( - key=self.key, part__tree_id=self.part.tree_id - ).exclude(pk=self.pk) + # Check that this test is unique for this part + # (including template parts of which this part is a variant) + parts = self.part.get_ancestors(include_self=True) + + tests = PartTestTemplate.objects.filter(key=self.key, part__in=parts).exclude( + pk=self.pk + ) if tests.exists(): raise ValidationError({ diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 1522304d7a..b1141db39a 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -482,7 +482,14 @@ class DuplicatePartSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = ['part', 'copy_image', 'copy_bom', 'copy_parameters', 'copy_notes'] + fields = [ + 'part', + 'copy_image', + 'copy_bom', + 'copy_parameters', + 'copy_notes', + 'copy_tests', + ] part = serializers.PrimaryKeyRelatedField( queryset=Part.objects.all(), @@ -519,6 +526,13 @@ class DuplicatePartSerializer(serializers.Serializer): default=True, ) + copy_tests = serializers.BooleanField( + label=_('Copy Tests'), + help_text=_('Copy test templates from original part'), + required=False, + default=False, + ) + class InitialStockSerializer(serializers.Serializer): """Serializer for creating initial stock quantity.""" @@ -1073,20 +1087,23 @@ class PartSerializer( if duplicate: original = duplicate['part'] - if duplicate['copy_bom']: + if duplicate.get('copy_bom', False): instance.copy_bom_from(original) - if duplicate['copy_notes']: + if duplicate.get('copy_notes', False): instance.notes = original.notes instance.save() - if duplicate['copy_image']: + if duplicate.get('copy_image', False): instance.image = original.image instance.save() - if duplicate['copy_parameters']: + if duplicate.get('copy_parameters', False): instance.copy_parameters_from(original) + if duplicate.get('copy_tests', False): + instance.copy_tests_from(original) + # Duplicate parameter data from part category (and parents) if copy_category_parameters and instance.category is not None: # Get flattened list of parent categories diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 4d8af05846..e046cd9266 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -1445,31 +1445,46 @@ class PartCreationTests(PartAPITestBase): def test_duplication(self): """Test part duplication options.""" - # Run a matrix of tests - for bom in [True, False]: - for img in [True, False]: - for params in [True, False]: - response = self.post( - reverse('api-part-list'), - { - 'name': f'thing_{bom}{img}{params}', - 'description': 'Some long description text for this part', - 'category': 1, - 'duplicate': { - 'part': 100, - 'copy_bom': bom, - 'copy_image': img, - 'copy_parameters': params, - }, - }, - expected_code=201, - ) + base_part = Part.objects.get(pk=100) + base_part.testable = True + base_part.save() - part = Part.objects.get(pk=response.data['pk']) + # Create some test templates against this part + for key in ['A', 'B', 'C']: + PartTestTemplate.objects.create( + part=base_part, + test_name=f'Test {key}', + description=f'Test template {key} for duplication', + ) - # Check new part - self.assertEqual(part.bom_items.count(), 4 if bom else 0) - self.assertEqual(part.parameters.count(), 2 if params else 0) + for do_copy in [True, False]: + response = self.post( + reverse('api-part-list'), + { + 'name': f'thing_{do_copy}', + 'description': 'Some long description text for this part', + 'category': 1, + 'testable': do_copy, + 'assembly': do_copy, + 'duplicate': { + 'part': 100, + 'copy_bom': do_copy, + 'copy_notes': do_copy, + 'copy_image': do_copy, + 'copy_parameters': do_copy, + 'copy_tests': do_copy, + }, + }, + expected_code=201, + ) + + part = Part.objects.get(pk=response.data['pk']) + + # Check new part + self.assertEqual(part.bom_items.count(), 4 if do_copy else 0) + self.assertEqual(part.notes, base_part.notes if do_copy else None) + self.assertEqual(part.parameters.count(), 2 if do_copy else 0) + self.assertEqual(part.test_templates.count(), 3 if do_copy else 0) def test_category_parameters(self): """Test that category parameters are correctly applied.""" diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 767276df73..c0ffdc9472 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -830,13 +830,18 @@ export default function PartDetail() { value: true }, copy_bom: { - value: globalSettings.isSet('PART_COPY_BOM') + value: part.assembly && globalSettings.isSet('PART_COPY_BOM'), + hidden: !part.assembly }, copy_notes: { value: true }, copy_parameters: { value: globalSettings.isSet('PART_COPY_PARAMETERS') + }, + copy_tests: { + value: part.testable, + hidden: !part.testable } } } diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index bda6b5ea66..a0891d1da6 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -507,3 +507,19 @@ test('Parts - Bulk Edit', async ({ browser }) => { await page.getByRole('button', { name: 'Update' }).click(); await page.getByText('Items Updated').waitFor(); }); + +test('Parts - Duplicate', async ({ browser }) => { + const page = await doCachedLogin(browser, { + url: 'part/74/details' + }); + + // Open "duplicate part" dialog + await page.getByLabel('action-menu-part-actions').click(); + await page.getByLabel('action-menu-part-actions-duplicate').click(); + + // Check for expected fields + await page.getByText('Copy Image', { exact: true }).waitFor(); + await page.getByText('Copy Notes', { exact: true }).waitFor(); + await page.getByText('Copy Parameters', { exact: true }).waitFor(); + await page.getByText('Copy Tests', { exact: true }).waitFor(); +});