Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Test result choices #7417

Merged
merged 10 commits into from
Jun 8, 2024
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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'),
),
]
35 changes: 35 additions & 0 deletions src/backend/InvenTree/part/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3470,6 +3470,27 @@ def clean(self):
)
})

# 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()

Expand Down Expand Up @@ -3548,6 +3569,20 @@ def validate_unique(self, exclude=None):
),
)

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."""
Expand Down
1 change: 1 addition & 0 deletions src/backend/InvenTree/part/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ class Meta:
'requires_value',
'requires_attachment',
'results',
'choices',
]

key = serializers.CharField(read_only=True)
Expand Down
138 changes: 93 additions & 45 deletions src/backend/InvenTree/part/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,51 +893,6 @@ def test_include_children(self):
# 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')
Expand Down Expand Up @@ -2904,3 +2859,96 @@ def test_get_schedule(self):
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']))
31 changes: 18 additions & 13 deletions src/backend/InvenTree/stock/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2380,23 +2380,28 @@ def clean(self):
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):
Expand Down
54 changes: 54 additions & 0 deletions src/backend/InvenTree/stock/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1715,6 +1715,60 @@ def test_bulk_delete(self):

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."""
Expand Down
2 changes: 2 additions & 0 deletions src/backend/InvenTree/templates/js/translated/part.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -2827,6 +2828,7 @@ function partTestTemplateFields(options={}) {
requires_value: {},
requires_attachment: {},
enabled: {},
choices: {},
part: {
hidden: true,
}
Expand Down
Loading
Loading