mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-30 00:21:34 +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 information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v346 -> 2025-06-07 : https://github.com/inventree/InvenTree/pull/9718
|
||||||
- Adds "read_only" field to the GlobalSettings API endpoint(s)
|
- Adds "read_only" field to the GlobalSettings API endpoint(s)
|
||||||
|
|
||||||
|
@@ -105,12 +105,14 @@
|
|||||||
fields:
|
fields:
|
||||||
name: 'Bob'
|
name: 'Bob'
|
||||||
description: 'Can we build it? Yes we can!'
|
description: 'Can we build it? Yes we can!'
|
||||||
|
notes: 'Some notes associated with this part'
|
||||||
assembly: true
|
assembly: true
|
||||||
salable: true
|
salable: true
|
||||||
purchaseable: false
|
purchaseable: false
|
||||||
creation_date: '2025-08-08'
|
creation_date: '2025-08-08'
|
||||||
category: 7
|
category: 7
|
||||||
active: True
|
active: True
|
||||||
|
testable: True
|
||||||
IPN: BOB
|
IPN: BOB
|
||||||
revision: A2
|
revision: A2
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
|
@@ -2320,13 +2320,15 @@ class Part(
|
|||||||
sub.save()
|
sub.save()
|
||||||
|
|
||||||
@transaction.atomic
|
@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."""
|
"""Copy all parameter values from another Part instance."""
|
||||||
clear = kwargs.get('clear', True)
|
clear = kwargs.get('clear', True)
|
||||||
|
|
||||||
if clear:
|
if clear:
|
||||||
self.get_parameters().delete()
|
self.get_parameters().delete()
|
||||||
|
|
||||||
|
parameters = []
|
||||||
|
|
||||||
for parameter in other.get_parameters():
|
for parameter in other.get_parameters():
|
||||||
# If this part already has a parameter pointing to the same template,
|
# If this part already has a parameter pointing to the same template,
|
||||||
# delete that parameter from this part first!
|
# delete that parameter from this part first!
|
||||||
@@ -2342,7 +2344,37 @@ class Part(
|
|||||||
parameter.part = self
|
parameter.part = self
|
||||||
parameter.pk = None
|
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(
|
def getTestTemplates(
|
||||||
self, required=None, include_parent: bool = True, enabled=None
|
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')
|
'part': _('Test templates can only be created for testable parts')
|
||||||
})
|
})
|
||||||
|
|
||||||
# Check that this test is unique within the part tree
|
# Check that this test is unique for this part
|
||||||
tests = PartTestTemplate.objects.filter(
|
# (including template parts of which this part is a variant)
|
||||||
key=self.key, part__tree_id=self.part.tree_id
|
parts = self.part.get_ancestors(include_self=True)
|
||||||
).exclude(pk=self.pk)
|
|
||||||
|
tests = PartTestTemplate.objects.filter(key=self.key, part__in=parts).exclude(
|
||||||
|
pk=self.pk
|
||||||
|
)
|
||||||
|
|
||||||
if tests.exists():
|
if tests.exists():
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
|
@@ -482,7 +482,14 @@ class DuplicatePartSerializer(serializers.Serializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass options."""
|
"""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(
|
part = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=Part.objects.all(),
|
queryset=Part.objects.all(),
|
||||||
@@ -519,6 +526,13 @@ class DuplicatePartSerializer(serializers.Serializer):
|
|||||||
default=True,
|
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):
|
class InitialStockSerializer(serializers.Serializer):
|
||||||
"""Serializer for creating initial stock quantity."""
|
"""Serializer for creating initial stock quantity."""
|
||||||
@@ -1073,20 +1087,23 @@ class PartSerializer(
|
|||||||
if duplicate:
|
if duplicate:
|
||||||
original = duplicate['part']
|
original = duplicate['part']
|
||||||
|
|
||||||
if duplicate['copy_bom']:
|
if duplicate.get('copy_bom', False):
|
||||||
instance.copy_bom_from(original)
|
instance.copy_bom_from(original)
|
||||||
|
|
||||||
if duplicate['copy_notes']:
|
if duplicate.get('copy_notes', False):
|
||||||
instance.notes = original.notes
|
instance.notes = original.notes
|
||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
if duplicate['copy_image']:
|
if duplicate.get('copy_image', False):
|
||||||
instance.image = original.image
|
instance.image = original.image
|
||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
if duplicate['copy_parameters']:
|
if duplicate.get('copy_parameters', False):
|
||||||
instance.copy_parameters_from(original)
|
instance.copy_parameters_from(original)
|
||||||
|
|
||||||
|
if duplicate.get('copy_tests', False):
|
||||||
|
instance.copy_tests_from(original)
|
||||||
|
|
||||||
# Duplicate parameter data from part category (and parents)
|
# Duplicate parameter data from part category (and parents)
|
||||||
if copy_category_parameters and instance.category is not None:
|
if copy_category_parameters and instance.category is not None:
|
||||||
# Get flattened list of parent categories
|
# Get flattened list of parent categories
|
||||||
|
@@ -1445,31 +1445,46 @@ class PartCreationTests(PartAPITestBase):
|
|||||||
|
|
||||||
def test_duplication(self):
|
def test_duplication(self):
|
||||||
"""Test part duplication options."""
|
"""Test part duplication options."""
|
||||||
# Run a matrix of tests
|
base_part = Part.objects.get(pk=100)
|
||||||
for bom in [True, False]:
|
base_part.testable = True
|
||||||
for img in [True, False]:
|
base_part.save()
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
for do_copy in [True, False]:
|
||||||
self.assertEqual(part.bom_items.count(), 4 if bom else 0)
|
response = self.post(
|
||||||
self.assertEqual(part.parameters.count(), 2 if params else 0)
|
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):
|
def test_category_parameters(self):
|
||||||
"""Test that category parameters are correctly applied."""
|
"""Test that category parameters are correctly applied."""
|
||||||
|
@@ -830,13 +830,18 @@ export default function PartDetail() {
|
|||||||
value: true
|
value: true
|
||||||
},
|
},
|
||||||
copy_bom: {
|
copy_bom: {
|
||||||
value: globalSettings.isSet('PART_COPY_BOM')
|
value: part.assembly && globalSettings.isSet('PART_COPY_BOM'),
|
||||||
|
hidden: !part.assembly
|
||||||
},
|
},
|
||||||
copy_notes: {
|
copy_notes: {
|
||||||
value: true
|
value: true
|
||||||
},
|
},
|
||||||
copy_parameters: {
|
copy_parameters: {
|
||||||
value: globalSettings.isSet('PART_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.getByRole('button', { name: 'Update' }).click();
|
||||||
await page.getByText('Items Updated').waitFor();
|
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