diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index be2e321f6e..cd371ed20a 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 205 +INVENTREE_API_VERSION = 206 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v206 - 2024-06-08 : https://github.com/inventree/InvenTree/pull/7417 + - Adds "choices" field to the PartTestTemplate model + v205 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7284 - Added model_type and model_id fields to the "NotesImage" serializer diff --git a/src/backend/InvenTree/part/migrations/0123_parttesttemplate_choices.py b/src/backend/InvenTree/part/migrations/0123_parttesttemplate_choices.py new file mode 100644 index 0000000000..df26051812 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0123_parttesttemplate_choices.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.12 on 2024-06-05 01:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0122_parttesttemplate_enabled'), + ] + + operations = [ + migrations.AddField( + model_name='parttesttemplate', + name='choices', + field=models.CharField(blank=True, help_text='Valid choices for this test (comma-separated)', max_length=5000, verbose_name='Choices'), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index cd0282b908..5626d8d060 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3470,6 +3470,27 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel): ) }) + # Check that 'choices' are in fact valid + if self.choices is None: + self.choices = '' + else: + self.choices = str(self.choices).strip() + + if self.choices: + choice_set = set() + + for choice in self.choices.split(','): + choice = choice.strip() + + # Ignore empty choices + if not choice: + continue + + if choice in choice_set: + raise ValidationError({'choices': _('Choices must be unique')}) + + choice_set.add(choice) + self.validate_unique() super().clean() @@ -3548,6 +3569,20 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel): ), ) + choices = models.CharField( + max_length=5000, + verbose_name=_('Choices'), + help_text=_('Valid choices for this test (comma-separated)'), + blank=True, + ) + + def get_choices(self): + """Return a list of valid choices for this test template.""" + if not self.choices: + return [] + + return [x.strip() for x in self.choices.split(',') if x.strip()] + def validate_template_name(name): """Placeholder for legacy function used in migrations.""" diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 9575c2c329..3cf35becad 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -179,6 +179,7 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer) 'requires_value', 'requires_attachment', 'results', + 'choices', ] key = serializers.CharField(read_only=True) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 7d998e0033..da3f4df47c 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -893,51 +893,6 @@ class PartAPITest(PartAPITestBase): # Now there should be 5 total parts self.assertEqual(len(response.data), 3) - def test_test_templates(self): - """Test the PartTestTemplate API.""" - url = reverse('api-part-test-template-list') - - # List ALL items - response = self.get(url) - 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), 6) - - # Try to post a new object (missing description) - response = self.post( - url, - data={'part': 10000, 'test_name': 'My very first test', 'required': False}, - expected_code=400, - ) - - # Try to post a new object (should succeed) - response = self.post( - url, - data={ - 'part': 10000, - 'test_name': 'New Test', - 'required': True, - 'description': 'a test description', - }, - ) - - # Try to post a new test with the same name (should fail) - response = self.post( - url, - data={'part': 10004, 'test_name': ' newtest', 'description': 'dafsdf'}, - expected_code=400, - ) - - # Try to post a new test against a non-trackable part (should fail) - response = self.post( - url, data={'part': 1, 'test_name': 'A simple test'}, expected_code=400 - ) - def test_get_thumbs(self): """Return list of part thumbnails.""" url = reverse('api-part-thumbs') @@ -2904,3 +2859,96 @@ class PartSchedulingTest(PartAPITestBase): for entry in data: for k in ['date', 'quantity', 'label']: self.assertIn(k, entry) + + +class PartTestTemplateTest(PartAPITestBase): + """API unit tests for the PartTestTemplate model.""" + + def test_test_templates(self): + """Test the PartTestTemplate API.""" + url = reverse('api-part-test-template-list') + + # List ALL items + response = self.get(url) + 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), 6) + + # Try to post a new object (missing description) + response = self.post( + url, + data={'part': 10000, 'test_name': 'My very first test', 'required': False}, + expected_code=400, + ) + + # Try to post a new object (should succeed) + response = self.post( + url, + data={ + 'part': 10000, + 'test_name': 'New Test', + 'required': True, + 'description': 'a test description', + }, + ) + + # Try to post a new test with the same name (should fail) + response = self.post( + url, + data={'part': 10004, 'test_name': ' newtest', 'description': 'dafsdf'}, + expected_code=400, + ) + + # Try to post a new test against a non-trackable part (should fail) + response = self.post( + url, data={'part': 1, 'test_name': 'A simple test'}, expected_code=400 + ) + + def test_choices(self): + """Test the 'choices' field for the PartTestTemplate model.""" + template = PartTestTemplate.objects.first() + + url = reverse('api-part-test-template-detail', kwargs={'pk': template.pk}) + + # Check OPTIONS response + response = self.options(url) + options = response.data['actions']['PUT'] + + self.assertTrue(options['pk']['read_only']) + self.assertTrue(options['pk']['required']) + self.assertEqual(options['part']['api_url'], '/api/part/') + self.assertTrue(options['test_name']['required']) + self.assertFalse(options['test_name']['read_only']) + self.assertFalse(options['choices']['required']) + self.assertFalse(options['choices']['read_only']) + self.assertEqual( + options['choices']['help_text'], + 'Valid choices for this test (comma-separated)', + ) + + # Check data endpoint + response = self.get(url) + data = response.data + + for key in [ + 'pk', + 'key', + 'part', + 'test_name', + 'description', + 'enabled', + 'required', + 'results', + 'choices', + ]: + self.assertIn(key, data) + + # Patch with invalid choices + response = self.patch(url, {'choices': 'a,b,c,d,e,f,f'}, expected_code=400) + + self.assertIn('Choices must be unique', str(response.data['choices'])) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 1def243c1a..aa1f797f5b 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2380,23 +2380,28 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel): super().clean() # If this test result corresponds to a template, check the requirements of the template - key = self.key + template = self.template - templates = self.stock_item.part.getTestTemplates() + if template is None: + # Fallback if there is no matching template + for template in self.stock_item.part.getTestTemplates(): + if self.key == template.key: + break - for template in templates: - if key == template.key: - if template.requires_value and not self.value: - raise ValidationError({ - 'value': _('Value must be provided for this test') - }) + if template: + if template.requires_value and not self.value: + raise ValidationError({ + 'value': _('Value must be provided for this test') + }) - if template.requires_attachment and not self.attachment: - raise ValidationError({ - 'attachment': _('Attachment must be uploaded for this test') - }) + if template.requires_attachment and not self.attachment: + raise ValidationError({ + 'attachment': _('Attachment must be uploaded for this test') + }) - break + if choices := template.get_choices(): + if self.value not in choices: + raise ValidationError({'value': _('Invalid value for this test')}) @property def key(self): diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 07b08334ea..91dbccfbb0 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -1715,6 +1715,60 @@ class StockTestResultTest(StockAPITestCase): self.assertEqual(StockItemTestResult.objects.count(), n) + def test_value_choices(self): + """Test that the 'value' field is correctly validated.""" + url = reverse('api-stock-test-result-list') + + test_template = PartTestTemplate.objects.first() + + test_template.choices = 'AA, BB, CC' + test_template.save() + + stock_item = StockItem.objects.create( + part=test_template.part, quantity=1, location=StockLocation.objects.first() + ) + + # Create result with invalid choice + response = self.post( + url, + { + 'template': test_template.pk, + 'stock_item': stock_item.pk, + 'result': True, + 'value': 'DD', + }, + expected_code=400, + ) + + self.assertIn('Invalid value for this test', str(response.data['value'])) + + # Create result with valid choice + response = self.post( + url, + { + 'template': test_template.pk, + 'stock_item': stock_item.pk, + 'result': True, + 'value': 'BB', + }, + expected_code=201, + ) + + # Create result with unrestricted choice + test_template.choices = '' + test_template.save() + + response = self.post( + url, + { + 'template': test_template.pk, + 'stock_item': stock_item.pk, + 'result': False, + 'value': '12345', + }, + expected_code=201, + ) + class StockAssignTest(StockAPITestCase): """Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer.""" diff --git a/src/backend/InvenTree/templates/js/translated/part.js b/src/backend/InvenTree/templates/js/translated/part.js index 48b3dcfdbb..f28281cfd7 100644 --- a/src/backend/InvenTree/templates/js/translated/part.js +++ b/src/backend/InvenTree/templates/js/translated/part.js @@ -305,6 +305,7 @@ function partFields(options={}) { function categoryFields(options={}) { let fields = { parent: { + label: '{% trans "Parent" %}', help_text: '{% trans "Parent part category" %}', required: false, tree_picker: { @@ -2827,6 +2828,7 @@ function partTestTemplateFields(options={}) { requires_value: {}, requires_attachment: {}, enabled: {}, + choices: {}, part: { hidden: true, } diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index c6a944992d..123a2834f0 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -866,3 +866,63 @@ export function stockLocationFields({}: {}): ApiFormFieldSet { return fields; } + +// Construct a set of fields for +export function useTestResultFields({ + partId, + itemId +}: { + partId: number; + itemId: number; +}): ApiFormFieldSet { + // Valid field choices + const [choices, setChoices] = useState([]); + + // Field type for the "value" input + const [fieldType, setFieldType] = useState<'string' | 'choice'>('string'); + + return useMemo(() => { + return { + stock_item: { + value: itemId, + hidden: true + }, + template: { + filters: { + include_inherited: true, + part: partId + }, + onValueChange: (value: any, record: any) => { + // Adjust the type of the "value" field based on the selected template + if (record?.choices) { + let _choices: string[] = record.choices.split(','); + + if (_choices.length > 0) { + setChoices( + _choices.map((choice) => { + return { + label: choice.trim(), + value: choice.trim() + }; + }) + ); + setFieldType('choice'); + } else { + setChoices([]); + setFieldType('string'); + } + } + } + }, + result: {}, + value: { + field_type: fieldType, + choices: fieldType === 'choice' ? choices : undefined + }, + attachment: {}, + notes: {}, + started_datetime: {}, + finished_datetime: {} + }; + }, [choices, fieldType, partId, itemId]); +} diff --git a/src/frontend/src/tables/part/PartTestTemplateTable.tsx b/src/frontend/src/tables/part/PartTestTemplateTable.tsx index 4b453f5b9e..6dd0af662d 100644 --- a/src/frontend/src/tables/part/PartTestTemplateTable.tsx +++ b/src/frontend/src/tables/part/PartTestTemplateTable.tsx @@ -60,6 +60,11 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) { BooleanColumn({ accessor: 'enabled' }), + { + accessor: 'choices', + sortable: false, + switchable: true + }, BooleanColumn({ accessor: 'required' }), @@ -117,6 +122,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) { required: {}, requires_value: {}, requires_attachment: {}, + choices: {}, enabled: {} }; }, [user]); diff --git a/src/frontend/src/tables/stock/StockItemTestResultTable.tsx b/src/frontend/src/tables/stock/StockItemTestResultTable.tsx index dfce96cb7d..68ea56fbc6 100644 --- a/src/frontend/src/tables/stock/StockItemTestResultTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTestResultTable.tsx @@ -19,6 +19,7 @@ import { RenderUser } from '../../components/render/User'; import { renderDate } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { UserRoles } from '../../enums/Roles'; +import { useTestResultFields } from '../../forms/StockForms'; import { useCreateApiFormModal, useDeleteApiFormModal, @@ -232,27 +233,10 @@ export default function StockItemTestResultTable({ ]; }, [itemId]); - const resultFields: ApiFormFieldSet = useMemo(() => { - return { - template: { - filters: { - include_inherited: true, - part: partId - } - }, - result: {}, - value: {}, - attachment: {}, - notes: {}, - test_station: {}, - started_datetime: {}, - finished_datetime: {}, - stock_item: { - value: itemId, - hidden: true - } - }; - }, [partId, itemId]); + const resultFields: ApiFormFieldSet = useTestResultFields({ + partId: partId, + itemId: itemId + }); const [selectedTemplate, setSelectedTemplate] = useState( undefined @@ -260,7 +244,7 @@ export default function StockItemTestResultTable({ const newTestModal = useCreateApiFormModal({ url: ApiEndpoints.stock_test_result_list, - fields: resultFields, + fields: useMemo(() => ({ ...resultFields }), [resultFields]), initialData: { template: selectedTemplate, result: true @@ -275,7 +259,7 @@ export default function StockItemTestResultTable({ const editTestModal = useEditApiFormModal({ url: ApiEndpoints.stock_test_result_list, pk: selectedTest, - fields: resultFields, + fields: useMemo(() => ({ ...resultFields }), [resultFields]), title: t`Edit Test Result`, table: table, successMessage: t`Test result updated`