2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 04:55:44 +00:00

Parameter types (#4935)

* Add fields to PartParameterTemplateModel

- checkbox: Is the field a 'checkbox'
- choices: List of valid options

* Update javascript

* Adds unit test for PartParameterTemplate

- Checkbox cannot have units
- Checkbox cannot have choices
- Choices must be unique

* Improve API filtering

- Add "has_choices" filter
- Add "has_units" filter

* Prune dead code

* Update js functions for creating / editing parameters

* Update part parameter form

- Rebuild the "data" field based on the selected template
- Supports "string" / "boolean" / "select"

* Adjust data input based on parameter type

- Choice displays available options
- Checkbox displays boolean switch
- Otherwise displays text input
- Adds more unit testing
- Updates to forms.js for improved functionality

* Calculate numeric value for boolean parameters

* Update docs

* Bump API version
This commit is contained in:
Oliver
2023-06-01 07:20:11 +10:00
committed by GitHub
parent 2c05e3e74d
commit e21a5e62b8
16 changed files with 519 additions and 112 deletions

View File

@ -1349,8 +1349,35 @@ class PartParameterTemplateFilter(rest_filters.FilterSet):
# Simple filter fields
fields = [
'units',
'checkbox',
]
has_choices = rest_filters.BooleanFilter(
method='filter_has_choices',
label='Has Choice',
)
def filter_has_choices(self, queryset, name, value):
"""Filter queryset to include only PartParameterTemplates with choices."""
if str2bool(value):
return queryset.exclude(Q(choices=None) | Q(choices=''))
else:
return queryset.filter(Q(choices=None) | Q(choices=''))
has_units = rest_filters.BooleanFilter(
method='filter_has_units',
label='Has Units',
)
def filter_has_units(self, queryset, name, value):
"""Filter queryset to include only PartParameterTemplates with units."""
if str2bool(value):
return queryset.exclude(Q(units=None) | Q(units=''))
else:
return queryset.filter(Q(units=None) | Q(units=''))
class PartParameterTemplateList(ListCreateAPI):
"""API endpoint for accessing a list of PartParameterTemplate objects.
@ -1377,6 +1404,7 @@ class PartParameterTemplateList(ListCreateAPI):
ordering_fields = [
'name',
'units',
'checkbox',
]
def filter_queryset(self, queryset):
@ -1580,45 +1608,33 @@ class BomFilter(rest_filters.FilterSet):
def filter_available_stock(self, queryset, name, value):
"""Filter the queryset based on whether each line item has any available stock"""
value = str2bool(value)
if value:
queryset = queryset.filter(available_stock__gt=0)
if str2bool(value):
return queryset.filter(available_stock__gt=0)
else:
queryset = queryset.filter(available_stock=0)
return queryset
return queryset.filter(available_stock=0)
on_order = rest_filters.BooleanFilter(label="On order", method="filter_on_order")
def filter_on_order(self, queryset, name, value):
"""Filter the queryset based on whether each line item has any stock on order"""
value = str2bool(value)
if value:
queryset = queryset.filter(on_order__gt=0)
if str2bool(value):
return queryset.filter(on_order__gt=0)
else:
queryset = queryset.filter(on_order=0)
return queryset
return queryset.filter(on_order=0)
has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing")
def filter_has_pricing(self, queryset, name, value):
"""Filter the queryset based on whether pricing information is available for the sub_part"""
value = str2bool(value)
q_a = Q(sub_part__pricing_data=None)
q_b = Q(sub_part__pricing_data__overall_min=None, sub_part__pricing_data__overall_max=None)
if value:
queryset = queryset.exclude(q_a | q_b)
if str2bool(value):
return queryset.exclude(q_a | q_b)
else:
queryset = queryset.filter(q_a | q_b)
return queryset
return queryset.filter(q_a | q_b)
class BomMixin:

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.19 on 2023-05-31 12:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0111_auto_20230521_1350'),
]
operations = [
migrations.AddField(
model_name='partparametertemplate',
name='checkbox',
field=models.BooleanField(default=False, help_text='Is this parameter a checkbox?', verbose_name='Checkbox'),
),
migrations.AddField(
model_name='partparametertemplate',
name='choices',
field=models.CharField(blank=True, help_text='Valid choices for this parameter (comma-separated)', max_length=5000, verbose_name='Choices'),
),
]

View File

@ -46,7 +46,8 @@ from common.settings import currency_code_default
from company.models import SupplierPart
from InvenTree import helpers, validators
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2money, decimal2string, normalize
from InvenTree.helpers import (decimal2money, decimal2string, normalize,
str2bool)
from InvenTree.models import (DataImportMixin, InvenTreeAttachment,
InvenTreeBarcodeMixin, InvenTreeNotesMixin,
InvenTreeTree, MetadataMixin)
@ -3307,6 +3308,8 @@ class PartParameterTemplate(MetadataMixin, models.Model):
name: The name (key) of the Parameter [string]
units: The units of the Parameter [string]
description: Description of the parameter [string]
checkbox: Boolean flag to indicate whether the parameter is a checkbox [bool]
choices: List of valid choices for the parameter [string]
"""
@staticmethod
@ -3321,6 +3324,47 @@ class PartParameterTemplate(MetadataMixin, models.Model):
s += " ({units})".format(units=self.units)
return s
def clean(self):
"""Custom cleaning step for this model:
- A 'checkbox' field cannot have 'choices' set
- A 'checkbox' field cannot have 'units' set
"""
super().clean()
# Check that checkbox parameters do not have units or choices
if self.checkbox:
if self.units:
raise ValidationError({
'units': _('Checkbox parameters cannot have units')
})
if self.choices:
raise ValidationError({
'choices': _('Checkbox parameters cannot have choices')
})
# Check that 'choices' are in fact valid
self.choices = 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)
def validate_unique(self, exclude=None):
"""Ensure that PartParameterTemplates cannot be created with the same name.
@ -3337,6 +3381,14 @@ class PartParameterTemplate(MetadataMixin, models.Model):
except PartParameterTemplate.DoesNotExist:
pass
def get_choices(self):
"""Return a list of choices for this parameter template"""
if not self.choices:
return []
return [x.strip() for x in self.choices.split(',') if x.strip()]
name = models.CharField(
max_length=100,
verbose_name=_('Name'),
@ -3360,6 +3412,19 @@ class PartParameterTemplate(MetadataMixin, models.Model):
blank=True,
)
checkbox = models.BooleanField(
default=False,
verbose_name=_('Checkbox'),
help_text=_('Is this parameter a checkbox?')
)
choices = models.CharField(
max_length=5000,
verbose_name=_('Choices'),
help_text=_('Valid choices for this parameter (comma-separated)'),
blank=True,
)
@receiver(post_save, sender=PartParameterTemplate, dispatch_uid='post_save_part_parameter_template')
def post_save_part_parameter_template(sender, instance, created, **kwargs):
@ -3412,6 +3477,11 @@ class PartParameter(models.Model):
# Validate the PartParameter before saving
self.calculate_numeric_value()
# Convert 'boolean' values to 'True' / 'False'
if self.template.checkbox:
self.data = str2bool(self.data)
self.data_numeric = 1 if self.data else 0
super().save(*args, **kwargs)
def clean(self):
@ -3428,6 +3498,13 @@ class PartParameter(models.Model):
'data': e.message
})
# Validate the parameter data against the template choices
if choices := self.template.get_choices():
if self.data not in choices:
raise ValidationError({
'data': _('Invalid choice for parameter value')
})
def calculate_numeric_value(self):
"""Calculate a numeric value for the parameter data.

View File

@ -216,6 +216,8 @@ class PartParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerial
'name',
'units',
'description',
'checkbox',
'choices',
]

View File

@ -846,47 +846,13 @@
}
);
$('#param-table').inventreeTable({
});
{% if roles.part.add %}
$('#param-create').click(function() {
constructForm('{% url "api-part-parameter-list" %}', {
method: 'POST',
fields: {
part: {
value: {{ part.pk }},
hidden: true,
},
template: {
filters: {
ordering: 'name',
},
},
data: {},
},
title: '{% trans "Add Parameter" %}',
refreshTable: '#parameter-table',
createPartParameter({{ part.pk }}, {
refreshTable: '#parameter-table'
});
});
{% endif %}
$('.param-edit').click(function() {
var button = $(this);
launchModalForm(button.attr('url'), {
reload: true,
});
});
$('.param-delete').click(function() {
var button = $(this);
launchModalForm(button.attr('url'), {
reload: true,
});
});
});
onPanelLoad("part-attachments", function() {

View File

@ -102,6 +102,29 @@ class ParameterTests(TestCase):
'params'
]
def test_choice_validation(self):
"""Test that parameter choices are correctly validated"""
template = PartParameterTemplate.objects.create(
name='My Template',
description='A template with choices',
choices='red, blue, green'
)
pass_values = ['red', 'blue', 'green']
fail_values = ['rod', 'bleu', 'grene']
part = Part.objects.all().first()
for value in pass_values:
param = PartParameter(part=part, template=template, data=value)
param.full_clean()
for value in fail_values:
param = PartParameter(part=part, template=template, data=value)
with self.assertRaises(django_exceptions.ValidationError):
param.full_clean()
def test_unit_validation(self):
"""Test validation of 'units' field for PartParameterTemplate"""
@ -116,7 +139,7 @@ class ParameterTests(TestCase):
with self.assertRaises(django_exceptions.ValidationError):
tmp.full_clean()
def test_param_validation(self):
def test_param_unit_validation(self):
"""Test that parameters are correctly validated against template units"""
template = PartParameterTemplate.objects.create(
@ -137,7 +160,7 @@ class ParameterTests(TestCase):
with self.assertRaises(django_exceptions.ValidationError):
param.full_clean()
def test_param_conversion(self):
def test_param_unit_conversion(self):
"""Test that parameters are correctly converted to template units"""
template = PartParameterTemplate.objects.create(
@ -202,6 +225,41 @@ class PartParameterTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 4)
def test_param_template_validation(self):
"""Test that part parameter template validation routines work correctly."""
# Checkbox parameter cannot have "units" specified
with self.assertRaises(django_exceptions.ValidationError):
template = PartParameterTemplate(
name='test',
description='My description',
units='mm',
checkbox=True
)
template.clean()
# Checkbox parameter cannot have "choices" specified
with self.assertRaises(django_exceptions.ValidationError):
template = PartParameterTemplate(
name='test',
description='My description',
choices='a,b,c',
checkbox=True
)
template.clean()
# Choices must be 'unique'
with self.assertRaises(django_exceptions.ValidationError):
template = PartParameterTemplate(
name='test',
description='My description',
choices='a,a,b',
)
template.clean()
def test_create_param(self):
"""Test that we can create a param via the API."""
url = reverse('api-part-parameter-list')