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:
@ -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:
|
||||
|
23
InvenTree/part/migrations/0112_auto_20230531_1205.py
Normal file
23
InvenTree/part/migrations/0112_auto_20230531_1205.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
||||
|
@ -216,6 +216,8 @@ class PartParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerial
|
||||
'name',
|
||||
'units',
|
||||
'description',
|
||||
'checkbox',
|
||||
'choices',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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')
|
||||
|
Reference in New Issue
Block a user