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:
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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({
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
|
Reference in New Issue
Block a user