Test result choices (#7417)

* Add "choices" field to PartTestTemplate

- Will allow validation of "value" field on StockItemTestResult

* Run validation against StockItemTestResult

* Expose 'choices' to serializer

* Update unit test

* Add unit test for test result validation

* Add 'choices' field for CUI forms

* Add "choices" field to PUI form

* Add 'choices' column to PartTestTemplateTable

* memoize stockitemtestresult fields

- Adjust field type of "value" field based on template choices

* Bump API version
This commit is contained in:
Oliver 2024-06-08 11:21:27 +10:00 committed by GitHub
parent bae5dcdbdc
commit a90b05add5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 298 additions and 82 deletions

View File

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

View File

@ -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'),
),
]

View File

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

View File

@ -179,6 +179,7 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer)
'requires_value',
'requires_attachment',
'results',
'choices',
]
key = serializers.CharField(read_only=True)

View File

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

View File

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

View File

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

View File

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

View File

@ -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<any[]>([]);
// 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]);
}

View File

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

View File

@ -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<number | undefined>(
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`