2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-13 18:45:40 +00:00

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
This commit is contained in:
Oliver
2025-06-12 12:56:16 +10:00
committed by GitHub
parent c81d0eb628
commit e30c4e7cdd
7 changed files with 129 additions and 36 deletions

View File

@ -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)

View File

@ -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

View File

@ -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({

View File

@ -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

View File

@ -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."""

View File

@ -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
}
}
}

View File

@ -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();
});