mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
bae5dcdbdc
commit
a90b05add5
@ -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
|
||||
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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."""
|
||||
|
@ -179,6 +179,7 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer)
|
||||
'requires_value',
|
||||
'requires_attachment',
|
||||
'results',
|
||||
'choices',
|
||||
]
|
||||
|
||||
key = serializers.CharField(read_only=True)
|
||||
|
@ -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']))
|
||||
|
@ -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):
|
||||
|
@ -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."""
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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]);
|
||||
|
@ -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`
|
||||
|
Loading…
Reference in New Issue
Block a user