Merge commit '61dcc04205aa1a46f1627bcc4908d0dcb0db8b00' into line-discount

This commit is contained in:
Oliver Walters
2026-07-14 07:06:21 +00:00
16 changed files with 469 additions and 8 deletions
@@ -1,14 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 522
INVENTREE_API_VERSION = 523
"""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/12393
v523 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12393
- Adds "discount" field to order line items (and extra line items)
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
+6
View File
@@ -2047,6 +2047,12 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
if quantity > item.quantity:
quantity = item.quantity
if quantity <= 0:
# There is nothing to consume or install:
# simply remove this (empty) allocation
self.delete()
return
# Split the allocated stock if there are more available than allocated
if item.quantity > quantity:
item = item.splitStock(quantity, None, user, notes=notes)
+52
View File
@@ -711,6 +711,58 @@ class BuildTest(BuildTestBase):
self.line_1.refresh_from_db()
self.assertEqual(self.line_1.consumed, 8)
def test_complete_zero_quantity_allocation(self):
"""A zero-quantity allocation is removed cleanly on completion.
Regression test: completing a BuildItem with quantity=0 (permitted by
the model validators) crashed with an AttributeError, blocking build
completion until the empty allocation was manually removed.
"""
self.build.issue_build()
# An allocation with zero quantity, against an item with stock available
alloc = BuildItem.objects.create(
build_line=self.line_1, stock_item=self.stock_1_2, quantity=0
)
n_items = StockItem.objects.count()
alloc.complete_allocation(user=self.user)
# The empty allocation is deleted, with no stock operations performed
self.assertFalse(BuildItem.objects.filter(pk=alloc.pk).exists())
self.assertEqual(StockItem.objects.count(), n_items)
self.stock_1_2.refresh_from_db()
self.assertEqual(self.stock_1_2.quantity, 100)
self.assertIsNone(self.stock_1_2.consumed_by)
self.line_1.refresh_from_db()
self.assertEqual(self.line_1.consumed, 0)
# An allocation whose stock item has been depleted elsewhere
# is also removed cleanly (allocated quantity clamps to zero)
depleted = StockItem.objects.create(
part=self.sub_part_1, quantity=5, delete_on_deplete=False
)
alloc = BuildItem.objects.create(
build_line=self.line_1, stock_item=depleted, quantity=5
)
depleted.take_stock(5, self.user)
depleted.refresh_from_db()
self.assertEqual(depleted.quantity, 0)
alloc.complete_allocation(user=self.user)
self.assertFalse(BuildItem.objects.filter(pk=alloc.pk).exists())
depleted.refresh_from_db()
self.assertIsNone(depleted.consumed_by)
self.line_1.refresh_from_db()
self.assertEqual(self.line_1.consumed, 0)
def test_complete_with_required_tests(self):
"""Test the prevention completion when a required test is missing feature."""
# with required tests incompleted the save should fail
+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."""