mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-17 20:23:50 +00:00
[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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -111,7 +111,8 @@ export function useParameterTemplateFields(): ApiFormFieldSet {
|
||||
active: true
|
||||
}
|
||||
},
|
||||
enabled: {}
|
||||
enabled: {},
|
||||
unique: {}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user