[feature] Unique parameters (#12388)

* Add uniqueness requirement for parameter values

* Add documentation

* Do not copy unique parameters

* Updated playwright tests

* Enhanced validation checks for uniqueness

* Update documentation

* Update CHANGELOG

* Bump API version
This commit is contained in:
Oliver
2026-07-14 14:34:17 +10:00
committed by GitHub
parent 183e6c37b1
commit 61dcc04205
14 changed files with 411 additions and 7 deletions
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 521
INVENTREE_API_VERSION = 522
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v522 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12388
- Adds "unique" field to the ParameterTemplate model
v521 -> 2026-07-12 : https://github.com/inventree/InvenTree/pull/12360
- Removes the MPTT mixin from the StockItem model, and removes the self-referential tree structure from the database.
+11 -2
View File
@@ -597,12 +597,21 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
content_type = ContentType.objects.get_for_model(self.__class__)
template_ids = [parameter.template.pk for parameter in other.parameters.all()]
# Skip any parameters which are linked to a template with a uniqueness requirement,
# as copying these values would create conflicting (duplicate) values
copyable_parameters = [
parameter
for parameter in other.parameters.all().select_related('template')
if parameter.template.unique
== common.models.ParameterTemplate.UniqueOptions.NONE
]
template_ids = [parameter.template.pk for parameter in copyable_parameters]
# Remove all conflicting parameters first
self.parameters_list.filter(template__pk__in=template_ids).delete()
for parameter in other.parameters.all():
for parameter in copyable_parameters:
parameter.pk = None
parameter.model_id = self.pk
parameter.model_type = content_type
+1 -1
View File
@@ -10,7 +10,7 @@ import common.validators
class ParameterTemplateAdmin(admin.ModelAdmin):
"""Admin interface for ParameterTemplate objects."""
list_display = ('name', 'description', 'model_type', 'units')
list_display = ('name', 'description', 'model_type', 'units', 'unique')
search_fields = ('name', 'description')
+1 -1
View File
@@ -877,7 +877,7 @@ class ParameterTemplateFilter(FilterSet):
"""Metaclass options."""
model = common.models.ParameterTemplate
fields = ['name', 'units', 'checkbox', 'enabled']
fields = ['name', 'units', 'checkbox', 'enabled', 'unique']
has_choices = rest_filters.BooleanFilter(
method='filter_has_choices', label='Has Choice'
@@ -0,0 +1,23 @@
# Generated by Django 5.2.15 on 2026-07-14 00:05
from django.db import migrations, models
from common.models import ParameterTemplate
class Migration(migrations.Migration):
dependencies = [
("common", "0046_alter_emailmessage_global_id_and_more"),
]
operations = [
migrations.AddField(
model_name="parametertemplate",
name="unique",
field=models.PositiveIntegerField(
choices=ParameterTemplate.UniqueOptions.choices,
default=0,
help_text="Enforce uniqueness of linked parameter values against this template",
verbose_name="Uniqueness",
),
),
]
+61
View File
@@ -2640,6 +2640,19 @@ class ParameterTemplate(
choice_fnc = common.validators.parameter_template_model_options
class UniqueOptions(models.IntegerChoices):
"""Enumeration of uniqueness options for a ParameterTemplate.
Attributes:
NONE: No uniqueness requirement is enforced (default)
MODEL_TYPE: Linked parameter values must be unique for a given model type
GLOBAL: Linked parameter values must be unique across all model types
"""
NONE = 0, _('No uniqueness required')
MODEL_TYPE = 1, _('Unique for model type')
GLOBAL = 2, _('Globally unique')
@staticmethod
def get_api_url() -> str:
"""Return the API URL associated with the ParameterTemplate model."""
@@ -2783,6 +2796,15 @@ class ParameterTemplate(
help_text=_('Is this parameter template enabled?'),
)
unique = models.PositiveIntegerField(
default=UniqueOptions.NONE,
choices=UniqueOptions.choices,
verbose_name=_('Uniqueness'),
help_text=_(
'Enforce uniqueness of linked parameter values against this template'
),
)
@receiver(
post_save, sender=ParameterTemplate, dispatch_uid='post_save_parameter_template'
@@ -2888,6 +2910,9 @@ class Parameter(
except ValidationError as e:
raise ValidationError({'data': e.message})
# Validate the parameter data against any uniqueness requirements imposed by the template
self.validate_uniqueness()
if InvenTree.ready.isReadOnlyCommand():
# Skip plugin validation checks during read-only management commands
return
@@ -2935,6 +2960,42 @@ class Parameter(
if math.isnan(self.data_numeric) or math.isinf(self.data_numeric):
self.data_numeric = None
def validate_uniqueness(self):
"""Ensure that this Parameter satisfies any uniqueness requirements imposed by its template.
The ParameterTemplate.unique field determines the scope of the uniqueness check:
- NONE: No uniqueness check is performed
- MODEL_TYPE: The value must be unique amongst other parameters (for this template) linked to the same model type
- GLOBAL: The value must be unique amongst all other parameters linked to this template
Note: If the template defines a set of 'units', the comparison is performed against the
normalized 'data_numeric' value, so that equivalent values expressed in different
(but compatible) units are correctly detected as duplicates (e.g. '1k' and '1000' ohms).
"""
uniqueness = self.template.unique
if uniqueness == ParameterTemplate.UniqueOptions.NONE:
return
if self.template.units and self.data_numeric is not None:
query = Parameter.objects.filter(
template=self.template, data_numeric=self.data_numeric
)
else:
query = Parameter.objects.filter(
template=self.template, data__iexact=self.data
)
if self.pk:
query = query.exclude(pk=self.pk)
if uniqueness == ParameterTemplate.UniqueOptions.MODEL_TYPE:
query = query.filter(model_type=self.model_type)
if query.exists():
raise ValidationError({'data': _('Parameter value must be unique')})
def check_permission(self, permission, user):
"""Check if the user has the required permission for this parameter."""
from InvenTree.models import InvenTreeParameterMixin
@@ -874,6 +874,7 @@ class ParameterTemplateSerializer(
'choices',
'selectionlist',
'enabled',
'unique',
]
# Note: The choices are overridden at run-time on class initialization
+175
View File
@@ -2,6 +2,7 @@
import io
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -73,6 +74,7 @@ class ParameterAPITests(InvenTreeAPITestCase):
'model_type',
'selectionlist',
'enabled',
'unique',
]:
self.assertIn(
field,
@@ -567,6 +569,179 @@ class ParameterAPITests(InvenTreeAPITestCase):
common.models.Parameter.objects.filter(pk=parameter.pk).exists()
)
def test_parameter_uniqueness(self):
"""Test the uniqueness options which can be applied to a ParameterTemplate."""
from company.models import Company
from part.models import Part
part_a = Part.objects.create(name='Part A', description='A part for testing')
part_b = Part.objects.create(name='Part B', description='A part for testing')
part_c = Part.objects.create(name='Part C', description='A part for testing')
company = Company.objects.create(
name='Test Company', description='A company for testing'
)
template = common.models.ParameterTemplate.objects.create(
name='Serial Number', description='A serial number parameter'
)
self.assertEqual(
template.unique, common.models.ParameterTemplate.UniqueOptions.NONE
)
param_a = common.models.Parameter(
template=template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='ABC123',
)
param_a.full_clean()
param_a.save()
# No uniqueness requirement - a duplicate value against a different part is fine
param_b = common.models.Parameter(
template=template,
model_type=part_b.get_content_type(),
model_id=part_b.pk,
data='ABC123',
)
param_b.full_clean()
param_b.save()
# Re-saving the existing instance (unchanged) should not raise any errors
param_a.full_clean()
param_a.save()
# Now, require uniqueness *per model type*
template.unique = common.models.ParameterTemplate.UniqueOptions.MODEL_TYPE
template.save()
# A new Part with the same value should be rejected
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_c.get_content_type(),
model_id=part_c.pk,
data='ABC123',
).full_clean()
# A case-insensitive match should also be rejected
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_c.get_content_type(),
model_id=part_c.pk,
data='abc123',
).full_clean()
# A different model type entirely is not affected by the 'model type' restriction
param_company = common.models.Parameter(
template=template,
model_type=company.get_content_type(),
model_id=company.pk,
data='ABC123',
)
param_company.full_clean()
param_company.save()
# Finally, require the value to be *globally* unique
template.unique = common.models.ParameterTemplate.UniqueOptions.GLOBAL
template.save()
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_c.get_content_type(),
model_id=part_c.pk,
data='ABC123',
).full_clean()
def test_parameter_uniqueness_units(self):
"""Test that uniqueness checks are unit-aware for templates which define units.
Values expressed in different (but compatible) units which represent the
same physical quantity must be detected as duplicates.
"""
from part.models import Part
part_a = Part.objects.create(name='Part A', description='A part for testing')
part_b = Part.objects.create(name='Part B', description='A part for testing')
template = common.models.ParameterTemplate.objects.create(
name='Resistance',
units='ohm',
description='A globally unique resistance parameter',
unique=common.models.ParameterTemplate.UniqueOptions.GLOBAL,
)
param_a = common.models.Parameter(
template=template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='1000',
)
param_a.full_clean()
param_a.save()
# A value expressed as '1k' ohms is numerically identical to '1000' ohms
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_b.get_content_type(),
model_id=part_b.pk,
data='1k',
).full_clean()
# A distinct value (in different units) is not a duplicate
param_b = common.models.Parameter(
template=template,
model_type=part_b.get_content_type(),
model_id=part_b.pk,
data='2k',
)
param_b.full_clean()
param_b.save()
def test_copy_unique_parameters(self):
"""Test that 'unique' parameters are skipped when copying parameters between model instances."""
from part.models import Part
part_a = Part.objects.create(name='Part A', description='A part for testing')
part_b = Part.objects.create(name='Part B', description='A part for testing')
normal_template = common.models.ParameterTemplate.objects.create(
name='Color', description='A normal (non-unique) parameter'
)
unique_template = common.models.ParameterTemplate.objects.create(
name='Serial Number',
description='A globally unique parameter',
unique=common.models.ParameterTemplate.UniqueOptions.GLOBAL,
)
common.models.Parameter.objects.create(
template=normal_template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='Red',
)
common.models.Parameter.objects.create(
template=unique_template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='ABC123',
)
# Copy parameters from part_a to part_b
part_b.copy_parameters_from(part_a)
# The non-unique parameter should have been copied
self.assertEqual(part_b.get_parameter('Color').data, 'Red')
# The unique parameter should *not* have been copied, to avoid a conflicting value
self.assertIsNone(part_b.get_parameter('Serial Number'))
def test_parameter_annotation(self):
"""Test that we can annotate parameters against a queryset."""
from company.models import Company
+8
View File
@@ -2348,6 +2348,14 @@ class Part(
if category_template.template.pk in template_ids:
continue
# Skip templates which enforce a uniqueness requirement - applying the same
# default value to every part in the category would create conflicting values
if (
category_template.template.unique
!= common.models.ParameterTemplate.UniqueOptions.NONE
):
continue
template_ids.add(category_template.template.pk)
parameters.append(
+45
View File
@@ -1746,6 +1746,51 @@ class PartCreationTests(PartAPITestBase):
prt = Part.objects.get(pk=data['pk'])
self.assertEqual(prt.parameters.count(), 3)
def test_category_parameters_unique(self):
"""Test that category parameters with a uniqueness requirement are not applied.
Applying the same default value to every part created in a category
would immediately conflict with a 'unique' parameter template.
"""
cat = PartCategory.objects.get(pk=1)
normal_template = ParameterTemplate.objects.get(pk=1)
unique_template = ParameterTemplate.objects.create(
name='Serial Number',
description='A globally unique parameter',
unique=ParameterTemplate.UniqueOptions.GLOBAL,
)
PartCategoryParameterTemplate.objects.create(
template=normal_template, category=cat, default_value='Normal Value'
)
PartCategoryParameterTemplate.objects.create(
template=unique_template, category=cat, default_value='Fixed Value'
)
# Create two parts in this category, copying category parameters
for name in ['Part A', 'Part B']:
data = self.post(
reverse('api-part-list'),
{
'category': cat.pk,
'name': name,
'description': 'A part for testing unique category parameters',
'copy_category_parameters': True,
},
expected_code=201,
).data
prt = Part.objects.get(pk=data['pk'])
# The 'normal' parameter should have been copied for each part
self.assertIsNotNone(prt.get_parameter(normal_template.name))
# The 'unique' parameter template should *not* have been applied
self.assertIsNone(prt.get_parameter(unique_template.name))
class PartDetailTests(PartImageTestMixin, PartAPITestBase):
"""Test that we can create / edit / delete Part objects via the API."""
+2 -1
View File
@@ -111,7 +111,8 @@ export function useParameterTemplateFields(): ApiFormFieldSet {
active: true
}
},
enabled: {}
enabled: {},
unique: {}
};
}, []);
}
+37 -1
View File
@@ -70,7 +70,43 @@ test('Company - Parameters', async ({ browser }) => {
await page.getByRole('option', { name: 'NET-30' }).click();
await page.getByRole('cell', { name: 'Arrow Electronics' }).waitFor();
await page.getByRole('cell', { name: 'PCB assembly house' }).waitFor();
await page.getByRole('cell', { name: 'SUP-012' }).waitFor();
await page.getByRole('cell', { name: 'PCBA+' }).click();
await page.getByRole('link', { name: 'details-company-45' }).click();
// Let's duplicate this company
await openDetailAction(page, 'company', 'duplicate');
await page
.getByRole('textbox', { name: 'text-field-name' })
.fill(`PCBA Duplicate ${Math.floor(Math.random() * 1000)}`);
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForLoadState('networkidle');
await loadTab(page, 'Parameters');
// Only one parameter should be visible (unique parameters not copied)
await page.getByRole('cell', { name: 'NET-30' }).waitFor();
await page.getByText('1 - 1 / 1').waitFor();
// Try to create a duplicate parameter
await page
.getByRole('button', { name: 'action-menu-add-parameters' })
.click();
await page
.getByRole('menuitem', {
name: 'action-menu-add-parameters-create-parameter'
})
.click();
await page
.getByRole('combobox', { name: 'related-field-template' })
.fill('supplier');
await page.getByRole('option', { name: 'Supplier ID' }).click();
await page.getByRole('textbox', { name: 'text-field-data' }).fill('SUP-012');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Parameter value must be unique').waitFor();
});
test('Company - Supplier Parts', async ({ browser }) => {